@zerwiz/ymir 0.1.16 → 0.1.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (171) hide show
  1. package/.agents/config/app-repos.yaml +38 -0
  2. package/.claude/settings.json +31 -0
  3. package/.codex/hooks.json +43 -0
  4. package/.cursor/hooks.json +33 -0
  5. package/.pi/extensions/README.md +39 -0
  6. package/.pi/extensions/gna-pi-watch.ts +15 -0
  7. package/.pi/extensions/lib/ro-assistant-layout.ts +96 -0
  8. package/.pi/extensions/lib/ro-operational-user-layout.ts +145 -0
  9. package/.pi/extensions/lib/ro-visibility.ts +99 -0
  10. package/.pi/extensions/lib/ro-working-ship.ts +243 -0
  11. package/.pi/extensions/lib/rodd-operational-input.ts +62 -0
  12. package/.pi/extensions/lib/skuld-branch-dispatch.ts +252 -0
  13. package/.pi/extensions/lib/skuld-branch-model-picker.ts +77 -0
  14. package/.pi/extensions/lib/vordr-sessionstart-supervisor.mjs +48 -0
  15. package/.pi/extensions/ro.ts +11 -0
  16. package/.pi/extensions/skuld-branch-supervision.ts +11 -0
  17. package/.pi/extensions/syn-turnend-guard.ts +11 -0
  18. package/.pi/mcp.json +16 -0
  19. package/.pi/mcp.json.example +11 -0
  20. package/.pi/npm/.gitignore +2 -0
  21. package/.pi/settings.json +8 -0
  22. package/.pi/shared/extensions/gna-pi-watch.ts +750 -0
  23. package/.pi/shared/extensions/herdr-agent-state.ts +257 -0
  24. package/.pi/shared/extensions/open-editor.ts +509 -0
  25. package/.pi/shared/extensions/ro.ts +504 -0
  26. package/.pi/shared/extensions/skuld-branch-supervision.ts +1535 -0
  27. package/.pi/shared/extensions/syn-turnend-guard.ts +683 -0
  28. package/.pi/shared/extensions/todo.ts +133 -0
  29. package/.pi/shared/extensions/ymir-subagents.ts +287 -0
  30. package/.pi/shared/extensions/ymir-well.ts +130 -0
  31. package/CHANGELOG.md +23 -0
  32. package/assets/Ymir.md +4654 -0
  33. package/assets/healer.html +717 -0
  34. package/assets/icon-family/family-sheet.png +0 -0
  35. package/assets/icon-family/hlidskjalf.svg +1 -0
  36. package/assets/icon-family/sessrumnir.svg +1 -0
  37. package/assets/icon-family/smidja.svg +1 -0
  38. package/assets/icon-family/ymir.svg +1 -0
  39. package/assets/mimirswell.html +880 -0
  40. package/assets/mock/README.md +16 -0
  41. package/assets/mock/feeds.ts +175 -0
  42. package/assets/mock/mock.ts +408 -0
  43. package/assets/reference/docs/agent-control.md +122 -0
  44. package/assets/reference/docs/apodex/Appdex_as_Orchestrator_Planner_Reviewer_Assessment.md +176 -0
  45. package/assets/reference/docs/apodex/Final Report(1).md +198 -0
  46. package/assets/reference/docs/apodex/Final Report(2).md +54 -0
  47. package/assets/reference/docs/apodex/Final Report(3).md +40 -0
  48. package/assets/reference/docs/apodex/Final Report(4).md +234 -0
  49. package/assets/reference/docs/apodex/Final Report(5).md +13 -0
  50. package/assets/reference/docs/apodex/Final Report.md +82 -0
  51. package/assets/reference/docs/architecture.md +392 -0
  52. package/assets/reference/docs/arm-pretool-check.md +247 -0
  53. package/assets/reference/docs/calm-mode-feasibility.md +541 -0
  54. package/assets/reference/docs/calm.md +59 -0
  55. package/assets/reference/docs/captain-hold-lifecycle.md +96 -0
  56. package/assets/reference/docs/cd-guard.md +165 -0
  57. package/assets/reference/docs/cmux-backend.md +133 -0
  58. package/assets/reference/docs/codex-app-backend.md +57 -0
  59. package/assets/reference/docs/configuration.md +924 -0
  60. package/assets/reference/docs/documentation-audiences.json +468 -0
  61. package/assets/reference/docs/documentation-audiences.md +28 -0
  62. package/assets/reference/docs/examples/crew-dispatch.json +26 -0
  63. package/assets/reference/docs/examples/process-event-extension/file-signal.mjs +96 -0
  64. package/assets/reference/docs/examples/process-event-extension/firstmate-extension.json +15 -0
  65. package/assets/reference/docs/examples/watched-tools.json +24 -0
  66. package/assets/reference/docs/examples/wedge-alarm +22 -0
  67. package/assets/reference/docs/extension-bindings.md +237 -0
  68. package/assets/reference/docs/fm-test-isolation-proof.json +37 -0
  69. package/assets/reference/docs/fm-test-isolation-proof.md +149 -0
  70. package/assets/reference/docs/fm-test-portable-shards.md +118 -0
  71. package/assets/reference/docs/gitlab-merge-watch.md +275 -0
  72. package/assets/reference/docs/herdr-backend.md +346 -0
  73. package/assets/reference/docs/orca-backend.md +84 -0
  74. package/assets/reference/docs/pi-supervision-branch-poster.svg +125 -0
  75. package/assets/reference/docs/pi-supervision-branch.md +96 -0
  76. package/assets/reference/docs/plans/agentic-engineering-workflow.md +395 -0
  77. package/assets/reference/docs/plans/agentic-factory-implementation-plan.md +615 -0
  78. package/assets/reference/docs/plans/command-factory-enhancement-plan.md +420 -0
  79. package/assets/reference/docs/plans/phase-1-layer3-program-design.md +134 -0
  80. package/assets/reference/docs/plans/phase-2-vertical-slices.md +100 -0
  81. package/assets/reference/docs/plans/phase-3-measurable-goals.md +106 -0
  82. package/assets/reference/docs/plans/phase-4-context-budget.md +106 -0
  83. package/assets/reference/docs/plans/phase-5-pre-mortem-confidence.md +100 -0
  84. package/assets/reference/docs/plans/phase-6-incident-to-agent.md +109 -0
  85. package/assets/reference/docs/remote-secondmates.md +268 -0
  86. package/assets/reference/docs/scripts.md +146 -0
  87. package/assets/reference/docs/sessionstart-nudge.md +115 -0
  88. package/assets/reference/docs/subagent-guard.md +383 -0
  89. package/assets/reference/docs/supervision-protocols/claude.md +27 -0
  90. package/assets/reference/docs/supervision-protocols/codex.md +16 -0
  91. package/assets/reference/docs/supervision-protocols/cursor.md +31 -0
  92. package/assets/reference/docs/supervision-protocols/grok.md +39 -0
  93. package/assets/reference/docs/supervision-protocols/opencode.md +17 -0
  94. package/assets/reference/docs/supervision-protocols/pi.md +33 -0
  95. package/assets/reference/docs/supervision-protocols/unknown.md +13 -0
  96. package/assets/reference/docs/tmux-backend.md +115 -0
  97. package/assets/reference/docs/trace-context.md +117 -0
  98. package/assets/reference/docs/turnend-guard.md +170 -0
  99. package/assets/reference/docs/verification/dispatch-auth.md +204 -0
  100. package/assets/reference/docs/verification/muse.md +221 -0
  101. package/assets/reference/docs/verification/process-event-sources.md +208 -0
  102. package/assets/reference/docs/verification/public-followup.md +119 -0
  103. package/assets/reference/docs/verification/runtime-backends.md +1022 -0
  104. package/assets/reference/docs/verification/stow-memory.md +53 -0
  105. package/assets/reference/docs/verification/supervision.md +534 -0
  106. package/assets/reference/docs/verification/trace-context.md +31 -0
  107. package/assets/reference/docs/voice-relay.md +295 -0
  108. package/assets/reference/docs/watcher-continuity.md +119 -0
  109. package/assets/reference/docs/wedge-alarm.md +39 -0
  110. package/assets/reference/docs/zellij-backend.md +114 -0
  111. package/assets/reference/index.html +1172 -0
  112. package/assets/reference/skills/afk/SKILL.md +241 -0
  113. package/assets/reference/skills/ahoy/SKILL.md +56 -0
  114. package/assets/reference/skills/ask-user-authority/SKILL.md +56 -0
  115. package/assets/reference/skills/bearings/SKILL.md +161 -0
  116. package/assets/reference/skills/bearings/assets/board-template.html +731 -0
  117. package/assets/reference/skills/bootstrap-diagnostics/SKILL.md +68 -0
  118. package/assets/reference/skills/captain-hold-lifecycle/SKILL.md +54 -0
  119. package/assets/reference/skills/decision-hold-lifecycle/SKILL.md +15 -0
  120. package/assets/reference/skills/diagnostic-reasoning/SKILL.md +53 -0
  121. package/assets/reference/skills/firstmate-codexapp/SKILL.md +110 -0
  122. package/assets/reference/skills/firstmate-codexapp/agents/openai.yaml +4 -0
  123. package/assets/reference/skills/firstmate-coding-guidelines/SKILL.md +127 -0
  124. package/assets/reference/skills/firstmate-orca/SKILL.md +92 -0
  125. package/assets/reference/skills/fmx-respond/SKILL.md +283 -0
  126. package/assets/reference/skills/harness-adapters/SKILL.md +95 -0
  127. package/assets/reference/skills/harness-adapters/references/common/control-and-recovery.md +37 -0
  128. package/assets/reference/skills/harness-adapters/references/common/dispatch.md +32 -0
  129. package/assets/reference/skills/harness-adapters/references/common/model-and-effort.md +42 -0
  130. package/assets/reference/skills/harness-adapters/references/common/primary-hooks.md +40 -0
  131. package/assets/reference/skills/harness-adapters/references/harness/claude.md +55 -0
  132. package/assets/reference/skills/harness-adapters/references/harness/codex.md +43 -0
  133. package/assets/reference/skills/harness-adapters/references/harness/cursor.md +75 -0
  134. package/assets/reference/skills/harness-adapters/references/harness/grok.md +69 -0
  135. package/assets/reference/skills/harness-adapters/references/harness/kimi.md +51 -0
  136. package/assets/reference/skills/harness-adapters/references/harness/muse.md +70 -0
  137. package/assets/reference/skills/harness-adapters/references/harness/opencode.md +42 -0
  138. package/assets/reference/skills/harness-adapters/references/harness/pi.md +56 -0
  139. package/assets/reference/skills/process-event-sources/SKILL.md +137 -0
  140. package/assets/reference/skills/project-management/SKILL.md +92 -0
  141. package/assets/reference/skills/quota-array-dispatch/SKILL.md +131 -0
  142. package/assets/reference/skills/secondmate-provisioning/SKILL.md +254 -0
  143. package/assets/reference/skills/stow/SKILL.md +309 -0
  144. package/assets/reference/skills/stuck-crewmate-recovery/SKILL.md +54 -0
  145. package/assets/reference/skills/updatefirstmate/SKILL.md +64 -0
  146. package/assets/skills/README.md +5 -0
  147. package/assets/yggdrasil.html +649 -0
  148. package/assets/ymir-banner-01.png +0 -0
  149. package/assets/ymir-banner-02.png +0 -0
  150. package/assets/ymir-banner-03.png +0 -0
  151. package/assets/ymir-banner-04.png +0 -0
  152. package/assets/ymir-banner-05.png +0 -0
  153. package/assets/ymir-banner-06.png +0 -0
  154. package/assets/ymir-emblem-darksteel.svg +63 -0
  155. package/assets/ymir-emblem-forged.svg +55 -0
  156. package/assets/ymir-emblem-slate.svg +55 -0
  157. package/assets/ymir-mark-algiz-anvil.svg +9 -0
  158. package/assets/ymir-stave.svg +53 -0
  159. package/assets/ymir.html +563 -0
  160. package/bin/ymir-install.sh +6 -1
  161. package/data/learnings.md +33 -0
  162. package/data/local-models.md +34 -0
  163. package/data/operator.md +15 -0
  164. package/data/projects.md +28 -0
  165. package/data/realm.md +1 -0
  166. package/deploy/Containerfile +27 -0
  167. package/deploy/README.md +104 -0
  168. package/deploy/compose/compose.yaml +45 -0
  169. package/deploy/env.example +30 -0
  170. package/deploy/quadlet/ymir.container +57 -0
  171. package/package.json +10 -8
