gentle-pi 3.2.1 → 3.4.0

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 (108) hide show
  1. package/README.md +63 -59
  2. package/assets/orchestrator-delegation.md +1 -1
  3. package/docs/assets/brand/gentle-shell-banner.gif +0 -0
  4. package/docs/assets/diagrams/odd-workflow.svg +74 -0
  5. package/docs/assets/features/agents-view.png +0 -0
  6. package/docs/assets/features/changes-view.png +0 -0
  7. package/docs/assets/features/command-palette.png +0 -0
  8. package/docs/assets/features/profiles-routing.png +0 -0
  9. package/docs/gentle-shell.md +52 -15
  10. package/docs/readme-reference.md +67 -7
  11. package/docs/review-integration.md +22 -17
  12. package/extensions/ask-user-question.ts +210 -0
  13. package/extensions/gentle-agents.ts +93 -18
  14. package/extensions/gentle-ai.ts +180 -37
  15. package/extensions/gentle-shell.ts +476 -39
  16. package/extensions/gentle-todo.ts +19 -1
  17. package/extensions/quiet-tools.ts +28 -5
  18. package/extensions/startup-banner.ts +25 -10
  19. package/lib/agents-view.ts +41 -14
  20. package/lib/agents-widget.ts +84 -13
  21. package/lib/animation-policy.ts +52 -0
  22. package/lib/background-cache-warming.ts +38 -0
  23. package/lib/command-palette-catalog.ts +2 -0
  24. package/lib/double-esc-cancel-policy.ts +138 -0
  25. package/lib/inprocess-reviewer.ts +297 -0
  26. package/lib/native-review-cli.ts +50 -10
  27. package/lib/odd-runtime-delegation-gate.ts +88 -0
  28. package/lib/questionnaire/questionnaire-view.ts +603 -0
  29. package/lib/questionnaire/schema.ts +82 -0
  30. package/lib/questionnaire/validate.ts +141 -0
  31. package/lib/review-candidate-view-owner.ts +20 -5
  32. package/lib/review-candidate-view.ts +9 -2
  33. package/lib/review-host-relay.ts +256 -171
  34. package/lib/review-integration-v2.ts +114 -27
  35. package/lib/shell-bar.ts +163 -75
  36. package/lib/shell-card.ts +19 -9
  37. package/lib/shell-changes-view.ts +43 -5
  38. package/lib/shell-changes.ts +92 -5
  39. package/lib/shell-hover.ts +39 -0
  40. package/lib/shell-prompt.ts +10 -1
  41. package/lib/shell-sidebar-layout.ts +118 -16
  42. package/lib/shell-sidebar.ts +16 -0
  43. package/lib/shell-todo.ts +7 -1
  44. package/lib/shell-usage-view.ts +103 -12
  45. package/lib/shell-usage.ts +120 -6
  46. package/package.json +1 -1
  47. package/runtime/native-review-cli.mjs +49 -9
  48. package/runtime/review-integration-v2.mjs +114 -27
  49. package/scripts/gentle-ai-installer.mjs +10 -10
  50. package/scripts/maintainer/provider-relay-matrix.mjs +118 -47
  51. package/scripts/verify-package-files.mjs +3 -4
  52. package/tests/agents-grouping.test.ts +75 -18
  53. package/tests/agents-view.test.ts +28 -18
  54. package/tests/agents-widget.test.ts +100 -12
  55. package/tests/animation-policy.test.ts +42 -0
  56. package/tests/ask-user-question.test.ts +435 -0
  57. package/tests/background-cache-warming.test.ts +60 -0
  58. package/tests/background-subagents.test.ts +68 -0
  59. package/tests/command-palette.test.ts +10 -0
  60. package/tests/devbinary/pi-host-relay.devtest.ts +176 -138
  61. package/tests/double-esc-cancel-policy.test.ts +194 -0
  62. package/tests/gentle-agents.test.ts +599 -7
  63. package/tests/gentle-ai-binary.test.ts +1 -1
  64. package/tests/gentle-ai-installer.test.ts +47 -47
  65. package/tests/gentle-ai.test.ts +125 -9
  66. package/tests/gentle-shell.test.ts +1149 -24
  67. package/tests/gentle-todo.test.ts +17 -4
  68. package/tests/inprocess-reviewer.test.ts +460 -0
  69. package/tests/maintainer/provider-relay.maintest.ts +101 -143
  70. package/tests/native-review-capability-contract.test.ts +34 -1
  71. package/tests/native-review-parity.test.ts +19 -0
  72. package/tests/odd-runtime-delegation-gate.test.ts +212 -0
  73. package/tests/orchestrator-rdd-ownership.test.ts +3 -3
  74. package/tests/package-manifest.test.ts +6 -17
  75. package/tests/questionnaire-schema.test.ts +274 -0
  76. package/tests/questionnaire-view.test.ts +446 -0
  77. package/tests/rdd-status-line.test.ts +21 -4
  78. package/tests/review-candidate-owner-retry.test.ts +63 -0
  79. package/tests/review-candidate-view.test.ts +15 -0
  80. package/tests/review-controller-native-routing.test.ts +86 -0
  81. package/tests/review-host-relay-routing.test.ts +77 -0
  82. package/tests/review-host-relay.test.ts +297 -299
  83. package/tests/review-integration-v2-forward.test.ts +61 -0
  84. package/tests/review-integration-v2.test.ts +146 -1
  85. package/tests/review-ledger-contract.test.ts +1 -2
  86. package/tests/review-relay-transport-agent.test.ts +129 -26
  87. package/tests/review-risk-assessment.test.ts +104 -0
  88. package/tests/runtime-harness.mjs +11 -0
  89. package/tests/session-changes-shell.test.ts +27 -0
  90. package/tests/session-worktree-registry.test.ts +41 -0
  91. package/tests/shell-bar.test.ts +200 -124
  92. package/tests/shell-card.test.ts +5 -3
  93. package/tests/shell-changes-view.test.ts +47 -0
  94. package/tests/shell-changes.test.ts +177 -0
  95. package/tests/shell-hover.test.ts +19 -0
  96. package/tests/shell-prompt.test.ts +20 -0
  97. package/tests/shell-sidebar-fullscreen.test.ts +59 -0
  98. package/tests/shell-sidebar-layout.test.ts +301 -8
  99. package/tests/shell-sidebar.test.ts +25 -1
  100. package/tests/shell-todo.test.ts +36 -0
  101. package/tests/shell-usage-view.test.ts +120 -1
  102. package/tests/shell-usage.test.ts +129 -0
  103. package/tests/skill-collision-prefixes.test.ts +1 -1
  104. package/tests/startup-banner.test.ts +93 -2
  105. package/docs/assets/brand/gentle-pi-banner.png +0 -0
  106. package/lib/opaque-pi-reviewer-adapter.ts +0 -404
  107. package/skills/release/SKILL.md +0 -137
  108. package/tests/opaque-pi-reviewer-adapter.test.ts +0 -410
