@zerwiz/ymir 0.1.16 → 0.1.18

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 (171) hide show
  1. package/.agents/config/app-repos.yaml +38 -0
  2. package/.claude/settings.json +31 -0
  3. package/.codex/hooks.json +43 -0
  4. package/.cursor/hooks.json +33 -0
  5. package/.pi/extensions/README.md +39 -0
  6. package/.pi/extensions/gna-pi-watch.ts +15 -0
  7. package/.pi/extensions/lib/ro-assistant-layout.ts +96 -0
  8. package/.pi/extensions/lib/ro-operational-user-layout.ts +145 -0
  9. package/.pi/extensions/lib/ro-visibility.ts +99 -0
  10. package/.pi/extensions/lib/ro-working-ship.ts +243 -0
  11. package/.pi/extensions/lib/rodd-operational-input.ts +62 -0
  12. package/.pi/extensions/lib/skuld-branch-dispatch.ts +252 -0
  13. package/.pi/extensions/lib/skuld-branch-model-picker.ts +77 -0
  14. package/.pi/extensions/lib/vordr-sessionstart-supervisor.mjs +48 -0
  15. package/.pi/extensions/ro.ts +11 -0
  16. package/.pi/extensions/skuld-branch-supervision.ts +11 -0
  17. package/.pi/extensions/syn-turnend-guard.ts +11 -0
  18. package/.pi/mcp.json +16 -0
  19. package/.pi/mcp.json.example +11 -0
  20. package/.pi/npm/.gitignore +2 -0
  21. package/.pi/settings.json +8 -0
  22. package/.pi/shared/extensions/gna-pi-watch.ts +750 -0
  23. package/.pi/shared/extensions/herdr-agent-state.ts +257 -0
  24. package/.pi/shared/extensions/open-editor.ts +509 -0
  25. package/.pi/shared/extensions/ro.ts +504 -0
  26. package/.pi/shared/extensions/skuld-branch-supervision.ts +1535 -0
  27. package/.pi/shared/extensions/syn-turnend-guard.ts +683 -0
  28. package/.pi/shared/extensions/todo.ts +133 -0
  29. package/.pi/shared/extensions/ymir-subagents.ts +287 -0
  30. package/.pi/shared/extensions/ymir-well.ts +130 -0
  31. package/CHANGELOG.md +23 -0
  32. package/assets/Ymir.md +4654 -0
  33. package/assets/healer.html +717 -0
  34. package/assets/icon-family/family-sheet.png +0 -0
  35. package/assets/icon-family/hlidskjalf.svg +1 -0
  36. package/assets/icon-family/sessrumnir.svg +1 -0
  37. package/assets/icon-family/smidja.svg +1 -0
  38. package/assets/icon-family/ymir.svg +1 -0
  39. package/assets/mimirswell.html +880 -0
  40. package/assets/mock/README.md +16 -0
  41. package/assets/mock/feeds.ts +175 -0
  42. package/assets/mock/mock.ts +408 -0
  43. package/assets/reference/docs/agent-control.md +122 -0
  44. package/assets/reference/docs/apodex/Appdex_as_Orchestrator_Planner_Reviewer_Assessment.md +176 -0
  45. package/assets/reference/docs/apodex/Final Report(1).md +198 -0
  46. package/assets/reference/docs/apodex/Final Report(2).md +54 -0
  47. package/assets/reference/docs/apodex/Final Report(3).md +40 -0
  48. package/assets/reference/docs/apodex/Final Report(4).md +234 -0
  49. package/assets/reference/docs/apodex/Final Report(5).md +13 -0
  50. package/assets/reference/docs/apodex/Final Report.md +82 -0
  51. package/assets/reference/docs/architecture.md +392 -0
  52. package/assets/reference/docs/arm-pretool-check.md +247 -0
  53. package/assets/reference/docs/calm-mode-feasibility.md +541 -0
  54. package/assets/reference/docs/calm.md +59 -0
  55. package/assets/reference/docs/captain-hold-lifecycle.md +96 -0
  56. package/assets/reference/docs/cd-guard.md +165 -0
  57. package/assets/reference/docs/cmux-backend.md +133 -0
  58. package/assets/reference/docs/codex-app-backend.md +57 -0
  59. package/assets/reference/docs/configuration.md +924 -0
  60. package/assets/reference/docs/documentation-audiences.json +468 -0
  61. package/assets/reference/docs/documentation-audiences.md +28 -0
  62. package/assets/reference/docs/examples/crew-dispatch.json +26 -0
  63. package/assets/reference/docs/examples/process-event-extension/file-signal.mjs +96 -0
  64. package/assets/reference/docs/examples/process-event-extension/firstmate-extension.json +15 -0
  65. package/assets/reference/docs/examples/watched-tools.json +24 -0
  66. package/assets/reference/docs/examples/wedge-alarm +22 -0
  67. package/assets/reference/docs/extension-bindings.md +237 -0
  68. package/assets/reference/docs/fm-test-isolation-proof.json +37 -0
  69. package/assets/reference/docs/fm-test-isolation-proof.md +149 -0
  70. package/assets/reference/docs/fm-test-portable-shards.md +118 -0
  71. package/assets/reference/docs/gitlab-merge-watch.md +275 -0
  72. package/assets/reference/docs/herdr-backend.md +346 -0
  73. package/assets/reference/docs/orca-backend.md +84 -0
  74. package/assets/reference/docs/pi-supervision-branch-poster.svg +125 -0
  75. package/assets/reference/docs/pi-supervision-branch.md +96 -0
  76. package/assets/reference/docs/plans/agentic-engineering-workflow.md +395 -0
  77. package/assets/reference/docs/plans/agentic-factory-implementation-plan.md +615 -0
  78. package/assets/reference/docs/plans/command-factory-enhancement-plan.md +420 -0
  79. package/assets/reference/docs/plans/phase-1-layer3-program-design.md +134 -0
  80. package/assets/reference/docs/plans/phase-2-vertical-slices.md +100 -0
  81. package/assets/reference/docs/plans/phase-3-measurable-goals.md +106 -0
  82. package/assets/reference/docs/plans/phase-4-context-budget.md +106 -0
  83. package/assets/reference/docs/plans/phase-5-pre-mortem-confidence.md +100 -0
  84. package/assets/reference/docs/plans/phase-6-incident-to-agent.md +109 -0
  85. package/assets/reference/docs/remote-secondmates.md +268 -0
  86. package/assets/reference/docs/scripts.md +146 -0
  87. package/assets/reference/docs/sessionstart-nudge.md +115 -0
  88. package/assets/reference/docs/subagent-guard.md +383 -0
  89. package/assets/reference/docs/supervision-protocols/claude.md +27 -0
  90. package/assets/reference/docs/supervision-protocols/codex.md +16 -0
  91. package/assets/reference/docs/supervision-protocols/cursor.md +31 -0
  92. package/assets/reference/docs/supervision-protocols/grok.md +39 -0
  93. package/assets/reference/docs/supervision-protocols/opencode.md +17 -0
  94. package/assets/reference/docs/supervision-protocols/pi.md +33 -0
  95. package/assets/reference/docs/supervision-protocols/unknown.md +13 -0
  96. package/assets/reference/docs/tmux-backend.md +115 -0
  97. package/assets/reference/docs/trace-context.md +117 -0
  98. package/assets/reference/docs/turnend-guard.md +170 -0
  99. package/assets/reference/docs/verification/dispatch-auth.md +204 -0
  100. package/assets/reference/docs/verification/muse.md +221 -0
  101. package/assets/reference/docs/verification/process-event-sources.md +208 -0
  102. package/assets/reference/docs/verification/public-followup.md +119 -0
  103. package/assets/reference/docs/verification/runtime-backends.md +1022 -0
  104. package/assets/reference/docs/verification/stow-memory.md +53 -0
  105. package/assets/reference/docs/verification/supervision.md +534 -0
  106. package/assets/reference/docs/verification/trace-context.md +31 -0
  107. package/assets/reference/docs/voice-relay.md +295 -0
  108. package/assets/reference/docs/watcher-continuity.md +119 -0
  109. package/assets/reference/docs/wedge-alarm.md +39 -0
  110. package/assets/reference/docs/zellij-backend.md +114 -0
  111. package/assets/reference/index.html +1172 -0
  112. package/assets/reference/skills/afk/SKILL.md +241 -0
  113. package/assets/reference/skills/ahoy/SKILL.md +56 -0
  114. package/assets/reference/skills/ask-user-authority/SKILL.md +56 -0
  115. package/assets/reference/skills/bearings/SKILL.md +161 -0
  116. package/assets/reference/skills/bearings/assets/board-template.html +731 -0
  117. package/assets/reference/skills/bootstrap-diagnostics/SKILL.md +68 -0
  118. package/assets/reference/skills/captain-hold-lifecycle/SKILL.md +54 -0
  119. package/assets/reference/skills/decision-hold-lifecycle/SKILL.md +15 -0
  120. package/assets/reference/skills/diagnostic-reasoning/SKILL.md +53 -0
  121. package/assets/reference/skills/firstmate-codexapp/SKILL.md +110 -0
  122. package/assets/reference/skills/firstmate-codexapp/agents/openai.yaml +4 -0
  123. package/assets/reference/skills/firstmate-coding-guidelines/SKILL.md +127 -0
  124. package/assets/reference/skills/firstmate-orca/SKILL.md +92 -0
  125. package/assets/reference/skills/fmx-respond/SKILL.md +283 -0
  126. package/assets/reference/skills/harness-adapters/SKILL.md +95 -0
  127. package/assets/reference/skills/harness-adapters/references/common/control-and-recovery.md +37 -0
  128. package/assets/reference/skills/harness-adapters/references/common/dispatch.md +32 -0
  129. package/assets/reference/skills/harness-adapters/references/common/model-and-effort.md +42 -0
  130. package/assets/reference/skills/harness-adapters/references/common/primary-hooks.md +40 -0
  131. package/assets/reference/skills/harness-adapters/references/harness/claude.md +55 -0
  132. package/assets/reference/skills/harness-adapters/references/harness/codex.md +43 -0
  133. package/assets/reference/skills/harness-adapters/references/harness/cursor.md +75 -0
  134. package/assets/reference/skills/harness-adapters/references/harness/grok.md +69 -0
  135. package/assets/reference/skills/harness-adapters/references/harness/kimi.md +51 -0
  136. package/assets/reference/skills/harness-adapters/references/harness/muse.md +70 -0
  137. package/assets/reference/skills/harness-adapters/references/harness/opencode.md +42 -0
  138. package/assets/reference/skills/harness-adapters/references/harness/pi.md +56 -0
  139. package/assets/reference/skills/process-event-sources/SKILL.md +137 -0
  140. package/assets/reference/skills/project-management/SKILL.md +92 -0
  141. package/assets/reference/skills/quota-array-dispatch/SKILL.md +131 -0
  142. package/assets/reference/skills/secondmate-provisioning/SKILL.md +254 -0
  143. package/assets/reference/skills/stow/SKILL.md +309 -0
  144. package/assets/reference/skills/stuck-crewmate-recovery/SKILL.md +54 -0
  145. package/assets/reference/skills/updatefirstmate/SKILL.md +64 -0
  146. package/assets/skills/README.md +5 -0
  147. package/assets/yggdrasil.html +649 -0
  148. package/assets/ymir-banner-01.png +0 -0
  149. package/assets/ymir-banner-02.png +0 -0
  150. package/assets/ymir-banner-03.png +0 -0
  151. package/assets/ymir-banner-04.png +0 -0
  152. package/assets/ymir-banner-05.png +0 -0
  153. package/assets/ymir-banner-06.png +0 -0
  154. package/assets/ymir-emblem-darksteel.svg +63 -0
  155. package/assets/ymir-emblem-forged.svg +55 -0
  156. package/assets/ymir-emblem-slate.svg +55 -0
  157. package/assets/ymir-mark-algiz-anvil.svg +9 -0
  158. package/assets/ymir-stave.svg +53 -0
  159. package/assets/ymir.html +563 -0
  160. package/bin/ymir-install.sh +6 -1
  161. package/data/learnings.md +33 -0
  162. package/data/local-models.md +34 -0
  163. package/data/operator.md +15 -0
  164. package/data/projects.md +28 -0
  165. package/data/realm.md +1 -0
  166. package/deploy/Containerfile +27 -0
  167. package/deploy/README.md +104 -0
  168. package/deploy/compose/compose.yaml +45 -0
  169. package/deploy/env.example +30 -0
  170. package/deploy/quadlet/ymir.container +57 -0
  171. package/package.json +10 -8
