@xmanrui/dsh-im 0.7.0 → 0.7.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/lib/index.js +119 -121
- package/package.json +1 -1
- package/src/channels/dingtalk/dingtalk-bridge.mjs +423 -8
- package/src/channels/dingtalk/harness-client.mjs +16 -366
- package/src/channels/discord/discord-api.mjs +1 -1
- package/src/channels/discord/discord-runtime.mjs +11 -1
- package/src/channels/discord/harness-client.mjs +10 -2
- package/src/channels/feishu/bridge.mjs +571 -50
- package/src/channels/feishu/feishu-runtime.mjs +41 -1
- package/src/channels/feishu/harness-client.mjs +16 -335
- package/src/channels/qq/harness-client.mjs +10 -2
- package/src/channels/qq/qq-bridge.mjs +428 -28
- package/src/channels/qq/qq-runtime.mjs +14 -3
- package/src/channels/shared/harness-approval.mjs +472 -0
- package/src/channels/shared/harness-client.mjs +858 -0
- package/src/channels/shared/harness-question.mjs +85 -0
- package/src/channels/shared/text-harness-bridge.mjs +486 -24
- package/src/channels/slack/harness-client.mjs +10 -2
- package/src/channels/slack/slack-runtime.mjs +11 -1
- package/src/channels/telegram/harness-client.mjs +10 -2
- package/src/channels/telegram/telegram-runtime.mjs +15 -4
- package/src/channels/wecom/harness-client.mjs +10 -2
- package/src/channels/wecom/wecom-bridge.mjs +434 -14
- package/src/channels/wecom/wecom-runtime.mjs +6 -0
- package/src/channels/weixin/harness-client.mjs +16 -326
- package/src/channels/weixin/weixin-api.mjs +1 -1
- package/src/channels/weixin/weixin-bridge.mjs +451 -22
- package/src/channels/weixin/weixin-runtime.mjs +56 -7
- package/src/channels/whatsapp/harness-client.mjs +10 -2
- package/src/channels/whatsapp/whatsapp-runtime.mjs +1 -0
|
@@ -0,0 +1,858 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { isAbsolute } from 'node:path';
|
|
4
|
+
|
|
5
|
+
import { adoptRegisteredWorkspaceSession } from './harness-session-binding.mjs';
|
|
6
|
+
|
|
7
|
+
// Every channel plugin runs in the same Host process. Sharing ownership by
|
|
8
|
+
// Harness origin prevents two channel-specific clients bound to one Session
|
|
9
|
+
// from claiming or cancelling each other's interactions.
|
|
10
|
+
const interactionRegistries = new Map();
|
|
11
|
+
|
|
12
|
+
function interactionRegistry(origin) {
|
|
13
|
+
let registry = interactionRegistries.get(origin);
|
|
14
|
+
if (!registry) {
|
|
15
|
+
registry = { ownerships: new Map(), claims: new Map(), nextOrder: 0 };
|
|
16
|
+
interactionRegistries.set(origin, registry);
|
|
17
|
+
}
|
|
18
|
+
return registry;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function workspacePaths(value) {
|
|
22
|
+
if (!Array.isArray(value?.items)) return [];
|
|
23
|
+
return value.items.flatMap((item) => (
|
|
24
|
+
typeof item?.path === 'string' && isAbsolute(item.path) ? [item.path] : []
|
|
25
|
+
));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function workspaceFromList(workspacePath, workspaceList) {
|
|
29
|
+
if (!Array.isArray(workspaceList?.items)
|
|
30
|
+
|| !Array.isArray(workspaceList?.archivedSessionIds)) {
|
|
31
|
+
throw new Error('Harness returned an invalid response for workspace.list');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const workspace = workspaceList.items.find((item) => item?.path === workspacePath);
|
|
35
|
+
if (!workspace) return null;
|
|
36
|
+
if (!Array.isArray(workspace.sessionIds)
|
|
37
|
+
|| workspace.sessionIds.some((sessionId) => typeof sessionId !== 'string')) {
|
|
38
|
+
throw new Error('Harness returned invalid session IDs for workspace.list');
|
|
39
|
+
}
|
|
40
|
+
return workspace;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function workspaceSessions(workspace, archivedSessionIds, sessionList) {
|
|
44
|
+
if (!Array.isArray(sessionList?.items)) {
|
|
45
|
+
throw new Error('Harness returned an invalid response for session.list');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const archived = new Set(archivedSessionIds);
|
|
49
|
+
const summaries = new Map(sessionList.items.flatMap((item) => (
|
|
50
|
+
typeof item?.sessionId === 'string' ? [[item.sessionId, item]] : []
|
|
51
|
+
)));
|
|
52
|
+
return {
|
|
53
|
+
workspace: workspace.path,
|
|
54
|
+
sessions: workspace.sessionIds.map((sessionId) => {
|
|
55
|
+
const summary = summaries.get(sessionId);
|
|
56
|
+
const title = summary?.projections?.values?.title;
|
|
57
|
+
return {
|
|
58
|
+
sessionId,
|
|
59
|
+
title: typeof title === 'string' ? title : null,
|
|
60
|
+
archived: archived.has(sessionId),
|
|
61
|
+
blank: summary?.blank === true,
|
|
62
|
+
origin: summary?.origin === 'subagent' ? 'subagent' : null,
|
|
63
|
+
summaryAvailable: summary !== undefined,
|
|
64
|
+
};
|
|
65
|
+
}),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function sleep(ms, signal) {
|
|
70
|
+
return new Promise((resolve, reject) => {
|
|
71
|
+
if (signal?.aborted) {
|
|
72
|
+
reject(signal.reason ?? new DOMException('Aborted', 'AbortError'));
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
const timer = setTimeout(() => {
|
|
76
|
+
signal?.removeEventListener('abort', onAbort);
|
|
77
|
+
resolve();
|
|
78
|
+
}, ms);
|
|
79
|
+
const onAbort = () => {
|
|
80
|
+
clearTimeout(timer);
|
|
81
|
+
reject(signal.reason ?? new DOMException('Aborted', 'AbortError'));
|
|
82
|
+
};
|
|
83
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function assistantMessageText(event) {
|
|
88
|
+
return (event?.data?.message?.content ?? [])
|
|
89
|
+
.filter((part) => part.type === 'text' && typeof part.text === 'string')
|
|
90
|
+
.map((part) => part.text)
|
|
91
|
+
.join('\n')
|
|
92
|
+
.trim();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function consumeInteractionOwnership(ownership, entries) {
|
|
96
|
+
const ordered = [...entries]
|
|
97
|
+
.map((entry) => entry?.event ?? entry)
|
|
98
|
+
.filter(Boolean)
|
|
99
|
+
.sort((left, right) => (left.seq ?? -1) - (right.seq ?? -1));
|
|
100
|
+
|
|
101
|
+
for (const event of ordered) {
|
|
102
|
+
const seq = event.seq ?? -1;
|
|
103
|
+
if (seq <= ownership.lastSeq) continue;
|
|
104
|
+
ownership.lastSeq = seq;
|
|
105
|
+
|
|
106
|
+
if (event.type === 'turn/start') {
|
|
107
|
+
const turn = event.data?.turn ?? null;
|
|
108
|
+
if (ownership.active && turn !== ownership.turn) ownership.active = false;
|
|
109
|
+
if (ownership.turn !== null && turn !== ownership.turn) ownership.completed = true;
|
|
110
|
+
ownership.openTurn = turn;
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
if (event.type === 'user/message' && event.data?.source?.rpcId === ownership.promptRpcId) {
|
|
114
|
+
ownership.active = true;
|
|
115
|
+
ownership.started = true;
|
|
116
|
+
ownership.completed = false;
|
|
117
|
+
ownership.turn = event.data?.turn ?? ownership.openTurn;
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
if (event.type === 'turn/end' && event.data?.turn === ownership.turn) {
|
|
121
|
+
ownership.active = false;
|
|
122
|
+
ownership.completed = true;
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
let toolCall = null;
|
|
126
|
+
if (event.type === 'tool/call'
|
|
127
|
+
&& ownership.active
|
|
128
|
+
&& event.data?.turn === ownership.turn
|
|
129
|
+
&& typeof event.data?.callId === 'string'
|
|
130
|
+
&& event.data.callId) {
|
|
131
|
+
toolCall = {
|
|
132
|
+
callId: event.data.callId,
|
|
133
|
+
name: event.data?.name,
|
|
134
|
+
arguments: event.data?.arguments,
|
|
135
|
+
};
|
|
136
|
+
} else if (event.type === 'tool/code-dispatch-start'
|
|
137
|
+
&& ownership.active
|
|
138
|
+
&& typeof event.data?.subCallId === 'string'
|
|
139
|
+
&& event.data.subCallId) {
|
|
140
|
+
let argumentsText;
|
|
141
|
+
try {
|
|
142
|
+
argumentsText = JSON.stringify(event.data?.arguments);
|
|
143
|
+
} catch {
|
|
144
|
+
argumentsText = undefined;
|
|
145
|
+
}
|
|
146
|
+
toolCall = {
|
|
147
|
+
callId: event.data.subCallId,
|
|
148
|
+
name: event.data?.name,
|
|
149
|
+
arguments: argumentsText,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
if (toolCall) ownership.toolCalls.set(toolCall.callId, Object.freeze(toolCall));
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export class HarnessReplyTracker {
|
|
157
|
+
#promptRpcId;
|
|
158
|
+
#lastSeq;
|
|
159
|
+
#openTurn = null;
|
|
160
|
+
#targetTurn = null;
|
|
161
|
+
#stepText = new Map();
|
|
162
|
+
#latestText = '';
|
|
163
|
+
#finished = false;
|
|
164
|
+
#reason = null;
|
|
165
|
+
|
|
166
|
+
constructor({ promptRpcId, afterSeq = -1 }) {
|
|
167
|
+
this.#promptRpcId = promptRpcId;
|
|
168
|
+
this.#lastSeq = afterSeq;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
get finished() {
|
|
172
|
+
return this.#finished;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
get answer() {
|
|
176
|
+
return this.#latestText.trim();
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
get reason() {
|
|
180
|
+
return this.#reason;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
get tracking() {
|
|
184
|
+
return this.#targetTurn !== null && !this.#finished;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
get turn() {
|
|
188
|
+
return this.#targetTurn;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
consume(entries) {
|
|
192
|
+
let update = null;
|
|
193
|
+
const ordered = [...entries]
|
|
194
|
+
.map((entry) => entry?.event ?? entry)
|
|
195
|
+
.filter(Boolean)
|
|
196
|
+
.sort((left, right) => (left.seq ?? -1) - (right.seq ?? -1));
|
|
197
|
+
|
|
198
|
+
for (const event of ordered) {
|
|
199
|
+
const seq = event.seq ?? -1;
|
|
200
|
+
if (seq <= this.#lastSeq) continue;
|
|
201
|
+
this.#lastSeq = seq;
|
|
202
|
+
|
|
203
|
+
if (event.type === 'turn/start') this.#openTurn = event.data?.turn ?? null;
|
|
204
|
+
|
|
205
|
+
if (event.type === 'user/message' && event.data?.source?.rpcId === this.#promptRpcId) {
|
|
206
|
+
this.#targetTurn = this.#openTurn;
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
if (this.#targetTurn === null) continue;
|
|
210
|
+
|
|
211
|
+
if (event.type === 'turn/end') {
|
|
212
|
+
if (event.data?.turn !== this.#targetTurn) continue;
|
|
213
|
+
this.#finished = true;
|
|
214
|
+
this.#reason = event.data?.reason ?? null;
|
|
215
|
+
this.#openTurn = null;
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
if (event.data?.turn !== this.#targetTurn) continue;
|
|
219
|
+
|
|
220
|
+
if (event.type === 'assistant/chunk' && event.data?.chunk?.type === 'text-delta') {
|
|
221
|
+
const step = event.data?.step ?? 0;
|
|
222
|
+
const index = event.data.chunk.index ?? 0;
|
|
223
|
+
const key = `${step}:${index}`;
|
|
224
|
+
this.#stepText.set(key, (this.#stepText.get(key) ?? '') + event.data.chunk.text);
|
|
225
|
+
const prefix = `${step}:`;
|
|
226
|
+
const text = [...this.#stepText.entries()]
|
|
227
|
+
.filter(([partKey]) => partKey.startsWith(prefix))
|
|
228
|
+
.sort(([left], [right]) => Number(left.split(':')[1]) - Number(right.split(':')[1]))
|
|
229
|
+
.map(([, part]) => part)
|
|
230
|
+
.join('\n')
|
|
231
|
+
.trim();
|
|
232
|
+
if (text && text !== this.#latestText) {
|
|
233
|
+
this.#latestText = text;
|
|
234
|
+
update = { type: 'text', text };
|
|
235
|
+
}
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (event.type === 'assistant/message') {
|
|
240
|
+
const text = assistantMessageText(event);
|
|
241
|
+
if (text && text !== this.#latestText) {
|
|
242
|
+
this.#latestText = text;
|
|
243
|
+
update = { type: 'text', text };
|
|
244
|
+
}
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (event.type === 'tool/call') {
|
|
249
|
+
update = { type: 'tool', name: event.data?.name ?? '工具' };
|
|
250
|
+
} else if (event.type === 'tool/result') {
|
|
251
|
+
update = { type: 'status', text: '正在整理结果…' };
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
return update;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export class HarnessRpcError extends Error {
|
|
259
|
+
constructor(method, error) {
|
|
260
|
+
super(`${method}: ${error?.message ?? 'unknown Harness RPC error'}`);
|
|
261
|
+
this.name = 'HarnessRpcError';
|
|
262
|
+
this.method = method;
|
|
263
|
+
this.code = error?.code ?? 'internal';
|
|
264
|
+
this.details = error?.details ?? {};
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export class HarnessInteractionError extends Error {
|
|
269
|
+
constructor(code, message) {
|
|
270
|
+
super(message);
|
|
271
|
+
this.name = 'HarnessInteractionError';
|
|
272
|
+
this.code = code;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
export class HarnessClient {
|
|
277
|
+
#baseUrl;
|
|
278
|
+
#workspace;
|
|
279
|
+
#agentPreset;
|
|
280
|
+
#autostart;
|
|
281
|
+
#dshBin;
|
|
282
|
+
#fetch;
|
|
283
|
+
#createWebSocket;
|
|
284
|
+
#interactionReconnectDelayMs;
|
|
285
|
+
#rpcIdPrefix;
|
|
286
|
+
#logPrefix;
|
|
287
|
+
#managedProcess = null;
|
|
288
|
+
#interactionRegistry;
|
|
289
|
+
#interactionOwnerships;
|
|
290
|
+
#interactionClaims;
|
|
291
|
+
|
|
292
|
+
constructor({
|
|
293
|
+
baseUrl,
|
|
294
|
+
workspace,
|
|
295
|
+
agentPreset = 'standard',
|
|
296
|
+
autostart = false,
|
|
297
|
+
dshBin = 'dsh',
|
|
298
|
+
fetchImpl = fetch,
|
|
299
|
+
createWebSocket = (url) => new WebSocket(url),
|
|
300
|
+
interactionReconnectDelayMs = 500,
|
|
301
|
+
rpcIdPrefix = 'im',
|
|
302
|
+
logPrefix = 'dsh-im',
|
|
303
|
+
}) {
|
|
304
|
+
if (typeof createWebSocket !== 'function') {
|
|
305
|
+
throw new TypeError('createWebSocket must be a function');
|
|
306
|
+
}
|
|
307
|
+
if (!Number.isFinite(interactionReconnectDelayMs) || interactionReconnectDelayMs < 0) {
|
|
308
|
+
throw new TypeError('interactionReconnectDelayMs must be a non-negative number');
|
|
309
|
+
}
|
|
310
|
+
if (typeof rpcIdPrefix !== 'string' || !rpcIdPrefix.trim()) {
|
|
311
|
+
throw new TypeError('rpcIdPrefix must be a non-empty string');
|
|
312
|
+
}
|
|
313
|
+
if (typeof logPrefix !== 'string' || !logPrefix.trim()) {
|
|
314
|
+
throw new TypeError('logPrefix must be a non-empty string');
|
|
315
|
+
}
|
|
316
|
+
this.#baseUrl = new URL(baseUrl);
|
|
317
|
+
this.#workspace = workspace;
|
|
318
|
+
this.#agentPreset = agentPreset;
|
|
319
|
+
this.#autostart = autostart;
|
|
320
|
+
this.#dshBin = dshBin;
|
|
321
|
+
this.#fetch = fetchImpl;
|
|
322
|
+
this.#createWebSocket = createWebSocket;
|
|
323
|
+
this.#interactionReconnectDelayMs = interactionReconnectDelayMs;
|
|
324
|
+
this.#rpcIdPrefix = rpcIdPrefix.trim();
|
|
325
|
+
this.#logPrefix = logPrefix.trim();
|
|
326
|
+
this.#interactionRegistry = interactionRegistry(this.#baseUrl.origin);
|
|
327
|
+
this.#interactionOwnerships = this.#interactionRegistry.ownerships;
|
|
328
|
+
this.#interactionClaims = this.#interactionRegistry.claims;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
async rpc(method, payload = {}, timeoutMs = 30_000, options = {}) {
|
|
332
|
+
const rpcId = options.rpcId ?? `${this.#rpcIdPrefix}-${randomUUID()}`;
|
|
333
|
+
const timeoutSignal = AbortSignal.timeout(timeoutMs);
|
|
334
|
+
const signal = options.signal
|
|
335
|
+
? AbortSignal.any([options.signal, timeoutSignal])
|
|
336
|
+
: timeoutSignal;
|
|
337
|
+
const response = await this.#fetch(new URL(`/api/${method}`, this.#baseUrl), {
|
|
338
|
+
method: 'POST',
|
|
339
|
+
headers: { 'content-type': 'application/json' },
|
|
340
|
+
body: JSON.stringify({ type: 'client-request', rpcId, method, payload }),
|
|
341
|
+
signal,
|
|
342
|
+
});
|
|
343
|
+
if (!response.ok) throw new Error(`Harness transport ${method} failed: HTTP ${response.status}`);
|
|
344
|
+
const body = await response.json();
|
|
345
|
+
if (body?.type !== 'server-response' || body?.rpcId !== rpcId) {
|
|
346
|
+
throw new Error(`Harness returned an invalid response for ${method}`);
|
|
347
|
+
}
|
|
348
|
+
if (!body.result?.ok) throw new HarnessRpcError(method, body.result?.error);
|
|
349
|
+
return body.result.value;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
async health(options = {}) {
|
|
353
|
+
await this.rpc('host.describe', {}, 5_000, options);
|
|
354
|
+
return true;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
async ensureRunning(options = {}) {
|
|
358
|
+
try {
|
|
359
|
+
return await this.health(options);
|
|
360
|
+
} catch (firstError) {
|
|
361
|
+
if (!this.#autostart) throw firstError;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
if (!this.#managedProcess || this.#managedProcess.exitCode !== null) {
|
|
365
|
+
const port = this.#baseUrl.port || (this.#baseUrl.protocol === 'https:' ? '443' : '80');
|
|
366
|
+
this.#managedProcess = spawn(this.#dshBin, [
|
|
367
|
+
'web', '--host', this.#baseUrl.hostname, '--port', port,
|
|
368
|
+
], {
|
|
369
|
+
cwd: this.#workspace,
|
|
370
|
+
env: process.env,
|
|
371
|
+
stdio: ['ignore', 'inherit', 'inherit'],
|
|
372
|
+
});
|
|
373
|
+
this.#managedProcess.on('error', (error) => {
|
|
374
|
+
console.error(`[${this.#logPrefix}] failed to start Harness:`, error.message);
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const deadline = Date.now() + 60_000;
|
|
379
|
+
let lastError;
|
|
380
|
+
while (Date.now() < deadline) {
|
|
381
|
+
await sleep(1_000, options.signal);
|
|
382
|
+
try {
|
|
383
|
+
return await this.health(options);
|
|
384
|
+
} catch (error) {
|
|
385
|
+
lastError = error;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
throw new Error(`Harness did not become ready: ${lastError?.message ?? 'timeout'}`);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
async listWorkspaces(options = {}) {
|
|
392
|
+
await this.ensureRunning(options);
|
|
393
|
+
return workspacePaths(await this.rpc('workspace.list', {}, 30_000, options));
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
async listWorkspaceSessions(workspacePath, options = {}) {
|
|
397
|
+
await this.ensureRunning(options);
|
|
398
|
+
const workspaceList = await this.rpc('workspace.list', {}, 30_000, options);
|
|
399
|
+
const workspace = workspaceFromList(workspacePath, workspaceList);
|
|
400
|
+
if (!workspace) return { workspace: workspacePath, sessions: [] };
|
|
401
|
+
const sessionList = await this.rpc('session.list', {}, 30_000, options);
|
|
402
|
+
return workspaceSessions(workspace, workspaceList.archivedSessionIds, sessionList);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
async adoptWorkspaceSession(value, options = {}) {
|
|
406
|
+
return adoptRegisteredWorkspaceSession(this, value, options);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
async workspaceId(options = {}) {
|
|
410
|
+
const { workspace = this.#workspace, ...rpcOptions } = options;
|
|
411
|
+
const { items } = await this.rpc('workspace.list', {}, 30_000, rpcOptions);
|
|
412
|
+
const existing = items.find((item) => item.path === workspace);
|
|
413
|
+
if (existing) return existing.workspaceId;
|
|
414
|
+
const created = await this.rpc('workspace.create', { path: workspace }, 30_000, rpcOptions);
|
|
415
|
+
return created.workspace.workspaceId;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
async createSession(options = {}) {
|
|
419
|
+
await this.ensureRunning(options);
|
|
420
|
+
const workspaceId = await this.workspaceId(options);
|
|
421
|
+
const created = await this.rpc('session.create', {
|
|
422
|
+
workspaceId,
|
|
423
|
+
agentPreset: this.#agentPreset,
|
|
424
|
+
}, 30_000, options);
|
|
425
|
+
return created.sessionId;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
async sessionExists(sessionId, options = {}) {
|
|
429
|
+
try {
|
|
430
|
+
await this.rpc('session.history', { sessionId, maxMessages: 1 }, 30_000, options);
|
|
431
|
+
return true;
|
|
432
|
+
} catch (error) {
|
|
433
|
+
if (error instanceof HarnessRpcError && error.code === 'session-not-found') return false;
|
|
434
|
+
throw error;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
async respondInteraction(rpcId, result, options = {}) {
|
|
439
|
+
if (typeof rpcId !== 'string' || !rpcId) throw new TypeError('rpcId is required');
|
|
440
|
+
if (!result || typeof result !== 'object' || typeof result.ok !== 'boolean') {
|
|
441
|
+
throw new TypeError('A Harness RPC result is required');
|
|
442
|
+
}
|
|
443
|
+
const timeoutSignal = AbortSignal.timeout(options.timeoutMs ?? 30_000);
|
|
444
|
+
const signal = options.signal
|
|
445
|
+
? AbortSignal.any([options.signal, timeoutSignal])
|
|
446
|
+
: timeoutSignal;
|
|
447
|
+
const response = await this.#fetch(new URL('/api/respond', this.#baseUrl), {
|
|
448
|
+
method: 'POST',
|
|
449
|
+
headers: { 'content-type': 'application/json' },
|
|
450
|
+
body: JSON.stringify({ type: 'client-response', rpcId, result }),
|
|
451
|
+
signal,
|
|
452
|
+
});
|
|
453
|
+
if (!response.ok) {
|
|
454
|
+
throw new Error(`Harness transport respond failed: HTTP ${response.status}`);
|
|
455
|
+
}
|
|
456
|
+
const receipt = await response.json();
|
|
457
|
+
if (receipt?.accepted === true) return receipt;
|
|
458
|
+
if (receipt?.accepted !== false
|
|
459
|
+
|| (receipt.reason !== 'bad-response' && receipt.reason !== 'not-pending')) {
|
|
460
|
+
throw new Error('Harness returned an invalid interaction response receipt');
|
|
461
|
+
}
|
|
462
|
+
const reason = receipt.reason;
|
|
463
|
+
throw new HarnessInteractionError(
|
|
464
|
+
`interaction-${reason}`,
|
|
465
|
+
`Harness interaction response was rejected (${reason})`,
|
|
466
|
+
);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
async watchInteractions(sessionId, {
|
|
470
|
+
signal,
|
|
471
|
+
onInteraction,
|
|
472
|
+
onResolved,
|
|
473
|
+
onOpen,
|
|
474
|
+
ownership,
|
|
475
|
+
} = {}) {
|
|
476
|
+
if (typeof sessionId !== 'string' || !sessionId) throw new TypeError('sessionId is required');
|
|
477
|
+
if (!signal || typeof signal.addEventListener !== 'function') {
|
|
478
|
+
throw new TypeError('watchInteractions requires an AbortSignal');
|
|
479
|
+
}
|
|
480
|
+
if (onInteraction !== undefined && typeof onInteraction !== 'function') {
|
|
481
|
+
throw new TypeError('onInteraction must be a function');
|
|
482
|
+
}
|
|
483
|
+
if (onResolved !== undefined && typeof onResolved !== 'function') {
|
|
484
|
+
throw new TypeError('onResolved must be a function');
|
|
485
|
+
}
|
|
486
|
+
if (onOpen !== undefined && typeof onOpen !== 'function') {
|
|
487
|
+
throw new TypeError('onOpen must be a function');
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
while (!signal.aborted) {
|
|
491
|
+
try {
|
|
492
|
+
await this.#watchInteractionSocket(sessionId, {
|
|
493
|
+
signal,
|
|
494
|
+
onInteraction,
|
|
495
|
+
onResolved,
|
|
496
|
+
onOpen,
|
|
497
|
+
ownership,
|
|
498
|
+
});
|
|
499
|
+
} catch (error) {
|
|
500
|
+
if (signal.aborted) return;
|
|
501
|
+
console.warn(`[${this.#logPrefix}] Harness interaction stream disconnected:`, error.message);
|
|
502
|
+
}
|
|
503
|
+
if (signal.aborted) return;
|
|
504
|
+
try {
|
|
505
|
+
await sleep(this.#interactionReconnectDelayMs, signal);
|
|
506
|
+
} catch {
|
|
507
|
+
if (signal.aborted) return;
|
|
508
|
+
throw new Error('Harness interaction reconnect wait failed');
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
#registerInteractionOwnership(sessionId, ownership) {
|
|
514
|
+
const owners = this.#interactionOwnerships.get(sessionId) ?? new Set();
|
|
515
|
+
ownership.order = this.#interactionRegistry.nextOrder;
|
|
516
|
+
this.#interactionRegistry.nextOrder += 1;
|
|
517
|
+
owners.add(ownership);
|
|
518
|
+
this.#interactionOwnerships.set(sessionId, owners);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
#unregisterInteractionOwnership(sessionId, ownership) {
|
|
522
|
+
const owners = this.#interactionOwnerships.get(sessionId);
|
|
523
|
+
owners?.delete(ownership);
|
|
524
|
+
if (owners?.size === 0) this.#interactionOwnerships.delete(sessionId);
|
|
525
|
+
for (const [key, claim] of this.#interactionClaims) {
|
|
526
|
+
if (claim.ownership === ownership) this.#interactionClaims.delete(key);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
#consumeInteractionOwnerships(sessionId, entries) {
|
|
531
|
+
for (const ownership of this.#interactionOwnerships.get(sessionId) ?? []) {
|
|
532
|
+
consumeInteractionOwnership(ownership, entries);
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
async #refreshInteractionOwnerships(sessionId, signal) {
|
|
537
|
+
const history = await this.rpc(
|
|
538
|
+
'session.history',
|
|
539
|
+
{ sessionId, maxMessages: 50 },
|
|
540
|
+
30_000,
|
|
541
|
+
{ signal },
|
|
542
|
+
);
|
|
543
|
+
this.#consumeInteractionOwnerships(sessionId, history.events ?? []);
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
#interactionOwner(sessionId, claimKey, kind) {
|
|
547
|
+
const claim = this.#interactionClaims.get(claimKey);
|
|
548
|
+
if (claim && this.#interactionOwnerships.get(sessionId)?.has(claim.ownership)) return claim;
|
|
549
|
+
|
|
550
|
+
const owners = [...(this.#interactionOwnerships.get(sessionId) ?? [])];
|
|
551
|
+
const active = owners
|
|
552
|
+
.filter((ownership) => ownership.active)
|
|
553
|
+
.sort((left, right) => left.order - right.order);
|
|
554
|
+
if (active.length > 0) return { ownership: active[0], recovered: false };
|
|
555
|
+
|
|
556
|
+
// A newly attached IM conversation may encounter a question left by
|
|
557
|
+
// an earlier runtime before its queued prompt starts. Let the oldest such
|
|
558
|
+
// ask adopt that replay so the Session can recover instead of deadlocking.
|
|
559
|
+
// Approval adopters receive recovered=true and must reject it without ever
|
|
560
|
+
// presenting it as approvable; the original actor/route cannot be proven
|
|
561
|
+
// after a runtime restart.
|
|
562
|
+
const ownership = owners
|
|
563
|
+
.filter((ownership) => !ownership.started && !ownership.completed)
|
|
564
|
+
.sort((left, right) => left.order - right.order)[0] ?? null;
|
|
565
|
+
return ownership ? { ownership, recovered: true } : null;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
async ask(sessionId, text, options = {}) {
|
|
569
|
+
if (typeof options === 'number') options = { timeoutMs: options };
|
|
570
|
+
const timeoutMs = options.timeoutMs ?? 600_000;
|
|
571
|
+
const signal = options.signal;
|
|
572
|
+
const onUpdate = typeof options.onUpdate === 'function' ? options.onUpdate : null;
|
|
573
|
+
const onInteraction = typeof options.onInteraction === 'function'
|
|
574
|
+
? options.onInteraction
|
|
575
|
+
: undefined;
|
|
576
|
+
const onInteractionResolved = typeof options.onInteractionResolved === 'function'
|
|
577
|
+
? options.onInteractionResolved
|
|
578
|
+
: undefined;
|
|
579
|
+
await this.ensureRunning({ signal });
|
|
580
|
+
const before = await this.rpc(
|
|
581
|
+
'session.history',
|
|
582
|
+
{ sessionId, maxMessages: 1 },
|
|
583
|
+
30_000,
|
|
584
|
+
{ signal },
|
|
585
|
+
);
|
|
586
|
+
const baselineSeq = Math.max(-1, ...(before.events ?? []).map(({ event }) => event.seq ?? -1));
|
|
587
|
+
const promptRpcId = `${this.#rpcIdPrefix}-${randomUUID()}`;
|
|
588
|
+
const tracker = new HarnessReplyTracker({ promptRpcId, afterSeq: baselineSeq });
|
|
589
|
+
const interactionController = onInteraction || onInteractionResolved
|
|
590
|
+
? new AbortController()
|
|
591
|
+
: null;
|
|
592
|
+
const interactionSignal = interactionController
|
|
593
|
+
? (signal
|
|
594
|
+
? AbortSignal.any([signal, interactionController.signal])
|
|
595
|
+
: interactionController.signal)
|
|
596
|
+
: null;
|
|
597
|
+
// The mux is host-global. A prompt RPC becomes the owner only when its
|
|
598
|
+
// durable user/message starts a turn, so two chats bound to one Session
|
|
599
|
+
// cannot answer each other's questions or approvals.
|
|
600
|
+
const ownership = interactionController
|
|
601
|
+
? {
|
|
602
|
+
promptRpcId,
|
|
603
|
+
active: false,
|
|
604
|
+
started: false,
|
|
605
|
+
completed: false,
|
|
606
|
+
turn: null,
|
|
607
|
+
openTurn: null,
|
|
608
|
+
lastSeq: baselineSeq,
|
|
609
|
+
reconnect: null,
|
|
610
|
+
order: -1,
|
|
611
|
+
toolCalls: new Map(),
|
|
612
|
+
}
|
|
613
|
+
: null;
|
|
614
|
+
let interactionTask = null;
|
|
615
|
+
|
|
616
|
+
if (ownership) this.#registerInteractionOwnership(sessionId, ownership);
|
|
617
|
+
|
|
618
|
+
try {
|
|
619
|
+
if (interactionSignal) {
|
|
620
|
+
let markOpen;
|
|
621
|
+
const opened = new Promise((resolve) => { markOpen = resolve; });
|
|
622
|
+
interactionTask = this.watchInteractions(sessionId, {
|
|
623
|
+
signal: interactionSignal,
|
|
624
|
+
onInteraction,
|
|
625
|
+
onResolved: onInteractionResolved,
|
|
626
|
+
onOpen: markOpen,
|
|
627
|
+
ownership,
|
|
628
|
+
});
|
|
629
|
+
void interactionTask.catch(() => undefined);
|
|
630
|
+
await Promise.race([
|
|
631
|
+
opened,
|
|
632
|
+
sleep(30_000, interactionSignal).then(() => {
|
|
633
|
+
throw new Error('Harness interaction stream did not open within 30 seconds');
|
|
634
|
+
}),
|
|
635
|
+
]);
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
await this.rpc('session.prompt', {
|
|
639
|
+
sessionId,
|
|
640
|
+
mode: 'queue',
|
|
641
|
+
content: [{ type: 'text', text }],
|
|
642
|
+
clientTimeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
|
643
|
+
}, 30_000, { rpcId: promptRpcId, signal });
|
|
644
|
+
|
|
645
|
+
const deadline = Date.now() + timeoutMs;
|
|
646
|
+
while (Date.now() < deadline) {
|
|
647
|
+
await sleep(300, signal);
|
|
648
|
+
const history = await this.rpc(
|
|
649
|
+
'session.history',
|
|
650
|
+
{ sessionId, maxMessages: 50 },
|
|
651
|
+
30_000,
|
|
652
|
+
{ signal },
|
|
653
|
+
);
|
|
654
|
+
const wasActive = ownership?.active === true;
|
|
655
|
+
if (ownership) {
|
|
656
|
+
this.#consumeInteractionOwnerships(sessionId, history.events ?? []);
|
|
657
|
+
if (!wasActive && ownership.active) ownership.reconnect?.();
|
|
658
|
+
}
|
|
659
|
+
const update = tracker.consume(history.events ?? []);
|
|
660
|
+
if (update && onUpdate) {
|
|
661
|
+
try {
|
|
662
|
+
await onUpdate(update);
|
|
663
|
+
} catch (error) {
|
|
664
|
+
console.warn(`[${this.#logPrefix}] ignored a progress update failure:`, error.message);
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
if (!tracker.finished) continue;
|
|
668
|
+
if (tracker.answer) return tracker.answer;
|
|
669
|
+
throw new Error(
|
|
670
|
+
`Harness turn ended without a text reply${tracker.reason ? ` (${JSON.stringify(tracker.reason)})` : ''}`,
|
|
671
|
+
);
|
|
672
|
+
}
|
|
673
|
+
throw new Error(`Harness reply timed out after ${Math.round(timeoutMs / 1_000)} seconds`);
|
|
674
|
+
} finally {
|
|
675
|
+
interactionController?.abort(new DOMException('Harness turn finished', 'AbortError'));
|
|
676
|
+
if (interactionTask) await interactionTask.catch(() => undefined);
|
|
677
|
+
if (ownership) this.#unregisterInteractionOwnership(sessionId, ownership);
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
#watchInteractionSocket(sessionId, {
|
|
682
|
+
signal,
|
|
683
|
+
onInteraction,
|
|
684
|
+
onResolved,
|
|
685
|
+
onOpen,
|
|
686
|
+
ownership,
|
|
687
|
+
}) {
|
|
688
|
+
const url = new URL('/api/events.mux', this.#baseUrl);
|
|
689
|
+
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
690
|
+
|
|
691
|
+
return new Promise((resolve, reject) => {
|
|
692
|
+
let socket;
|
|
693
|
+
try {
|
|
694
|
+
socket = this.#createWebSocket(url.toString());
|
|
695
|
+
} catch (error) {
|
|
696
|
+
reject(error);
|
|
697
|
+
return;
|
|
698
|
+
}
|
|
699
|
+
let opened = false;
|
|
700
|
+
let settled = false;
|
|
701
|
+
let callbackFailure = null;
|
|
702
|
+
let callbackTail = Promise.resolve();
|
|
703
|
+
let ownershipReady = ownership === undefined || ownership === null;
|
|
704
|
+
const bufferedEnvelopes = [];
|
|
705
|
+
const finish = (error) => {
|
|
706
|
+
if (settled) return;
|
|
707
|
+
settled = true;
|
|
708
|
+
socket.removeEventListener('open', handleOpen);
|
|
709
|
+
socket.removeEventListener('message', handleMessage);
|
|
710
|
+
socket.removeEventListener('close', handleClose);
|
|
711
|
+
socket.removeEventListener('error', handleError);
|
|
712
|
+
signal.removeEventListener('abort', handleAbort);
|
|
713
|
+
if (ownership?.reconnect === close) ownership.reconnect = null;
|
|
714
|
+
void callbackTail.then(() => {
|
|
715
|
+
const failure = error ?? callbackFailure;
|
|
716
|
+
if (failure) reject(failure);
|
|
717
|
+
else resolve();
|
|
718
|
+
}, reject);
|
|
719
|
+
};
|
|
720
|
+
const close = () => {
|
|
721
|
+
try {
|
|
722
|
+
if (socket.readyState === 0 || socket.readyState === 1) socket.close();
|
|
723
|
+
} catch {
|
|
724
|
+
// Cleanup must still settle the watcher if a WebSocket rejects close while connecting.
|
|
725
|
+
}
|
|
726
|
+
};
|
|
727
|
+
const handleOpen = () => {
|
|
728
|
+
opened = true;
|
|
729
|
+
if (ownership) ownership.reconnect = close;
|
|
730
|
+
try {
|
|
731
|
+
onOpen?.();
|
|
732
|
+
} catch (error) {
|
|
733
|
+
console.warn(`[${this.#logPrefix}] ignored an interaction open callback failure:`, error.message);
|
|
734
|
+
}
|
|
735
|
+
if (ownership) {
|
|
736
|
+
void this.#refreshInteractionOwnerships(sessionId, signal).then(() => {
|
|
737
|
+
if (settled) return;
|
|
738
|
+
ownershipReady = true;
|
|
739
|
+
for (const envelope of bufferedEnvelopes.splice(0)) processEnvelope(envelope);
|
|
740
|
+
}).catch((error) => {
|
|
741
|
+
callbackFailure ??= error;
|
|
742
|
+
close();
|
|
743
|
+
finish(error);
|
|
744
|
+
});
|
|
745
|
+
}
|
|
746
|
+
};
|
|
747
|
+
const dispatch = (callback, value) => {
|
|
748
|
+
if (!callback) return;
|
|
749
|
+
callbackTail = callbackTail
|
|
750
|
+
.then(() => callback(value))
|
|
751
|
+
.catch((error) => {
|
|
752
|
+
callbackFailure ??= error;
|
|
753
|
+
close();
|
|
754
|
+
finish(callbackFailure);
|
|
755
|
+
});
|
|
756
|
+
};
|
|
757
|
+
const processEnvelope = (envelope) => {
|
|
758
|
+
const payload = envelope.payload;
|
|
759
|
+
if (ownership && payload.type === 'session/event') {
|
|
760
|
+
this.#consumeInteractionOwnerships(sessionId, [payload.event]);
|
|
761
|
+
return;
|
|
762
|
+
}
|
|
763
|
+
if (payload.type === 'question/requested' || payload.type === 'approval/requested') {
|
|
764
|
+
const kind = payload.type === 'question/requested' ? 'question' : 'approval';
|
|
765
|
+
const interactionId = kind === 'question' ? envelope.rpcId : payload.approvalId;
|
|
766
|
+
const claimKey = `${kind}:${interactionId}`;
|
|
767
|
+
if (ownership) {
|
|
768
|
+
const claim = this.#interactionOwner(sessionId, claimKey, kind);
|
|
769
|
+
if (claim?.ownership !== ownership) return;
|
|
770
|
+
this.#interactionClaims.set(claimKey, claim);
|
|
771
|
+
}
|
|
772
|
+
const toolCall = kind === 'approval' && ownership && typeof payload.callId === 'string'
|
|
773
|
+
? this.#interactionClaims.get(claimKey)?.ownership.toolCalls.get(payload.callId)
|
|
774
|
+
: undefined;
|
|
775
|
+
dispatch(onInteraction, Object.freeze({
|
|
776
|
+
kind,
|
|
777
|
+
interactionId,
|
|
778
|
+
rpcId: envelope.rpcId,
|
|
779
|
+
sessionId,
|
|
780
|
+
payload,
|
|
781
|
+
recovered: ownership
|
|
782
|
+
? this.#interactionClaims.get(claimKey)?.recovered === true
|
|
783
|
+
: false,
|
|
784
|
+
...(toolCall ? { toolCall } : {}),
|
|
785
|
+
reconnect: close,
|
|
786
|
+
respond: (result, options = {}) => this.respondInteraction(
|
|
787
|
+
envelope.rpcId,
|
|
788
|
+
result,
|
|
789
|
+
{ ...options, signal: options.signal ?? signal },
|
|
790
|
+
),
|
|
791
|
+
}));
|
|
792
|
+
return;
|
|
793
|
+
}
|
|
794
|
+
if (payload.type === 'question/resolved' || payload.type === 'approval/resolved') {
|
|
795
|
+
const kind = payload.type === 'question/resolved' ? 'question' : 'approval';
|
|
796
|
+
const interactionId = kind === 'question'
|
|
797
|
+
? payload.questionRpcId
|
|
798
|
+
: payload.approvalId;
|
|
799
|
+
const claimKey = `${kind}:${interactionId}`;
|
|
800
|
+
if (ownership) {
|
|
801
|
+
const claim = this.#interactionClaims.get(claimKey);
|
|
802
|
+
if (claim?.ownership !== ownership) return;
|
|
803
|
+
this.#interactionClaims.delete(claimKey);
|
|
804
|
+
}
|
|
805
|
+
dispatch(onResolved, Object.freeze({
|
|
806
|
+
kind,
|
|
807
|
+
interactionId,
|
|
808
|
+
sessionId,
|
|
809
|
+
outcome: payload.outcome,
|
|
810
|
+
payload,
|
|
811
|
+
}));
|
|
812
|
+
}
|
|
813
|
+
};
|
|
814
|
+
const handleMessage = (event) => {
|
|
815
|
+
try {
|
|
816
|
+
if (typeof event.data !== 'string') throw new Error('binary WebSocket frame');
|
|
817
|
+
const envelope = JSON.parse(event.data);
|
|
818
|
+
const payload = envelope?.payload;
|
|
819
|
+
if (envelope?.type !== 'server-request'
|
|
820
|
+
|| typeof envelope.rpcId !== 'string'
|
|
821
|
+
|| !payload || typeof payload !== 'object'
|
|
822
|
+
|| envelope.method !== payload.type) {
|
|
823
|
+
throw new Error('invalid server-request envelope');
|
|
824
|
+
}
|
|
825
|
+
if (payload.sessionId !== sessionId) return;
|
|
826
|
+
if (!ownershipReady) bufferedEnvelopes.push(envelope);
|
|
827
|
+
else processEnvelope(envelope);
|
|
828
|
+
} catch (error) {
|
|
829
|
+
console.warn(`[${this.#logPrefix}] ignored a malformed Harness interaction frame:`, error.message);
|
|
830
|
+
}
|
|
831
|
+
};
|
|
832
|
+
const handleClose = () => finish(opened ? null : new Error(
|
|
833
|
+
'Harness interaction WebSocket closed before opening',
|
|
834
|
+
));
|
|
835
|
+
const handleError = () => {
|
|
836
|
+
finish(new Error(opened
|
|
837
|
+
? 'Harness interaction WebSocket failed'
|
|
838
|
+
: 'Harness interaction WebSocket failed before opening'));
|
|
839
|
+
close();
|
|
840
|
+
};
|
|
841
|
+
const handleAbort = () => {
|
|
842
|
+
close();
|
|
843
|
+
finish();
|
|
844
|
+
};
|
|
845
|
+
|
|
846
|
+
socket.addEventListener('open', handleOpen);
|
|
847
|
+
socket.addEventListener('message', handleMessage);
|
|
848
|
+
socket.addEventListener('close', handleClose, { once: true });
|
|
849
|
+
socket.addEventListener('error', handleError, { once: true });
|
|
850
|
+
signal.addEventListener('abort', handleAbort, { once: true });
|
|
851
|
+
if (signal.aborted) handleAbort();
|
|
852
|
+
});
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
stopManagedProcess() {
|
|
856
|
+
if (this.#managedProcess?.exitCode === null) this.#managedProcess.kill('SIGTERM');
|
|
857
|
+
}
|
|
858
|
+
}
|