pi-harness-runtime 1.1.458 → 1.1.460
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/CHANGELOG.md +73 -0
- package/README.md +10 -2
- package/harness/honcho-init.ts +907 -192
- package/harness/honcho-memory.ts +31 -69
- package/harness/key-file-audit.ts +79 -1
- package/harness/pending-countdown.ts +348 -0
- package/harness/service-commands.ts +48 -16
- package/index.ts +460 -76
- package/package.json +3 -8
- package/packages/agent-policy/package.json +1 -1
- package/packages/auth/package.json +1 -1
- package/packages/autonomous-runtime/package.json +1 -1
- package/packages/checkpoint/package.json +1 -1
- package/packages/clipboard/package.json +1 -1
- package/packages/codex-watcher/package.json +1 -1
- package/packages/config-capture/package.json +1 -1
- package/packages/contracts/package.json +1 -1
- package/packages/cookie-sanitizer/package.json +1 -1
- package/packages/evaluation-engine/package.json +1 -1
- package/packages/event-bus/package.json +1 -1
- package/packages/event-store/package.json +1 -1
- package/packages/experience-replay/package.json +1 -1
- package/packages/file-copy-helper/package.json +1 -1
- package/packages/intent-analyzer/package.json +1 -1
- package/packages/jev-judge/package.json +1 -1
- package/packages/knowledge-retrieval/package.json +1 -1
- package/packages/learning-engine/package.json +1 -1
- package/packages/logging/package.json +1 -1
- package/packages/memory-engine/package.json +1 -1
- package/packages/notification/package.json +1 -1
- package/packages/paperclip-integration/package.json +1 -1
- package/packages/performance-optimizer/package.json +1 -1
- package/packages/privilege-broker/package.json +1 -1
- package/packages/prompt-compiler/package.json +1 -1
- package/packages/providers/package.json +1 -1
- package/packages/qdrant-skills/package.json +1 -1
- package/packages/quota-manager/package.json +1 -1
- package/packages/relay/package.json +1 -1
- package/packages/skill-mcp-client/package.json +1 -1
- package/packages/skills/package.json +1 -1
- package/packages/todo-bd-sync/package.json +1 -1
- package/packages/token-estimation/package.json +1 -1
- package/packages/tooling-preference/package.json +1 -1
- package/packages/trajectory/package.json +1 -1
- package/packages/tui/package.json +1 -1
- package/packages/two-phase-review/package.json +1 -1
- package/packages/types/package.json +1 -1
- package/packages/webmcp-tester/package.json +1 -1
- package/packages/wiki/package.json +1 -1
- package/packages/write-review/package.json +1 -1
package/harness/honcho-init.ts
CHANGED
|
@@ -1,22 +1,31 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Honcho MCP Integration - Initialization and Registration
|
|
3
3
|
*
|
|
4
|
-
* Implements the full 6-step Honcho MCP integration
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* Implements the full 6-step Honcho MCP integration on Pi's native MCP
|
|
5
|
+
* support:
|
|
6
|
+
* 1. Register the Honcho MCP server with Pi (pi.registerMcpServer)
|
|
7
|
+
* 2. Connect with the native MCP client and list the tools
|
|
8
|
+
* 3. Create/retrieve the workspace, the peers and the session
|
|
8
9
|
* 4. Ingest messages on agent turn complete
|
|
9
10
|
* 5. Retrieve context before agent execution
|
|
10
11
|
* 6. Truthful status and proper shutdown
|
|
12
|
+
*
|
|
13
|
+
* The harness talks to the server twice, and both paths are native MCP:
|
|
14
|
+
* the model reaches the tools through the registration of step 1, and the
|
|
15
|
+
* automatic ingestion and recall of steps 4 and 5 use the MCP client of Pi's
|
|
16
|
+
* own npm package against the same URL and the same credential.
|
|
11
17
|
*/
|
|
12
18
|
|
|
13
19
|
import { logServiceEvent, honchoStatus } from "./service-diagnostics.js";
|
|
14
|
-
import {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
+
import { HonchoMemory, loadHonchoApiKey } from "./honcho-memory.js";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The credential loader of the integration.
|
|
24
|
+
*
|
|
25
|
+
* The startup uses it, so the environment variable wins over the key file
|
|
26
|
+
* everywhere and not only inside the module.
|
|
27
|
+
*/
|
|
28
|
+
export { loadHonchoApiKey };
|
|
20
29
|
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
21
30
|
import { join } from "node:path";
|
|
22
31
|
import { homedir } from "node:os";
|
|
@@ -26,79 +35,147 @@ import { homedir } from "node:os";
|
|
|
26
35
|
// ---------------------------------------------------------------------------
|
|
27
36
|
|
|
28
37
|
/**
|
|
29
|
-
*
|
|
38
|
+
* The outcome of the initialization.
|
|
39
|
+
*
|
|
40
|
+
* The startup report names the actual cause, so the status is a closed set
|
|
41
|
+
* instead of a boolean: "not ready" alone never says WHY.
|
|
30
42
|
*/
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
43
|
+
export type HonchoInitStatus =
|
|
44
|
+
| "ready"
|
|
45
|
+
| "missing_key"
|
|
46
|
+
| "mcp_unavailable"
|
|
47
|
+
| "registration_rejected";
|
|
48
|
+
|
|
49
|
+
export interface HonchoInitResult {
|
|
50
|
+
/** What happened. */
|
|
51
|
+
status: HonchoInitStatus;
|
|
52
|
+
/** The memory instance, only when the status is "ready". */
|
|
53
|
+
memory: HonchoMemory | null;
|
|
54
|
+
/** Short, credential-free cause. */
|
|
55
|
+
reason?: string;
|
|
56
|
+
/** Actionable, credential-free hint. */
|
|
57
|
+
hint?: string;
|
|
58
|
+
/** The HTTP status of the answer, when the server answered with one. */
|
|
59
|
+
httpStatus?: number;
|
|
60
|
+
/** Whether the MCP server is registered with Pi. */
|
|
61
|
+
mcpRegistered?: boolean;
|
|
62
|
+
/**
|
|
63
|
+
* Why the MCP server was not registered, credential-free.
|
|
64
|
+
*
|
|
65
|
+
* The status stays "ready": the memory works without the registration.
|
|
66
|
+
* The reason names the cause, so the startup does not have to guess
|
|
67
|
+
* ("check the Pi version") when the cause is something else.
|
|
68
|
+
*/
|
|
69
|
+
mcpRegistrationReason?: string;
|
|
41
70
|
}
|
|
42
71
|
|
|
43
72
|
/**
|
|
44
|
-
*
|
|
73
|
+
* The Honcho session identity
|
|
45
74
|
*/
|
|
46
|
-
interface
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
result?: McpToolCallResult;
|
|
75
|
+
interface HonchoIdentity {
|
|
76
|
+
workspaceId: string;
|
|
77
|
+
userPeerId: string;
|
|
78
|
+
assistantPeerId: string;
|
|
79
|
+
sessionId: string;
|
|
52
80
|
}
|
|
53
81
|
|
|
54
|
-
type McpToolCallResult =
|
|
55
|
-
| { ok: true; result?: unknown }
|
|
56
|
-
| { ok: false; error?: Error };
|
|
57
|
-
|
|
58
82
|
/**
|
|
59
|
-
*
|
|
83
|
+
* The native MCP client library of Pi.
|
|
60
84
|
*
|
|
61
|
-
* The
|
|
62
|
-
*
|
|
63
|
-
* not narrowed - the guard narrows in every setting.
|
|
85
|
+
* The library is Pi's own npm package; the interface keeps the harness
|
|
86
|
+
* independent of the concrete classes and lets the tests inject a fake.
|
|
64
87
|
*/
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
88
|
+
export interface HonchoMcpLibrary {
|
|
89
|
+
McpClient: new (options: {
|
|
90
|
+
name: string;
|
|
91
|
+
version: string;
|
|
92
|
+
requestTimeoutMs?: number;
|
|
93
|
+
}) => HonchoMcpClient & {
|
|
94
|
+
connect(transport: unknown): Promise<void>;
|
|
95
|
+
};
|
|
96
|
+
StreamableHttpTransport: new (options: {
|
|
97
|
+
url: string;
|
|
98
|
+
headers?: Record<string, string>;
|
|
99
|
+
}) => unknown;
|
|
69
100
|
}
|
|
70
101
|
|
|
71
102
|
/**
|
|
72
|
-
*
|
|
103
|
+
* Loads the native MCP client library.
|
|
73
104
|
*
|
|
74
|
-
* The
|
|
105
|
+
* The production loader imports the library by name. The tests inject a
|
|
106
|
+
* fake, because a dynamic import cannot be intercepted inside the sandbox
|
|
107
|
+
* of the test suite.
|
|
75
108
|
*/
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
109
|
+
export type HonchoMcpLibraryLoader = () => Promise<HonchoMcpLibrary>;
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The part of the MCP client the harness uses.
|
|
113
|
+
*
|
|
114
|
+
* The client is Pi's own npm package; the interface keeps the harness
|
|
115
|
+
* independent of the concrete class and lets the tests inject a fake.
|
|
116
|
+
*/
|
|
117
|
+
export interface HonchoMcpClient {
|
|
118
|
+
/** The tools of the server. */
|
|
119
|
+
listTools(): Promise<unknown[]>;
|
|
120
|
+
/** Call a tool of the server. */
|
|
121
|
+
callTool(
|
|
122
|
+
tool: string,
|
|
123
|
+
args?: Record<string, unknown>,
|
|
124
|
+
options?: { signal?: AbortSignal }
|
|
125
|
+
): Promise<unknown>;
|
|
126
|
+
/** Close the connection. */
|
|
127
|
+
close(): Promise<void>;
|
|
128
|
+
/** The connection state of the client, when it exposes one. */
|
|
129
|
+
readonly connectionState?: string;
|
|
80
130
|
}
|
|
81
131
|
|
|
82
132
|
/**
|
|
83
|
-
*
|
|
133
|
+
* Creates a connected MCP client.
|
|
134
|
+
*
|
|
135
|
+
* The default implementation uses Pi's native MCP client; the tests inject
|
|
136
|
+
* a fake with the same shape.
|
|
84
137
|
*/
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
138
|
+
export type HonchoMcpClientFactory = (
|
|
139
|
+
apiKey: string
|
|
140
|
+
) => Promise<HonchoMcpClient>;
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* The part of Pi's extension API the harness uses for the MCP server.
|
|
144
|
+
*/
|
|
145
|
+
export interface HonchoMcpRegistrar {
|
|
146
|
+
registerMcpServer?: (name: string, config: unknown) => void;
|
|
147
|
+
unregisterMcpServer?: (name: string) => void;
|
|
90
148
|
}
|
|
91
149
|
|
|
92
150
|
// ---------------------------------------------------------------------------
|
|
93
151
|
// Constants
|
|
94
152
|
// ---------------------------------------------------------------------------
|
|
95
153
|
|
|
96
|
-
|
|
97
|
-
const
|
|
154
|
+
/** The Honcho MCP server (streamable HTTP). */
|
|
155
|
+
const HONCHO_MCP_URL = "https://mcp.honcho.dev";
|
|
98
156
|
|
|
99
|
-
/** Honcho MCP server
|
|
157
|
+
/** The name of the Honcho MCP server in Pi. */
|
|
100
158
|
const HONCHO_SERVER_NAME = "honcho";
|
|
101
159
|
|
|
160
|
+
/**
|
|
161
|
+
* Pi's native MCP client library.
|
|
162
|
+
*
|
|
163
|
+
* The library is imported dynamically: a runtime without it has to report
|
|
164
|
+
* "mcp_unavailable" instead of breaking the whole harness.
|
|
165
|
+
*/
|
|
166
|
+
const MCP_CLIENT_MODULE = "@earendil-works/pi-mcp";
|
|
167
|
+
|
|
168
|
+
/** The request timeout of the MCP client. */
|
|
169
|
+
const MCP_REQUEST_TIMEOUT_MS = 30_000;
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* The timeout of the credential probe.
|
|
173
|
+
*
|
|
174
|
+
* The probe is a courtesy, not a gate: it must never hold the startup for
|
|
175
|
+
* long, so a server that does not answer counts as unreachable.
|
|
176
|
+
*/
|
|
177
|
+
const CREDENTIAL_PROBE_TIMEOUT_MS = 5_000;
|
|
178
|
+
|
|
102
179
|
/** Default workspace name for Pi harness */
|
|
103
180
|
const DEFAULT_WORKSPACE_NAME = "pi-harness";
|
|
104
181
|
|
|
@@ -108,6 +185,20 @@ const HONCHO_CONFIG_DIR = join(homedir(), ".pi-harness-runtime", "honcho");
|
|
|
108
185
|
/** File to persist workspace ID */
|
|
109
186
|
const WORKSPACE_ID_FILE = join(HONCHO_CONFIG_DIR, "workspace-id.json");
|
|
110
187
|
|
|
188
|
+
/**
|
|
189
|
+
* The answer of the server that says the credential was refused.
|
|
190
|
+
*
|
|
191
|
+
* The server does not check the credential during the handshake: it answers
|
|
192
|
+
* the first tool call with the verdict. The texts that carry the verdict
|
|
193
|
+
* name the key, the authorization or the status of the refusal.
|
|
194
|
+
*/
|
|
195
|
+
const CREDENTIAL_REJECTION_PATTERN =
|
|
196
|
+
/api[\s_-]*key|unauthoriz|unauthentic|forbidden|invalid[\s_-]*token|credential|\b401\b|\b403\b/i;
|
|
197
|
+
|
|
198
|
+
/** The error of a missing MCP client library. */
|
|
199
|
+
const MODULE_MISSING_PATTERN =
|
|
200
|
+
/cannot find (module|package)|err_module_not_found|err_unknown_file_extension|failed to resolve|cannot resolve/i;
|
|
201
|
+
|
|
111
202
|
// ---------------------------------------------------------------------------
|
|
112
203
|
// Workspace ID Persistence
|
|
113
204
|
// ---------------------------------------------------------------------------
|
|
@@ -155,17 +246,43 @@ const DEFAULT_ASSISTANT_NAME = "pi-agent";
|
|
|
155
246
|
/** Global Honcho memory instance */
|
|
156
247
|
let _honchoMemory: HonchoMemory | null = null;
|
|
157
248
|
|
|
158
|
-
/** Owned registration handle for disposal */
|
|
159
|
-
let _honchoRegistration: { dispose(): Promise<void> } | null = null;
|
|
160
|
-
|
|
161
249
|
/** Whether initHoncho has been called (attempted) */
|
|
162
250
|
let _initialized = false;
|
|
163
251
|
|
|
252
|
+
/** Whether the MCP connection is up and the tools are listed */
|
|
253
|
+
let _initReady = false;
|
|
254
|
+
|
|
164
255
|
/** Whether initHoncho succeeded (can retry if false) */
|
|
165
256
|
let _initSucceeded = false;
|
|
166
257
|
|
|
167
|
-
/**
|
|
168
|
-
let
|
|
258
|
+
/** The native MCP client of the session */
|
|
259
|
+
let _mcpClient: HonchoMcpClient | null = null;
|
|
260
|
+
|
|
261
|
+
/** The factory that creates the client (the seam of the tests) */
|
|
262
|
+
let _clientFactory: HonchoMcpClientFactory | null = null;
|
|
263
|
+
|
|
264
|
+
/** The credential of the session. It never reaches a log or a message. */
|
|
265
|
+
let _apiKey: string | null = null;
|
|
266
|
+
/** Whether the MCP server is registered with Pi. */
|
|
267
|
+
let _mcpRegistered = false;
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Why the MCP server is not registered with Pi.
|
|
271
|
+
*
|
|
272
|
+
* The reason is credential-free and it names the cause, so the startup does
|
|
273
|
+
* not have to guess ("check the Pi version") when the cause is another one.
|
|
274
|
+
*/
|
|
275
|
+
let _mcpRegistrationReason: string | undefined;
|
|
276
|
+
/** Loads the native MCP client library. The tests inject a fake. */
|
|
277
|
+
let _libraryLoader: HonchoMcpLibraryLoader | null = null;
|
|
278
|
+
/**
|
|
279
|
+
* The extension API of Pi, used to unregister the MCP server again.
|
|
280
|
+
*
|
|
281
|
+
* It is set only when this extension registered the server: a name that
|
|
282
|
+
* another extension owns has to stay with its owner, so a refused
|
|
283
|
+
* registration must never be undone by the shutdown.
|
|
284
|
+
*/
|
|
285
|
+
let _registrar: HonchoMcpRegistrar | null = null;
|
|
169
286
|
|
|
170
287
|
/** Current Honcho identity (workspace, peers, session) */
|
|
171
288
|
let _identity: HonchoIdentity | null = null;
|
|
@@ -173,6 +290,242 @@ let _identity: HonchoIdentity | null = null;
|
|
|
173
290
|
/** Disposed flag to prevent post-shutdown operations */
|
|
174
291
|
let _disposed = false;
|
|
175
292
|
|
|
293
|
+
/** The result of the last initialization attempt */
|
|
294
|
+
let _initResult: HonchoInitResult | null = null;
|
|
295
|
+
|
|
296
|
+
// ---------------------------------------------------------------------------
|
|
297
|
+
// MCP client
|
|
298
|
+
// ---------------------------------------------------------------------------
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Create a connected client with Pi's native MCP client library.
|
|
302
|
+
*
|
|
303
|
+
* The connection is streamable HTTP against the Honcho server, and the
|
|
304
|
+
* credential travels in the Authorization header.
|
|
305
|
+
*/
|
|
306
|
+
async function createNativeMcpClient(
|
|
307
|
+
apiKey: string,
|
|
308
|
+
load: HonchoMcpLibraryLoader
|
|
309
|
+
): Promise<HonchoMcpClient> {
|
|
310
|
+
const mod = await load();
|
|
311
|
+
const client = new mod.McpClient({
|
|
312
|
+
name: "pi-harness-runtime",
|
|
313
|
+
version: "1.0.0",
|
|
314
|
+
requestTimeoutMs: MCP_REQUEST_TIMEOUT_MS,
|
|
315
|
+
});
|
|
316
|
+
const transport = new mod.StreamableHttpTransport({
|
|
317
|
+
url: HONCHO_MCP_URL,
|
|
318
|
+
headers: { Authorization: `Bearer ${apiKey}` },
|
|
319
|
+
});
|
|
320
|
+
await client.connect(transport);
|
|
321
|
+
return client;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Loads the native MCP client library of Pi.
|
|
326
|
+
*
|
|
327
|
+
* The library is a dependency of the harness, so the import belongs to the
|
|
328
|
+
* module and not to the caller. The failure of the import is reported by
|
|
329
|
+
* the caller, which knows what it means for the startup.
|
|
330
|
+
*/
|
|
331
|
+
async function defaultLibraryLoader(): Promise<HonchoMcpLibrary> {
|
|
332
|
+
const mod = await import(MCP_CLIENT_MODULE);
|
|
333
|
+
// SAFETY: the package exports the two classes with these names; the
|
|
334
|
+
// interface above describes exactly how the harness uses them.
|
|
335
|
+
return mod as unknown as HonchoMcpLibrary;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* The factory of the client of the session.
|
|
340
|
+
*
|
|
341
|
+
* The injected factory wins (the tests use it), the native client of Pi is
|
|
342
|
+
* the default.
|
|
343
|
+
*/
|
|
344
|
+
function clientFactory(): HonchoMcpClientFactory {
|
|
345
|
+
return (
|
|
346
|
+
_clientFactory ??
|
|
347
|
+
((apiKey: string) =>
|
|
348
|
+
createNativeMcpClient(apiKey, _libraryLoader ?? defaultLibraryLoader))
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* The client of the session, connected.
|
|
354
|
+
*
|
|
355
|
+
* The client stays alive for the whole session; a dropped connection is
|
|
356
|
+
* re-established lazily, so a long session survives a server restart.
|
|
357
|
+
*/
|
|
358
|
+
async function ensureMcpClient(): Promise<HonchoMcpClient | null> {
|
|
359
|
+
if (_disposed) {
|
|
360
|
+
return null;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
if (_mcpClient) {
|
|
364
|
+
const state = _mcpClient.connectionState;
|
|
365
|
+
// A client without a state (a fake, or an older library) counts as
|
|
366
|
+
// connected: the calls report their own failures. A client that is
|
|
367
|
+
// still connecting is not usable: a call would run before the
|
|
368
|
+
// handshake is finished.
|
|
369
|
+
if (!state || state === "connected") {
|
|
370
|
+
return _mcpClient;
|
|
371
|
+
}
|
|
372
|
+
try {
|
|
373
|
+
await _mcpClient.close();
|
|
374
|
+
} catch {
|
|
375
|
+
// A client that cannot be closed is dropped anyway.
|
|
376
|
+
}
|
|
377
|
+
_mcpClient = null;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
if (!_apiKey) {
|
|
381
|
+
return null;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
const factory = clientFactory();
|
|
385
|
+
try {
|
|
386
|
+
_mcpClient = await factory(_apiKey);
|
|
387
|
+
return _mcpClient;
|
|
388
|
+
} catch (err) {
|
|
389
|
+
logServiceEvent({
|
|
390
|
+
ts: new Date().toISOString(),
|
|
391
|
+
level: "error",
|
|
392
|
+
service: "honcho",
|
|
393
|
+
operation: "mcp_connect",
|
|
394
|
+
phase: "failed",
|
|
395
|
+
reason: redact(err instanceof Error ? err.message : String(err)),
|
|
396
|
+
});
|
|
397
|
+
return null;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Did the server answer the call with an error?
|
|
403
|
+
*
|
|
404
|
+
* The MCP shape carries the verdict of a failed tool call in the result
|
|
405
|
+
* (`isError`), not in an exception: the server refuses an unknown
|
|
406
|
+
* credential with a result that carries the text of the refusal.
|
|
407
|
+
*/
|
|
408
|
+
function isErrorResult(result: unknown): boolean {
|
|
409
|
+
return (result as { isError?: unknown })?.isError === true;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* The text of an answer.
|
|
414
|
+
*
|
|
415
|
+
* The text of the content blocks is the answer of the server; a result that
|
|
416
|
+
* carries an error object instead answers with the message of the error.
|
|
417
|
+
*/
|
|
418
|
+
function resultText(result: unknown): string {
|
|
419
|
+
const content = (result as { content?: unknown })?.content;
|
|
420
|
+
if (Array.isArray(content)) {
|
|
421
|
+
const text = content
|
|
422
|
+
.filter((block) => (block as { type?: unknown })?.type === "text")
|
|
423
|
+
.map((block) => String((block as { text?: unknown }).text ?? ""))
|
|
424
|
+
.filter((entry) => entry !== "")
|
|
425
|
+
.join("\n");
|
|
426
|
+
if (text !== "") {
|
|
427
|
+
return text;
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
const error = (result as { error?: { message?: unknown } })?.error;
|
|
431
|
+
if (error && typeof error.message === "string") {
|
|
432
|
+
return error.message;
|
|
433
|
+
}
|
|
434
|
+
return "";
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* The HTTP status of an error, when it carries one.
|
|
439
|
+
*/
|
|
440
|
+
function errorStatus(err: unknown): number | undefined {
|
|
441
|
+
const status = (err as { status?: unknown })?.status;
|
|
442
|
+
return typeof status === "number" ? status : undefined;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* Remove the credential from a text.
|
|
447
|
+
*
|
|
448
|
+
* The server can echo the credential in its answer (an error message that
|
|
449
|
+
* quotes the key). Every text that the module keeps, reports or logs passes
|
|
450
|
+
* through here, so the key never reaches a log file, a reason or a hint.
|
|
451
|
+
*
|
|
452
|
+
* This complements `redactSecrets` of honcho-memory, it does not replace it:
|
|
453
|
+
* the pattern redactor only masks a value that follows a secret-looking key
|
|
454
|
+
* name, while the server can quote the credential on its own. The exact mask
|
|
455
|
+
* covers that case.
|
|
456
|
+
*
|
|
457
|
+
* @param text The text to mask
|
|
458
|
+
* @param apiKey The credential to remove
|
|
459
|
+
* @returns The text without the credential
|
|
460
|
+
*/
|
|
461
|
+
export function redactHonchoKey(text: string, apiKey: string): string {
|
|
462
|
+
if (!apiKey || !text) return text;
|
|
463
|
+
return text.split(apiKey).join("<redacted>");
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* Remove the credential of this session from a text.
|
|
468
|
+
*
|
|
469
|
+
* The text of the server is the one place where the key can come back: the
|
|
470
|
+
* answer of a refused call may quote it.
|
|
471
|
+
*/
|
|
472
|
+
function redact(text: string): string {
|
|
473
|
+
return redactHonchoKey(text, _apiKey ?? "");
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* Did the server refuse the credential?
|
|
478
|
+
*
|
|
479
|
+
* The refusal arrives either as an HTTP status (401, 403) or as the text of
|
|
480
|
+
* the answer, and both are named without the credential itself.
|
|
481
|
+
*/
|
|
482
|
+
function isCredentialRejection(text: string, status?: number): boolean {
|
|
483
|
+
if (status === 401 || status === 403) {
|
|
484
|
+
return true;
|
|
485
|
+
}
|
|
486
|
+
return text !== "" && CREDENTIAL_REJECTION_PATTERN.test(text);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* The result of a refusal of the server, when the answer is one.
|
|
491
|
+
*
|
|
492
|
+
* The server that answers with a status refused the request: a refused
|
|
493
|
+
* credential (401, 403) and a refused endpoint (404) are both a rejection
|
|
494
|
+
* of the registration and not an unreachable server.
|
|
495
|
+
*/
|
|
496
|
+
function refusalResult(
|
|
497
|
+
message: string,
|
|
498
|
+
status?: number
|
|
499
|
+
): HonchoInitResult | null {
|
|
500
|
+
if (status === 401 || status === 403) {
|
|
501
|
+
return {
|
|
502
|
+
status: "registration_rejected",
|
|
503
|
+
memory: null,
|
|
504
|
+
reason: `the server refused the stored credential (HTTP ${status})`,
|
|
505
|
+
hint: "the stored Honcho credential was rejected by the server",
|
|
506
|
+
httpStatus: status,
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
if (status === 404) {
|
|
510
|
+
return {
|
|
511
|
+
status: "registration_rejected",
|
|
512
|
+
memory: null,
|
|
513
|
+
reason: "the server refused the MCP endpoint (HTTP 404)",
|
|
514
|
+
hint: "the Honcho MCP endpoint answered 404",
|
|
515
|
+
httpStatus: status,
|
|
516
|
+
};
|
|
517
|
+
}
|
|
518
|
+
if (isCredentialRejection(message)) {
|
|
519
|
+
return {
|
|
520
|
+
status: "registration_rejected",
|
|
521
|
+
memory: null,
|
|
522
|
+
reason: `the server rejected the stored credential: ${redact(message)}`,
|
|
523
|
+
hint: "the stored Honcho credential was rejected by the server",
|
|
524
|
+
};
|
|
525
|
+
}
|
|
526
|
+
return null;
|
|
527
|
+
}
|
|
528
|
+
|
|
176
529
|
// ---------------------------------------------------------------------------
|
|
177
530
|
// MCP Tool Calls
|
|
178
531
|
// ---------------------------------------------------------------------------
|
|
@@ -246,12 +599,39 @@ function peerIdFrom(data: Record<string, unknown> | null): string | null {
|
|
|
246
599
|
if (typeof direct === "string" && direct !== "") {
|
|
247
600
|
return direct;
|
|
248
601
|
}
|
|
249
|
-
const nested = (data
|
|
602
|
+
const nested = (data as { peer?: { id?: unknown } }).peer?.id;
|
|
250
603
|
return typeof nested === "string" && nested !== "" ? nested : null;
|
|
251
604
|
}
|
|
252
605
|
|
|
253
606
|
/**
|
|
254
|
-
*
|
|
607
|
+
* Describe the arguments of a call for the log, without their values.
|
|
608
|
+
*
|
|
609
|
+
* The values are what the user wrote (a message, a peer name) and a server
|
|
610
|
+
* can be asked to echo the credential. Only the names of the arguments and
|
|
611
|
+
* their size are logged.
|
|
612
|
+
*/
|
|
613
|
+
function describeArgs(args: Record<string, unknown>): string {
|
|
614
|
+
const names = Object.keys(args ?? {});
|
|
615
|
+
return `${names.join(",") || "none"} (${describeShape(args)})`;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/**
|
|
619
|
+
* Describe the size of a value for the log, without its content.
|
|
620
|
+
*
|
|
621
|
+
* @param value The value to describe
|
|
622
|
+
* @returns The size of the value as text
|
|
623
|
+
*/
|
|
624
|
+
function describeShape(value: unknown): string {
|
|
625
|
+
try {
|
|
626
|
+
const text = JSON.stringify(value);
|
|
627
|
+
return `${typeof text === "string" ? text.length : 0} chars`;
|
|
628
|
+
} catch {
|
|
629
|
+
return "unknown size";
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/**
|
|
634
|
+
* Call a tool on the Honcho MCP server through the native MCP client.
|
|
255
635
|
* Returns actual result from the MCP server.
|
|
256
636
|
*/
|
|
257
637
|
async function callHonchoTool(
|
|
@@ -262,49 +642,37 @@ async function callHonchoTool(
|
|
|
262
642
|
return { success: false, error: "Honcho integration disposed" };
|
|
263
643
|
}
|
|
264
644
|
|
|
265
|
-
|
|
266
|
-
if (!
|
|
267
|
-
return { success: false, error: "
|
|
645
|
+
const client = await ensureMcpClient();
|
|
646
|
+
if (!client) {
|
|
647
|
+
return { success: false, error: "The Honcho MCP client is not connected" };
|
|
268
648
|
}
|
|
269
|
-
const piEvents = _piEvents;
|
|
270
|
-
|
|
271
|
-
// Create tool call request
|
|
272
|
-
const request: McpToolCallRequest = {
|
|
273
|
-
version: 1,
|
|
274
|
-
server: HONCHO_SERVER_NAME,
|
|
275
|
-
tool: toolName,
|
|
276
|
-
args,
|
|
277
|
-
};
|
|
278
649
|
|
|
279
|
-
logServiceEvent({ ts: new Date().toISOString(), level: "debug", service: "honcho", operation: "tool_call", phase: "start", tool: toolName, args:
|
|
650
|
+
logServiceEvent({ ts: new Date().toISOString(), level: "debug", service: "honcho", operation: "tool_call", phase: "start", tool: toolName, args: describeArgs(args) });
|
|
280
651
|
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
return { success: false, error: "No response from MCP adapter" };
|
|
652
|
+
let raw: unknown;
|
|
653
|
+
try {
|
|
654
|
+
raw = await client.callTool(toolName, args);
|
|
655
|
+
} catch (err) {
|
|
656
|
+
// The message of the error is the real reason and has to survive: a
|
|
657
|
+
// failing call that reads like "Unknown error" names nothing.
|
|
658
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
659
|
+
logServiceEvent({ ts: new Date().toISOString(), level: "error", service: "honcho", operation: "tool_call", phase: "failed", tool: toolName, error: redact(message) });
|
|
660
|
+
return { success: false, error: redact(message) };
|
|
291
661
|
}
|
|
292
662
|
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
// The adapter hands back an Error instance: JSON.stringify would show an
|
|
298
|
-
// empty object, so the message goes into the log on its own. Without it
|
|
299
|
-
// a failing call reads like "Unknown error" and names nothing.
|
|
300
|
-
const message = result.error?.message ?? "Unknown error";
|
|
663
|
+
if (isErrorResult(raw)) {
|
|
664
|
+
// The server refuses a call with a result that carries the reason in
|
|
665
|
+
// its content, not with an exception.
|
|
666
|
+
const message = redact(resultText(raw) || "The Honcho server refused the call");
|
|
301
667
|
logServiceEvent({ ts: new Date().toISOString(), level: "error", service: "honcho", operation: "tool_call", phase: "failed", tool: toolName, error: message });
|
|
302
668
|
return { success: false, error: message };
|
|
303
669
|
}
|
|
304
670
|
|
|
305
|
-
|
|
671
|
+
// The content of a result is not logged: it carries what the user wrote
|
|
672
|
+
// (and a server can quote the credential in it). Only the shape is.
|
|
673
|
+
logServiceEvent({ ts: new Date().toISOString(), level: "debug", service: "honcho", operation: "tool_call", phase: "completed", tool: toolName, result: describeShape(raw) });
|
|
306
674
|
|
|
307
|
-
return { success: true, result:
|
|
675
|
+
return { success: true, result: raw };
|
|
308
676
|
}
|
|
309
677
|
|
|
310
678
|
// ---------------------------------------------------------------------------
|
|
@@ -336,9 +704,10 @@ async function setWorkspaceMetadata(workspaceId: string, _workspaceName: string)
|
|
|
336
704
|
|
|
337
705
|
/**
|
|
338
706
|
* Get or validate workspace ID.
|
|
339
|
-
*
|
|
340
|
-
* Note:
|
|
341
|
-
*
|
|
707
|
+
*
|
|
708
|
+
* Note: the harness does not create the workspace. The server knows the
|
|
709
|
+
* tool `create_workspace`, but the workspace of the harness is a
|
|
710
|
+
* deployment decision and stays a CLI/API task.
|
|
342
711
|
* This function:
|
|
343
712
|
* 1. Loads persisted workspace ID
|
|
344
713
|
* 2. Validates it exists by calling inspect_workspace
|
|
@@ -362,22 +731,30 @@ async function ensureWorkspace(workspaceName: string): Promise<string | null> {
|
|
|
362
731
|
// nothing and silently fall back to the workspace name.
|
|
363
732
|
const parsed = honchoPayload(inspectResult.result).parsed as {
|
|
364
733
|
workspace?: { id?: string };
|
|
734
|
+
workspace_id?: string;
|
|
365
735
|
id?: string;
|
|
366
736
|
metadata?: Record<string, unknown>;
|
|
367
737
|
} | null;
|
|
368
738
|
const response: {
|
|
369
739
|
workspace?: { id?: string };
|
|
740
|
+
workspace_id?: string;
|
|
370
741
|
id?: string;
|
|
371
742
|
metadata?: Record<string, unknown>;
|
|
372
743
|
} = parsed ?? {};
|
|
373
|
-
|
|
744
|
+
// The server answers with `workspace_id`; `workspace.id` and `id` are
|
|
745
|
+
// the shapes of the older answers and stay accepted.
|
|
746
|
+
const workspaceId =
|
|
747
|
+
response.workspace_id ??
|
|
748
|
+
response.workspace?.id ??
|
|
749
|
+
response.id ??
|
|
750
|
+
workspaceName;
|
|
374
751
|
savePersistedWorkspace(workspaceId, workspaceName);
|
|
375
|
-
|
|
752
|
+
|
|
376
753
|
// Set metadata if not already set
|
|
377
754
|
if (!response.metadata || Object.keys(response.metadata).length === 0) {
|
|
378
755
|
await setWorkspaceMetadata(workspaceId, workspaceName);
|
|
379
756
|
}
|
|
380
|
-
|
|
757
|
+
|
|
381
758
|
logServiceEvent({ ts: new Date().toISOString(), level: "info", service: "honcho", operation: "workspace_validate", phase: "completed", workspaceId });
|
|
382
759
|
return workspaceId;
|
|
383
760
|
}
|
|
@@ -620,52 +997,310 @@ async function getContext(
|
|
|
620
997
|
return texts.length > 0 ? texts.join("\n") : null;
|
|
621
998
|
}
|
|
622
999
|
|
|
1000
|
+
// ---------------------------------------------------------------------------
|
|
1001
|
+
// Registration with Pi
|
|
1002
|
+
// ---------------------------------------------------------------------------
|
|
1003
|
+
|
|
1004
|
+
/**
|
|
1005
|
+
* The configuration of the Honcho MCP server for Pi.
|
|
1006
|
+
*
|
|
1007
|
+
* The credential travels in the Authorization header of the server
|
|
1008
|
+
* configuration, exactly like it travels in the header of the client.
|
|
1009
|
+
*/
|
|
1010
|
+
export function honchoMcpServerConfig(apiKey: string): {
|
|
1011
|
+
url: string;
|
|
1012
|
+
headers: Record<string, string>;
|
|
1013
|
+
exposure: string;
|
|
1014
|
+
description: string;
|
|
1015
|
+
} {
|
|
1016
|
+
return {
|
|
1017
|
+
url: HONCHO_MCP_URL,
|
|
1018
|
+
headers: {
|
|
1019
|
+
Authorization: `Bearer ${apiKey}`,
|
|
1020
|
+
},
|
|
1021
|
+
exposure: "codemode",
|
|
1022
|
+
description:
|
|
1023
|
+
"Honcho memory: workspaces, peers, sessions, messages and recall",
|
|
1024
|
+
};
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
/**
|
|
1028
|
+
* Register the Honcho MCP server with Pi.
|
|
1029
|
+
*
|
|
1030
|
+
* The registration is what the model and `/mcp` see. A registration that Pi
|
|
1031
|
+
* refuses (for example a name that another extension owns) is reported and
|
|
1032
|
+
* never throws into the startup.
|
|
1033
|
+
*
|
|
1034
|
+
* @param pi The extension API of Pi
|
|
1035
|
+
* @param apiKey The Honcho credential
|
|
1036
|
+
* @returns Whether the server is registered, and why it is not
|
|
1037
|
+
*/
|
|
1038
|
+
export function registerHonchoMcpServer(
|
|
1039
|
+
pi: HonchoMcpRegistrar | undefined | null,
|
|
1040
|
+
apiKey: string
|
|
1041
|
+
): { registered: boolean; reason?: string } {
|
|
1042
|
+
if (!pi || typeof pi.registerMcpServer !== "function") {
|
|
1043
|
+
return {
|
|
1044
|
+
registered: false,
|
|
1045
|
+
reason: "the extension API of this runtime has no registerMcpServer",
|
|
1046
|
+
};
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
try {
|
|
1050
|
+
pi.registerMcpServer(HONCHO_SERVER_NAME, honchoMcpServerConfig(apiKey));
|
|
1051
|
+
return { registered: true };
|
|
1052
|
+
} catch (err) {
|
|
1053
|
+
// The message of Pi can echo the configuration, and the configuration
|
|
1054
|
+
// carries the credential: the key never reaches the reason.
|
|
1055
|
+
let reason = err instanceof Error ? err.message : String(err);
|
|
1056
|
+
if (apiKey !== "") {
|
|
1057
|
+
reason = reason.split(apiKey).join("[REDACTED]");
|
|
1058
|
+
}
|
|
1059
|
+
logServiceEvent({
|
|
1060
|
+
ts: new Date().toISOString(),
|
|
1061
|
+
level: "warn",
|
|
1062
|
+
service: "honcho",
|
|
1063
|
+
operation: "mcp_register",
|
|
1064
|
+
phase: "failed",
|
|
1065
|
+
reason,
|
|
1066
|
+
});
|
|
1067
|
+
return { registered: false, reason };
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
|
|
623
1071
|
// ---------------------------------------------------------------------------
|
|
624
1072
|
// Initialization
|
|
625
1073
|
// ---------------------------------------------------------------------------
|
|
626
1074
|
|
|
627
1075
|
/**
|
|
628
|
-
*
|
|
1076
|
+
* Run a promise with a timeout.
|
|
629
1077
|
*
|
|
630
|
-
*
|
|
631
|
-
*
|
|
1078
|
+
* A promise that never settles must not hold the startup, so the timeout
|
|
1079
|
+
* wins and the pending promise is left to its own fate.
|
|
1080
|
+
*/
|
|
1081
|
+
async function withTimeout<T>(
|
|
1082
|
+
work: Promise<T>,
|
|
1083
|
+
timeoutMs: number
|
|
1084
|
+
): Promise<{ timedOut: true } | { timedOut: false; value: T }> {
|
|
1085
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
1086
|
+
const timeout = new Promise<{ timedOut: true }>((resolve) => {
|
|
1087
|
+
timer = setTimeout(() => resolve({ timedOut: true }), timeoutMs);
|
|
1088
|
+
});
|
|
1089
|
+
try {
|
|
1090
|
+
const settled = await Promise.race([
|
|
1091
|
+
work.then((value) => ({ timedOut: false as const, value })),
|
|
1092
|
+
timeout,
|
|
1093
|
+
]);
|
|
1094
|
+
return settled;
|
|
1095
|
+
} finally {
|
|
1096
|
+
if (timer) {
|
|
1097
|
+
clearTimeout(timer);
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
/**
|
|
1103
|
+
* Close a client that the initialization could not use.
|
|
632
1104
|
*
|
|
633
|
-
*
|
|
634
|
-
*
|
|
1105
|
+
* An initialization that fails must not leave a connection open: the
|
|
1106
|
+
* session would end with a client that nobody closes.
|
|
1107
|
+
*
|
|
1108
|
+
* @param client The client to close
|
|
635
1109
|
*/
|
|
1110
|
+
async function closeUnusedClient(client: HonchoMcpClient | null): Promise<void> {
|
|
1111
|
+
if (!client) {
|
|
1112
|
+
return;
|
|
1113
|
+
}
|
|
1114
|
+
if (_mcpClient === client) {
|
|
1115
|
+
_mcpClient = null;
|
|
1116
|
+
}
|
|
1117
|
+
try {
|
|
1118
|
+
await client.close();
|
|
1119
|
+
logServiceEvent({ ts: new Date().toISOString(), level: "debug", service: "honcho", operation: "mcp_close", phase: "completed", reason: "the initialization failed" });
|
|
1120
|
+
} catch (err) {
|
|
1121
|
+
logServiceEvent({ ts: new Date().toISOString(), level: "warn", service: "honcho", operation: "mcp_close", phase: "failed", reason: redact(err instanceof Error ? err.message : String(err)) });
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1124
|
+
|
|
636
1125
|
/**
|
|
637
|
-
*
|
|
1126
|
+
* Did the timeout win?
|
|
1127
|
+
*
|
|
1128
|
+
* The check is a type guard and not a plain `outcome.timedOut`: the root
|
|
1129
|
+
* project runs without strictNullChecks, and there the discriminant of a
|
|
1130
|
+
* union is not narrowed - the guard narrows in every setting.
|
|
638
1131
|
*/
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
1132
|
+
function isTimeoutOutcome(
|
|
1133
|
+
outcome: { timedOut: true } | { timedOut: false; value: unknown }
|
|
1134
|
+
): outcome is { timedOut: true } {
|
|
1135
|
+
return outcome.timedOut === true;
|
|
642
1136
|
}
|
|
643
1137
|
|
|
1138
|
+
/**
|
|
1139
|
+
* The verdict of the server about the credential.
|
|
1140
|
+
*
|
|
1141
|
+
* The server does not check the credential during the handshake: the
|
|
1142
|
+
* verdict arrives with the first tool call. The probe therefore calls a
|
|
1143
|
+
* tool that a normal (non-admin) credential may pass, and reads the answer.
|
|
1144
|
+
*/
|
|
1145
|
+
type CredentialVerdict =
|
|
1146
|
+
| { verdict: "accepted" }
|
|
1147
|
+
| { verdict: "rejected"; reason: string; httpStatus?: number }
|
|
1148
|
+
| { verdict: "unavailable"; reason: string };
|
|
1149
|
+
|
|
1150
|
+
async function probeCredential(
|
|
1151
|
+
client: HonchoMcpClient,
|
|
1152
|
+
timeoutMs: number = CREDENTIAL_PROBE_TIMEOUT_MS
|
|
1153
|
+
): Promise<CredentialVerdict> {
|
|
1154
|
+
const persisted = loadPersistedWorkspace();
|
|
1155
|
+
const workspaceId = persisted?.workspaceId ?? DEFAULT_WORKSPACE_NAME;
|
|
1156
|
+
|
|
1157
|
+
// The probe must not outlive its timeout: the request is aborted, so no
|
|
1158
|
+
// call stays pending on a connection that the initialization closes.
|
|
1159
|
+
const controller = new AbortController();
|
|
1160
|
+
|
|
1161
|
+
let outcome: { timedOut: true } | { timedOut: false; value: unknown };
|
|
1162
|
+
try {
|
|
1163
|
+
outcome = await withTimeout(
|
|
1164
|
+
client.callTool(
|
|
1165
|
+
"inspect_workspace",
|
|
1166
|
+
{ workspace_id: workspaceId },
|
|
1167
|
+
{ signal: controller.signal }
|
|
1168
|
+
),
|
|
1169
|
+
timeoutMs
|
|
1170
|
+
);
|
|
1171
|
+
} catch (err) {
|
|
1172
|
+
controller.abort();
|
|
1173
|
+
const status = errorStatus(err);
|
|
1174
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1175
|
+
if (isCredentialRejection(message, status)) {
|
|
1176
|
+
return {
|
|
1177
|
+
verdict: "rejected",
|
|
1178
|
+
reason: message,
|
|
1179
|
+
...(status ? { httpStatus: status } : {}),
|
|
1180
|
+
};
|
|
1181
|
+
}
|
|
1182
|
+
// The probe did not get an answer. A workspace that does not exist is
|
|
1183
|
+
// answered with a result (the server creates it), so an exception here
|
|
1184
|
+
// is a transport failure - never a proof that the credential is good.
|
|
1185
|
+
logServiceEvent({
|
|
1186
|
+
ts: new Date().toISOString(),
|
|
1187
|
+
level: "warn",
|
|
1188
|
+
service: "honcho",
|
|
1189
|
+
operation: "credential_probe",
|
|
1190
|
+
phase: "failed",
|
|
1191
|
+
reason: redact(message),
|
|
1192
|
+
});
|
|
1193
|
+
return { verdict: "unavailable", reason: redact(message) };
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
if (isTimeoutOutcome(outcome)) {
|
|
1197
|
+
controller.abort();
|
|
1198
|
+
return {
|
|
1199
|
+
verdict: "unavailable",
|
|
1200
|
+
reason: `the server did not answer the credential probe within ${timeoutMs}ms`,
|
|
1201
|
+
};
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
const raw = outcome.value;
|
|
1205
|
+
if (!isErrorResult(raw)) {
|
|
1206
|
+
return { verdict: "accepted" };
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
const text = resultText(raw);
|
|
1210
|
+
if (isCredentialRejection(text)) {
|
|
1211
|
+
return { verdict: "rejected", reason: redact(text) };
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
// The credential was accepted: the tool failed for another reason (a
|
|
1215
|
+
// workspace that does not exist, for example). The identity step reports
|
|
1216
|
+
// the workspace problem.
|
|
1217
|
+
logServiceEvent({
|
|
1218
|
+
ts: new Date().toISOString(),
|
|
1219
|
+
level: "warn",
|
|
1220
|
+
service: "honcho",
|
|
1221
|
+
operation: "credential_probe",
|
|
1222
|
+
phase: "completed",
|
|
1223
|
+
reason: "the probe failed for another reason than the credential",
|
|
1224
|
+
});
|
|
1225
|
+
return { verdict: "accepted" };
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
/**
|
|
1229
|
+
* Initialize the Honcho MCP integration.
|
|
1230
|
+
*
|
|
1231
|
+
* Must be called during session_start or later.
|
|
1232
|
+
*
|
|
1233
|
+
* @param options Initialization options
|
|
1234
|
+
* @returns The outcome of the initialization with its actual cause
|
|
1235
|
+
*/
|
|
644
1236
|
export async function initHoncho(options?: {
|
|
645
1237
|
workspaceName?: string;
|
|
646
1238
|
assistantName?: string;
|
|
647
1239
|
userName?: string;
|
|
648
1240
|
sessionId?: string;
|
|
649
1241
|
apiKey?: string;
|
|
650
|
-
|
|
651
|
-
|
|
1242
|
+
/** The extension API of Pi, used to register the MCP server. */
|
|
1243
|
+
pi?: HonchoMcpRegistrar;
|
|
1244
|
+
/** Creates the MCP client. The tests inject a fake client here. */
|
|
1245
|
+
createClient?: HonchoMcpClientFactory;
|
|
1246
|
+
/** Loads the native MCP client library. The tests inject a fake here. */
|
|
1247
|
+
loadMcpLibrary?: HonchoMcpLibraryLoader;
|
|
1248
|
+
/** The timeout of the credential probe. The tests shorten it. */
|
|
1249
|
+
probeTimeoutMs?: number;
|
|
1250
|
+
}): Promise<HonchoInitResult> {
|
|
1251
|
+
const result = await runInit(options);
|
|
1252
|
+
// The diagnostics commands report the outcome of the last attempt, so
|
|
1253
|
+
// the cause survives until somebody asks for it.
|
|
1254
|
+
_initResult = result;
|
|
1255
|
+
return result;
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
/**
|
|
1259
|
+
* The result of the last initialization, for the diagnostics commands.
|
|
1260
|
+
*/
|
|
1261
|
+
export function getHonchoInitResult(): HonchoInitResult | null {
|
|
1262
|
+
return _initResult;
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
async function runInit(options?: {
|
|
1266
|
+
workspaceName?: string;
|
|
1267
|
+
assistantName?: string;
|
|
1268
|
+
userName?: string;
|
|
1269
|
+
sessionId?: string;
|
|
1270
|
+
apiKey?: string;
|
|
1271
|
+
pi?: HonchoMcpRegistrar;
|
|
1272
|
+
createClient?: HonchoMcpClientFactory;
|
|
1273
|
+
loadMcpLibrary?: HonchoMcpLibraryLoader;
|
|
1274
|
+
probeTimeoutMs?: number;
|
|
1275
|
+
}): Promise<HonchoInitResult> {
|
|
652
1276
|
if (_disposed) {
|
|
653
1277
|
logServiceEvent({ ts: new Date().toISOString(), level: "error", service: "honcho", operation: "dispose", phase: "failed", reason: "integration has been disposed, cannot reinitialize" });
|
|
654
|
-
return
|
|
1278
|
+
return {
|
|
1279
|
+
status: "mcp_unavailable",
|
|
1280
|
+
memory: null,
|
|
1281
|
+
reason: "the Honcho integration has been disposed",
|
|
1282
|
+
};
|
|
655
1283
|
}
|
|
656
1284
|
|
|
657
1285
|
// Allow retry if previous init failed
|
|
658
|
-
if (_initialized &&
|
|
1286
|
+
if (_initialized && _initReady) {
|
|
659
1287
|
logServiceEvent({ ts: new Date().toISOString(), level: "debug", service: "honcho", operation: "init", phase: "completed", reason: "already initialized, returning existing instance" });
|
|
660
|
-
|
|
1288
|
+
const existing: HonchoInitResult = {
|
|
1289
|
+
status: "ready",
|
|
1290
|
+
memory: _honchoMemory,
|
|
1291
|
+
mcpRegistered: _mcpRegistered,
|
|
1292
|
+
};
|
|
1293
|
+
if (_mcpRegistrationReason) {
|
|
1294
|
+
existing.mcpRegistrationReason = _mcpRegistrationReason;
|
|
1295
|
+
}
|
|
1296
|
+
return existing;
|
|
661
1297
|
}
|
|
662
1298
|
|
|
663
|
-
|
|
664
|
-
if (_initialized && !_initSucceeded) {
|
|
1299
|
+
if (_initialized) {
|
|
665
1300
|
logServiceEvent({ ts: new Date().toISOString(), level: "info", service: "honcho", operation: "init", phase: "start", reason: "retrying initialization" });
|
|
666
1301
|
}
|
|
667
1302
|
_initialized = true;
|
|
668
|
-
|
|
1303
|
+
_initReady = false;
|
|
669
1304
|
|
|
670
1305
|
logServiceEvent({ ts: new Date().toISOString(), level: "info", service: "honcho", operation: "init", phase: "start" });
|
|
671
1306
|
|
|
@@ -675,92 +1310,156 @@ export async function initHoncho(options?: {
|
|
|
675
1310
|
logServiceEvent({ ts: new Date().toISOString(), level: "error", service: "honcho", operation: "init", phase: "failed", reason: "No API key found", helpUrl: "https://app.honcho.dev" });
|
|
676
1311
|
honchoStatus.setConfigured(false);
|
|
677
1312
|
honchoStatus.recordFailure("init", "no_key", { error: "No API key" });
|
|
678
|
-
return
|
|
1313
|
+
return {
|
|
1314
|
+
status: "missing_key",
|
|
1315
|
+
memory: null,
|
|
1316
|
+
reason: "no Honcho API key was found",
|
|
1317
|
+
hint: "Set HONCHO_API_KEY or store the key in ~/.pi-harness-runtime/keys/honcho-api-key.txt",
|
|
1318
|
+
};
|
|
679
1319
|
}
|
|
680
1320
|
|
|
681
1321
|
honchoStatus.setConfigured(true);
|
|
1322
|
+
_apiKey = key;
|
|
1323
|
+
_clientFactory = options?.createClient ?? null;
|
|
1324
|
+
_libraryLoader = options?.loadMcpLibrary ?? null;
|
|
1325
|
+
|
|
1326
|
+
// Connect with the native MCP client.
|
|
1327
|
+
const factory = clientFactory();
|
|
1328
|
+
let client: HonchoMcpClient;
|
|
1329
|
+
try {
|
|
1330
|
+
client = await factory(key);
|
|
1331
|
+
} catch (err) {
|
|
1332
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1333
|
+
const status = errorStatus(err);
|
|
1334
|
+
logServiceEvent({ ts: new Date().toISOString(), level: "error", service: "honcho", operation: "mcp_connect", phase: "failed", reason: redact(message) });
|
|
1335
|
+
honchoStatus.recordFailure("init", "connect_failed", { error: redact(message) });
|
|
1336
|
+
|
|
1337
|
+
const refusal = refusalResult(message, status);
|
|
1338
|
+
if (refusal) {
|
|
1339
|
+
honchoStatus.setConnected(false);
|
|
1340
|
+
honchoStatus.recordFailure("init", "registration_rejected", { error: redact(message) });
|
|
1341
|
+
return refusal;
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
if (MODULE_MISSING_PATTERN.test(message)) {
|
|
1345
|
+
return {
|
|
1346
|
+
status: "mcp_unavailable",
|
|
1347
|
+
memory: null,
|
|
1348
|
+
reason: "the native MCP client library is not installed",
|
|
1349
|
+
hint: "@earendil-works/pi-mcp 1.4.0 or newer is required for the native MCP client",
|
|
1350
|
+
};
|
|
1351
|
+
}
|
|
682
1352
|
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
1353
|
+
return {
|
|
1354
|
+
status: "mcp_unavailable",
|
|
1355
|
+
memory: null,
|
|
1356
|
+
reason: `the Honcho MCP server could not be reached: ${redact(message)}`,
|
|
1357
|
+
hint: `check the network and the endpoint ${HONCHO_MCP_URL}`,
|
|
1358
|
+
};
|
|
686
1359
|
}
|
|
687
1360
|
|
|
688
|
-
|
|
689
|
-
ensureMcpConfigDirectory();
|
|
1361
|
+
_mcpClient = client;
|
|
690
1362
|
|
|
691
|
-
//
|
|
1363
|
+
// The tools of the server have to be listed: a server that answers the
|
|
1364
|
+
// handshake but not the tools is not usable.
|
|
692
1365
|
try {
|
|
693
|
-
|
|
694
|
-
logServiceEvent({ ts: new Date().toISOString(), level: "info", service: "honcho", operation: "mcp_config", phase: "completed" });
|
|
1366
|
+
await client.listTools();
|
|
695
1367
|
} catch (err) {
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
1368
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1369
|
+
const status = errorStatus(err);
|
|
1370
|
+
logServiceEvent({ ts: new Date().toISOString(), level: "error", service: "honcho", operation: "mcp_tools", phase: "failed", reason: redact(message) });
|
|
1371
|
+
honchoStatus.recordFailure("init", "list_tools_failed", { error: redact(message) });
|
|
1372
|
+
|
|
1373
|
+
const refusal = refusalResult(message, status);
|
|
1374
|
+
if (refusal) {
|
|
1375
|
+
honchoStatus.setConnected(false);
|
|
1376
|
+
honchoStatus.recordFailure("init", "registration_rejected", { error: redact(message) });
|
|
1377
|
+
await closeUnusedClient(client);
|
|
1378
|
+
return refusal;
|
|
1379
|
+
}
|
|
702
1380
|
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
1381
|
+
await closeUnusedClient(client);
|
|
1382
|
+
return {
|
|
1383
|
+
status: "mcp_unavailable",
|
|
1384
|
+
memory: null,
|
|
1385
|
+
reason: `the Honcho MCP server did not list its tools: ${redact(message)}`,
|
|
1386
|
+
hint: `the endpoint ${HONCHO_MCP_URL} answered the handshake but no tools`,
|
|
1387
|
+
};
|
|
707
1388
|
}
|
|
708
1389
|
|
|
709
|
-
//
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
1390
|
+
// The handshake does not check the credential: the verdict arrives with
|
|
1391
|
+
// the first tool call. The probe asks for it, with a short timeout.
|
|
1392
|
+
const probe = await probeCredential(client, options?.probeTimeoutMs ?? CREDENTIAL_PROBE_TIMEOUT_MS);
|
|
1393
|
+
if (probe.verdict === "rejected") {
|
|
1394
|
+
logServiceEvent({ ts: new Date().toISOString(), level: "error", service: "honcho", operation: "credential_probe", phase: "failed", reason: redact(probe.reason) });
|
|
1395
|
+
honchoStatus.setConnected(false);
|
|
1396
|
+
honchoStatus.recordFailure("init", "credential_rejected", { error: redact(probe.reason) });
|
|
1397
|
+
await closeUnusedClient(client);
|
|
1398
|
+
return {
|
|
1399
|
+
status: "registration_rejected",
|
|
1400
|
+
memory: null,
|
|
1401
|
+
reason: `the server rejected the stored credential: ${redact(probe.reason)}`,
|
|
1402
|
+
hint: "the stored Honcho credential was rejected by the server",
|
|
1403
|
+
...(probe.httpStatus ? { httpStatus: probe.httpStatus } : {}),
|
|
1404
|
+
};
|
|
1405
|
+
}
|
|
1406
|
+
if (probe.verdict === "unavailable") {
|
|
1407
|
+
logServiceEvent({ ts: new Date().toISOString(), level: "warn", service: "honcho", operation: "credential_probe", phase: "failed", reason: redact(probe.reason) });
|
|
1408
|
+
honchoStatus.recordFailure("init", "probe_failed", { error: redact(probe.reason) });
|
|
1409
|
+
await closeUnusedClient(client);
|
|
1410
|
+
return {
|
|
1411
|
+
status: "mcp_unavailable",
|
|
1412
|
+
memory: null,
|
|
1413
|
+
reason: `the Honcho MCP server did not answer the credential probe: ${probe.reason}`,
|
|
1414
|
+
hint: `the endpoint ${HONCHO_MCP_URL} accepted the handshake but did not answer a call`,
|
|
1415
|
+
};
|
|
733
1416
|
}
|
|
734
1417
|
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
1418
|
+
// Register the server with Pi, so the model and `/mcp` see it. The
|
|
1419
|
+
// registration needs the extension API of the running Pi: without it the
|
|
1420
|
+
// module does not attempt a registration, and it never reports a failure
|
|
1421
|
+
// for a registration that the caller performs itself.
|
|
1422
|
+
const pi = options?.pi;
|
|
1423
|
+
if (pi) {
|
|
1424
|
+
const registration = registerHonchoMcpServer(pi, key);
|
|
1425
|
+
_mcpRegistered = registration.registered;
|
|
1426
|
+
if (registration.registered) {
|
|
1427
|
+
// The extension API is kept for the shutdown: the server has to be
|
|
1428
|
+
// unregistered again, or a reload keeps the old registration alive.
|
|
1429
|
+
// Only a registration that this extension made is ever unregistered:
|
|
1430
|
+
// a name that another extension owns has to stay with its owner.
|
|
1431
|
+
_registrar = pi;
|
|
1432
|
+
_mcpRegistrationReason = undefined;
|
|
1433
|
+
logServiceEvent({ ts: new Date().toISOString(), level: "info", service: "honcho", operation: "mcp_register", phase: "completed" });
|
|
742
1434
|
} else {
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
1435
|
+
_mcpRegistrationReason = redact(registration.reason ?? "the server was not registered");
|
|
1436
|
+
logServiceEvent({ ts: new Date().toISOString(), level: "warn", service: "honcho", operation: "mcp_register", phase: "failed", reason: _mcpRegistrationReason });
|
|
1437
|
+
honchoStatus.recordFailure("init", "registration_failed", { error: _mcpRegistrationReason });
|
|
746
1438
|
}
|
|
747
|
-
} else {
|
|
748
|
-
// Registration accepted - store handle for disposal
|
|
749
|
-
_honchoRegistration = registration.registration;
|
|
750
|
-
logServiceEvent({ ts: new Date().toISOString(), level: "info", service: "honcho", operation: "mcp_register", phase: "completed" });
|
|
751
1439
|
}
|
|
752
1440
|
|
|
753
1441
|
// Create memory instance
|
|
754
1442
|
_honchoMemory = new HonchoMemory({
|
|
755
1443
|
apiKey: key,
|
|
756
1444
|
});
|
|
1445
|
+
honchoStatus.setConnected(true);
|
|
757
1446
|
|
|
758
|
-
//
|
|
759
|
-
//
|
|
760
|
-
logServiceEvent({ ts: new Date().toISOString(), level: "info", service: "honcho", operation: "
|
|
1447
|
+
// The identity setup is deferred to session_start (the workspace, the
|
|
1448
|
+
// peers and the session belong to a running session).
|
|
1449
|
+
logServiceEvent({ ts: new Date().toISOString(), level: "info", service: "honcho", operation: "init", phase: "completed", reason: "identity_setup_deferred" });
|
|
761
1450
|
honchoStatus.recordSuccess("init", undefined, { note: "identity deferred to session_start" });
|
|
762
1451
|
|
|
763
|
-
|
|
1452
|
+
_initReady = true;
|
|
1453
|
+
|
|
1454
|
+
const ready: HonchoInitResult = {
|
|
1455
|
+
status: "ready",
|
|
1456
|
+
memory: _honchoMemory,
|
|
1457
|
+
mcpRegistered: _mcpRegistered,
|
|
1458
|
+
};
|
|
1459
|
+
if (_mcpRegistrationReason) {
|
|
1460
|
+
ready.mcpRegistrationReason = _mcpRegistrationReason;
|
|
1461
|
+
}
|
|
1462
|
+
return ready;
|
|
764
1463
|
}
|
|
765
1464
|
|
|
766
1465
|
/**
|
|
@@ -936,7 +1635,7 @@ export async function retrieveHonchoContext(
|
|
|
936
1635
|
|
|
937
1636
|
/**
|
|
938
1637
|
* Shutdown Honcho integration.
|
|
939
|
-
*
|
|
1638
|
+
* Closes the MCP client, stops timers, clears state.
|
|
940
1639
|
*/
|
|
941
1640
|
export async function shutdownHoncho(): Promise<void> {
|
|
942
1641
|
if (_disposed) {
|
|
@@ -946,6 +1645,18 @@ export async function shutdownHoncho(): Promise<void> {
|
|
|
946
1645
|
_disposed = true;
|
|
947
1646
|
logServiceEvent({ ts: new Date().toISOString(), level: "info", service: "honcho", operation: "shutdown", phase: "start" });
|
|
948
1647
|
|
|
1648
|
+
// The MCP server must not stay registered with Pi. A reload registers it
|
|
1649
|
+
// again, and the old registration would keep the old credential alive.
|
|
1650
|
+
if (_registrar && typeof _registrar.unregisterMcpServer === "function") {
|
|
1651
|
+
try {
|
|
1652
|
+
_registrar.unregisterMcpServer(HONCHO_SERVER_NAME);
|
|
1653
|
+
logServiceEvent({ ts: new Date().toISOString(), level: "info", service: "honcho", operation: "mcp_unregister", phase: "completed" });
|
|
1654
|
+
} catch (err) {
|
|
1655
|
+
logServiceEvent({ ts: new Date().toISOString(), level: "warn", service: "honcho", operation: "mcp_unregister", phase: "failed", reason: redact(err instanceof Error ? err.message : String(err)) });
|
|
1656
|
+
}
|
|
1657
|
+
}
|
|
1658
|
+
_registrar = null;
|
|
1659
|
+
|
|
949
1660
|
// Dispose memory instance
|
|
950
1661
|
if (_honchoMemory) {
|
|
951
1662
|
_honchoMemory.dispose();
|
|
@@ -955,27 +1666,25 @@ export async function shutdownHoncho(): Promise<void> {
|
|
|
955
1666
|
// Clear identity
|
|
956
1667
|
_identity = null;
|
|
957
1668
|
|
|
958
|
-
//
|
|
959
|
-
if (
|
|
1669
|
+
// Close the native MCP client
|
|
1670
|
+
if (_mcpClient) {
|
|
960
1671
|
try {
|
|
961
|
-
await
|
|
962
|
-
logServiceEvent({ ts: new Date().toISOString(), level: "info", service: "honcho", operation: "
|
|
1672
|
+
await _mcpClient.close();
|
|
1673
|
+
logServiceEvent({ ts: new Date().toISOString(), level: "info", service: "honcho", operation: "mcp_close", phase: "completed" });
|
|
963
1674
|
} catch (err) {
|
|
964
|
-
logServiceEvent({ ts: new Date().toISOString(), level: "warn", service: "honcho", operation: "
|
|
1675
|
+
logServiceEvent({ ts: new Date().toISOString(), level: "warn", service: "honcho", operation: "mcp_close", phase: "failed", reason: redact(err instanceof Error ? err.message : String(err)) });
|
|
965
1676
|
}
|
|
966
|
-
|
|
1677
|
+
_mcpClient = null;
|
|
967
1678
|
}
|
|
968
1679
|
|
|
1680
|
+
_apiKey = null;
|
|
1681
|
+
_mcpRegistered = false;
|
|
1682
|
+
_mcpRegistrationReason = undefined;
|
|
969
1683
|
_initialized = false;
|
|
1684
|
+
_initReady = false;
|
|
970
1685
|
logServiceEvent({ ts: new Date().toISOString(), level: "info", service: "honcho", operation: "shutdown", phase: "completed" });
|
|
971
1686
|
}
|
|
972
1687
|
|
|
973
|
-
/**
|
|
974
|
-
* Register Honcho with session lifecycle hooks.
|
|
975
|
-
* Call this during harness startup to wire Honcho into Pi events.
|
|
976
|
-
*
|
|
977
|
-
* @param getPiEvents Function that returns pi.events when available
|
|
978
|
-
*/
|
|
979
1688
|
/**
|
|
980
1689
|
* Call a Honcho MCP tool (exported for honcho-memory.ts)
|
|
981
1690
|
*/
|
|
@@ -994,6 +1703,8 @@ export async function honchoToolCall(
|
|
|
994
1703
|
export function registerHonchoLifecycle(
|
|
995
1704
|
pi: {
|
|
996
1705
|
on: (event: string, handler: (...args: unknown[]) => void | Promise<void>) => void;
|
|
1706
|
+
registerMcpServer?: (name: string, config: unknown) => void;
|
|
1707
|
+
unregisterMcpServer?: (name: string) => void;
|
|
997
1708
|
}
|
|
998
1709
|
): void {
|
|
999
1710
|
// Note: initHoncho() is called at startup in index.ts
|
|
@@ -1002,6 +1713,10 @@ export function registerHonchoLifecycle(
|
|
|
1002
1713
|
|
|
1003
1714
|
logServiceEvent({ ts: new Date().toISOString(), level: "info", service: "honcho", operation: "lifecycle_hooks", phase: "start" });
|
|
1004
1715
|
|
|
1716
|
+
// The extension API is not remembered here: this function registers
|
|
1717
|
+
// nothing. Only the registration of the initialization may be undone by
|
|
1718
|
+
// the shutdown, and the shutdown may only undo what this extension made.
|
|
1719
|
+
|
|
1005
1720
|
// Shutdown at session shutdown
|
|
1006
1721
|
pi.on("session_shutdown", async () => {
|
|
1007
1722
|
await shutdownHoncho();
|