dsh-mac-pet 0.0.0-stage → 0.9.2
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/LICENSE +21 -0
- package/README.md +287 -2
- package/cordis.patch.yml +13 -0
- package/lib/client.js +154 -0
- package/lib/index.js +1470 -0
- package/lib/types/CommandBridge.d.ts +135 -0
- package/lib/types/HarnessInstance.d.ts +37 -0
- package/lib/types/InstallDetection.d.ts +2 -0
- package/lib/types/LocalStateBridge.d.ts +39 -0
- package/lib/types/index.d.ts +28 -0
- package/package.json +76 -4
package/lib/index.js
ADDED
|
@@ -0,0 +1,1470 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { basename, dirname, join } from "node:path";
|
|
3
|
+
import { connect, createServer } from "node:net";
|
|
4
|
+
import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { homedir } from "node:os";
|
|
6
|
+
import { execFile } from "node:child_process";
|
|
7
|
+
import { promisify } from "node:util";
|
|
8
|
+
//#region src/CommandBridge.ts
|
|
9
|
+
const COMMAND_METADATA_BYTES = 1048576;
|
|
10
|
+
var CommandError = class extends Error {
|
|
11
|
+
code;
|
|
12
|
+
constructor(code, message) {
|
|
13
|
+
super(message);
|
|
14
|
+
this.code = code;
|
|
15
|
+
this.name = "CommandError";
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
/**
|
|
19
|
+
* User-local command and session-status transport.
|
|
20
|
+
*
|
|
21
|
+
* This socket is intentionally separate from LocalStateBridge. Older DSH Pet
|
|
22
|
+
* clients continue to receive the global read-only snapshot, while new
|
|
23
|
+
* clients can issue authenticated-by-file-permission commands and subscribe
|
|
24
|
+
* to one selected session without mixing command responses into that stream.
|
|
25
|
+
*/
|
|
26
|
+
var CommandBridge = class CommandBridge {
|
|
27
|
+
static defaultPath = join(homedir(), "Library", "Application Support", "DSH Pet", "dsh-pet-command.sock");
|
|
28
|
+
socketPath;
|
|
29
|
+
handlers;
|
|
30
|
+
instance;
|
|
31
|
+
maxCommandBytes;
|
|
32
|
+
server;
|
|
33
|
+
ownsSocket = false;
|
|
34
|
+
clients = /* @__PURE__ */ new Set();
|
|
35
|
+
probeGeneration = 0;
|
|
36
|
+
probing = false;
|
|
37
|
+
socketIdentity;
|
|
38
|
+
constructor(handlers, socketPath = process.env.DSH_PET_COMMAND_SOCKET_PATH ?? process.env.MOODBALL_COMMAND_SOCKET_PATH ?? CommandBridge.defaultPath, instance) {
|
|
39
|
+
this.handlers = handlers;
|
|
40
|
+
this.instance = instance;
|
|
41
|
+
this.socketPath = socketPath;
|
|
42
|
+
this.maxCommandBytes = maxCommandBytes(handlers.imageAttachmentLimits);
|
|
43
|
+
}
|
|
44
|
+
start(onReady) {
|
|
45
|
+
if (this.server || this.probing) return;
|
|
46
|
+
mkdirSync(dirname(this.socketPath), { recursive: true });
|
|
47
|
+
if (existsSync(this.socketPath)) {
|
|
48
|
+
if (!lstatSync(this.socketPath).isSocket()) {
|
|
49
|
+
console.warn(`[dsh-pet] command bridge path is not a socket: ${this.socketPath}`);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
const staleIdentity = socketIdentity$1(this.socketPath);
|
|
53
|
+
const generation = ++this.probeGeneration;
|
|
54
|
+
this.probing = true;
|
|
55
|
+
const probe = connect(this.socketPath);
|
|
56
|
+
probe.once("connect", () => {
|
|
57
|
+
probe.destroy();
|
|
58
|
+
this.probing = false;
|
|
59
|
+
console.warn(`[dsh-pet] command bridge path is already active: ${this.socketPath}`);
|
|
60
|
+
});
|
|
61
|
+
probe.once("error", (error) => {
|
|
62
|
+
probe.destroy();
|
|
63
|
+
this.probing = false;
|
|
64
|
+
if (generation !== this.probeGeneration) return;
|
|
65
|
+
const code = "code" in error ? error.code : void 0;
|
|
66
|
+
if (code !== "ECONNREFUSED" && code !== "ENOENT") {
|
|
67
|
+
console.warn(`[dsh-pet] command bridge unavailable: ${error.message}`);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
if (!sameSocketIdentity$1(staleIdentity, socketIdentity$1(this.socketPath))) return;
|
|
71
|
+
try {
|
|
72
|
+
unlinkSync(this.socketPath);
|
|
73
|
+
} catch {}
|
|
74
|
+
this.listen(onReady);
|
|
75
|
+
});
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
this.listen(onReady);
|
|
79
|
+
}
|
|
80
|
+
listen(onReady) {
|
|
81
|
+
const generation = this.probeGeneration;
|
|
82
|
+
const server = createServer((socket) => {
|
|
83
|
+
const client = {
|
|
84
|
+
socket,
|
|
85
|
+
buffer: "",
|
|
86
|
+
bufferBytes: 0,
|
|
87
|
+
discardingOversizedLine: false,
|
|
88
|
+
subscriptions: /* @__PURE__ */ new Set(),
|
|
89
|
+
taskSubscription: false,
|
|
90
|
+
operationTail: Promise.resolve()
|
|
91
|
+
};
|
|
92
|
+
this.clients.add(client);
|
|
93
|
+
socket.setNoDelay(true);
|
|
94
|
+
socket.setEncoding("utf8");
|
|
95
|
+
socket.on("data", (chunk) => {
|
|
96
|
+
let text = String(chunk);
|
|
97
|
+
if (client.discardingOversizedLine) {
|
|
98
|
+
const newline = text.indexOf("\n");
|
|
99
|
+
if (newline < 0) return;
|
|
100
|
+
client.discardingOversizedLine = false;
|
|
101
|
+
text = text.slice(newline + 1);
|
|
102
|
+
}
|
|
103
|
+
client.buffer += text;
|
|
104
|
+
client.bufferBytes += Buffer.byteLength(text);
|
|
105
|
+
this.consume(client);
|
|
106
|
+
});
|
|
107
|
+
socket.on("close", () => this.clients.delete(client));
|
|
108
|
+
socket.on("error", () => this.clients.delete(client));
|
|
109
|
+
});
|
|
110
|
+
server.on("error", (error) => {
|
|
111
|
+
console.warn(`[dsh-pet] command bridge unavailable: ${error.message}`);
|
|
112
|
+
});
|
|
113
|
+
server.listen(this.socketPath, () => {
|
|
114
|
+
const identity = socketIdentity$1(this.socketPath);
|
|
115
|
+
if (generation !== this.probeGeneration) {
|
|
116
|
+
server.close();
|
|
117
|
+
unlinkIfSame$1(this.socketPath, identity);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
this.ownsSocket = true;
|
|
121
|
+
this.socketIdentity = identity;
|
|
122
|
+
try {
|
|
123
|
+
chmodSync(this.socketPath, 384);
|
|
124
|
+
} catch {}
|
|
125
|
+
onReady?.();
|
|
126
|
+
});
|
|
127
|
+
this.server = server;
|
|
128
|
+
}
|
|
129
|
+
/** Push a replacement snapshot to clients subscribed to this session. */
|
|
130
|
+
publish(sessionId, snapshot) {
|
|
131
|
+
for (const client of this.clients) {
|
|
132
|
+
if (!client.subscriptions.has(sessionId)) continue;
|
|
133
|
+
this.write(client.socket, {
|
|
134
|
+
event: "status",
|
|
135
|
+
sessionId,
|
|
136
|
+
snapshot
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
/** Push a replacement task projection to clients that requested task updates. */
|
|
141
|
+
publishTasks(tasks) {
|
|
142
|
+
for (const client of this.clients) {
|
|
143
|
+
if (!client.taskSubscription) continue;
|
|
144
|
+
this.write(client.socket, {
|
|
145
|
+
event: "tasks",
|
|
146
|
+
tasks
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
/** Notify task subscribers that a session's reply projection changed. */
|
|
151
|
+
publishTaskPreviewChange(sessionId) {
|
|
152
|
+
for (const client of this.clients) if (client.taskSubscription) this.write(client.socket, {
|
|
153
|
+
event: "taskPreviewChanged",
|
|
154
|
+
sessionId
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
stop() {
|
|
158
|
+
this.probeGeneration += 1;
|
|
159
|
+
this.probing = false;
|
|
160
|
+
for (const client of this.clients) client.socket.destroy();
|
|
161
|
+
this.clients.clear();
|
|
162
|
+
const server = this.server;
|
|
163
|
+
this.server = void 0;
|
|
164
|
+
if (!server) return;
|
|
165
|
+
const replacement = preserveReplacement$1(this.socketPath, this.socketIdentity);
|
|
166
|
+
server.close();
|
|
167
|
+
if (replacement !== void 0) try {
|
|
168
|
+
renameSync(replacement, this.socketPath);
|
|
169
|
+
} catch {}
|
|
170
|
+
else if (this.ownsSocket) unlinkIfSame$1(this.socketPath, this.socketIdentity);
|
|
171
|
+
this.ownsSocket = false;
|
|
172
|
+
this.socketIdentity = void 0;
|
|
173
|
+
}
|
|
174
|
+
consume(client) {
|
|
175
|
+
while (true) {
|
|
176
|
+
const newline = client.buffer.indexOf("\n");
|
|
177
|
+
if (newline < 0) {
|
|
178
|
+
if (client.bufferBytes > this.maxCommandBytes) {
|
|
179
|
+
client.buffer = "";
|
|
180
|
+
client.bufferBytes = 0;
|
|
181
|
+
client.discardingOversizedLine = true;
|
|
182
|
+
this.rejectOversizedRequest(client.socket);
|
|
183
|
+
}
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
const line = client.buffer.slice(0, newline);
|
|
187
|
+
client.buffer = client.buffer.slice(newline + 1);
|
|
188
|
+
client.bufferBytes -= Buffer.byteLength(`${line}\n`);
|
|
189
|
+
if (line.trim() === "") continue;
|
|
190
|
+
if (Buffer.byteLength(line) > this.maxCommandBytes) {
|
|
191
|
+
this.rejectOversizedRequest(client.socket);
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
client.operationTail = client.operationTail.then(() => this.handle(client, line)).catch((error) => {
|
|
195
|
+
this.write(client.socket, {
|
|
196
|
+
id: null,
|
|
197
|
+
ok: false,
|
|
198
|
+
error: {
|
|
199
|
+
code: "internal-error",
|
|
200
|
+
message: errorMessage(error)
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
rejectOversizedRequest(socket) {
|
|
207
|
+
this.write(socket, {
|
|
208
|
+
id: null,
|
|
209
|
+
ok: false,
|
|
210
|
+
error: {
|
|
211
|
+
code: "request-too-large",
|
|
212
|
+
message: "command request exceeds the deployment image limits"
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
async handle(client, line) {
|
|
217
|
+
let request;
|
|
218
|
+
try {
|
|
219
|
+
request = JSON.parse(line);
|
|
220
|
+
} catch {
|
|
221
|
+
this.write(client.socket, {
|
|
222
|
+
id: null,
|
|
223
|
+
ok: false,
|
|
224
|
+
error: {
|
|
225
|
+
code: "invalid-json",
|
|
226
|
+
message: "request must be one JSON object per line"
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
const id = typeof request.id === "string" && request.id !== "" ? request.id : null;
|
|
232
|
+
const action = request.action;
|
|
233
|
+
try {
|
|
234
|
+
if (action === "capabilities") {
|
|
235
|
+
this.respond(client.socket, id, {
|
|
236
|
+
protocolVersion: 1,
|
|
237
|
+
commandSocket: true,
|
|
238
|
+
statusSubscription: true,
|
|
239
|
+
supports: [
|
|
240
|
+
"workspaces",
|
|
241
|
+
"tasks",
|
|
242
|
+
"subscribeTasks",
|
|
243
|
+
"unsubscribeTasks",
|
|
244
|
+
"createSession",
|
|
245
|
+
"openSession",
|
|
246
|
+
"prompt",
|
|
247
|
+
"imageAttachments",
|
|
248
|
+
"subscribe",
|
|
249
|
+
"unsubscribe",
|
|
250
|
+
...this.handlers.capabilities?.() ?? []
|
|
251
|
+
],
|
|
252
|
+
attachmentLimits: this.handlers.imageAttachmentLimits,
|
|
253
|
+
maxCommandBytes: this.maxCommandBytes,
|
|
254
|
+
...this.instance === void 0 ? {} : { instance: this.instance }
|
|
255
|
+
});
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
if (action === "workspaces") {
|
|
259
|
+
this.respond(client.socket, id, { workspaces: await this.handlers.listWorkspaces() });
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
if (action === "tasks") {
|
|
263
|
+
this.respond(client.socket, id, { tasks: await this.handlers.listTasks() });
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
if (action === "subscribeTasks") {
|
|
267
|
+
client.taskSubscription = true;
|
|
268
|
+
this.respond(client.socket, id, { tasks: await this.handlers.listTasks() });
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
if (action === "unsubscribeTasks") {
|
|
272
|
+
client.taskSubscription = false;
|
|
273
|
+
this.respond(client.socket, id, {});
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
if (action === "createSession") {
|
|
277
|
+
const workspaceId = stringField(request.workspaceId, "workspaceId");
|
|
278
|
+
const sessionId = stringField(request.sessionId, "sessionId");
|
|
279
|
+
const result = await this.handlers.createSession({
|
|
280
|
+
workspaceId,
|
|
281
|
+
sessionId
|
|
282
|
+
});
|
|
283
|
+
this.respond(client.socket, id, result);
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
if (action === "openSession") {
|
|
287
|
+
const workspaceId = stringField(request.workspaceId, "workspaceId");
|
|
288
|
+
const sessionId = stringField(request.sessionId, "sessionId");
|
|
289
|
+
const result = await this.handlers.openSession({
|
|
290
|
+
workspaceId,
|
|
291
|
+
sessionId
|
|
292
|
+
});
|
|
293
|
+
this.respond(client.socket, id, result);
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
if (action === "fileCandidates" || action === "skillCandidates" || action === "taskPreview") {
|
|
297
|
+
const workspaceId = stringField(request.workspaceId, "workspaceId");
|
|
298
|
+
const sessionId = stringField(request.sessionId, "sessionId");
|
|
299
|
+
if (!this.handlers.capabilities?.().includes(action)) throw new CommandError("unsupported", `${action} is unavailable`);
|
|
300
|
+
if (action === "fileCandidates" && this.handlers.fileCandidates !== void 0) {
|
|
301
|
+
const query = optionalStringField(request.query, "query") ?? "";
|
|
302
|
+
this.respond(client.socket, id, await this.handlers.fileCandidates({
|
|
303
|
+
workspaceId,
|
|
304
|
+
sessionId,
|
|
305
|
+
query
|
|
306
|
+
}));
|
|
307
|
+
} else if (action === "skillCandidates" && this.handlers.skillCandidates !== void 0) this.respond(client.socket, id, await this.handlers.skillCandidates({
|
|
308
|
+
workspaceId,
|
|
309
|
+
sessionId
|
|
310
|
+
}));
|
|
311
|
+
else if (action === "taskPreview" && this.handlers.taskPreview !== void 0) this.respond(client.socket, id, await this.handlers.taskPreview({
|
|
312
|
+
workspaceId,
|
|
313
|
+
sessionId
|
|
314
|
+
}));
|
|
315
|
+
else throw new CommandError("unsupported", `${action} is unavailable`);
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
if (action === "prompt") {
|
|
319
|
+
const workspaceId = stringField(request.workspaceId, "workspaceId");
|
|
320
|
+
const sessionId = stringField(request.sessionId, "sessionId");
|
|
321
|
+
const requestId = stringField(request.requestId, "requestId");
|
|
322
|
+
const text = optionalStringField(request.text, "text") ?? "";
|
|
323
|
+
const images = imageFields(request.images, this.handlers.imageAttachmentLimits);
|
|
324
|
+
if (text.trim() === "" && images.length === 0) throw new CommandError("empty-prompt", "prompt must include non-whitespace text or an image");
|
|
325
|
+
const result = await this.handlers.prompt({
|
|
326
|
+
workspaceId,
|
|
327
|
+
sessionId,
|
|
328
|
+
requestId,
|
|
329
|
+
text,
|
|
330
|
+
images
|
|
331
|
+
});
|
|
332
|
+
this.respond(client.socket, id, result);
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
if (action === "subscribe") {
|
|
336
|
+
const sessionId = stringField(request.sessionId, "sessionId");
|
|
337
|
+
client.subscriptions.add(sessionId);
|
|
338
|
+
this.respond(client.socket, id, {
|
|
339
|
+
sessionId,
|
|
340
|
+
snapshot: await this.handlers.snapshotFor(sessionId) ?? null
|
|
341
|
+
});
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
if (action === "unsubscribe") {
|
|
345
|
+
const sessionId = stringField(request.sessionId, "sessionId");
|
|
346
|
+
client.subscriptions.delete(sessionId);
|
|
347
|
+
this.respond(client.socket, id, { sessionId });
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
throw new CommandError("unknown-action", "unsupported DSH Pet command");
|
|
351
|
+
} catch (error) {
|
|
352
|
+
this.write(client.socket, {
|
|
353
|
+
id,
|
|
354
|
+
ok: false,
|
|
355
|
+
error: {
|
|
356
|
+
code: commandErrorCode(error),
|
|
357
|
+
message: errorMessage(error)
|
|
358
|
+
}
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
respond(socket, id, result) {
|
|
363
|
+
this.write(socket, {
|
|
364
|
+
id,
|
|
365
|
+
ok: true,
|
|
366
|
+
...asObject(result)
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
write(socket, payload) {
|
|
370
|
+
if (!socket.destroyed) socket.write(`${JSON.stringify(payload)}\n`);
|
|
371
|
+
}
|
|
372
|
+
};
|
|
373
|
+
function socketIdentity$1(path) {
|
|
374
|
+
try {
|
|
375
|
+
const stat = lstatSync(path);
|
|
376
|
+
return stat.isSocket() ? {
|
|
377
|
+
dev: stat.dev,
|
|
378
|
+
ino: stat.ino
|
|
379
|
+
} : void 0;
|
|
380
|
+
} catch {
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
function sameSocketIdentity$1(left, right) {
|
|
385
|
+
return left !== void 0 && right !== void 0 && left.dev === right.dev && left.ino === right.ino;
|
|
386
|
+
}
|
|
387
|
+
function unlinkIfSame$1(path, identity) {
|
|
388
|
+
if (!sameSocketIdentity$1(identity, socketIdentity$1(path))) return;
|
|
389
|
+
try {
|
|
390
|
+
unlinkSync(path);
|
|
391
|
+
} catch {}
|
|
392
|
+
}
|
|
393
|
+
function preserveReplacement$1(path, owner) {
|
|
394
|
+
if (owner === void 0) return void 0;
|
|
395
|
+
const current = socketIdentity$1(path);
|
|
396
|
+
if (sameSocketIdentity$1(owner, current) || current === void 0) return void 0;
|
|
397
|
+
const backup = `${path}.dsh-pet-preserve-${randomUUID()}`;
|
|
398
|
+
try {
|
|
399
|
+
renameSync(path, backup);
|
|
400
|
+
return backup;
|
|
401
|
+
} catch {
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
function stringField(value, name) {
|
|
406
|
+
if (typeof value !== "string" || value.trim() === "") throw new CommandError("invalid-request", `${name} must be a non-empty string`);
|
|
407
|
+
return value;
|
|
408
|
+
}
|
|
409
|
+
function optionalStringField(value, name) {
|
|
410
|
+
if (value === void 0) return void 0;
|
|
411
|
+
if (typeof value !== "string") throw new CommandError("invalid-request", `${name} must be a string`);
|
|
412
|
+
return value;
|
|
413
|
+
}
|
|
414
|
+
function imageFields(value, limits) {
|
|
415
|
+
if (value === void 0) return [];
|
|
416
|
+
if (!Array.isArray(value)) throw new CommandError("invalid-request", "images must be an array");
|
|
417
|
+
if (value.length > limits.maxImagesPerMessage) throw new CommandError("session/attachment-invalid", "Image batch exceeds the configured image-count limit.");
|
|
418
|
+
let aggregateBytes = 0;
|
|
419
|
+
return value.map((candidate, index) => {
|
|
420
|
+
if (candidate === null || typeof candidate !== "object" || Array.isArray(candidate)) throw new CommandError("invalid-request", `images[${index}] must be an object`);
|
|
421
|
+
const image = candidate;
|
|
422
|
+
const mediaType = stringField(image.mediaType, `images[${index}].mediaType`);
|
|
423
|
+
if (!limits.mediaTypes.includes(mediaType)) throw new CommandError("session/attachment-invalid", `Image type ${mediaType} is not accepted by this deployment.`);
|
|
424
|
+
const data = stringField(image.data, `images[${index}].data`);
|
|
425
|
+
const name = optionalStringField(image.name, `images[${index}].name`);
|
|
426
|
+
const bytes = canonicalBase64Bytes(data);
|
|
427
|
+
if (bytes > limits.maxImageBytes) throw new CommandError("session/attachment-invalid", "Image exceeds the configured image-byte limit.");
|
|
428
|
+
aggregateBytes += bytes;
|
|
429
|
+
if (aggregateBytes > limits.maxMessageImageBytes) throw new CommandError("session/attachment-invalid", "Image batch exceeds the configured aggregate image-byte limit.");
|
|
430
|
+
return {
|
|
431
|
+
mediaType,
|
|
432
|
+
data,
|
|
433
|
+
...name === void 0 ? {} : { name }
|
|
434
|
+
};
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
function canonicalBase64Bytes(data) {
|
|
438
|
+
if (data.length === 0 || data.length % 4 !== 0 || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(data)) throw new CommandError("session/attachment-invalid", "Image upload is not canonical base64.");
|
|
439
|
+
const padding = data.endsWith("==") ? 2 : data.endsWith("=") ? 1 : 0;
|
|
440
|
+
return data.length / 4 * 3 - padding;
|
|
441
|
+
}
|
|
442
|
+
function maxCommandBytes(limits) {
|
|
443
|
+
const aggregateBytes = Math.min(limits.maxMessageImageBytes, limits.maxImageBytes * limits.maxImagesPerMessage);
|
|
444
|
+
const base64Bytes = Math.ceil(aggregateBytes / 3) * 4;
|
|
445
|
+
return Math.min(Number.MAX_SAFE_INTEGER, base64Bytes + COMMAND_METADATA_BYTES);
|
|
446
|
+
}
|
|
447
|
+
function commandErrorCode(error) {
|
|
448
|
+
if (error instanceof CommandError) return error.code;
|
|
449
|
+
if (error !== null && typeof error === "object" && "code" in error) {
|
|
450
|
+
const code = error.code;
|
|
451
|
+
if (typeof code === "string" && code !== "") return code;
|
|
452
|
+
}
|
|
453
|
+
return "command-failed";
|
|
454
|
+
}
|
|
455
|
+
function asObject(value) {
|
|
456
|
+
if (value !== null && typeof value === "object" && !Array.isArray(value)) return value;
|
|
457
|
+
return { value };
|
|
458
|
+
}
|
|
459
|
+
function errorMessage(error) {
|
|
460
|
+
return error instanceof Error ? error.message : String(error);
|
|
461
|
+
}
|
|
462
|
+
//#endregion
|
|
463
|
+
//#region src/LocalStateBridge.ts
|
|
464
|
+
/**
|
|
465
|
+
* Small, opt-in local transport for the host plugin.
|
|
466
|
+
*
|
|
467
|
+
* The socket is deliberately a server owned by the plugin: DSH Pet only
|
|
468
|
+
* observes it and never starts or stops Harness. Each client receives the
|
|
469
|
+
* latest snapshot immediately, then one JSON object per line on change.
|
|
470
|
+
*/
|
|
471
|
+
var LocalStateBridge = class LocalStateBridge {
|
|
472
|
+
static defaultPath = join(homedir(), "Library", "Application Support", "DSH Pet", "dsh-pet.sock");
|
|
473
|
+
socketPath;
|
|
474
|
+
readSnapshot;
|
|
475
|
+
server;
|
|
476
|
+
clients = /* @__PURE__ */ new Set();
|
|
477
|
+
ownsSocket = false;
|
|
478
|
+
probeGeneration = 0;
|
|
479
|
+
probing = false;
|
|
480
|
+
socketIdentity;
|
|
481
|
+
constructor(readSnapshot, socketPath = process.env.DSH_PET_SOCKET_PATH ?? process.env.MOODBALL_SOCKET_PATH ?? LocalStateBridge.defaultPath) {
|
|
482
|
+
this.readSnapshot = readSnapshot;
|
|
483
|
+
this.socketPath = socketPath;
|
|
484
|
+
}
|
|
485
|
+
start(onReady) {
|
|
486
|
+
if (this.server || this.probing) return;
|
|
487
|
+
mkdirSync(dirname(this.socketPath), { recursive: true });
|
|
488
|
+
if (existsSync(this.socketPath)) {
|
|
489
|
+
if (!lstatSync(this.socketPath).isSocket()) {
|
|
490
|
+
console.warn(`[dsh-pet] local bridge path is not a socket: ${this.socketPath}`);
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
const staleIdentity = socketIdentity(this.socketPath);
|
|
494
|
+
const generation = ++this.probeGeneration;
|
|
495
|
+
this.probing = true;
|
|
496
|
+
const probe = connect(this.socketPath);
|
|
497
|
+
probe.once("connect", () => {
|
|
498
|
+
probe.destroy();
|
|
499
|
+
this.probing = false;
|
|
500
|
+
console.warn(`[dsh-pet] local bridge path is already active: ${this.socketPath}`);
|
|
501
|
+
});
|
|
502
|
+
probe.once("error", (error) => {
|
|
503
|
+
probe.destroy();
|
|
504
|
+
this.probing = false;
|
|
505
|
+
if (generation !== this.probeGeneration) return;
|
|
506
|
+
const code = "code" in error ? error.code : void 0;
|
|
507
|
+
if (code !== "ECONNREFUSED" && code !== "ENOENT") {
|
|
508
|
+
console.warn(`[dsh-pet] local bridge unavailable: ${error.message}`);
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
if (!sameSocketIdentity(staleIdentity, socketIdentity(this.socketPath))) return;
|
|
512
|
+
try {
|
|
513
|
+
unlinkSync(this.socketPath);
|
|
514
|
+
} catch {}
|
|
515
|
+
this.listen(onReady);
|
|
516
|
+
});
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
this.listen(onReady);
|
|
520
|
+
}
|
|
521
|
+
listen(onReady) {
|
|
522
|
+
const generation = this.probeGeneration;
|
|
523
|
+
const server = createServer((client) => {
|
|
524
|
+
this.clients.add(client);
|
|
525
|
+
client.setNoDelay(true);
|
|
526
|
+
client.on("close", () => this.clients.delete(client));
|
|
527
|
+
client.on("error", () => this.clients.delete(client));
|
|
528
|
+
this.write(client);
|
|
529
|
+
});
|
|
530
|
+
server.on("error", (error) => {
|
|
531
|
+
console.warn(`[dsh-pet] local bridge unavailable: ${error.message}`);
|
|
532
|
+
});
|
|
533
|
+
server.listen(this.socketPath, () => {
|
|
534
|
+
const identity = socketIdentity(this.socketPath);
|
|
535
|
+
if (generation !== this.probeGeneration) {
|
|
536
|
+
server.close();
|
|
537
|
+
unlinkIfSame(this.socketPath, identity);
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
540
|
+
this.ownsSocket = true;
|
|
541
|
+
this.socketIdentity = identity;
|
|
542
|
+
try {
|
|
543
|
+
chmodSync(this.socketPath, 384);
|
|
544
|
+
} catch {}
|
|
545
|
+
onReady?.();
|
|
546
|
+
});
|
|
547
|
+
this.server = server;
|
|
548
|
+
}
|
|
549
|
+
publish() {
|
|
550
|
+
for (const client of this.clients) this.write(client);
|
|
551
|
+
}
|
|
552
|
+
stop() {
|
|
553
|
+
this.probeGeneration += 1;
|
|
554
|
+
this.probing = false;
|
|
555
|
+
for (const client of this.clients) client.destroy();
|
|
556
|
+
this.clients.clear();
|
|
557
|
+
const server = this.server;
|
|
558
|
+
this.server = void 0;
|
|
559
|
+
if (!server) return;
|
|
560
|
+
const replacement = preserveReplacement(this.socketPath, this.socketIdentity);
|
|
561
|
+
server.close();
|
|
562
|
+
if (replacement !== void 0) try {
|
|
563
|
+
renameSync(replacement, this.socketPath);
|
|
564
|
+
} catch {}
|
|
565
|
+
else if (this.ownsSocket) unlinkIfSame(this.socketPath, this.socketIdentity);
|
|
566
|
+
this.ownsSocket = false;
|
|
567
|
+
this.socketIdentity = void 0;
|
|
568
|
+
}
|
|
569
|
+
write(client) {
|
|
570
|
+
if (!client.destroyed) client.write(`${JSON.stringify(this.readSnapshot())}\n`);
|
|
571
|
+
}
|
|
572
|
+
};
|
|
573
|
+
function socketIdentity(path) {
|
|
574
|
+
try {
|
|
575
|
+
const stat = lstatSync(path);
|
|
576
|
+
return stat.isSocket() ? {
|
|
577
|
+
dev: stat.dev,
|
|
578
|
+
ino: stat.ino
|
|
579
|
+
} : void 0;
|
|
580
|
+
} catch {
|
|
581
|
+
return;
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
function sameSocketIdentity(left, right) {
|
|
585
|
+
return left !== void 0 && right !== void 0 && left.dev === right.dev && left.ino === right.ino;
|
|
586
|
+
}
|
|
587
|
+
function unlinkIfSame(path, identity) {
|
|
588
|
+
if (!sameSocketIdentity(identity, socketIdentity(path))) return;
|
|
589
|
+
try {
|
|
590
|
+
unlinkSync(path);
|
|
591
|
+
} catch {}
|
|
592
|
+
}
|
|
593
|
+
function preserveReplacement(path, owner) {
|
|
594
|
+
if (owner === void 0) return void 0;
|
|
595
|
+
const current = socketIdentity(path);
|
|
596
|
+
if (sameSocketIdentity(owner, current) || current === void 0) return void 0;
|
|
597
|
+
const backup = `${path}.dsh-pet-preserve-${randomUUID()}`;
|
|
598
|
+
try {
|
|
599
|
+
renameSync(path, backup);
|
|
600
|
+
return backup;
|
|
601
|
+
} catch {
|
|
602
|
+
return;
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
//#endregion
|
|
606
|
+
//#region src/InstallDetection.ts
|
|
607
|
+
const execFileAsync = promisify(execFile);
|
|
608
|
+
/** Detect the App on the Harness host without opening it or exposing paths. */
|
|
609
|
+
async function detectHostApp() {
|
|
610
|
+
if (process.platform !== "darwin") return "unknown";
|
|
611
|
+
try {
|
|
612
|
+
const { stdout } = await execFileAsync("/usr/bin/osascript", [
|
|
613
|
+
"-l",
|
|
614
|
+
"JavaScript",
|
|
615
|
+
"-e",
|
|
616
|
+
`
|
|
617
|
+
ObjC.import('AppKit')
|
|
618
|
+
const bundleId = 'com.sundusk.dsh-pet'
|
|
619
|
+
const home = ObjC.unwrap($.NSHomeDirectory())
|
|
620
|
+
const registered = $.NSWorkspace.sharedWorkspace.URLForApplicationWithBundleIdentifier(bundleId)
|
|
621
|
+
const paths = ['/Applications/DSH Pet.app', home + '/Applications/DSH Pet.app']
|
|
622
|
+
if (!registered.isNil()) paths.push(ObjC.unwrap(registered.path))
|
|
623
|
+
const found = paths.some(path => {
|
|
624
|
+
const bundle = $.NSBundle.bundleWithPath(path)
|
|
625
|
+
return !bundle.isNil() && ObjC.unwrap(bundle.bundleIdentifier) === bundleId
|
|
626
|
+
})
|
|
627
|
+
found ? 'installed' : 'not-found'
|
|
628
|
+
`
|
|
629
|
+
], {
|
|
630
|
+
timeout: 3e3,
|
|
631
|
+
maxBuffer: 1024
|
|
632
|
+
});
|
|
633
|
+
const status = stdout.trim();
|
|
634
|
+
return status === "installed" || status === "not-found" ? status : "unknown";
|
|
635
|
+
} catch {
|
|
636
|
+
return "unknown";
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
const MAX_UNIX_SOCKET_PATH_BYTES = 103;
|
|
640
|
+
/**
|
|
641
|
+
* Build a per-plugin identity. The plugin gets no reliable process-name based
|
|
642
|
+
* signal, so kind stays unknown unless the host supplies an explicit profile or
|
|
643
|
+
* kind. This avoids presenting a guessed Desktop/Web label in the settings UI.
|
|
644
|
+
*/
|
|
645
|
+
function createHarnessInstanceDescriptor(context) {
|
|
646
|
+
const hints = context;
|
|
647
|
+
const profileContext = readHint(hints, "profileContext");
|
|
648
|
+
const resolvedProfile = readHint(hints, "resolvedProfile");
|
|
649
|
+
const profile = firstNonEmptyString(process.env.DSH_PET_PROFILE, process.env.MOODBALL_PROFILE, process.env.DSH_PROFILE, profileContext?.name, readHint(hints, "profile"), resolvedProfile?.profile, profileFromArgv()) ?? profileName(resolvedProfile?.profile);
|
|
650
|
+
const kind = normalizeKind(firstNonEmptyString(process.env.DSH_PET_HARNESS_KIND, process.env.MOODBALL_HARNESS_KIND, process.env.DSH_PET_HARNESS_TYPE, process.env.MOODBALL_HARNESS_TYPE, readHint(hints, "kind"), readHint(hints, "type"), readServiceString(hints, "harnessKind"))) ?? (profile === "desktop" ? "desktop" : profile === "web" ? "web" : "unknown");
|
|
651
|
+
const instanceId = randomUUID().replaceAll("-", "").slice(0, 12);
|
|
652
|
+
const uid = typeof process.getuid === "function" ? process.getuid() : process.pid;
|
|
653
|
+
const socketDir = join("/tmp", `dsh-pet-${uid}`);
|
|
654
|
+
mkdirSync(socketDir, {
|
|
655
|
+
recursive: true,
|
|
656
|
+
mode: 448
|
|
657
|
+
});
|
|
658
|
+
chmodSync(socketDir, 448);
|
|
659
|
+
const statusSocketPath = join(socketDir, `${instanceId}-s.sock`);
|
|
660
|
+
const commandSocketPath = join(socketDir, `${instanceId}-c.sock`);
|
|
661
|
+
assertSocketPath(statusSocketPath);
|
|
662
|
+
assertSocketPath(commandSocketPath);
|
|
663
|
+
const webURL = safeLoopbackURL(process.env.DSH_PET_WEB_URL ?? process.env.MOODBALL_WEB_URL ?? process.env.DSH_WEB_URL ?? contextOrigin(hints) ?? contextWebURL(hints));
|
|
664
|
+
const targetId = targetIdentity(profile, hints);
|
|
665
|
+
return {
|
|
666
|
+
protocolVersion: 1,
|
|
667
|
+
instanceId,
|
|
668
|
+
kind,
|
|
669
|
+
...profile === void 0 ? {} : { profile },
|
|
670
|
+
...targetId === void 0 ? {} : { targetId },
|
|
671
|
+
pid: process.pid,
|
|
672
|
+
startedAt: Math.floor(Date.now() / 1e3),
|
|
673
|
+
...webURL === void 0 ? {} : { webURL },
|
|
674
|
+
statusSocketPath,
|
|
675
|
+
commandSocketPath
|
|
676
|
+
};
|
|
677
|
+
}
|
|
678
|
+
/** Refresh the origin after WebServer has finished listening. */
|
|
679
|
+
function refreshHarnessInstanceWebURL(descriptor, context) {
|
|
680
|
+
const hints = context;
|
|
681
|
+
const webURL = safeLoopbackURL(contextOrigin(hints) ?? contextWebURL(hints));
|
|
682
|
+
if (webURL === void 0) return false;
|
|
683
|
+
descriptor.webURL = webURL;
|
|
684
|
+
return true;
|
|
685
|
+
}
|
|
686
|
+
/** The default discovery directory shared by Desktop and Web Harness hosts. */
|
|
687
|
+
function instanceDiscoveryDirectory(home = homedir()) {
|
|
688
|
+
return join(home, "Library", "Application Support", "DSH Pet", "instances");
|
|
689
|
+
}
|
|
690
|
+
function instanceManifestPath(instanceId, home = homedir()) {
|
|
691
|
+
return join(instanceDiscoveryDirectory(home), `${instanceId}.json`);
|
|
692
|
+
}
|
|
693
|
+
/** Publishes and removes only the manifest belonging to one generated identity. */
|
|
694
|
+
var HarnessInstanceManifest = class {
|
|
695
|
+
descriptor;
|
|
696
|
+
directory;
|
|
697
|
+
published = false;
|
|
698
|
+
constructor(descriptor, directory = instanceDiscoveryDirectory()) {
|
|
699
|
+
this.descriptor = descriptor;
|
|
700
|
+
this.directory = directory;
|
|
701
|
+
}
|
|
702
|
+
get path() {
|
|
703
|
+
return join(this.directory, `${this.descriptor.instanceId}.json`);
|
|
704
|
+
}
|
|
705
|
+
publish() {
|
|
706
|
+
mkdirSync(this.directory, {
|
|
707
|
+
recursive: true,
|
|
708
|
+
mode: 448
|
|
709
|
+
});
|
|
710
|
+
const temporary = `${this.path}.${process.pid}.${randomUUID()}.tmp`;
|
|
711
|
+
writeFileSync(temporary, `${JSON.stringify(this.descriptor)}\n`, { mode: 384 });
|
|
712
|
+
chmodSync(temporary, 384);
|
|
713
|
+
renameSync(temporary, this.path);
|
|
714
|
+
chmodSync(this.path, 384);
|
|
715
|
+
this.published = true;
|
|
716
|
+
}
|
|
717
|
+
remove() {
|
|
718
|
+
if (!this.published || !existsSync(this.path)) return;
|
|
719
|
+
try {
|
|
720
|
+
if (JSON.parse(readFileSync(this.path, "utf8")).instanceId !== this.descriptor.instanceId) return;
|
|
721
|
+
unlinkSync(this.path);
|
|
722
|
+
this.published = false;
|
|
723
|
+
} catch {}
|
|
724
|
+
}
|
|
725
|
+
};
|
|
726
|
+
function firstNonEmptyString(...values) {
|
|
727
|
+
return values.find((value) => typeof value === "string" && value.trim() !== "");
|
|
728
|
+
}
|
|
729
|
+
function normalizeKind(value) {
|
|
730
|
+
if (value === "desktop" || value === "web" || value === "unknown") return value;
|
|
731
|
+
}
|
|
732
|
+
function readHint(context, name) {
|
|
733
|
+
try {
|
|
734
|
+
return context?.get?.(name, false) ?? context?.[name];
|
|
735
|
+
} catch {
|
|
736
|
+
return;
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
function readServiceString(context, name) {
|
|
740
|
+
try {
|
|
741
|
+
return context?.get?.(name, false);
|
|
742
|
+
} catch {
|
|
743
|
+
return;
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
function profileFromArgv() {
|
|
747
|
+
const args = process.argv.slice(1);
|
|
748
|
+
const flag = args.indexOf("--profile");
|
|
749
|
+
const value = flag >= 0 ? args[flag + 1] : void 0;
|
|
750
|
+
return value !== void 0 && value !== "" && !value.startsWith("-") ? value : void 0;
|
|
751
|
+
}
|
|
752
|
+
function contextOrigin(context) {
|
|
753
|
+
try {
|
|
754
|
+
return readHint(context, "webServer")?.getOrigin?.();
|
|
755
|
+
} catch {
|
|
756
|
+
return;
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
function contextWebURL(context) {
|
|
760
|
+
const server = readHint(context, "webServer");
|
|
761
|
+
if (server === void 0) return void 0;
|
|
762
|
+
const protocol = typeof server.protocol === "string" ? server.protocol : void 0;
|
|
763
|
+
const host = firstNonEmptyString(server.host, server.hostname);
|
|
764
|
+
const port = typeof server.port === "number" && Number.isInteger(server.port) ? server.port : void 0;
|
|
765
|
+
if (protocol === void 0 || host === void 0 || port === void 0) return void 0;
|
|
766
|
+
return `${protocol}//${host}:${port}`;
|
|
767
|
+
}
|
|
768
|
+
function profileName(value) {
|
|
769
|
+
if (value === null || typeof value !== "object") return void 0;
|
|
770
|
+
const name = value.name;
|
|
771
|
+
return typeof name === "string" && name.trim() !== "" ? name : void 0;
|
|
772
|
+
}
|
|
773
|
+
function targetIdentity(profile, context) {
|
|
774
|
+
const explicit = firstNonEmptyString(process.env.DSH_PET_TARGET_ID, process.env.MOODBALL_TARGET_ID, readServiceString(context, "targetId"));
|
|
775
|
+
if (explicit !== void 0) return explicit;
|
|
776
|
+
const profileContext = readHint(context, "profileContext");
|
|
777
|
+
const profileObject = readHint(context, "resolvedProfile")?.profile;
|
|
778
|
+
const profileDir = firstNonEmptyString(profileContext?.dir, readHint(context, "profileDir"), profileObject !== null && typeof profileObject === "object" ? profileObject.dir : void 0);
|
|
779
|
+
const home = firstNonEmptyString(process.env.DSH_HOME, profileContext?.home, readHint(context, "home"));
|
|
780
|
+
const source = profileDir ?? (home !== void 0 && profile !== void 0 ? `${home}\u0000${profile}` : void 0);
|
|
781
|
+
if (source === void 0) return void 0;
|
|
782
|
+
return `target-${createHash("sha256").update(source).digest("hex").slice(0, 16)}`;
|
|
783
|
+
}
|
|
784
|
+
function safeLoopbackURL(value) {
|
|
785
|
+
if (value === void 0) return void 0;
|
|
786
|
+
try {
|
|
787
|
+
const url = new URL(value);
|
|
788
|
+
if (!["http:", "https:"].includes(url.protocol)) return void 0;
|
|
789
|
+
if (![
|
|
790
|
+
"127.0.0.1",
|
|
791
|
+
"localhost",
|
|
792
|
+
"[::1]",
|
|
793
|
+
"::1"
|
|
794
|
+
].includes(url.hostname)) return void 0;
|
|
795
|
+
if (url.username !== "" || url.password !== "" || url.search !== "" || url.hash !== "") return void 0;
|
|
796
|
+
if (url.pathname !== "" && url.pathname !== "/") return void 0;
|
|
797
|
+
return url.origin;
|
|
798
|
+
} catch {
|
|
799
|
+
return;
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
function assertSocketPath(path) {
|
|
803
|
+
if (Buffer.byteLength(path) > MAX_UNIX_SOCKET_PATH_BYTES) throw new Error(`DSH Pet instance socket path exceeds Unix limit: ${path}`);
|
|
804
|
+
}
|
|
805
|
+
//#endregion
|
|
806
|
+
//#region src/index.ts
|
|
807
|
+
/** Stable cordis plugin name (matches cordis.patch.yml insert id). */
|
|
808
|
+
const name = "dsh-pet";
|
|
809
|
+
/** Services required before the status surface can mount. */
|
|
810
|
+
const inject = [
|
|
811
|
+
"webServer",
|
|
812
|
+
"workspaceRegistry",
|
|
813
|
+
"sessionController",
|
|
814
|
+
"attachments"
|
|
815
|
+
];
|
|
816
|
+
function stateForMood(mood) {
|
|
817
|
+
switch (mood) {
|
|
818
|
+
case "waiting": return "thinking";
|
|
819
|
+
case "jumping": return "toolCalling";
|
|
820
|
+
case "authorizing": return "waitingApproval";
|
|
821
|
+
case "questioning": return "waitingUserAnswer";
|
|
822
|
+
case "done": return "completed";
|
|
823
|
+
case "failed": return "failed";
|
|
824
|
+
case "stopped": return "stopped";
|
|
825
|
+
case "idle": return "idle";
|
|
826
|
+
default: return "disconnected";
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
function isRunningMood(mood) {
|
|
830
|
+
return [
|
|
831
|
+
"waiting",
|
|
832
|
+
"jumping",
|
|
833
|
+
"authorizing",
|
|
834
|
+
"questioning"
|
|
835
|
+
].includes(mood);
|
|
836
|
+
}
|
|
837
|
+
/** Write one JSON response. */
|
|
838
|
+
function json(res, status, body) {
|
|
839
|
+
res.writeHead(status, { "content-type": "application/json; charset=utf-8" });
|
|
840
|
+
res.end(JSON.stringify(body));
|
|
841
|
+
}
|
|
842
|
+
/**
|
|
843
|
+
* Register the DSH Pet status surface: fold the agent session stream into a
|
|
844
|
+
* stable snapshot, serve it over GET /api/dsh-pet/status, and broadcast the
|
|
845
|
+
* same snapshot through LocalStateBridge. The route is always live while the
|
|
846
|
+
* plugin is loaded — there is no settings namespace to toggle.
|
|
847
|
+
* @param ctx - host root context.
|
|
848
|
+
*/
|
|
849
|
+
function apply(ctx) {
|
|
850
|
+
const globalState = {
|
|
851
|
+
mood: "idle",
|
|
852
|
+
holdUntil: 0,
|
|
853
|
+
questionActive: false
|
|
854
|
+
};
|
|
855
|
+
const sessionStates = /* @__PURE__ */ new Map();
|
|
856
|
+
const unconfirmedSessions = /* @__PURE__ */ new Set();
|
|
857
|
+
const sessionOperations = /* @__PURE__ */ new Map();
|
|
858
|
+
let taskSnapshot = [];
|
|
859
|
+
let taskRefreshTimer;
|
|
860
|
+
let refreshTaskSnapshot;
|
|
861
|
+
let activeSessionId;
|
|
862
|
+
let commandBridge;
|
|
863
|
+
const instance = createHarnessInstanceDescriptor(ctx);
|
|
864
|
+
const instanceManifest = new HarnessInstanceManifest(instance, process.env.DSH_PET_INSTANCE_DIRECTORY ?? process.env.MOODBALL_INSTANCE_DIRECTORY ?? instanceDiscoveryDirectory());
|
|
865
|
+
let statusReady = false;
|
|
866
|
+
let commandReady = false;
|
|
867
|
+
let originRetryTimer;
|
|
868
|
+
let instanceManifestPublished = false;
|
|
869
|
+
const publishInstanceManifest = () => {
|
|
870
|
+
if (!statusReady || !commandReady || instanceManifestPublished) return;
|
|
871
|
+
const hasOriginGetter = typeof ctx.webServer?.getOrigin === "function";
|
|
872
|
+
if (instance.kind === "web" && hasOriginGetter && !refreshHarnessInstanceWebURL(instance, ctx)) {
|
|
873
|
+
originRetryTimer ??= setTimeout(() => {
|
|
874
|
+
originRetryTimer = void 0;
|
|
875
|
+
publishInstanceManifest();
|
|
876
|
+
}, 100);
|
|
877
|
+
return;
|
|
878
|
+
}
|
|
879
|
+
refreshHarnessInstanceWebURL(instance, ctx);
|
|
880
|
+
try {
|
|
881
|
+
instanceManifest.publish();
|
|
882
|
+
instanceManifestPublished = true;
|
|
883
|
+
} catch (error) {
|
|
884
|
+
console.warn(`[dsh-pet] instance discovery unavailable: ${String(error)}`);
|
|
885
|
+
}
|
|
886
|
+
};
|
|
887
|
+
const stateFor = (sessionId, workspaceId) => {
|
|
888
|
+
const existing = sessionStates.get(sessionId);
|
|
889
|
+
if (existing) {
|
|
890
|
+
if (workspaceId) existing.workspaceId = workspaceId;
|
|
891
|
+
return existing;
|
|
892
|
+
}
|
|
893
|
+
const created = {
|
|
894
|
+
sessionId,
|
|
895
|
+
workspaceId,
|
|
896
|
+
mood: "idle",
|
|
897
|
+
holdUntil: 0,
|
|
898
|
+
questionActive: false
|
|
899
|
+
};
|
|
900
|
+
sessionStates.set(sessionId, created);
|
|
901
|
+
return created;
|
|
902
|
+
};
|
|
903
|
+
const workspaceForSession = (sessionId) => {
|
|
904
|
+
const workspace = ctx.workspaceRegistry.list().find((candidate) => candidate.sessionIds.some((candidateSessionId) => String(candidateSessionId) === sessionId));
|
|
905
|
+
return workspace ? String(workspace.id) : void 0;
|
|
906
|
+
};
|
|
907
|
+
const snapshotOf = (state) => ({
|
|
908
|
+
instanceId: instance.instanceId,
|
|
909
|
+
state: stateForMood(state.mood),
|
|
910
|
+
mood: state.mood,
|
|
911
|
+
...state.sessionId ? { sessionId: state.sessionId } : {},
|
|
912
|
+
...state.workspaceId ? { workspaceId: state.workspaceId } : {},
|
|
913
|
+
taskRunning: [
|
|
914
|
+
"waiting",
|
|
915
|
+
"jumping",
|
|
916
|
+
"authorizing",
|
|
917
|
+
"questioning"
|
|
918
|
+
].includes(state.mood),
|
|
919
|
+
waitingForUser: state.mood === "authorizing" || state.mood === "questioning",
|
|
920
|
+
failed: state.mood === "failed",
|
|
921
|
+
completed: state.mood === "done",
|
|
922
|
+
...state.tool ? { tool: state.tool } : {},
|
|
923
|
+
...state.message ? { message: state.message } : {},
|
|
924
|
+
updatedAt: Date.now()
|
|
925
|
+
});
|
|
926
|
+
const snapshotForSession = (sessionId) => snapshotOf(stateFor(sessionId, workspaceForSession(sessionId)));
|
|
927
|
+
const snapshotForTask = (task) => ({
|
|
928
|
+
...snapshotForSession(task.sessionId),
|
|
929
|
+
state: task.state,
|
|
930
|
+
mood: task.mood,
|
|
931
|
+
taskRunning: task.taskRunning,
|
|
932
|
+
waitingForUser: task.waitingForUser,
|
|
933
|
+
failed: task.failed,
|
|
934
|
+
completed: task.completed
|
|
935
|
+
});
|
|
936
|
+
const snapshot = () => activeSessionId === void 0 ? snapshotOf(globalState) : snapshotForSession(activeSessionId);
|
|
937
|
+
const localBridge = new LocalStateBridge(snapshot, instance.statusSocketPath);
|
|
938
|
+
const workspaceForSummary = (sessionId) => ctx.workspaceRegistry.list().find((workspace) => workspace.sessionIds.some((candidate) => String(candidate) === sessionId));
|
|
939
|
+
const titleForSummary = (summary, workspace) => {
|
|
940
|
+
const serviceContext = ctx;
|
|
941
|
+
const sessions = serviceContext.get?.("sessions", false);
|
|
942
|
+
const titleService = serviceContext.get?.("sessionTitle", false);
|
|
943
|
+
const liveSession = sessions?.get(summary.sessionId);
|
|
944
|
+
const title = liveSession === void 0 ? void 0 : titleService?.get(liveSession)?.title;
|
|
945
|
+
if (title !== void 0 && title.trim() !== "") return title;
|
|
946
|
+
const directory = summary.cwd === void 0 ? "" : basename(summary.cwd);
|
|
947
|
+
const suffix = summary.sessionId.slice(-8);
|
|
948
|
+
return directory !== "" && directory !== workspace.title ? `${directory} · 会话 ${suffix}` : `会话 ${suffix}`;
|
|
949
|
+
};
|
|
950
|
+
const tasksFromSummaries = (summaries) => summaries.flatMap((summary) => {
|
|
951
|
+
if (summary.parentSessionId !== void 0 || summary.origin === "subagent") return [];
|
|
952
|
+
const workspace = workspaceForSummary(summary.sessionId);
|
|
953
|
+
if (workspace === void 0) return [];
|
|
954
|
+
const state = stateFor(summary.sessionId, String(workspace.id));
|
|
955
|
+
let mood = state.mood;
|
|
956
|
+
if (summary.running && !isRunningMood(mood)) mood = "waiting";
|
|
957
|
+
if (!summary.running && isRunningMood(mood)) mood = "idle";
|
|
958
|
+
if (!summary.running && state.lastResult !== void 0) mood = state.lastResult;
|
|
959
|
+
const projected = snapshotOf({
|
|
960
|
+
...state,
|
|
961
|
+
mood,
|
|
962
|
+
workspaceId: String(workspace.id)
|
|
963
|
+
});
|
|
964
|
+
return [{
|
|
965
|
+
sessionId: summary.sessionId,
|
|
966
|
+
workspaceId: String(workspace.id),
|
|
967
|
+
title: titleForSummary(summary, workspace),
|
|
968
|
+
...summary.cwd === void 0 ? {} : { cwd: summary.cwd },
|
|
969
|
+
updatedAt: summary.updatedAt,
|
|
970
|
+
running: summary.running,
|
|
971
|
+
blank: summary.blank,
|
|
972
|
+
state: projected.state,
|
|
973
|
+
mood: projected.mood,
|
|
974
|
+
taskRunning: summary.running || projected.taskRunning,
|
|
975
|
+
waitingForUser: projected.waitingForUser,
|
|
976
|
+
failed: projected.failed,
|
|
977
|
+
completed: projected.completed,
|
|
978
|
+
...projected.tool === void 0 ? {} : { tool: projected.tool },
|
|
979
|
+
...projected.message === void 0 ? {} : { message: projected.message }
|
|
980
|
+
}];
|
|
981
|
+
});
|
|
982
|
+
const scheduleTaskRefresh = () => {
|
|
983
|
+
if (taskRefreshTimer !== void 0) return;
|
|
984
|
+
taskRefreshTimer = setTimeout(() => {
|
|
985
|
+
taskRefreshTimer = void 0;
|
|
986
|
+
refreshTaskSnapshot?.().catch((error) => {
|
|
987
|
+
console.warn(`[dsh-pet] task list refresh failed: ${String(error)}`);
|
|
988
|
+
});
|
|
989
|
+
}, 150);
|
|
990
|
+
};
|
|
991
|
+
const publish = (state) => {
|
|
992
|
+
localBridge.publish();
|
|
993
|
+
if (state.sessionId) {
|
|
994
|
+
const snapshot = snapshotOf(state);
|
|
995
|
+
commandBridge?.publish(state.sessionId, unconfirmedSessions.has(state.sessionId) ? {
|
|
996
|
+
...snapshot,
|
|
997
|
+
taskRunning: true,
|
|
998
|
+
message: "无法确认任务状态,暂不可发送"
|
|
999
|
+
} : snapshot);
|
|
1000
|
+
}
|
|
1001
|
+
scheduleTaskRefresh();
|
|
1002
|
+
};
|
|
1003
|
+
const setTransient = (state, next, ms) => {
|
|
1004
|
+
state.mood = next;
|
|
1005
|
+
if (next === "done" || next === "failed" || next === "stopped") state.lastResult = next;
|
|
1006
|
+
state.holdUntil = Date.now() + ms;
|
|
1007
|
+
publish(state);
|
|
1008
|
+
setTimeout(() => {
|
|
1009
|
+
if (state.mood === next) {
|
|
1010
|
+
state.mood = "idle";
|
|
1011
|
+
publish(state);
|
|
1012
|
+
}
|
|
1013
|
+
}, ms);
|
|
1014
|
+
};
|
|
1015
|
+
ctx.on("session/event", (_session, event) => {
|
|
1016
|
+
const info = _session;
|
|
1017
|
+
const sessionId = info.id ?? info.sessionId;
|
|
1018
|
+
const state = sessionId === void 0 ? globalState : stateFor(sessionId, info.workspaceId ?? workspaceForSession(sessionId));
|
|
1019
|
+
if (sessionId) activeSessionId = sessionId;
|
|
1020
|
+
if (event.type === "turn/start" || event.type === "step/start" || event.type === "assistant/chunk") {
|
|
1021
|
+
state.lastResult = void 0;
|
|
1022
|
+
state.mood = "waiting";
|
|
1023
|
+
state.holdUntil = 0;
|
|
1024
|
+
state.message = void 0;
|
|
1025
|
+
} else if (event.type === "tool/call") {
|
|
1026
|
+
state.lastResult = void 0;
|
|
1027
|
+
const call = event.data ?? {};
|
|
1028
|
+
state.tool = call.name;
|
|
1029
|
+
if (call.name === "ask_user_question") {
|
|
1030
|
+
state.questionActive = true;
|
|
1031
|
+
state.mood = "questioning";
|
|
1032
|
+
state.holdUntil = 0;
|
|
1033
|
+
} else {
|
|
1034
|
+
state.mood = "jumping";
|
|
1035
|
+
state.holdUntil = 0;
|
|
1036
|
+
}
|
|
1037
|
+
} else if (event.type === "tool/result") {
|
|
1038
|
+
const result = event.data ?? {};
|
|
1039
|
+
if (state.questionActive) {
|
|
1040
|
+
state.questionActive = false;
|
|
1041
|
+
if (result.error !== void 0) setTransient(state, "stopped", 1500);
|
|
1042
|
+
else {
|
|
1043
|
+
state.mood = "waiting";
|
|
1044
|
+
state.holdUntil = 0;
|
|
1045
|
+
}
|
|
1046
|
+
} else {
|
|
1047
|
+
state.mood = "waiting";
|
|
1048
|
+
state.holdUntil = 0;
|
|
1049
|
+
}
|
|
1050
|
+
} else if (event.type === "approval/asked") {
|
|
1051
|
+
state.lastResult = void 0;
|
|
1052
|
+
state.mood = "authorizing";
|
|
1053
|
+
state.holdUntil = 0;
|
|
1054
|
+
} else if (event.type === "approval/decided") {
|
|
1055
|
+
const payload = event.data ?? {};
|
|
1056
|
+
const outcome = payload.outcome ?? payload.result;
|
|
1057
|
+
if (outcome === "allowed-once") {
|
|
1058
|
+
state.lastResult = void 0;
|
|
1059
|
+
state.mood = "waiting";
|
|
1060
|
+
state.holdUntil = 0;
|
|
1061
|
+
} else if (outcome === "rejected" || outcome === "cancelled" || outcome === "unavailable") setTransient(state, "failed", 3e3);
|
|
1062
|
+
} else if (event.type === "activity/status") {
|
|
1063
|
+
const payload = event.data ?? {};
|
|
1064
|
+
if (payload.phase === void 0) return;
|
|
1065
|
+
switch (payload.phase) {
|
|
1066
|
+
case "waiting":
|
|
1067
|
+
case "thinking":
|
|
1068
|
+
state.lastResult = void 0;
|
|
1069
|
+
state.mood = "waiting";
|
|
1070
|
+
state.holdUntil = 0;
|
|
1071
|
+
state.message = void 0;
|
|
1072
|
+
break;
|
|
1073
|
+
case "tool":
|
|
1074
|
+
if (state.questionActive) return;
|
|
1075
|
+
state.lastResult = void 0;
|
|
1076
|
+
state.mood = "jumping";
|
|
1077
|
+
state.holdUntil = 0;
|
|
1078
|
+
break;
|
|
1079
|
+
case "done":
|
|
1080
|
+
setTransient(state, "done", 2500);
|
|
1081
|
+
break;
|
|
1082
|
+
case "idle":
|
|
1083
|
+
if (Date.now() < state.holdUntil) return;
|
|
1084
|
+
state.mood = "idle";
|
|
1085
|
+
state.tool = void 0;
|
|
1086
|
+
state.message = void 0;
|
|
1087
|
+
}
|
|
1088
|
+
} else if (event.type === "turn/end") {
|
|
1089
|
+
state.questionActive = false;
|
|
1090
|
+
const kind = (event.data ?? {}).reason?.kind;
|
|
1091
|
+
if (kind === "error") setTransient(state, "failed", 3e3);
|
|
1092
|
+
else if (kind === "completed") setTransient(state, "done", 2500);
|
|
1093
|
+
else if (kind !== void 0) setTransient(state, "stopped", 3e3);
|
|
1094
|
+
}
|
|
1095
|
+
publish(state);
|
|
1096
|
+
});
|
|
1097
|
+
ctx.on("session/created", () => {
|
|
1098
|
+
scheduleTaskRefresh();
|
|
1099
|
+
});
|
|
1100
|
+
ctx.on("session/disposed", (session) => {
|
|
1101
|
+
sessionStates.delete(String(session.id));
|
|
1102
|
+
scheduleTaskRefresh();
|
|
1103
|
+
});
|
|
1104
|
+
const serializeSession = (sessionId, operation) => {
|
|
1105
|
+
const result = (sessionOperations.get(sessionId) ?? Promise.resolve()).then(operation);
|
|
1106
|
+
const settled = result.then(() => void 0, () => void 0);
|
|
1107
|
+
sessionOperations.set(sessionId, settled);
|
|
1108
|
+
settled.then(() => {
|
|
1109
|
+
if (sessionOperations.get(sessionId) === settled) sessionOperations.delete(sessionId);
|
|
1110
|
+
});
|
|
1111
|
+
return result;
|
|
1112
|
+
};
|
|
1113
|
+
const pendingSessionOpens = /* @__PURE__ */ new Map();
|
|
1114
|
+
const settlePendingSessionOpen = (requestId, accepted) => {
|
|
1115
|
+
const pending = pendingSessionOpens.get(requestId);
|
|
1116
|
+
if (pending === void 0) return false;
|
|
1117
|
+
clearTimeout(pending.timer);
|
|
1118
|
+
pendingSessionOpens.delete(requestId);
|
|
1119
|
+
pending.resolve(accepted);
|
|
1120
|
+
return true;
|
|
1121
|
+
};
|
|
1122
|
+
const requestSessionOpen = (workspaceId, sessionId) => {
|
|
1123
|
+
const requestId = randomUUID();
|
|
1124
|
+
return new Promise((resolve) => {
|
|
1125
|
+
const timer = setTimeout(() => {
|
|
1126
|
+
pendingSessionOpens.delete(requestId);
|
|
1127
|
+
resolve(false);
|
|
1128
|
+
}, 8e3);
|
|
1129
|
+
pendingSessionOpens.set(requestId, {
|
|
1130
|
+
requestId,
|
|
1131
|
+
workspaceId,
|
|
1132
|
+
sessionId,
|
|
1133
|
+
timer,
|
|
1134
|
+
resolve
|
|
1135
|
+
});
|
|
1136
|
+
});
|
|
1137
|
+
};
|
|
1138
|
+
const optionalService = (name) => ctx.get?.(name, false);
|
|
1139
|
+
const requireWorkspaceSession = (workspaceId, sessionId) => {
|
|
1140
|
+
const workspace = ctx.workspaceRegistry.get(workspaceId);
|
|
1141
|
+
if (workspace === void 0 || !workspace.sessionIds.some((candidate) => String(candidate) === sessionId)) throw new Error(`session "${sessionId}" is not attached to workspace "${workspaceId}"`);
|
|
1142
|
+
};
|
|
1143
|
+
const resolveAgent = async (sessionId) => {
|
|
1144
|
+
const controller = ctx.sessionController;
|
|
1145
|
+
if (typeof controller.resolveAgent !== "function") throw new Error("Harness agent resolution is unavailable");
|
|
1146
|
+
const result = await controller.resolveAgent(sessionId);
|
|
1147
|
+
if (result.error !== void 0) throw result.error;
|
|
1148
|
+
if (result.agent === void 0) throw new Error("Harness agent is unavailable");
|
|
1149
|
+
return result.agent;
|
|
1150
|
+
};
|
|
1151
|
+
const optionalCapabilities = () => {
|
|
1152
|
+
const resolveAvailable = typeof ctx.sessionController.resolveAgent === "function";
|
|
1153
|
+
const files = optionalService("sessionFileReferences");
|
|
1154
|
+
const skills = optionalService("sessionSkillCatalog");
|
|
1155
|
+
const commands = optionalService("commands");
|
|
1156
|
+
return [
|
|
1157
|
+
...resolveAvailable && typeof files?.list === "function" ? ["fileCandidates"] : [],
|
|
1158
|
+
...resolveAvailable && typeof skills?.list === "function" && typeof commands?.list === "function" ? ["skillCandidates"] : [],
|
|
1159
|
+
...typeof ctx.sessionController.projections === "function" ? ["taskPreview"] : []
|
|
1160
|
+
];
|
|
1161
|
+
};
|
|
1162
|
+
commandBridge = new CommandBridge({
|
|
1163
|
+
imageAttachmentLimits: ctx.attachments.imageLimits,
|
|
1164
|
+
listWorkspaces: async () => Promise.all(ctx.workspaceRegistry.list().map(async (workspace) => ({
|
|
1165
|
+
id: String(workspace.id),
|
|
1166
|
+
title: workspace.title,
|
|
1167
|
+
path: workspace.path,
|
|
1168
|
+
status: await workspace.status(),
|
|
1169
|
+
sessionIds: workspace.sessionIds.map(String)
|
|
1170
|
+
}))),
|
|
1171
|
+
listTasks: async () => {
|
|
1172
|
+
if (refreshTaskSnapshot !== void 0) return refreshTaskSnapshot();
|
|
1173
|
+
return taskSnapshot;
|
|
1174
|
+
},
|
|
1175
|
+
createSession: (request) => serializeSession(request.sessionId, async () => {
|
|
1176
|
+
const workspace = ctx.workspaceRegistry.get(request.workspaceId);
|
|
1177
|
+
if (workspace === void 0) throw new Error(`workspace "${request.workspaceId}" not found`);
|
|
1178
|
+
const result = await ctx.sessionController.create({
|
|
1179
|
+
workspaceId: workspace.id,
|
|
1180
|
+
sessionId: request.sessionId
|
|
1181
|
+
});
|
|
1182
|
+
stateFor(String(result.sessionId), String(workspace.id)).workspaceId = String(workspace.id);
|
|
1183
|
+
return { sessionId: String(result.sessionId) };
|
|
1184
|
+
}),
|
|
1185
|
+
openSession: async (request) => {
|
|
1186
|
+
const workspace = ctx.workspaceRegistry.get(request.workspaceId);
|
|
1187
|
+
if (workspace === void 0) throw new Error(`workspace "${request.workspaceId}" not found`);
|
|
1188
|
+
if (!workspace.sessionIds.some((candidate) => String(candidate) === request.sessionId)) throw new Error(`session "${request.sessionId}" is not attached to workspace "${request.workspaceId}"`);
|
|
1189
|
+
if (!await requestSessionOpen(request.workspaceId, request.sessionId)) {
|
|
1190
|
+
const error = /* @__PURE__ */ new Error("Harness client did not acknowledge the session open");
|
|
1191
|
+
Object.assign(error, { code: "desktop-client-unavailable" });
|
|
1192
|
+
throw error;
|
|
1193
|
+
}
|
|
1194
|
+
return {
|
|
1195
|
+
accepted: true,
|
|
1196
|
+
sessionId: request.sessionId
|
|
1197
|
+
};
|
|
1198
|
+
},
|
|
1199
|
+
prompt: (request) => serializeSession(request.sessionId, async () => {
|
|
1200
|
+
const workspace = ctx.workspaceRegistry.get(request.workspaceId);
|
|
1201
|
+
if (workspace === void 0) throw new Error(`workspace "${request.workspaceId}" not found`);
|
|
1202
|
+
if (!workspace.sessionIds.some((candidate) => String(candidate) === request.sessionId)) throw new Error(`session "${request.sessionId}" is not attached to workspace "${request.workspaceId}"`);
|
|
1203
|
+
const content = [...request.text.trim() === "" ? [] : [{
|
|
1204
|
+
type: "text",
|
|
1205
|
+
text: request.text
|
|
1206
|
+
}], ...request.images.map((image) => ({
|
|
1207
|
+
type: "image",
|
|
1208
|
+
...image
|
|
1209
|
+
}))];
|
|
1210
|
+
await ctx.sessionController.prompt({
|
|
1211
|
+
requestId: request.requestId,
|
|
1212
|
+
sessionId: request.sessionId,
|
|
1213
|
+
mode: "queue",
|
|
1214
|
+
content
|
|
1215
|
+
}, AbortSignal.timeout(3e4));
|
|
1216
|
+
return {
|
|
1217
|
+
accepted: true,
|
|
1218
|
+
sessionId: request.sessionId
|
|
1219
|
+
};
|
|
1220
|
+
}),
|
|
1221
|
+
capabilities: optionalCapabilities,
|
|
1222
|
+
fileCandidates: async ({ workspaceId, sessionId, query }) => {
|
|
1223
|
+
requireWorkspaceSession(workspaceId, sessionId);
|
|
1224
|
+
const files = optionalService("sessionFileReferences");
|
|
1225
|
+
if (files === void 0) throw new Error("Harness file reference service is unavailable");
|
|
1226
|
+
return { candidates: await files.list(await resolveAgent(sessionId), query, AbortSignal.timeout(15e3)) };
|
|
1227
|
+
},
|
|
1228
|
+
skillCandidates: async ({ workspaceId, sessionId }) => {
|
|
1229
|
+
requireWorkspaceSession(workspaceId, sessionId);
|
|
1230
|
+
const catalog = optionalService("sessionSkillCatalog");
|
|
1231
|
+
const commands = optionalService("commands");
|
|
1232
|
+
if (catalog === void 0 || commands === void 0) throw new Error("Harness skill or command service is unavailable");
|
|
1233
|
+
const agent = await resolveAgent(sessionId);
|
|
1234
|
+
const [listed, commandList] = await Promise.all([catalog.list({ sessionId }, AbortSignal.timeout(15e3)), Promise.resolve(commands.list(agent))]);
|
|
1235
|
+
const names = new Set(commandList.map((command) => command.name));
|
|
1236
|
+
return { skills: listed.skills.map((skill) => ({
|
|
1237
|
+
name: skill.name,
|
|
1238
|
+
description: skill.description,
|
|
1239
|
+
conflict: names.has(skill.name)
|
|
1240
|
+
})) };
|
|
1241
|
+
},
|
|
1242
|
+
taskPreview: async ({ workspaceId, sessionId }) => {
|
|
1243
|
+
requireWorkspaceSession(workspaceId, sessionId);
|
|
1244
|
+
const result = await ctx.sessionController.projections({ sessionId }, AbortSignal.timeout(15e3));
|
|
1245
|
+
if (result?.kind === "migration-required") return { status: "migration-required" };
|
|
1246
|
+
const outline = result?.values?.turnOutline;
|
|
1247
|
+
if (!Array.isArray(outline)) return { status: "unsupported" };
|
|
1248
|
+
const last = outline.at(-1);
|
|
1249
|
+
if (typeof last?.response !== "string" || last.response.trim() === "") return { status: "empty" };
|
|
1250
|
+
return {
|
|
1251
|
+
status: "available",
|
|
1252
|
+
text: last.response
|
|
1253
|
+
};
|
|
1254
|
+
},
|
|
1255
|
+
snapshotFor: async (sessionId) => {
|
|
1256
|
+
const snapshot = snapshotForSession(sessionId);
|
|
1257
|
+
let task;
|
|
1258
|
+
try {
|
|
1259
|
+
task = (await refreshTaskSnapshot?.(5e3))?.find((candidate) => candidate.sessionId === sessionId);
|
|
1260
|
+
} catch (error) {
|
|
1261
|
+
console.warn(`[dsh-pet] unable to confirm session state: ${String(error)}`);
|
|
1262
|
+
}
|
|
1263
|
+
if (task !== void 0) return snapshotForTask(task);
|
|
1264
|
+
unconfirmedSessions.add(sessionId);
|
|
1265
|
+
return {
|
|
1266
|
+
...snapshot,
|
|
1267
|
+
taskRunning: true,
|
|
1268
|
+
message: "无法确认任务状态,暂不可发送"
|
|
1269
|
+
};
|
|
1270
|
+
}
|
|
1271
|
+
}, instance.commandSocketPath, instance);
|
|
1272
|
+
refreshTaskSnapshot = async (timeoutMs = 15e3) => {
|
|
1273
|
+
const listed = await ctx.sessionController.list({}, AbortSignal.timeout(timeoutMs));
|
|
1274
|
+
taskSnapshot = tasksFromSummaries(listed.items);
|
|
1275
|
+
commandBridge?.publishTasks(taskSnapshot);
|
|
1276
|
+
for (const task of taskSnapshot) if (unconfirmedSessions.delete(task.sessionId)) commandBridge?.publish(task.sessionId, snapshotForTask(task));
|
|
1277
|
+
return taskSnapshot;
|
|
1278
|
+
};
|
|
1279
|
+
ctx.effect(() => {
|
|
1280
|
+
const dispose = optionalService("sessionProjections")?.onChanged?.((session, key) => {
|
|
1281
|
+
if (key === "turnOutline") commandBridge?.publishTaskPreviewChange(String(session.id));
|
|
1282
|
+
});
|
|
1283
|
+
return () => {
|
|
1284
|
+
dispose?.();
|
|
1285
|
+
};
|
|
1286
|
+
}, "dsh-pet: reply projection updates");
|
|
1287
|
+
ctx.effect(() => {
|
|
1288
|
+
statusReady = false;
|
|
1289
|
+
commandReady = false;
|
|
1290
|
+
localBridge.start(() => {
|
|
1291
|
+
statusReady = true;
|
|
1292
|
+
publishInstanceManifest();
|
|
1293
|
+
});
|
|
1294
|
+
commandBridge?.start(() => {
|
|
1295
|
+
commandReady = true;
|
|
1296
|
+
publishInstanceManifest();
|
|
1297
|
+
});
|
|
1298
|
+
refreshTaskSnapshot?.().catch((error) => {
|
|
1299
|
+
console.warn(`[dsh-pet] initial task list unavailable: ${String(error)}`);
|
|
1300
|
+
});
|
|
1301
|
+
return () => {
|
|
1302
|
+
if (taskRefreshTimer !== void 0) clearTimeout(taskRefreshTimer);
|
|
1303
|
+
taskRefreshTimer = void 0;
|
|
1304
|
+
commandBridge?.stop();
|
|
1305
|
+
localBridge.stop();
|
|
1306
|
+
if (originRetryTimer !== void 0) clearTimeout(originRetryTimer);
|
|
1307
|
+
originRetryTimer = void 0;
|
|
1308
|
+
instanceManifest.remove();
|
|
1309
|
+
instanceManifestPublished = false;
|
|
1310
|
+
statusReady = false;
|
|
1311
|
+
commandReady = false;
|
|
1312
|
+
};
|
|
1313
|
+
}, "dsh-pet: local bridges");
|
|
1314
|
+
const sessionOpenPendingRoute = {
|
|
1315
|
+
kind: "exact",
|
|
1316
|
+
path: "/api/dsh-pet/session-open/pending",
|
|
1317
|
+
handler: (req, res) => {
|
|
1318
|
+
if (req.method !== "GET") {
|
|
1319
|
+
json(res, 405, {
|
|
1320
|
+
ok: false,
|
|
1321
|
+
error: "method-not-allowed"
|
|
1322
|
+
});
|
|
1323
|
+
return;
|
|
1324
|
+
}
|
|
1325
|
+
const now = Date.now();
|
|
1326
|
+
const pending = [...pendingSessionOpens.values()].find((candidate) => candidate.deliveredAt === void 0 || now - candidate.deliveredAt > 2e3);
|
|
1327
|
+
if (pending === void 0) {
|
|
1328
|
+
json(res, 200, { request: null });
|
|
1329
|
+
return;
|
|
1330
|
+
}
|
|
1331
|
+
pending.deliveredAt = now;
|
|
1332
|
+
json(res, 200, { request: {
|
|
1333
|
+
requestId: pending.requestId,
|
|
1334
|
+
workspaceId: pending.workspaceId,
|
|
1335
|
+
sessionId: pending.sessionId
|
|
1336
|
+
} });
|
|
1337
|
+
}
|
|
1338
|
+
};
|
|
1339
|
+
const sessionOpenAckRoute = {
|
|
1340
|
+
kind: "exact",
|
|
1341
|
+
path: "/api/dsh-pet/session-open/ack",
|
|
1342
|
+
handler: async (req, res) => {
|
|
1343
|
+
if (req.method !== "POST") {
|
|
1344
|
+
json(res, 405, {
|
|
1345
|
+
ok: false,
|
|
1346
|
+
error: "method-not-allowed"
|
|
1347
|
+
});
|
|
1348
|
+
return;
|
|
1349
|
+
}
|
|
1350
|
+
let body = "";
|
|
1351
|
+
for await (const chunk of req) {
|
|
1352
|
+
body += String(chunk);
|
|
1353
|
+
if (Buffer.byteLength(body) > 4096) {
|
|
1354
|
+
json(res, 413, {
|
|
1355
|
+
ok: false,
|
|
1356
|
+
error: "request-too-large"
|
|
1357
|
+
});
|
|
1358
|
+
return;
|
|
1359
|
+
}
|
|
1360
|
+
}
|
|
1361
|
+
let requestId;
|
|
1362
|
+
try {
|
|
1363
|
+
requestId = JSON.parse(body).requestId;
|
|
1364
|
+
} catch {
|
|
1365
|
+
requestId = void 0;
|
|
1366
|
+
}
|
|
1367
|
+
if (typeof requestId !== "string" || requestId === "") {
|
|
1368
|
+
json(res, 400, {
|
|
1369
|
+
ok: false,
|
|
1370
|
+
error: "invalid-request"
|
|
1371
|
+
});
|
|
1372
|
+
return;
|
|
1373
|
+
}
|
|
1374
|
+
if (!settlePendingSessionOpen(requestId, true)) {
|
|
1375
|
+
json(res, 404, {
|
|
1376
|
+
ok: false,
|
|
1377
|
+
error: "unknown-request"
|
|
1378
|
+
});
|
|
1379
|
+
return;
|
|
1380
|
+
}
|
|
1381
|
+
json(res, 200, { ok: true });
|
|
1382
|
+
}
|
|
1383
|
+
};
|
|
1384
|
+
const statusRoute = {
|
|
1385
|
+
kind: "exact",
|
|
1386
|
+
path: "/api/dsh-pet/status",
|
|
1387
|
+
handler: (req, res) => {
|
|
1388
|
+
if (req.method !== "GET") {
|
|
1389
|
+
json(res, 405, {
|
|
1390
|
+
ok: false,
|
|
1391
|
+
error: "method-not-allowed"
|
|
1392
|
+
});
|
|
1393
|
+
return;
|
|
1394
|
+
}
|
|
1395
|
+
json(res, 200, {
|
|
1396
|
+
ok: true,
|
|
1397
|
+
enabled: true,
|
|
1398
|
+
...snapshot()
|
|
1399
|
+
});
|
|
1400
|
+
}
|
|
1401
|
+
};
|
|
1402
|
+
let installationCheck;
|
|
1403
|
+
const installationRoute = {
|
|
1404
|
+
kind: "exact",
|
|
1405
|
+
path: "/api/dsh-pet/install-status",
|
|
1406
|
+
handler: async (req, res) => {
|
|
1407
|
+
let connection;
|
|
1408
|
+
try {
|
|
1409
|
+
connection = ctx.get("connection");
|
|
1410
|
+
} catch {
|
|
1411
|
+
json(res, 503, { status: "unknown" });
|
|
1412
|
+
return;
|
|
1413
|
+
}
|
|
1414
|
+
if (typeof connection?.requestRejection !== "function") {
|
|
1415
|
+
json(res, 503, { status: "unknown" });
|
|
1416
|
+
return;
|
|
1417
|
+
}
|
|
1418
|
+
let rejection;
|
|
1419
|
+
try {
|
|
1420
|
+
rejection = connection.requestRejection(req);
|
|
1421
|
+
} catch {
|
|
1422
|
+
json(res, 503, { status: "unknown" });
|
|
1423
|
+
return;
|
|
1424
|
+
}
|
|
1425
|
+
if (rejection !== void 0) {
|
|
1426
|
+
res.writeHead(rejection);
|
|
1427
|
+
res.end();
|
|
1428
|
+
return;
|
|
1429
|
+
}
|
|
1430
|
+
if (req.method !== "GET") {
|
|
1431
|
+
json(res, 405, {
|
|
1432
|
+
ok: false,
|
|
1433
|
+
error: "method-not-allowed"
|
|
1434
|
+
});
|
|
1435
|
+
return;
|
|
1436
|
+
}
|
|
1437
|
+
installationCheck ??= detectHostApp().finally(() => {
|
|
1438
|
+
installationCheck = void 0;
|
|
1439
|
+
});
|
|
1440
|
+
const status = await installationCheck;
|
|
1441
|
+
res.setHeader("cache-control", "no-store");
|
|
1442
|
+
json(res, 200, { status });
|
|
1443
|
+
}
|
|
1444
|
+
};
|
|
1445
|
+
const legacyStatusRoute = {
|
|
1446
|
+
...statusRoute,
|
|
1447
|
+
path: "/api/moodball/status"
|
|
1448
|
+
};
|
|
1449
|
+
ctx.effect(() => {
|
|
1450
|
+
const dispose = ctx.webServer.register(statusRoute);
|
|
1451
|
+
const disposeInstallation = ctx.webServer.register(installationRoute);
|
|
1452
|
+
const disposeLegacy = ctx.webServer.register(legacyStatusRoute);
|
|
1453
|
+
const disposePending = ctx.webServer.register(sessionOpenPendingRoute);
|
|
1454
|
+
const disposeAck = ctx.webServer.register(sessionOpenAckRoute);
|
|
1455
|
+
return () => {
|
|
1456
|
+
disposeAck();
|
|
1457
|
+
disposePending();
|
|
1458
|
+
for (const pending of pendingSessionOpens.values()) {
|
|
1459
|
+
clearTimeout(pending.timer);
|
|
1460
|
+
pending.resolve(false);
|
|
1461
|
+
}
|
|
1462
|
+
pendingSessionOpens.clear();
|
|
1463
|
+
disposeLegacy();
|
|
1464
|
+
disposeInstallation();
|
|
1465
|
+
dispose();
|
|
1466
|
+
};
|
|
1467
|
+
}, "dsh-pet: status and session-open routes");
|
|
1468
|
+
}
|
|
1469
|
+
//#endregion
|
|
1470
|
+
export { CommandBridge, HarnessInstanceManifest, LocalStateBridge, apply, createHarnessInstanceDescriptor, inject, instanceDiscoveryDirectory, instanceManifestPath, name, refreshHarnessInstanceWebURL };
|