@@ -13,12 +13,12 @@ import os from "node:os";
13
13
  import { join, resolve, isAbsolute, sep } from "node:path";
14
14
  import { createBashToolDefinition, createLocalBashOperations, type BashOperations, keyHint, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
15
15
  import { Text, type TUI } from "@earendil-works/pi-tui";
16
- import { sidebarPart } from "../lib/shell-sidebar.ts";
17
16
  import { invalidateSidebar } from "../lib/shell-sidebar-layout.ts";
18
17
  import { createCompletionQueue } from "../lib/agents-completion-delivery.ts";
19
18
  import { AGENT_MODE, discoverAgents, parseAgentDefinition, loadAgentsConfig, resolveAgentProfile, withPinnedModelProfiles, type AgentDefinition, type AgentMode } from "../lib/agents-config.ts";
20
19
  import { resolveBackgroundSubagentsPolicy } from "../lib/background-subagents-policy.ts";
21
- import { isFinished, TASK_STATUS, TaskStore, type AskRequest, type TaskRecord } from "../lib/agents-protocol.ts";
20
+ import { installBackgroundCacheWarming } from "../lib/background-cache-warming.ts";
21
+ import { isFinished, TASK_EVENT, TASK_STATUS, TaskStore, type AskRequest, type TaskRecord } from "../lib/agents-protocol.ts";
22
22
  import { AgentRunner, piCommand, abortReasonText, plannedCommands, type RemediationPlan, type RemediationScope, REMEDIATION_PLAN_ENV, parseRemediationPlan, type AskAnswer, type RunnerDeps, type SddChangeSelection, type TaskRequest } from "../lib/agents-runner.ts";
23
23
  import { ChildMessenger, type IpcEndpoint } from "../lib/agents-messaging.ts";
24
24
  import { ActiveSessionClient, ActiveSessionListener, SessionPresenceRegistry, type PresenceRecord, type ReceivedNotification, type SentNotification, type SessionPresenceCandidate } from "../lib/agents-session-transport.ts";
@@ -499,6 +499,9 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
499
499
  let renderQueued = false;
500
500
  let cancelClock: (() => void) | undefined;
501
501
  const ownedTaskIds = new Set<string>();
502
+ // One diagnostic note per (task, guard): a dropped mutation says why once,
503
+ // not once per file, so a chatty child cannot flood its own thread.
504
+ const droppedAttributionGuards = new Set<string>();
502
505
  const stoppingTaskIds = new Set<string>();
503
506
  const yieldedTaskIds = new Set<string>();
504
507
  const metricsNow = deps.metricsNow ?? (() => performance.now());
@@ -527,6 +530,12 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
527
530
  // started before /new or /resume stays in the store and comes back with
528
531
  // its session. Before the first session_start there is nothing to scope by.
529
532
  const activeSessionId = (): string | undefined => (sessions === undefined ? undefined : sessions.getSessionId() ?? "");
533
+ // Pi 0.86.1 adds this event; the package's pinned 0.85.1 types predate it.
534
+ installBackgroundCacheWarming(pi as unknown as Parameters<typeof installBackgroundCacheWarming>[0], () => ({
535
+ sessionId: activeSessionId(),
536
+ ownedTaskIds,
537
+ tasks: store.list(activeSessionId()),
538
+ }));
530
539
  const visibleTasks = (): TaskRecord[] => store.list(activeSessionId());
531
540
  type SessionTransport = { generation: number; sessionId: string; sessionManager: ExtensionContext["sessionManager"]; client: SessionTransportClient; listener: SessionTransportListener; registry: SessionTransportRegistry; close(): Promise<void> };
532
541
  let transportGeneration = 0;
@@ -744,16 +753,43 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
744
753
  }
