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
package/index.ts CHANGED
@@ -26,6 +26,7 @@ import {
26
26
  formatKeyFileFixes,
27
27
  formatKeyPermissionFixes,
28
28
  selectKeyFileFixes,
29
+ secureKeyTree,
29
30
  type KeyFileAuditResult,
30
31
  } from "./harness/key-file-audit.ts";
31
32
  import { renderDetailLines } from "./harness/startup-lines.ts";
@@ -41,7 +42,17 @@ import {
41
42
  relayShouldBeReported,
42
43
  } from "./harness/startup-report.ts";
43
44
  import { localSkillsReport, skillsReport } from "./harness/skills-startup.ts";
45
+ import {
46
+ PendingQuestionCountdown,
47
+ type CountdownView,
48
+ } from "./harness/pending-countdown.ts";
44
49
  import { isChannelDisabled, setChannelDisabled } from "./harness/notification-settings.ts";
50
+ // The Honcho contract lives with the Honcho integration. A type-only import
51
+ // is erased at runtime, so the startup keeps loading the module lazily.
52
+ import type {
53
+ HonchoInitResult,
54
+ HonchoMcpRegistrar,
55
+ } from "./harness/honcho-init.ts";
45
56
  import { UsageTracker } from "./tracker.ts";
46
57
  import { MirrorStore, type MirrorRecord } from "./mirror.ts";
47
58
  import { MiniMaxQuotaScraper } from "./harness/e2e/minimax-quota-scraper.js";
