jeopi-cli 16.2.23 → 16.2.25

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 (52) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/dist/cli.js +3011 -2970
  3. package/dist/types/advisor/runtime.d.ts +9 -1
  4. package/dist/types/cli/update-cli.d.ts +37 -0
  5. package/dist/types/config/settings-schema.d.ts +12 -1
  6. package/dist/types/extensibility/plugins/loader.d.ts +4 -4
  7. package/dist/types/extensibility/plugins/trust.d.ts +30 -0
  8. package/dist/types/goals/completion-evidence.d.ts +21 -0
  9. package/dist/types/goals/runtime.d.ts +1 -1
  10. package/dist/types/goals/state.d.ts +3 -0
  11. package/dist/types/goals/tools/goal-tool.d.ts +2 -0
  12. package/dist/types/main.d.ts +20 -0
  13. package/dist/types/mnemopi/state.d.ts +1 -0
  14. package/dist/types/session/agent-session.d.ts +3 -1
  15. package/dist/types/slash-commands/types.d.ts +2 -0
  16. package/dist/types/task/critic-gate.d.ts +34 -0
  17. package/dist/types/tools/builtin-names.d.ts +1 -1
  18. package/dist/types/tools/index.d.ts +6 -1
  19. package/dist/types/tools/subagent.d.ts +70 -0
  20. package/dist/types/tools/subagent.test.d.ts +1 -0
  21. package/package.json +12 -12
  22. package/src/advisor/__tests__/advisor.test.ts +173 -0
  23. package/src/advisor/runtime.ts +66 -2
  24. package/src/cli/update-cli.ts +144 -0
  25. package/src/config/settings-schema.ts +14 -2
  26. package/src/extensibility/plugins/loader.ts +35 -9
  27. package/src/extensibility/plugins/trust.ts +133 -0
  28. package/src/goals/completion-evidence.ts +73 -0
  29. package/src/goals/runtime.ts +6 -1
  30. package/src/goals/state.ts +3 -0
  31. package/src/goals/tools/goal-tool.ts +16 -1
  32. package/src/internal-urls/docs-index.generated.txt +2 -2
  33. package/src/main.ts +56 -10
  34. package/src/memories/index.ts +21 -1
  35. package/src/mnemopi/state.ts +10 -2
  36. package/src/modes/interactive-mode.ts +21 -0
  37. package/src/prompts/memories/read-path.md +3 -1
  38. package/src/prompts/system/safety-kernel.md +17 -0
  39. package/src/prompts/system/tool-call-cycle-redirect.md +5 -0
  40. package/src/prompts/tools/goal.md +1 -1
  41. package/src/prompts/tools/subagent.md +29 -0
  42. package/src/session/agent-session.ts +68 -19
  43. package/src/session/session-context.ts +8 -2
  44. package/src/slash-commands/builtin-registry.ts +28 -0
  45. package/src/slash-commands/types.ts +2 -0
  46. package/src/system-prompt.ts +14 -2
  47. package/src/task/critic-gate.ts +46 -0
  48. package/src/task/index.ts +63 -2
  49. package/src/tools/builtin-names.ts +1 -0
  50. package/src/tools/index.ts +9 -1
  51. package/src/tools/subagent.test.ts +305 -0
  52. package/src/tools/subagent.ts +591 -0
@@ -26,6 +26,7 @@ import {
26
26
  isAdvisorInterruptImmuneTurnActive,
27
27
  isAdvisorTranscriptName,
28
28
  isInterruptingSeverity,
29
+ isRefusalError,
29
30
  resolveAdvisorDeliveryChannel,
30
31
  type WatchdogConfigDoc,
31
32
  } from "..";
@@ -1312,6 +1313,178 @@ describe("advisor", () => {
1312
1313
  expect(state.messages).toHaveLength(2);
1313
1314
  });
1314
1315
 
