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.
Files changed (50) hide show
  1. package/CHANGELOG.md +73 -0
  2. package/README.md +10 -2
  3. package/harness/honcho-init.ts +907 -192
  4. package/harness/honcho-memory.ts +31 -69
  5. package/harness/key-file-audit.ts +79 -1
  6. package/harness/pending-countdown.ts +348 -0
  7. package/harness/service-commands.ts +48 -16
  8. package/index.ts +460 -76
  9. package/package.json +3 -8
  10. package/packages/agent-policy/package.json +1 -1
  11. package/packages/auth/package.json +1 -1
  12. package/packages/autonomous-runtime/package.json +1 -1
  13. package/packages/checkpoint/package.json +1 -1
  14. package/packages/clipboard/package.json +1 -1
  15. package/packages/codex-watcher/package.json +1 -1
  16. package/packages/config-capture/package.json +1 -1
  17. package/packages/contracts/package.json +1 -1
  18. package/packages/cookie-sanitizer/package.json +1 -1
  19. package/packages/evaluation-engine/package.json +1 -1
  20. package/packages/event-bus/package.json +1 -1
  21. package/packages/event-store/package.json +1 -1
  22. package/packages/experience-replay/package.json +1 -1
  23. package/packages/file-copy-helper/package.json +1 -1
  24. package/packages/intent-analyzer/package.json +1 -1
  25. package/packages/jev-judge/package.json +1 -1
  26. package/packages/knowledge-retrieval/package.json +1 -1
  27. package/packages/learning-engine/package.json +1 -1
  28. package/packages/logging/package.json +1 -1
  29. package/packages/memory-engine/package.json +1 -1
  30. package/packages/notification/package.json +1 -1
  31. package/packages/paperclip-integration/package.json +1 -1
  32. package/packages/performance-optimizer/package.json +1 -1
  33. package/packages/privilege-broker/package.json +1 -1
  34. package/packages/prompt-compiler/package.json +1 -1
  35. package/packages/providers/package.json +1 -1
  36. package/packages/qdrant-skills/package.json +1 -1
  37. package/packages/quota-manager/package.json +1 -1
  38. package/packages/relay/package.json +1 -1
  39. package/packages/skill-mcp-client/package.json +1 -1
  40. package/packages/skills/package.json +1 -1
  41. package/packages/todo-bd-sync/package.json +1 -1
  42. package/packages/token-estimation/package.json +1 -1
  43. package/packages/tooling-preference/package.json +1 -1
  44. package/packages/trajectory/package.json +1 -1
  45. package/packages/tui/package.json +1 -1
  46. package/packages/two-phase-review/package.json +1 -1
  47. package/packages/types/package.json +1 -1
  48. package/packages/webmcp-tester/package.json +1 -1
  49. package/packages/wiki/package.json +1 -1
  50. package/packages/write-review/package.json +1 -1
