one-code-extension 0.5.0 → 0.6.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 (95) hide show
  1. package/README.md +1 -1
  2. package/docs/guide/differences-from-claude-code.md +9 -6
  3. package/docs/guide/installation.md +3 -4
  4. package/docs/guide/troubleshooting.md +1 -1
  5. package/extensions/auto-mode/denials.ts +3 -2
  6. package/extensions/background/cron.ts +127 -0
  7. package/extensions/background/index.ts +135 -47
  8. package/extensions/background/loop-fire.ts +39 -2
  9. package/extensions/background/switch-guard.ts +10 -6
  10. package/extensions/background/wakeup.ts +21 -2
  11. package/extensions/bash/index.ts +8 -30
  12. package/extensions/btw/index.ts +32 -18
  13. package/extensions/cache-marks/index.ts +42 -0
  14. package/extensions/claude-context/index.ts +157 -172
  15. package/extensions/compaction/index.ts +11 -8
  16. package/extensions/context-budget/budget.ts +16 -2
  17. package/extensions/context-budget/index.ts +15 -14
  18. package/extensions/context-management/index.ts +11 -28
  19. package/extensions/effort/index.ts +70 -7
  20. package/extensions/file-tracker/index.ts +111 -44
  21. package/extensions/file-tracker/replay.ts +76 -49
  22. package/extensions/file-tracker/restore.ts +12 -9
  23. package/extensions/file-tracker/tracker.ts +14 -1
  24. package/extensions/hooks/index.ts +60 -76
  25. package/extensions/hooks/prompt-context.ts +59 -0
  26. package/extensions/hooks/trust.ts +13 -7
  27. package/extensions/ledger/index.ts +46 -0
  28. package/extensions/ledger/wire.ts +154 -0
  29. package/extensions/lib/agent-cron.ts +17 -1
  30. package/extensions/lib/agent-loader.ts +3 -2
  31. package/extensions/lib/anthropic-auth.ts +98 -0
  32. package/extensions/lib/child-extensions.ts +14 -4
  33. package/extensions/lib/context-facts.ts +75 -56
  34. package/extensions/lib/covered-messages.ts +23 -0
  35. package/extensions/lib/deferred.ts +152 -12
  36. package/extensions/lib/ledger/fold-memo.ts +49 -0
  37. package/extensions/lib/ledger/fold.ts +265 -0
  38. package/extensions/lib/ledger/layout.ts +72 -0
  39. package/extensions/lib/ledger/plan.ts +102 -0
  40. package/extensions/lib/ledger/records.ts +276 -0
  41. package/extensions/lib/ledger/state.ts +47 -0
  42. package/extensions/lib/ledger/validate.ts +141 -0
  43. package/extensions/lib/ledger/wire.ts +632 -0
  44. package/extensions/lib/local-command.ts +24 -6
  45. package/extensions/lib/model-catalog/huggingface.json +5 -1
  46. package/extensions/lib/model-catalog/models-dev.json +10535 -10299
  47. package/extensions/lib/model-catalog/openrouter.json +191 -393
  48. package/extensions/lib/notifications.ts +57 -17
  49. package/extensions/lib/pi-version.ts +25 -8
  50. package/extensions/lib/plan-mode-channels.ts +29 -7
  51. package/extensions/lib/previous-mark.ts +101 -0
  52. package/extensions/lib/process-state.ts +48 -0
  53. package/extensions/lib/process-tree.ts +3 -2
  54. package/extensions/lib/reminders.ts +327 -621
  55. package/extensions/lib/request-replay.ts +186 -78
  56. package/extensions/lib/side-call.ts +11 -0
  57. package/extensions/lib/subagent-handback.ts +0 -1
  58. package/extensions/lib/tool-additions.ts +2 -22
  59. package/extensions/lib/turn-budget-layout.ts +18 -2
  60. package/extensions/lsp/index.ts +6 -8
  61. package/extensions/mcp/announce.ts +63 -98
  62. package/extensions/mcp/index.ts +51 -90
  63. package/extensions/mcp/trust.ts +13 -6
  64. package/extensions/model-default/index.ts +86 -19
  65. package/extensions/permissions/index.ts +157 -48
  66. package/extensions/permissions/modes.ts +5 -0
  67. package/extensions/plan-mode/index.ts +142 -45
  68. package/extensions/plan-mode/run-state.ts +26 -0
  69. package/extensions/plugins/index.ts +4 -1
  70. package/extensions/powershell/index.ts +2 -0
  71. package/extensions/recap/index.ts +11 -14
  72. package/extensions/search-tools/index.ts +2 -0
  73. package/extensions/skill/announce.ts +30 -0
  74. package/extensions/skill/index.ts +65 -48
  75. package/extensions/skill/invoke.ts +0 -67
  76. package/extensions/subagents/agents.ts +30 -8
  77. package/extensions/subagents/fork-cache.ts +9 -3
  78. package/extensions/subagents/index.ts +180 -107
  79. package/extensions/subagents/outcome.ts +3 -0
  80. package/extensions/subagents/parent-mode.ts +50 -0
  81. package/extensions/subagents/pending-claim.ts +18 -1
  82. package/extensions/subagents/runner.ts +29 -2
  83. package/extensions/subagents/runs.ts +5 -0
  84. package/extensions/system-prompt/index.ts +40 -6
  85. package/extensions/system-prompt/template.ts +10 -1
  86. package/extensions/system-reminder/index.ts +174 -202
  87. package/extensions/tool-search/index.ts +241 -167
  88. package/extensions/workflow/agent-session.ts +4 -1
  89. package/extensions/workflow/index.ts +16 -4
  90. package/extensions/workflow/run-manager.ts +4 -0
  91. package/extensions/worktree/index.ts +50 -14
  92. package/package.json +5 -2
  93. package/extensions/lib/context-stack.ts +0 -81
  94. package/extensions/subagents/announce.ts +0 -69
  95. package/extensions/tool-search/announce.ts +0 -58
