omegon 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (160) hide show
  1. package/.gitattributes +3 -0
  2. package/AGENTS.md +16 -0
  3. package/LICENSE +15 -0
  4. package/README.md +289 -0
  5. package/bin/pi.mjs +30 -0
  6. package/extensions/00-secrets/index.ts +1126 -0
  7. package/extensions/01-auth/auth.ts +401 -0
  8. package/extensions/01-auth/index.ts +289 -0
  9. package/extensions/auto-compact.ts +42 -0
  10. package/extensions/bootstrap/deps.ts +291 -0
  11. package/extensions/bootstrap/index.ts +811 -0
  12. package/extensions/chronos/chronos.sh +487 -0
  13. package/extensions/chronos/index.ts +148 -0
  14. package/extensions/cleave/assessment.ts +754 -0
  15. package/extensions/cleave/bridge.ts +31 -0
  16. package/extensions/cleave/conflicts.ts +250 -0
  17. package/extensions/cleave/dispatcher.ts +808 -0
  18. package/extensions/cleave/guardrails.ts +426 -0
  19. package/extensions/cleave/index.ts +3121 -0
  20. package/extensions/cleave/lifecycle-emitter.ts +20 -0
  21. package/extensions/cleave/openspec.ts +811 -0
  22. package/extensions/cleave/planner.ts +260 -0
  23. package/extensions/cleave/review.ts +579 -0
  24. package/extensions/cleave/skills.ts +355 -0
  25. package/extensions/cleave/types.ts +261 -0
  26. package/extensions/cleave/workspace.ts +861 -0
  27. package/extensions/cleave/worktree.ts +243 -0
  28. package/extensions/core-renderers.ts +253 -0
  29. package/extensions/dashboard/context-gauge.ts +58 -0
  30. package/extensions/dashboard/file-watch.ts +14 -0
  31. package/extensions/dashboard/footer.ts +1145 -0
  32. package/extensions/dashboard/git.ts +185 -0
  33. package/extensions/dashboard/index.ts +478 -0
  34. package/extensions/dashboard/memory-audit.ts +34 -0
  35. package/extensions/dashboard/overlay-data.ts +705 -0
  36. package/extensions/dashboard/overlay.ts +365 -0
  37. package/extensions/dashboard/render-utils.ts +54 -0
  38. package/extensions/dashboard/types.ts +191 -0
  39. package/extensions/dashboard/uri-helper.ts +45 -0
  40. package/extensions/debug.ts +69 -0
  41. package/extensions/defaults.ts +282 -0
  42. package/extensions/design-tree/dashboard-state.ts +161 -0
  43. package/extensions/design-tree/design-card.ts +362 -0
  44. package/extensions/design-tree/index.ts +2130 -0
  45. package/extensions/design-tree/lifecycle-emitter.ts +41 -0
  46. package/extensions/design-tree/tree.ts +1607 -0
  47. package/extensions/design-tree/types.ts +163 -0
  48. package/extensions/distill.ts +127 -0
  49. package/extensions/effort/index.ts +395 -0
  50. package/extensions/effort/tiers.ts +146 -0
  51. package/extensions/effort/types.ts +105 -0
  52. package/extensions/lib/git-state.ts +227 -0
  53. package/extensions/lib/local-models.ts +157 -0
  54. package/extensions/lib/model-preferences.ts +51 -0
  55. package/extensions/lib/model-routing.ts +720 -0
  56. package/extensions/lib/operator-fallback.ts +205 -0
  57. package/extensions/lib/operator-profile.ts +360 -0
  58. package/extensions/lib/slash-command-bridge.ts +253 -0
  59. package/extensions/lib/typebox-helpers.ts +16 -0
  60. package/extensions/local-inference/index.ts +727 -0
  61. package/extensions/mcp-bridge/README.md +220 -0
  62. package/extensions/mcp-bridge/index.ts +951 -0
  63. package/extensions/mcp-bridge/lib.ts +365 -0
  64. package/extensions/mcp-bridge/mcp.json +3 -0
  65. package/extensions/mcp-bridge/package.json +11 -0
  66. package/extensions/model-budget.ts +752 -0
  67. package/extensions/offline-driver.ts +403 -0
  68. package/extensions/openspec/archive-gate.ts +164 -0
  69. package/extensions/openspec/branch-cleanup.ts +64 -0
  70. package/extensions/openspec/dashboard-state.ts +50 -0
  71. package/extensions/openspec/index.ts +1917 -0
  72. package/extensions/openspec/lifecycle-emitter.ts +65 -0
  73. package/extensions/openspec/lifecycle-files.ts +70 -0
  74. package/extensions/openspec/lifecycle.ts +50 -0
  75. package/extensions/openspec/reconcile.ts +187 -0
  76. package/extensions/openspec/spec.ts +1385 -0
  77. package/extensions/openspec/types.ts +98 -0
  78. package/extensions/project-memory/DESIGN-global-mind.md +198 -0
  79. package/extensions/project-memory/README.md +202 -0
  80. package/extensions/project-memory/api-types.ts +382 -0
  81. package/extensions/project-memory/compaction-policy.ts +29 -0
  82. package/extensions/project-memory/core.ts +164 -0
  83. package/extensions/project-memory/embeddings.ts +230 -0
  84. package/extensions/project-memory/extraction-v2.ts +861 -0
  85. package/extensions/project-memory/factstore.ts +2177 -0
  86. package/extensions/project-memory/index.ts +3459 -0
  87. package/extensions/project-memory/injection-metrics.ts +91 -0
  88. package/extensions/project-memory/jsonl-io.ts +12 -0
  89. package/extensions/project-memory/lifecycle.ts +331 -0
  90. package/extensions/project-memory/migration.ts +293 -0
  91. package/extensions/project-memory/package.json +9 -0
  92. package/extensions/project-memory/sci-renderers.ts +7 -0
  93. package/extensions/project-memory/template.ts +103 -0
  94. package/extensions/project-memory/triggers.ts +52 -0
  95. package/extensions/project-memory/types.ts +102 -0
  96. package/extensions/render/composition/fonts/Inter-Bold.ttf +0 -0
  97. package/extensions/render/composition/fonts/Inter-Regular.ttf +0 -0
  98. package/extensions/render/composition/fonts/Tomorrow-Bold.ttf +0 -0
  99. package/extensions/render/composition/fonts/Tomorrow-Regular.ttf +0 -0
  100. package/extensions/render/composition/package-lock.json +534 -0
  101. package/extensions/render/composition/package.json +22 -0
  102. package/extensions/render/composition/render.mjs +246 -0
  103. package/extensions/render/composition/test-comp.tsx +87 -0
  104. package/extensions/render/composition/types.ts +24 -0
  105. package/extensions/render/excalidraw/UPSTREAM.md +81 -0
  106. package/extensions/render/excalidraw/elements.ts +764 -0
  107. package/extensions/render/excalidraw/index.ts +66 -0
  108. package/extensions/render/excalidraw/types.ts +223 -0
  109. package/extensions/render/excalidraw-renderer/pyproject.toml +8 -0
  110. package/extensions/render/excalidraw-renderer/render_excalidraw.py +182 -0
  111. package/extensions/render/excalidraw-renderer/render_template.html +59 -0
  112. package/extensions/render/index.ts +830 -0
  113. package/extensions/render/native-diagrams/index.ts +57 -0
  114. package/extensions/render/native-diagrams/motifs.ts +542 -0
  115. package/extensions/render/native-diagrams/raster.ts +8 -0
  116. package/extensions/render/native-diagrams/scene.ts +75 -0
  117. package/extensions/render/native-diagrams/spec.ts +204 -0
  118. package/extensions/render/native-diagrams/svg.ts +116 -0
  119. package/extensions/sci-ui.ts +304 -0
  120. package/extensions/session-log.ts +174 -0
  121. package/extensions/shared-state.ts +146 -0
  122. package/extensions/spinner-verbs.ts +91 -0
  123. package/extensions/style.ts +281 -0
  124. package/extensions/terminal-title.ts +191 -0
  125. package/extensions/tool-profile/index.ts +291 -0
  126. package/extensions/tool-profile/profiles.ts +290 -0
  127. package/extensions/types.d.ts +9 -0
  128. package/extensions/vault/index.ts +185 -0
  129. package/extensions/version-check.ts +90 -0
  130. package/extensions/view/index.ts +859 -0
  131. package/extensions/view/uri-resolver.ts +148 -0
  132. package/extensions/web-search/index.ts +182 -0
  133. package/extensions/web-search/providers.ts +121 -0
  134. package/extensions/web-ui/index.ts +110 -0
  135. package/extensions/web-ui/server.ts +265 -0
  136. package/extensions/web-ui/state.ts +462 -0
  137. package/extensions/web-ui/static/index.html +145 -0
  138. package/extensions/web-ui/types.ts +284 -0
  139. package/package.json +76 -0
  140. package/prompts/init.md +75 -0
  141. package/prompts/new-repo.md +54 -0
  142. package/prompts/oci-login.md +56 -0
  143. package/prompts/status.md +50 -0
  144. package/settings.json +4 -0
  145. package/skills/cleave/SKILL.md +218 -0
  146. package/skills/git/SKILL.md +209 -0
  147. package/skills/git/_reference/ci-validation.md +204 -0
  148. package/skills/oci/SKILL.md +338 -0
  149. package/skills/openspec/SKILL.md +346 -0
  150. package/skills/pi-extensions/SKILL.md +191 -0
  151. package/skills/pi-tui/SKILL.md +517 -0
  152. package/skills/python/SKILL.md +189 -0
  153. package/skills/rust/SKILL.md +268 -0
  154. package/skills/security/SKILL.md +206 -0
  155. package/skills/style/SKILL.md +264 -0
  156. package/skills/typescript/SKILL.md +225 -0
  157. package/skills/vault/SKILL.md +102 -0
  158. package/themes/alpharius-legacy.json +85 -0
  159. package/themes/alpharius.conf +59 -0
  160. package/themes/alpharius.json +88 -0