@@ -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
- * 1. Register MCP server through pi-mcp-adapter
6
- * 2. Discover tools and establish workspace/peer mapping
7
- * 3. Create/retrieve session for Pi session
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
- HonchoMemory,
16
- loadHonchoApiKey,
17
- ensureMcpConfigDirectory,
18
- writeHonchoMcpConfig,
19
- } from "./honcho-memory.js";
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
- * MCP Runtime Registration Request (per pi-mcp-adapter contract)
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
- interface McpRuntimeRegistrationRequest {
32
- version: 1;
33
- name: string;
34
- definition: {
35
- url: string;
36
- headers?: Record<string, string>;
37
- };
38
- result?:
39
- | { ok: true; registration: { dispose(): Promise<void> } }
40
- | { ok: false; error: Error };
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
- * MCP Tool Call Request (per pi-mcp-adapter contract)
73
+ * The Honcho session identity
45
74
  */
46
- interface McpToolCallRequest {
47
- version: 1;
48
- server: string;
49
- tool: string;
50
- args: Record<string, unknown>;
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
- * Did the adapter refuse the call?
83
+ * The native MCP client library of Pi.
60
84
  *
61
- * The check is a type guard and not a plain `!result.ok`: the root project
62
- * runs without strictNullChecks, and there the discriminant of a union is
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
- function isToolCallFailure(
66
- result: McpToolCallResult
67
- ): result is { ok: false; error?: Error } {
68
- return result.ok === false;
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
- * Did the adapter refuse the registration?
103
+ * Loads the native MCP client library.
73
104
  *
74
- * The same reason as isToolCallFailure: the guard narrows in every setting.
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
- function isRegistrationFailure(
77
- result: NonNullable<McpRuntimeRegistrationRequest["result"]>
78
- ): result is { ok: false; error: Error } {
79
- return result.ok === false;
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
- * Honcho session identity
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
- interface HonchoIdentity {
86
- workspaceId: string;
87
- userPeerId: string;
88
- assistantPeerId: string;
89
- sessionId: string;
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
- const MCP_RUNTIME_REGISTER_EVENT = "pi-mcp-adapter:runtime-register:v1";
97
- const MCP_RUNTIME_TOOL_CALL_EVENT = "pi-mcp-adapter:runtime-tool-call:v1";
154
+ /** The Honcho MCP server (streamable HTTP). */
155
+ const HONCHO_MCP_URL = "https://mcp.honcho.dev";
98
156
 
99
- /** Honcho MCP server name in the adapter */
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
- /** Stored pi.events for use in tool calls */
168
- let _piEvents: PiEvents | null = null;
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.peer as { id?: unknown } | undefined)?.id;
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
- * Call a tool on the Honcho MCP server through the adapter.
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
- // Use stored pi.events from initHoncho
266
- if (!_piEvents) {
267
- return { success: false, error: "pi.events not available" };
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: JSON.stringify(args).slice(0, 100) });
650
+ logServiceEvent({ ts: new Date().toISOString(), level: "debug", service: "honcho", operation: "tool_call", phase: "start", tool: toolName, args: describeArgs(args) });
280
651
 
281
- // Emit tool call
282
- piEvents.emit(MCP_RUNTIME_TOOL_CALL_EVENT, request);
283
-
284
- // Read result immediately after emit. The value goes into a local: reading
285
- // the property again after the emit would lose the narrowing, because a
286
- // call may change the property.
287
- const pending = request.result;
288
- if (pending === undefined) {
289
- logServiceEvent({ ts: new Date().toISOString(), level: "error", service: "honcho", operation: "tool_call", phase: "failed", tool: toolName, reason: "no response from adapter" });
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
- // The adapter sets the promise during the emit - await it.
294
- const result = await pending;
295
-
296
- if (isToolCallFailure(result)) {
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
- logServiceEvent({ ts: new Date().toISOString(), level: "debug", service: "honcho", operation: "tool_call", phase: "completed", tool: toolName, result: JSON.stringify(result.result).slice(0, 200) });
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: result.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: Honcho MCP does NOT have create_workspace/get_workspace tools.
341
- * Workspaces are created via CLI/API. MCP tools are for session management.
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
- const workspaceId = response.workspace?.id ?? response.id ?? workspaceName;
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
- * Initialize Honcho MCP integration.
1076
+ * Run a promise with a timeout.
629
1077
  *
630
- * Must be called during session_start or later when the adapter listener is installed.
631
- * Reads request.result synchronously after emit per the adapter contract.
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
- * @param options Initialization options
634
- * @returns Promise resolving to the memory instance (or null if registration failed)
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
- * Pi events interface for lifecycle hooks
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
- interface PiEvents {
640
- on: (event: string, handler: (...args: unknown[]) => void | Promise<void>) => void;
641
- emit: (event: string, ...args: unknown[]) => void;
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
- piEvents?: PiEvents;
651
- }): Promise<HonchoMemory | null> {
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 null;
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 && _initSucceeded) {
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
- return _honchoMemory;
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
- // Reset state if retrying
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
- _initSucceeded = false;
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 null;
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
- // Store pi.events for use in tool calls
684
- if (options?.piEvents) {
685
- _piEvents = options.piEvents;
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
- // Ensure MCP config directory exists (non-destructive)
689
- ensureMcpConfigDirectory();
1361
+ _mcpClient = client;
690
1362
 
691
- // Write MCP config for the adapter to pick up
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
- writeHonchoMcpConfig(key);
694
- logServiceEvent({ ts: new Date().toISOString(), level: "info", service: "honcho", operation: "mcp_config", phase: "completed" });
1366
+ await client.listTools();
695
1367
  } catch (err) {
696
- logServiceEvent({ ts: new Date().toISOString(), level: "warn", service: "honcho", operation: "mcp_config", phase: "failed", reason: err instanceof Error ? err.message : String(err) });
697
- // Continue anyway - adapter may use its own config
698
- }
699
-
700
- // Get pi.events from options (passed from startup) or fall back to global
701
- const piEvents = options?.piEvents ?? (globalThis as { pi?: { events?: PiEvents } }).pi?.events;
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
- if (!piEvents) {
704
- logServiceEvent({ ts: new Date().toISOString(), level: "error", service: "honcho", operation: "init", phase: "failed", reason: "pi.events not available" });
705
- honchoStatus.recordFailure("init", "adapter_missing", { error: "pi.events not available" });
706
- return null;
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
- // Create registration request with bearer token authentication
710
- const request: McpRuntimeRegistrationRequest = {
711
- version: 1,
712
- name: HONCHO_SERVER_NAME,
713
- definition: {
714
- url: "https://mcp.honcho.dev",
715
- headers: {
716
- Authorization: `Bearer ${key}`,
717
- },
718
- },
719
- };
720
-
721
- // Emit registration event - adapter writes request.result synchronously
722
- piEvents.emit(MCP_RUNTIME_REGISTER_EVENT, request);
723
-
724
- // Read result immediately after emit. The value goes into a local: reading
725
- // the property again after the emit would lose the narrowing, because a
726
- // call may change the property.
727
- const registration = request.result;
728
- if (registration === undefined) {
729
- logServiceEvent({ ts: new Date().toISOString(), level: "error", service: "honcho", operation: "mcp_register", phase: "failed", reason: "MCP adapter not responding" });
730
- logServiceEvent({ ts: new Date().toISOString(), level: "info", service: "honcho", operation: "mcp_register", phase: "failed", helpUrl: "pi install npm:pi-mcp-adapter" });
731
- honchoStatus.recordFailure("init", "no_listener", { error: "No adapter responded" });
732
- return null;
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
- if (isRegistrationFailure(registration)) {
736
- const errorMsg = registration.error?.message ?? "Unknown registration error";
737
-
738
- // Check for duplicate name - an existing server may already be registered
739
- if (errorMsg.includes("already") || errorMsg.includes("duplicate")) {
740
- logServiceEvent({ ts: new Date().toISOString(), level: "warn", service: "honcho", operation: "mcp_register", phase: "completed", reason: "server already registered (duplicate name)" });
741
- // Continue with memory initialization - the existing server should work
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
- logServiceEvent({ ts: new Date().toISOString(), level: "error", service: "honcho", operation: "mcp_register", phase: "failed", reason: errorMsg });
744
- honchoStatus.recordFailure("init", "registration_rejected", { error: errorMsg });
745
- return null;
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
- // MCP registration done - identity setup deferred to session_start
759
- // (MCP tool calls require an active Pi session)
760
- logServiceEvent({ ts: new Date().toISOString(), level: "info", service: "honcho", operation: "mcp_register", phase: "completed", reason: "identity_setup_deferred" });
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
- return _honchoMemory;
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
- * Disposes owned registration, stops timers, clears state.
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
- // Dispose owned registration
959
- if (_honchoRegistration) {
1669
+ // Close the native MCP client
1670
+ if (_mcpClient) {
960
1671
  try {
961
- await _honchoRegistration.dispose();
962
- logServiceEvent({ ts: new Date().toISOString(), level: "info", service: "honcho", operation: "dispose_registration", phase: "completed" });
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: "dispose_registration", phase: "failed", reason: err instanceof Error ? err.message : String(err) });
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
- _honchoRegistration = null;
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();