gentle-pi 4.1.0 → 4.2.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 (81) hide show
  1. package/README.md +39 -14
  2. package/bin/engram-guard +1 -1
  3. package/bin/gentle-shell.mjs +16 -11
  4. package/docs/auto-permission-answers.md +25 -0
  5. package/docs/auto-permission-evidence.md +39 -0
  6. package/docs/bundled-install.md +8 -6
  7. package/docs/gentle-shell.md +17 -10
  8. package/docs/install-wizard.md +9 -9
  9. package/docs/permission-modes.md +50 -5
  10. package/docs/readme-reference.md +48 -12
  11. package/docs/session-profile-format.md +39 -0
  12. package/docs/yolo-mode.md +2 -1
  13. package/extensions/ask-user-choice.ts +4 -0
  14. package/extensions/ask-user-question.ts +6 -1
  15. package/extensions/gentle-agents.ts +54 -23
  16. package/extensions/gentle-ai.ts +105 -25
  17. package/extensions/gentle-shell.ts +26 -31
  18. package/extensions/gentle-todo.ts +26 -16
  19. package/lib/auto-permission-answers.ts +133 -0
  20. package/lib/auto-permission-classifier.ts +215 -36
  21. package/lib/auto-permission-evidence.ts +88 -0
  22. package/lib/auto-permission-memory.ts +69 -0
  23. package/lib/auto-permission-runtime.ts +251 -35
  24. package/lib/auto-permission-tool-context.ts +61 -0
  25. package/lib/destructive-command-guard.ts +104 -28
  26. package/lib/gentle-shell-bundled.ts +23 -5
  27. package/lib/gentle-shell-launcher.ts +1 -1
  28. package/lib/model-routing-authority.ts +6 -1
  29. package/lib/selection-engine.ts +4 -1
  30. package/lib/session-profile-freeze.ts +10 -15
  31. package/lib/session-profile-persistence.ts +187 -0
  32. package/lib/shell-changes.ts +2 -0
  33. package/lib/shell-prompt.ts +6 -4
  34. package/lib/shell-todo.ts +20 -8
  35. package/lib/vim-editor-adapter.ts +22 -4
  36. package/package.json +5 -5
  37. package/runtime/gentle-shell-bundled.mjs +23 -5
  38. package/runtime/gentle-shell-launcher.mjs +1 -1
  39. package/scripts/install-wizard-preview.mjs +2 -2
  40. package/scripts/installer-downloads.mjs +7 -7
  41. package/scripts/installer-preflight.mjs +1 -1
  42. package/scripts/pi-update.mjs +223 -0
  43. package/tests/append-system-prompt-route.test.ts +18 -1
  44. package/tests/auto-mode-human-answers.test.ts +68 -0
  45. package/tests/auto-mode-invariants.test.ts +23 -5
  46. package/tests/auto-mode-sdk.test.ts +29 -2
  47. package/tests/auto-permission-answers.test.ts +170 -0
  48. package/tests/auto-permission-classifier.test.ts +353 -2
  49. package/tests/auto-permission-compound.test.ts +24 -0
  50. package/tests/auto-permission-evidence.test.ts +88 -0
  51. package/tests/auto-permission-memory.test.ts +46 -0
  52. package/tests/auto-permission-public-regressions.test.ts +111 -0
  53. package/tests/auto-permission-runtime.test.ts +270 -12
  54. package/tests/autonomous-guard.test.ts +86 -16
  55. package/tests/bundled-install.test.ts +3 -3
  56. package/tests/engram-safety.test.ts +34 -6
  57. package/tests/gentle-agents.test.ts +162 -6
  58. package/tests/gentle-shell-bin.test.ts +17 -17
  59. package/tests/gentle-shell-bundled.test.ts +67 -33
  60. package/tests/gentle-shell-launcher.test.ts +18 -13
  61. package/tests/gentle-shell.test.ts +71 -5
  62. package/tests/gentle-todo.test.ts +79 -27
  63. package/tests/install-wizard.test.ts +2 -2
  64. package/tests/installer-posix-bootstrap.test.ts +8 -8
  65. package/tests/installer-preflight.test.ts +3 -3
  66. package/tests/installer-probes.test.ts +8 -8
  67. package/tests/installer-runner.test.ts +14 -13
  68. package/tests/installer-server.test.ts +5 -5
  69. package/tests/main-channel-upgrade.test.ts +7 -1
  70. package/tests/odd-runtime-delegation-gate.test.ts +4 -1
  71. package/tests/package-manifest.test.ts +6 -6
  72. package/tests/pi-update-launcher.test.ts +87 -0
  73. package/tests/pi-update.test.ts +186 -0
  74. package/tests/rdd-status-line.test.ts +52 -0
  75. package/tests/selection-engine.test.ts +35 -5
  76. package/tests/session-profile-freeze.test.ts +14 -1
  77. package/tests/session-profile-persistence.test.ts +334 -0
  78. package/tests/shell-changes.test.ts +18 -0
  79. package/tests/shell-todo.test.ts +15 -12
  80. package/tests/vim-editor-adapter.test.ts +41 -15
  81. package/tests/yolo-mode.test.ts +6 -1
@@ -1,5 +1,5 @@
1
1
  import { appendSystemPromptOnce } from "../lib/append-system-prompt.ts";
2
- import { recognizeDestructiveCommands } from "../lib/destructive-command-guard.ts";
2
+ import { dataArgumentRanges, recognizeDestructiveCommands } from "../lib/destructive-command-guard.ts";
3
3
  import { SHELL_COMMAND_TOOLS } from "../lib/background-jobs.ts";
4
4
  import { blockChildDestructiveCommand } from "./child-safety.ts";
5
5
  import { allowedEditSurfaces as hasTaskScopedAllowedEditSurfaces, bindSessionRepositoryPreparation, captureBoundSessionRepositoryAuthority, isHomeOrBroadUserDirectory, prepareBoundSessionRepository, rejectUnscopedBoundedWriterDispatch, safeBootstrapDirectory, sourcePathWithinProject } from "../lib/bounded-writer-admission.ts";