@@ -0,0 +1,811 @@
1
+ /**
2
+ * bootstrap — First-time setup and dependency management for Omegon.
3
+ *
4
+ * On first session start after install, presents a friendly checklist of
5
+ * external dependencies grouped by tier (core / recommended / optional).
6
+ * Offers interactive installation for missing deps and captures a safe
7
+ * operator capability profile for routing/fallback defaults.
8
+ *
9
+ * Commands:
10
+ * /bootstrap — Run interactive setup (install missing deps + profile)
11
+ * /bootstrap status — Show dependency checklist without installing
12
+ * /bootstrap install — Install all missing core + recommended deps
13
+ * /update-pi — Update pi binary to latest @cwilson613/pi-coding-agent release
14
+ * /update-pi --dry-run — Check for update without installing
15
+ *
16
+ * Guards:
17
+ * - First-run detection via ~/.pi/agent/omegon-bootstrap-done marker (checks pi-kit-bootstrap-done as legacy fallback)
18
+ * - Re-running /bootstrap is always safe (idempotent checks)
19
+ * - Never auto-installs anything — always asks or requires explicit command
20
+ */
21
+
22
+ import { spawn } from "node:child_process";
23
+ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
24
+ import { join } from "node:path";
25
+ import { homedir, tmpdir } from "node:os";
26
+ import type { ExtensionAPI } from "@cwilson613/pi-coding-agent";
27
+ import { checkAllProviders, type AuthResult } from "../01-auth/auth.ts";
28
+ import { loadPiConfig } from "../lib/model-preferences.ts";
29
+ import {
30
+ getDefaultOperatorProfile,
31
+ parseOperatorProfile as parseCapabilityProfile,
32
+ writeOperatorProfile as persistOperatorProfile,
33
+ type OperatorCapabilityProfile,
34
+ type OperatorProfileCandidate,
35
+ } from "../lib/operator-profile.ts";
36
+ import { sharedState } from "../shared-state.ts";
37
+ import { getDefaultPolicy, type ProviderRoutingPolicy } from "../lib/model-routing.ts";
38
+ import { DEPS, checkAll, formatReport, bestInstallCmd, sortByRequires, type DepStatus, type DepTier } from "./deps.ts";
39
+
40
+ const AGENT_DIR = join(homedir(), ".pi", "agent");
41
+ const MARKER_PATH = join(AGENT_DIR, "omegon-bootstrap-done");
42
+ const MARKER_PATH_LEGACY = join(AGENT_DIR, "pi-kit-bootstrap-done"); // legacy — treat as done if present
43
+ const MARKER_VERSION = "2"; // bump to re-trigger bootstrap after adding operator profile capture
44
+
45
+ export type { OperatorCapabilityProfile } from "../lib/operator-profile.ts";
46
+ export type LocalFallbackPolicy = "allow" | "ask" | "deny";
47
+
48
+ interface PiConfigWithProfile {
49
+ operatorProfile?: unknown;
50
+ [key: string]: unknown;
51
+ }
52
+
53
+ interface ProviderReadinessSummary {
54
+ ready: string[];
55
+ authAttention: string[];
56
+ missing: string[];
57
+ }
58
+
59
+ interface SetupAnswers {
60
+ primaryProvider: "anthropic" | "openai" | "no-preference";
61
+ allowCloudCrossProviderFallback: boolean;
62
+ automaticLightLocalFallback: boolean;
63
+ heavyLocalFallback: LocalFallbackPolicy;
64
+ }
65
+
66
+ interface CommandContext {
67
+ say: (msg: string) => void;
68
+ hasUI: boolean;
69
+ cwd?: string;
70
+ ui: {
71
+ notify: (msg: string, level?: string) => void;
72
+ confirm: (title: string, message: string) => Promise<boolean>;
73
+ input?: (label: string, initial?: string) => Promise<string>;
74
+ select?: (title: string, options: string[]) => Promise<string | undefined>;
75
+ };
76
+ }
77
+
78
+ function isFirstRun(): boolean {
79
+ // Check new marker first, then legacy pi-kit marker (omegon renamed from pi-kit) (migration: existing installs skip re-run)
80
+ if (existsSync(MARKER_PATH)) {
81
+ try {
82
+ const version = readFileSync(MARKER_PATH, "utf8").trim();
83
+ return version !== MARKER_VERSION;
84
+ } catch {
85
+ return true;
86
+ }
87
+ }
88
+ if (existsSync(MARKER_PATH_LEGACY)) return false;
89
+ return true;
90
+ }
91
+
92
+ function markDone(): void {
93
+ mkdirSync(AGENT_DIR, { recursive: true });
94
+ writeFileSync(MARKER_PATH, MARKER_VERSION + "\n", "utf8");
95
+ }
96
+
97
+ function reorderCandidates(
98
+ candidates: OperatorProfileCandidate[],
99
+ primaryProvider: "anthropic" | "openai" | "no-preference",
100
+ ): OperatorProfileCandidate[] {
101
+ if (primaryProvider === "no-preference") return [...candidates];
102
+ const rank = (candidate: OperatorProfileCandidate): number => {
103
+ if (candidate.provider === primaryProvider) return 0;
104
+ if (candidate.provider === "local") return 2;
105
+ return 1;
106
+ };
107
+ return [...candidates].sort((a, b) => rank(a) - rank(b));
108
+ }
109
+
110
+ function applyPreferredProviderOrder(
111
+ profile: OperatorCapabilityProfile,
112
+ primaryProvider: "anthropic" | "openai" | "no-preference",
113
+ ): void {
114
+ for (const role of ["archmagos", "magos", "adept", "servitor", "servoskull"] as const) {
115
+ profile.roles[role] = reorderCandidates(profile.roles[role], primaryProvider);
116
+ }
117
+ }
118
+
119
+ function ensureAutomaticLightLocalFallback(profile: OperatorCapabilityProfile): void {
120
+ const localSeed = profile.roles.servoskull.find((candidate) => candidate.source === "local");
121
+ if (!localSeed) return;
122
+ const servitorHasLocal = profile.roles.servitor.some((candidate) => candidate.source === "local");
123
+ if (!servitorHasLocal) {
124
+ profile.roles.servitor.push({
125
+ id: localSeed.id,
126
+ provider: localSeed.provider,
127
+ source: "local",
128
+ weight: "light",
129
+ maxThinking: "minimal",
130
+ });
131
+ }
132
+ }
133
+
134
+ export function loadOperatorProfile(root: string): OperatorCapabilityProfile | undefined {
135
+ const config = loadPiConfig(root) as PiConfigWithProfile;
136
+ const raw = config.operatorProfile;
137
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined;
138
+ if (!Object.prototype.hasOwnProperty.call(raw, "roles") && !Object.prototype.hasOwnProperty.call(raw, "fallback")) {
139
+ return undefined;
140
+ }
141
+ return parseCapabilityProfile(raw);
142
+ }
143
+
144
+ export function needsOperatorProfileSetup(root: string): boolean {
145
+ return !loadOperatorProfile(root);
146
+ }
147
+
148
+ export function summarizeProviderReadiness(results: AuthResult[]): ProviderReadinessSummary {
149
+ const summary: ProviderReadinessSummary = { ready: [], authAttention: [], missing: [] };
150
+ for (const result of results) {
151
+ if (result.provider !== "github" && result.provider !== "gitlab" && result.provider !== "aws") continue;
152
+ if (result.status === "ok") summary.ready.push(result.provider);
153
+ else if (result.status === "missing") summary.missing.push(result.provider);
154
+ else summary.authAttention.push(result.provider);
155
+ }
156
+ return summary;
157
+ }
158
+
159
+ export function synthesizeSafeDefaultProfile(readiness?: AuthResult[]): OperatorCapabilityProfile {
160
+ const summary = readiness ? summarizeProviderReadiness(readiness) : { ready: [], authAttention: [], missing: [] };
161
+ const profile = getDefaultOperatorProfile();
162
+ profile.setupComplete = false;
163
+
164
+ const primaryProvider = summary.ready.includes("github")
165
+ ? "anthropic"
166
+ : summary.ready.includes("aws") || summary.ready.includes("gitlab")
167
+ ? "openai"
168
+ : "no-preference";
169
+ applyPreferredProviderOrder(profile, primaryProvider);
170
+ profile.fallback.sameRoleCrossProvider = "allow";
171
+ profile.fallback.crossSource = "ask";
172
+ profile.fallback.heavyLocal = "ask";
173
+ profile.fallback.unknownLocalPerformance = "ask";
174
+ return profile;
175
+ }
176
+
177
+ export function buildGuidedProfile(answers: SetupAnswers): OperatorCapabilityProfile {
178
+ const profile = getDefaultOperatorProfile();
179
+ profile.setupComplete = true;
180
+ applyPreferredProviderOrder(profile, answers.primaryProvider);
181
+ profile.fallback.sameRoleCrossProvider = answers.allowCloudCrossProviderFallback ? "allow" : "ask";
182
+ profile.fallback.crossSource = answers.automaticLightLocalFallback ? "ask" : "deny";
183
+ profile.fallback.heavyLocal = answers.heavyLocalFallback;
184
+ profile.fallback.unknownLocalPerformance = "ask";
185
+ if (answers.automaticLightLocalFallback) ensureAutomaticLightLocalFallback(profile);
186
+ return profile;
187
+ }
188
+
189
+ export function saveOperatorProfile(root: string, profile: OperatorCapabilityProfile): void {
190
+ persistOperatorProfile(root, profile);
191
+ }
192
+
193
+ export function routingPolicyFromProfile(profile: OperatorCapabilityProfile | undefined): ProviderRoutingPolicy {
194
+ const policy = getDefaultPolicy();
195
+ if (!profile) return policy;
196
+
197
+ const providerOrder: Array<"anthropic" | "openai" | "local"> = [];
198
+ for (const role of ["archmagos", "magos", "adept", "servitor", "servoskull"] as const) {
199
+ for (const candidate of profile.roles[role]) {
200
+ const provider = candidate.provider === "ollama" ? "local" : candidate.provider;
201
+ if ((provider === "anthropic" || provider === "openai" || provider === "local") && !providerOrder.includes(provider)) {
202
+ providerOrder.push(provider);
203
+ }
204
+ }
205
+ }
206
+ for (const provider of ["anthropic", "openai", "local"] as const) {
207
+ if (!providerOrder.includes(provider)) providerOrder.push(provider);
208
+ }
209
+
210
+ const automaticLocalFallback = profile.roles.servitor.some((candidate) => candidate.source === "local");
211
+ const avoidProviders = new Set(policy.avoidProviders);
212
+ if (!automaticLocalFallback) avoidProviders.add("local");
213
+
214
+ return {
215
+ ...policy,
216
+ providerOrder,
217
+ avoidProviders: [...avoidProviders],
218
+ cheapCloudPreferredOverLocal: !automaticLocalFallback,
219
+ notes: profile.setupComplete
220
+ ? "routing policy sourced from operator capability profile"
221
+ : "routing policy sourced from default operator capability profile",
222
+ };
223
+ }
224
+
225
+ function formatProviderSetupSummary(results: AuthResult[]): string {
226
+ const summary = summarizeProviderReadiness(results);
227
+ const parts: string[] = [];
228
+ if (summary.ready.length > 0) parts.push(`ready: ${summary.ready.join(", ")}`);
229
+ if (summary.authAttention.length > 0) parts.push(`needs auth: ${summary.authAttention.join(", ")}`);
230
+ if (summary.missing.length > 0) parts.push(`missing CLI: ${summary.missing.join(", ")}`);
231
+ return parts.length > 0 ? parts.join(" · ") : "No cloud providers detected yet";
232
+ }
233
+
234
+ function getConfigRoot(ctx: { cwd?: string }): string {
235
+ return ctx.cwd || process.cwd();
236
+ }
237
+
238
+ async function ensureOperatorProfile(pi: ExtensionAPI, ctx: CommandContext): Promise<OperatorCapabilityProfile> {
239
+ const root = getConfigRoot(ctx);
240
+ const existing = loadOperatorProfile(root);
241
+ if (existing) return existing;
242
+
243
+ const readiness = await checkAllProviders(pi);
244
+ if (!ctx.hasUI || !ctx.ui.confirm || !ctx.ui.select) {
245
+ const fallback = synthesizeSafeDefaultProfile(readiness);
246
+ saveOperatorProfile(root, fallback);
247
+ return fallback;
248
+ }
249
+
250
+ ctx.ui.notify(`Operator capability setup — ${formatProviderSetupSummary(readiness)}`, "info");
251
+ const proceed = await ctx.ui.confirm(
252
+ "Configure operator capability profile?",
253
+ "This captures cloud/local fallback preferences so Omegon avoids unsafe automatic model switches.",
254
+ );
255
+ if (!proceed) {
256
+ const fallback = synthesizeSafeDefaultProfile(readiness);
257
+ saveOperatorProfile(root, fallback);
258
+ ctx.ui.notify("Saved a conservative default operator profile. You can rerun /bootstrap later to customize it.", "info");
259
+ return fallback;
260
+ }
261
+
262
+ const primarySelection = await ctx.ui.select(
263
+ "Preferred cloud provider for normal work:",
264
+ [
265
+ "Anthropic first",
266
+ "OpenAI first",
267
+ "No preference",
268
+ ],
269
+ );
270
+ const primaryProvider = primarySelection === "OpenAI first"
271
+ ? "openai"
272
+ : primarySelection === "No preference"
273
+ ? "no-preference"
274
+ : "anthropic";
275
+ const allowCloudCrossProviderFallback = await ctx.ui.confirm(
276
+ "Allow same-role cloud fallback?",
277
+ "If your preferred cloud provider is unavailable, may Omegon retry the same capability role with another cloud provider?",
278
+ );
279
+ const automaticLightLocalFallback = await ctx.ui.confirm(
280
+ "Allow automatic light local fallback?",
281
+ "Allow Omegon to use local models automatically for lightweight work when cloud options are unavailable?",
282
+ );
283
+ const heavyLocalSelection = await ctx.ui.select(
284
+ "Heavy local fallback policy:",
285
+ [
286
+ "Ask before heavy local fallback",
287
+ "Deny heavy local fallback",
288
+ "Allow heavy local fallback",
289
+ ],
290
+ );
291
+ const heavyLocalFallback = heavyLocalSelection === "Deny heavy local fallback"
292
+ ? "deny"
293
+ : heavyLocalSelection === "Allow heavy local fallback"
294
+ ? "allow"
295
+ : "ask";
296
+
297
+ const profile = buildGuidedProfile({
298
+ primaryProvider,
299
+ allowCloudCrossProviderFallback,
300
+ automaticLightLocalFallback,
301
+ heavyLocalFallback,
302
+ });
303
+ saveOperatorProfile(root, profile);
304
+ ctx.ui.notify("Saved operator capability profile to .pi/config.json", "info");
305
+ return profile;
306
+ }
307
+
308
+ export default function (pi: ExtensionAPI) {
309
+ // --- First-run detection on session start ---
310
+ pi.on("session_start", async (_event, ctx) => {
311
+ sharedState.routingPolicy = routingPolicyFromProfile(loadOperatorProfile(getConfigRoot(ctx)));
312
+
313
+ if (!isFirstRun()) return;
314
+ if (!ctx.hasUI) return;
315
+
316
+ const statuses = checkAll();
317
+ const missing = statuses.filter((s) => !s.available);
318
+ const needsProfile = needsOperatorProfileSetup(getConfigRoot(ctx));
319
+
320
+ if (missing.length === 0 && !needsProfile) {
321
+ markDone();
322
+ return;
323
+ }
324
+
325
+ const coreMissing = missing.filter((s) => s.dep.tier === "core");
326
+ const recMissing = missing.filter((s) => s.dep.tier === "recommended");
327
+
328
+ let msg = "Welcome to Omegon! ";
329
+ if (coreMissing.length > 0) {
330
+ msg += `${coreMissing.length} core dep${coreMissing.length > 1 ? "s" : ""} missing. `;
331
+ }
332
+ if (recMissing.length > 0) {
333
+ msg += `${recMissing.length} recommended dep${recMissing.length > 1 ? "s" : ""} missing. `;
334
+ }
335
+ if (needsProfile) {
336
+ msg += "Operator capability setup is still pending. ";
337
+ }
338
+ msg += "Run /bootstrap to set up.";
339
+
340
+ ctx.ui.notify(msg, coreMissing.length > 0 ? "warning" : "info");
341
+ });
342
+
343
+ pi.registerCommand("bootstrap", {
344
+ description: "First-time setup — check/install Omegon dependencies and capture operator fallback preferences",
345
+ handler: async (args, ctx) => {
346
+ const sub = args.trim().toLowerCase();
347
+ const cmdCtx: CommandContext = {
348
+ say: (msg: string) => ctx.ui.notify(msg, "info"),
349
+ hasUI: true,
350
+ cwd: ctx.cwd,
351
+ ui: {
352
+ notify: (msg: string, level?: string) => ctx.ui.notify(msg, (level ?? "info") as "info"),
353
+ confirm: (title: string, message: string) => ctx.ui.confirm(title, message),
354
+ input: ctx.ui.input ? async (label: string, initial?: string) => (await ctx.ui.input(label, initial)) ?? "" : undefined,
355
+ select: ctx.ui.select ? (title: string, options: string[]) => ctx.ui.select(title, options) : undefined,
356
+ },
357
+ };
358
+
359
+ if (sub === "status") {
360
+ const statuses = checkAll();
361
+ cmdCtx.say(formatReport(statuses));
362
+ const profile = loadOperatorProfile(getConfigRoot(cmdCtx));
363
+ cmdCtx.say(profile
364
+ ? `\nOperator capability profile: ${profile.setupComplete ? "configured" : "defaulted"}`
365
+ : "\nOperator capability profile: not configured");
366
+ return;
367
+ }
368
+
369
+ if (sub === "install") {
370
+ await installMissing(cmdCtx, ["core", "recommended"]);
371
+ await ensureOperatorProfile(pi, cmdCtx);
372
+ return;
373
+ }
374
+
375
+ await interactiveSetup(pi, cmdCtx);
376
+ },
377
+ });
378
+
379
+ // --- /update-pi: update the pi binary from the cwilson613 fork ---
380
+ // Pulls the latest @cwilson613/pi-coding-agent from npm and relaunches.
381
+ // Useful after a new patch is published without leaving pi.
382
+ pi.registerCommand("update-pi", {
383
+ description: "Update the pi binary to the latest @cwilson613/pi-coding-agent release",
384
+ handler: async (args, ctx) => {
385
+ const dryRun = args.trim() === "--dry-run";
386
+ const PKG = "@cwilson613/pi-coding-agent";
387
+
388
+ // Resolve the npm registry latest
389
+ ctx.ui.notify(`Checking latest version of ${PKG}…`, "info");
390
+ let latestVersion: string;
391
+ try {
392
+ latestVersion = await new Promise<string>((resolve, reject) => {
393
+ let out = "";
394
+ const child = spawn("npm", ["view", PKG, "version", "--json"], {
395
+ stdio: ["ignore", "pipe", "pipe"],
396
+ });
397
+ child.stdout.on("data", (d: Buffer) => { out += d.toString(); });
398
+ child.on("close", (code: number) => {
399
+ if (code !== 0) return reject(new Error("npm view failed"));
400
+ resolve(JSON.parse(out.trim()));
401
+ });
402
+ });
403
+ } catch {
404
+ ctx.ui.notify(`Failed to query npm registry. Are you online?`, "warning");
405
+ return;
406
+ }
407
+
408
+ // Determine installed version
409
+ let installedVersion = "unknown";
410
+ try {
411
+ installedVersion = await new Promise<string>((resolve) => {
412
+ let out = "";
413
+ const child = spawn("npm", ["list", "-g", PKG, "--json", "--depth=0"], {
414
+ stdio: ["ignore", "pipe", "pipe"],
415
+ });
416
+ child.stdout.on("data", (d: Buffer) => { out += d.toString(); });
417
+ child.on("close", () => {
418
+ try {
419
+ const data = JSON.parse(out);
420
+ resolve(data.dependencies?.[PKG]?.version ?? "unknown");
421
+ } catch {
422
+ resolve("unknown");
423
+ }
424
+ });
425
+ });
426
+ } catch { /* ignore */ }
427
+
428
+ if (installedVersion === latestVersion) {
429
+ ctx.ui.notify(`Already on latest: ${PKG}@${latestVersion} ✅`, "info");
430
+ return;
431
+ }
432
+
433
+ ctx.ui.notify(
434
+ `Update available: ${installedVersion} → ${latestVersion}\n` +
435
+ (dryRun ? "(dry run — not installing)" : "Installing…"),
436
+ "info"
437
+ );
438
+
439
+ if (dryRun) return;
440
+
441
+ const confirmed = await ctx.ui.confirm(
442
+ "Update pi binary?",
443
+ `Install ${PKG}@${latestVersion} globally via npm?\n\nThis will replace the currently running binary. Restart pi after the update completes.`
444
+ );
445
+ if (!confirmed) {
446
+ ctx.ui.notify("Update cancelled.", "info");
447
+ return;
448
+ }
449
+
450
+ await new Promise<void>((resolve, reject) => {
451
+ let stderr = "";
452
+ const child = spawn("npm", ["install", "-g", `${PKG}@${latestVersion}`], {
453
+ stdio: ["ignore", "pipe", "pipe"],
454
+ });
455
+ child.stderr.on("data", (d: Buffer) => { stderr += d.toString(); });
456
+ child.on("close", (code: number) => {
457
+ if (code !== 0) {
458
+ ctx.ui.notify(`npm install failed:\n${stderr}`, "warning");
459
+ reject(new Error("install failed"));
460
+ } else {
461
+ resolve();
462
+ }
463
+ });
464
+ }).catch(() => { /* error already notified */ return; });
465
+
466
+ ctx.ui.notify(
467
+ `✅ Updated to ${PKG}@${latestVersion}.\n` +
468
+ "Restart pi to use the new version (/exit, then pi).",
469
+ "info"
470
+ );
471
+ },
472
+ });
473
+
474
+ // --- /refresh: clear jiti transpilation cache + reload ---
475
+ // jiti's fs cache uses path-based hashing, so source changes aren't
476
+ // detected on /reload. /refresh clears the cache first.
477
+ pi.registerCommand("refresh", {
478
+ description: "Clear transpilation cache and reload extensions",
479
+ handler: async (_args, ctx) => {
480
+ const jitiCacheDir = join(tmpdir(), "jiti");
481
+ let cleared = 0;
482
+ if (existsSync(jitiCacheDir)) {
483
+ try {
484
+ const files = readdirSync(jitiCacheDir);
485
+ cleared = files.length;
486
+ rmSync(jitiCacheDir, { recursive: true, force: true });
487
+ } catch { /* best-effort */ }
488
+ }
489
+ ctx.ui.notify(cleared > 0
490
+ ? `Cleared ${cleared} cached transpilations. Reloading…`
491
+ : "No transpilation cache found. Reloading…", "info");
492
+ await ctx.reload();
493
+ },
494
+ });
495
+ }
496
+
497
+ async function interactiveSetup(pi: ExtensionAPI, ctx: CommandContext): Promise<void> {
498
+ const statuses = checkAll();
499
+ const missing = statuses.filter((s) => !s.available);
500
+
501
+ ctx.ui.notify(formatReport(statuses));
502
+
503
+ if (missing.length === 0 && !needsOperatorProfileSetup(getConfigRoot(ctx))) {
504
+ markDone();
505
+ return;
506
+ }
507
+
508
+ if (!ctx.hasUI || !ctx.ui) {
509
+ ctx.ui.notify("\nRun individual install commands above, or use `/bootstrap install` to install all core + recommended deps.");
510
+ await ensureOperatorProfile(pi, ctx);
511
+ return;
512
+ }
513
+
514
+ const coreMissing = missing.filter((s) => s.dep.tier === "core");
515
+ const recMissing = missing.filter((s) => s.dep.tier === "recommended");
516
+ const optMissing = missing.filter((s) => s.dep.tier === "optional");
517
+
518
+ if (coreMissing.length > 0) {
519
+ const names = coreMissing.map((s) => s.dep.name).join(", ");
520
+ const proceed = await ctx.ui.confirm(
521
+ "Install core dependencies?",
522
+ `${coreMissing.length} missing: ${names}`,
523
+ );
524
+ if (proceed) {
525
+ await installDeps(ctx, coreMissing);
526
+ }
527
+ }
528
+
529
+ if (recMissing.length > 0) {
530
+ const names = recMissing.map((s) => s.dep.name).join(", ");
531
+ const proceed = await ctx.ui.confirm(
532
+ "Install recommended dependencies?",
533
+ `${recMissing.length} missing: ${names}`,
534
+ );
535
+ if (proceed) {
536
+ await installDeps(ctx, recMissing);
537
+ }
538
+ }
539
+
540
+ if (optMissing.length > 0) {
541
+ ctx.ui.notify(
542
+ `\n${optMissing.length} optional dep${optMissing.length > 1 ? "s" : ""} not installed: ${optMissing.map((s) => s.dep.name).join(", ")}.\n`
543
+ + "Install individually when needed — see `/bootstrap status` for commands.",
544
+ );
545
+ }
546
+
547
+ await ensureOperatorProfile(pi, ctx);
548
+
549
+ const recheck = checkAll();
550
+ const stillMissing = recheck.filter((s) => !s.available && (s.dep.tier === "core" || s.dep.tier === "recommended"));
551
+
552
+ if (stillMissing.length === 0) {
553
+ ctx.ui.notify("\n🎉 Setup complete! All core and recommended dependencies are available.");
554
+ markDone();
555
+ } else {
556
+ ctx.ui.notify(
557
+ `\n⚠️ ${stillMissing.length} dep${stillMissing.length > 1 ? "s" : ""} still missing. `
558
+ + "Run `/bootstrap` again after installing manually.",
559
+ );
560
+ }
561
+ }
562
+
563
+ async function installMissing(ctx: CommandContext, tiers: DepTier[]): Promise<void> {
564
+ const statuses = checkAll();
565
+ const toInstall = statuses.filter(
566
+ (s) => !s.available && tiers.includes(s.dep.tier),
567
+ );
568
+
569
+ if (toInstall.length === 0) {
570
+ ctx.ui.notify("All core and recommended dependencies are already installed. ✅");
571
+ return;
572
+ }
573
+
574
+ await installDeps(ctx, toInstall);
575
+
576
+ const recheck = checkAll();
577
+ const stillMissing = recheck.filter(
578
+ (s) => !s.available && tiers.includes(s.dep.tier),
579
+ );
580
+ if (stillMissing.length === 0) {
581
+ ctx.ui.notify("\n🎉 All core and recommended dependencies installed!");
582
+ } else {
583
+ ctx.ui.notify(
584
+ `\n⚠️ ${stillMissing.length} dep${stillMissing.length > 1 ? "s" : ""} failed to install:`,
585
+ );
586
+ for (const s of stillMissing) {
587
+ const cmd = bestInstallCmd(s.dep);
588
+ ctx.ui.notify(` ❌ ${s.dep.name}: try manually → \`${cmd}\``);
589
+ }
590
+ }
591
+ }
592
+
593
+ /**
594
+ * Determine whether a command string requires a shell interpreter.
595
+ *
596
+ * Commands that contain shell operators (pipes, redirects, logical
597
+ * connectors, glob expansions, subshells, environment variable
598
+ * assignments, or quoted whitespace) cannot be safely split into
599
+ * argv tokens without a shell. Everything else can be dispatched
600
+ * directly via execve-style spawn.
601
+ */
602
+ export function requiresShell(cmd: string): boolean {
603
+ // Shell metacharacters that need sh -c interpretation.
604
+ // `#` is only a shell comment when it appears at the start of a word
605
+ // (preceded by whitespace or at string start) — inside a URL fragment
606
+ // like https://host/path#anchor it is plain data and must NOT trigger
607
+ // the shell path. All other listed chars are unambiguous metacharacters.
608
+ return /[|&;<>()$`\\!*?[\]{}~]|(^|\s)#/.test(cmd);
609
+ }
610
+
611
+ /**
612
+ * Split a simple (no-shell) command string into [executable, ...args].
613
+ *
614
+ * Only call this after confirming `requiresShell(cmd) === false`.
615
+ * Splitting is naive whitespace-based — sufficient for the dep install
616
+ * commands in deps.ts which do not use quoting.
617
+ */
618
+ export function parseCommandArgv(cmd: string): [string, ...string[]] {
619
+ const parts = cmd.trim().split(/\s+/).filter(Boolean);
620
+ if (parts.length === 0) throw new Error("Empty command");
621
+ return parts as [string, ...string[]];
622
+ }
623
+
624
+ /**
625
+ * Strip ANSI escape sequences from a string so we can display raw text
626
+ * through pi's notification system without garbled control codes.
627
+ */
628
+ function stripAnsi(str: string): string {
629
+ // Covers CSI sequences, OSC, simple escapes, and reset codes.
630
+ // eslint-disable-next-line no-control-regex
631
+ return str.replace(/\x1b\[[0-9;]*[a-zA-Z]|\x1b\][^\x07\x1b]*(\x07|\x1b\\)|\x1b[^[]/g, "");
632
+ }
633
+
634
+ /**
635
+ * Decide whether a captured output line is worth forwarding to the operator.
636
+ *
637
+ * Filters out progress-bar-only lines (filled entirely with ═ = > # etc.)
638
+ * and carriage-return-overwritten lines that cargo/rustup use for spinners.
639
+ */
640
+ function isSignificantLine(raw: string): boolean {
641
+ const s = stripAnsi(raw).trim();
642
+ if (s.length === 0) return false;
643
+ // Pure progress bar characters — not meaningful as text
644
+ if (/^[=>\-#.·⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏ ]+$/.test(s)) return false;
645
+ // Very long lines are likely binary blobs or encoded data
646
+ if (s.length > 300) return false;
647
+ return true;
648
+ }
649
+
650
+ /**
651
+ * Run a helper command asynchronously, streaming output through `onLine`.
652
+ *
653
+ * stdin is closed (no interactive prompts). stdout and stderr are both
654
+ * piped so output is captured and forwarded through pi's notification
655
+ * system rather than fighting with the TUI renderer.
656
+ *
657
+ * A heartbeat tick fires every `heartbeatMs` so the operator knows the
658
+ * process is still alive during long compilations (e.g. cargo build).
659
+ *
660
+ * The install commands come exclusively from the static `deps.ts`
661
+ * registry and are never influenced by operator input.
662
+ *
663
+ * Returns the process exit code (124 = timeout).
664
+ */
665
+ export function runAsync(
666
+ cmd: string,
667
+ onLine: (line: string) => void,
668
+ timeoutMs: number = 600_000,
669
+ heartbeatMs: number = 15_000,
670
+ ): Promise<number> {
671
+ return new Promise((resolve) => {
672
+ const env = {
673
+ ...process.env,
674
+ // Homebrew / generic non-interactive suppression
675
+ NONINTERACTIVE: "1",
676
+ HOMEBREW_NO_AUTO_UPDATE: "1",
677
+ // Rustup: skip the interactive "1) Proceed / 2) Customise / 3) Cancel"
678
+ // prompt entirely. Belt-and-suspenders alongside the -y flag in the
679
+ // install command.
680
+ RUSTUP_INIT_SKIP_PATH_CHECK: "yes",
681
+ };
682
+
683
+ let child;
684
+ if (requiresShell(cmd)) {
685
+ child = spawn("sh", ["-c", cmd], { stdio: ["ignore", "pipe", "pipe"], env });
686
+ } else {
687
+ const [exe, ...args] = parseCommandArgv(cmd);
688
+ child = spawn(exe, args, { stdio: ["ignore", "pipe", "pipe"], env });
689
+ }
690
+
691
+ let settled = false;
692
+ let sigkillTimer: ReturnType<typeof setTimeout> | undefined;
693
+ let elapsedSec = 0;
694
+
695
+ const settle = (code: number) => {
696
+ if (settled) return;
697
+ settled = true;
698
+ clearTimeout(timer);
699
+ clearInterval(heartbeat);
700
+ clearTimeout(sigkillTimer);
701
+ resolve(code);
702
+ };
703
+
704
+ // Heartbeat — fires every heartbeatMs while the process is running.
705
+ const heartbeat = setInterval(() => {
706
+ elapsedSec += heartbeatMs / 1000;
707
+ onLine(` ⏳ still running… (${elapsedSec}s)`);
708
+ }, heartbeatMs);
709
+
710
+ // Forward captured lines from both streams.
711
+ const attachStream = (stream: NodeJS.ReadableStream | null) => {
712
+ if (!stream) return;
713
+ let buf = "";
714
+ stream.on("data", (chunk: Buffer) => {
715
+ // Strip carriage returns so spinner overwrites don't stack.
716
+ buf += chunk.toString().replace(/\r/g, "\n");
717
+ const parts = buf.split("\n");
718
+ buf = parts.pop() ?? "";
719
+ for (const part of parts) {
720
+ if (isSignificantLine(part)) onLine(" " + stripAnsi(part).trim());
721
+ }
722
+ });
723
+ stream.on("end", () => {
724
+ if (buf && isSignificantLine(buf)) onLine(" " + stripAnsi(buf).trim());
725
+ });
726
+ };
727
+ attachStream(child.stdout);
728
+ attachStream(child.stderr);
729
+
730
+ const timer = setTimeout(() => {
731
+ child.kill("SIGTERM");
732
+ sigkillTimer = setTimeout(() => {
733
+ try { child.kill("SIGKILL"); } catch { /* already exited */ }
734
+ }, 5_000);
735
+ settle(124);
736
+ }, timeoutMs);
737
+
738
+ child.on("exit", (code) => settle(code ?? 1));
739
+ child.on("error", () => settle(1));
740
+ });
741
+ }
742
+
743
+ /**
744
+ * After rustup installs, the cargo binaries land in ~/.cargo/bin which is
745
+ * NOT in the current process's PATH (only added to future shells via
746
+ * .profile/.bashrc). Source it now so subsequent deps (e.g. mdserve) can
747
+ * find cargo without the operator having to open a new terminal.
748
+ */
749
+ function patchPathForCargo(): void {
750
+ const cargoBin = join(homedir(), ".cargo", "bin");
751
+ const current = process.env.PATH ?? "";
752
+ if (!current.split(":").includes(cargoBin)) {
753
+ process.env.PATH = `${cargoBin}:${current}`;
754
+ }
755
+ }
756
+
757
+ async function installDeps(ctx: CommandContext, deps: DepStatus[]): Promise<void> {
758
+ // Sort so prerequisites come first (e.g., cargo before mdserve)
759
+ const sorted = sortByRequires(deps);
760
+ const total = sorted.length;
761
+
762
+ for (let i = 0; i < sorted.length; i++) {
763
+ const { dep } = sorted[i];
764
+ const step = `[${i + 1}/${total}]`;
765
+
766
+ // Check prerequisites — re-verify availability live (not from stale array)
767
+ if (dep.requires?.length) {
768
+ const unmet = dep.requires.filter((reqId) => {
769
+ const reqDep = DEPS.find((d) => d.id === reqId);
770
+ return reqDep ? !reqDep.check() : false;
771
+ });
772
+ if (unmet.length > 0) {
773
+ ctx.ui.notify(`\n${step} ⚠️ Skipping ${dep.name} — requires ${unmet.join(", ")} (not yet available)`);
774
+ continue;
775
+ }
776
+ }
777
+
778
+ const cmd = bestInstallCmd(dep);
779
+ if (!cmd) {
780
+ ctx.ui.notify(`\n${step} ⚠️ No install command available for ${dep.name} on this platform`);
781
+ continue;
782
+ }
783
+
784
+ ctx.ui.notify(`\n${step} 📦 Installing ${dep.name}…`);
785
+ ctx.ui.notify(` → \`${cmd}\``);
786
+
787
+ const exitCode = await runAsync(
788
+ cmd,
789
+ (line) => ctx.ui.notify(line),
790
+ );
791
+
792
+ // Rustup installs to ~/.cargo/bin — patch PATH immediately so the rest
793
+ // of the install sequence (e.g. mdserve) can find cargo.
794
+ if (dep.id === "cargo" && exitCode === 0) {
795
+ patchPathForCargo();
796
+ }
797
+
798
+ if (exitCode === 0 && dep.check()) {
799
+ ctx.ui.notify(`${step} ✅ ${dep.name} installed successfully`);
800
+ } else if (exitCode === 124) {
801
+ ctx.ui.notify(`${step} ❌ ${dep.name} install timed out (10 min limit)`);
802
+ } else if (exitCode === 0) {
803
+ ctx.ui.notify(`${step} ⚠️ Command succeeded but ${dep.name} not found on PATH — you may need to open a new shell.`);
804
+ } else {
805
+ ctx.ui.notify(`${step} ❌ Failed to install ${dep.name} (exit ${exitCode})`);
806
+ const hints = dep.install.filter((o) => o.cmd !== cmd);
807
+ if (hints.length > 0) ctx.ui.notify(` Alternative: \`${hints[0]!.cmd}\``);
808
+ if (dep.url) ctx.ui.notify(` Manual install: ${dep.url}`);
809
+ }
810
+ }
811
+ }