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-memory.ts
CHANGED
|
@@ -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,
|
|
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
|
|
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
|
-
*
|
|
82
|
-
*
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
/**
|
|
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
|
|
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 {
|
|
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
|
|
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
|
|
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
|
-
//
|
|
122
|
-
//
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
|
137
|
-
|
|
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) {
|