1316
+ it("strips thinking and retries once when the provider refuses the payload (reasoning_extraction)", async () => {
1317
+ const promptInputs: string[] = [];
1318
+ const strippedNotices: number[] = [];
1319
+ let refuseNext = true;
1320
+ const agent: AdvisorAgent = {
1321
+ prompt: async input => {
1322
+ promptInputs.push(input);
1323
+ if (refuseNext) {
1324
+ refuseNext = false;
1325
+ throw new Error(
1326
+ "Refusal (reasoning_extraction): This request was blocked as it seems to violate Anthropic's Terms of Service restrictions on reverse engineering or duplicating model outputs.",
1327
+ );
1328
+ }
1329
+ },
1330
+ abort: () => {},
1331
+ reset: () => {},
1332
+ state: { messages: [] },
1333
+ };
1334
+ const thinking = "secret chain of thought the classifier flags";
1335
+ const messages: AgentMessage[] = [
1336
+ { role: "user", content: "do the thing", timestamp: 1 } as AgentMessage,
1337
+ {
1338
+ role: "assistant",
1339
+ content: [
1340
+ { type: "thinking", thinking },
1341
+ { type: "text", text: "done" },
1342
+ ],
1343
+ timestamp: 2,
1344
+ } as AgentMessage,
1345
+ ];
1346
+ const host: AdvisorRuntimeHost = {
1347
+ snapshotMessages: () => messages,
1348
+ enqueueAdvice: () => {},
1349
+ notifyThinkingStripped: () => strippedNotices.push(1),
1350
+ };
1351
+ const runtime = new AdvisorRuntime(agent, host, 0);
1352
+
1353
+ runtime.onTurnEnd(messages);
1354
+ await Bun.sleep(0);
1355
+ await Bun.sleep(0);
1356
+
1357
+ // First attempt carried thinking; the refusal triggered exactly one
1358
+ // stripped re-render instead of a blind identical retry.
1359
+ expect(promptInputs).toHaveLength(2);
1360
+ expect(promptInputs[0]).toContain("_thinking:_");
1361
+ expect(promptInputs[0]).toContain(thinking);
1362
+ expect(promptInputs[1]).not.toContain("_thinking:_");
1363
+ expect(promptInputs[1]).not.toContain(thinking);
1364
+ expect(promptInputs[1]).toContain("done");
1365
+ expect(strippedNotices).toHaveLength(1);
1366
+ expect(runtime.backlog).toBe(0);
1367
+
1368
+ // Thinking stays latched off for subsequent turns.
1369
+ messages.push({
1370
+ role: "assistant",
1371
+ content: [
1372
+ { type: "thinking", thinking: "later thoughts" },
1373
+ { type: "text", text: "next" },
1374
+ ],
1375
+ timestamp: 3,
1376
+ } as AgentMessage);
1377
+ runtime.onTurnEnd(messages);
1378
+ await Bun.sleep(0);
1379
+ expect(promptInputs).toHaveLength(3);
1380
+ expect(promptInputs[2]).not.toContain("later thoughts");
1381
+ expect(strippedNotices).toHaveLength(1);
1382
+ });
1383
+
1384
+ it("skips the degrade path when thinking is disabled at construction and falls through to the watchdog", async () => {
1385
+ const promptInputs: string[] = [];
1386
+ const strippedNotices: number[] = [];
1387
+ const failures: unknown[] = [];
1388
+ const agent: AdvisorAgent = {
1389
+ prompt: async input => {
1390
+ promptInputs.push(input);
1391
+ throw new Error("Refusal (reasoning_extraction): blocked");
1392
+ },
1393
+ abort: () => {},
1394
+ reset: () => {},
1395
+ state: { messages: [] },
1396
+ };
1397
+ const messages: AgentMessage[] = [
1398
+ { role: "user", content: "start", timestamp: 1 } as AgentMessage,
1399
+ {
1400
+ role: "assistant",
1401
+ content: [
1402
+ { type: "thinking", thinking: "hidden reasoning" },
1403
+ { type: "text", text: "visible" },
1404
+ ],
1405
+ timestamp: 2,
1406
+ } as AgentMessage,
1407
+ ];
1408
+ const host: AdvisorRuntimeHost = {
1409
+ snapshotMessages: () => messages,
1410
+ enqueueAdvice: () => {},
1411
+ notifyFailure: error => failures.push(error),
1412
+ notifyThinkingStripped: () => strippedNotices.push(1),
1413
+ };
1414
+ const runtime = new AdvisorRuntime(agent, host, 0, { includeThinking: false });
1415
+
1416
+ runtime.onTurnEnd(messages);
1417
+ await Bun.sleep(0);
1418
+ await Bun.sleep(0);
1419
+ await Bun.sleep(0);
1420
+
1421
+ // Thinking never rendered, so there is nothing to strip: the refusal is
1422
+ // handled by the normal 3-strike watchdog, not an infinite degrade loop.
1423
+ expect(promptInputs).toHaveLength(3);
1424
+ for (const input of promptInputs) {
1425
+ expect(input).not.toContain("hidden reasoning");
1426
+ }
1427
+ expect(strippedNotices).toHaveLength(0);
1428
+ expect(failures).toHaveLength(1);
1429
+ expect(runtime.backlog).toBe(0);
1430
+ });
1431
+
1432
+ it("trips the watchdog when refusals persist after the thinking strip", async () => {
1433
+ const promptInputs: string[] = [];
1434
+ const strippedNotices: number[] = [];
1435
+ const failures: unknown[] = [];
1436
+ const agent: AdvisorAgent = {
1437
+ prompt: async input => {
1438
+ promptInputs.push(input);
1439
+ throw new Error("Refusal (reasoning_extraction): still blocked");
1440
+ },
1441
+ abort: () => {},
1442
+ reset: () => {},
1443
+ state: { messages: [] },
1444
+ };
1445
+ const messages: AgentMessage[] = [
1446
+ { role: "user", content: "start", timestamp: 1 } as AgentMessage,
1447
+ {
1448
+ role: "assistant",
1449
+ content: [
1450
+ { type: "thinking", thinking: "flagged reasoning" },
1451
+ { type: "text", text: "visible" },
1452
+ ],
1453
+ timestamp: 2,
1454
+ } as AgentMessage,
1455
+ ];
1456
+ const host: AdvisorRuntimeHost = {
1457
+ snapshotMessages: () => messages,
1458
+ enqueueAdvice: () => {},
1459
+ notifyFailure: error => failures.push(error),
1460
+ notifyThinkingStripped: () => strippedNotices.push(1),
1461
+ };
1462
+ const runtime = new AdvisorRuntime(agent, host, 0);
1463
+
1464
+ runtime.onTurnEnd(messages);
1465
+ for (let i = 0; i < 6; i++) await Bun.sleep(0);
1466
+
1467
+ // 1 refused attempt with thinking + 3 stripped attempts → watchdog drop.
1468
+ // The strip fires exactly once; there is no auto-recovery loop.
1469
+ expect(promptInputs).toHaveLength(4);
1470
+ expect(promptInputs[0]).toContain("_thinking:_");
1471
+ expect(promptInputs[1]).not.toContain("_thinking:_");
1472
+ expect(strippedNotices).toHaveLength(1);
1473
+ expect(failures).toHaveLength(1);
1474
+ expect(runtime.backlog).toBe(0);
1475
+ });
1476
+
1477
+ it("classifies pi-ai refusal message shapes without matching ordinary errors", () => {
1478
+ expect(isRefusalError(new Error("Refusal (reasoning_extraction): blocked by ToS"))).toBe(true);
1479
+ expect(isRefusalError(new Error("Refusal (no details provided)"))).toBe(true);
1480
+ expect(isRefusalError(new Error("Refusal: something"))).toBe(true);
1481
+ expect(isRefusalError(new Error("Refusal"))).toBe(true);
1482
+ expect(isRefusalError("Error: Refusal (reasoning_extraction): blocked")).toBe(true);
1483
+ expect(isRefusalError(new Error("404 No endpoints available"))).toBe(false);
1484
+ expect(isRefusalError(new Error("Refusals are handled elsewhere"))).toBe(false);
1485
+ expect(isRefusalError(new Error("stream timeout"))).toBe(false);
1486
+ });
1487
+
1315
1488
  it("drops the in-flight batch when a reset aborts the advisor prompt", async () => {
1316
1489
  const promptInputs: string[] = [];
1317
1490
  const { promise: firstPromptStarted, resolve: startFirstPrompt } = Promise.withResolvers<void>();
@@ -50,6 +50,8 @@ export interface AdvisorRuntimeHost {
50
50
  beginAdvisorUpdate?(): void;
51
51
  /** Surface a non-recovering advisor failure to the host UI without adding model-visible context. */
52
52
  notifyFailure?(error: unknown): void;
53
+ /** Surface a one-time notice that thinking was stripped from advisor renders after a provider refusal. */
54
+ notifyThinkingStripped?(): void;
53
55
  }
54
56
 
55
57
  interface PendingDelta {
@@ -57,6 +59,28 @@ interface PendingDelta {
57
59
  turns: number;
58
60
  }
59
61
 
62
+ /** Construction-time tuning for {@link AdvisorRuntime}. */
63
+ export interface AdvisorRuntimeOptions {
64
+ /** Render the primary's thinking blocks into advisor deltas (default true). */
65
+ includeThinking?: boolean;
66
+ }
67
+
68
+ /**
69
+ * Match the stable `Refusal` / `Refusal (…)` message prefix pi-ai emits for a
70
+ * provider classifier refusal (`stop_details.type === "refusal"` mapped in
71
+ * `packages/ai/src/providers/anthropic.ts`), e.g. Anthropic Fable's
72
+ * `Refusal (reasoning_extraction): This request was blocked …`. Refusals are
73
+ * payload-shape-bound: per Anthropic's refusal semantics, replaying the
74
+ * identical payload predictably re-refuses, so the drain loop must degrade the
75
+ * payload instead of blind-retrying it.
76
+ */
77
+ const REFUSAL_MESSAGE_PREFIX = /^(?:Error:\s*)?Refusal(?:\s*\(|:|$)/;
78
+
79
+ export function isRefusalError(error: unknown): boolean {
80
+ const message = error instanceof Error ? error.message : String(error);
81
+ return REFUSAL_MESSAGE_PREFIX.test(message.trim());
82
+ }
83
+
60
84
  interface CatchupWaiter {
61
85
  threshold: number;
62
86
  resolve: () => void;
@@ -86,11 +110,24 @@ export class AdvisorRuntime {
86
110
  #epoch = 0;
87
111
  disposed = false;
88
112
 
113
+ /**
114
+ * Render primary thinking into deltas. Doubles as the refusal-degrade latch:
115
+ * starts `true` (or whatever the constructor option says) and flips to
116
+ * `false` exactly once, the first time a classifier refusal is caught — see
117
+ * the `#drain` catch block. That single flip is sufficient to both change
118
+ * future renders AND guard against re-triggering the degrade path, so no
119
+ * separate one-shot flag is needed alongside it.
120
+ */
121
+ #includeThinking: boolean;
122
+
89
123
  constructor(
90
124
  private readonly agent: AdvisorAgent,
91
125
  private readonly host: AdvisorRuntimeHost,
92
126
  private readonly retryDelayMs = 1000,
93
- ) {}
127
+ options: AdvisorRuntimeOptions = {},
128
+ ) {
129
+ this.#includeThinking = options.includeThinking ?? true;
130
+ }
94
131
 
95
132
  get backlog(): number {
96
133
  return this.#backlog;
@@ -205,7 +242,7 @@ export class AdvisorRuntime {
205
242
  const obfuscator = this.host.obfuscator;
206
243
  const formattedDelta = obfuscator?.hasSecrets() ? obfuscateAdvisorDelta(obfuscator, delta) : delta;
207
244
  const md = formatSessionHistoryMarkdown(formattedDelta, {
208
- includeThinking: true,
245
+ includeThinking: this.#includeThinking,
209
246
  includeToolIntent: true,
210
247
  watchedRoles: true,
211
248
  expandPrimaryContext: true,
@@ -352,6 +389,33 @@ export class AdvisorRuntime {
352
389
  if (this.#epoch !== epoch) continue;
353
390
  this.#rollbackFailedTurn(messageSnapshot);
354
391
  logger.debug("advisor turn failed", { err: String(err) });
392
+ // Classifier refusals (e.g. Anthropic Fable's `reasoning_extraction`,
393
+ // which flags harnesses that feed model thinking back as text) are
394
+ // payload-shape-bound: re-sending the identical batch predictably
395
+ // re-refuses. Degrade once — latch thinking off, re-prime, and requeue
396
+ // a re-rendered batch without thinking. Flipping `#includeThinking` to
397
+ // `false` here doubles as the one-shot guard: a refusal after the
398
+ // strip fails the `this.#includeThinking` check below and falls
399
+ // through to the normal consecutive-failure watchdog, so this cannot
400
+ // loop.
401
+ if (this.#includeThinking && isRefusalError(err)) {
402
+ this.#includeThinking = false;
403
+ const queuedTurns = this.#pending.reduce((sum, b) => sum + b.turns, 0);
404
+ this.#resetAdvisorContext(false, false);
405
+ const stripped = this.#renderDelta(this.#latestMessages);
406
+ try {
407
+ this.host.notifyThinkingStripped?.();
408
+ } catch (notifyErr) {
409
+ logger.warn("advisor thinking-strip notification failed", { err: String(notifyErr) });
410
+ }
411
+ if (stripped !== null) {
412
+ this.#pending.unshift({ text: stripped, turns: finalTurns + queuedTurns });
413
+ } else {
414
+ this.#backlog = Math.max(0, this.#backlog - (finalTurns + queuedTurns));
415
+ this.#notifyWaiters();
416
+ }
417
+ continue;
418
+ }
355
419
  this.#consecutiveFailures++;
356
420
  if (this.#consecutiveFailures >= 3) {
357
421
  logger.warn("advisor failed consecutively 3 times; dropping backlog to prevent stall");
@@ -303,6 +303,130 @@ export async function fetchNewerPublishedVersion(currentVersion: string): Promis
303
303
  }
304
304
  }
305
305
 
306
+ /** Minimal published GitHub release shape used by the update-notes and registry-lag helpers. */
307
+ export interface GitHubReleaseSummary {
308
+ version: string;
309
+ body: string;
310
+ }
311
+
312
+ /** Cap on printed release-note lines so a many-versions-behind update does not flood the terminal. */
313
+ const RELEASE_NOTES_MAX_LINES = 40;
314
+
315
+ /**
316
+ * Parse a GitHub releases API response into {@link GitHubReleaseSummary}
317
+ * entries, keeping only published (non-draft, non-prerelease) releases whose
318
+ * tag matches the `vX.Y.Z` convention. Split out from {@link fetchGitHubReleases}
319
+ * so the response-shape handling is independently testable without a network
320
+ * call.
321
+ */
322
+ export function parseGitHubReleases(data: unknown): GitHubReleaseSummary[] {
323
+ if (!Array.isArray(data)) return [];
324
+ const releases: GitHubReleaseSummary[] = [];
325
+ for (const entry of data as Array<{
326
+ tag_name?: unknown;
327
+ body?: unknown;
328
+ draft?: unknown;
329
+ prerelease?: unknown;
330
+ }>) {
331
+ if (entry.draft === true || entry.prerelease === true) continue;
332
+ const tag = typeof entry.tag_name === "string" ? entry.tag_name : "";
333
+ const match = tag.match(/^v(\d+\.\d+\.\d+)$/);
334
+ if (!match) continue;
335
+ releases.push({ version: match[1], body: typeof entry.body === "string" ? entry.body : "" });
336
+ }
337
+ return releases;
338
+ }
339
+
340
+ /**
341
+ * Fetch recent published releases for {@link REPO} from the GitHub API.
342
+ *
343
+ * Best-effort: `jeopi update` must never fail because release notes are
344
+ * unreachable, so network errors, rate limits, and shape surprises all
345
+ * resolve to an empty list. Only called on explicit `jeopi update` runs —
346
+ * the frequent startup notice keeps using the npm registry to avoid
347
+ * unauthenticated GitHub rate limits.
348
+ */
349
+ async function fetchGitHubReleases(): Promise<GitHubReleaseSummary[]> {
350
+ try {
351
+ const response = await fetch(`https://api.github.com/repos/${REPO}/releases?per_page=30`, {
352
+ headers: { accept: "application/vnd.github+json", "user-agent": `${APP_NAME}-update` },
353
+ });
354
+ if (!response.ok) return [];
355
+ return parseGitHubReleases(await response.json());
356
+ } catch {
357
+ return [];
358
+ }
359
+ }
360
+
361
+ /**
362
+ * Collect release-note bodies for versions in `(currentVersion, targetVersion]`,
363
+ * newest first. Backs the "what changed" block `jeopi update` prints under the
364
+ * "New version available" line, so a user several versions behind sees every
365
+ * intervening release, not just the newest body.
366
+ */
367
+ export function selectReleaseNotesInRange(
368
+ releases: readonly GitHubReleaseSummary[],
369
+ currentVersion: string,
370
+ targetVersion: string,
371
+ ): GitHubReleaseSummary[] {
372
+ return releases
373
+ .filter(
374
+ release =>
375
+ compareVersions(release.version, currentVersion) > 0 &&
376
+ compareVersions(release.version, targetVersion) <= 0,
377
+ )
378
+ .sort((a, b) => compareVersions(b.version, a.version));
379
+ }
380
+
381
+ /**
382
+ * Render selected release notes as plain indented text, capped at `maxLines`.
383
+ * HTML comments (release template scaffolding) are stripped; a truncated
384
+ * rendering ends with a pointer at the releases page.
385
+ */
386
+ export function formatReleaseNotes(
387
+ notes: readonly GitHubReleaseSummary[],
388
+ maxLines: number = RELEASE_NOTES_MAX_LINES,
389
+ ): string {
390
+ const lines: string[] = [];
391
+ for (const note of notes) {
392
+ const body = note.body
393
+ .replace(/\r\n/g, "\n")
394
+ .replace(/<!--[\s\S]*?-->/g, "")
395
+ .trim();
396
+ lines.push(`v${note.version}`);
397
+ for (const bodyLine of body.split("\n")) {
398
+ if (body.length === 0) break;
399
+ lines.push(` ${bodyLine}`);
400
+ }
401
+ lines.push("");
402
+ }
403
+ while (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
404
+ if (lines.length > maxLines) {
405
+ lines.length = maxLines;
406
+ lines.push(` … full notes: https://github.com/${REPO}/releases`);
407
+ }
408
+ return lines.join("\n");
409
+ }
410
+
411
+ /**
412
+ * Detect a published GitHub release the npm registry has not served yet.
413
+ *
414
+ * When the release pipeline dies between the GitHub Release and the npm
415
+ * publish job (or npm credentials are missing so the publish silently
416
+ * no-ops), the npm `latest` this updater trusts stays behind while GitHub
417
+ * advances — and `jeopi update` would report "Already up to date" with no
418
+ * hint that a newer release exists. Returns the newest GitHub version
419
+ * strictly above `npmVersion`, else `undefined`.
420
+ */
421
+ export function detectRegistryLag(npmVersion: string, releases: readonly GitHubReleaseSummary[]): string | undefined {
422
+ let newest: string | undefined;
423
+ for (const release of releases) {
424
+ if (compareVersions(release.version, npmVersion) <= 0) continue;
425
+ if (newest === undefined || compareVersions(release.version, newest) > 0) newest = release.version;
426
+ }
427
+ return newest;
428
+ }
429
+
306
430
  /**
307
431
  * Compare semver versions. Returns:
308
432
  * - negative if a < b
@@ -922,11 +1046,31 @@ export async function runUpdateCommand(opts: { force: boolean; check: boolean })
922
1046
 
923
1047
  if (comparison <= 0 && !opts.force) {
924
1048
  console.log(chalk.green(`${theme.status.success} Already up to date`));
1049
+ // npm is this updater's install channel, but GitHub Releases is where the
1050
+ // pipeline publishes first — diverging means the npm publish job is still
1051
+ // running or died (or npm credentials were missing and the publish
1052
+ // silently no-oped). Say so instead of leaving "Already up to date" as
1053
+ // the only signal after a visibly tagged release.
1054
+ const laggedVersion = detectRegistryLag(release.version, await fetchGitHubReleases());
1055
+ if (laggedVersion) {
1056
+ console.log(
1057
+ chalk.yellow(`GitHub has release v${laggedVersion}, but the npm registry still serves ${release.version}.`),
1058
+ );
1059
+ console.log(
1060
+ chalk.yellow(
1061
+ `The npm publish for v${laggedVersion} has not landed (release pipeline still running or failed); try again in a few minutes.`,
1062
+ ),
1063
+ );
1064
+ }
925
1065
  return;
926
1066
  }
927
1067
 
928
1068
  if (comparison > 0) {
929
1069
  console.log(chalk.cyan(`New version available: ${release.version}`));
1070
+ const notes = selectReleaseNotesInRange(await fetchGitHubReleases(), VERSION, release.version);
1071
+ if (notes.length > 0) {
1072
+ console.log(chalk.dim(`\n${formatReleaseNotes(notes)}\n`));
1073
+ }
930
1074
  } else {
931
1075
  console.log(chalk.yellow(`Forcing reinstall of ${release.version}`));
932
1076
  }
@@ -285,7 +285,7 @@ const EMPTY_STRING_ARRAY: string[] = [];
285
285
  const EMPTY_STRING_RECORD: Record<string, string> = {};
286
286
  const EMPTY_NUMBER_RECORD: Record<string, number> = {};
287
287
  const DEFAULT_CYCLE_ORDER: string[] = ["smol", "default", "slow"];
288
- const DEFAULT_TOOL_CALL_LOOP_EXEMPT_TOOLS: string[] = ["job", "irc"];
288
+ const DEFAULT_TOOL_CALL_LOOP_EXEMPT_TOOLS: string[] = ["job", "irc", "subagent"];
289
289
  const EMPTY_MODEL_TAGS_RECORD: ModelTagsSettings = {};
290
290
  const HINDSIGHT_RECALL_TYPES_DEFAULT: string[] = ["world", "experience"];
291
291
  export const DEFAULT_BASH_INTERCEPTOR_RULES: BashInterceptorRule[] = [
@@ -414,6 +414,18 @@ export const SETTINGS_SCHEMA = {
414
414
  condition: "advisorEnabled",
415
415
  },
416
416
  },
417
+ "advisor.includeThinking": {
418
+ type: "boolean",
419
+ default: true,
420
+ ui: {
421
+ tab: "model",
422
+ group: "Advisor",
423
+ label: "Advisor Sees Thinking",
424
+ description:
425
+ "Include the primary agent's thinking blocks in advisor transcript updates. Some provider classifiers (e.g. Anthropic's reasoning_extraction) refuse payloads that replay thinking as text; the advisor also auto-disables this for the session after such a refusal.",
426
+ condition: "advisorEnabled",
427
+ },
428
+ },
417
429
  "advisor.syncBacklog": {
418
430
  type: "enum",
419
431
  values: ["off", "1", "3", "5"] as const,
@@ -1592,7 +1604,7 @@ export const SETTINGS_SCHEMA = {
1592
1604
  tab: "interaction",
1593
1605
  group: "Startup & Updates",
1594
1606
  label: "Check for Updates",
1595
- description: "Check for jeopi updates on startup",
1607
+ description: "Check for jeopi updates on startup and periodically during long sessions",
1596
1608
  },
1597
1609
  },
1598
1610
 
@@ -6,11 +6,12 @@
6
6
  */
7
7
  import * as fs from "node:fs";
8
8
  import * as path from "node:path";
9
- import { getPluginsDir, getPluginsLockfile, isEnoent } from "jeopi-utils";
9
+ import { getPluginsDir, getPluginsLockfile, isEnoent, logger } from "jeopi-utils";
10
10
  import { getConfigDirPaths } from "../../config";
11
11
  import { resolveActiveProjectRegistryPath } from "../../discovery/helpers";
12
12
  import { installLegacyPiSpecifierShim } from "./legacy-pi-compat";
13
13
  import { normalizePluginRuntimeConfig } from "./runtime-config";
14
+ import { resolvePluginTrust } from "./trust";
14
15
  import type {
15
16
  InstalledPlugin,
16
17
  PluginManifest,
@@ -443,10 +444,35 @@ export function resolvePluginExtensionPaths(plugin: InstalledPlugin): string[] {
443
444
  // =============================================================================
444
445
 
445
446
  /**
446
- * Get all tool paths from all enabled plugins.
447
+ * `getEnabledPlugins` filtered down to plugins the user has trusted (or
448
+ * explicitly denied) via {@link resolvePluginTrust}. Plugin code executes via
449
+ * native `import()` with full `exec`/API access, so — unlike user-authored
450
+ * `.jeopi/tools/` etc., which are trusted by construction — third-party
451
+ * plugin code must clear a trust-on-first-use gate before any of its
452
+ * tools/hooks/commands/extensions are loaded. Untrusted/denied plugins are
453
+ * silently skipped here (not thrown): the plugin stays installed and its
454
+ * non-code capabilities (skills/rules, discovered separately) are unaffected.
447
455
  */
448
- export async function getAllPluginToolPaths(cwd: string): Promise<string[]> {
456
+ async function getTrustedEnabledPlugins(cwd: string): Promise<ScopedInstalledPlugin[]> {
449
457
  const plugins = await getEnabledPlugins(cwd);
458
+ if (plugins.length === 0) return plugins;
459
+ const trusted: ScopedInstalledPlugin[] = [];
460
+ for (const plugin of plugins) {
461
+ const isTrusted = await resolvePluginTrust({ name: plugin.name, version: plugin.version });
462
+ if (isTrusted) {
463
+ trusted.push(plugin);
464
+ } else {
465
+ logger.debug("plugin code load skipped: not trusted", { name: plugin.name, version: plugin.version });
466
+ }
467
+ }
468
+ return trusted;
469
+ }
470
+
471
+ /**
472
+ * Get all tool paths from all enabled *and trusted* plugins.
473
+ */
474
+ export async function getAllPluginToolPaths(cwd: string): Promise<string[]> {
475
+ const plugins = await getTrustedEnabledPlugins(cwd);
450
476
  const paths: string[] = [];
451
477
 
452
478
  for (const plugin of plugins) {
@@ -457,10 +483,10 @@ export async function getAllPluginToolPaths(cwd: string): Promise<string[]> {
457
483
  }
458
484
 
459
485
  /**
460
- * Get all hook paths from all enabled plugins.
486
+ * Get all hook paths from all enabled *and trusted* plugins.
461
487
  */
462
488
  export async function getAllPluginHookPaths(cwd: string): Promise<string[]> {
463
- const plugins = await getEnabledPlugins(cwd);
489
+ const plugins = await getTrustedEnabledPlugins(cwd);
464
490
  const paths: string[] = [];
465
491
 
466
492
  for (const plugin of plugins) {
@@ -471,10 +497,10 @@ export async function getAllPluginHookPaths(cwd: string): Promise<string[]> {
471
497
  }
472
498
 
473
499
  /**
474
- * Get all command paths from all enabled plugins.
500
+ * Get all command paths from all enabled *and trusted* plugins.
475
501
  */
476
502
  export async function getAllPluginCommandPaths(cwd: string): Promise<string[]> {
477
- const plugins = await getEnabledPlugins(cwd);
503
+ const plugins = await getTrustedEnabledPlugins(cwd);
478
504
  const paths: string[] = [];
479
505
 
480
506
  for (const plugin of plugins) {
@@ -485,10 +511,10 @@ export async function getAllPluginCommandPaths(cwd: string): Promise<string[]> {
485
511
  }
486
512
 
487
513
  /**
488
- * Get all extension module paths from all enabled plugins.
514
+ * Get all extension module paths from all enabled *and trusted* plugins.
489
515
  */
490
516
  export async function getAllPluginExtensionPaths(cwd: string): Promise<string[]> {
491
- const plugins = await getEnabledPlugins(cwd);
517
+ const plugins = await getTrustedEnabledPlugins(cwd);
492
518
  const paths: string[] = [];
493
519
 
494
520
  for (const plugin of plugins) {