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
@@ -13,16 +13,14 @@
13
13
 
14
14
  import { logServiceEvent, honchoStatus } from "./service-diagnostics.js";
15
15
  import { honchoPayload, honchoToolCall } from "./honcho-init.js";
16
- import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync, unlinkSync } from "node:fs";
16
+ import { existsSync, readFileSync } from "node:fs";
17
17
  import { join } from "node:path";
18
18
  import { homedir } from "node:os";
19
19
 
20
20
  // ---------------------------------------------------------------------------
21
- // Honcho MCP Configuration (non-destructive)
21
+ // Honcho credential
22
22
  // ---------------------------------------------------------------------------
23
23
 
24
- const MCP_CONFIG_DIR = join(homedir(), ".config", "mcp");
25
- const MCP_CONFIG_FILE = join(MCP_CONFIG_DIR, "mcp.json");
26
24
  const HONCHO_KEY_FILE = join(homedir(), ".pi-harness-runtime", "keys", "honcho-api-key.txt");
27
25
 
28
26
  /**
@@ -78,20 +76,30 @@ function redactSecrets(text: string): string {
78
76
  }
79
77
 
80
78
  /**
81
- * Ensure MCP config directory exists (non-destructive).
82
- * Does NOT write config at import time.
83
- */
84
- export function ensureMcpConfigDirectory(): void {
85
- if (!existsSync(MCP_CONFIG_DIR)) {
86
- mkdirSync(MCP_CONFIG_DIR, { recursive: true, mode: 0o755 });
87
- }
88
- }
89
-
90
- /**
91
- * Load Honcho API key from keys directory.
79
+ * Load the Honcho API key.
80
+ *
81
+ * The environment variable wins over the key file: it is the explicit
82
+ * override of a session. The key file is the per-user default.
92
83
  * Does NOT write config files.
84
+ *
85
+ * This is deliberately not `key-loader.loadKey`: that one resolves the key
86
+ * through the Infisical provider, and the Honcho startup must stay a local
87
+ * read (no secret manager, no network in the startup path). The environment
88
+ * it reads is already filled by the key loader when another runtime filled
89
+ * it.
93
90
  */