745
754
  },
746
755
  onSuccessfulMutation: (task, tool) => {
747
- if (!sessions || !worktrees || task.parentSessionId !== activeSessionId() || !ownedTaskIds.has(task.id)) return;
748
- const root = deps.resolveWorktree(tool.path, task.cwd)?.root;
749
- const childRoot = deps.resolveWorktree(task.cwd, task.cwd)?.root;
750
- if (!root || root !== childRoot || !worktrees.roots().includes(root)) return;
751
- if (tool.evidence?.root === root) {
756
+ // Guard chain and posture are unchanged: only owned tasks of the
757
+ // active parent, inside registered roots, ever relay. The notes only
758
+ // explain a drop -- they never widen or narrow what gets attributed.
759
+ // The cheap session/ownership guards decide before any worktree
760
+ // resolution, so a foreign or stale mutation never reaches git.
761
+ let root: string | undefined;
762
+ let childRoot: string | undefined;
763
+ const noteDrop = (guard: string) => {
764
+ const key = `${task.id}:${guard}`;
765
+ if (droppedAttributionGuards.has(key)) return;
766
+ droppedAttributionGuards.add(key);
767
+ try { store.apply(task.id, { type: TASK_EVENT.NOTE, text: `changes not attributed: ${guard} (root=${root ?? "unknown"}, child=${childRoot ?? "unknown"})` }, deps.now()); }
768
+ catch { /* A note is best-effort explanation; it must never block the guard it is explaining. */ }
769
+ };
770
+ if (!sessions || !worktrees) return noteDrop("session-inactive");
771
+ if (task.parentSessionId !== activeSessionId()) return noteDrop("parent-session-mismatch");
772
+ if (!ownedTaskIds.has(task.id)) return noteDrop("not-owned");
773
+ root = deps.resolveWorktree(tool.path, task.cwd)?.root;
774
+ childRoot = deps.resolveWorktree(task.cwd, task.cwd)?.root;
775
+ if (!root) return noteDrop("root-unresolved");
776
+ if (root !== childRoot) return noteDrop("root-mismatch");
777
+ if (!worktrees.roots().includes(root)) return noteDrop("root-not-registered");
778
+ if (!tool.evidence) {
779
+ noteDrop("evidence-missing");
780
+ } else if (tool.evidence.root !== root) {
781
+ noteDrop("evidence-root-mismatch");
782
+ } else {
783
+ let path = tool.path.replace(/^@/, "");
784
+ if (path === "~" || path.startsWith("~/")) path = os.homedir() + path.slice(1);
785
+ let resolvedPath: string | undefined;
752
786
  try {
753
- let path = tool.path.replace(/^@/, "");
754
- if (path === "~" || path.startsWith("~/")) path = os.homedir() + path.slice(1);
755
- if (realpathSync(resolve(task.cwd, path)) === resolve(root, tool.evidence.path)) pi.events.emit(SESSION_CHANGE_RELAY, { sessionId: task.parentSessionId, evidence: { ...tool.evidence, id: `${task.id}:${tool.toolCallId}` } });
756
- } catch { /* Missing or mismatched targets cannot supply session diffs. */ }
787
+ resolvedPath = realpathSync(resolve(task.cwd, path));
788
+ } catch {
789
+ noteDrop("evidence-path-unreadable");
790
+ }
791
+ if (resolvedPath === resolve(root, tool.evidence.path)) pi.events.emit(SESSION_CHANGE_RELAY, { sessionId: task.parentSessionId, evidence: { ...tool.evidence, id: `${task.id}:${tool.toolCallId}` } });
792
+ else if (resolvedPath !== undefined) noteDrop("evidence-path-mismatch");
757
793
  }
758
794
  recordReviewMutation(pi, sessions, root, { source: "subagent", taskId: task.id, toolName: tool.toolName, toolCallId: tool.toolCallId });
759
795
  },