@@ -77,7 +77,9 @@ import { canShowCustomUi, rpcReadOnlyText } from "../lib/headless-output.ts";
77
77
  import { sessionResultsDir } from "../lib/persisted-output.ts";
78
78
  import { privateSessionScratchpadDir } from "../lib/scratchpad.ts";
79
79
  import { CONTEXT_ORDER, REMINDER_CHANNEL, type ReminderPayload } from "../lib/reminders.ts";
80
- import { CONTEXT_BASELINE_CHANNEL, restoredContext } from "../lib/context-stack.ts";
80
+ import { readState, readStringsState, recordState } from "../lib/ledger/state.ts";
81
+ import { processState } from "../lib/process-state.ts";
82
+ import { type EntryLike, readRecord } from "../lib/ledger/records.ts";
81
83
  import {
82
84
  decide,
83
85
  extractSubject,
@@ -89,7 +91,7 @@ import {
89
91
  isPathSubjectTool,
90
92
  } from "./matcher.ts";
91
93
  import { type SessionGrant, sessionGrant } from "./session-grant.ts";
92
- import { formatModel, modeBadge, nextMode, PERMISSION_STATUS_CHANNEL, type PermissionStatus, CYCLE_KEY } from "./modes.ts";
94
+ import { formatModel, modeBadge, nextMode, PERMISSION_MODE_STATE, PERMISSION_STATUS_CHANNEL, type PermissionStatus, CYCLE_KEY } from "./modes.ts";
93
95
  import { intrinsicTier, usesClaudeCodeFastPaths } from "../lib/model-tier.ts";
94
96
  import { type ChildToolCall, type ChildGateDecision, sessionPermissionBridge, SUBAGENT_GATE_CHANNEL } from "./subagent-gate.ts";
95
97
  import { trackOriginalCommands } from "../lib/original-command.ts";
@@ -264,6 +266,24 @@ const denyReason = (result: { cause?: string; rule?: { raw?: string } }): string
264
266
  * here, next to the emitter it belongs to.
265
267
  */
266
268
  const STANDING_REMINDER_MODES: ReadonlySet<PermissionMode> = new Set<PermissionMode>(["plan", "auto"]);
269
+ /** The `one-code:state` key for the directories added to the workspace for this session. */
270
+ const WORKSPACE_DIRS_STATE = "workspace-dirs";
271
+
272
+ /**
273
+ * What the user granted "for this session" and what auto mode's loop-breaker
274
+ * counted. This is process state (lib/process-state.ts): pi re-runs this
275
+ * factory at a /reload, which is the same conversation and keeps them
276
+ * (decisions/modes.md), and every other session start clears them. Only the
277
+ * main session loads this extension (children reach it through the bridge),
278
+ * so one slot per process is one per conversation.
279
+ */
280
+ interface SessionGate {
281
+ /** Rules minted by "Yes, and don't ask again … this session" (session-grant.ts). */
282
+ allows: PermissionRule[];
283
+ pause: PauseTracker;
284
+ /** Classifier denials, and the one-shot grants `/permissions` mints from them. */
285
+ denials: DenialStore;
286
+ }
267
287
 
268
288
  const YES = "Yes";
269
289
  const NO = "No, tell the agent what to do differently";
@@ -327,8 +347,10 @@ export default function permissionsExtension(pi: ExtensionAPI) {
327
347
  let bypassInCycle = false;
328
348
  /** Whether auto mode is a stop on the cycle — only when a classifier model is reachable. */
329
349
  let autoInCycle = false;
330
- /** Whether this conversation's first turn has decided on Claude Code's auto-mode note. */
331
- let autoNoteDecided = false;
350
+ /** The mode the branch's latest `one-code:state` entry holds: the mode follows the branch. */
351
+ let branchMode: PermissionMode | undefined;
352
+ /** Whether a request went out on this branch: only then was its model told the branch's mode. */
353
+ let requestOnBranch = false;
332
354
  let deny: PermissionRule[] = [];
333
355
  let ask: PermissionRule[] = [];
334
356
  let allow: PermissionRule[] = [];
@@ -346,9 +368,10 @@ export default function permissionsExtension(pi: ExtensionAPI) {
346
368
  * Rules minted by "Yes, and don't ask again … this session" (session-grant.ts).
347
369
  * Never applied in auto mode: there the classifier is the boundary and a
348
370
  * standing grant would be a bypass of it (PERMISSIONS-REVIEW-2026-09-05 M2).
349
- * Cleared with the session (L1).
371
+ * Cleared with the session (L1), kept by a reload (SessionGate).
350
372
  */
351
- const sessionAllows: PermissionRule[] = [];
373
+ const sessionGate = processState<SessionGate>("permissions:session-gate", () => ({ allows: [], pause: new PauseTracker(), denials: new DenialStore() }));
374
+ const sessionAllows = sessionGate.allows;
352
375
  const activeSessionAllows = () => (mode === "auto" ? [] : sessionAllows);
353
376
  let unparsableRules: string[] = [];
354
377
  let warnedUnparsable = "";
@@ -391,6 +414,11 @@ export default function permissionsExtension(pi: ExtensionAPI) {
391
414
  */
392
415
  let settingsWorkspaceDirs: SourcedDirectory[] = [];
393
416
  let flagWorkspaceDirs: string[] = [];
417
+ /**
418
+ * Added for this session. This is branch state: the /add-dir breadcrumb told
419
+ * the branch's model they are in its workspace. Recorded on every change and
420
+ * read back, validated again, at every session start and /tree switch.
421
+ */
394
422
  const sessionWorkspaceDirs: string[] = [];
395
423
  /** The workspace directories in force, as real paths: what the user and the system prompt see. */
396
424
  let workspacePaths: string[] = [];
@@ -405,6 +433,36 @@ export default function permissionsExtension(pi: ExtensionAPI) {
405
433
  workspacePaths = [...new Set([...trusted, ...flagWorkspaceDirs, ...sessionWorkspaceDirs].map((dir) => tryRealpath(dir) ?? dir))];
406
434
  workspaceDirs = [...new Set(workspacePaths.map(resolvedOrSelf))];
407
435
  readableRoots = [...harnessReadableRoots, ...docsDirPaths, ...workspaceDirs];
436
+ if (sessionStarted) announceWorkspace();
437
+ };
438
+ /** What the system prompt was last told the workspace is (lib/workspace-channel.ts). */
439
+ let announcedWorkspace: string | undefined;
440
+ let sessionStarted = false;
441
+ /**
442
+ * The environment block lists the workspace: a change mid-session reaches
443
+ * the next epoch's message 1 (a compaction), while inside the epoch the
444
+ * /add-dir output already told the model (R3-L4).
445
+ */
446
+ const announceWorkspace = () => {
447
+ const key = workspacePaths.join("\0");
448
+ if (key === announcedWorkspace) return;
449
+ announcedWorkspace = key;
450
+ pi.events.emit(WORKSPACE_CHANNEL, { dirs: workspacePaths } satisfies WorkspaceAnnouncement);
451
+ };
452
+ const recordSessionWorkspace = () => recordState(pi, WORKSPACE_DIRS_STATE, [...sessionWorkspaceDirs]);
453
+ /**
454
+ * Take the branch's session directories. Each is validated again, as a live
455
+ * /add-dir is: the stored list says only that the user once added it, so a
456
+ * directory that is gone, or one too broad for a workspace, is not applied.
457
+ */
458
+ const restoreSessionWorkspace = (ctx: ExtensionContext) => {
459
+ const restored: string[] = [];
460
+ for (const dir of readStringsState(ctx, WORKSPACE_DIRS_STATE)) {
461
+ const checked = validateWorkspaceDirectory(dir, ctx.cwd, os.homedir(), restored);
462
+ if ("path" in checked) restored.push(checked.path);
463
+ else if (ctx.hasUI) ctx.ui.notify(`${dir}, added earlier in this conversation, is no longer in the workspace: ${checked.error}`, "warning");
464
+ }
465
+ sessionWorkspaceDirs.splice(0, sessionWorkspaceDirs.length, ...restored);
408
466
  };
409
467
 
410
468
  /**
@@ -467,6 +525,7 @@ export default function permissionsExtension(pi: ExtensionAPI) {
467
525
  paused: pauseTracker.isPaused(),
468
526
  classifier: display.classifier,
469
527
  pinned: display.pinned,
528
+ bypassAvailable: bypassInCycle,
470
529
  ...(toolCallId ? { toolCallId } : {}),
471
530
  } satisfies PermissionStatus);
472
531
  };
@@ -501,7 +560,9 @@ export default function permissionsExtension(pi: ExtensionAPI) {
501
560
  };
502
561
  // Keyed by cwd: a worktree-isolated child classifies against ITS checkout's
503
562
  // CLAUDE.md/AGENTS.md, and must not poison the cache the main agent's own
504
- // calls (a different cwd) read for the rest of the session.
563
+ // calls (a different cwd) read for the rest of the session. The main
564
+ // session's calls read the entered worktree's. Dropped where the agent's own
565
+ // instructions are read again: a session start and a compaction.
505
566
  const projectInstructionsByCwd = new Map<string, string | undefined>();
506
567
  const instructionsFor = (cwd: string): string | undefined => {
507
568
  if (!projectInstructionsByCwd.has(cwd)) {
@@ -509,9 +570,8 @@ export default function permissionsExtension(pi: ExtensionAPI) {
509
570
  }
510
571
  return projectInstructionsByCwd.get(cwd);
511
572
  };
512
- const pauseTracker = new PauseTracker();
513
- /** Classifier denials this session, and the one-shot grants `/permissions` mints from them. */
514
- const denials = new DenialStore();
573
+ const pauseTracker = sessionGate.pause;
574
+ const denials = sessionGate.denials;
515
575
  /**
516
576
  * Which model the classifier settled on. Held here so the choice is pinned for
517
577
  * the session rather than re-resolved per call, and so a model that turns out
@@ -794,6 +854,8 @@ export default function permissionsExtension(pi: ExtensionAPI) {
794
854
  /** `startup`: the session opens in this mode, so there is no switch to announce, only the mode's standing block. */
795
855
  const setMode = (next: PermissionMode, startup = false, toolCallId?: string) => {
796
856
  const changed = mode !== next;
857
+ /** The mode the branch recorded before this switch; its model was told it once a request went out. */
858
+ const told = branchMode;
797
859
  mode = next;
798
860
  process.env[MODE_ENV] = next;
799
861
  // Close the standing block of the mode being left BEFORE the status
@@ -803,11 +865,15 @@ export default function permissionsExtension(pi: ExtensionAPI) {
803
865
  // becomes plan — removing the key afterwards would undo that re-add and
804
866
  // leave a turn entered mid-stream with no plan reminder until the next
805
867
  // prompt (STEERING-REVIEW-2026-09-05 M3). Auto installs its own block below.
806
- const previousMode = restoredContext(pi.events)?.baselines["permission-mode"];
807
- if (mode !== "auto" && !(mode === "plan" && (startup ? previousMode === "plan" : !changed))) {
868
+ if (mode !== "auto" && !(mode === "plan" && (startup ? branchMode === "plan" : !changed))) {
808
869
  pi.events.emit(REMINDER_CHANNEL, { remove: true, key: "permission-mode" });
809
870
  }
810
- pi.events.emit(CONTEXT_BASELINE_CHANNEL, { key: "permission-mode", value: mode });
871
+ // The mode follows the branch: recorded on every change, so a resume, a
872
+ // fork, /reload or a /tree switch starts from it (decisions/modes.md).
873
+ if (mode !== branchMode) {
874
+ recordState(pi, PERMISSION_MODE_STATE, mode);
875
+ branchMode = mode;
876
+ }
811
877
  applyBadge(undefined, toolCallId);
812
878
  // Every switch is announced once on the tail of the next request (the next
813
879
  // tool result mid-turn, the prompt between turns), so the model learns of
@@ -822,8 +888,9 @@ export default function permissionsExtension(pi: ExtensionAPI) {
822
888
  : "";
823
889
  // Not at startup: "is now" beside the mode's own standing block read as a
824
890
  // duplicate to both GPT-6 models in the 2026-10-04 self-test, and Claude
825
- // Code announces only a switch.
826
- if (!startup) {
891
+ // Code announces only a switch. A startup that leaves the mode the branch
892
+ // recorded (a flag, a stored bypass that needs its flag again) is one.
893
+ if (!startup || (told !== undefined && told !== mode && requestOnBranch)) {
827
894
  pi.events.emit(REMINDER_CHANNEL, {
828
895
  text: `The user's permission mode is now "${mode}".${planNote}`,
829
896
  key: "permission-mode-change",
@@ -836,7 +903,6 @@ export default function permissionsExtension(pi: ExtensionAPI) {
836
903
  "Auto mode is active: your tool calls run without per-action prompts, but each one is screened by an approval classifier that blocks anything irreversible, destructive, or aimed outside this environment. " +
837
904
  "Work normally — do not narrate the classifier or try to phrase calls to get past it. If a call is blocked, take the block at face value: do not retry it, and do not reach the same result another way. Continue with work that does not depend on it, or explain what you were trying to do and let the user decide. " +
838
905
  "Attempting to weaken this gate (editing permission settings, changing the mode, or routing work around it) is itself blocked.",
839
- scope: "every-turn",
840
906
  key: "permission-mode",
841
907
  // Session state: rides every user message since auto mode came on, so
842
908
  // the cached prefix holds turn to turn (lib/reminders.ts).
@@ -896,7 +962,11 @@ export default function permissionsExtension(pi: ExtensionAPI) {
896
962
  return settings;
897
963
  };
898
964
 
899
- const reloadSettings = (ctx: ExtensionContext) => {
965
+ /**
966
+ * `launching`: the process is starting (reason "startup"), when its flags
967
+ * pick the mode. `carried`: the mode a /clear or /new carries onto its empty branch.
968
+ */
969
+ const reloadSettings = (ctx: ExtensionContext, launching: boolean, carried: PermissionMode | undefined) => {
900
970
  const settings = reloadRules(ctx);
901
971
  projectAllowDeclined = false;
902
972
  // Dropped so edited autoMode rules and instruction files are picked up on
@@ -907,14 +977,15 @@ export default function permissionsExtension(pi: ExtensionAPI) {
907
977
  // Claude Code's resolution order (flags, then settings' defaultMode), with
908
978
  // bypassPermissions refused wherever a settings source disabled it — before
909
979
  // 2026-09-05 (H3) that policy was silently not in force here.
910
- const startup = resolveStartupMode(
911
- [
912
- pi.getFlag("dangerously-skip-permissions") === true ? "bypassPermissions" : undefined,
913
- normalizePermissionMode(pi.getFlag("permission-mode")),
914
- settings.defaultMode,
915
- ],
916
- settings,
917
- );
980
+ // The mode follows the branch: at launch the flags win over it, and after
981
+ // that (/reload, /resume, /fork, /clear in the same process) the branch's
982
+ // own mode wins over the launch flags, so a switch the user made is kept.
983
+ // A stored bypassPermissions is never restored by itself: it needs its flag.
984
+ const bypassFlag = pi.getFlag("dangerously-skip-permissions") === true ? ("bypassPermissions" as const) : undefined;
985
+ const modeFlag = normalizePermissionMode(pi.getFlag("permission-mode"));
986
+ const fromBranch = branchMode ?? carried;
987
+ const restored = fromBranch === "bypassPermissions" ? undefined : fromBranch;
988
+ const startup = resolveStartupMode(launching ? [bypassFlag, modeFlag, restored, settings.defaultMode] : [restored, bypassFlag, modeFlag, settings.defaultMode], settings);
918
989
  if (startup.mode) mode = startup.mode;
919
990
  if (startup.bypassRefused && ctx.hasUI) ctx.ui.notify("Bypass permissions mode was disabled by settings", "warning");
920
991
  bypassInCycle = mode === "bypassPermissions";
@@ -934,16 +1005,17 @@ export default function permissionsExtension(pi: ExtensionAPI) {
934
1005
  };
935
1006
 
936
1007
  // Claude Code's auto-mode note joins the mid-conversation system message
937
- // when the conversation opens in auto mode, decided once on its first turn
938
- // so message 1 never changes later (auto-mode/system-note.ts).
1008
+ // when the conversation opens in auto mode (auto-mode/system-note.ts). It is
1009
+ // live state, rendered only by a request that opens an epoch, so message 1
1010
+ // never changes inside one; a compaction or a model switch decides it again.
939
1011
  pi.on("turn_start", (_event, ctx) => {
940
- if (autoNoteDecided) return;
941
- autoNoteDecided = true;
942
1012
  const note = mode === "auto" && ctx.model ? autoModeSystemNote(ctx.model) : undefined;
943
- if (!note) return;
1013
+ if (!note) {
1014
+ pi.events.emit(REMINDER_CHANNEL, { remove: true, key: "auto-mode-note" });
1015
+ return;
1016
+ }
944
1017
  pi.events.emit(REMINDER_CHANNEL, {
945
1018
  text: note,
946
- scope: "every-turn",
947
1019
  key: "auto-mode-note",
948
1020
  placement: "first-prepend",
949
1021
  order: CONTEXT_ORDER.autoModeNote,
@@ -979,18 +1051,21 @@ export default function permissionsExtension(pi: ExtensionAPI) {
979
1051
  });
980
1052
  // A new session (`/clear`, `/resume`, a fork) starts with a clean gate: the
981
1053
  // previous conversation's user messages are not intent evidence for this
982
- // one, its "don't ask again" grants and its pause state do not carry over
983
- // (PERMISSIONS-REVIEW-2026-09-05 L1). A reload keeps them — same conversation.
1054
+ // one, and its "don't ask again" grants, approved denials and pause state
1055
+ // do not carry over (PERMISSIONS-REVIEW-2026-09-05 L1). A reload keeps
1056
+ // them, the same conversation: they are process state (SessionGate),
1057
+ // which the factory pi re-runs there does not reset.
984
1058
  if (event.reason !== "reload") {
985
- autoNoteDecided = false;
986
1059
  sessionAllows.length = 0;
987
1060
  currentMainToolCallId = undefined;
988
1061
  inFlightMainCalls = [];
989
1062
  pendingInputs.length = 0;
990
1063
  pauseTracker.reset();
991
1064
  denials.reset();
992
- sessionWorkspaceDirs.length = 0;
993
1065
  }
1066
+ // The directories this branch was told it added follow it, so a reload, a
1067
+ // resume and a fork keep them and a /clear starts without.
1068
+ restoreSessionWorkspace(ctx);
994
1069
  // With auto-memory off the folder is nobody's working space: no grant.
995
1070
  memoryDirPath = autoMemoryEnabled(ctx.cwd) ? projectMemoryDir(ctx.cwd) : undefined;
996
1071
  // Working space only when it is private to this user: another local user
@@ -1012,21 +1087,43 @@ export default function permissionsExtension(pi: ExtensionAPI) {
1012
1087
  oneCodeProjectSettingsFile = oneCodeProjectSettingsPath(ctx.cwd, os.homedir());
1013
1088
  protectedDirs = runtimeProtectedDirs();
1014
1089
  secretPaths = runtimeSecretPaths(os.homedir());
1015
- reloadSettings(ctx);
1016
- // Stored mode is announcement history only: live settings/flags still
1017
- // decide the gate. A resume into another mode is an ordinary switch.
1018
- const restored = restoredContext(pi.events);
1019
- const previousMode = normalizePermissionMode(restored?.baselines["permission-mode"]);
1020
- if (restored && previousMode && previousMode !== mode) setMode(mode);
1021
- else if (restored && !STANDING_REMINDER_MODES.has(mode)) pi.events.emit(REMINDER_CHANNEL, { remove: true, key: "permission-mode" });
1022
- pi.events.emit(CONTEXT_BASELINE_CHANNEL, { key: "permission-mode", value: mode });
1090
+ branchMode = normalizePermissionMode(readState(ctx, PERMISSION_MODE_STATE));
1091
+ requestOnBranch = (ctx.sessionManager.getBranch() as EntryLike[]).some((entry) => readRecord(entry)?.kind === "request");
1092
+ // Claude Code keeps the mode across /clear: the new branch starts from the
1093
+ // mode this process last published (MODE_ENV), and records it.
1094
+ const carried = event.reason === "new" && !branchMode ? normalizePermissionMode(process.env[MODE_ENV]) : undefined;
1095
+ reloadSettings(ctx, event.reason === "startup", carried);
1096
+ if (!STANDING_REMINDER_MODES.has(mode)) setMode(mode, true);
1023
1097
  // The system prompt lists the workspace as the session starts (lib/workspace-channel.ts).
1024
- pi.events.emit(WORKSPACE_CHANNEL, { dirs: workspacePaths } satisfies WorkspaceAnnouncement);
1098
+ sessionStarted = true;
1099
+ announcedWorkspace = undefined;
1100
+ announceWorkspace();
1025
1101
  resetClassifierChoice(ctx.model, "batched");
1026
1102
  // The bridge reads live mode/rules, but belongs to this parent session only.
1027
1103
  pi.events.emit(SUBAGENT_GATE_CHANNEL, { decide: sessionPermissionBridge(evaluateChildToolCall, childPermissionSession.signal) });
1028
1104
  });
1029
1105
 
1106
+ // A /tree switch selects a branch whose model was told that branch's mode:
1107
+ // take it, as a resume would, without announcing a switch. A stored
1108
+ // bypassPermissions is never taken.
1109
+ pi.on("session_tree", (_event, ctx) => {
1110
+ // The selected branch's model was told its own session directories.
1111
+ restoreSessionWorkspace(ctx);
1112
+ refreshWorkspace();
1113
+ const onBranch = normalizePermissionMode(readState(ctx, PERMISSION_MODE_STATE));
1114
+ if (!onBranch || onBranch === "bypassPermissions" || onBranch === mode) return;
1115
+ branchMode = onBranch;
1116
+ setMode(onBranch, true);
1117
+ });
1118
+
1119
+ // claude-context re-reads the instruction files at a compaction, and the
1120
+ // summary's epoch renders them, so the classifier judges against the same
1121
+ // set from then on. Unchanged files give the same bytes, so its cache holds;
1122
+ // an edit costs one classifier cache write at this boundary.
1123
+ pi.on("session_compact", () => {
1124
+ projectInstructionsByCwd.clear();
1125
+ });
1126
+
1030
1127
  // The published bridge stays: aborted, it denies every call a retained child
1031
1128
  // makes, while a parent without this extension keeps the local gate.
1032
1129
  pi.on("session_shutdown", (_event, ctx) => {
@@ -1087,7 +1184,12 @@ export default function permissionsExtension(pi: ExtensionAPI) {
1087
1184
  pi.events.on(MODE_CHANNEL, (data) => {
1088
1185
  const request = data as { mode?: unknown; toolCallId?: unknown } | undefined;
1089
1186
  const requested = normalizePermissionMode(request?.mode);
1090
- if (requested) setMode(requested, false, typeof request?.toolCallId === "string" ? request.toolCallId : undefined);
1187
+ if (!requested) return;
1188
+ // Another extension asks (plan mode's exit restores the mode it came from,
1189
+ // read from the branch). A stored value never grants a permission by
1190
+ // itself: bypass only where this session has it, else the default mode.
1191
+ const allowed = requested === "bypassPermissions" && !bypassInCycle ? "default" : requested;
1192
+ setMode(allowed, false, typeof request?.toolCallId === "string" ? request.toolCallId : undefined);
1091
1193
  });
1092
1194
 
1093
1195
  // The plan-mode extension announces the plan file on PLAN_FILE_CHANNEL;
@@ -1941,7 +2043,10 @@ export default function permissionsExtension(pi: ExtensionAPI) {
1941
2043
  reloadRules(ctx);
1942
2044
  return `Added directory ${path} to workspace and saved to ${tildify(target, home)}`;
1943
2045
  }
1944
- if (!sessionWorkspaceDirs.includes(path)) sessionWorkspaceDirs.push(path);
2046
+ if (!sessionWorkspaceDirs.includes(path)) {
2047
+ sessionWorkspaceDirs.push(path);
2048
+ recordSessionWorkspace();
2049
+ }
1945
2050
  refreshWorkspace();
1946
2051
  return `Added directory ${path} to workspace for this session`;
1947
2052
  };
@@ -2026,7 +2131,10 @@ export default function permissionsExtension(pi: ExtensionAPI) {
2026
2131
  const [source, settingsPath, raw] = key.split("\0");
2027
2132
  if (source === "session") {
2028
2133
  const index = sessionWorkspaceDirs.indexOf(raw);
2029
- if (index >= 0) sessionWorkspaceDirs.splice(index, 1);
2134
+ if (index >= 0) {
2135
+ sessionWorkspaceDirs.splice(index, 1);
2136
+ recordSessionWorkspace();
2137
+ }
2030
2138
  refreshWorkspace();
2031
2139
  } else if (source === ("onecode-user" satisfies RuleSource) || source === ("onecode-project" satisfies RuleSource)) {
2032
2140
  if (!removeWorkspaceDirectory(raw, settingsPath)) throw new Error(`${raw} is no longer in ${tildify(settingsPath, home)}.`);
@@ -2195,7 +2303,8 @@ export default function permissionsExtension(pi: ExtensionAPI) {
2195
2303
  toolName: "subagent-review",
2196
2304
  transcript: [...transcript, ...childEntries],
2197
2305
  userMessages: [...userMessages],
2198
- claudeMd: instructionsFor(ctx.cwd),
2306
+ // The main session's instructions, read where its own calls read them.
2307
+ claudeMd: instructionsFor(workCwd(ctx.cwd)),
2199
2308
  username: classifierUsername,
2200
2309
  environment: autoConfig.environment,
2201
2310
  // AutoModeConfig is structurally a RuleExtras; the user's own deny rules
@@ -128,6 +128,9 @@ function badgeBase(mode: PermissionMode, opts?: { paused?: boolean; classifier?:
128
128
  */
129
129
  export const PERMISSION_STATUS_CHANNEL = "one-code:permission-status";
130
130
 
131
+ /** The `one-code:state` key the permission mode is recorded under on the branch; a fork's file carries the parent's. */
132
+ export const PERMISSION_MODE_STATE = "permission-mode";
133
+
131
134
  export interface PermissionStatus {
132
135
  mode: string;
133
136
  paused: boolean;
@@ -137,6 +140,8 @@ export interface PermissionStatus {
137
140
  classifier?: string;
138
141
  /** Whether `classifier` is pinned by a real call, or still the planned first candidate. */
139
142
  pinned?: boolean;
143
+ /** Whether this session can enter bypassPermissions (it started in it); a request for it otherwise lands in default. */
144
+ bypassAvailable?: boolean;
140
145
  }
141
146
 
142
147
  /**