@@ -211,17 +222,44 @@ function getKeyFileAudit(): KeyFileAuditResult | null {
211
222
  * is a list nobody runs. The command is never executed here.
212
223
  */
213
224
  function reportExposedKeyFiles(): void {
214
- const audit = getKeyFileAudit();
215
- const exposed = audit?.exposed ?? [];
216
- const exposedDirectories = audit?.exposedDirectories ?? [];
225
+ let audit = getKeyFileAudit();
226
+ let exposed = audit?.exposed ?? [];
227
+ let exposedDirectories = audit?.exposedDirectories ?? [];
217
228
  if (exposed.length === 0 && exposedDirectories.length === 0) return;
229
+
230
+ // Key files belong to this app and are private by design. Repair unsafe
231
+ // Unix permissions automatically; keep the actionable warning only when
232
+ // the platform cannot express the repair or a path cannot be changed.
233
+ const repair = secureKeyTree(harnessKeysDir());
234
+ if (repair.supported) {
235
+ try {
236
+ audit = auditKeyFiles({ keysDir: harnessKeysDir() });
237
+ keyFileAudit = audit;
238
+ } catch {
239
+ // Preserve the original findings if the verification scan fails.
240
+ }
241
+ exposed = audit?.exposed ?? exposed;
242
+ exposedDirectories = audit?.exposedDirectories ?? exposedDirectories;
243
+ if (exposed.length === 0 && exposedDirectories.length === 0) {
244
+ addHarnessMessage(
245
+ "Keys",
246
+ `permissions secured automatically (${repair.securedFiles} files, ${repair.securedDirectories} directories)`,
247
+ "ok",
248
+ );
249
+ return;
250
+ }
251
+ }
252
+
218
253
  const report = exposedKeyFilesReport({
219
254
  exposed,
220
255
  exposedDirectories,
221
256
  fixes: formatKeyPermissionFixes({ keysDir: harnessKeysDir() }),
222
257
  });
223
258
  if (!report) return;
224
- addHarnessMessage(report.label, report.text, report.status, report.details);
259
+ const failedPaths = repair.failedPaths.length > 0
260
+ ? [`Could not change permissions for: ${repair.failedPaths.join(", ")}`]
261
+ : [];
262
+ addHarnessMessage(report.label, report.text, report.status, [...failedPaths, ...report.details]);
225
263
  }
226
264
 
227
265
  /**
@@ -258,6 +296,9 @@ const sharedState = {
258
296
  validationLedger: null as unknown,
259
297
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
260
298
  updateAutoContinueStatus: null as any,
299
+ // The live countdown of the pending question. The channels stop it when
300
+ // a human answer arrives.
301
+ pendingCountdown: null as PendingQuestionCountdown | null,
261
302
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
262
303
  pqController: null as any,
263
304
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -301,6 +342,8 @@ function applyChannelApproval(
301
342
  // cleared after a successful resolution.
302
343
  sharedState.waitingForUserSince = null;
303
344
  sharedState.updateAutoContinueStatus?.("IDLE");
345
+ // The countdown of the answered question is over with it.
346
+ sharedState.pendingCountdown?.stop();
304
347
 
305
348
  if (result.resolution === "yes") {
306
349
  (sharedState.todoContinuationController as any)?.resume?.();
@@ -898,6 +941,12 @@ async function initAutoContinue(pi: ExtensionAPI): Promise<void> {
898
941
  let lastUserMessage: Date | null = null;
899
942
  let autoContinueTimer: NodeJS.Timeout | null = null;
900
943
  let _inferenceInProgress = false; // Track for overlap prevention
944
+ // The timing of the wait. The countdown in the status row and the
945
+ // decision of the judge use the same constants, so the displayed
946
+ // deadline and the decision can never drift apart.
947
+ const AUTO_CONTINUE_INTERVAL_MS = 60 * 1000; // Fallback poll: every minute
948
+ const MIN_WAIT_MINUTES = 5; // Minimum wait before auto-continue
949
+ const MAX_WAIT_MINUTES = 30; // After this the work pauses instead of waiting
901
950
  // A continuation was dispatched and its turn has not settled yet.
902
951
  // The gate owns that state: the status stays "RUN" until the turn
903
952
  // settles, and no timer may clear it in the meantime.
@@ -989,6 +1038,15 @@ async function initAutoContinue(pi: ExtensionAPI): Promise<void> {
989
1038
  }
990
1039
  });
991
1040
  let _sessionId = Date.now().toString(36); // Track session for cancellation
1041
+ // The session of the Pi runtime. A countdown belongs to the session
1042
+ // that asked the question.
1043
+ let _piSessionId = "";
1044
+ // The session the running countdown belongs to. A countdown of a
1045
+ // session that is gone must not draw its status any more.
1046
+ let _countdownSessionId = "";
1047
+ // The request the row of the footer belongs to. A countdown that
1048
+ // stops must not clear the row of a newer question.
1049
+ let _countdownRowRequestId: string | null = null;
992
1050
  let _lastNotifiedQuestion = ""; // Track last question to prevent repeat notifications
993
1051
  let _completedNotifiedFor: string | null = null; // Track completion notification deduplication
994
1052
 
@@ -1014,36 +1072,164 @@ async function initAutoContinue(pi: ExtensionAPI): Promise<void> {
1014
1072
  ERROR: "Auto-continue unavailable",
1015
1073
  } as const;
1016
1074
 
1075
+ // The live countdown of the pending question.
1076
+ //
1077
+ // It owns the deadline of the wait: it shows the remaining time in
1078
+ // the status row once per second and starts the decision of the
1079
+ // judge exactly at the deadline. The fallback poll only takes over
1080
+ // when there is no countdown, so a decision is never started twice.
1081
+ const pendingCountdown = new PendingQuestionCountdown({
1082
+ // The two policies of the wait belong to the countdown, so the
1083
+ // displayed deadline and the deadline of the judge cannot drift
1084
+ // apart.
1085
+ waitMinutes: MIN_WAIT_MINUTES,
1086
+ recheckIntervalMs: AUTO_CONTINUE_INTERVAL_MS,
1087
+ render: (view: CountdownView) => {
1088
+ _countdownRowRequestId = view.requestId;
1089
+ updateAutoContinueStatus(
1090
+ "WAITING",
1091
+ view.requestId.slice(0, 8),
1092
+ view.mode === "recheck"
1093
+ ? `Jev will recheck in ${view.remaining}`
1094
+ : `Jev decision in ${view.remaining}`,
1095
+ );
1096
+ },
1097
+ onDeadline: (requestId: string) => {
1098
+ logToFile("[auto-continue]", "The decision deadline was reached:", requestId);
1099
+ checkAndAutoContinue("countdown")
1100
+ .then(() => {
1101
+ // The decision did not start - another one was still in
1102
+ // flight, or the wait was gone before it ran. The
1103
+ // deadline is not lost: the countdown is armed again
1104
+ // and the decision follows.
1105
+ if (pendingCountdown.fired && pendingCountdown.requestId === requestId) {
1106
+ pendingCountdown.startRecheck(requestId);
1107
+ }
1108
+ })
1109
+ .catch((error) => {
1110
+ logToFile("[auto-continue]", "The decision failed:", oneLineError(error));
1111
+ // The decision did not run: the countdown releases the
1112
+ // wait, so the fallback poll can take over again.
1113
+ pendingCountdown.stop();
1114
+ });
1115
+ },
1116
+ isPending: (requestId: string) => {
1117
+ // The wait must still belong to the request: a late tick of
1118
+ // an answered or replaced question must not draw its status
1119
+ // and must not trigger a decision.
1120
+ if (!waitingForUserSince) return false;
1121
+ if (_countdownSessionId !== _piSessionId) return false;
1122
+ const current = sharedState.pqController?.getCurrentQuestion?.();
1123
+ if (!current) return false;
1124
+ return (
1125
+ current.requestId === requestId &&
1126
+ (current.state === "pending" || current.state === "evaluating")
1127
+ );
1128
+ },
1129
+ onStop: (requestId: string | null) => {
1130
+ // The countdown is over: the row is cleared with it, so no
1131
+ // row claims a decision that is not due any more. Only the row
1132
+ // of that countdown is cleared: a row of another state - the
1133
+ // judge continues, for example - belongs to that state, and
1134
+ // the row of a newer question belongs to that question.
1135
+ if (_countdownRowRequestId && requestId && _countdownRowRequestId !== requestId) {
1136
+ return;
1137
+ }
1138
+ _countdownRowRequestId = null;
1139
+ if (_currentAutoContinueState.startsWith("[WAIT]")) {
1140
+ _currentAutoContinueState = "";
1141
+ _setAutoContinueStatus?.(undefined);
1142
+ }
1143
+ },
1144
+ onError: (error: unknown) => {
1145
+ logToFile("[auto-continue]", "The countdown failed:", oneLineError(error));
1146
+ },
1147
+ });
1148
+ sharedState.pendingCountdown = pendingCountdown;
1149
+
1017
1150
  // Wire shared state for callback handlers
1018
1151
  sharedState.waitingForUserSince = waitingForUserSince;
1019
1152
  sharedState.updateAutoContinueStatus = updateAutoContinueStatus;
1020
1153
 
1021
- // Update auto-continue status in TUI footer
1022
- function updateAutoContinueStatus(state: keyof typeof AC_STATE_MESSAGES, requestId?: string): void {
1023
- if (!_setAutoContinueStatus) return;
1154
+ /**
1155
+ * Start the countdown of a pending question.
1156
+ *
1157
+ * The deadline of the decision is derived from the wait that is
1158
+ * already running, so the displayed deadline and the decision of
1159
+ * the judge can never drift apart.
1160
+ */
1161
+ function startPendingCountdown(requestId: string): void {
1162
+ _countdownSessionId = _piSessionId;
1163
+ const startedAt = waitingForUserSince?.getTime() ?? Date.now();
1164
+ pendingCountdown.start(requestId, startedAt);
1165
+ }
1024
1166
 
1167
+ /**
1168
+ * Start the countdown of the next attempt.
1169
+ *
1170
+ * The judge asked to wait longer: the countdown counts down to the
1171
+ * next attempt instead of to the deadline of the decision.
1172
+ */
1173
+ function startRecheckCountdown(requestId: string): void {
1174
+ _countdownSessionId = _piSessionId;
1175
+ pendingCountdown.startRecheck(requestId);
1176
+ }
1177
+
1178
+ // Update auto-continue status in TUI footer
1179
+ function updateAutoContinueStatus(
1180
+ state: keyof typeof AC_STATE_MESSAGES,
1181
+ requestId?: string,
1182
+ detail?: string,
1183
+ ): void {
1025
1184
  const message = AC_STATE_MESSAGES[state];
1026
1185
  if (state === "IDLE") {
1027
- // Clear status
1028
- _setAutoContinueStatus(undefined);
1029
- _currentAutoContinueState = "";
1030
- } else {
1031
- const statusText = requestId
1032
- ? `[${AC_STATE[state]}] ${message} | ${requestId}`
1033
- : `[${AC_STATE[state]}] ${message}`;
1034
-
1035
- // Only update if state changed (prevent flicker)
1036
- if (statusText !== _currentAutoContinueState) {
1037
- _setAutoContinueStatus(statusText);
1038
- _currentAutoContinueState = statusText;
1186
+ // Only the row is cleared here: the countdown belongs to the
1187
+ // wait, not to the row. A settled continuation must not stop
1188
+ // the countdown of a question that the agent asked in the
1189
+ // same turn, and it must not blank its row either.
1190
+ if (!pendingCountdown.active) {
1191
+ _currentAutoContinueState = "";
1192
+ _setAutoContinueStatus?.(undefined);
1039
1193
  }
1194
+ return;
1195
+ }
1196
+
1197
+ const parts = [`[${AC_STATE[state]}] ${message}`];
1198
+ if (requestId) parts.push(requestId);
1199
+ if (detail) parts.push(detail);
1200
+ const statusText = parts.join(" | ");
1201
+
1202
+ // Only update if state changed (prevent flicker). The state is
1203
+ // kept even without a UI binding: a session that starts without
1204
+ // a TUI shows the current state as soon as the UI is there.
1205
+ if (statusText !== _currentAutoContinueState) {
1206
+ _currentAutoContinueState = statusText;
1207
+ _setAutoContinueStatus?.(statusText);
1040
1208
  }
1041
1209
  }
1042
1210
 
1043
1211
  // Capture UI context from session_start
1044
1212
  pi.on("session_start", (_event, ctx) => {
1213
+ // The session of the runtime: the countdown of a question belongs
1214
+ // to the session that asked it.
1215
+ const sessionId = (
1216
+ ctx as { sessionManager?: { getSessionId?: () => string | undefined } }
1217
+ ).sessionManager?.getSessionId?.();
1218
+ if (typeof sessionId === "string" && sessionId) {
1219
+ if (_piSessionId && _piSessionId !== sessionId) {
1220
+ // The session changed: the countdown of the old session
1221
+ // is over with it.
1222
+ pendingCountdown.stop();
1223
+ }
1224
+ _piSessionId = sessionId;
1225
+ }
1045
1226
  if (ctx.hasUI && ctx.ui) {
1046
1227
  _setAutoContinueStatus = ctx.ui.setStatus.bind(ctx.ui, "harness-auto-continue");
1228
+ // A session without a TUI collected the state already: it is
1229
+ // shown as soon as the UI is there.
1230
+ if (_currentAutoContinueState) {
1231
+ _setAutoContinueStatus(_currentAutoContinueState);
1232
+ }
1047
1233
  }
1048
1234
 
1049
1235
  });
@@ -1056,6 +1242,9 @@ async function initAutoContinue(pi: ExtensionAPI): Promise<void> {
1056
1242
  autoContinueTimer = null;
1057
1243
  }
1058
1244
 
1245
+ // The countdown of the pending question is over with the session.
1246
+ pendingCountdown.stop();
1247
+
1059
1248
  // Clear UI binding and status
1060
1249
  if (_setAutoContinueStatus) {
1061
1250
  _setAutoContinueStatus(undefined);
@@ -1114,6 +1303,8 @@ async function initAutoContinue(pi: ExtensionAPI): Promise<void> {
1114
1303
  waitingForUserSince = null;
1115
1304
  sharedState.waitingForUserSince = null;
1116
1305
  _lastNotifiedQuestion = "";
1306
+ // The question is answered: the countdown of the wait is over.
1307
+ pendingCountdown.stop();
1117
1308
  updateAutoContinueStatus("IDLE");
1118
1309
  // Dispatch answer to Pi
1119
1310
  const message = answer === "yes" ? "yes" : "not yet";
@@ -1190,8 +1381,10 @@ async function initAutoContinue(pi: ExtensionAPI): Promise<void> {
1190
1381
  // Use question.requestId as the jobId for callback matching
1191
1382
  const questionJobId = question.requestId;
1192
1383
 
1193
- // Update TUI status - use short ID for display
1194
- updateAutoContinueStatus("WAITING", questionJobId.slice(0, 8));
1384
+ // The status row counts down to the decision of the judge.
1385
+ // The countdown is bound to this request, so a newer
1386
+ // question replaces it.
1387
+ startPendingCountdown(questionJobId);
1195
1388
 
1196
1389
  // Get task context for additional info
1197
1390
  const taskContext = await getTaskContext().catch(() => ({ total: 0, completed: 0, remaining: [] as string[] }));
@@ -1354,6 +1547,9 @@ async function initAutoContinue(pi: ExtensionAPI): Promise<void> {
1354
1547
  if (waitingForUserSince) {
1355
1548
  waitingForUserSince = null;
1356
1549
  _lastNotifiedQuestion = "";
1550
+ // The user answered in Pi: the countdown of the wait is
1551
+ // over with it.
1552
+ pendingCountdown.stop();
1357
1553
  updateAutoContinueStatus("IDLE");
1358
1554
  // Cancel pending question on user response
1359
1555
  // (User answered via Pi instead of button)
@@ -1363,18 +1559,22 @@ async function initAutoContinue(pi: ExtensionAPI): Promise<void> {
1363
1559
  });
1364
1560
 
1365
1561
  // Periodic check for auto-continue decision
1366
- const AUTO_CONTINUE_INTERVAL_MS = 60 * 1000; // Check every 1 minute
1367
- const MIN_WAIT_MINUTES = 5; // Minimum wait before auto-continue
1368
- const MAX_WAIT_MINUTES = 30; // After this the work pauses instead of waiting
1369
1562
 
1370
- const checkAndAutoContinue = async () => {
1563
+ const checkAndAutoContinue = async (source: "countdown" | "poll" = "poll") => {
1371
1564
  // Prevent overlap if inference is already in progress
1372
1565
  if (_inferenceInProgress) {
1566
+ // A deadline that was blocked is armed again by the caller of
1567
+ // the countdown, so the decision is not lost.
1373
1568
  return;
1374
1569
  }
1375
1570
 
1376
1571
  if (!waitingForUserSince) return;
1377
1572
 
1573
+ // The countdown owns the wait while it runs: the fallback poll
1574
+ // only takes over when there is no countdown, so the decision is
1575
+ // never started twice.
1576
+ if (source === "poll" && pendingCountdown.active) return;
1577
+
1378
1578
  // Capture session ID at check time for post-inference validation
1379
1579
  const checkSessionId = _sessionId;
1380
1580
  // Use same hash algorithm as detection
@@ -1436,6 +1636,9 @@ async function initAutoContinue(pi: ExtensionAPI): Promise<void> {
1436
1636
  if (taskContext.total === 0 && !hasPendingQuestion) {
1437
1637
  logToFile("[auto-continue]", "No tasks and no pending questions, skipping auto-continue check");
1438
1638
  _inferenceInProgress = false;
1639
+ // The countdown is over: the fallback poll takes over the
1640
+ // next attempt.
1641
+ pendingCountdown.stop();
1439
1642
  return;
1440
1643
  }
1441
1644
 
@@ -1590,15 +1793,20 @@ async function initAutoContinue(pi: ExtensionAPI): Promise<void> {
1590
1793
  if (waitBelongsToRequest(questionContext?.requestId)) {
1591
1794
  waitingForUserSince = null;
1592
1795
  sharedState.waitingForUserSince = null;
1796
+ // The request expired: the countdown of the wait
1797
+ // is over with it.
1798
+ pendingCountdown.stop();
1593
1799
  updateAutoContinueStatus("IDLE");
1594
1800
  }
1595
1801
  } else {
1596
- // The request stays open: the next poll asks again
1597
- // until the deadline closes it.
1598
- updateAutoContinueStatus(
1599
- "WAITING",
1600
- questionContext?.requestId.slice(0, 8),
1601
- );
1802
+ // The request stays open: the countdown counts down to
1803
+ // the next check, so the retry does not wait for the
1804
+ // fallback poll.
1805
+ if (questionContext?.requestId) {
1806
+ startRecheckCountdown(questionContext.requestId);
1807
+ } else {
1808
+ updateAutoContinueStatus("WAITING");
1809
+ }
1602
1810
  }
1603
1811
  } else {
1604
1812
  // Close the question with the Jev decision. The buttons of
@@ -1635,6 +1843,9 @@ async function initAutoContinue(pi: ExtensionAPI): Promise<void> {
1635
1843
  waitingForUserSince = null;
1636
1844
  sharedState.waitingForUserSince = null;
1637
1845
  _lastNotifiedQuestion = "";
1846
+ // The stale decision is discarded: the countdown
1847
+ // of the wait is over with it.
1848
+ pendingCountdown.stop();
1638
1849
  updateAutoContinueStatus("IDLE");
1639
1850
  } else {
1640
1851
  logToFile(
@@ -1658,6 +1869,9 @@ async function initAutoContinue(pi: ExtensionAPI): Promise<void> {
1658
1869
  waitingForUserSince = null;
1659
1870
  sharedState.waitingForUserSince = null;
1660
1871
  _lastNotifiedQuestion = "";
1872
+ // The work pauses for a human: the countdown of
1873
+ // the wait is over with it.
1874
+ pendingCountdown.stop();
1661
1875
  updateAutoContinueStatus("IDLE");
1662
1876
  }
1663
1877
  return;
@@ -1691,6 +1905,8 @@ async function initAutoContinue(pi: ExtensionAPI): Promise<void> {
1691
1905
  waitingForUserSince = null;
1692
1906
  sharedState.waitingForUserSince = null;
1693
1907
  _lastNotifiedQuestion = "";
1908
+ // The question is closed: the countdown of the wait is over.
1909
+ pendingCountdown.stop();
1694
1910
 
1695
1911
  // The status stays "RUN" until the turn that the steer
1696
1912
  // started has settled. A timer would clear it while the
@@ -1701,6 +1917,9 @@ async function initAutoContinue(pi: ExtensionAPI): Promise<void> {
1701
1917
  _inferenceInProgress = false;
1702
1918
  } catch {
1703
1919
  _inferenceInProgress = false;
1920
+ // The countdown is over: the fallback poll takes over the next
1921
+ // attempt.
1922
+ pendingCountdown.stop();
1704
1923
  updateAutoContinueStatus("ERROR");
1705
1924
  addHarnessMessage("Jev", "auto-continue evaluation failed", "err", [
1706
1925
  "How to fix: Check logs at /tmp/pi-harness.log for API errors",
@@ -2074,7 +2293,10 @@ async function initAutoContinue(pi: ExtensionAPI): Promise<void> {
2074
2293
  });
2075
2294
 
2076
2295
  // Start periodic check
2077
- autoContinueTimer = setInterval(checkAndAutoContinue, AUTO_CONTINUE_INTERVAL_MS);
2296
+ autoContinueTimer = setInterval(
2297
+ () => checkAndAutoContinue("poll"),
2298
+ AUTO_CONTINUE_INTERVAL_MS,
2299
+ );
2078
2300
  if (jevKeyMissing) {
2079
2301
  // Without a provider the health check cannot run. The missing-key
2080
2302
  // message (with its fix commands) stays the visible one.
@@ -3220,39 +3442,65 @@ function initConsoleGuard(pi: ExtensionAPI): void {
3220
3442
  }
3221
3443
 
3222
3444
  // --- honcho-memory: Honcho MCP for peer/user memory via https://mcp.honcho.dev ------
3223
- // Registers Honcho MCP server and implements automatic memory lifecycle:
3445
+ // Registers the Honcho MCP server with Pi's NATIVE MCP support and implements
3446
+ // the automatic memory lifecycle:
3224
3447
  // - Ingest sanitized messages on completed turns
3225
3448
  // - Retrieve relevant context before agent execution
3226
3449
  // - Deduplication by message ID
3227
3450
  // - Bounded context injection with provenance
3228
3451
 
3452
+ /**
3453
+ * The contract of the Honcho integration module.
3454
+ *
3455
+ * The module is loaded at runtime, so the shape of its exports is declared
3456
+ * here: this is the only part TypeScript cannot check for us.
3457
+ */
3458
+ interface HonchoModule {
3459
+ initHoncho: (options: {
3460
+ apiKey?: string;
3461
+ pi?: HonchoMcpRegistrar;
3462
+ }) => Promise<HonchoInitResult>;
3463
+ registerHonchoLifecycle: (pi: unknown) => void;
3464
+ /** The credential: the environment wins over the key file. */
3465
+ loadHonchoApiKey: () => string | undefined;
3466
+ /** Removes the credential from a text. */
3467
+ redactHonchoKey: (text: string, apiKey: string) => string;
3468
+ }
3469
+
3229
3470
  async function initHoncho(pi: ExtensionAPI): Promise<void> {
3230
3471
  addHarnessMessage("Honcho", "initializing", "wait");
3472
+ let apiKey = "";
3473
+ let honcho: HonchoModule | null = null;
3231
3474
  try {
3232
- // Import new Honcho integration module
3233
- const { initHoncho: honchoInit, registerHonchoLifecycle } = await import("./harness/honcho-init.js");
3475
+ // Import the Honcho integration module.
3476
+ honcho = (await import("./harness/honcho-init.js")) as unknown as HonchoModule;
3234
3477
 
3235
- // Check for API key
3236
- const { readFileSync, existsSync } = await import("node:fs");
3237
- const home = process.env.HOME || process.env.USERPROFILE || homedir();
3238
- const keyPath = `${home}/.pi-harness-runtime/keys/honcho-api-key.txt`;
3478
+ // The credential comes from the loader of the module: the environment
3479
+ // variable wins over the key file, and the file is the per-user default.
3480
+ apiKey = honcho.loadHonchoApiKey() ?? "";
3481
+ if (!apiKey) {
3482
+ // A key file that exists but is empty is a different advice than a
3483
+ // missing file.
3484
+ const { readFileSync, existsSync } = await import("node:fs");
3485
+ const home = process.env.HOME || process.env.USERPROFILE || homedir();
3486
+ const keyPath = `${home}/.pi-harness-runtime/keys/honcho-api-key.txt`;
3487
+ const emptyFile = existsSync(keyPath) && readFileSync(keyPath, "utf8").trim() === "";
3488
+
3489
+ if (emptyFile) {
3490
+ logStartup("[pi-harness] Honcho: API key is empty — skipping");
3491
+ addHarnessMessage("Honcho", "API key empty", "warn", [
3492
+ ...keyFileFixesOrFallback("Honcho", ["API key"]),
3493
+ "Then reload Pi.",
3494
+ ]);
3495
+ return;
3496
+ }
3239
3497
 
3240
- if (!existsSync(keyPath)) {
3241
3498
  logStartup("[pi-harness] Honcho not configured:");
3242
3499
  logStartup("[pi-harness] Get key: https://app.honcho.dev/api-keys");
3243
3500
  logStartup(`[pi-harness] Then: echo "{api-key}" > ~/.pi-harness-runtime/keys/honcho-api-key.txt`);
3244
3501
  addHarnessMessage("Honcho", "API key missing", "warn", [
3245
3502
  ...keyFileFixesOrFallback("Honcho", ["API key"]),
3246
- "Then reload Pi.",
3247
- ]);
3248
- return;
3249
- }
3250
-
3251
- const apiKey = readFileSync(keyPath, "utf8").trim();
3252
- if (!apiKey) {
3253
- logStartup("[pi-harness] Honcho: API key is empty — skipping");
3254
- addHarnessMessage("Honcho", "API key empty", "warn", [
3255
- ...keyFileFixesOrFallback("Honcho", ["API key"]),
3503
+ "Get a key: https://app.honcho.dev/api-keys",
3256
3504
  "Then reload Pi.",
3257
3505
  ]);
3258
3506
  return;
@@ -3260,27 +3508,130 @@ async function initHoncho(pi: ExtensionAPI): Promise<void> {
3260
3508
 
3261
3509
  // Register lifecycle hooks using the new integration
3262
3510
  // SAFETY: pi.on accepts these event names at runtime
3263
- registerHonchoLifecycle(pi as unknown as { on: (event: string, handler: (...args: unknown[]) => void | Promise<void>) => void });
3264
-
3265
- // Initialize Honcho with the API key and pi.events
3266
- // SAFETY: pi.events implements the required on/emit interface
3267
- const result = await honchoInit({ apiKey, piEvents: pi.events });
3511
+ honcho.registerHonchoLifecycle(pi as unknown as { on: (event: string, handler: (...args: unknown[]) => void | Promise<void>) => void });
3512
+
3513
+ // Initialize Honcho with the API key. The integration uses Pi's own MCP
3514
+ // client, so no event adapter is passed any more. It also registers
3515
+ // the MCP server with the extension API of Pi, so the model and
3516
+ // `/mcp` see it - and a refused registration is not fatal: the
3517
+ // harness talks to the server through its own MCP client, so only
3518
+ // the native tools are missing and the rest of the startup continues.
3519
+ // SAFETY: the runtime that runs this extension exposes
3520
+ // registerMcpServer (verified with Pi 1.4.0); the local type
3521
+ // definitions are older, so the call is cast.
3522
+ const result = await honcho.initHoncho({
3523
+ apiKey,
3524
+ pi: pi as unknown as HonchoMcpRegistrar,
3525
+ });
3268
3526
 
3269
- if (result) {
3270
- logStartup("[pi-harness] Honcho integration ready");
3271
- addHarnessMessage("Honcho", "initialized", "ok");
3272
- } else {
3273
- logStartup("[pi-harness] Honcho: initialization returned null");
3274
- addHarnessMessage("Honcho", "initialization returned no client", "err", [
3275
- "How to fix: Check HONCHO_API_KEY in ~/.pi-harness-runtime/keys/honcho-api-key.txt",
3276
- "Verify Honcho account is active at honcho.dev.",
3527
+ if (!result || !result.status) {
3528
+ logStartup("[pi-harness] Honcho: initialization produced no status");
3529
+ addHarnessMessage("Honcho", "init failed", "err", [
3530
+ "Check /tmp/pi-harness.log for the Honcho reason.",
3531
+ "The Honcho integration returned no status.",
3532
+ "Honcho memory stays disabled for this session.",
3277
3533
  ]);
3534
+ return;
3535
+ }
3536
+
3537
+ switch (result.status) {
3538
+ case "ready": {
3539
+ logStartup("[pi-harness] Honcho integration ready");
3540
+ if (result.mcpRegistered) {
3541
+ logStartup("[pi-harness] Honcho: native MCP server registered");
3542
+ addHarnessMessage("Honcho", "initialized", "ok");
3543
+ } else {
3544
+ // The memory works, but the model has no Honcho tools: the
3545
+ // user has to know that, not only the log. The reason of
3546
+ // the registration names the cause, so the advice is not
3547
+ // always "check the Pi version".
3548
+ const cause = honcho.redactHonchoKey(
3549
+ result.mcpRegistrationReason ?? "the reason was not reported",
3550
+ apiKey,
3551
+ );
3552
+ logStartup(`[pi-harness] Honcho: native MCP server not registered (the model sees no Honcho tools): ${cause}`);
3553
+ // The advice follows the cause: a runtime without
3554
+ // registerMcpServer is a different problem than a name
3555
+ // that another extension owns.
3556
+ const capability = cause.includes("registerMcpServer")
3557
+ ? "This runtime does not provide registerMcpServer: the Honcho memory works, but the model sees no Honcho tools."
3558
+ : "The Honcho memory works, but the model sees no Honcho tools.";
3559
+ addHarnessMessage("Honcho", "native MCP tools not registered", "warn", [
3560
+ `The registration failed: ${cause}`,
3561
+ "Check /tmp/pi-harness.log for the Honcho reason.",
3562
+ capability,
3563
+ ]);
3564
+ }
3565
+ break;
3566
+ }
3567
+ case "missing_key": {
3568
+ // The only state that may point at the key file: the credential
3569
+ // is what is missing.
3570
+ logStartup("[pi-harness] Honcho not configured:");
3571
+ logStartup("[pi-harness] Get key: https://app.honcho.dev/api-keys");
3572
+ logStartup(`[pi-harness] Then: echo "{api-key}" > ~/.pi-harness-runtime/keys/honcho-api-key.txt`);
3573
+ addHarnessMessage("Honcho", "API key missing", "warn", [
3574
+ ...keyFileFixesOrFallback("Honcho", ["API key"]),
3575
+ "Get a key: https://app.honcho.dev/api-keys",
3576
+ "Then reload Pi.",
3577
+ ]);
3578
+ break;
3579
+ }
3580
+ case "mcp_unavailable": {
3581
+ // The key was read successfully - the problem is the MCP runtime.
3582
+ const reason = honcho.redactHonchoKey(result.reason ?? "no reason given", apiKey);
3583
+ const hint = honcho.redactHonchoKey(
3584
+ result.hint ?? "The native MCP client library @earendil-works/pi-mcp is required.",
3585
+ apiKey,
3586
+ );
3587
+ logStartup(`[pi-harness] Honcho: native MCP unavailable (${reason})`);
3588
+ addHarnessMessage("Honcho", "native MCP unavailable", "err", [
3589
+ "Check /tmp/pi-harness.log for the Honcho reason.",
3590
+ reason,
3591
+ hint,
3592
+ ]);
3593
+ break;
3594
+ }
3595
+ case "registration_rejected": {
3596
+ const reason = honcho.redactHonchoKey(
3597
+ result.reason ?? "the server did not accept the request",
3598
+ apiKey,
3599
+ );
3600
+ const httpStatus = typeof result.httpStatus === "number" ? ` (HTTP ${result.httpStatus})` : "";
3601
+ const hint = honcho.redactHonchoKey(
3602
+ result.hint ?? "Check the Honcho account at https://honcho.dev.",
3603
+ apiKey,
3604
+ );
3605
+ logStartup(`[pi-harness] Honcho: the server refused the request${httpStatus} (the MCP server is not registered): ${reason}`);
3606
+ addHarnessMessage("Honcho", "server refused the request", "err", [
3607
+ "Check the Honcho account and the endpoint: https://app.honcho.dev/api-keys",
3608
+ reason,
3609
+ hint,
3610
+ ]);
3611
+ break;
3612
+ }
3613
+ default: {
3614
+ const reason = honcho.redactHonchoKey(String(result.status), apiKey);
3615
+ logStartup("[pi-harness] Honcho: initialization failed:", reason);
3616
+ addHarnessMessage("Honcho", "init failed", "err", [
3617
+ "Check /tmp/pi-harness.log for the Honcho reason.",
3618
+ reason,
3619
+ "Honcho memory stays disabled for this session.",
3620
+ ]);
3621
+ break;
3622
+ }
3278
3623
  }
3279
3624
  } catch (err) {
3280
- logStartup("[pi-harness] Honcho: init failed:", err instanceof Error ? err.message : String(err));
3625
+ const message = err instanceof Error ? err.message : String(err);
3626
+ const reason =
3627
+ honcho && typeof honcho.redactHonchoKey === "function"
3628
+ ? honcho.redactHonchoKey(message, apiKey)
3629
+ : message;
3630
+ logStartup("[pi-harness] Honcho: init failed:", reason);
3281
3631
  addHarnessMessage("Honcho", "init failed", "err", [
3282
- err instanceof Error ? err.message : String(err),
3283
- "Check keys/honcho-api-key.txt",
3632
+ "Check /tmp/pi-harness.log for the Honcho reason.",
3633
+ reason,
3634
+ "Honcho memory stays disabled for this session.",
3284
3635
  ]);
3285
3636
  }
3286
3637
  }
@@ -3820,8 +4171,31 @@ async function initHermesSkills(pi: ExtensionAPI): Promise<void> {
3820
4171
  addHarnessMessage("Skills", "initializing", "wait");
3821
4172
  try {
3822
4173
  // Dynamically import to avoid circular deps
3823
- const { initSkills, sanitizationStatus } =
4174
+ const { initSkills, sanitizationStatus, sanitizationFilePath } =
3824
4175
  await import("./packages/skills/dist/index.js");
4176
+ let sanitizationFileCreated = false;
4177
+ let sanitizationFileCreateError: string | undefined;
4178
+ try {
4179
+ const path = sanitizationFilePath();
4180
+ const { existsSync, mkdirSync, writeFileSync } = await import("node:fs");
4181
+ const { dirname } = await import("node:path");
4182
+ if (!existsSync(path)) {
4183
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
4184
+ try {
4185
+ writeFileSync(
4186
+ path,
4187
+ `${JSON.stringify({ rules: [], nameMap: {} }, null, 2)}\n`,
4188
+ { encoding: "utf8", flag: "wx", mode: 0o600 },
4189
+ );
4190
+ sanitizationFileCreated = true;
4191
+ } catch (error) {
4192
+ // Another Pi process may have created the file after existsSync.
4193
+ if ((error as NodeJS.ErrnoException)?.code !== "EEXIST") throw error;
4194
+ }
4195
+ }
4196
+ } catch (error) {
4197
+ sanitizationFileCreateError = error instanceof Error ? error.message : String(error);
4198
+ }
3825
4199
 
3826
4200
  // Scan skills from default locations
3827
4201
  const result = initSkills();
@@ -3833,21 +4207,30 @@ async function initHermesSkills(pi: ExtensionAPI): Promise<void> {
3833
4207
  const local = localSkillsReport(localCount);
3834
4208
  addHarnessMessage("Skills", local.text, local.status, local.details);
3835
4209
  // The rules that keep a client name out of the shared collection
3836
- // live in a local file - they are not in the repository, it is
3837
- // public. Without them only the generic rules hold, and a client
3838
- // name travels into the collection. That is a visible state, not
3839
- // a silent one.
4210
+ // live in a local file. Create a private empty template on first
4211
+ // use; client-specific rules can be added locally.
3840
4212
  try {
3841
4213
  const rules = sanitizationStatus();
3842
- if (!rules.configured) {
4214
+ if (sanitizationFileCreated && rules.configured) {
4215
+ addHarnessMessage(
4216
+ "Skill privacy",
4217
+ "created a private sanitization rules file",
4218
+ "info",
4219
+ [
4220
+ `File: ${rules.path}`,
4221
+ "The file starts with no client-specific rules. Add private rules before publishing skills that contain client names.",
4222
+ ],
4223
+ );
4224
+ } else if (!rules.configured) {
3843
4225
  addHarnessMessage(
3844
4226
  "Skill privacy",
3845
- "sanitization rules not configured",
4227
+ "sanitization rules file could not be created",
3846
4228
  "warn",
3847
4229
  [
3848
- `How to fix: create ${rules.path} with the rules of your client names, or set PI_SKILL_SANITIZATION_FILE to a file that holds them.`,
3849
- "The rules are not in the repository - it is public, and a list of client names in it is a list of client names in the world.",
3850
- "A client name can travel into the shared collection until they are.",
4230
+ `Create ${rules.path} with { "rules": [], "nameMap": {} } and add private client-specific rules if needed.`,
4231
+ "Check the path in PI_SKILL_SANITIZATION_FILE.",
4232
+ "PI_SKILL_SANITIZATION_FILE must name a file path, not a JSON array.",
4233
+ ...(sanitizationFileCreateError ? [sanitizationFileCreateError] : []),
3851
4234
  ...(rules.error ? [rules.error] : []),
3852
4235
  ],
3853
4236
  );
@@ -6020,6 +6403,7 @@ Run \`bd ready\` to see current bd issues.
6020
6403
  // --- /usage today — focused view -------------------------------------
6021
6404
  pi.registerCommand("usage-today", {
6022
6405
  description: "Show today's usage + 5h window",
6406
+ // The handler returns a Promise: the command API of Pi requires one.
6023
6407
  handler: async (_args: string, ctx: ExtensionCommandContext) => {
6024
6408
  const local = aggregateWindows(tracker.all());
6025
6409
  const lines = [