@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.
- package/.agents/config/app-repos.yaml +38 -0
- package/.claude/settings.json +31 -0
- package/.codex/hooks.json +43 -0
- package/.cursor/hooks.json +33 -0
- package/.pi/extensions/README.md +39 -0
- package/.pi/extensions/gna-pi-watch.ts +15 -0
- package/.pi/extensions/lib/ro-assistant-layout.ts +96 -0
- package/.pi/extensions/lib/ro-operational-user-layout.ts +145 -0
- package/.pi/extensions/lib/ro-visibility.ts +99 -0
- package/.pi/extensions/lib/ro-working-ship.ts +243 -0
- package/.pi/extensions/lib/rodd-operational-input.ts +62 -0
- package/.pi/extensions/lib/skuld-branch-dispatch.ts +252 -0
- package/.pi/extensions/lib/skuld-branch-model-picker.ts +77 -0
- package/.pi/extensions/lib/vordr-sessionstart-supervisor.mjs +48 -0
- package/.pi/extensions/ro.ts +11 -0
- package/.pi/extensions/skuld-branch-supervision.ts +11 -0
- package/.pi/extensions/syn-turnend-guard.ts +11 -0
- package/.pi/mcp.json +16 -0
- package/.pi/mcp.json.example +11 -0
- package/.pi/npm/.gitignore +2 -0
- package/.pi/settings.json +8 -0
- package/.pi/shared/extensions/gna-pi-watch.ts +750 -0
- package/.pi/shared/extensions/herdr-agent-state.ts +257 -0
- package/.pi/shared/extensions/open-editor.ts +509 -0
- package/.pi/shared/extensions/ro.ts +504 -0
- package/.pi/shared/extensions/skuld-branch-supervision.ts +1535 -0
- package/.pi/shared/extensions/syn-turnend-guard.ts +683 -0
- package/.pi/shared/extensions/todo.ts +133 -0
- package/.pi/shared/extensions/ymir-subagents.ts +287 -0
- package/.pi/shared/extensions/ymir-well.ts +130 -0
- package/CHANGELOG.md +23 -0
- package/assets/Ymir.md +4654 -0
- package/assets/healer.html +717 -0
- package/assets/icon-family/family-sheet.png +0 -0
- package/assets/icon-family/hlidskjalf.svg +1 -0
- package/assets/icon-family/sessrumnir.svg +1 -0
- package/assets/icon-family/smidja.svg +1 -0
- package/assets/icon-family/ymir.svg +1 -0
- package/assets/mimirswell.html +880 -0
- package/assets/mock/README.md +16 -0
- package/assets/mock/feeds.ts +175 -0
- package/assets/mock/mock.ts +408 -0
- package/assets/reference/docs/agent-control.md +122 -0
- package/assets/reference/docs/apodex/Appdex_as_Orchestrator_Planner_Reviewer_Assessment.md +176 -0
- package/assets/reference/docs/apodex/Final Report(1).md +198 -0
- package/assets/reference/docs/apodex/Final Report(2).md +54 -0
- package/assets/reference/docs/apodex/Final Report(3).md +40 -0
- package/assets/reference/docs/apodex/Final Report(4).md +234 -0
- package/assets/reference/docs/apodex/Final Report(5).md +13 -0
- package/assets/reference/docs/apodex/Final Report.md +82 -0
- package/assets/reference/docs/architecture.md +392 -0
- package/assets/reference/docs/arm-pretool-check.md +247 -0
- package/assets/reference/docs/calm-mode-feasibility.md +541 -0
- package/assets/reference/docs/calm.md +59 -0
- package/assets/reference/docs/captain-hold-lifecycle.md +96 -0
- package/assets/reference/docs/cd-guard.md +165 -0
- package/assets/reference/docs/cmux-backend.md +133 -0
- package/assets/reference/docs/codex-app-backend.md +57 -0
- package/assets/reference/docs/configuration.md +924 -0
- package/assets/reference/docs/documentation-audiences.json +468 -0
- package/assets/reference/docs/documentation-audiences.md +28 -0
- package/assets/reference/docs/examples/crew-dispatch.json +26 -0
- package/assets/reference/docs/examples/process-event-extension/file-signal.mjs +96 -0
- package/assets/reference/docs/examples/process-event-extension/firstmate-extension.json +15 -0
- package/assets/reference/docs/examples/watched-tools.json +24 -0
- package/assets/reference/docs/examples/wedge-alarm +22 -0
- package/assets/reference/docs/extension-bindings.md +237 -0
- package/assets/reference/docs/fm-test-isolation-proof.json +37 -0
- package/assets/reference/docs/fm-test-isolation-proof.md +149 -0
- package/assets/reference/docs/fm-test-portable-shards.md +118 -0
- package/assets/reference/docs/gitlab-merge-watch.md +275 -0
- package/assets/reference/docs/herdr-backend.md +346 -0
- package/assets/reference/docs/orca-backend.md +84 -0
- package/assets/reference/docs/pi-supervision-branch-poster.svg +125 -0
- package/assets/reference/docs/pi-supervision-branch.md +96 -0
- package/assets/reference/docs/plans/agentic-engineering-workflow.md +395 -0
- package/assets/reference/docs/plans/agentic-factory-implementation-plan.md +615 -0
- package/assets/reference/docs/plans/command-factory-enhancement-plan.md +420 -0
- package/assets/reference/docs/plans/phase-1-layer3-program-design.md +134 -0
- package/assets/reference/docs/plans/phase-2-vertical-slices.md +100 -0
- package/assets/reference/docs/plans/phase-3-measurable-goals.md +106 -0
- package/assets/reference/docs/plans/phase-4-context-budget.md +106 -0
- package/assets/reference/docs/plans/phase-5-pre-mortem-confidence.md +100 -0
- package/assets/reference/docs/plans/phase-6-incident-to-agent.md +109 -0
- package/assets/reference/docs/remote-secondmates.md +268 -0
- package/assets/reference/docs/scripts.md +146 -0
- package/assets/reference/docs/sessionstart-nudge.md +115 -0
- package/assets/reference/docs/subagent-guard.md +383 -0
- package/assets/reference/docs/supervision-protocols/claude.md +27 -0
- package/assets/reference/docs/supervision-protocols/codex.md +16 -0
- package/assets/reference/docs/supervision-protocols/cursor.md +31 -0
- package/assets/reference/docs/supervision-protocols/grok.md +39 -0
- package/assets/reference/docs/supervision-protocols/opencode.md +17 -0
- package/assets/reference/docs/supervision-protocols/pi.md +33 -0
- package/assets/reference/docs/supervision-protocols/unknown.md +13 -0
- package/assets/reference/docs/tmux-backend.md +115 -0
- package/assets/reference/docs/trace-context.md +117 -0
- package/assets/reference/docs/turnend-guard.md +170 -0
- package/assets/reference/docs/verification/dispatch-auth.md +204 -0
- package/assets/reference/docs/verification/muse.md +221 -0
- package/assets/reference/docs/verification/process-event-sources.md +208 -0
- package/assets/reference/docs/verification/public-followup.md +119 -0
- package/assets/reference/docs/verification/runtime-backends.md +1022 -0
- package/assets/reference/docs/verification/stow-memory.md +53 -0
- package/assets/reference/docs/verification/supervision.md +534 -0
- package/assets/reference/docs/verification/trace-context.md +31 -0
- package/assets/reference/docs/voice-relay.md +295 -0
- package/assets/reference/docs/watcher-continuity.md +119 -0
- package/assets/reference/docs/wedge-alarm.md +39 -0
- package/assets/reference/docs/zellij-backend.md +114 -0
- package/assets/reference/index.html +1172 -0
- package/assets/reference/skills/afk/SKILL.md +241 -0
- package/assets/reference/skills/ahoy/SKILL.md +56 -0
- package/assets/reference/skills/ask-user-authority/SKILL.md +56 -0
- package/assets/reference/skills/bearings/SKILL.md +161 -0
- package/assets/reference/skills/bearings/assets/board-template.html +731 -0
- package/assets/reference/skills/bootstrap-diagnostics/SKILL.md +68 -0
- package/assets/reference/skills/captain-hold-lifecycle/SKILL.md +54 -0
- package/assets/reference/skills/decision-hold-lifecycle/SKILL.md +15 -0
- package/assets/reference/skills/diagnostic-reasoning/SKILL.md +53 -0
- package/assets/reference/skills/firstmate-codexapp/SKILL.md +110 -0
- package/assets/reference/skills/firstmate-codexapp/agents/openai.yaml +4 -0
- package/assets/reference/skills/firstmate-coding-guidelines/SKILL.md +127 -0
- package/assets/reference/skills/firstmate-orca/SKILL.md +92 -0
- package/assets/reference/skills/fmx-respond/SKILL.md +283 -0
- package/assets/reference/skills/harness-adapters/SKILL.md +95 -0
- package/assets/reference/skills/harness-adapters/references/common/control-and-recovery.md +37 -0
- package/assets/reference/skills/harness-adapters/references/common/dispatch.md +32 -0
- package/assets/reference/skills/harness-adapters/references/common/model-and-effort.md +42 -0
- package/assets/reference/skills/harness-adapters/references/common/primary-hooks.md +40 -0
- package/assets/reference/skills/harness-adapters/references/harness/claude.md +55 -0
- package/assets/reference/skills/harness-adapters/references/harness/codex.md +43 -0
- package/assets/reference/skills/harness-adapters/references/harness/cursor.md +75 -0
- package/assets/reference/skills/harness-adapters/references/harness/grok.md +69 -0
- package/assets/reference/skills/harness-adapters/references/harness/kimi.md +51 -0
- package/assets/reference/skills/harness-adapters/references/harness/muse.md +70 -0
- package/assets/reference/skills/harness-adapters/references/harness/opencode.md +42 -0
- package/assets/reference/skills/harness-adapters/references/harness/pi.md +56 -0
- package/assets/reference/skills/process-event-sources/SKILL.md +137 -0
- package/assets/reference/skills/project-management/SKILL.md +92 -0
- package/assets/reference/skills/quota-array-dispatch/SKILL.md +131 -0
- package/assets/reference/skills/secondmate-provisioning/SKILL.md +254 -0
- package/assets/reference/skills/stow/SKILL.md +309 -0
- package/assets/reference/skills/stuck-crewmate-recovery/SKILL.md +54 -0
- package/assets/reference/skills/updatefirstmate/SKILL.md +64 -0
- package/assets/skills/README.md +5 -0
- package/assets/yggdrasil.html +649 -0
- package/assets/ymir-banner-01.png +0 -0
- package/assets/ymir-banner-02.png +0 -0
- package/assets/ymir-banner-03.png +0 -0
- package/assets/ymir-banner-04.png +0 -0
- package/assets/ymir-banner-05.png +0 -0
- package/assets/ymir-banner-06.png +0 -0
- package/assets/ymir-emblem-darksteel.svg +63 -0
- package/assets/ymir-emblem-forged.svg +55 -0
- package/assets/ymir-emblem-slate.svg +55 -0
- package/assets/ymir-mark-algiz-anvil.svg +9 -0
- package/assets/ymir-stave.svg +53 -0
- package/assets/ymir.html +563 -0
- package/bin/ymir-install.sh +6 -1
- package/data/learnings.md +33 -0
- package/data/local-models.md +34 -0
- package/data/operator.md +15 -0
- package/data/projects.md +28 -0
- package/data/realm.md +1 -0
- package/deploy/Containerfile +27 -0
- package/deploy/README.md +104 -0
- package/deploy/compose/compose.yaml +45 -0
- package/deploy/env.example +30 -0
- package/deploy/quadlet/ymir.container +57 -0
- 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
|
+
}
|