@@ -0,0 +1,1535 @@
1
+ // Brokk supervision branch for Pi (docs/pi-supervision-branch.md).
2
+ //
3
+ // A persistent second AgentSession - the supervision BRANCH - inside the same
4
+ // pi process as the Allfather's MAIN session. The watcher extension offers each
5
+ // actionable wake here (lib/skuld-branch-dispatch.ts); the branch handles it with
6
+ // real tools and reports through the skuld_branch_report custom tool, which
7
+ // writes the durable outcome store FIRST (bin/skuld-branch-outcome.sh) and then
8
+ // merges an append-only note to main's tail. Main's Allfather/assistant dialog
9
+ // is mirrored into the branch as read-only brokk-main-mirror context from Pi's
10
+ // before_agent_start prompt and at main's turn_end. Pi-only by construction: this
11
+ // file lives in .pi/extensions, so no
12
+ // other harness ever loads it. Supervision is default-on for every task once
13
+ // this Pi session owns the fleet lock: no Allfather grant file is required.
14
+ // Away mode (or a broken branch) keeps today's wake-to-main behavior
15
+ // untouched regardless.
16
+ //
17
+ // Prefix stability (the cache contract, owner: bin/skuld-branch-prompt.sh
18
+ // header): the branch's system prompt is the generator's byte-stable output,
19
+ // the tool set is BRANCH_TOOL_NAMES in that fixed order on every spawn, and
20
+ // one shared per-home prompt_cache_key is set for branch requests in a
21
+ // before_provider_request hook - main keeps Pi's default per-session key.
22
+ // Wakes, mirrored dialog, and merge notes are all appends at a tail.
23
+ //
24
+ // Session-lock ownership: every branch side-effect boundary re-evaluates the
25
+ // current extension generation and lock ownership LAZILY, the same way the
26
+ // watcher extension evaluates ownership at arm time. A cold
27
+ // Pi start acquires the lock only when the session runs saga-session-start.sh,
28
+ // so latching ownership once at session_start would leave the branch inert
29
+ // for the whole process; and a secondary read-only Pi session that never owns
30
+ // the lock must never write markers, clean leases, or accept wakes.
31
+ //
32
+ // Failure direction: every path that cannot reach a working branch falls back
33
+ // to delivering the wake to MAIN exactly as before the branch existed - a
34
+ // broken branch degrades to today's behavior, never to a lost wake. The wake
35
+ // queue itself stays durable until the handler runs the drain's
36
+ // acknowledgement, so a branch that dies mid-handling re-presents its rows at
37
+ // the next drain exactly as a mid-handling main crash always has.
38
+ //
39
+ // Model and effort selection: supervision is an easier job than main, so the
40
+ // Allfather can pin a cheaper model AND a shallower reasoning effort for the
41
+ // branch alone with /skuld-model, which picks from Pi's own catalog and
42
+ // Pi's own supported-thinking-level list and persists each choice as one line
43
+ // under this home's config/. docs/configuration.md owns those files'
44
+ // operator-facing schema. The two pins are independent: either, both, or
45
+ // neither may be set. An absent pin makes the branch follow main's own
46
+ // current model or effort, applied explicitly on every build so a reopened
47
+ // branch cannot restore what an earlier pin left in its session.
48
+ //
49
+ // Threat model (Allfather-decided): the branch's actor identity is
50
+ // CONFUSED-AGENT-GRADE - deterministic spawnHook env injection plus a
51
+ // readonly-variable shell prelude so an accidental override fails loudly
52
+ // inside the branch's own shell. bin/brokk-lease-lib.sh documents the grade and
53
+ // its deliberate limits.
54
+ import { spawnSync } from "node:child_process";
55
+ import { createHash, randomUUID } from "node:crypto";
56
+ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
57
+ import { dirname, join, resolve } from "node:path";
58
+ import { fileURLToPath } from "node:url";
59
+ // Pi exposes pi-ai to extensions as a first-class module in both its Node
60
+ // and compiled-binary loaders, the same standing as pi-tui and typebox
61
+ // below, and aliases this root specifier to its compat entrypoint.
62
+ import { clampThinkingLevel, getSupportedThinkingLevels } from "@earendil-works/pi-ai";
63
+ import {
64
+ createAgentSession,
65
+ createBashToolDefinition,
66
+ DefaultResourceLoader,
67
+ DynamicBorder,
68
+ getAgentDir,
69
+ keyHint,
70
+ ModelRuntime,
71
+ SessionManager,
72
+ ToolExecutionComponent,
73
+ type AgentSession,
74
+ type ExtensionAPI,
75
+ type ExtensionCommandContext,
76
+ type ToolDefinition,
77
+ } from "@earendil-works/pi-coding-agent";
78
+ import { Box, Container, fuzzyFilter, Input, SelectList, Text } from "@earendil-works/pi-tui";
79
+ import { Type } from "typebox";
80
+ import {
81
+ type RoPresentationState,
82
+ roTranscriptClassIsVisible,
83
+ RO_PRESENTATION_EVENT,
84
+ } from "./lib/ro-visibility.ts";
85
+ import {
86
+ activateEligibleRowsOwner,
87
+ deactivateEligibleRowsOwner,
88
+ SKULD_BRANCH_DISPATCH_EVENT,
89
+ releaseEligibleRowsSnapshot,
90
+ scopeForSkuldWake,
91
+ writeEligibleRowsSnapshot,
92
+ type SkuldDispatchOffer,
93
+ } from "./lib/skuld-branch-dispatch.ts";
94
+ import {
95
+ BRANCH_PICKER_MAX_VISIBLE,
96
+ buildBranchModelItems,
97
+ filterBranchPickerItems,
98
+ FOLLOW_MAIN_VALUE,
99
+ type BranchPickerItem,
100
+ } from "./lib/skuld-branch-model-picker.ts";
101
+ import {
102
+ classifyRoddOperationalText,
103
+ encodeRoddOperationalInput,
104
+ } from "./lib/rodd-operational-input.ts";
105
+
106
+ const extensionFile = fileURLToPath(import.meta.url);
107
+ const extensionDir = dirname(extensionFile);
108
+ const root = resolve(extensionDir, "../..");
109
+ const fmHome = process.env.BROKK_HOME || process.env.BROKK_ROOT_OVERRIDE || root;
110
+ const fmRoot = process.env.BROKK_ROOT_OVERRIDE || root;
111
+ const state = process.env.BROKK_STATE_OVERRIDE || `${fmHome}/state`;
112
+ const config = process.env.BROKK_CONFIG_OVERRIDE || `${fmHome}/config`;
113
+ const afkFlag = join(state, ".afk");
114
+ const sessionsDir = join(state, "branch-session");
115
+ const sessionPointer = join(state, ".branch-session");
116
+ const mirrorCursorFile = join(state, ".branch-mirror-cursor");
117
+ const promptScript = join(fmRoot, "bin", "skuld-branch-prompt.sh");
118
+ const outcomeScript = join(fmRoot, "bin", "skuld-branch-outcome.sh");
119
+ const leaseScript = join(fmRoot, "bin", "brokk-lease.sh");
120
+ const wakeGrantScript = join(fmRoot, "bin", "brokk-wake-grant.sh");
121
+ const loadedMarker = join(state, ".pi-branch-extension-loaded");
122
+ const modelPinFile = join(config, "skuld-branch-model");
123
+ const effortPinFile = join(config, "skuld-branch-effort");
124
+
125
+ // Same tool set in the same order on every request (part of the cached
126
+ // prefix). "bash" resolves to the customTools override below, which injects
127
+ // the branch actor identity deterministically into every shell command.
128
+ const BRANCH_TOOL_NAMES = ["read", "bash", "skuld_branch_report"] as const;
129
+
130
+ // One shared prompt_cache_key per home for ALL branch sessions, derived only
131
+ // from the home path so it survives restarts; main keeps its own session key.
132
+ const branchCacheKey = `skuld-branch-${createHash("sha256").update(fmHome).digest("hex").slice(0, 24)}`;
133
+
134
+ const MIRROR_MESSAGE_CAP = 4000;
135
+ const MERGE_NOTE_BOAT = "⛵";
136
+ // Carried inside the Allfather note's own text because that text is the only
137
+ // part of a custom message Pi gives the model (see mergeIntoMain).
138
+ //
139
+ // The note still needs to identify itself so main cannot mistake an incoming
140
+ // outcome for its own earlier answer and silently lose the outcome. Event
141
+ // ownership forbids a second fleet operation, while the Allfather-facing verdict
142
+ // requires a visible response and leaves its wording to main.
143
+ const ALLFATHER_OUTCOME_INSTRUCTION =
144
+ "This is a supervision outcome delivered automatically by the supervision branch. " +
145
+ "It was not typed by the Allfather. " +
146
+ "The fleet event is already handled: do not re-drain, re-run, or acknowledge it. " +
147
+ "This outcome is Allfather-facing: give the Allfather a visible response now. " +
148
+ "Use your judgment over the wording and how to incorporate it, not whether to surface it. " +
149
+ "An outcome that directly answers an explicit Allfather request is Allfather-facing, regardless of whether it is healthy, routine, measured, actionable, or requires a decision.";
150
+ type MirrorItem = { tag: "Allfather" | "main"; text: string };
151
+ type MirrorCursor = { file: string; index: number };
152
+ type Verdict = "routine" | "Allfather";
153
+ type LockOwnership = "owned" | "other" | "missing";
154
+
155
+ const scriptEnv = {
156
+ ...process.env,
157
+ BROKK_HOME: fmHome,
158
+ BROKK_ROOT_OVERRIDE: fmRoot,
159
+ BROKK_STATE_OVERRIDE: state,
160
+ BROKK_CONFIG_OVERRIDE: config,
161
+ };
162
+
163
+ function offerEligible(offer: SkuldDispatchOffer): boolean {
164
+ return offer.eligible === true;
165
+ }
166
+
167
+ function afkActive(): boolean {
168
+ return existsSync(afkFlag);
169
+ }
170
+
171
+ // One model the runtime can hand back, without importing a model type
172
+ // directly, and Pi's own reasoning-effort vocabulary taken from the API
173
+ // surface Pi already hands this extension.
174
+ type BranchModel = NonNullable<ReturnType<ModelRuntime["getModel"]>>;
175
+ type BranchEffort = ReturnType<NonNullable<ExtensionAPI["getThinkingLevel"]>>;
176
+ type PinnedBranchModel = { model: BranchModel; modelRuntime: ModelRuntime };
177
+ type BranchModelResolution = { ok: true; selection: PinnedBranchModel } | { ok: false; reason: string };
178
+
179
+ // Pi owns the effort vocabulary. The picker's options and every clamp still
180
+ // come from Pi's own getSupportedThinkingLevels/clampThinkingLevel, so this
181
+ // array exists for exactly one job the type system cannot do at runtime:
182
+ // rejecting a hand-edited pin token Pi would not recognize at all. The
183
+ // assertion below fails the tracked strict typecheck against the INSTALLED Pi
184
+ // package (tests/brokk-pi-primary-types.test.sh) the moment Pi adds or removes a
185
+ // level, in either direction, so the list cannot drift into a stale Brokk
186
+ // catalog.
187
+ const BRANCH_EFFORT_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
188
+ type DeclaredBranchEffort = (typeof BRANCH_EFFORT_LEVELS)[number];
189
+ const piOwnsTheEffortVocabulary: [DeclaredBranchEffort] extends [BranchEffort]
190
+ ? [BranchEffort] extends [DeclaredBranchEffort]
191
+ ? true
192
+ : never
193
+ : never = true;
194
+ void piOwnsTheEffortVocabulary;
195
+
196
+ // The supervision-branch model pin, owned operator-side by
197
+ // docs/configuration.md: one "<provider>/<model-id>" line under this home's
198
+ // config/. An absent, unreadable, or unparseable file means no pin, and the
199
+ // branch then follows main's own model. Only the FIRST "/" separates the two
200
+ // halves, so a provider-qualified model id such as
201
+ // openrouter/anthropic/claude survives.
202
+ function readModelPin(): { provider: string; modelId: string } | null {
203
+ let stored: string;
204
+ try {
205
+ stored = readFileSync(modelPinFile, "utf8");
206
+ } catch {
207
+ return null;
208
+ }
209
+ const line = (stored.split("\n")[0] ?? "").trim();
210
+ const separator = line.indexOf("/");
211
+ if (separator <= 0 || separator >= line.length - 1) return null;
212
+ return { provider: line.slice(0, separator), modelId: line.slice(separator + 1) };
213
+ }
214
+
215
+ // The supervision-branch effort pin, owned operator-side by the same
216
+ // docs/configuration.md section: one Pi thinking-level line under this home's
217
+ // config/, independent of the model pin. An absent, unreadable, or
218
+ // unrecognized file means no pin, and the branch then follows main's own
219
+ // effort.
220
+ function readEffortPin(): BranchEffort | null {
221
+ let stored: string;
222
+ try {
223
+ stored = readFileSync(effortPinFile, "utf8");
224
+ } catch {
225
+ return null;
226
+ }
227
+ const line = (stored.split("\n")[0] ?? "").trim();
228
+ return (BRANCH_EFFORT_LEVELS as readonly string[]).includes(line) ? (line as BranchEffort) : null;
229
+ }
230
+
231
+ // Replaces a pin atomically so a failed write leaves the current choice
232
+ // intact rather than claiming persistence (the config/calm precedent).
233
+ function writePinFile(pinFile: string, selection: string): void {
234
+ mkdirSync(dirname(pinFile), { recursive: true });
235
+ const temporaryPath = `${pinFile}.${process.pid}.${randomUUID()}.tmp`;
236
+ try {
237
+ writeFileSync(temporaryPath, `${selection}\n`, { encoding: "utf8", flag: "wx", mode: 0o600 });
238
+ renameSync(temporaryPath, pinFile);
239
+ } finally {
240
+ rmSync(temporaryPath, { force: true });
241
+ }
242
+ }
243
+
244
+ function clearPinFile(pinFile: string): void {
245
+ rmSync(pinFile, { force: true });
246
+ }
247
+
248
+ function modelLabel(model: { provider: string; id: string }): string {
249
+ return `${model.provider}/${model.id}`;
250
+ }
251
+
252
+ function parentPid(pid: string): string {
253
+ const result = spawnSync("ps", ["-o", "ppid=", "-p", pid], { encoding: "utf8" });
254
+ if (result.status !== 0) return "";
255
+ return result.stdout.trim();
256
+ }
257
+
258
+ function pidAlive(pid: string): boolean {
259
+ try {
260
+ process.kill(Number(pid), 0);
261
+ return true;
262
+ } catch {
263
+ return false;
264
+ }
265
+ }
266
+
267
+ let ownedLockPid = "";
268
+
269
+ // Same ownership read as the watcher extension's lockOwnership(): the lock
270
+ // names the harness pid, and this process owns it when that pid appears in
271
+ // its own ancestry.
272
+ function lockOwnership(): LockOwnership {
273
+ ownedLockPid = "";
274
+ let lockPid = "";
275
+ try {
276
+ lockPid = readFileSync(`${state}/.lock`, "utf8").trim();
277
+ } catch {
278
+ return "missing";
279
+ }
280
+ if (!/^[0-9]+$/.test(lockPid) || lockPid === "1") return "other";
281
+ let pid = String(process.pid);
282
+ for (let i = 0; i < 8; i += 1) {
283
+ if (pid === lockPid) {
284
+ ownedLockPid = lockPid;
285
+ return "owned";
286
+ }
287
+ pid = parentPid(pid);
288
+ if (!pid || pid === "1") break;
289
+ }
290
+ return pidAlive(lockPid) ? "other" : "missing";
291
+ }
292
+
293
+ function textOfContent(content: unknown): string {
294
+ if (typeof content === "string") return content;
295
+ if (Array.isArray(content)) {
296
+ return content
297
+ .map((part) => {
298
+ const p = part as { type?: string; text?: string };
299
+ return p && p.type === "text" && typeof p.text === "string" ? p.text : "";
300
+ })
301
+ .filter((piece) => piece.length > 0)
302
+ .join("\n");
303
+ }
304
+ return "";
305
+ }
306
+
307
+ // Operational injections (watcher wakes, away-supervisor escalations, launch
308
+ // briefs) are fleet machinery, not Allfather dialog; the report's volume
309
+ // analysis counts them apart from dialog, and mirroring them would feed the
310
+ // branch its own supervision traffic back.
311
+ function isOperationalUserText(text: string): boolean {
312
+ return classifyRoddOperationalText(text) !== undefined;
313
+ }
314
+
315
+ function allfatherMirrorText(text: string): string {
316
+ if (text.length <= MIRROR_MESSAGE_CAP) return text;
317
+ const headLength = Math.ceil(MIRROR_MESSAGE_CAP / 2);
318
+ const tailLength = MIRROR_MESSAGE_CAP - headLength;
319
+ const omitted = text.length - MIRROR_MESSAGE_CAP;
320
+ return `${text.slice(0, headLength)}\n[mirror truncated: ${omitted} characters omitted]\n${text.slice(-tailLength)}`;
321
+ }
322
+
323
+ function readMirrorCursor(): MirrorCursor {
324
+ try {
325
+ const parsed = JSON.parse(readFileSync(mirrorCursorFile, "utf8")) as Partial<MirrorCursor>;
326
+ if (typeof parsed.file === "string" && typeof parsed.index === "number" && parsed.index >= 0) {
327
+ return { file: parsed.file, index: Math.floor(parsed.index) };
328
+ }
329
+ } catch {
330
+ // Absent or torn cursor: re-mirror the current main session from its
331
+ // start. Idempotent context, so over-mirroring is safe; dropping is not.
332
+ }
333
+ return { file: "", index: 0 };
334
+ }
335
+
336
+ function writeMirrorCursor(cursor: MirrorCursor): void {
337
+ mkdirSync(state, { recursive: true });
338
+ writeFileSync(mirrorCursorFile, `${JSON.stringify(cursor)}\n`);
339
+ }
340
+
341
+ type ReadonlyEntries = {
342
+ getSessionFile(): string | undefined;
343
+ getEntries(): Array<{ type: string }>;
344
+ };
345
+
346
+ // Volatile mirror-collection state. Instance-scoped and cleared at the
347
+ // session replacement boundary, so a replacement extension instance
348
+ // reconstructs EXCLUSIVELY from the durable cursor: dialog collected but not
349
+ // yet delivered re-mirrors rather than dropping (the durable cursor advances
350
+ // only in flushMirror after delivery).
351
+ type MirrorCollectionState = {
352
+ collectAnchor: MirrorCursor | null;
353
+ pendingCursor: MirrorCursor | null;
354
+ // Pi emits before_agent_start before it appends that turn's user message to
355
+ // SessionManager. The prompt is mirrored from the event immediately, then
356
+ // this marker suppresses the same persisted entry when turn_end collects it.
357
+ stagedAllfather: { file: string; index: number; text: string } | null;
358
+ };
359
+
360
+ function collectMainDialog(sessionManager: ReadonlyEntries, collection: MirrorCollectionState): MirrorItem[] {
361
+ const file = sessionManager.getSessionFile() ?? "";
362
+ const entries = sessionManager.getEntries();
363
+ const anchor = collection.collectAnchor ?? readMirrorCursor();
364
+ const start = anchor.file === file ? Math.min(anchor.index, entries.length) : 0;
365
+ let currentAllfatherIndex = -1;
366
+ for (let index = entries.length - 1; index >= start; index -= 1) {
367
+ const entry = entries[index];
368
+ if (entry.type !== "message") continue;
369
+ const message = (entry as { message?: { role?: string; content?: unknown } }).message;
370
+ if (message?.role !== "user") continue;
371
+ const text = textOfContent(message.content).trim();
372
+ if (!text || isOperationalUserText(text)) continue;
373
+ currentAllfatherIndex = index;
374
+ break;
375
+ }
376
+ const items: MirrorItem[] = [];
377
+ for (let index = start; index < entries.length; index += 1) {
378
+ const entry = entries[index];
379
+ if (entry.type !== "message") continue;
380
+ const message = (entry as { message?: { role?: string; content?: unknown } }).message;
381
+ if (!message) continue;
382
+ if (message.role !== "user" && message.role !== "assistant") continue;
383
+ const text = textOfContent(message.content).trim();
384
+ if (!text) continue;
385
+ if (message.role === "user" && isOperationalUserText(text)) continue;
386
+ const staged = collection.stagedAllfather;
387
+ if (
388
+ message.role === "user" &&
389
+ staged?.file === file &&
390
+ staged.index === index &&
391
+ staged.text === text
392
+ ) {
393
+ collection.stagedAllfather = null;
394
+ continue;
395
+ }
396
+ items.push({
397
+ tag: message.role === "user" ? "Allfather" : "main",
398
+ text: index === currentAllfatherIndex ? text : allfatherMirrorText(text),
399
+ });
400
+ }
401
+ collection.collectAnchor = { file, index: entries.length };
402
+ collection.pendingCursor = collection.collectAnchor;
403
+ return items;
404
+ }
405
+
406
+ export default function (pi: ExtensionAPI) {
407
+ let branch: AgentSession | null = null;
408
+ let branchBroken = "";
409
+ let mainStreaming = false;
410
+ let shuttingDown = false;
411
+ // Bumps at every session replacement so a stale chain continuation from the
412
+ // prior generation cannot act into the new one.
413
+ let generation = 0;
414
+ // One-time per-generation activation work (marker write + stray branch
415
+ // lease cleanup); ownership itself is re-read lazily at every boundary.
416
+ let activatedGeneration = -1;
417
+ // Serializes branch work: mirror appends and wake turns run strictly in
418
+ // dispatch order, one at a time (the branch runs drain -> handle -> ack
419
+ // serially by design).
420
+ let branchChain: Promise<void> = Promise.resolve();
421
+ const pendingMirror: MirrorItem[] = [];
422
+ const mirrorCollection: MirrorCollectionState = {
423
+ collectAnchor: null,
424
+ pendingCursor: null,
425
+ stagedAllfather: null,
426
+ };
427
+ let currentMainSession: ReadonlyEntries | null = null;
428
+ // One revision for BOTH selections: a model or effort change invalidates an
429
+ // in-flight branch build exactly the same way.
430
+ let branchSelectionRevision = 0;
431
+ // Main's own current model, tracked from the contexts Pi already hands this
432
+ // extension plus its model_select event, because createBranch runs at wake
433
+ // time with no context of its own. It is what "follow main" applies.
434
+ let mainModel: { provider: string; id: string } | null = null;
435
+
436
+ // Main's own current effort needs no such tracking: Pi answers it directly
437
+ // on demand, including at wake time. It throws only when the extension
438
+ // runtime is unbound or the captured API is stale, which is never a reason
439
+ // to refuse a wake.
440
+ function mainEffort(): BranchEffort | undefined {
441
+ try {
442
+ return pi.getThinkingLevel?.();
443
+ } catch {
444
+ return undefined;
445
+ }
446
+ }
447
+
448
+ function rememberMainModel(ctx?: { model?: { provider: string; id: string } }): void {
449
+ if (ctx?.model) mainModel = { provider: ctx.model.provider, id: ctx.model.id };
450
+ }
451
+
452
+ // Resolves one model against the isolated branch runtime using only the
453
+ // credentials that runtime already holds - the branch runs in the same home
454
+ // and same user as main, so stored credentials keep their own semantics
455
+ // (OAuth stays OAuth, an API key stays an API key) and nothing is ever
456
+ // installed, converted, derived, or overwritten here.
457
+ async function resolveBranchModel(provider: string, modelId: string): Promise<BranchModelResolution> {
458
+ const label = `${provider}/${modelId}`;
459
+ const modelRuntime = await ModelRuntime.create();
460
+ const model = modelRuntime.getModel(provider, modelId) as BranchModel | undefined;
461
+ if (!model) return { ok: false, reason: `${label} is unavailable to the isolated branch runtime` };
462
+ if (!modelRuntime.hasConfiguredAuth(provider)) {
463
+ return { ok: false, reason: `${label} has no configured credentials in the isolated branch runtime` };
464
+ }
465
+ return { ok: true, selection: { model, modelRuntime } };
466
+ }
467
+
468
+ async function preparePinnedBranchModel(pin: { provider: string; modelId: string }): Promise<PinnedBranchModel> {
469
+ const resolved = await resolveBranchModel(pin.provider, pin.modelId);
470
+ if (!resolved.ok) {
471
+ throw new Error(`supervision model pin ${resolved.reason} (config/skuld-branch-model)`);
472
+ }
473
+ return resolved.selection;
474
+ }
475
+
476
+ // The pin file's CURRENT state decides the model on every branch build,
477
+ // create and reopen alike, and it overrides Pi's restore of whatever model
478
+ // a reopened branch session recorded. With a pin, that model. With no pin,
479
+ // main's own model is applied EXPLICITLY - otherwise clearing the pin would
480
+ // report that the branch follows main while the reopened session quietly
481
+ // restored the model an earlier pin left behind. Only when main's model is
482
+ // genuinely unknown, or the isolated runtime cannot run it, does the build
483
+ // fall back to passing no override at all, which is the pre-feature
484
+ // behavior; an unpinned branch is never refused over model choice alone.
485
+ async function branchModelSelection(): Promise<PinnedBranchModel | undefined> {
486
+ const pin = readModelPin();
487
+ if (pin) return preparePinnedBranchModel(pin);
488
+ if (!mainModel) return undefined;
489
+ try {
490
+ const resolved = await resolveBranchModel(mainModel.provider, mainModel.id);
491
+ return resolved.ok ? resolved.selection : undefined;
492
+ } catch {
493
+ return undefined;
494
+ }
495
+ }
496
+
497
+ async function effectiveBranchModel(selected: BranchModel | undefined): Promise<BranchModel | undefined> {
498
+ if (selected) return selected;
499
+ try {
500
+ const recorded = readFileSync(sessionPointer, "utf8").trim();
501
+ if (!recorded || !existsSync(recorded)) return undefined;
502
+ const context = SessionManager.open(recorded, sessionsDir).buildSessionContext();
503
+ if (context.messages.length === 0 || !context.model) return undefined;
504
+ const resolved = await resolveBranchModel(context.model.provider, context.model.modelId);
505
+ return resolved.ok ? resolved.selection.model : undefined;
506
+ } catch {
507
+ return undefined;
508
+ }
509
+ }
510
+
511
+ // The effort pin file's CURRENT state decides the branch's reasoning effort
512
+ // on every branch build, create and reopen alike, on exactly the model-pin
513
+ // contract above and for exactly the same reason: a reopened branch session
514
+ // records the effort it last ran under, so an unpinned branch must apply
515
+ // main's own effort EXPLICITLY or clearing a pin would silently restore the
516
+ // level that pin left behind. Pi owns the clamp, so a level the branch's
517
+ // model does not support becomes that model's nearest supported level
518
+ // rather than a refusal - the branch is never refused over effort. Only
519
+ // when main's own effort is unknowable too does the build fall back to
520
+ // passing no effort override at all, which is the behavior from before this
521
+ // file existed.
522
+ function branchEffortSelection(model: BranchModel | undefined): BranchEffort | undefined {
523
+ const chosen = readEffortPin() ?? mainEffort();
524
+ if (chosen === undefined) return undefined;
525
+ return model ? (clampThinkingLevel(model, chosen) as BranchEffort) : chosen;
526
+ }
527
+
528
+ function generationOwnsLock(expectedGeneration: number): boolean {
529
+ return !shuttingDown && expectedGeneration === generation && lockOwnership() === "owned";
530
+ }
531
+
532
+ function markLoaded(): void {
533
+ try {
534
+ mkdirSync(state, { recursive: true });
535
+ writeFileSync(loadedMarker, `${process.pid}\n`);
536
+ } catch {
537
+ // Diagnostic marker only; never block activation on it.
538
+ }
539
+ }
540
+
541
+ // A replaced branch conversation must not leave its per-task leases behind
542
+ // (the session-lock holder pid is still alive, so the sweep alone would
543
+ // keep them). One bulk release per generation, at activation.
544
+ function releaseBranchLeases(expectedGeneration: number): boolean {
545
+ if (!generationOwnsLock(expectedGeneration)) return false;
546
+ try {
547
+ const result = spawnSync("bash", [leaseScript, "release-actor", "--actor", "branch"], {
548
+ cwd: fmRoot,
549
+ encoding: "utf8",
550
+ env: { ...scriptEnv, SKULD_ACTOR: "branch" },
551
+ });
552
+ return result.status === 0;
553
+ } catch {
554
+ return false;
555
+ }
556
+ }
557
+
558
+ // Lazy, per-action ownership evaluation (see the header). Returns true only
559
+ // when this session owns the fleet lock right now; the first true evaluation
560
+ // of a generation also writes the diagnostic marker and clears stray branch
561
+ // leases from a prior generation.
562
+ function actingAsOwner(expectedGeneration = generation): boolean {
563
+ if (!generationOwnsLock(expectedGeneration)) return false;
564
+ if (activatedGeneration !== expectedGeneration) {
565
+ if (!releaseBranchLeases(expectedGeneration)) return false;
566
+ if (!generationOwnsLock(expectedGeneration)) return false;
567
+ if (!activateEligibleRowsOwner(state, wakeGrantScript, process.pid, String(expectedGeneration))) return false;
568
+ if (!generationOwnsLock(expectedGeneration)) {
569
+ deactivateEligibleRowsOwner(state, wakeGrantScript, process.pid, String(expectedGeneration));
570
+ return false;
571
+ }
572
+ markLoaded();
573
+ activatedGeneration = expectedGeneration;
574
+ }
575
+ return generationOwnsLock(expectedGeneration);
576
+ }
577
+
578
+ function runOutcomeScript(args: string[]): { ok: boolean; stdout: string; detail: string } {
579
+ try {
580
+ const result = spawnSync("bash", [outcomeScript, ...args], {
581
+ cwd: fmRoot,
582
+ encoding: "utf8",
583
+ env: scriptEnv,
584
+ });
585
+ if (result.status === 0) return { ok: true, stdout: (result.stdout || "").trim(), detail: "" };
586
+ return {
587
+ ok: false,
588
+ stdout: "",
589
+ detail: `skuld-branch-outcome.sh exited ${result.status ?? "none"}: ${(result.stderr || "").trim()}`,
590
+ };
591
+ } catch (error) {
592
+ return { ok: false, stdout: "", detail: error instanceof Error ? error.message : String(error) };
593
+ }
594
+ }
595
+
596
+ // Append-only merge into main. The store row is already durable when this
597
+ // runs; the note is a cache of it at main's tail. Delivery modes per the
598
+ // design: routine+idle appends now with no turn, routine+busy appends after
599
+ // the Allfather's next prompt, Allfather-relevant triggers exactly one turn
600
+ // (queued as a follow-up while main is busy) - that follow-up turn is
601
+ // itself the Allfather-visible outcome, so the Allfather-facing note is
602
+ // delivered silently (display: false) rather than printed or rendered a
603
+ // second time; routine notes stay rendered except an explicitly silent
604
+ // no-change heartbeat. The read cursor advances once the note is handed to
605
+ // Pi; a crash inside Pi's
606
+ // own delivery window leaves the outcome durable in the store, where
607
+ // main's brokk_branch_outcomes tool still reads it on demand.
608
+ //
609
+ // Pi keeps only `content` when it converts a custom message for the model:
610
+ // customType, display, and details never reach the provider. A Allfather note
611
+ // therefore has to carry its own identity inside `content`, or main receives
612
+ // an unattributed user message written in main's own Allfather-facing voice
613
+ // and cannot tell an incoming outcome from its own earlier answer. When that
614
+ // happens main can lose the outcome while deciding how to handle it. The
615
+ // typed operational envelope is what makes the note self-describing; it stays
616
+ // invisible to the Allfather because the note is never rendered. The
617
+ // instruction preserves the event-ownership boundary while requiring the
618
+ // Allfather-facing response and leaving its wording to main.
619
+ //
620
+ // Encoding shells out, so it can fail on a broken checkout. This file's
621
+ // failure direction applies: an outcome that cannot be typed is still
622
+ // delivered, carrying the same instruction as plain text, because an
623
+ // untyped outcome main can still read beats an outcome the Allfather never
624
+ // sees.
625
+ function AllfatherOutcomeInput(task: string, summary: string): string {
626
+ const body = `${ALLFATHER_OUTCOME_INSTRUCTION}\n\n${task}: ${summary}`;
627
+ try {
628
+ return encodeRoddOperationalInput("branch-outcome", body);
629
+ } catch {
630
+ return body;
631
+ }
632
+ }
633
+
634
+ function mergeIntoMain(
635
+ expectedGeneration: number,
636
+ seq: string,
637
+ task: string,
638
+ verdict: Verdict,
639
+ summary: string,
640
+ silent: boolean,
641
+ ): boolean {
642
+ if (!actingAsOwner(expectedGeneration)) return false;
643
+ if (verdict === "Allfather") {
644
+ const message = {
645
+ customType: "skuld-branch-merge",
646
+ content: AllfatherOutcomeInput(task, summary),
647
+ display: false,
648
+ };
649
+ pi.sendMessage(message, { triggerTurn: true, deliverAs: "followUp" });
650
+ } else {
651
+ const message = { customType: "skuld-branch-merge", content: `${MERGE_NOTE_BOAT} ${task}: ${summary}`, display: !(task === "fleet" && silent) };
652
+ if (mainStreaming) {
653
+ pi.sendMessage(message, { deliverAs: "nextTurn" });
654
+ } else {
655
+ pi.sendMessage(message, {});
656
+ }
657
+ }
658
+ if (/^[0-9]+$/.test(seq)) {
659
+ if (!actingAsOwner(expectedGeneration)) return false;
660
+ return runOutcomeScript(["mark-read", "--through", seq]).ok;
661
+ }
662
+ return true;
663
+ }
664
+
665
+ function createReportTool(toolGeneration: number): ToolDefinition {
666
+ return {
667
+ name: "skuld_branch_report",
668
+ label: "Report supervision outcome",
669
+ description:
670
+ "Record the outcome of one handled fleet event: write it durably to the outcome store, then merge an append-only note into the Allfather-facing main conversation. verdict Allfather surfaces it to the Allfather in one turn; routine notes render unless silent marks a no-change heartbeat.",
671
+ parameters: Type.Object({
672
+ task: Type.String({ description: "The task id the event belongs to (or 'fleet' for fleet-wide events)" }),
673
+ verdict: Type.Union([Type.Literal("routine"), Type.Literal("Allfather")], {
674
+ description:
675
+ "Use Allfather unconditionally for an outcome that directly answers an explicit Allfather request, regardless of whether it is healthy, routine, measured, actionable, or requires a decision. Also use Allfather for work ready for review, Allfather-only decisions, blockers or failures after recovery is exhausted, needed credentials, and destructive, irreversible, or security-sensitive actions; use routine otherwise.",
676
+ }),
677
+ summary: Type.String({
678
+ description:
679
+ "One or two sentences in Allfather outcome language; include the full https:// PR URL when a PR is involved",
680
+ }),
681
+ wake: Type.Optional(Type.String({ description: "The wake reason line this outcome answers" })),
682
+ silent: Type.Optional(Type.Boolean({
683
+ description: "True only when a fleet-wide heartbeat review found literally nothing worth reporting; omit or use false whenever any action was taken or any routine result is worth a note",
684
+ })),
685
+ }),
686
+ execute: async (_toolCallId, params) => {
687
+ const task = String((params as { task: unknown }).task || "").trim();
688
+ const verdictRaw = String((params as { verdict: unknown }).verdict || "");
689
+ const summary = String((params as { summary: unknown }).summary || "").trim();
690
+ const wake = String((params as { wake?: unknown }).wake ?? "").trim();
691
+ const silent = (params as { silent?: unknown }).silent === true;
692
+ if (!task || !summary || (verdictRaw !== "routine" && verdictRaw !== "Allfather") || (silent && (task !== "fleet" || verdictRaw !== "routine"))) {
693
+ return {
694
+ content: [{ type: "text", text: "invalid report: task, verdict (routine|Allfather), and summary are required" }],
695
+ details: undefined,
696
+ isError: true,
697
+ };
698
+ }
699
+ const verdict = verdictRaw as Verdict;
700
+ const appendArgs = ["append", "--task", task, "--verdict", verdict, "--summary", summary, "--silent", String(silent)];
701
+ if (wake) appendArgs.push("--wake", wake);
702
+ if (!actingAsOwner(toolGeneration)) {
703
+ return {
704
+ content: [{ type: "text", text: "report refused: supervision session was replaced or lost lock ownership" }],
705
+ details: undefined,
706
+ isError: true,
707
+ };
708
+ }
709
+ const appended = runOutcomeScript(appendArgs);
710
+ if (!appended.ok) {
711
+ return {
712
+ content: [{ type: "text", text: `outcome store append failed (nothing merged): ${appended.detail}` }],
713
+ details: undefined,
714
+ isError: true,
715
+ };
716
+ }
717
+ if (!mergeIntoMain(toolGeneration, appended.stdout, task, verdict, summary, silent)) {
718
+ return {
719
+ content: [{ type: "text", text: `recorded seq ${appended.stdout}, but merge refused after supervision replacement or lock loss` }],
720
+ details: undefined,
721
+ isError: true,
722
+ };
723
+ }
724
+ return {
725
+ content: [{ type: "text", text: `recorded seq ${appended.stdout} and merged [${verdict}] into main` }],
726
+ details: undefined,
727
+ };
728
+ },
729
+ };
730
+ }
731
+
732
+ async function createBranch(branchGeneration: number): Promise<AgentSession> {
733
+ // Resolved first, before any session file or prompt work: a model pin Pi
734
+ // cannot honor must fail before this build leaves anything behind. Every
735
+ // branch build goes through here - first wake of a cold start, and the
736
+ // reopen after /new, /resume, /fork, or reload - so resolving the model
737
+ // and the effort here is what makes the Allfather's current choices
738
+ // authoritative on all of them.
739
+ const pinned = await branchModelSelection();
740
+ const effort = branchEffortSelection(pinned?.model);
741
+ const prompt = spawnSync("bash", [promptScript], {
742
+ cwd: fmRoot,
743
+ encoding: "utf8",
744
+ env: scriptEnv,
745
+ maxBuffer: 4 * 1024 * 1024,
746
+ });
747
+ if (prompt.status !== 0 || !prompt.stdout || prompt.stdout.length < 1024) {
748
+ throw new Error(
749
+ `skuld-branch-prompt.sh did not produce a usable branch prompt (status=${prompt.status ?? "none"}): ${(prompt.stderr || "").trim()}`,
750
+ );
751
+ }
752
+ if (!actingAsOwner(branchGeneration)) throw new Error("supervision session was replaced or lost lock ownership");
753
+ mkdirSync(sessionsDir, { recursive: true });
754
+ let sessionManager: SessionManager | null = null;
755
+ try {
756
+ const recorded = readFileSync(sessionPointer, "utf8").trim();
757
+ if (recorded && existsSync(recorded)) {
758
+ sessionManager = SessionManager.open(recorded, sessionsDir);
759
+ }
760
+ } catch {
761
+ sessionManager = null;
762
+ }
763
+ if (!sessionManager) {
764
+ sessionManager = SessionManager.create(fmRoot, sessionsDir);
765
+ }
766
+ // The branch loads no project resources at all: extensions off (so it can
767
+ // never spawn its own branch), skills/context files off (they vary per
768
+ // home and would destabilize the byte-stable prefix). Its whole standing
769
+ // context is the generator's prompt.
770
+ const loader = new DefaultResourceLoader({
771
+ cwd: fmRoot,
772
+ agentDir: getAgentDir(),
773
+ noExtensions: true,
774
+ noSkills: true,
775
+ noPromptTemplates: true,
776
+ noThemes: true,
777
+ noContextFiles: true,
778
+ systemPrompt: prompt.stdout,
779
+ extensionFactories: [
780
+ {
781
+ name: "skuld-branch-cache-key",
782
+ smidja: (branchPi: ExtensionAPI) => {
783
+ branchPi.on("before_provider_request", (event) => {
784
+ const payload = event.payload;
785
+ // Only providers whose request already carries Pi's default
786
+ // per-session prompt_cache_key get the shared per-home override;
787
+ // any other provider payload passes through untouched.
788
+ if (payload && typeof payload === "object" && "prompt_cache_key" in payload) {
789
+ return { ...(payload as Record<string, unknown>), prompt_cache_key: branchCacheKey };
790
+ }
791
+ });
792
+ },
793
+ },
794
+ ],
795
+ });
796
+ await loader.reload();
797
+ if (!actingAsOwner(branchGeneration)) throw new Error("supervision session was replaced or lost lock ownership");
798
+ const leaseHolderPid = ownedLockPid;
799
+ const bashTool = createBashToolDefinition(fmRoot, {
800
+ spawnHook: (context) => {
801
+ if (!actingAsOwner(branchGeneration)) {
802
+ throw new Error("bash refused: supervision session was replaced or lost lock ownership");
803
+ }
804
+ return {
805
+ ...context,
806
+ // Loud accidental-override guard (Allfather-decided): the actor
807
+ // variables are readonly inside the branch's own shell, so an
808
+ // accidental in-shell reassignment fails loudly instead of silently
809
+ // impersonating main. Confused-agent-grade by design; the threat
810
+ // model lives in bin/brokk-lease-lib.sh.
811
+ command: `readonly SKULD_ACTOR GLEIPNIR_LEASE_HOLDER_PID
812
+ (
813
+ ${context.command}
814
+ )`,
815
+ env: {
816
+ ...context.env,
817
+ ...scriptEnv,
818
+ SKULD_ACTOR: "branch",
819
+ GLEIPNIR_LEASE_HOLDER_PID: leaseHolderPid,
820
+ },
821
+ };
822
+ },
823
+ });
824
+ const created = await createAgentSession({
825
+ cwd: fmRoot,
826
+ sessionManager,
827
+ resourceLoader: loader,
828
+ tools: [...BRANCH_TOOL_NAMES],
829
+ customTools: [bashTool as unknown as ToolDefinition, createReportTool(branchGeneration)],
830
+ ...(pinned ? { model: pinned.model, modelRuntime: pinned.modelRuntime } : {}),
831
+ ...(effort === undefined ? {} : { thinkingLevel: effort }),
832
+ });
833
+ if (!actingAsOwner(branchGeneration)) {
834
+ try {
835
+ created.session.dispose();
836
+ } catch {}
837
+ throw new Error("supervision session was replaced or lost lock ownership");
838
+ }
839
+ try {
840
+ writeFileSync(sessionPointer, `${sessionManager.getSessionFile()}\n`);
841
+ } catch {
842
+ // Pointer write failure only costs cross-restart session reuse.
843
+ }
844
+ return created.session;
845
+ }
846
+
847
+ async function ensureBranch(expectedGeneration: number): Promise<AgentSession> {
848
+ if (!actingAsOwner(expectedGeneration)) throw new Error("supervision session was replaced or lost lock ownership");
849
+ if (branch) return branch;
850
+ if (branchBroken) throw new Error(branchBroken);
851
+ while (true) {
852
+ const buildRevision = branchSelectionRevision;
853
+ try {
854
+ const created = await createBranch(expectedGeneration);
855
+ if (buildRevision !== branchSelectionRevision) {
856
+ try {
857
+ created.dispose();
858
+ } catch {}
859
+ continue;
860
+ }
861
+ if (!actingAsOwner(expectedGeneration)) {
862
+ try {
863
+ created.dispose();
864
+ } catch {}
865
+ throw new Error("supervision session was replaced or lost lock ownership");
866
+ }
867
+ branch = created;
868
+ return created;
869
+ } catch (error) {
870
+ if (buildRevision !== branchSelectionRevision) continue;
871
+ if (expectedGeneration === generation && !shuttingDown) {
872
+ branchBroken = error instanceof Error ? error.message : String(error);
873
+ }
874
+ throw error;
875
+ }
876
+ }
877
+ }
878
+
879
+ async function flushMirror(session: AgentSession, expectedGeneration: number): Promise<void> {
880
+ if (!actingAsOwner(expectedGeneration)) throw new Error("supervision session no longer owns the fleet lock");
881
+ while (pendingMirror.length > 0) {
882
+ const item = pendingMirror[0];
883
+ if (!actingAsOwner(expectedGeneration)) throw new Error("supervision session no longer owns the fleet lock");
884
+ await session.sendCustomMessage(
885
+ { customType: "brokk-main-mirror", content: `[${item.tag}] ${item.text}`, display: false },
886
+ {},
887
+ );
888
+ if (!actingAsOwner(expectedGeneration)) throw new Error("supervision session was replaced during mirror delivery");
889
+ pendingMirror.shift();
890
+ }
891
+ if (mirrorCollection.pendingCursor) {
892
+ if (!actingAsOwner(expectedGeneration)) throw new Error("supervision session no longer owns the fleet lock");
893
+ writeMirrorCursor(mirrorCollection.pendingCursor);
894
+ mirrorCollection.pendingCursor = null;
895
+ }
896
+ }
897
+
898
+ async function fallbackToMain(message: string, detail: string): Promise<void> {
899
+ const body = `BROKK WATCHER WAKE: ${message}\n\nRun bin/saga-wake-drain.sh first and handle the queued wake. (Supervision branch unavailable, falling back to main: ${detail})`;
900
+ let content = body;
901
+ try {
902
+ // Marked operational like every watcher injection, so the wake is never
903
+ // mistaken for Allfather input (away-mode return semantics, mirror filter).
904
+ content = encodeRoddOperationalInput("watcher", body);
905
+ } catch {
906
+ // An encoding failure must not lose the wake; deliver it unmarked.
907
+ }
908
+ await pi.sendUserMessage(content, { deliverAs: "followUp" });
909
+ }
910
+
911
+ function enqueueWake(message: string, acceptedGeneration: number): void {
912
+ branchChain = branchChain
913
+ .then(async () => {
914
+ if (shuttingDown || acceptedGeneration !== generation) {
915
+ throw new Error("supervision session was replaced before handling the accepted wake");
916
+ }
917
+ if (!actingAsOwner(acceptedGeneration)) throw new Error("supervision session no longer owns the fleet lock");
918
+ const session = await ensureBranch(acceptedGeneration);
919
+ await flushMirror(session, acceptedGeneration);
920
+ if (!actingAsOwner(acceptedGeneration)) throw new Error("supervision session no longer owns the fleet lock");
921
+ const heartbeat = /^heartbeat($|:)/.test(message);
922
+ const scope = scopeForSkuldWake(state, heartbeat);
923
+ // A newly-arrived main-owned (check-kind) row never bounces this
924
+ // whole recheck back to main - scopeForSkuldWake excludes it from
925
+ // eligibleSeqs rather than vetoing the scan, in a heartbeat review as
926
+ // in every other, so it stays queued for main while whatever else is
927
+ // eligible right now still reaches the branch. A genuinely empty
928
+ // queue, or a queue that simply has nothing (or nothing further)
929
+ // eligible for the branch right now, is an ordinary quiet no-op - not
930
+ // a fault, so it is never reported back to main. Only a scan
931
+ // scopeForSkuldWake itself marks corrupted (the queue or its
932
+ // metadata could not be read safely, or an unresolvable task-local
933
+ // row) still falls back to main.
934
+ if (scope.status === "empty" || (!scope.corrupted && scope.eligibleSeqs.length === 0)) return;
935
+ if (scope.corrupted) {
936
+ throw new Error("the unread wake queue could not be read safely");
937
+ }
938
+ const grant = writeEligibleRowsSnapshot(
939
+ state,
940
+ scope.eligibleSeqs,
941
+ wakeGrantScript,
942
+ String(acceptedGeneration),
943
+ );
944
+ if (grant === "main-owned") throw new Error("the wake rows are already claimed by main");
945
+ if (grant !== "published") throw new Error("could not record the branch's eligible row snapshot");
946
+ // A row can still arrive between this re-check and the model starting
947
+ // the drain; that residual is accepted by the confused-agent-grade boundary.
948
+ await session.prompt(
949
+ `BROKK SUPERVISION WAKE: ${message}\n\nHandle this per your operating procedure and finish with skuld_branch_report.`,
950
+ );
951
+ if (!releaseEligibleRowsSnapshot(state, wakeGrantScript, String(acceptedGeneration))) {
952
+ throw new Error("could not release the branch's settled wake-row grant");
953
+ }
954
+ })
955
+ .catch(async (error: unknown) => {
956
+ releaseEligibleRowsSnapshot(state, wakeGrantScript, String(acceptedGeneration));
957
+ try {
958
+ await fallbackToMain(message, error instanceof Error ? error.message : String(error));
959
+ } catch {}
960
+ });
961
+ }
962
+
963
+ // A model or effort change applies to the next branch turn without waiting
964
+ // for /new: the live session is dropped synchronously so nothing enqueued
965
+ // afterwards can capture it, then disposed in dispatch order behind work
966
+ // already queued. The branch CONVERSATION is persistent
967
+ // (state/.branch-session), so the next wake reopens the same conversation
968
+ // under the new selection. Clearing the broken latch is what lets a
969
+ // corrected pin recover in place.
970
+ function releaseBranchForSelectionChange(): void {
971
+ branchBroken = "";
972
+ const stale = branch;
973
+ branch = null;
974
+ if (!stale) return;
975
+ branchChain = branchChain
976
+ .then(() => {
977
+ stale.dispose();
978
+ })
979
+ .catch(() => {
980
+ // Already gone, or disposed by a session replacement first.
981
+ });
982
+ }
983
+
984
+ function collectCurrentMainDialog(): boolean {
985
+ if (!currentMainSession) return true;
986
+ try {
987
+ pendingMirror.push(...collectMainDialog(currentMainSession, mirrorCollection));
988
+ return true;
989
+ } catch {
990
+ return false;
991
+ }
992
+ }
993
+
994
+ function enqueueMirrorFlush(): void {
995
+ if (!branch || pendingMirror.length === 0) return;
996
+ const flushGeneration = generation;
997
+ const flushSession = branch;
998
+ branchChain = branchChain
999
+ .then(async () => {
1000
+ if (!actingAsOwner(flushGeneration)) return;
1001
+ await flushMirror(flushSession, flushGeneration);
1002
+ })
1003
+ .catch(() => {
1004
+ // Mirror items stay queued in pendingMirror on failure; the next wake
1005
+ // or flush retries them in order.
1006
+ });
1007
+ }
1008
+
1009
+ pi.events?.on?.(SKULD_BRANCH_DISPATCH_EVENT, (data) => {
1010
+ const offer = data as SkuldDispatchOffer;
1011
+ if (!offer || typeof offer.accept !== "function") return;
1012
+ // Check eligibility before ownership activation so an out-of-scope wake
1013
+ // gets neither branch routing nor branch-owned state/lease cleanup side
1014
+ // effects.
1015
+ if (!offerEligible(offer)) return;
1016
+ if (!actingAsOwner()) return; // cold start pre-lock, secondary session, or shutdown
1017
+ if (afkActive()) return; // the away daemon owns supervision while afk
1018
+ if (branchBroken) return; // fail back to today's wake-to-main path
1019
+ if (!collectCurrentMainDialog()) return;
1020
+ offer.accept();
1021
+ enqueueWake(offer.message, generation);
1022
+ });
1023
+
1024
+ pi.on?.("before_agent_start", (event, ctx) => {
1025
+ rememberMainModel(ctx);
1026
+ currentMainSession = ctx?.sessionManager ?? null;
1027
+ if (!actingAsOwner() || !currentMainSession || !collectCurrentMainDialog()) return;
1028
+
1029
+ // This event is Pi's authoritative complete current prompt. At this point
1030
+ // SessionManager still contains only the preceding dialog, so relying on
1031
+ // getEntries() here loses the Allfather request that the next wake may answer.
1032
+ // Stage it verbatim and remember the future persisted index for turn_end's
1033
+ // duplicate suppression. Operational extension injections are not dialog.
1034
+ const prompt = event.prompt.trim();
1035
+ if (!prompt || isOperationalUserText(prompt)) return;
1036
+ const file = currentMainSession.getSessionFile() ?? "";
1037
+ const index = mirrorCollection.collectAnchor?.index ?? currentMainSession.getEntries().length;
1038
+ pendingMirror.push({ tag: "Allfather", text: prompt });
1039
+ mirrorCollection.stagedAllfather = { file, index, text: prompt };
1040
+ });
1041
+
1042
+ pi.on?.("agent_start", () => {
1043
+ mainStreaming = true;
1044
+ });
1045
+ pi.on?.("agent_end", () => {
1046
+ mainStreaming = false;
1047
+ });
1048
+ pi.on?.("agent_settled", () => {
1049
+ mainStreaming = false;
1050
+ });
1051
+
1052
+ // before_agent_start stages Pi's authoritative in-flight prompt before
1053
+ // SessionManager persists it. The dispatch handler then collects any newly
1054
+ // persisted dialog immediately before accepting a wake, so all context joins
1055
+ // the serialized chain before that wake's branch prompt. turn_end remains
1056
+ // the idle-path mirror flush. The durable cursor advances only in
1057
+ // flushMirror after the complete pending batch reaches the branch.
1058
+ pi.on?.("turn_end", (_event, ctx) => {
1059
+ rememberMainModel(ctx);
1060
+ currentMainSession = ctx.sessionManager;
1061
+ if (!actingAsOwner() || !collectCurrentMainDialog()) return;
1062
+ enqueueMirrorFlush();
1063
+ });
1064
+
1065
+ // Pi emits session_shutdown for ordinary same-process replacements (/new,
1066
+ // /resume, /fork, reload) as well as terminal quit, exactly as the watcher
1067
+ // extension documents. Shutdown quiesces this generation, clears the
1068
+ // volatile mirror state so the replacement reconstructs from the durable
1069
+ // cursor, and releases the branch session; a replacement session_start
1070
+ // re-arms, and the next wake reopens the persistent branch from its
1071
+ // recorded pointer. Terminal quit simply never fires another session_start.
1072
+ pi.on?.("session_start", (_event, ctx) => {
1073
+ rememberMainModel(ctx);
1074
+ currentMainSession = ctx?.sessionManager ?? null;
1075
+ shuttingDown = false;
1076
+ branchBroken = "";
1077
+ generation += 1;
1078
+ actingAsOwner(generation);
1079
+ });
1080
+
1081
+ // Pi emits this for /model, Ctrl+P cycling, and session restore, so it is
1082
+ // the authoritative signal that "follow main" now means a different model.
1083
+ // A model change often follows a quota failure, so an unpinned supervision
1084
+ // branch follows live rather than retaining a model that may no longer work.
1085
+ pi.on?.("model_select", (event) => {
1086
+ const selected = (event as { model?: { provider: string; id: string } }).model;
1087
+ if (!selected) return;
1088
+ const changed = !mainModel || mainModel.provider !== selected.provider || mainModel.id !== selected.id;
1089
+ mainModel = { provider: selected.provider, id: selected.id };
1090
+ if (!changed || readModelPin()) return;
1091
+ branchSelectionRevision += 1;
1092
+ releaseBranchForSelectionChange();
1093
+ });
1094
+
1095
+ // Pi emits this only when main's effort actually changes, so an unpinned
1096
+ // supervision branch follows main's effort live for the same reason it
1097
+ // follows main's model: the Allfather's current setting, not the level the
1098
+ // branch conversation happens to have recorded, is what supervision should
1099
+ // run at. A pin stays authoritative and is left alone.
1100
+ pi.on?.("thinking_level_select", (event) => {
1101
+ const level = (event as { level?: BranchEffort }).level;
1102
+ if (!level || readEffortPin()) return;
1103
+ branchSelectionRevision += 1;
1104
+ releaseBranchForSelectionChange();
1105
+ });
1106
+
1107
+ pi.on?.("session_shutdown", () => {
1108
+ deactivateEligibleRowsOwner(state, wakeGrantScript, process.pid, String(generation));
1109
+ shuttingDown = true;
1110
+ generation += 1;
1111
+ pendingMirror.length = 0;
1112
+ currentMainSession = null;
1113
+ mirrorCollection.collectAnchor = null;
1114
+ mirrorCollection.pendingCursor = null;
1115
+ mirrorCollection.stagedAllfather = null;
1116
+ if (branch) {
1117
+ try {
1118
+ branch.dispose();
1119
+ } catch {
1120
+ // Already gone.
1121
+ }
1122
+ branch = null;
1123
+ }
1124
+ });
1125
+
1126
+ // Pi keeps /model and its own thinking selector for the Allfather's own
1127
+ // conversation and exposes no hook an extension can use to open either
1128
+ // picker, so this is the smallest supported equivalent: Pi's own catalog
1129
+ // intersected with the isolated branch runtime, then Pi's own supported
1130
+ // thinking levels for the model just chosen, with no parallel Brokk
1131
+ // model or effort list. The model step shows that catalog through the same
1132
+ // bounded, searchable SelectList primitive Pi's own /model dialog scrolls
1133
+ // (pickBranchModel below); the effort step's menu is a handful of levels
1134
+ // and stays on Pi's generic selector dialog. The effort step follows the
1135
+ // model step because the model decides which levels exist.
1136
+ pi.registerCommand?.("skuld-model", {
1137
+ description: "Pick the model and reasoning effort Brokk's Pi supervision branch uses, or follow main's.",
1138
+ handler: async (_args, ctx) => {
1139
+ rememberMainModel(ctx);
1140
+ const pin = readModelPin();
1141
+ const current = pin ? `${pin.provider}/${pin.modelId}` : "follows main";
1142
+ const followMain = `Follow main${ctx.model ? ` (${modelLabel(ctx.model)})` : ""}`;
1143
+ let available: string[];
1144
+ try {
1145
+ const modelRuntime = await ModelRuntime.create();
1146
+ available = ctx.modelRegistry
1147
+ .getAvailable()
1148
+ .filter((model) => modelRuntime.getModel(model.provider, model.id) && modelRuntime.hasConfiguredAuth(model.provider))
1149
+ .map(modelLabel);
1150
+ } catch (error) {
1151
+ ctx.ui.notify(
1152
+ `Could not read the supervision branch models: ${error instanceof Error ? error.message : String(error)}`,
1153
+ "error",
1154
+ );
1155
+ return;
1156
+ }
1157
+ const picked = await pickBranchModel(
1158
+ ctx,
1159
+ `Supervision branch model (now: ${current})`,
1160
+ buildBranchModelItems(followMain, available, pin ? `${pin.provider}/${pin.modelId}` : null),
1161
+ );
1162
+ if (picked === undefined) return; // cancelled: the current choice stands
1163
+ // Whatever the model step resolves is also the model the effort step
1164
+ // builds its menu from, so it is captured here rather than resolved a
1165
+ // second time through another isolated runtime.
1166
+ let branchModel: BranchModel | undefined;
1167
+ try {
1168
+ if (picked === FOLLOW_MAIN_VALUE) {
1169
+ clearPinFile(modelPinFile);
1170
+ } else {
1171
+ const separator = picked.indexOf("/");
1172
+ if (separator <= 0 || separator >= picked.length - 1) throw new Error(`invalid model selection: ${picked}`);
1173
+ branchModel = (
1174
+ await preparePinnedBranchModel({ provider: picked.slice(0, separator), modelId: picked.slice(separator + 1) })
1175
+ ).model;
1176
+ writePinFile(modelPinFile, picked);
1177
+ }
1178
+ } catch (error) {
1179
+ ctx.ui.notify(
1180
+ `Could not apply or save the supervision branch model: ${error instanceof Error ? error.message : String(error)}`,
1181
+ "error",
1182
+ );
1183
+ return;
1184
+ }
1185
+ // The model choice is persisted; report it exactly, then run the effort
1186
+ // step on the model the branch will actually use.
1187
+ let modelReport: { message: string; warning: boolean };
1188
+ if (picked !== FOLLOW_MAIN_VALUE) {
1189
+ modelReport = { message: `Supervision branch model: ${picked}.`, warning: false };
1190
+ } else {
1191
+ // Clearing the pin only follows main if main's model can actually be
1192
+ // applied to the branch; say what will really happen rather than
1193
+ // reporting a state that did not take effect.
1194
+ try {
1195
+ const following = mainModel ? await resolveBranchModel(mainModel.provider, mainModel.id) : null;
1196
+ if (following?.ok) branchModel = following.selection.model;
1197
+ modelReport = following?.ok
1198
+ ? {
1199
+ message: `Supervision branch follows main's model (${modelLabel(following.selection.model)}).`,
1200
+ warning: false,
1201
+ }
1202
+ : {
1203
+ message: `Supervision branch pin cleared, but main's model could not be applied (${following ? following.reason : "main's model is not known yet"}); the branch keeps the model its own session recorded until that conversation is replaced.`,
1204
+ warning: true,
1205
+ };
1206
+ } catch (error) {
1207
+ modelReport = {
1208
+ message: `Supervision branch pin cleared, but main's model could not be applied (${error instanceof Error ? error.message : String(error)}); the branch keeps the model its own session recorded until that conversation is replaced.`,
1209
+ warning: true,
1210
+ };
1211
+ }
1212
+ }
1213
+
1214
+ // The model choice is already persisted, so a failing effort step must
1215
+ // never swallow it: the branch still rebinds and the Allfather still
1216
+ // hears what took effect and what did not.
1217
+ let effortReport: { message: string; warning: boolean };
1218
+ try {
1219
+ effortReport = await pickBranchEffort(ctx, branchModel);
1220
+ } catch (error) {
1221
+ effortReport = {
1222
+ message: `The effort step failed (${error instanceof Error ? error.message : String(error)}); the branch keeps its current effort choice.`,
1223
+ warning: true,
1224
+ };
1225
+ }
1226
+ branchSelectionRevision += 1;
1227
+ releaseBranchForSelectionChange();
1228
+ ctx.ui.notify(
1229
+ `${modelReport.message} ${effortReport.message}`,
1230
+ modelReport.warning || effortReport.warning ? "warning" : "info",
1231
+ );
1232
+ },
1233
+ });
1234
+
1235
+ // Step one of /skuld-model's dialog. Pi's generic extension selector
1236
+ // renders every option at once with no search box, so a real eligible
1237
+ // catalog ran off the top of the terminal; this shows the same rows through
1238
+ // Pi's own SelectList - the bounded, scrolling primitive behind Pi's /model
1239
+ // picker - with Pi's own Input and fuzzy filter above it for search.
1240
+ // Pi's ModelSelectorComponent is deliberately NOT reused: its own selection
1241
+ // handler writes the Allfather's default model through Pi's settings manager,
1242
+ // which would move main's conversation as a side effect of pinning the
1243
+ // branch, and it has no room for the "follow main" row or for Brokk's
1244
+ // branch-runtime eligibility filter. Ordering and filtering live in
1245
+ // lib/skuld-branch-model-picker.ts; everything here is Pi's own rendering.
1246
+ // Returns the chosen item's value, or undefined when the Allfather cancels.
1247
+ // Non-TUI modes have no custom component surface, so they keep Pi's generic
1248
+ // selector: overflow is a terminal-rendering problem those modes do not have.
1249
+ async function pickBranchModel(
1250
+ ctx: ExtensionCommandContext,
1251
+ title: string,
1252
+ items: BranchPickerItem[],
1253
+ ): Promise<string | undefined> {
1254
+ if (ctx.mode !== "tui" || typeof ctx.ui.custom !== "function") {
1255
+ const picked = await ctx.ui.select(
1256
+ title,
1257
+ items.map((item) => item.label),
1258
+ );
1259
+ if (picked === undefined) return undefined;
1260
+ return items.find((item) => item.label === picked)?.value;
1261
+ }
1262
+ const picked = await ctx.ui.custom<string | null>((tui, theme, keybindings, done) => {
1263
+ const accent = (text: string) => theme.fg("accent", text);
1264
+ const muted = (text: string) => theme.fg("muted", text);
1265
+ const container = new Container();
1266
+ container.addChild(new DynamicBorder(accent));
1267
+ container.addChild(new Text(accent(theme.bold(title)), 1, 0));
1268
+ const search = new Input();
1269
+ search.focused = true;
1270
+ container.addChild(search);
1271
+ const listContainer = new Container();
1272
+ container.addChild(listContainer);
1273
+ container.addChild(new Text(muted("type to search - up/down navigate - enter select - esc cancel"), 1, 0));
1274
+ container.addChild(new DynamicBorder(accent));
1275
+
1276
+ // SelectList takes its rows at construction, so a new query builds a new
1277
+ // list into the same container rather than mutating the old one.
1278
+ let list = buildList("");
1279
+ function buildList(query: string): SelectList {
1280
+ const rebuilt = new SelectList(filterBranchPickerItems(items, query, fuzzyFilter), BRANCH_PICKER_MAX_VISIBLE, {
1281
+ selectedPrefix: accent,
1282
+ selectedText: accent,
1283
+ description: muted,
1284
+ scrollInfo: muted,
1285
+ noMatch: muted,
1286
+ });
1287
+ rebuilt.onSelect = (item) => done(item.value);
1288
+ rebuilt.onCancel = () => done(null);
1289
+ listContainer.clear();
1290
+ listContainer.addChild(rebuilt);
1291
+ return rebuilt;
1292
+ }
1293
+
1294
+ const navigationKeys = ["tui.select.up", "tui.select.down", "tui.select.confirm", "tui.select.cancel"] as const;
1295
+ return {
1296
+ render: (width: number) => container.render(width),
1297
+ invalidate: () => container.invalidate(),
1298
+ handleInput: (data: string) => {
1299
+ if (navigationKeys.some((key) => keybindings.matches(data, key))) {
1300
+ list.handleInput(data);
1301
+ } else {
1302
+ search.handleInput(data);
1303
+ list = buildList(search.getValue());
1304
+ }
1305
+ tui.requestRender();
1306
+ },
1307
+ };
1308
+ });
1309
+ return picked === null ? undefined : picked;
1310
+ }
1311
+
1312
+ // Step two of /skuld-model, shown after the model pick and driven by
1313
+ // Pi's own supported-level list for the model the branch will now use, so
1314
+ // the menu is the one Pi's own thinking selector would show and keeps no
1315
+ // parallel Brokk picker catalog. Cancelling leaves the current effort
1316
+ // choice standing; the model pick already made is still applied.
1317
+ async function pickBranchEffort(
1318
+ ctx: { ui: { select: (title: string, options: string[]) => Promise<string | undefined> } },
1319
+ selectedModel: BranchModel | undefined,
1320
+ ): Promise<{ message: string; warning: boolean }> {
1321
+ const branchModel = await effectiveBranchModel(selectedModel);
1322
+ const currentPin = readEffortPin();
1323
+ const current = currentPin ?? "follows main";
1324
+ const main = mainEffort();
1325
+ const followMainEffort = `Follow main${main ? ` (${main})` : ""}`;
1326
+ const levels = branchModel ? getSupportedThinkingLevels(branchModel) : [];
1327
+ const picked = await ctx.ui.select(`Supervision branch effort (now: ${current})`, [followMainEffort, ...levels]);
1328
+ if (picked === undefined) {
1329
+ return { message: describeBranchEffort(currentPin, branchModel), warning: branchModel === undefined };
1330
+ }
1331
+ try {
1332
+ if (picked === followMainEffort) {
1333
+ clearPinFile(effortPinFile);
1334
+ } else if ((BRANCH_EFFORT_LEVELS as readonly string[]).includes(picked)) {
1335
+ writePinFile(effortPinFile, picked);
1336
+ } else {
1337
+ throw new Error(`invalid effort selection: ${picked}`);
1338
+ }
1339
+ } catch (error) {
1340
+ return {
1341
+ message: `The effort choice could not be saved (${error instanceof Error ? error.message : String(error)}). ${describeBranchEffort(currentPin, branchModel)}`,
1342
+ warning: true,
1343
+ };
1344
+ }
1345
+ return {
1346
+ message: describeBranchEffort(readEffortPin(), branchModel),
1347
+ warning: branchModel === undefined,
1348
+ };
1349
+ }
1350
+
1351
+ // Reports the effort the branch will actually run at, never the raw choice:
1352
+ // Pi clamps a level the branch's model does not support, and an unpinned
1353
+ // branch follows main's own effort only when Pi can tell us what that is.
1354
+ function describeBranchEffort(pin: BranchEffort | null, branchModel: BranchModel | undefined): string {
1355
+ if (!branchModel) {
1356
+ return "The effort level the branch will run at cannot be determined because its effective model could not be resolved.";
1357
+ }
1358
+ const chosen = pin ?? mainEffort();
1359
+ if (chosen === undefined) {
1360
+ return "Effort follows main, whose own effort is not known yet, so the branch keeps the effort its own session recorded until that conversation is replaced.";
1361
+ }
1362
+ const applied = clampThinkingLevel(branchModel, chosen) as BranchEffort;
1363
+ if (pin === null) return `Effort follows main (${applied}).`;
1364
+ return applied === pin ? `Effort: ${pin}.` : `Effort: ${pin}, which this model runs at ${applied}.`;
1365
+ }
1366
+
1367
+ let calmPresentation: RoPresentationState = {
1368
+ active: false,
1369
+ stockExportRendering: false,
1370
+ };
1371
+ pi.events?.on?.(RO_PRESENTATION_EVENT, (data) => {
1372
+ const next = data as Partial<RoPresentationState>;
1373
+ calmPresentation = {
1374
+ active: next.active === true,
1375
+ stockExportRendering: next.stockExportRendering === true,
1376
+ };
1377
+ });
1378
+ const calmHides = (itemClass: Parameters<typeof roTranscriptClassIsVisible>[0]): boolean =>
1379
+ calmPresentation.active &&
1380
+ !calmPresentation.stockExportRendering &&
1381
+ !roTranscriptClassIsVisible(itemClass);
1382
+
1383
+ const outcomesToolAnsiPattern = new RegExp(
1384
+ "(?:\\u001B\\][\\s\\S]*?(?:\\u0007|\\u001B\\u005C|\\u009C))|[\\u001B\\u009B][[\\]\\()#;?]*(?:\\d{1,4}(?:[;:]\\d{0,4})*)?[\\dA-PR-TZcf-nq-uy=><~]",
1385
+ "g",
1386
+ );
1387
+ const normalizeOutcomesToolOutput = (value: string): string => {
1388
+ const withoutAnsi = value.includes("\u001B") || value.includes("\u009B")
1389
+ ? value.replace(outcomesToolAnsiPattern, "")
1390
+ : value;
1391
+ return Array.from(withoutAnsi)
1392
+ .filter((char) => {
1393
+ const code = char.codePointAt(0);
1394
+ if (code === undefined) return false;
1395
+ if (code === 0x09 || code === 0x0a || code === 0x0d) return true;
1396
+ if (code <= 0x1f) return false;
1397
+ return code < 0xfff9 || code > 0xfffb;
1398
+ })
1399
+ .join("")
1400
+ .replace(/\r/g, "");
1401
+ };
1402
+
1403
+ let stockOutcomesPreviewLines: number | null | undefined;
1404
+ const getStockOutcomesPreviewLines = (): number | undefined => {
1405
+ if (stockOutcomesPreviewLines !== undefined) return stockOutcomesPreviewLines ?? undefined;
1406
+ const probeTokens = Array.from(
1407
+ { length: 64 },
1408
+ (_, index) => `BROKK_OUTCOMES_PREVIEW_PROBE_${String(index).padStart(2, "0")}`,
1409
+ );
1410
+ try {
1411
+ const probeDefinition: ToolDefinition = {
1412
+ name: "brokk_outcomes_preview_probe",
1413
+ label: "Preview probe",
1414
+ description: "Preview probe",
1415
+ parameters: Type.Object({}),
1416
+ execute: async () => ({ content: [], details: undefined }),
1417
+ };
1418
+ const probe = new ToolExecutionComponent(
1419
+ probeDefinition.name,
1420
+ "brokk-outcomes-preview-probe",
1421
+ {},
1422
+ { showImages: false },
1423
+ probeDefinition,
1424
+ { requestRender() {} } as ConstructorParameters<typeof ToolExecutionComponent>[5],
1425
+ root,
1426
+ );
1427
+ probe.updateResult({
1428
+ content: [{ type: "text", text: probeTokens.join("\n") }],
1429
+ isError: false,
1430
+ });
1431
+ const rendered = probe.render(4096).join("\n");
1432
+ const visibleLines = probeTokens.filter((token) => rendered.includes(token)).length;
1433
+ stockOutcomesPreviewLines = visibleLines > 0 && visibleLines < probeTokens.length ? visibleLines : null;
1434
+ } catch {
1435
+ stockOutcomesPreviewLines = null;
1436
+ }
1437
+ return stockOutcomesPreviewLines ?? undefined;
1438
+ };
1439
+
1440
+ type OutcomesToolShellState = {
1441
+ shell?: Box;
1442
+ call?: Text;
1443
+ result?: Text | Container;
1444
+ };
1445
+ const refreshOutcomesToolShell = (
1446
+ shellState: OutcomesToolShellState,
1447
+ theme: Parameters<NonNullable<ToolDefinition["renderCall"]>>[1],
1448
+ context: Parameters<NonNullable<ToolDefinition["renderCall"]>>[2],
1449
+ ): Box => {
1450
+ const background = context.isPartial
1451
+ ? (text: string) => theme.bg("toolPendingBg", text)
1452
+ : context.isError
1453
+ ? (text: string) => theme.bg("toolErrorBg", text)
1454
+ : (text: string) => theme.bg("toolSuccessBg", text);
1455
+ const shell = shellState.shell ?? new Box(1, 1, background);
1456
+ shellState.shell = shell;
1457
+ shell.setBgFn(background);
1458
+ shell.clear();
1459
+ if (shellState.call) shell.addChild(shellState.call);
1460
+ if (shellState.result) shell.addChild(shellState.result);
1461
+ return shell;
1462
+ };
1463
+
1464
+ pi.registerTool?.({
1465
+ name: "brokk_branch_outcomes",
1466
+ label: "Read supervision branch outcomes",
1467
+ description:
1468
+ "Read the durable outcome store of the supervision branch: what fleet events it handled, each verdict, and each summary. Use when the Allfather asks what happened in the fleet.",
1469
+ promptSnippet: "Read what the supervision branch handled (durable outcome store).",
1470
+ parameters: Type.Object({
1471
+ recent: Type.Optional(Type.Number({ description: "How many most-recent outcomes to read (default 20)" })),
1472
+ }),
1473
+ renderShell: "self",
1474
+ renderCall: (_args, theme, context) => {
1475
+ if (calmPresentation.stockExportRendering) throw new Error("Use Pi stock export rendering");
1476
+ if (calmHides("assistant-tool-call")) return new Container();
1477
+ const shellState = context.state as OutcomesToolShellState;
1478
+ shellState.call = new Text(theme.fg("toolTitle", theme.bold("brokk_branch_outcomes")), 0, 0);
1479
+ return refreshOutcomesToolShell(shellState, theme, context);
1480
+ },
1481
+ renderResult: (result, options, theme, context) => {
1482
+ if (calmPresentation.stockExportRendering) throw new Error("Use Pi stock export rendering");
1483
+ if (calmHides("tool-result")) return new Container();
1484
+ const output = result.content
1485
+ .filter((item) => item.type === "text")
1486
+ .map((item) => normalizeOutcomesToolOutput(item.text))
1487
+ .join("\n");
1488
+ const shellState = context.state as OutcomesToolShellState;
1489
+ // Keep each line's ANSI scope independent, matching Pi's stock fallback.
1490
+ // Pi 0.84.4 no longer supplies an implicit reset at multiline boundaries.
1491
+ const lines = output.split("\n");
1492
+ const previewLines = getStockOutcomesPreviewLines();
1493
+ const displayLines = options.expanded || previewLines === undefined ? lines : lines.slice(0, previewLines);
1494
+ const remaining = lines.length - displayLines.length;
1495
+ let renderedOutput = displayLines.map((line) => theme.fg("toolOutput", line)).join("\n");
1496
+ if (remaining > 0) {
1497
+ renderedOutput += `${theme.fg("muted", `\n... (${remaining} more lines,`)} ${keyHint("app.tools.expand", "to expand")}${theme.fg("muted", ")")}`;
1498
+ }
1499
+ shellState.result = output ? new Text(renderedOutput, 0, 0) : new Container();
1500
+ refreshOutcomesToolShell(shellState, theme, context);
1501
+ return new Container();
1502
+ },
1503
+ execute: async (_toolCallId, params) => {
1504
+ const recentRaw = (params as { recent?: unknown }).recent;
1505
+ const recent = typeof recentRaw === "number" && recentRaw >= 1 ? String(Math.floor(recentRaw)) : "20";
1506
+ const listed = runOutcomeScript(["list", "--recent", recent]);
1507
+ if (!listed.ok) {
1508
+ return {
1509
+ content: [{ type: "text", text: `could not read the outcome store: ${listed.detail}` }],
1510
+ details: undefined,
1511
+ isError: true,
1512
+ };
1513
+ }
1514
+ return {
1515
+ content: [{ type: "text", text: listed.stdout || "(no branch outcomes recorded)" }],
1516
+ details: undefined,
1517
+ };
1518
+ },
1519
+ });
1520
+
1521
+ // Pi only calls this renderer for a message with display: true, which
1522
+ // mergeIntoMain sets for every routine note except an explicitly silent
1523
+ // fleet heartbeat; Allfather-facing notes are never printed or rendered here.
1524
+ pi.registerMessageRenderer?.("skuld-branch-merge", (message, _options, theme) => {
1525
+ const note = textOfContent(message.content);
1526
+ const hasGlyph = note.startsWith(MERGE_NOTE_BOAT);
1527
+ const rest = hasGlyph ? note.slice(MERGE_NOTE_BOAT.length) : note;
1528
+ const outputPad = 1;
1529
+ return new Text(
1530
+ `${hasGlyph ? theme.fg("customMessageText", MERGE_NOTE_BOAT) : ""}${theme.fg("dim", rest)}`,
1531
+ outputPad,
1532
+ 0,
1533
+ );
1534
+ });
1535
+ }