@@ -0,0 +1,683 @@
1
+ import { spawn, spawnSync, type ChildProcess } from "node:child_process";
2
+ import { createHash } from "node:crypto";
3
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
4
+ import { dirname, resolve } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
7
+ import {
8
+ classifyRoddCurrentOperationalText,
9
+ encodeRoddOperationalInput,
10
+ } from "./lib/rodd-operational-input.ts";
11
+
12
+ let guardFollowupActive = false;
13
+
14
+ type LockOwnership = "owned" | "missing" | "other";
15
+
16
+ const extensionFile = fileURLToPath(import.meta.url);
17
+ const extensionDir = dirname(extensionFile);
18
+ const root = resolve(extensionDir, "../..");
19
+ const fmHome = process.env.BROKK_HOME || process.env.BROKK_ROOT_OVERRIDE || root;
20
+ const state = process.env.BROKK_STATE_OVERRIDE || `${fmHome}/state`;
21
+ const marker = `${state}/.pi-turnend-extension-loaded`;
22
+ const extensionVersion = `sha256:${createHash("sha256").update(readFileSync(extensionFile)).digest("hex")}`;
23
+
24
+ function parentPid(pid: string): string {
25
+ const result = spawnSync("ps", ["-o", "ppid=", "-p", pid], { encoding: "utf8" });
26
+ if (result.status !== 0) return "";
27
+ return result.stdout.trim();
28
+ }
29
+
30
+ // Liveness that actually sees death: a zombie (killed but unreaped) or a
31
+ // recycled pid must not pass for a live lock holder. /proc/<pid>/stat field 3
32
+ // is the state character (Z/X = zombie/dead); when /proc is unavailable we
33
+ // fall back to kill(0).
34
+ function pidAlive(pid: string): boolean {
35
+ if (/^[0-9]+$/.test(pid) && pid !== "1") {
36
+ try {
37
+ const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
38
+ const close = stat.lastIndexOf(")");
39
+ if (close >= 0) {
40
+ const state = stat.slice(close + 2).trim().split(/\s+/)[0];
41
+ if (state === "Z" || state === "X") return false;
42
+ }
43
+ } catch {
44
+ // no /proc (non-Linux) — rely on kill(0)
45
+ }
46
+ }
47
+ try {
48
+ process.kill(Number(pid), 0);
49
+ return true;
50
+ } catch {
51
+ return false;
52
+ }
53
+ }
54
+
55
+ // The resolved session-lock path (machine-global for the primary, per-home for
56
+ // an Eindri-home). gleipnir-lock-lib.sh records it in state/.lock-path; a
57
+ // pre-contract session still lives at the legacy state/.lock.
58
+ function resolvedLockPath(): string {
59
+ try {
60
+ const pointer = readFileSync(`${state}/.lock-path`, "utf8").trim();
61
+ if (pointer) return pointer;
62
+ } catch {
63
+ // no pointer yet — pre-machine-lock session
64
+ }
65
+ return `${state}/.lock`;
66
+ }
67
+
68
+ function lockOwnership(): LockOwnership {
69
+ let lockPid = "";
70
+ try {
71
+ lockPid = readFileSync(resolvedLockPath(), "utf8").trim();
72
+ } catch {
73
+ return "missing";
74
+ }
75
+ if (!/^[0-9]+$/.test(lockPid) || lockPid === "1") return "other";
76
+ let pid = String(process.pid);
77
+ for (let i = 0; i < 8; i += 1) {
78
+ if (pid === lockPid) return "owned";
79
+ pid = parentPid(pid);
80
+ if (!pid || pid === "1") break;
81
+ }
82
+ return pidAlive(lockPid) ? "other" : "missing";
83
+ }
84
+
85
+ function markLoaded(): void {
86
+ if (!existsSync(state) || lockOwnership() === "other") return;
87
+ writeFileSync(marker, `${extensionVersion}\n${process.pid}\n`);
88
+ }
89
+
90
+ // Pi's session_start reasons are startup | reload | new | resume | fork, and a
91
+ // separate session_compact event fires after a compaction. "new" is Pi's /new
92
+ // while reload, resume, and fork all keep prior context.
93
+ const sessionstartDeliveryBytes = 512 * 1024;
94
+
95
+ type SessionStartContext = {
96
+ sessionManager?: {
97
+ getHeader?: () => { timestamp?: unknown } | null | undefined;
98
+ getSessionId?: () => unknown;
99
+ };
100
+ };
101
+
102
+ function restoredSessionEvidence(ctx: SessionStartContext): boolean {
103
+ try {
104
+ const timestamp = ctx.sessionManager?.getHeader?.()?.timestamp;
105
+ const createdAt = typeof timestamp === "string" ? Date.parse(timestamp) : Number.NaN;
106
+ return Number.isFinite(createdAt) && createdAt < performance.timeOrigin;
107
+ } catch {
108
+ return false;
109
+ }
110
+ }
111
+
112
+ function startupRebuildSource(ctx: SessionStartContext): "resume" | "fork" | undefined {
113
+ const args = process.argv.slice(2);
114
+ const restored = restoredSessionEvidence(ctx);
115
+ for (const arg of args) {
116
+ if (arg === "--fork" || arg.startsWith("--fork=")) return "fork";
117
+ if (
118
+ restored && (
119
+ arg === "-c" || arg === "--continue" ||
120
+ arg === "-r" || arg === "--resume" ||
121
+ arg === "--session" || arg.startsWith("--session=") ||
122
+ arg === "--session-id" || arg.startsWith("--session-id=")
123
+ )
124
+ ) return "resume";
125
+ }
126
+ return undefined;
127
+ }
128
+ const sessionstartTruncatedMarker =
129
+ "\n\nPI SESSION-START DELIVERY TRUNCATED - the digest exceeded 512 KiB. " +
130
+ "Treat omitted context as unread and inspect the named files directly before acting on it.";
131
+ const sessionstartManualFallback =
132
+ "Run `bin/saga-session-start.sh` now, exactly once, before executing any other instructions.";
133
+ const sessionstartIneligibleExit = 3;
134
+ const sessionstartRetireTimeoutMs = 1000;
135
+
136
+ // One active generation owns native startup from child launch through context
137
+ // claim. Replacement activates first, serially retires every predecessor, and
138
+ // lets only the matching session id claim one persistent provider prerequisite.
139
+ type SessionstartSource = "startup" | "clear" | "resume" | "fork" | "compact";
140
+ type SessionstartResult =
141
+ | { kind: "ready"; raw: string }
142
+ | { kind: "empty" | "failed" | "ineligible" | "cancelled" };
143
+ type SessionstartMessage = {
144
+ customType: "brokk-sessionstart-nudge";
145
+ content: string;
146
+ display: false;
147
+ details: { kind: "session-start" };
148
+ };
149
+ type SessionstartGeneration = {
150
+ id: number;
151
+ sessionId: string;
152
+ source: SessionstartSource;
153
+ stopping: boolean;
154
+ delivered: boolean;
155
+ child: ChildProcess | null;
156
+ processGroupId: number | null;
157
+ childClosed: boolean;
158
+ childClose: Promise<void> | null;
159
+ stopPromise: Promise<void> | null;
160
+ result: Promise<SessionstartResult>;
161
+ };
162
+
163
+ let nextSessionstartGenerationId = 0;
164
+ let activeSessionstartGeneration: SessionstartGeneration | null = null;
165
+
166
+ function sessionIdFromContext(ctx: SessionStartContext): string {
167
+ try {
168
+ return String(ctx.sessionManager?.getSessionId?.() ?? "");
169
+ } catch {
170
+ return "";
171
+ }
172
+ }
173
+
174
+ function sessionstartGenerationIsLive(generation: SessionstartGeneration): boolean {
175
+ return activeSessionstartGeneration === generation && !generation.stopping;
176
+ }
177
+
178
+ function signalSessionstartChild(child: ChildProcess, signal: NodeJS.Signals): void {
179
+ const pid = child.pid;
180
+ if (!pid) return;
181
+ if (process.platform === "win32") {
182
+ const args = ["/pid", String(pid), "/t"];
183
+ if (signal === "SIGKILL") args.push("/f");
184
+ spawnSync("taskkill", args, { stdio: "ignore" });
185
+ return;
186
+ }
187
+ try {
188
+ process.kill(-pid, signal);
189
+ } catch {
190
+ try {
191
+ child.kill(signal);
192
+ } catch {
193
+ }
194
+ }
195
+ }
196
+
197
+ function sessionstartProcessGroupAlive(processGroupId: number): boolean {
198
+ try {
199
+ process.kill(-processGroupId, 0);
200
+ return true;
201
+ } catch {
202
+ return false;
203
+ }
204
+ }
205
+
206
+ function waitForSessionstartProcessGroupExit(
207
+ processGroupId: number,
208
+ timeoutMs: number,
209
+ ): Promise<void> {
210
+ return new Promise((resolveWait) => {
211
+ const startedAt = Date.now();
212
+ const poll = (): void => {
213
+ if (!sessionstartProcessGroupAlive(processGroupId) || Date.now() - startedAt >= timeoutMs) {
214
+ resolveWait();
215
+ return;
216
+ }
217
+ setTimeout(poll, 10);
218
+ };
219
+ poll();
220
+ });
221
+ }
222
+
223
+ function waitForSessionstartClose(generation: SessionstartGeneration, timeoutMs: number): Promise<void> {
224
+ if (generation.childClosed || !generation.childClose) return Promise.resolve();
225
+ return new Promise((resolveWait) => {
226
+ const timer = setTimeout(resolveWait, timeoutMs);
227
+ void generation.childClose?.then(() => {
228
+ clearTimeout(timer);
229
+ resolveWait();
230
+ });
231
+ });
232
+ }
233
+
234
+ function stopSessionstartGeneration(generation: SessionstartGeneration): Promise<void> {
235
+ if (generation.stopPromise) return generation.stopPromise;
236
+ generation.stopping = true;
237
+ generation.stopPromise = (async () => {
238
+ const child = generation.child;
239
+ if (process.platform === "win32") {
240
+ if (!child || generation.childClosed) {
241
+ await generation.result;
242
+ return;
243
+ }
244
+ signalSessionstartChild(child, "SIGTERM");
245
+ await waitForSessionstartClose(generation, sessionstartRetireTimeoutMs);
246
+ if (!generation.childClosed) {
247
+ signalSessionstartChild(child, "SIGKILL");
248
+ await waitForSessionstartClose(generation, sessionstartRetireTimeoutMs);
249
+ }
250
+ return;
251
+ }
252
+ const processGroupId = generation.processGroupId;
253
+ if (!child || !processGroupId) {
254
+ await generation.result;
255
+ return;
256
+ }
257
+ try {
258
+ process.kill(-processGroupId, "SIGTERM");
259
+ } catch {
260
+ }
261
+ await waitForSessionstartProcessGroupExit(processGroupId, sessionstartRetireTimeoutMs);
262
+ if (sessionstartProcessGroupAlive(processGroupId)) {
263
+ try {
264
+ process.kill(-processGroupId, "SIGKILL");
265
+ } catch {
266
+ }
267
+ await waitForSessionstartProcessGroupExit(processGroupId, sessionstartRetireTimeoutMs);
268
+ }
269
+ })();
270
+ return generation.stopPromise;
271
+ }
272
+
273
+ function runSessionstartHook(generation: SessionstartGeneration): Promise<SessionstartResult> {
274
+ return new Promise((resolveResult) => {
275
+ let settled = false;
276
+ let closeChild: () => void = () => {};
277
+ const settle = (result: SessionstartResult): void => {
278
+ if (settled) return;
279
+ settled = true;
280
+ resolveResult(result);
281
+ };
282
+ const supervised = process.platform !== "win32";
283
+ const runner = `${root}/bin/saga-sessionstart-run.sh`;
284
+ let child: ChildProcess;
285
+ try {
286
+ child = spawn(
287
+ supervised ? "node" : runner,
288
+ supervised
289
+ ? [
290
+ `${extensionDir}/lib/vordr-sessionstart-supervisor.mjs`,
291
+ runner,
292
+ "--source",
293
+ generation.source,
294
+ "--pi-prerequisite",
295
+ ]
296
+ : ["--source", generation.source, "--pi-prerequisite"],
297
+ {
298
+ detached: supervised,
299
+ stdio: supervised
300
+ ? ["ignore", "pipe", "ignore", "ipc"]
301
+ : ["ignore", "pipe", "ignore"],
302
+ env: {
303
+ ...process.env,
304
+ BROKK_SESSION_PID: String(process.pid),
305
+ },
306
+ },
307
+ );
308
+ } catch {
309
+ settle(generation.stopping ? { kind: "cancelled" } : { kind: "failed" });
310
+ return;
311
+ }
312
+ generation.child = child;
313
+ generation.processGroupId = child.pid ?? null;
314
+ generation.childClose = new Promise<void>((resolveClose) => {
315
+ closeChild = resolveClose;
316
+ });
317
+ const chunks: Buffer[] = [];
318
+ let observedBytes = 0;
319
+ let retainedBytes = 0;
320
+ let truncated = false;
321
+ let pendingCompletion: { code: number | null; bytes: number } | null = null;
322
+ const unrefSupervisor = (): void => {
323
+ if (!supervised) return;
324
+ child.unref();
325
+ child.channel?.unref?.();
326
+ const stdout = child.stdout as (NodeJS.ReadableStream & { unref?: () => void }) | null;
327
+ stdout?.unref?.();
328
+ };
329
+ const markClosed = (): void => {
330
+ if (generation.childClosed) return;
331
+ generation.childClosed = true;
332
+ if (generation.child === child) generation.child = null;
333
+ generation.processGroupId = null;
334
+ closeChild();
335
+ };
336
+ const complete = (code: number | null): void => {
337
+ unrefSupervisor();
338
+ if (generation.stopping) {
339
+ settle({ kind: "cancelled" });
340
+ return;
341
+ }
342
+ if (code === sessionstartIneligibleExit) {
343
+ settle({ kind: "ineligible" });
344
+ return;
345
+ }
346
+ if (code !== 0) {
347
+ settle({ kind: "failed" });
348
+ return;
349
+ }
350
+ const raw = Buffer.concat(chunks).toString("utf8").trim();
351
+ if (!raw) {
352
+ settle({ kind: "empty" });
353
+ return;
354
+ }
355
+ settle({
356
+ kind: "ready",
357
+ raw: truncated ? `${raw}${sessionstartTruncatedMarker}` : raw,
358
+ });
359
+ };
360
+ const completePending = (): void => {
361
+ if (!pendingCompletion || observedBytes < pendingCompletion.bytes) return;
362
+ complete(pendingCompletion.code);
363
+ pendingCompletion = null;
364
+ };
365
+ child.stdout?.on("data", (chunk: Buffer) => {
366
+ observedBytes += chunk.length;
367
+ if (retainedBytes >= sessionstartDeliveryBytes) {
368
+ truncated = true;
369
+ completePending();
370
+ return;
371
+ }
372
+ const remaining = sessionstartDeliveryBytes - retainedBytes;
373
+ const retained = chunk.length <= remaining ? chunk : chunk.subarray(0, remaining);
374
+ chunks.push(retained);
375
+ retainedBytes += retained.length;
376
+ if (retained.length !== chunk.length) truncated = true;
377
+ completePending();
378
+ });
379
+ if (supervised) {
380
+ child.on("message", (message: unknown) => {
381
+ const result = message as { type?: unknown; code?: unknown; bytes?: unknown };
382
+ if (result.type !== "result" ||
383
+ (typeof result.code !== "number" && result.code !== null) ||
384
+ typeof result.bytes !== "number") return;
385
+ pendingCompletion = { code: result.code, bytes: result.bytes };
386
+ completePending();
387
+ });
388
+ }
389
+ child.on("error", () => {
390
+ markClosed();
391
+ settle(generation.stopping ? { kind: "cancelled" } : { kind: "failed" });
392
+ });
393
+ child.on("close", (code) => {
394
+ markClosed();
395
+ if (supervised) {
396
+ settle(generation.stopping ? { kind: "cancelled" } : { kind: "failed" });
397
+ return;
398
+ }
399
+ complete(code);
400
+ });
401
+ });
402
+ }
403
+
404
+ function createSessionstartGeneration(
405
+ source: SessionstartSource,
406
+ sessionId: string,
407
+ ): SessionstartGeneration {
408
+ const previous = activeSessionstartGeneration;
409
+ const generation: SessionstartGeneration = {
410
+ id: ++nextSessionstartGenerationId,
411
+ sessionId,
412
+ source,
413
+ stopping: false,
414
+ delivered: false,
415
+ child: null,
416
+ processGroupId: null,
417
+ childClosed: false,
418
+ childClose: null,
419
+ stopPromise: null,
420
+ result: Promise.resolve({ kind: "cancelled" }),
421
+ };
422
+ activeSessionstartGeneration = generation;
423
+ generation.result = (async (): Promise<SessionstartResult> => {
424
+ if (previous) await stopSessionstartGeneration(previous);
425
+ if (!sessionstartGenerationIsLive(generation)) return { kind: "cancelled" };
426
+ return runSessionstartHook(generation);
427
+ })();
428
+ return generation;
429
+ }
430
+
431
+ function sessionstartMessage(
432
+ generation: SessionstartGeneration,
433
+ result: SessionstartResult,
434
+ ): SessionstartMessage | undefined {
435
+ let raw = result.kind === "ready" ? result.raw : "";
436
+ if (!raw && result.kind === "failed") {
437
+ raw = sessionstartManualFallback;
438
+ } else if (!raw && ["startup", "clear", "compact"].includes(generation.source) &&
439
+ result.kind === "empty") {
440
+ raw = sessionstartManualFallback;
441
+ }
442
+ if (!raw) return undefined;
443
+ try {
444
+ // The wrapper already returns an encoded nudge on a context-preserving
445
+ // open, so only an unencoded digest or fallback needs the marker added.
446
+ const content = classifyRoddCurrentOperationalText(raw)
447
+ ? raw
448
+ : encodeRoddOperationalInput("session-start", raw);
449
+ return {
450
+ customType: "brokk-sessionstart-nudge",
451
+ content,
452
+ display: false,
453
+ details: { kind: "session-start" },
454
+ };
455
+ } catch {
456
+ return undefined;
457
+ }
458
+ }
459
+
460
+ async function claimSessionstartMessage(
461
+ generation: SessionstartGeneration,
462
+ ctx?: SessionStartContext,
463
+ ): Promise<SessionstartMessage | undefined> {
464
+ const result = await generation.result;
465
+ if (!sessionstartGenerationIsLive(generation) || generation.delivered) return undefined;
466
+ const currentSessionId = ctx ? sessionIdFromContext(ctx) : "";
467
+ if (generation.sessionId && currentSessionId && generation.sessionId !== currentSessionId) {
468
+ return undefined;
469
+ }
470
+ generation.delivered = true;
471
+ return sessionstartMessage(generation, result);
472
+ }
473
+
474
+ function runGuard(): Promise<{ code: number; stderr: string }> {
475
+ return new Promise((resolveResult) => {
476
+ const child = spawn(`${root}/bin/syn-turnend-guard.sh`, {
477
+ stdio: ["pipe", "ignore", "pipe"],
478
+ });
479
+ let stderr = "";
480
+ child.stderr.on("data", (chunk) => {
481
+ stderr += chunk.toString();
482
+ });
483
+ child.on("error", () => resolveResult({ code: 0, stderr: "" }));
484
+ child.on("close", (code) => resolveResult({ code: code ?? 0, stderr }));
485
+ child.stdin.end('{"stop_hook_active":false}');
486
+ });
487
+ }
488
+
489
+ // PreToolUse seatbelts (bin/syn-arm-pretool-check.sh, docs/arm-pretool-check.md;
490
+ // bin/syn-cd-pretool-check.sh, docs/cd-guard.md). Both piggyback on this same
491
+ // extension file rather than separate ones so no extra Pi -e flag is needed at
492
+ // launch - the primary already loads this file for the turn-end guard, and
493
+ // pi.on("tool_call", ...) can block (verified 2026-07-09 against pi 0.80.5:
494
+ // returning {block: true} prevents the bash command from running). Each owner
495
+ // script owns its own decision and is inert outside the real primary checkout.
496
+ function runChecker(script: string, command: string): Promise<{ code: number; stderr: string }> {
497
+ return new Promise((resolveResult) => {
498
+ const child = spawn(`${root}/bin/${script}`, ["--command", command], {
499
+ stdio: ["ignore", "ignore", "pipe"],
500
+ });
501
+ let stderr = "";
502
+ child.stderr.on("data", (chunk) => {
503
+ stderr += chunk.toString();
504
+ });
505
+ child.on("error", () => resolveResult({ code: 0, stderr: "" }));
506
+ child.on("close", (code) => resolveResult({ code: code ?? 0, stderr }));
507
+ });
508
+ }
509
+
510
+ function runPretoolCheck(command: string): Promise<{ code: number; stderr: string }> {
511
+ return runChecker("syn-arm-pretool-check.sh", command);
512
+ }
513
+
514
+ function runCdCheck(command: string): Promise<{ code: number; stderr: string }> {
515
+ return runChecker("syn-cd-pretool-check.sh", command);
516
+ }
517
+
518
+ // Asset seatbelt (bin/syn-asset-pretool-check.sh): a governed path may not be
519
+ // edited until its owning asset has been read this session. The read itself is
520
+ // recorded through the same script, so the gate is self-contained.
521
+ function runAssetCheck(path: string): Promise<{ code: number; stderr: string }> {
522
+ return new Promise((resolveResult) => {
523
+ const child = spawn(`${root}/bin/syn-asset-pretool-check.sh`, ["--path", path], {
524
+ stdio: ["ignore", "ignore", "pipe"],
525
+ });
526
+ let stderr = "";
527
+ child.stderr.on("data", (chunk) => {
528
+ stderr += chunk.toString();
529
+ });
530
+ child.on("error", () => resolveResult({ code: 0, stderr: "" }));
531
+ child.on("close", (code) => resolveResult({ code: code ?? 0, stderr }));
532
+ });
533
+ }
534
+
535
+ function noteAssetRead(path: string): void {
536
+ try {
537
+ const child = spawn(`${root}/bin/syn-asset-pretool-check.sh`, ["--note", path], {
538
+ stdio: ["ignore", "ignore", "ignore"],
539
+ });
540
+ child.on("error", () => undefined);
541
+ } catch {
542
+ // The note is best-effort; never break a read over it.
543
+ }
544
+ }
545
+
546
+ export default function (pi: ExtensionAPI) {
547
+ let sessionstartGeneration: SessionstartGeneration | null = null;
548
+ let sessionstartExitListenerRegistered = false;
549
+ const cleanupSessionstartOnProcessExit = (): void => {
550
+ const generation = sessionstartGeneration;
551
+ if (!generation) return;
552
+ if (process.platform === "win32") {
553
+ if (generation.child) signalSessionstartChild(generation.child, "SIGKILL");
554
+ return;
555
+ }
556
+ const processGroupId = generation.processGroupId;
557
+ if (!processGroupId) {
558
+ if (generation.child) signalSessionstartChild(generation.child, "SIGKILL");
559
+ return;
560
+ }
561
+ try {
562
+ process.kill(-processGroupId, "SIGKILL");
563
+ } catch {
564
+ }
565
+ };
566
+ const registerSessionstartExitListener = (): void => {
567
+ if (sessionstartExitListenerRegistered) return;
568
+ process.once("exit", cleanupSessionstartOnProcessExit);
569
+ sessionstartExitListenerRegistered = true;
570
+ };
571
+ const removeSessionstartExitListener = (): void => {
572
+ if (!sessionstartExitListenerRegistered) return;
573
+ process.removeListener("exit", cleanupSessionstartOnProcessExit);
574
+ sessionstartExitListenerRegistered = false;
575
+ };
576
+ registerSessionstartExitListener();
577
+
578
+ pi.on?.("session_start", (event, ctx) => {
579
+ const reason = String((event as { reason?: unknown }).reason ?? "");
580
+ const source = reason === "startup"
581
+ ? startupRebuildSource(ctx) ?? "startup"
582
+ : { new: "clear", resume: "resume", fork: "fork" }[reason];
583
+ markLoaded();
584
+ if (!source) return;
585
+ registerSessionstartExitListener();
586
+ sessionstartGeneration = createSessionstartGeneration(
587
+ source as SessionstartSource,
588
+ sessionIdFromContext(ctx),
589
+ );
590
+ });
591
+
592
+ pi.on?.("before_agent_start", async (_event, ctx) => {
593
+ const generation = sessionstartGeneration;
594
+ if (!generation) return;
595
+ const message = await claimSessionstartMessage(generation, ctx);
596
+ return message ? { message } : undefined;
597
+ });
598
+
599
+ // Pi's compaction equivalent. Manual compaction is idle and auto-compaction
600
+ // may retry without another before_agent_start, so the event keeps its
601
+ // existing delivery path while sharing generation ownership and cancellation.
602
+ pi.on?.("session_compact", async (_event, ctx) => {
603
+ registerSessionstartExitListener();
604
+ const generation = createSessionstartGeneration("compact", sessionIdFromContext(ctx));
605
+ sessionstartGeneration = generation;
606
+ const message = await claimSessionstartMessage(generation, ctx);
607
+ if (!message || !sessionstartGenerationIsLive(generation)) return;
608
+ try {
609
+ pi.sendMessage(message);
610
+ } catch {
611
+ generation.delivered = false;
612
+ }
613
+ });
614
+
615
+ pi.on?.("session_shutdown", async () => {
616
+ const generation = sessionstartGeneration;
617
+ try {
618
+ if (generation) await stopSessionstartGeneration(generation);
619
+ } finally {
620
+ if (sessionstartGeneration === generation) sessionstartGeneration = null;
621
+ removeSessionstartExitListener();
622
+ }
623
+ });
624
+
625
+ pi.on("tool_call", async (event) => {
626
+ if (event.type !== "tool_call") return {};
627
+
628
+ // Reading an asset records it, which unlocks editing the governed path.
629
+ if (event.toolName === "read") {
630
+ const readPath = String((event.input as { path?: unknown })?.path ?? "");
631
+ if (readPath.includes("/assets/")) noteAssetRead(readPath.replace(/^\.\//, ""));
632
+ return {};
633
+ }
634
+
635
+ // A governed path may not be edited until its owning asset was loaded.
636
+ if (event.toolName === "edit" || event.toolName === "write") {
637
+ const editPath = String((event.input as { path?: unknown })?.path ?? "");
638
+ if (editPath) {
639
+ const assetResult = await runAssetCheck(editPath);
640
+ if (assetResult.code === 2) {
641
+ return { block: true, reason: assetResult.stderr.trim() || "denied by the governed-path asset seatbelt" };
642
+ }
643
+ }
644
+ return {};
645
+ }
646
+
647
+ if (event.toolName !== "bash") return {};
648
+ const command = String((event.input as { command?: unknown })?.command ?? "");
649
+ if (!command) return {};
650
+ const cdResult = await runCdCheck(command);
651
+ if (cdResult.code === 2) {
652
+ return { block: true, reason: cdResult.stderr.trim() || "denied by the cd-guard PreToolUse seatbelt" };
653
+ }
654
+ const result = await runPretoolCheck(command);
655
+ if (result.code !== 2) return {};
656
+ return { block: true, reason: result.stderr.trim() || "denied by the watcher-arm PreToolUse seatbelt" };
657
+ });
658
+
659
+ pi.on("agent_settled", async () => {
660
+ if (guardFollowupActive) {
661
+ guardFollowupActive = false;
662
+ return;
663
+ }
664
+
665
+ const result = await runGuard();
666
+ if (result.code !== 2) return;
667
+
668
+ guardFollowupActive = true;
669
+ try {
670
+ const content = encodeRoddOperationalInput(
671
+ "turn-end-guard",
672
+ "TURN WOULD END BLIND - supervision is off. " +
673
+ "The watcher cycle is missing, failed, or unhealthy. Follow the harness recovery instruction below before ending the turn.\n\n" +
674
+ result.stderr,
675
+ );
676
+ await pi.sendUserMessage(content, { deliverAs: "followUp" });
677
+ } catch {
678
+ guardFollowupActive = false;
679
+ }
680
+ });
681
+
682
+ markLoaded();
683
+ }