@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,750 @@
1
+ // Brokk primary watcher bridge for Pi.
2
+ //
3
+ // Session-generation ownership (stated once here):
4
+ // Pi emits session_shutdown for ordinary same-process replacements (/new, /resume,
5
+ // /fork, reload) as well as terminal quit. This extension binds one generation per
6
+ // session activation. Only the active live generation may start, stop, rearm, or
7
+ // clear the arm child. Replacement session_start (or a fresh smidja bind) activates
8
+ // a new live generation so monitoring can arm again without restarting Pi. Terminal
9
+ // quit leaves the final generation stopped so late callbacks cannot rearm. Stale
10
+ // callbacks from a prior generation are no-ops against the active replacement.
11
+ import { spawn, spawnSync, type ChildProcess } from "node:child_process";
12
+ import { createHash } from "node:crypto";
13
+ import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
14
+ import { dirname, resolve } from "node:path";
15
+ import { fileURLToPath } from "node:url";
16
+ import type { ExtensionAPI, Theme } from "@earendil-works/pi-coding-agent";
17
+ import { Box, Container, Text, type Component } from "@earendil-works/pi-tui";
18
+ import { Type } from "typebox";
19
+ import {
20
+ createSkuldDispatchOffer,
21
+ SKULD_BRANCH_DISPATCH_EVENT,
22
+ scopeForSkuldWake,
23
+ } from "./lib/skuld-branch-dispatch.ts";
24
+ import {
25
+ type RoPresentationState,
26
+ roTranscriptClassIsVisible,
27
+ RO_PRESENTATION_EVENT,
28
+ } from "./lib/ro-visibility.ts";
29
+ import { encodeRoddOperationalInput } from "./lib/rodd-operational-input.ts";
30
+
31
+ type ArmResult = {
32
+ ok: boolean;
33
+ message: string;
34
+ };
35
+
36
+ type LockOwnership = "owned" | "missing" | "other";
37
+
38
+ type CloseClassification = {
39
+ kind: "actionable" | "failure";
40
+ message: string;
41
+ };
42
+
43
+ type WatchToolShellState = {
44
+ shell?: Box;
45
+ call?: Component;
46
+ result?: Component;
47
+ };
48
+
49
+ type WatchToolRenderContext = {
50
+ isError: boolean;
51
+ isPartial: boolean;
52
+ };
53
+
54
+ type SessionGeneration = {
55
+ id: number;
56
+ stopping: boolean;
57
+ child: ChildProcess | null;
58
+ retryTimer: ReturnType<typeof setTimeout> | null;
59
+ retryFailures: number;
60
+ restoring: boolean;
61
+ seq: number;
62
+ };
63
+
64
+ function refreshWatchToolShell(
65
+ state: WatchToolShellState,
66
+ theme: Theme,
67
+ context: WatchToolRenderContext,
68
+ ): Box {
69
+ const background = context.isPartial
70
+ ? (text: string) => theme.bg("toolPendingBg", text)
71
+ : context.isError
72
+ ? (text: string) => theme.bg("toolErrorBg", text)
73
+ : (text: string) => theme.bg("toolSuccessBg", text);
74
+ const shell = state.shell ?? new Box(1, 1, background);
75
+ state.shell = shell;
76
+ shell.setBgFn(background);
77
+ shell.clear();
78
+ if (state.call) shell.addChild(state.call);
79
+ if (state.result) shell.addChild(state.result);
80
+ return shell;
81
+ }
82
+
83
+ const extensionFile = fileURLToPath(import.meta.url);
84
+ const extensionDir = dirname(extensionFile);
85
+ const root = resolve(extensionDir, "../..");
86
+ const fmHome = process.env.BROKK_HOME || process.env.BROKK_ROOT_OVERRIDE || root;
87
+ const fmRoot = process.env.BROKK_ROOT_OVERRIDE || root;
88
+ const state = process.env.BROKK_STATE_OVERRIDE || `${fmHome}/state`;
89
+ const config = process.env.BROKK_CONFIG_OVERRIDE || `${fmHome}/config`;
90
+ const armScript = `${fmRoot}/bin/syn-watch-arm.sh`;
91
+ const marker = `${state}/.pi-watch-extension-loaded`;
92
+ const extensionVersion = `sha256:${createHash("sha256").update(readFileSync(extensionFile)).digest("hex")}`;
93
+ const retryBaseMs = positiveInteger("BROKK_WATCH_REARM_RETRY_BASE_MS", 250);
94
+ const retryMaxMs = positiveInteger("BROKK_WATCH_REARM_RETRY_MAX_MS", 4000);
95
+ const retryLimit = positiveInteger("BROKK_WATCH_REARM_RETRY_LIMIT", 5);
96
+ // 35s on Windows so the budget stays above arm's MSYS confirm default (30s in
97
+ // bin/syn-watch-arm.sh): a slow but successful Git Bash cold start must not be
98
+ // SIGTERMed mid-confirmation. Conditioned on win32 so other platforms keep 12s.
99
+ const armReadyTimeoutMs = positiveInteger(
100
+ "BROKK_PI_ARM_READY_TIMEOUT_MS",
101
+ process.platform === "win32" ? 35000 : 12000,
102
+ );
103
+ const armRetireTimeoutMs = positiveInteger("BROKK_WATCH_ARM_RETIRE_TIMEOUT_MS", 1000);
104
+ const repairOnlyHint = "call gna_watch_arm again only after a later notification says the cycle is missing, failed, or unhealthy";
105
+ const shuttingDownMessage = "watcher: not armed - Pi session is shutting down";
106
+
107
+ let nextGenerationId = 0;
108
+ let activeGeneration: SessionGeneration | null = null;
109
+ const armReadiness = new WeakMap<ChildProcess, Promise<boolean>>();
110
+ const armClose = new WeakMap<ChildProcess, Promise<void>>();
111
+ const armRecovery = new WeakMap<ChildProcess, { generation: string; watcherPid: string }>();
112
+
113
+ function positiveInteger(name: string, fallback: number): number {
114
+ const value = Number(process.env[name]);
115
+ if (!Number.isFinite(value) || value <= 0) return fallback;
116
+ return Math.floor(value);
117
+ }
118
+
119
+ function parentPid(pid: string): string {
120
+ const result = spawnSync("ps", ["-o", "ppid=", "-p", pid], { encoding: "utf8" });
121
+ if (result.status !== 0) return "";
122
+ return result.stdout.trim();
123
+ }
124
+
125
+ // Liveness that actually sees death: process.kill(pid, 0) alone "succeeds" for
126
+ // a zombie (dead but unreaped) and for a recycled pid. /proc/<pid>/stat resolves
127
+ // both — the state character (field 3: Z = zombie, X = dead) and the process
128
+ // starttime (field 22), which is stable for a pid's whole life. A mismatch with
129
+ // the starttime recorded at acquire means the original holder is gone and the
130
+ // kernel handed the pid to an unrelated process.
131
+ function procStatParts(pid: string): string[] | null {
132
+ if (!/^[0-9]+$/.test(pid) || pid === "1") return null;
133
+ try {
134
+ const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
135
+ const close = stat.lastIndexOf(")");
136
+ if (close < 0) return null;
137
+ const parts = stat.slice(close + 2).trim().split(/\s+/);
138
+ return parts[0] === "" ? parts.slice(1) : parts;
139
+ } catch {
140
+ return null;
141
+ }
142
+ }
143
+
144
+ function procStarttime(pid: string): string {
145
+ const parts = procStatParts(pid);
146
+ // Field 22 sits at index 19 after the comm field (remaining fields start at 3).
147
+ return parts && parts.length > 19 ? parts[19] : "";
148
+ }
149
+
150
+ function pidAlive(pid: string, starttime = ""): boolean {
151
+ const parts = procStatParts(pid);
152
+ if (parts) {
153
+ if (parts[0] === "Z" || parts[0] === "X") return false;
154
+ if (starttime) {
155
+ const current = parts.length > 19 ? parts[19] : "";
156
+ if (current && current !== starttime) return false;
157
+ }
158
+ }
159
+ try {
160
+ process.kill(Number(pid), 0);
161
+ return true;
162
+ } catch {
163
+ return false;
164
+ }
165
+ }
166
+
167
+ // The resolved session-lock path. The primary holds a machine-global lock; an
168
+ // Eindri-home holds a per-home one. gleipnir-lock-lib.sh records whichever it
169
+ // resolved in state/.lock-path; a session that started before that contract is
170
+ // still on the legacy state/.lock, so fall back to it.
171
+ function resolvedLockPath(): string {
172
+ try {
173
+ const pointer = readFileSync(`${state}/.lock-path`, "utf8").trim();
174
+ if (pointer) return pointer;
175
+ } catch {
176
+ // no pointer yet — pre-machine-lock session
177
+ }
178
+ return `${state}/.lock`;
179
+ }
180
+
181
+ function lockOwnership(): LockOwnership {
182
+ const lockPath = resolvedLockPath();
183
+ let lockPid = "";
184
+ try {
185
+ lockPid = readFileSync(lockPath, "utf8").trim();
186
+ } catch {
187
+ return "missing";
188
+ }
189
+ // An empty lock carries no verifiably-live holder — a vacant helm, not
190
+ // another session's. Classify it missing so the reclaim takes the helm in
191
+ // place; a truncated/empty lock must never strand supervision or punt to a
192
+ // manual session start.
193
+ if (!lockPid) return "missing";
194
+ if (!/^[0-9]+$/.test(lockPid) || lockPid === "1") return "other";
195
+ let pid = String(process.pid);
196
+ for (let i = 0; i < 8; i += 1) {
197
+ if (pid === lockPid) return "owned";
198
+ pid = parentPid(pid);
199
+ if (!pid || pid === "1") break;
200
+ }
201
+ let recordedStarttime = "";
202
+ try {
203
+ recordedStarttime = readFileSync(`${lockPath}.starttime`, "utf8").trim();
204
+ } catch {
205
+ // lock predates the starttime sidecar — liveness falls back to the
206
+ // state/zombie check and kill(0)
207
+ }
208
+ // pidAlive rejects zombies and recycled pids, so "missing" truly means the
209
+ // recorded holder is verifiably gone — never a live "other" session.
210
+ return pidAlive(lockPid, recordedStarttime) ? "other" : "missing";
211
+ }
212
+
213
+ // A stale lock (owner dead / zombie / pid reused) is cleared and the helm is
214
+ // taken directly, so a leftover lock can never strand supervision. This mirrors
215
+ // gleipnir_lock_acquire in bin/gleipnir-lock-lib.sh (pid + starttime sidecar +
216
+ // state/.lock-path pointer; the legacy state/.lock is dropped).
217
+ function reclaimStaleLock(lockPath: string): void {
218
+ mkdirSync(dirname(lockPath), { recursive: true });
219
+ writeFileSync(lockPath, `${process.pid}\n`);
220
+ const starttime = procStarttime(String(process.pid));
221
+ if (starttime) writeFileSync(`${lockPath}.starttime`, `${starttime}\n`);
222
+ const legacyPath = `${state}/.lock`;
223
+ if (legacyPath !== lockPath) {
224
+ try {
225
+ const legacyOwner = readFileSync(legacyPath, "utf8").trim();
226
+ if (legacyOwner === String(process.pid) || !pidAlive(legacyOwner)) {
227
+ rmSync(legacyPath, { force: true });
228
+ }
229
+ } catch {
230
+ // no legacy lock to clear
231
+ }
232
+ }
233
+ writeFileSync(`${state}/.lock-path`, `${lockPath}\n`);
234
+ }
235
+
236
+ // On real process exit (never on in-process /new resets, which reuse the same
237
+ // pid and keep the lock), drop the lock this session owns so a dying or killed
238
+ // agent cannot leave the helm for everyone after it. A hard kill that skips
239
+ // this hook is covered by the next session's reap (zombie/pid-reuse aware).
240
+ function releaseLockIfOwned(): void {
241
+ if (lockOwnership() !== "owned") return;
242
+ const lockPath = resolvedLockPath();
243
+ try {
244
+ rmSync(lockPath, { force: true });
245
+ rmSync(`${lockPath}.starttime`, { force: true });
246
+ } catch {
247
+ // best-effort: the next session's reap covers a leftover lock
248
+ }
249
+ }
250
+
251
+ function markLoaded(): void {
252
+ if (lockOwnership() === "other") return;
253
+ mkdirSync(state, { recursive: true });
254
+ writeFileSync(marker, `${extensionVersion}\n${process.pid}\n`);
255
+ }
256
+
257
+ function actionableLine(output: string): string {
258
+ const lines = output.split(/\r?\n/);
259
+ return lines.find((line) => /^(signal:|stale:|check:|heartbeat($|:))/.test(line)) || "";
260
+ }
261
+
262
+ function classifyClose(stdout: string, stderr: string, code: number | null, signal: NodeJS.Signals | null): CloseClassification {
263
+ const combined = `${stdout}\n${stderr}`.trim();
264
+ const reason = actionableLine(combined);
265
+ if (reason) return { kind: "actionable", message: reason };
266
+ const healthy = combined.split(/\r?\n/).find((line) => /^watcher: healthy\b/.test(line));
267
+ if (healthy) {
268
+ return {
269
+ kind: "failure",
270
+ message: `watcher: FAILED - Pi extension arm child found an external healthy watcher instead of owning wake delivery\n${healthy}`,
271
+ };
272
+ }
273
+ const failed = combined.split(/\r?\n/).find((line) => /^watcher: FAILED/.test(line));
274
+ if (failed) return { kind: "failure", message: failed };
275
+ if (signal) {
276
+ return {
277
+ kind: "failure",
278
+ message: `watcher: FAILED - Pi extension arm child ended from ${signal}${combined ? `\n${combined}` : ""}`,
279
+ };
280
+ }
281
+ if (code && code !== 0) {
282
+ return {
283
+ kind: "failure",
284
+ message: `watcher: FAILED - syn-watch-arm.sh exited ${code}${combined ? `\n${combined}` : ""}`,
285
+ };
286
+ }
287
+ return {
288
+ kind: "failure",
289
+ message: "watcher: FAILED - Pi extension arm cycle ended without an actionable reason",
290
+ };
291
+ }
292
+
293
+ function createGeneration(): SessionGeneration {
294
+ return {
295
+ id: ++nextGenerationId,
296
+ stopping: false,
297
+ child: null,
298
+ retryTimer: null,
299
+ retryFailures: 0,
300
+ restoring: false,
301
+ seq: 0,
302
+ };
303
+ }
304
+
305
+ function activateGeneration(generation: SessionGeneration): void {
306
+ activeGeneration = generation;
307
+ }
308
+
309
+ function generationIsLive(generation: SessionGeneration): boolean {
310
+ return activeGeneration === generation && !generation.stopping;
311
+ }
312
+
313
+ function stopGeneration(generation: SessionGeneration): void {
314
+ generation.stopping = true;
315
+ if (generation.retryTimer) clearTimeout(generation.retryTimer);
316
+ generation.retryTimer = null;
317
+ if (generation.child) generation.child.kill("SIGTERM");
318
+ generation.child = null;
319
+ }
320
+
321
+ const cleanupOnProcessExit = () => {
322
+ if (activeGeneration) stopGeneration(activeGeneration);
323
+ releaseLockIfOwned();
324
+ };
325
+ process.once("exit", cleanupOnProcessExit);
326
+
327
+ export default function (pi: ExtensionAPI) {
328
+ let generation = createGeneration();
329
+ activateGeneration(generation);
330
+
331
+ let calmPresentation: RoPresentationState = {
332
+ active: false,
333
+ stockExportRendering: false,
334
+ };
335
+ pi.events?.on?.(RO_PRESENTATION_EVENT, (data) => {
336
+ const next = data as Partial<RoPresentationState>;
337
+ calmPresentation = {
338
+ active: next.active === true,
339
+ stockExportRendering: next.stockExportRendering === true,
340
+ };
341
+ });
342
+ const calmHides = (itemClass: Parameters<typeof roTranscriptClassIsVisible>[0]): boolean =>
343
+ calmPresentation.active &&
344
+ !calmPresentation.stockExportRendering &&
345
+ !roTranscriptClassIsVisible(itemClass);
346
+
347
+ let lastWatcherWake = "";
348
+
349
+ function queueHasFreshContent(): boolean {
350
+ // A wake is fresh only while one of the durable doors still carries a line;
351
+ // with both empty, a repeated identical wake is an echo of one already
352
+ // drained (the loud-stretch flood came from exactly this re-presentation).
353
+ for (const p of [`${state}/.wake-queue`, `${fmHome}/.agents/state/.wake-queue`]) {
354
+ try {
355
+ if (readFileSync(p, "utf8").trim().length > 0) return true;
356
+ } catch {
357
+ // absent queue reads as empty
358
+ }
359
+ }
360
+ return false;
361
+ }
362
+
363
+ async function sendWake(
364
+ owner: SessionGeneration,
365
+ message: string,
366
+ ): Promise<void> {
367
+ if (!generationIsLive(owner)) return;
368
+ // Deliver an identical wake at most once per drained state: a repeat with
369
+ // no fresh queue content is the same news the primary already handled, and
370
+ // re-sending it floods the follow-up queue one-per-prompt.
371
+ if (message === lastWatcherWake && !queueHasFreshContent()) return;
372
+ lastWatcherWake = message;
373
+ const content = encodeRoddOperationalInput(
374
+ "watcher",
375
+ `BROKK WATCHER WAKE: ${message}\n\nRun bin/saga-wake-drain.sh first and handle the queued wake. Watcher continuity is extension-owned.`,
376
+ );
377
+ await pi.sendUserMessage(content, { deliverAs: "followUp" });
378
+ }
379
+
380
+ function confirmHandlingDelivery(recovery: { generation: string; watcherPid: string }): {
381
+ ok: boolean;
382
+ detail: string;
383
+ } {
384
+ try {
385
+ const result = spawnSync(
386
+ "bash",
387
+ [armScript, "--handling-delivered", recovery.generation, "--watcher-pid", recovery.watcherPid],
388
+ {
389
+ cwd: fmRoot,
390
+ encoding: "utf8",
391
+ env: { ...process.env, BROKK_HOME: fmHome, BROKK_STATE_OVERRIDE: state, BROKK_ROOT_OVERRIDE: fmRoot },
392
+ },
393
+ );
394
+ if (result.status === 0) return { ok: true, detail: "" };
395
+ const stderr = (result.stderr || "").trim();
396
+ return {
397
+ ok: false,
398
+ detail: `watcher: FAILED - handling delivery confirmation was rejected (status=${result.status ?? "none"} generation=${recovery.generation} watcherPid=${recovery.watcherPid})${stderr ? `\n${stderr}` : ""}`,
399
+ };
400
+ } catch (error) {
401
+ const message = error instanceof Error ? error.message : String(error);
402
+ return {
403
+ ok: false,
404
+ detail: `watcher: FAILED - handling delivery confirmation could not be executed (generation=${recovery.generation} watcherPid=${recovery.watcherPid})\n${message}`,
405
+ };
406
+ }
407
+ }
408
+
409
+ function confirmHandlingDeliveryWithRetry(
410
+ owner: SessionGeneration,
411
+ recovery: { generation: string; watcherPid: string },
412
+ ): { ok: boolean; detail: string } {
413
+ const snapshot = (): { generation: string; watcherPid: string } => {
414
+ const current = owner.child ? armRecovery.get(owner.child) : undefined;
415
+ return current ?? recovery;
416
+ };
417
+ const first = confirmHandlingDelivery(snapshot());
418
+ if (first.ok) return first;
419
+ return confirmHandlingDelivery(snapshot());
420
+ }
421
+
422
+ function offerWakeToBranch(message: string): boolean {
423
+ const heartbeat = /^heartbeat($|:)/.test(message);
424
+ // A check-kind close (merge-confirmation polls, Relay mentions,
425
+ // credential/auth failures, and every other legitimately main-only
426
+ // class - docs/pi-supervision-branch.md) is never routed to the branch
427
+ // even when other currently-unread rows are individually eligible: this
428
+ // watcher cycle's own triggering event stays on main, exactly as before
429
+ // scopeForSkuldWake stopped letting a co-present check row veto the
430
+ // whole scan. That relaxation is what lets an UNRELATED eligible
431
+ // signal/stale row still reach the branch on this cycle; it must never
432
+ // also let a check-kind trigger itself slip past main's delivery.
433
+ const isCheckTrigger = /^check:/.test(message);
434
+ const scope = scopeForSkuldWake(state, heartbeat);
435
+ const eligible = !isCheckTrigger && scope.eligible;
436
+ const offer = createSkuldDispatchOffer(message, scope.projects, heartbeat, eligible);
437
+ pi.events?.emit?.(SKULD_BRANCH_DISPATCH_EVENT, offer);
438
+ return offer.accepted;
439
+ }
440
+
441
+ async function deliverActionableWake(
442
+ owner: SessionGeneration,
443
+ message: string,
444
+ repairFailed: boolean,
445
+ recovery?: { generation: string; watcherPid: string },
446
+ ): Promise<void> {
447
+ if (!generationIsLive(owner)) return;
448
+ if (recovery) {
449
+ const confirmed = confirmHandlingDeliveryWithRetry(owner, recovery);
450
+ if (!confirmed.ok) {
451
+ const watcherPid = recovery.watcherPid;
452
+ if (!pidAlive(watcherPid)) {
453
+ await retireArm(owner.child);
454
+ }
455
+ await sendWake(owner, `${message}\n\n${confirmed.detail}`);
456
+ return;
457
+ }
458
+ }
459
+ if (!repairFailed && offerWakeToBranch(message)) return;
460
+ await sendWake(owner, message);
461
+ }
462
+
463
+ function surfaceFailure(owner: SessionGeneration, message: string): void {
464
+ void sendWake(owner, message).catch(() => {
465
+ // Pi owns delivery errors; continuity restoration never waits on prompting.
466
+ });
467
+ }
468
+
469
+ function retryDelay(attempt: number): number {
470
+ return Math.min(retryMaxMs, retryBaseMs * 2 ** Math.max(0, attempt - 1));
471
+ }
472
+
473
+ function waitForRetry(attempt: number): Promise<void> {
474
+ return new Promise((resolveRetry) => {
475
+ const timer = setTimeout(resolveRetry, retryDelay(attempt));
476
+ timer.unref();
477
+ });
478
+ }
479
+
480
+ function waitForReadiness(armChild: ChildProcess): Promise<boolean> {
481
+ const readiness = armReadiness.get(armChild);
482
+ if (!readiness) return Promise.resolve(false);
483
+ return new Promise((resolveReady) => {
484
+ const timer = setTimeout(() => resolveReady(false), armReadyTimeoutMs);
485
+ timer.unref();
486
+ void readiness.then((ready) => {
487
+ clearTimeout(timer);
488
+ resolveReady(ready);
489
+ });
490
+ });
491
+ }
492
+
493
+ async function retireArm(armChild: ChildProcess | null): Promise<boolean> {
494
+ if (!armChild) return true;
495
+ armChild.kill("SIGTERM");
496
+ const closed = armClose.get(armChild);
497
+ if (!closed) return false;
498
+ return new Promise((resolveRetired) => {
499
+ const timer = setTimeout(() => resolveRetired(false), armRetireTimeoutMs);
500
+ timer.unref();
501
+ void closed.then(() => {
502
+ clearTimeout(timer);
503
+ resolveRetired(true);
504
+ });
505
+ });
506
+ }
507
+
508
+ async function restoreAfterActionableClose(owner: SessionGeneration, predecessorArmPid: string): Promise<{
509
+ failure: string;
510
+ recovery?: { generation: string; watcherPid: string };
511
+ }> {
512
+ let failure = "";
513
+ for (let attempt = 0; attempt <= retryLimit; attempt += 1) {
514
+ if (!generationIsLive(owner)) return { failure: "" };
515
+ const replacement = startArm(owner, predecessorArmPid);
516
+ const successorChild = owner.child;
517
+ if (replacement.ok && successorChild && await waitForReadiness(successorChild)) {
518
+ return { failure: "", recovery: armRecovery.get(successorChild) };
519
+ }
520
+ if (replacement.ok) {
521
+ failure = "watcher: FAILED - Pi extension could not verify a ready successor watcher";
522
+ if (!(await retireArm(successorChild))) {
523
+ return {
524
+ failure: `${failure}\nwatcher: FAILED - Pi extension could not restore watcher continuity because the unready successor arm did not exit within ${armRetireTimeoutMs}ms`,
525
+ };
526
+ }
527
+ } else {
528
+ failure = /(?:read-only|no live session)/.test(replacement.message)
529
+ ? `watcher: FAILED - Pi extension cannot restore continuity because this session no longer owns the lock\n${replacement.message}`
530
+ : `watcher: FAILED - Pi extension could not start the successor watcher cycle\n${replacement.message}`;
531
+ if (/(?:read-only|no live session)/.test(replacement.message)) break;
532
+ }
533
+ if (attempt === retryLimit) break;
534
+ await waitForRetry(attempt + 1);
535
+ }
536
+ return { failure: `${failure}\nwatcher: FAILED - Pi extension could not restore watcher continuity after ${retryLimit} retries` };
537
+ }
538
+
539
+ function scheduleRetry(owner: SessionGeneration, message: string, predecessorArmPid: string): void {
540
+ if (!generationIsLive(owner) || owner.child || owner.retryTimer) return;
541
+ const ownership = lockOwnership();
542
+ if (ownership !== "owned") {
543
+ surfaceFailure(owner, `watcher: FAILED - Pi extension cannot restore continuity because this session no longer owns the lock\n${message}`);
544
+ return;
545
+ }
546
+ owner.retryFailures += 1;
547
+ if (owner.retryFailures > retryLimit) {
548
+ surfaceFailure(owner, `watcher: FAILED - Pi extension could not restore watcher continuity after ${retryLimit} retries\n${message}`);
549
+ return;
550
+ }
551
+ const timer = setTimeout(() => {
552
+ if (owner.retryTimer === timer) owner.retryTimer = null;
553
+ if (!generationIsLive(owner)) return;
554
+ const result = startArm(owner, predecessorArmPid);
555
+ if (!result.ok) {
556
+ surfaceFailure(owner, `watcher: FAILED - Pi extension could not launch a continuity retry\n${result.message}`);
557
+ }
558
+ }, retryDelay(owner.retryFailures));
559
+ timer.unref();
560
+ owner.retryTimer = timer;
561
+ }
562
+
563
+ function startArm(owner: SessionGeneration, predecessorArmPid = ""): ArmResult {
564
+ if (!generationIsLive(owner)) return { ok: false, message: shuttingDownMessage };
565
+ const ownership = lockOwnership();
566
+ if (ownership === "other") return { ok: false, message: "watcher: read-only - session lock is held by another brokk session" };
567
+ if (ownership === "missing") {
568
+ // No verifiably-live holder: the recorded owner is dead, a zombie, or a
569
+ // recycled pid. Reclaim the helm directly (was: punt to
570
+ // saga-session-start.sh) so a leftover lock never strands supervision.
571
+ reclaimStaleLock(resolvedLockPath());
572
+ return startArm(owner, predecessorArmPid);
573
+ }
574
+ markLoaded();
575
+ if (owner.child) {
576
+ return {
577
+ ok: true,
578
+ message: `watcher: unchanged - Pi extension already owns an arm child; no manual re-arm needed; ${repairOnlyHint}`,
579
+ };
580
+ }
581
+ if (owner.retryTimer) {
582
+ return {
583
+ ok: true,
584
+ message: `watcher: unchanged - Pi extension already owns a scheduled continuity retry; no manual re-arm needed; ${repairOnlyHint}`,
585
+ };
586
+ }
587
+ const id = ++owner.seq;
588
+ const env = {
589
+ ...process.env,
590
+ BROKK_HOME: fmHome,
591
+ BROKK_ROOT_OVERRIDE: fmRoot,
592
+ BROKK_CONFIG_OVERRIDE: config,
593
+ BROKK_WATCH_ARM_SCRIPT: armScript,
594
+ BROKK_WATCH_PREDECESSOR_ARM_PID: predecessorArmPid,
595
+ BROKK_SESSION_PID: String(process.pid),
596
+ };
597
+ const armChild = spawn("bash", ["-lc", "config_dir=\"${BROKK_CONFIG_OVERRIDE:-$BROKK_HOME/config}\"; [ -f \"$config_dir/x-mode.env\" ] && . \"$config_dir/x-mode.env\"; exec \"$BROKK_WATCH_ARM_SCRIPT\" --restart"], {
598
+ cwd: fmRoot,
599
+ env,
600
+ stdio: ["ignore", "pipe", "pipe"],
601
+ });
602
+ owner.child = armChild;
603
+ let stdout = "";
604
+ let stderr = "";
605
+ let settled = false;
606
+ let readinessSettled = false;
607
+ let resolveReadiness: (ready: boolean) => void = () => {};
608
+ let resolveClosed: () => void = () => {};
609
+ const readiness = new Promise<boolean>((resolveReady) => {
610
+ resolveReadiness = resolveReady;
611
+ });
612
+ armReadiness.set(armChild, readiness);
613
+ const closed = new Promise<void>((resolveClosedChild) => {
614
+ resolveClosed = resolveClosedChild;
615
+ });
616
+ armClose.set(armChild, closed);
617
+ const settleReadiness = (ready: boolean): void => {
618
+ if (readinessSettled) return;
619
+ readinessSettled = true;
620
+ resolveReadiness(ready);
621
+ };
622
+ const observeEstablishedArm = (): void => {
623
+ const combined = `${stdout}\n${stderr}`;
624
+ const recovery = combined.match(/^watcher: started pid=([0-9]+).* recovery-generation=([A-Za-z0-9._-]+)$/m);
625
+ if (recovery) armRecovery.set(armChild, { watcherPid: recovery[1], generation: recovery[2] });
626
+ if (/^watcher: (?:started|attached)\b/m.test(combined)) {
627
+ settleReadiness(true);
628
+ }
629
+ };
630
+ const releaseChild = (): void => {
631
+ if (owner.child === armChild) owner.child = null;
632
+ };
633
+ armChild.stdout.on("data", (chunk: Buffer) => {
634
+ stdout += chunk.toString();
635
+ observeEstablishedArm();
636
+ });
637
+ armChild.stderr.on("data", (chunk: Buffer) => {
638
+ stderr += chunk.toString();
639
+ observeEstablishedArm();
640
+ });
641
+ armChild.on("close", (code: number | null, signal: NodeJS.Signals | null) => {
642
+ if (settled) return;
643
+ settled = true;
644
+ resolveClosed();
645
+ settleReadiness(false);
646
+ releaseChild();
647
+ if (!generationIsLive(owner)) return;
648
+ const classification = classifyClose(stdout, stderr, code, signal);
649
+ const predecessor = String(armChild.pid ?? "");
650
+ if (classification.kind === "actionable") {
651
+ if (owner.restoring) return;
652
+ owner.retryFailures = 0;
653
+ owner.restoring = true;
654
+ void (async () => {
655
+ try {
656
+ const restoration = await restoreAfterActionableClose(owner, predecessor);
657
+ if (!generationIsLive(owner)) return;
658
+ const message = restoration.failure ? `${classification.message}\n\n${restoration.failure}` : classification.message;
659
+ await deliverActionableWake(owner, message, Boolean(restoration.failure), restoration.recovery);
660
+ } catch (error) {
661
+ const detail = error instanceof Error ? error.message : String(error);
662
+ surfaceFailure(owner, `watcher: FAILED - Pi extension could not deliver an actionable wake\n${detail}`);
663
+ } finally {
664
+ if (generationIsLive(owner)) owner.restoring = false;
665
+ }
666
+ })();
667
+ return;
668
+ }
669
+ if (owner.restoring) return;
670
+ scheduleRetry(owner, classification.message, predecessor);
671
+ });
672
+ armChild.on("error", (error: Error) => {
673
+ if (settled) return;
674
+ settled = true;
675
+ resolveClosed();
676
+ settleReadiness(false);
677
+ releaseChild();
678
+ if (!generationIsLive(owner)) return;
679
+ if (owner.restoring) return;
680
+ scheduleRetry(owner, `watcher: FAILED - Pi extension arm child ${id} failed: ${error.message}`, String(armChild.pid ?? ""));
681
+ });
682
+ return {
683
+ ok: true,
684
+ message: `watcher: started Pi extension arm child ${id}; future ordinary re-arms are automatic; ${repairOnlyHint}`,
685
+ };
686
+ }
687
+
688
+ pi.on?.("session_start", () => {
689
+ if (generation.stopping) generation = createGeneration();
690
+ activateGeneration(generation);
691
+ markLoaded();
692
+ });
693
+ pi.on?.("session_shutdown", () => {
694
+ stopGeneration(generation);
695
+ });
696
+
697
+ pi.registerCommand?.("gna-watch-arm", {
698
+ description: "Arm brokk watcher supervision through the Pi extension instead of foreground bash.",
699
+ handler: async (_args, ctx) => {
700
+ const result = startArm(generation);
701
+ ctx.ui.notify(result.message, result.ok ? "info" : "warning");
702
+ },
703
+ });
704
+
705
+ pi.registerTool?.({
706
+ name: "gna_watch_arm",
707
+ label: "Arm brokk watcher",
708
+ description: "Start the first required Pi watcher cycle, or repair one only after a notification says the cycle is missing, failed, or unhealthy. Do not call after ordinary work or ordinary notifications; the Pi extension re-arms automatically. Never run bin/syn-watch-arm.sh through bash.",
709
+ promptSnippet: "Start the first required Pi watcher cycle or repair a cycle reported missing, failed, or unhealthy; ordinary re-arming is automatic.",
710
+ promptGuidelines: [
711
+ "Call gna_watch_arm only for the first required cycle or after a notification says the cycle is missing, failed, or unhealthy. Do not call it after ordinary work, turn completion, or ordinary signal, stale, check, or heartbeat handling because the Pi extension owns re-arming. Never run bin/syn-watch-arm.sh through bash.",
712
+ ],
713
+ parameters: Type.Object({}),
714
+ renderShell: "self",
715
+ renderCall: (_args, theme, context) => {
716
+ if (calmHides("assistant-tool-call")) return new Container();
717
+ if (calmPresentation.stockExportRendering) {
718
+ return new Text(theme.fg("toolTitle", theme.bold("gna_watch_arm")), 0, 0);
719
+ }
720
+ const state = context.state as WatchToolShellState;
721
+ state.call = new Text(theme.fg("toolTitle", theme.bold("gna_watch_arm")), 0, 0);
722
+ return refreshWatchToolShell(state, theme, context);
723
+ },
724
+ renderResult: (result, _options, theme, context) => {
725
+ if (calmHides("tool-result")) return new Container();
726
+ const output = result.content
727
+ .filter((item) => item.type === "text")
728
+ .map((item) => item.text)
729
+ .join("\n");
730
+ if (calmPresentation.stockExportRendering) {
731
+ return new Text(theme.fg("toolOutput", output), 0, 0);
732
+ }
733
+ const state = context.state as WatchToolShellState;
734
+ state.result = output
735
+ ? new Text(theme.fg("toolOutput", output), 0, 0)
736
+ : new Container();
737
+ refreshWatchToolShell(state, theme, context);
738
+ return new Container();
739
+ },
740
+ execute: async () => {
741
+ const result = startArm(generation);
742
+ return {
743
+ content: [{ type: "text", text: result.message }],
744
+ details: result,
745
+ };
746
+ },
747
+ });
748
+
749
+ markLoaded();
750
+ }