@@ -1098,9 +1098,11 @@ function homeRootedRepository(cwd: string): string | undefined {
1098
1098
  * `NativeReviewModeStatus`) that the `/gentle:review-mode` command also
1099
1099
  * calls. Never throws and never hangs past `signal`'s deadline when one is
1100
1100
  * given: an absent binary, a timed-out/aborted process, or a native CLI
1101
- * failure all resolve to `undefined`. `ctx` is optional and used only for a
1102
- * one-shot (per process) UI notice when the read is swallowed, so a
1103
- * sustained native outage is observable beyond the rendered "unknown" line.
1101
+ * failure all resolve to `undefined` unless an earlier read for the same cwd
1102
+ * succeeded, in which case that last known status is kept (gentle-shell#1029:
1103
+ * the rendered line sits in the cached prompt prefix). `ctx` is optional and
1104
+ * used only for a one-shot (per process) UI notice when the read is
1105
+ * swallowed, so a sustained native outage is observable beyond the prompt.
1104
1106
  */
1105
1107
  async function resolveRddModeStatus(
1106
1108
  nativeReviewCli: Pick<NativeReviewCli, "reviewMode"> | null | undefined,
@@ -1112,13 +1114,19 @@ async function resolveRddModeStatus(
1112
1114
  const nowMs = now();
1113
1115
  const cached = rddStatusMemo.get(cwd);
1114
1116
  if (cached !== undefined && cached.expiresAt > nowMs) return cached.status;
1115
- const status = await readRddModeStatusOnce(nativeReviewCli, cwd, signal);
1117
+ // gentle-shell#1029: the rendered line is part of the cached system-prompt
1118
+ // prefix. A re-read that fails or times out keeps the last successfully
1119
+ // read status instead of flipping the prompt to "unknown" and back, which
1120
+ // would invalidate the provider's prefix cache for the whole conversation.
1121
+ // Only a status the native reader actually reports replaces a known one.
1122
+ const read = await readRddModeStatusOnce(nativeReviewCli, cwd, signal);
1123
+ const status = read ?? cached?.status;
1116
1124
  rddStatusMemo.set(cwd, { status, expiresAt: nowMs + RDD_STATUS_MEMO_TTL_MS });
1117
- if (status === undefined && !rddStatusUnavailableWarned) {
1125
+ if (read === undefined && !rddStatusUnavailableWarned) {
1118
1126
  rddStatusUnavailableWarned = true;
1119
1127
  if (ctx?.hasUI) {
1120
1128
  ctx.ui.notify(
1121
- "Gentle AI: receipt-driven-development status is unavailable (native review CLI absent, timed out, or failed). The parent prompt renders \"unknown\" until this recovers; this notice will not repeat this session.",
1129
+ "Gentle AI: receipt-driven-development status is unavailable (native review CLI absent, timed out, or failed). The parent prompt keeps the last known status, or renders \"unknown\" when none was read; this notice will not repeat this session.",
1122
1130
  "warning",
1123
1131
  );
1124
1132
  }
@@ -1306,6 +1314,14 @@ ${personaPrompt}
1306
1314
 
1307
1315
  ${languageBoundary}
1308
1316
 
1317
+ ## Authorized work continuity
1318
+
1319
+ An authorized outcome includes its necessary in-scope implementation, documentation and verification steps. Continue those steps without asking the human to approve each transition. Never end a turn merely announcing an immediately executable authorized next step; execute it instead. A progress update is not a request for permission.
1320
+
1321
+ Stop only for an unresolved consequential scope/product decision, required explicit consent, a denied action, unavailable required proof, or the human's explicit stop. Do not infer permission for publishing, destructive actions, unrelated scope or native consent from this continuity rule. Report a concrete blocker and the exact decision needed rather than requesting a generic "continue".
1322
+
1323
+ For background work, launch the bounded action and end the turn when no independent in-scope work remains. Retain its identity and resume the already-authorized continuation automatically on its completion event; do not ask the human to say "continue", poll, or claim completion while checks remain pending.
1324
+
1309
1325
  Default workflow: Organic Driven Development (MANDATORY)
1310
1326
  Organic Driven Development (ODD) is the predefined workflow of this orchestrator. Every request enters it, without the user asking for a workflow, a plan, or task tracking. Never describe this workflow only when asked about it: run it. Run these steps in this order on every request:
1311
1327
  1. **Authorize.** Investigation, explanation, review, comparison, and proposal-only requests stay read-only: no writer, apply, or implementation artifacts. Ambiguous or conditional change intent (unclear whether a change is authorized at all) gets one clarification; stop and wait. A user saying they may stop you or resume later asks for notes and separate commits, not a stop.
@@ -1333,7 +1349,9 @@ ${getOrchestratorPrompt(cwd, activeTools, rddStatusLine, hostMode)}`;
1333
1349
 
1334
1350
  // Matches `git [global-flags] push` — tolerates flags like -C /repo or --work-tree=/tmp
1335
1351
  // between `git` and the subcommand. Short flags may be followed by a separate value token.
1336
- const GIT_GLOBAL_FLAGS_SRC = String.raw`(?:\s+--?\S+(?:\s+[^-\s]\S*)?)* `;
1352
+ // gentle-shell#2172: `\s+` rather than a literal space, so a tab-separated
1353
+ // `git\tpush` is guarded like any other spelling.
1354
+ const GIT_GLOBAL_FLAGS_SRC = String.raw`(?:\s+--?\S+(?:\s+[^-\s]\S*)?)*\s+`;
1337
1355
  const GIT_PUSH_RE = new RegExp(String.raw`\bgit${GIT_GLOBAL_FLAGS_SRC}(push)\b`);
1338
1356
 
1339
1357
  const DENIED_BASH_PATTERNS: RegExp[] = [
@@ -1399,7 +1417,7 @@ interface LoadGuardrailsOptions {
1399
1417
 
1400
1418
  const GUARDED_KEY_PATTERNS: Record<GuardedCommandKey, RegExp> = {
1401
1419
  gitPush: GIT_PUSH_RE,
1402
- gitRebase: /\bgit\s+(rebase)\b/,
1420
+ gitRebase: new RegExp(String.raw`\bgit${GIT_GLOBAL_FLAGS_SRC}(rebase)\b`),
1403
1421
  gitBranchDeleteForce: /\bgit\s+(branch)\s+(?:-[a-zA-Z]*D[a-zA-Z]*|-[a-zA-Z]*d[a-zA-Z]*f[a-zA-Z]*|-[a-zA-Z]*f[a-zA-Z]*d[a-zA-Z]*|--delete\b[^\r\n;&|]*--force\b|--force\b[^\r\n;&|]*--delete\b)/,
1404
1422
  npmPublish: /\bnpm\s+(publish)\b/,
1405
1423
  piRemove: /\bpi\s+(remove)\b/,
@@ -1441,12 +1459,15 @@ function collectGuardedMatches(
1441
1459
  config: RuntimeGuardrailsConfig,
1442
1460
  ): GuardMatch[] {
1443
1461
  const matches: GuardMatch[] = [];
1462
+ const dataRanges = dataArgumentRanges(command);
1444
1463
  for (const [key, pattern] of Object.entries(GUARDED_KEY_PATTERNS) as [
1445
1464
  GuardedCommandKey,
1446
1465
  RegExp,
1447
1466
  ][]) {
1448
1467
  const globalPattern = new RegExp(pattern.source, `${pattern.flags}g`);
1449
1468
  for (const match of command.matchAll(globalPattern)) {
1469
+ // gentle-shell#2174: text inside a data command's argument is not an operation.
1470
+ if (inDataArgument(dataRanges, match.index)) continue;
1450
1471
  const action = config.autonomousMode
1451
1472
  ? (config.guardedCommands[key] ?? AUTONOMOUS_DEFAULT_ACTIONS[key])
1452
1473
  : "confirm";
@@ -1460,6 +1481,10 @@ function collectGuardedMatches(
1460
1481
  return matches.sort((left, right) => left.triggerIndex - right.triggerIndex);
1461
1482
  }
1462
1483
 
1484
+ function inDataArgument(ranges: ReadonlyArray<readonly [number, number]>, index: number): boolean {
1485
+ return ranges.some(([start, end]) => index >= start && index < end);
1486
+ }
1487
+
1463
1488
  function evaluateGuardedCommand(
1464
1489
  command: string,
1465
1490
  config: RuntimeGuardrailsConfig,
@@ -1470,8 +1495,10 @@ function evaluateGuardedCommand(
1470
1495
  if (hardDeny) return { action: "block", triggerIndex: hardDeny.triggerIndex, matches };
1471
1496
 
1472
1497
  // Hard denies override every configured action across the complete command.
1498
+ const dataRanges = dataArgumentRanges(command);
1473
1499
  for (const pattern of DENIED_BASH_PATTERNS) {
1474
- const denied = pattern.exec(command);
1500
+ const denied = [...command.matchAll(new RegExp(pattern.source, `${pattern.flags.replace("g", "")}g`))]
1501
+ .find((candidate) => !inDataArgument(dataRanges, candidate.index));
1475
1502
  if (!denied) continue;
1476
1503
  const matchedAction = matches.find((match) =>
1477
1504
  match.triggerIndex >= denied.index && match.triggerIndex < denied.index + denied[0].length,
@@ -1817,14 +1844,60 @@ function createHerdrConfirmationLifecycle(events: ExtensionAPI["events"]): Herdr
1817
1844
  };
1818
1845
  }
1819
1846
 
1820
- /** Conservative waiver surface: one plain push in the current repository, no shell/wrapper or destination-changing options. */
1821
- function isOrdinaryYoloPush(command: string, evaluation: GuardEvaluation): boolean {
1822
- if (evaluation.action !== "confirm" || evaluation.dataLoss || evaluation.matches.length !== 1 || evaluation.key !== "gitPush") return false;
1823
- return /^git\s+push(?:\s+(?:-u|--set-upstream|[A-Za-z0-9_][A-Za-z0-9_./-]*))*\s*$/.test(command);
1847
+ // gentle-shell#2172: YOLO waives reversible delivery, not only one bare push.
1848
+ // A guarded segment is judged from its trigger word (`push`/`rebase`) to the
1849
+ // next command separator; a plain token or allow-listed option list is the
1850
+ // whole eligible grammar, so quotes, wrappers and unknown options fall back
1851
+ // to the ordinary confirmation. Everything that deletes or rewrites a remote
1852
+ // ref, publishes, or runs arbitrary commands is deliberately absent:
1853
+ // `--delete`/`-d`, `:dst` and `+src` refspecs, `--mirror`, `--prune`,
1854
+ // `--all`, every force spelling, `--receive-pack`/`--exec`, `--repo`, `-o`.
1855
+ const YOLO_WAIVABLE_KEYS: ReadonlySet<GuardedCommandKey> = new Set(["gitPush", "gitRebase"]);
1856
+ const YOLO_SEGMENT_END_RE = /&&|\|\||;|\||\n|\r/;
1857
+ // Text between the segment start and the `push`/`rebase` word: optional
1858
+ // variable assignments, `env`/`command` prefixes and the `git` word with at
1859
+ // most a `-C <path>`. Other global options (`-c key=value`, `--exec-path`,
1860
+ // `--git-dir`) can run or redirect code, so they keep the confirmation.
1861
+ const YOLO_GIT_PREFIX_RE = /^\s*(?:[A-Za-z_][A-Za-z0-9_]*=[^\s'"`$]*\s+|env\s+(?:-i\s+)?|command\s+)*git(?:\s+-C\s+[^\s'"`$;&|]+)?\s+$/;
1862
+ const YOLO_PUSH_SEGMENT_RE = /^push(?:\s+(?:-u|--set-upstream|-q|--quiet|-v|--verbose|--tags|--follow-tags|--no-verify|--porcelain|[A-Za-z0-9_][A-Za-z0-9_./-]*(?::[A-Za-z0-9_][A-Za-z0-9_./-]*)?))*\s*$/;
1863
+ const YOLO_REBASE_SEGMENT_RE = /^rebase(?:\s+(?:--onto\s+[A-Za-z0-9_][A-Za-z0-9_./~^-]*|--continue|--abort|--skip|--autostash|--no-autostash|[A-Za-z0-9_][A-Za-z0-9_./~^-]*))*\s*$/;
1864
+
1865
+ function yoloGuardedSegment(command: string, triggerIndex: number): string {
1866
+ const rest = command.slice(triggerIndex);
1867
+ const end = rest.search(YOLO_SEGMENT_END_RE);
1868
+ return end < 0 ? rest : rest.slice(0, end);
1869
+ }
1870
+
1871
+ function yoloSegmentPrefix(command: string, triggerIndex: number): string {
1872
+ const head = command.slice(0, triggerIndex);
1873
+ const separators = [...head.matchAll(new RegExp(YOLO_SEGMENT_END_RE.source, "g"))];
1874
+ const last = separators.at(-1);
1875
+ return last === undefined ? head : head.slice(last.index + last[0].length);
1876
+ }
1877
+
1878
+ /** Every guarded match and every recognized Git side effect must be a reversible push or non-interactive rebase. */
1879
+ function isReversibleYoloCommand(command: string, evaluation: GuardEvaluation): boolean {
1880
+ if (evaluation.action !== "confirm" || evaluation.dataLoss || evaluation.matches.length === 0) return false;
1881
+ for (const match of evaluation.matches) {
1882
+ if (!YOLO_WAIVABLE_KEYS.has(match.key)) return false;
1883
+ if (!YOLO_GIT_PREFIX_RE.test(yoloSegmentPrefix(command, match.triggerIndex))) return false;
1884
+ const segment = yoloGuardedSegment(command, match.triggerIndex);
1885
+ if (!(match.key === "gitPush" ? YOLO_PUSH_SEGMENT_RE : YOLO_REBASE_SEGMENT_RE).test(segment)) return false;
1886
+ }
1887
+ // The data-loss recognizer leaves non-hard Git effects (reset, stash drop,
1888
+ // restore, checkout --) to the primary policy; under YOLO only a rebase that
1889
+ // a guarded match already vetted may remain.
1890
+ const rebaseTriggers = new Set(evaluation.matches.filter((match) => match.key === "gitRebase").map((match) => match.triggerIndex));
1891
+ for (const effect of recognizeDestructiveCommands(command)) {
1892
+ if (effect.hardDeny || effect.kind !== "git") return false;
1893
+ const rebase = /^git(?:\s+(?:-C|-c|--git-dir|--work-tree|--namespace)\s+\S+|\s+-\S+)*\s+(rebase)\b/.exec(command.slice(effect.triggerIndex));
1894
+ if (rebase === null || !rebaseTriggers.has(effect.triggerIndex + rebase.index + rebase[0].lastIndexOf("rebase"))) return false;
1895
+ }
1896
+ return true;
1824
1897
  }
1825
1898
 
1826
- /** Preserve explicitly configured confirmations even when legacy env/autonomy resolution ignores them. */
1827
- function yoloPushConfiguredRestriction(cwd: string, options: LoadGuardrailsOptions = {}): GuardAction | undefined {
1899
+ /** Preserve explicitly configured confirmations for the waivable keys present, even when legacy env/autonomy resolution ignores them. */
1900
+ function yoloConfiguredRestriction(cwd: string, keys: readonly GuardedCommandKey[], options: LoadGuardrailsOptions = {}): GuardAction | undefined {
1828
1901
  let restriction: GuardAction | undefined;
1829
1902
  for (const path of [join(options.gentlePiConfigHome ?? gentleAiConfigHome(), "runtime-guardrails.json"), join(cwd, ".pi", "gentle-ai", "runtime-guardrails.json")]) {
1830
1903
  try {
@@ -1834,9 +1907,11 @@ function yoloPushConfiguredRestriction(cwd: string, options: LoadGuardrailsOptio
1834
1907
  restriction ??= "confirm";
1835
1908
  continue;
1836
1909
  }
1837
- const action = isRecord(parsed.guardedCommands) ? parsed.guardedCommands.gitPush : undefined;
1838
- if (action === "block") return "block";
1839
- if (action !== undefined && action !== "allow") restriction = "confirm";
1910
+ for (const key of keys) {
1911
+ const action = isRecord(parsed.guardedCommands) ? parsed.guardedCommands[key] : undefined;
1912
+ if (action === "block") return "block";
1913
+ if (action !== undefined && action !== "allow") restriction = "confirm";
1914
+ }
1840
1915
  } catch { restriction ??= "confirm"; }
1841
1916
  }
1842
1917
  return restriction;
@@ -1855,8 +1930,9 @@ async function confirmCommand(
1855
1930
  // Configuration provenance is distinct from legacy autonomy resolution, which
1856
1931
  // can ignore confirmations when an environment override is present. Activation
1857
1932
  // cannot waive a restriction from either user or project configuration layer.
1858
- const configuredRestriction = yoloActive && evaluation.matches.some((match) => match.key === "gitPush")
1859
- ? yoloPushConfiguredRestriction(ctx.cwd) : undefined;
1933
+ const waivableKeys = evaluation.matches.map((match) => match.key).filter((key) => YOLO_WAIVABLE_KEYS.has(key));
1934
+ const configuredRestriction = yoloActive && waivableKeys.length > 0
1935
+ ? yoloConfiguredRestriction(ctx.cwd, waivableKeys) : undefined;
1860
1936
  const classification = evaluation.action === "block" || configuredRestriction === "block"
1861
1937
  ? "block" : configuredRestriction ?? evaluation.action;
1862
1938
 
@@ -1892,7 +1968,7 @@ async function confirmCommand(
1892
1968
  }
1893
1969
 
1894
1970
  // Full T1 evaluation has already run. YOLO never changes persistent autonomy.
1895
- if (yoloActive && configuredRestriction === undefined && isOrdinaryYoloPush(command, evaluation)) return undefined;
1971
+ if (yoloActive && configuredRestriction === undefined && isReversibleYoloCommand(command, evaluation)) return undefined;
1896
1972
 
1897
1973
  // classification === "confirm"
1898
1974
  if (!ctx.hasUI) {
@@ -9384,8 +9460,8 @@ export const __testing = {
9384
9460
  guardedCommandPreview,
9385
9461
  guardedCommandTitle,
9386
9462
  loadRuntimeGuardrailsConfig,
9387
- isOrdinaryYoloPush,
9388
- yoloPushConfiguredRestriction,
9463
+ isReversibleYoloCommand,
9464
+ yoloConfiguredRestriction,
9389
9465
  buildGentlePrompt,
9390
9466
  nativeStatusUnsupported,
9391
9467
  nativeStartRejection,
@@ -9487,6 +9563,9 @@ function createGentleAiExtensionForTesting(
9487
9563
  cancel: () => classifier?.cancel(),
9488
9564
  }, { configHome: gentlePiConfigHome(permissionEnvironment) });
9489
9565
  const auto = autoPermissions;
9566
+ pi.on("tool_result", (event, ctx) => {
9567
+ if (permissionEnvironment.GENTLE_PI_AGENTS_CHILD !== "1") auto.toolResult(event, ctx);
9568
+ });
9490
9569
  pi.on("input", (event, ctx) => {
9491
9570
  if (permissionEnvironment.GENTLE_PI_AGENTS_CHILD !== "1") auto.input(event, ctx);
9492
9571
  return { action: "continue" };
@@ -10051,7 +10130,8 @@ function createGentleAiExtensionForTesting(
10051
10130
  // this branch, so no line is resolved or computed for them.
10052
10131
  // resolveRddStatusLine never throws and never hangs past
10053
10132
  // RDD_STATUS_TIMEOUT_MS: an absent/timed-out/aborted/failing native
10054
- // binary renders the fail-closed "unknown" line instead.
10133
+ // binary keeps the last known line for this cwd, or renders the
10134
+ // fail-closed "unknown" line when none was ever read (gentle-shell#1029).
10055
10135
  const rddStatusSignal = AbortSignal.timeout(RDD_STATUS_TIMEOUT_MS);
10056
10136
  // A startup read still in flight fills the memo: wait for it within the
10057
10137
  // same deadline instead of spawning the same STATUS a second time.
@@ -6,8 +6,7 @@ import { existsSync, realpathSync, statSync } from "node:fs";
6
6
  import { lstat, readFile } from "node:fs/promises";
7
7
  import { fileURLToPath } from "node:url";
8
8
  import { profilesFilePath, profileRoleEntries, readProfilesFileResult } from "../lib/agent-profiles.ts";
9
- import { readSessionProfileBinding } from "../lib/session-profile-binding.ts";
10
- import { resolveProfilePin } from "../lib/agent-profile-pin.ts";
9
+ import { resolveSessionProfile, sessionProfileLabel, sessionProfileModelProfiles } from "../lib/session-profile-freeze.ts";
11
10
  import * as os from "node:os";
12
11
  import { dirname, join, resolve } from "node:path";
13
12
  import { buildShellHeaderModel, renderShellBar, renderShellBelowInputFloat, renderShellBottomOnlyBar, renderShellHeaderChrome, renderShellSidebarBar, keepNativeWorkingRow, shellEnabled, shellHeaderUsageHit, shellJobsCount, type ShellBarModel, type ShellBarTheme } from "../lib/shell-bar.ts";
@@ -208,23 +207,18 @@ export function createActiveProfileReader(env: NodeJS.ProcessEnv = process.env):
208
207
  const reader = (() => bound ? effective : global()) as ActiveProfileReader;
209
208
  reader.refresh = () => {
210
209
  if (!bound) return false;
211
- // gentle-shell#1064 slice 1: a parent-session profile binding outranks
212
- // both the pin layers and the global active profile, with the same
213
- // "name (scope)" spelling the pin uses. The shared precedence rule lives
214
- // in one place: session → p (local pin) → P (repo declaration) → global.
215
- const session = sessionId === undefined ? undefined : readSessionProfileBinding(sessionId);
216
- if (session !== undefined) {
217
- const next = `${session.name} (session)`;
218
- const changed = next !== effective;
219
- effective = next;
220
- return changed;
221
- }
222
- const pin = identity && cwd ? resolveProfilePin({
210
+ // gentle-shell#1064: the status shows the profile launches use, through
211
+ // the same single rule (explicit Enter binding → profile frozen at
212
+ // startup → live p → P → G only in follow mode), with the existing
213
+ // spelling: "name (session)", "name (local)", "name (repo)", or the bare
214
+ // global name.
215
+ const next = cwd === undefined ? global() : sessionProfileLabel(resolveSessionProfile({
216
+ sessionId,
223
217
  cwd,
224
218
  configHome: bundledGentlePiConfigHome(env) ?? env.GENTLE_PI_CONFIG_HOME ?? join(os.homedir(), ".pi", "gentle-ai"),
225
- resolveWorktree: () => identity!,
226
- }) : undefined;
227
- const next = pin ? `${pin.profile} (${pin.source})` : global();
219
+ resolveWorktree: () => identity,
220
+ env,
221
+ }));
228
222
  const changed = next !== effective;
229
223
  effective = next;
230
224
  return changed;
@@ -1578,19 +1572,20 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p
1578
1572
  // store and the profile reader cannot drift onto two different stores.
1579
1573
  const usageConfigHome = gentlePiConfigHome(env);
1580
1574
  const usageFetchTimeout = usageFetchTimeoutMs(env);
1581
- // The subagent routing in force for this session: a session binding first
1582
- // (gentle-shell#1064 slice 1), then a repository pin, then the global active
1583
- // profile. Only the profile's own role entries count; the reserved
1584
- // orchestrator key is not a route.
1575
+ // The subagent routing in force for this session, through the same single
1576
+ // rule launches use (gentle-shell#1064): explicit Enter binding, then the
1577
+ // profile frozen at startup, then the live p → P → G only in follow mode.
1578
+ // Only the profile's own role entries count; the reserved orchestrator key
1579
+ // is not a route.
1585
1580
  const activeRoutingModels = (ctx: ExtensionContext): Array<string | undefined> => {
1586
- const session = readSessionProfileBinding(ctx.sessionManager?.getSessionId?.())?.modelProfiles;
1587
- if (session) return [...profileRoleEntries(session).map(([, entry]) => entry.model)];
1588
- const pin = resolveProfilePin({ cwd: ctx.cwd, configHome: usageConfigHome, resolveWorktree: deps.resolveWorktree });
1589
- const config = pin ? pin.modelProfiles : undefined;
1590
- if (config) return [...profileRoleEntries(config).map(([, entry]) => entry.model)];
1591
- const store = readProfilesFileResult(profilesFilePath(usageConfigHome));
1592
- const active = store.status === "valid" && store.file.active !== undefined ? store.file.profiles[store.file.active] : undefined;
1593
- return active ? profileRoleEntries(active).map(([, entry]) => entry.model) : [];
1581
+ const config = sessionProfileModelProfiles(resolveSessionProfile({
1582
+ sessionId: ctx.sessionManager?.getSessionId?.(),
1583
+ cwd: ctx.cwd,
1584
+ configHome: usageConfigHome,
1585
+ resolveWorktree: deps.resolveWorktree,
1586
+ env,
1587
+ }));
1588
+ return config ? profileRoleEntries(config).map(([, entry]) => entry.model) : [];
1594
1589
  };
1595
1590
  // A bare model id names a provider only when exactly one provider in the
1596
1591
  // registry carries that id; anything else stays untargeted rather than guessed.
@@ -2261,7 +2256,7 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p
2261
2256
  });
2262
2257
  for (const mode of ["normal", "auto", "yolo"] as const) rows.push({
2263
2258
  category,
2264
- label: () => `Session permissions: ${mode.toUpperCase()}${permissionDisplay === mode ? " (current)" : ""}`,
2259
+ label: () => `Session permissions: ${mode.toUpperCase()}${mode === "auto" ? " (beta)" : ""}${permissionDisplay === mode ? " (current)" : ""}`,
2265
2260
  preview: () => ({ title: "Permissions · current session", sample: mode === "auto"
2266
2261
  ? "independent session-model action checks · extra inference cost · hard safety and native consent remain"
2267
2262
  : mode === "yolo" ? "ordinary scoped delivery permission · destructive confirmations remain" : "normal checks · overrides an elevated global default" }),
@@ -2273,7 +2268,7 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p
2273
2268
  });
2274
2269
  for (const mode of ["normal", "auto", "yolo"] as const) rows.push({
2275
2270
  category,
2276
- label: () => `Default permissions: ${mode.toUpperCase()}${permissionDefault === mode ? " (saved)" : ""}`,
2271
+ label: () => `Default permissions: ${mode.toUpperCase()}${mode === "auto" ? " (beta)" : ""}${permissionDefault === mode ? " (saved)" : ""}`,
2277
2272
  preview: () => ({ title: "Permissions · user-global default", sample: `New primary sessions start in ${mode.toUpperCase()}; current session stays unchanged. ${mode === "auto" ? "Additional classifier inference uses the session model; usage is separate." : "Independent safety and consent remain."}` }),
2278
2273
  action: async () => {
2279
2274
  if (closed) return;
@@ -16,6 +16,7 @@ import {
16
16
  TODO_TOOL_NAME,
17
17
  todoCardTone,
18
18
  todoPromptBlock,
19
+ todoStateMessage,
19
20
  todoSummary,
20
21
  type TodoParams,
21
22
  type TodoState,
@@ -24,10 +25,12 @@ import {
24
25
  // Gentle Todo: the task list the model keeps while it works, drawn as a
25
26
  // Gentle Shell card above the editor. Three things keep it current that a
26
27
  // static tool description cannot: `write` replaces the whole list in one
27
- // call, each run's system prompt carries the open tasks and the rules, and
28
- // a list that goes untouched while tasks stay open is marked stale for both
29
- // the human and the model. Long runs also receive one request-local reminder
30
- // after several tool-use turns, without waking the agent or changing tasks.
28
+ // call, each run's system prompt carries a byte-stable rules block while the
29
+ // open tasks reach the model as a request-local trailing message (so list
30
+ // changes never invalidate the cached prompt prefix, gentle-shell#1662), and
31
+ // a list that goes untouched while tasks stay open is marked stale for the
32
+ // human. Long runs also receive one request-local reminder after several
33
+ // tool-use turns, without waking the agent or changing tasks.
31
34
 
32
35
  const WIDGET_KEY = "gentle-todo";
33
36
  const STATUS_ENUM = ["pending", "in_progress", "blocked", "done", "dropped"];
@@ -281,7 +284,9 @@ export default function gentleTodo(pi: ExtensionAPI, env: NodeJS.ProcessEnv = pr
281
284
  });
282
285
  }
283
286
 
284
- for (const [key, lines] of [["ctrl+shift+up", -3], ["ctrl+shift+down", 3]] as const) {
287
+ // Pi binds ctrl+shift+up/down to prompt navigation (tui.altScreen.*Prompt) and
288
+ // alt+up/down to dequeue and model reordering; alt+shift+up/down are free.
289
+ for (const [key, lines] of [["alt+shift+up", -3], ["alt+shift+down", 3]] as const) {
285
290
  pi.registerShortcut(key, {
286
291
  description: `Scroll the Todo list ${lines < 0 ? "up" : "down"}`,
287
292
  handler: async (ctx) => session(ctx).scrollTodo?.(lines),
@@ -315,11 +320,10 @@ export default function gentleTodo(pi: ExtensionAPI, env: NodeJS.ProcessEnv = pr
315
320
  current.clearOnNextTurn = false;
316
321
  show(current);
317
322
  }
318
- const block = todoPromptBlock(current.state, staleTurns(current.state, current.turn));
319
- if (!block) return undefined;
320
323
  // gentle-shell#1485: pi-claude-bridge drops a handler-returned
321
- // systemPrompt, so the open-tasks block goes through appendSystemPrompt.
322
- appendSystemPromptOnce(event.systemPromptOptions, block);
324
+ // systemPrompt, so the rules block goes through appendSystemPrompt. It is
325
+ // the same bytes on every turn, list or no list (gentle-shell#1662).
326
+ appendSystemPromptOnce(event.systemPromptOptions, todoPromptBlock());
323
327
  return undefined;
324
328
  });
325
329
 
@@ -335,14 +339,20 @@ export default function gentleTodo(pi: ExtensionAPI, env: NodeJS.ProcessEnv = pr
335
339
 
336
340
  pi.on("context", (event, ctx) => {
337
341
  const current = session(ctx);
338
- if (current.reminded || current.toolTurnsSinceWrite < REMIND_AFTER_TOOL_TURNS) return undefined;
339
- if (!current.state.tasks.some((task) => task.status === "pending" || task.status === "in_progress")) return undefined;
340
- current.reminded = true;
341
342
  // Context transforms are request-local: no transcript entry, UI
342
- // notification, continuation or provider-specific wake is needed.
343
- return {
344
- messages: [...event.messages, { role: "custom" as const, customType: "gentle-todo-reminder", content: TODO_REMINDER, display: false, timestamp: Date.now() }],
345
- };
343
+ // notification, continuation or provider-specific wake is needed. The
344
+ // current list trails the transcript (gentle-shell#1662), then the
345
+ // one-shot reminder when it is due.
346
+ const trailing: Array<{ role: "custom"; customType: string; content: string; display: false; timestamp: number }> = [];
347
+ const state = todoStateMessage(current.state);
348
+ if (state !== undefined) trailing.push({ role: "custom", customType: "gentle-todo-state", content: state, display: false, timestamp: Date.now() });
349
+ const actionable = current.state.tasks.some((task) => task.status === "pending" || task.status === "in_progress");
350
+ if (!current.reminded && current.toolTurnsSinceWrite >= REMIND_AFTER_TOOL_TURNS && actionable) {
351
+ current.reminded = true;
352
+ trailing.push({ role: "custom", customType: "gentle-todo-reminder", content: TODO_REMINDER, display: false, timestamp: Date.now() });
353
+ }
354
+ if (trailing.length === 0) return undefined;
355
+ return { messages: [...event.messages, ...trailing] };
346
356
  });
347
357
 
348
358
  pi.on("tool_execution_end", (event, ctx) => {
@@ -0,0 +1,133 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import type { QuestionParams } from "./questionnaire/schema.ts";
3
+ import type { AnswerRow } from "./questionnaire/questionnaire-view.ts";
4
+ import { isInteractiveMode } from "./rpc-host.ts";
5
+
6
+ type AnswerContext = Pick<ExtensionContext, "sessionManager" | "cwd" | "mode" | "hasUI">;
7
+ export type HumanAnswer =
8
+ | { readonly kind: "option" | "custom"; readonly text: string; readonly selected?: readonly string[] }
9
+ | { readonly kind: "multi"; readonly selected: readonly string[] };
10
+ export interface HumanAnswerReceipt {
11
+ readonly sessionManager: ExtensionContext["sessionManager"];
12
+ readonly sessionId: string;
13
+ readonly cwd: string;
14
+ readonly branchAnchor: string | null;
15
+ readonly toolName: "ask_user_choice" | "ask_user_question";
16
+ readonly toolCallId: string;
17
+ readonly questionIndex: number;
18
+ /** Untrusted referent, never an instruction or an authorization. */
19
+ readonly question: string;
20
+ /** Full original prompt context, untrusted and inseparable from its answer. */
21
+ readonly prompt: string;
22
+ /** Authenticated human response; opaque tokens and previews are excluded. */
23
+ readonly answer: HumanAnswer;
24
+ }
25
+ interface Subscription {
26
+ ctx: AnswerContext;
27
+ sessionId: string;
28
+ cwd: string;
29
+ callback: (receipt: HumanAnswerReceipt) => void;
30
+ }
31
+ const subscriptions = new WeakMap<object, Set<Subscription>>();
32
+
33
+ /** Subscribe from a trusted host context; dispose on session shutdown/replacement. */
34
+ export function subscribeHumanAnswers(ctx: AnswerContext, callback: (receipt: HumanAnswerReceipt) => void): () => void {
35
+ if (!ctx.sessionManager || !isInteractiveMode(ctx.mode, process.env) || ctx.hasUI === false) return () => {};
36
+ const sessionId = ctx.sessionManager.getSessionId();
37
+ if (!sessionId || !ctx.cwd) return () => {};
38
+ let listeners = subscriptions.get(ctx.sessionManager);
39
+ if (!listeners) subscriptions.set(ctx.sessionManager, listeners = new Set());
40
+ const subscription = { ctx, sessionId, cwd: ctx.cwd, callback };
41
+ listeners.add(subscription);
42
+ return () => { listeners.delete(subscription); };
43
+ }
44
+
45
+ /** Host-only capture seam. Call before mounting UI, never from tool_result. */
46
+ function beginCapture(ctx: AnswerContext, toolName: HumanAnswerReceipt["toolName"], toolCallId: string, questions: ReadonlyArray<{ question: string; prompt: string }>, signal?: AbortSignal) {
47
+ const manager = ctx.sessionManager;
48
+ const sessionId = manager?.getSessionId();
49
+ const cwd = ctx.cwd;
50
+ const branchAnchor = manager?.getLeafId?.();
51
+ const listeners = manager ? subscriptions.get(manager) : undefined;
52
+ const eligible = [...(listeners ?? [])].filter((entry) => entry.sessionId === sessionId && entry.cwd === cwd);
53
+ const interactive = isInteractiveMode(ctx.mode, process.env) && ctx.hasUI !== false;
54
+ let finished = false;
55
+ return (answers: ReadonlyArray<{ questionIndex: number; answer: HumanAnswer }>) => {
56
+ if (finished) return;
57
+ finished = true;
58
+ if (!interactive || !isInteractiveMode(ctx.mode, process.env) || ctx.hasUI === false ||
59
+ !manager || !sessionId || signal?.aborted || ctx.sessionManager !== manager ||
60
+ manager.getSessionId() !== sessionId || ctx.cwd !== cwd || branchAnchor === undefined || manager.getLeafId() !== branchAnchor) return;
61
+ for (const { questionIndex, answer } of answers) {
62
+ const referent = questions[questionIndex];
63
+ if (referent === undefined) continue;
64
+ const { question, prompt } = referent;
65
+ const receipt = Object.freeze({ sessionManager: manager, sessionId, cwd, branchAnchor, toolName, toolCallId, questionIndex, question, prompt,
66
+ answer: Object.freeze({ ...answer, ...(answer.selected ? { selected: Object.freeze([...answer.selected]) } : {}) }) });
67
+ for (const entry of eligible) {
68
+ if (!listeners?.has(entry) || !isInteractiveMode(entry.ctx.mode, process.env) || entry.ctx.hasUI === false ||
69
+ entry.ctx.sessionManager !== manager || entry.ctx.cwd !== cwd ||
70
+ manager.getSessionId() !== sessionId || manager.getLeafId() !== branchAnchor || signal?.aborted) continue;
71
+ // Observers must not change the existing public tool outcome.
72
+ try { entry.callback(receipt); } catch { /* Isolate host observer failures. */ }
73
+ }
74
+ }
75
+ };
76
+ }
77
+
78
+ interface ChoicePrompt {
79
+ question: string;
80
+ options: Array<{ label: string; description?: string; value: string }>;
81
+ allowCustomResponse?: boolean;
82
+ }
83
+ export function captureChoiceAnswer(ctx: AnswerContext, toolCallId: string, params: ChoicePrompt, signal?: AbortSignal) {
84
+ const options = params.options.map((option) => ({ ...option }));
85
+ const allowCustom = params.allowCustomResponse === true;
86
+ const prompt = JSON.stringify({ question: params.question,
87
+ options: options.map(({ label, description }) => ({ label, description })), allowCustomResponse: allowCustom });
88
+ const publish = beginCapture(ctx, "ask_user_choice", toolCallId, [{ question: params.question, prompt }], signal);
89
+ return (selection: { value: string; label: string; index: number } | { customResponse: string } | undefined) => {
90
+ if (!selection) return;
91
+ if ("customResponse" in selection) {
92
+ if (allowCustom && typeof selection.customResponse === "string" && selection.customResponse.trim())
93
+ publish([{ questionIndex: 0, answer: { kind: "custom", text: selection.customResponse } }]);
94
+ return;
95
+ }
96
+ const option = options[selection.index - 1];
97
+ if (Number.isInteger(selection.index) && option && selection.label === option.label && selection.value === option.value)
98
+ publish([{ questionIndex: 0, answer: { kind: "option", text: option.label } }]);
99
+ };
100
+ }
101
+
102
+ export function captureQuestionnaireAnswers(ctx: AnswerContext, toolCallId: string, params: QuestionParams, signal?: AbortSignal) {
103
+ const questions = params.questions.map((question) => ({ ...question, options: question.options.map((option) => ({ ...option })) }));
104
+ const referents = questions.map((question) => ({ question: question.question, prompt: JSON.stringify({
105
+ question: question.question, header: question.header, multiSelect: question.multiSelect === true,
106
+ options: question.options.map(({ label, description, preview }) => ({ label, description,
107
+ ...(ctx.mode === "tui" && preview !== undefined ? { preview } : {}) })),
108
+ }) }));
109
+ const publish = beginCapture(ctx, "ask_user_question", toolCallId, referents, signal);
110
+ return (rows: AnswerRow[] | undefined) => {
111
+ if (!rows?.length) return;
112
+ const seen = new Set<number>();
113
+ const answers: Array<{ questionIndex: number; answer: HumanAnswer }> = [];
114
+ for (const row of rows) {
115
+ const question = questions[row.questionIndex];
116
+ if (!Number.isInteger(row.questionIndex) || !question || seen.has(row.questionIndex) || row.question !== question.question) return;
117
+ seen.add(row.questionIndex);
118
+ const labels = question.options.map((option) => option.label);
119
+ const selected = row.selected;
120
+ if (selected && (!question.multiSelect || new Set(selected).size !== selected.length || selected.some((label) => !labels.includes(label)))) return;
121
+ let answer: HumanAnswer;
122
+ if (row.kind === "option" && typeof row.answer === "string" && labels.includes(row.answer) && !question.multiSelect && !selected)
123
+ answer = { kind: "option", text: row.answer };
124
+ else if (row.kind === "custom" && typeof row.answer === "string" && row.answer.trim())
125
+ answer = { kind: "custom", text: row.answer, ...(selected ? { selected } : {}) };
126
+ else if (row.kind === "multi" && question.multiSelect && selected && row.answer === null)
127
+ answer = { kind: "multi", selected };
128
+ else return;
129
+ answers.push({ questionIndex: row.questionIndex, answer });
130
+ }
131
+ publish(answers);
132
+ };
133
+ }