94
91
  export function loadHonchoApiKey(): string | undefined {
92
+ // `process` does not exist in every context (a sandboxed module), so
93
+ // the environment is read defensively.
94
+ const fromEnv =
95
+ typeof process !== "undefined" && process.env
96
+ ? process.env.HONCHO_API_KEY
97
+ : undefined;
98
+ const envKey = typeof fromEnv === "string" ? fromEnv.trim() : "";
99
+ if (envKey !== "") {
100
+ return envKey;
101
+ }
102
+
95
103
  if (existsSync(HONCHO_KEY_FILE)) {
96
104
  try {
97
105
  return readFileSync(HONCHO_KEY_FILE, "utf8").trim() || undefined;
@@ -102,58 +110,6 @@ export function loadHonchoApiKey(): string | undefined {
102
110
  return undefined;
103
111
  }
104
112
 
105
- /**
106
- * Read existing MCP config (non-destructive).
107
- * Returns null if config doesn't exist or is invalid.
108
- */
109
- export function readMcpConfig(): Record<string, unknown> | null {
110
- if (!existsSync(MCP_CONFIG_FILE)) {
111
- return null;
112
- }
113
- try {
114
- const content = readFileSync(MCP_CONFIG_FILE, "utf8");
115
- return JSON.parse(content);
116
- } catch {
117
- return null;
118
- }
119
- }
120
-
121
- /**
122
- * Write MCP config atomically (non-destructive).
123
- * Only updates the Honcho server entry, preserves other entries.
124
- */
125
- export function writeHonchoMcpConfig(apiKey: string): void {
126
- ensureMcpConfigDirectory();
127
-
128
- // Read existing config or create new one
129
- let config: Record<string, unknown> = readMcpConfig() || { mcpServers: {} };
130
- if (!config.mcpServers || typeof config.mcpServers !== "object") {
131
- config = { mcpServers: {} };
132
- }
133
-
134
- // Update Honcho entry
135
- const mcpServers = config.mcpServers as Record<string, unknown>;
136
- mcpServers.honcho = {
137
- url: "https://mcp.honcho.dev",
138
- headers: {
139
- Authorization: `Bearer ${apiKey}`,
140
- },
141
- };
142
-
143
- // Atomic write with temp file
144
- const tempFile = `${MCP_CONFIG_FILE}.tmp`;
145
- writeFileSync(tempFile, JSON.stringify(config, null, 2), { mode: 0o644 });
146
- try {
147
- // Verify the temp file is valid JSON before renaming
148
- JSON.parse(readFileSync(tempFile, "utf8"));
149
- renameSync(tempFile, MCP_CONFIG_FILE);
150
- } catch {
151
- // Clean up invalid temp file
152
- try { unlinkSync(tempFile); } catch { /* ignore */ }
153
- throw new Error("Failed to write valid MCP config");
154
- }
155
- }
156
-
157
113
  // ---------------------------------------------------------------------------
158
114
  // Honcho Health Check - Detect suspended accounts
159
115
  // ---------------------------------------------------------------------------
@@ -274,7 +230,13 @@ export function stopHonchoHealthCheck(): void {
274
230
  * Honcho memory configuration
275
231
  */
276
232
  export interface HonchoMemoryConfig {
277
- /** Honcho API key */
233
+ /**
234
+ * Honcho API key.
235
+ *
236
+ * Accepted, but the class does not read it: the calls go through the
237
+ * native MCP client of honcho-init, which owns the credential of the
238
+ * session. The field stays for the callers that still pass it.
239
+ */
278
240
  apiKey: string;
279
241
  /** Workspace ID for memory storage */
280
242
  workspaceId?: string;
@@ -475,7 +437,7 @@ export class HonchoMemory {
475
437
  }
476
438
 
477
439
  /**
478
- * Call a Honcho MCP tool through the adapter.
440
+ * Call a Honcho MCP tool through the native MCP client.
479
441
  * Uses the honchoToolCall from honcho-init.ts.
480
442
  */
481
443
  private async callHonchoTool(
@@ -15,7 +15,13 @@
15
15
  * state of a file (missing / empty).
16
16
  */
17
17
 
18
- import { existsSync, lstatSync, readFileSync, readdirSync } from "node:fs";
18
+ import {
19
+ chmodSync,
20
+ existsSync,
21
+ lstatSync,
22
+ readFileSync,
23
+ readdirSync,
24
+ } from "node:fs";
19
25
  import { homedir } from "node:os";
20
26
  import { join } from "node:path";
21
27
 
@@ -390,6 +396,78 @@ export interface KeyFileAuditResult {
390
396
  exposedDirectories: ExposedKeyDirectory[];
391
397
  }
392
398
 
399
+ export interface SecureKeyTreeResult {
400
+ /** False on platforms where chmod does not express the required ACL. */
401
+ supported: boolean;
402
+ securedFiles: number;
403
+ securedDirectories: number;
404
+ failedPaths: string[];
405
+ }
406
+
407
+ /**
408
+ * Restrict the key tree to its owner on Unix-like systems.
409
+ *
410
+ * This runs only on the dedicated application key directory. It never
411
+ * follows symlinks, and records per-path failures so startup can retain a
412
+ * warning when the current user cannot repair an entry (for example, when
413
+ * it is owned by another account).
414
+ */
415
+ export function secureKeyTree(
416
+ keysDir: string,
417
+ platform: NodeJS.Platform = process.platform,
418
+ ): SecureKeyTreeResult {
419
+ const result: SecureKeyTreeResult = {
420
+ supported: platform !== "win32",
421
+ securedFiles: 0,
422
+ securedDirectories: 0,
423
+ failedPaths: [],
424
+ };
425
+ if (!result.supported) return result;
426
+
427
+ const root = defaultStat(keysDir);
428
+ if (!root?.isDirectory || root.isSymbolicLink) {
429
+ result.failedPaths.push(keysDir);
430
+ return result;
431
+ }
432
+
433
+ const secureDirectory = (path: string): boolean => {
434
+ try {
435
+ chmodSync(path, 0o700);
436
+ result.securedDirectories++;
437
+ return true;
438
+ } catch {
439
+ result.failedPaths.push(path);
440
+ return false;
441
+ }
442
+ };
443
+
444
+ const secureFile = (path: string): void => {
445
+ try {
446
+ chmodSync(path, 0o600);
447
+ result.securedFiles++;
448
+ } catch {
449
+ result.failedPaths.push(path);
450
+ }
451
+ };
452
+
453
+ const walk = (dir: string, depth: number): void => {
454
+ if (depth > MAX_KEY_TREE_DEPTH) return;
455
+ for (const name of defaultReaddir(dir).sort(compareByName)) {
456
+ const path = join(dir, name);
457
+ const info = defaultStat(path);
458
+ if (!info || info.isSymbolicLink) continue;
459
+ if (info.isDirectory) {
460
+ if (secureDirectory(path)) walk(path, depth + 1);
461
+ } else if (info.isFile) {
462
+ secureFile(path);
463
+ }
464
+ }
465
+ };
466
+
467
+ if (secureDirectory(keysDir)) walk(keysDir, 1);
468
+ return result;
469
+ }
470
+
393
471
  /**
394
472
  * Compare two file names in a stable, locale independent order.
395
473
  */
@@ -0,0 +1,348 @@
1
+ /**
2
+ * The live countdown of a pending question.
3
+ *
4
+ * The harness asks a human a question and waits. The wait has a deadline,
5
+ * and the countdown shows the remaining time in the status row of the TUI
6
+ * and starts the decision of the judge exactly at that deadline.
7
+ *
8
+ * The module owns the two policies of the wait:
9
+ *
10
+ * - the first countdown counts down to the deadline of the decision, which
11
+ * is derived from the moment the wait started,
12
+ * - the recheck counts down to the next attempt, after the judge asked to
13
+ * wait longer.
14
+ *
15
+ * The caller cannot pass a deadline of its own: the displayed deadline and
16
+ * the deadline the judge uses therefore cannot drift apart.
17
+ *
18
+ * The countdown is bound to one request. Every start replaces the previous
19
+ * binding and every stop releases the timer, so there is never more than
20
+ * one countdown and never a timer without a request.
21
+ */
22
+
23
+ /** What the countdown counts to. */
24
+ export type CountdownMode = "initial" | "recheck";
25
+
26
+ /** The state of the countdown that is drawn in the status row. */
27
+ export interface CountdownView {
28
+ /** The request the countdown belongs to. */
29
+ requestId: string;
30
+ /** The milliseconds that are left. Never negative. */
31
+ remainingMs: number;
32
+ /** The remaining time as "MM:SS". */
33
+ remaining: string;
34
+ /** What the countdown counts to. */
35
+ mode: CountdownMode;
36
+ }
37
+
38
+ /** The dependencies of a countdown. */
39
+ export interface PendingCountdownDeps {
40
+ /** Draw the countdown in the status row. */
41
+ render: (view: CountdownView) => void;
42
+ /** The deadline was reached and the decision is due. */
43
+ onDeadline: (requestId: string) => void;
44
+ /**
45
+ * Is the request still the pending one?
46
+ *
47
+ * A tick of a question that was answered or replaced in the meantime
48
+ * must not redraw its status and must not trigger a decision.
49
+ */
50
+ isPending: (requestId: string) => boolean;
51
+ /** A failing callback of the countdown is reported here. */
52
+ onError?: (error: unknown) => void;
53
+ /** The wait is over: the status row is cleared. */
54
+ onStop?: (requestId: string | null) => void;
55
+ /** The minutes the judge waits for a human answer. */
56
+ waitMinutes: number;
57
+ /** The milliseconds until the next attempt after the judge asked to wait. */
58
+ recheckIntervalMs: number;
59
+ /** The clock (the tests drive it). */
60
+ now?: () => number;
61
+ /** The timer that drives the ticks (the tests drive it). */
62
+ setInterval?: (callback: () => void, ms: number) => unknown;
63
+ /** The timer that stops the ticks (the tests drive it). */
64
+ clearInterval?: (handle: unknown) => void;
65
+ /** The tick interval in milliseconds. */
66
+ tickMs?: number;
67
+ }
68
+
69
+ /** The milliseconds between two ticks of the display. */
70
+ const COUNTDOWN_TICK_MS = 1_000;
71
+
72
+ /**
73
+ * The smallest tick interval.
74
+ *
75
+ * A shorter interval would repaint the status row faster than a reader can
76
+ * follow it and would waste the event loop of the harness.
77
+ */
78
+ const MIN_TICK_MS = 100;
79
+
80
+ /**
81
+ * The deadline of the decision.
82
+ *
83
+ * The deadline is derived from the moment the wait started, so the
84
+ * countdown and the judge share one timestamp.
85
+ */
86
+ export function decisionDeadlineAt(startedAt: number, waitMinutes: number): number {
87
+ const minutes = Number.isFinite(waitMinutes) ? Math.max(0, waitMinutes) : 0;
88
+ const started = Number.isFinite(startedAt) ? startedAt : 0;
89
+ return started + minutes * 60_000;
90
+ }
91
+
92
+ /**
93
+ * The remaining time as "MM:SS".
94
+ *
95
+ * A time that is not a number is shown as an empty countdown: a status row
96
+ * must never claim a time that does not exist.
97
+ */
98
+ export function formatRemainingTime(remainingMs: number): string {
99
+ const safeMs = Number.isFinite(remainingMs) ? Math.max(0, remainingMs) : 0;
100
+ const totalSeconds = Math.floor(safeMs / 1_000);
101
+ const minutes = Math.floor(totalSeconds / 60);
102
+ const seconds = totalSeconds % 60;
103
+ return `${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`;
104
+ }
105
+
106
+ /** The binding of a running countdown. */
107
+ interface CountdownBinding {
108
+ requestId: string;
109
+ deadlineAt: number;
110
+ mode: CountdownMode;
111
+ }
112
+
113
+ /**
114
+ * The countdown of one pending question.
115
+ *
116
+ * The countdown renders at once: the status row must show the remaining
117
+ * time without waiting for the first tick.
118
+ */
119
+ export class PendingQuestionCountdown {
120
+ private readonly deps: PendingCountdownDeps;
121
+ private readonly now: () => number;
122
+ private readonly startTimer: (callback: () => void, ms: number) => unknown;
123
+ private readonly stopTimer: (handle: unknown) => void;
124
+ private readonly tickMs: number;
125
+ private handle: unknown = null;
126
+ private binding: CountdownBinding | null = null;
127
+ /**
128
+ * Bumped on every start and every stop.
129
+ *
130
+ * A tick of an older generation belongs to a binding that is gone: it is
131
+ * ignored instead of redrawing a status or triggering a decision.
132
+ */
133
+ private generation = 0;
134
+ /**
135
+ * True while the countdown owns the wait.
136
+ *
137
+ * The countdown stays engaged after its deadline fired, until the
138
+ * decision path stops it or replaces it. The fallback poll uses this to
139
+ * stay out of the way of the accurate timer.
140
+ */
141
+ private engaged = false;
142
+ /**
143
+ * True when the deadline fired and the decision did not start yet.
144
+ *
145
+ * The countdown owns the wait in that state, but it has no timer: the
146
+ * caller arms it again when the decision did not start.
147
+ */
148
+ private deadlineFired = false;
149
+
150
+ constructor(deps: PendingCountdownDeps) {
151
+ this.deps = deps;
152
+ this.now = deps.now ?? (() => Date.now());
153
+ this.startTimer =
154
+ deps.setInterval ??
155
+ ((callback, ms) => setInterval(callback, ms) as unknown);
156
+ this.stopTimer =
157
+ deps.clearInterval ??
158
+ ((handle) => clearInterval(handle as NodeJS.Timeout));
159
+ this.tickMs = Math.max(MIN_TICK_MS, deps.tickMs ?? COUNTDOWN_TICK_MS);
160
+ }
161
+
162
+ /** Does the countdown own the current wait? */
163
+ get active(): boolean {
164
+ return this.engaged;
165
+ }
166
+
167
+ /** Did the deadline fire and is the decision still due? */
168
+ get fired(): boolean {
169
+ return this.deadlineFired;
170
+ }
171
+
172
+ /** The request the countdown belongs to. */
173
+ get requestId(): string | null {
174
+ return this.binding?.requestId ?? null;
175
+ }
176
+
177
+ /** What the countdown counts to. */
178
+ get mode(): CountdownMode | null {
179
+ return this.binding?.mode ?? null;
180
+ }
181
+
182
+ /**
183
+ * Start the countdown of a request, or replace the running one.
184
+ *
185
+ * The deadline of the decision is derived from the moment the wait
186
+ * started: the caller passes that timestamp, the countdown computes the
187
+ * deadline. The countdown and the judge therefore share one timestamp.
188
+ */
189
+ start(requestId: string, startedAt: number): void {
190
+ this.begin(requestId, decisionDeadlineAt(startedAt, this.deps.waitMinutes), "initial");
191
+ }
192
+
193
+ /**
194
+ * Start the countdown of the next attempt.
195
+ *
196
+ * The judge asked to wait longer: the countdown counts down to the next
197
+ * attempt instead of to the deadline of the decision.
198
+ */
199
+ startRecheck(requestId: string): void {
200
+ this.begin(requestId, this.now() + this.deps.recheckIntervalMs, "recheck");
201
+ }
202
+
203
+ /**
204
+ * Stop the countdown and release its timer.
205
+ *
206
+ * Stopping is idempotent: a countdown that is not running can be stopped
207
+ * as often as the cleanup paths need. The status row is cleared with it,
208
+ * so a dead countdown never leaves a row that claims a decision is due.
209
+ */
210
+ stop(): void {
211
+ const requestId = this.binding?.requestId ?? null;
212
+ this.engaged = false;
213
+ this.deadlineFired = false;
214
+ this.generation += 1;
215
+ this.clearTimer();
216
+ this.binding = null;
217
+ this.safeCall(() => this.deps.onStop?.(requestId));
218
+ }
219
+
220
+ /** Bind the countdown to a request and start its timer. */
221
+ private begin(requestId: string, deadlineAt: number, mode: CountdownMode): void {
222
+ this.clearTimer();
223
+ this.generation += 1;
224
+ const generation = this.generation;
225
+ this.binding = { requestId, deadlineAt, mode };
226
+ this.engaged = true;
227
+ this.deadlineFired = false;
228
+ try {
229
+ this.handle = this.startTimer(() => this.tick(generation), this.tickMs);
230
+ } catch (error) {
231
+ // The timer could not be created: the countdown owns nothing, so
232
+ // it must not claim the wait. The row of the countdown is
233
+ // cleared with it, so no row claims a decision that is not due.
234
+ this.engaged = false;
235
+ this.binding = null;
236
+ this.safeCall(() => this.deps.onStop?.(requestId));
237
+ this.report(error);
238
+ return;
239
+ }
240
+ this.tick(generation);
241
+ }
242
+
243
+ /**
244
+ * One tick of the countdown.
245
+ *
246
+ * The tick is a no-op when it belongs to a binding that is gone, or
247
+ * when the request it counts for is no longer the pending one.
248
+ */
249
+ private tick(generation: number): void {
250
+ if (!this.engaged || generation !== this.generation) return;
251
+ const binding = this.binding;
252
+ if (!binding) return;
253
+
254
+ let remainingMs: number;
255
+ try {
256
+ if (!this.deps.isPending(binding.requestId)) {
257
+ // The question was answered or replaced: the countdown of
258
+ // that question is over.
259
+ this.stop();
260
+ return;
261
+ }
262
+ // The callback may have stopped the countdown: the binding of
263
+ // this tick is gone and must not be drawn any more.
264
+ if (!this.engaged || generation !== this.generation) return;
265
+ remainingMs = Math.max(0, binding.deadlineAt - this.now());
266
+ } catch (error) {
267
+ // A failing callback must never escape the timer: the countdown
268
+ // stops and the failure is reported.
269
+ this.stop();
270
+ this.report(error);
271
+ return;
272
+ }
273
+
274
+ if (remainingMs <= 0) {
275
+ // The deadline is reached. The timer stops, the countdown stays
276
+ // engaged until the decision path stops it or replaces it, so
277
+ // the fallback poll does not start a second decision.
278
+ this.clearTimer();
279
+ this.deadlineFired = true;
280
+ // The countdown reaches its zero before the decision starts.
281
+ this.safeRender({
282
+ requestId: binding.requestId,
283
+ remainingMs: 0,
284
+ remaining: formatRemainingTime(0),
285
+ mode: binding.mode,
286
+ });
287
+ // A failing decision handler must not leave a countdown that owns
288
+ // the wait without a timer: the countdown stops, so the fallback
289
+ // poll can take over.
290
+ try {
291
+ this.deps.onDeadline(binding.requestId);
292
+ } catch (error) {
293
+ this.stop();
294
+ this.report(error);
295
+ }
296
+ return;
297
+ }
298
+
299
+ this.safeRender({
300
+ requestId: binding.requestId,
301
+ remainingMs,
302
+ remaining: formatRemainingTime(remainingMs),
303
+ mode: binding.mode,
304
+ });
305
+ }
306
+
307
+ /** Draw the countdown, and report a failing status row. */
308
+ private safeRender(view: CountdownView): void {
309
+ try {
310
+ this.deps.render(view);
311
+ } catch (error) {
312
+ this.stop();
313
+ this.report(error);
314
+ }
315
+ }
316
+
317
+ /** Run a callback of the countdown without letting it escape. */
318
+ private safeCall(action: () => void): void {
319
+ try {
320
+ action();
321
+ } catch (error) {
322
+ this.report(error);
323
+ }
324
+ }
325
+
326
+ /** Report a failure without letting the report escape. */
327
+ private report(error: unknown): void {
328
+ try {
329
+ this.deps.onError?.(error);
330
+ } catch {
331
+ // The report of a failure must not become a new failure.
332
+ }
333
+ }
334
+
335
+ /** Release the timer of the countdown. */
336
+ private clearTimer(): void {
337
+ if (this.handle === null) return;
338
+ const handle = this.handle;
339
+ this.handle = null;
340
+ try {
341
+ this.stopTimer(handle);
342
+ } catch (error) {
343
+ // The timer is already released from the point of view of the
344
+ // countdown: the failure is reported, nothing is left behind.
345
+ this.report(error);
346
+ }
347
+ }
348
+ }
@@ -101,7 +101,7 @@ async function handleTestJev(ctx: ExtensionContext, flags: Record<string, string
101
101
 
102
102
  /**
103
103
  * Handle /harness-services test honcho --read-only
104
- * Checks Honcho integration status through the MCP adapter.
104
+ * Checks the Honcho integration status.
105
105
  * Does NOT create sessions/peers, write messages, or claim success when only flags were inspected.
106
106
  */
107
107
  async function handleTestHoncho(ctx: ExtensionContext, flags: Record<string, string | boolean>): Promise<void> {
@@ -113,33 +113,65 @@ async function handleTestHoncho(ctx: ExtensionContext, flags: Record<string, str
113
113
  ctx.ui.notify("[services] Testing Honcho MCP connection...", "info");
114
114
 
115
115
  try {
116
- // Report actual registration status from the status tracker
116
+ // Report actual status from the status tracker
117
117
  ctx.ui.notify(`[services] Honcho configured: ${honchoStatus.configured}`, "info");
118
118
  ctx.ui.notify(`[services] Honcho connected: ${honchoStatus.connected}`, "info");
119
119
  ctx.ui.notify(`[services] Honcho feature enabled: ${honchoStatus.featureEnabled}`, "info");
120
120
 
121
- // Check if MCP adapter is available via globalThis
122
- // SAFETY: We're accessing pi.events which may not exist in all contexts
123
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
124
- const globalAny = globalThis as any;
125
- const piEvents = globalAny.pi?.events;
126
-
127
- if (!piEvents) {
128
- ctx.ui.notify("[services] Honcho test skipped: MCP adapter not available (pi.events)", "warning");
129
- ctx.ui.notify("[services] Note: Install pi-mcp-adapter to enable Honcho MCP", "info");
130
- return;
121
+ // The cause of the last attempt, when the integration knows one.
122
+ // The reason is credential-free: it names the status and the cause.
123
+ let reason: string | undefined;
124
+ // The memory client and the native tools are two things: a connected
125
+ // client does not mean that the model sees the Honcho tools.
126
+ let nativeTools: boolean | undefined;
127
+ try {
128
+ const { getHonchoInitResult } = await import("./honcho-init.js");
129
+ const init = getHonchoInitResult();
130
+ if (init && init.status !== "ready") {
131
+ reason = `${init.status}: ${init.reason ?? "no reason reported"}`;
132
+ }
133
+ if (init && init.status === "ready") {
134
+ nativeTools = init.mcpRegistered === true;
135
+ if (!nativeTools && init.mcpRegistrationReason) {
136
+ reason = `native tools not registered: ${init.mcpRegistrationReason}`;
137
+ }
138
+ }
139
+ } catch {
140
+ // The module is optional for this command: the tracker still reports.
131
141
  }
132
142
 
133
- // For read-only test, report current status from our tracker
134
- // A full MCP tool discovery would require a non-read-only request
135
143
  if (honchoStatus.connected) {
136
- ctx.ui.notify("[services] Honcho MCP server is connected", "info");
137
- ctx.ui.notify("[services] Honcho test completed successfully", "info");
144
+ ctx.ui.notify("[services] Honcho memory client is connected", "info");
145
+ if (nativeTools === true) {
146
+ ctx.ui.notify("[services] Honcho native MCP tools are registered (the model sees them)", "info");
147
+ ctx.ui.notify("[services] Honcho test completed successfully", "info");
148
+ } else if (nativeTools === false) {
149
+ // The memory works, but the model has no Honcho tools: the check
150
+ // must not report a success that hides that.
151
+ ctx.ui.notify("[services] Honcho native MCP tools are NOT registered (the model sees no Honcho tools)", "warning");
152
+ if (reason) {
153
+ ctx.ui.notify(`[services] ${reason}`, "warning");
154
+ }
155
+ ctx.ui.notify("[services] The memory connection succeeded, but the native tool registration failed", "warning");
156
+ } else {
157
+ // The state of the native tools is unknown: only the memory
158
+ // connection is reported.
159
+ ctx.ui.notify("[services] Honcho memory test completed successfully", "info");
160
+ }
138
161
  } else if (honchoStatus.configured) {
139
162
  ctx.ui.notify("[services] Honcho MCP server not yet connected", "warning");
163
+ if (reason) {
164
+ ctx.ui.notify(`[services] Honcho is unavailable: ${reason}`, "warning");
165
+ }
166
+ if (honchoStatus.lastErrorCode) {
167
+ ctx.ui.notify(`[services] Last error: ${honchoStatus.lastErrorCode}`, "warning");
168
+ }
140
169
  ctx.ui.notify("[services] Connection will be established on first use", "info");
141
170
  } else {
142
171
  ctx.ui.notify("[services] Honcho MCP not configured", "warning");
172
+ if (reason) {
173
+ ctx.ui.notify(`[services] Honcho is unavailable: ${reason}`, "warning");
174
+ }
143
175
  ctx.ui.notify("[services] Add API key to enable Honcho memory", "info");
144
176
  }
145
177
  } catch (err) {