@@ -895,6 +931,33 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
895
931
  return stored?.task;
896
932
  };
897
933
 
934
+ // Restores this exact session's own finished subagents as visible history
935
+ // on an explicit resume, or on startup into a session that already has
936
+ // entries -- never on new, fork, or reload (see the session_start handler's
937
+ // preexisting check below, which is the actual gate). Marked in
938
+ // restoredTaskIds like any other disk restoration, so it stays
939
+ // non-cancellable (ownedTaskIds never gained the id either way) and is
940
+ // skipped if the id is somehow already live.
941
+ const restoreSessionHistory = async (ctx: ExtensionContext, sessionId: string): Promise<void> => {
942
+ let history: Awaited<ReturnType<typeof loadHistory>>;
943
+ try {
944
+ history = await loadHistory(tasksDir);
945
+ } catch {
946
+ return;
947
+ }
948
+ // The session may have moved on while disk was read; a stale restore
949
+ // must never land in the wrong session's store.
950
+ if (ctx.sessionManager.getSessionId() !== sessionId) return;
951
+ // Fire-and-forget from session_start: a throwing summary subscriber must
952
+ // never surface as an unhandled rejection. History is best-effort.
953
+ try {
954
+ for (const { task, thread } of history) {
955
+ if (task.parentSessionId !== sessionId) continue;
956
+ if (store.restore(task, thread)) restoredTaskIds.add(task.id);
957
+ }
958
+ } catch { /* Partial history is acceptable; the live session keeps running. */ }
959
+ };
960
+
898
961
  const openOverlay = async (ctx: ExtensionContext) => {
899
962
  if (!ctx.hasUI) return;
900
963
  if (ctx.mode !== "tui") {
@@ -974,19 +1037,19 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
974
1037
  ui = ctx.hasUI ? ctx.ui : undefined;
975
1038
  sessions = ctx.sessionManager;
976
1039
  tickClock();
1040
+ // Agents is not a rail part: the fullscreen sidebar only suppresses
1041
+ // bottom components registered through sidebarPart, and this widget is
1042
+ // the only Agents surface in every mode, so it stays a plain component.
977
1043
  ui?.setWidget(AGENTS_WIDGET_KEY, (tui, theme) => {
978
1044
  host = tui;
979
1045
  sidebarTui = tui;
980
- return sidebarPart(tui, "agents", {
1046
+ return {
981
1047
  render(width: number) {
982
1048
  const lines = renderAgentsCard(visibleTasks(), theme, width, deps.now(), { collapsed, collapseKey, maxRows: widgetRows(tui.terminal?.rows), viewKey });
983
1049
  return lines.length === 0 ? [] : [...lines, ""];
984
1050
  },
985
1051
  invalidate() {},
986
- }, {
987
- render: (width) => renderAgentsCard(visibleTasks(), theme, width, deps.now(), { collapsed, collapseKey, viewKey }),
988
- invalidate() {},
989
- });
1052
+ };
990
1053
  });
991
1054
  };
992
1055
 
@@ -1112,7 +1175,7 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
1112
1175
  if (observe) metricTasks.set(task.id, metrics);
1113
1176
  ownedTaskIds.add(task.id);
1114
1177
  store.subscribe(task.id, () => { publishActivity(); requestRender(); });
1115
- if (request.mode === AGENT_MODE.BACKGROUND) return text(`Started ${task.agent} in the background as task ${task.id}. Use subagent_status or subagent_result with that id.`, taskDetails(task));
1178
+ if (request.mode === AGENT_MODE.BACKGROUND) return text(`Started ${task.agent} in the background as task ${task.id}. Retain that id; completion is pushed automatically. Never sleep or periodically poll subagent_status/subagent_result for completion or cache maintenance. Inspect status only at a real orchestration decision boundary; never relaunch equivalent queued/running work.`, taskDetails(task));
1116
1179
  // A tool call aborted by the host (a human interrupting the turn, a timeout)
1117
1180
  // would otherwise leave the child running and end the call with no result and
1118
1181
  // no recorded reason. Cancel through the runner so the lifecycle runs and the
@@ -1362,13 +1425,25 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
1362
1425
  });
1363
1426
  }
1364
1427
 
1365
- pi.on("session_start", async (_event, ctx) => {
1428
+ pi.on("session_start", async (event, ctx) => {
1366
1429
  // A resumed, reloaded, or replaced session starts with an empty completion
1367
1430
  // queue so nothing pending from another session can replay here.
1368
1431
  completions.dropAll();
1369
1432
  presence?.dispose();
1370
1433
  registryFor(ctx);
1371
1434
  showWidget(ctx);
1435
+ // An explicit in-session /resume always brings this session's own
1436
+ // finished subagents back from disk. Pi also reports "startup" (not
1437
+ // "resume") when the CLI is launched directly into an existing session
1438
+ // file, e.g. --continue or the --resume picker (agent-session.js:152
1439
+ // defaults to "startup"); that case restores too, but only when the
1440
+ // session actually has prior entries -- a brand-new session can also be
1441
+ // announced as "startup", and a fresh session has none. "new", "fork",
1442
+ // and "reload" never restore here, matching the existing on-demand
1443
+ // resolveTask path for anything else.
1444
+ const sessionId = ctx.sessionManager.getSessionId();
1445
+ const preexisting = event.reason === "resume" || (event.reason === "startup" && ctx.sessionManager.getEntries().length > 0);
1446
+ if (preexisting && sessionId) void restoreSessionHistory(ctx, sessionId);
1372
1447
  try {
1373
1448
  presence = PresencePublisher.start({ profile: agentHome, sessionId: activeSessionId() ?? "",
1374
1449
  label: ctx.sessionManager.getSessionName?.() || ctx.sessionManager.getCwd().split(/[\\/]/).pop() || "Orchestrator", activity: [] });