omegon 0.6.10 → 0.6.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/omegon.mjs CHANGED
@@ -52,10 +52,14 @@ function injectBundledResourceArgs(argv) {
52
52
  }
53
53
  };
54
54
 
55
+ // Omegon is the sole authority for bundled resources.
56
+ // Suppress pi's auto-discovery of skills, prompts, and themes (which scans
57
+ // ~/.pi/agent/*, installed packages, and project .pi/ dirs) so only our
58
+ // manifest-declared resources load. The --no-* flags disable discovery
59
+ // but still allow CLI-injected paths (our --extension manifest).
60
+ // Extensions are NOT suppressed — project-local .pi/extensions/ should still work.
61
+ injected.push("--no-skills", "--no-prompt-templates", "--no-themes");
55
62
  pushPair("--extension", omegonRoot);
56
- pushPair("--skill", join(omegonRoot, "skills"));
57
- pushPair("--prompt-template", join(omegonRoot, "prompts"));
58
- pushPair("--theme", join(omegonRoot, "themes"));
59
63
  return injected;
60
64
  }
61
65
 
@@ -46,46 +46,9 @@ function hasCmd(cmd: string): boolean {
46
46
  }
47
47
  }
48
48
 
49
- /**
50
- * Detect immutable/atomic Linux distros (Bazzite, Silverblue, Kinoite, etc.)
51
- * where dnf/apt are unavailable or aliased to guides. These distros typically
52
- * use Homebrew (Linuxbrew) or Flatpak for user-space packages.
53
- */
54
- function isImmutableLinux(): boolean {
55
- if (process.platform !== "linux") return false;
56
- try {
57
- const osRelease = execSync("cat /etc/os-release 2>/dev/null", { encoding: "utf-8" });
58
- // Bazzite, Silverblue, Kinoite, Aurora, Bluefin — all Fedora Atomic variants
59
- return /VARIANT_ID=.*(silverblue|kinoite|bazzite|aurora|bluefin|atomic)/i.test(osRelease)
60
- || /ostree/i.test(osRelease);
61
- } catch {
62
- return false;
63
- }
64
- }
65
-
66
- /** Cached immutable Linux detection */
67
- const _isImmutable = isImmutableLinux();
68
-
69
49
  /** Get the best install command for the current platform */
70
50
  export function bestInstallCmd(dep: Dep): string | undefined {
71
51
  const plat = process.platform === "darwin" ? "darwin" : "linux";
72
-
73
- // On immutable Linux (Bazzite, Silverblue, etc.), dnf/apt are unavailable
74
- // or aliased to documentation guides. Prefer brew commands.
75
- // On regular Linux, prefer non-brew (apt/dnf) unless brew is the only option.
76
- const hasBrew = hasCmd("brew");
77
- if (plat === "linux" && (_isImmutable || !hasBrew)) {
78
- // Immutable: must use brew (skip apt/dnf). Regular without brew: skip brew commands.
79
- const candidates = dep.install.filter((o) => o.platform === plat || o.platform === "any");
80
- if (_isImmutable && hasBrew) {
81
- const brewCmd = candidates.find((o) => o.cmd.startsWith("brew "));
82
- if (brewCmd) return brewCmd.cmd;
83
- } else if (!_isImmutable) {
84
- const nonBrew = candidates.find((o) => !o.cmd.startsWith("brew "));
85
- if (nonBrew) return nonBrew.cmd;
86
- }
87
- }
88
-
89
52
  return (
90
53
  dep.install.find((o) => o.platform === plat)?.cmd ??
91
54
  dep.install.find((o) => o.platform === "any")?.cmd ??
@@ -108,6 +71,18 @@ export function installHints(dep: Dep): string[] {
108
71
  */
109
72
  export const DEPS: Dep[] = [
110
73
  // --- Core: most users want these ---
74
+ {
75
+ id: "nix",
76
+ name: "Nix",
77
+ purpose: "Universal package manager — installs all other dependencies on any OS",
78
+ usedBy: ["bootstrap"],
79
+ tier: "core",
80
+ check: () => hasCmd("nix"),
81
+ install: [
82
+ { platform: "any", cmd: "curl --proto '=https' --tlsv1.2 -sSf -L https://install.determinate.systems/nix | sh -s -- install" },
83
+ ],
84
+ url: "https://zero-to-nix.com",
85
+ },
111
86
  {
112
87
  id: "ollama",
113
88
  name: "Ollama",
@@ -115,9 +90,9 @@ export const DEPS: Dep[] = [
115
90
  usedBy: ["local-inference", "project-memory", "cleave", "offline-driver"],
116
91
  tier: "core",
117
92
  check: () => hasCmd("ollama"),
93
+ requires: ["nix"],
118
94
  install: [
119
- { platform: "darwin", cmd: "brew install ollama" },
120
- { platform: "linux", cmd: "curl -fsSL https://ollama.com/install.sh | sh" },
95
+ { platform: "any", cmd: "nix profile install nixpkgs#ollama" },
121
96
  ],
122
97
  url: "https://ollama.com",
123
98
  },
@@ -128,9 +103,9 @@ export const DEPS: Dep[] = [
128
103
  usedBy: ["render", "view"],
129
104
  tier: "core",
130
105
  check: () => hasCmd("d2"),
106
+ requires: ["nix"],
131
107
  install: [
132
- { platform: "darwin", cmd: "brew install d2" },
133
- { platform: "linux", cmd: "curl -fsSL https://d2lang.com/install.sh | sh" },
108
+ { platform: "any", cmd: "nix profile install nixpkgs#d2" },
134
109
  ],
135
110
  url: "https://d2lang.com",
136
111
  },
@@ -143,10 +118,9 @@ export const DEPS: Dep[] = [
143
118
  usedBy: ["01-auth"],
144
119
  tier: "recommended",
145
120
  check: () => hasCmd("gh"),
121
+ requires: ["nix"],
146
122
  install: [
147
- { platform: "darwin", cmd: "brew install gh" },
148
- { platform: "linux", cmd: "brew install gh" },
149
- { platform: "linux", cmd: "sudo apt install gh || sudo dnf install gh" },
123
+ { platform: "any", cmd: "nix profile install nixpkgs#gh" },
150
124
  ],
151
125
  url: "https://cli.github.com",
152
126
  },
@@ -157,9 +131,9 @@ export const DEPS: Dep[] = [
157
131
  usedBy: ["view"],
158
132
  tier: "recommended",
159
133
  check: () => hasCmd("pandoc"),
134
+ requires: ["nix"],
160
135
  install: [
161
- { platform: "darwin", cmd: "brew install pandoc" },
162
- { platform: "linux", cmd: "sudo apt install pandoc || sudo dnf install pandoc" },
136
+ { platform: "any", cmd: "nix profile install nixpkgs#pandoc" },
163
137
  ],
164
138
  url: "https://pandoc.org",
165
139
  },
@@ -171,9 +145,7 @@ export const DEPS: Dep[] = [
171
145
  tier: "recommended",
172
146
  check: () => hasCmd("cargo"),
173
147
  install: [
174
- // -s -- -y passes -y to rustup-init, suppressing the interactive
175
- // "1) Proceed / 2) Customise / 3) Cancel" prompt that otherwise hangs.
176
- { platform: "any", cmd: "curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y" },
148
+ { platform: "any", cmd: "nix profile install nixpkgs#rustup && rustup default stable" },
177
149
  ],
178
150
  url: "https://rustup.rs",
179
151
  },
@@ -199,10 +171,9 @@ export const DEPS: Dep[] = [
199
171
  usedBy: ["view"],
200
172
  tier: "optional",
201
173
  check: () => hasCmd("rsvg-convert"),
174
+ requires: ["nix"],
202
175
  install: [
203
- { platform: "darwin", cmd: "brew install librsvg" },
204
- { platform: "linux", cmd: "brew install librsvg" },
205
- { platform: "linux", cmd: "sudo apt install librsvg2-bin" },
176
+ { platform: "any", cmd: "nix profile install nixpkgs#librsvg" },
206
177
  ],
207
178
  },
208
179
  {
@@ -212,10 +183,9 @@ export const DEPS: Dep[] = [
212
183
  usedBy: ["view"],
213
184
  tier: "optional",
214
185
  check: () => hasCmd("pdftoppm"),
186
+ requires: ["nix"],
215
187
  install: [
216
- { platform: "darwin", cmd: "brew install poppler" },
217
- { platform: "linux", cmd: "brew install poppler" },
218
- { platform: "linux", cmd: "sudo apt install poppler-utils" },
188
+ { platform: "any", cmd: "nix profile install nixpkgs#poppler_utils" },
219
189
  ],
220
190
  },
221
191
  {
@@ -225,9 +195,9 @@ export const DEPS: Dep[] = [
225
195
  usedBy: ["render"],
226
196
  tier: "optional",
227
197
  check: () => hasCmd("uv"),
198
+ requires: ["nix"],
228
199
  install: [
229
- { platform: "darwin", cmd: "brew install uv" },
230
- { platform: "any", cmd: "curl -LsSf https://astral.sh/uv/install.sh | sh" },
200
+ { platform: "any", cmd: "nix profile install nixpkgs#uv" },
231
201
  ],
232
202
  url: "https://docs.astral.sh/uv/",
233
203
  },
@@ -238,10 +208,9 @@ export const DEPS: Dep[] = [
238
208
  usedBy: ["01-auth"],
239
209
  tier: "optional",
240
210
  check: () => hasCmd("aws"),
211
+ requires: ["nix"],
241
212
  install: [
242
- { platform: "darwin", cmd: "brew install awscli" },
243
- { platform: "linux", cmd: "brew install awscli" },
244
- { platform: "linux", cmd: "sudo apt install awscli" },
213
+ { platform: "any", cmd: "nix profile install nixpkgs#awscli2" },
245
214
  ],
246
215
  },
247
216
  {
@@ -251,10 +220,9 @@ export const DEPS: Dep[] = [
251
220
  usedBy: ["01-auth"],
252
221
  tier: "optional",
253
222
  check: () => hasCmd("kubectl"),
223
+ requires: ["nix"],
254
224
  install: [
255
- { platform: "darwin", cmd: "brew install kubectl" },
256
- { platform: "linux", cmd: "brew install kubectl" },
257
- { platform: "linux", cmd: "sudo apt install kubectl" },
225
+ { platform: "any", cmd: "nix profile install nixpkgs#kubectl" },
258
226
  ],
259
227
  },
260
228
  ];
@@ -269,13 +237,40 @@ export function checkAll(): DepStatus[] {
269
237
  }));
270
238
  }
271
239
 
240
+ /**
241
+ * Detect whether the terminal supports Unicode emoji rendering.
242
+ *
243
+ * Returns true for modern terminals (Windows Terminal, VS Code, xterm-256color,
244
+ * iTerm2, etc.) and false for legacy consoles (Windows conhost.exe) where emoji
245
+ * render as blank boxes. Errs on the side of ASCII when uncertain.
246
+ */
247
+ function supportsEmoji(): boolean {
248
+ // Windows Terminal sets WT_SESSION; conhost.exe does not
249
+ if (process.env["WT_SESSION"]) return true;
250
+ // VS Code integrated terminal
251
+ if (process.env["TERM_PROGRAM"] === "vscode") return true;
252
+ // iTerm2, Hyper, and other macOS/Linux terminals advertising 256-color
253
+ if (process.env["TERM_PROGRAM"] === "iTerm.app") return true;
254
+ // xterm-256color and similar modern TERM values
255
+ const term = process.env["TERM"] ?? "";
256
+ if (term.includes("256color") || term === "xterm-kitty") return true;
257
+ // COLORTERM=truecolor or 24bit signals a modern terminal
258
+ const colorterm = process.env["COLORTERM"] ?? "";
259
+ if (colorterm === "truecolor" || colorterm === "24bit") return true;
260
+ // CI environments typically render emoji correctly
261
+ if (process.env["CI"]) return true;
262
+ // Non-Windows: default to emoji; on Windows without the above signals, use ASCII
263
+ return process.platform !== "win32";
264
+ }
265
+
272
266
  /** Format a single dep status as a line, with install hint if missing */
273
267
  function formatStatus(s: DepStatus): string {
274
- const icon = s.available ? "✅" : "❌";
268
+ const emoji = supportsEmoji();
269
+ const icon = s.available ? (emoji ? "✅" : "[ok]") : (emoji ? "❌" : "[x]");
275
270
  let line = `${icon} ${s.dep.name} — ${s.dep.purpose}`;
276
271
  if (!s.available) {
277
272
  const cmd = bestInstallCmd(s.dep);
278
- if (cmd) line += `\n → \`${cmd}\``;
273
+ if (cmd) line += `\n ${emoji ? "" : "->"} \`${cmd}\``;
279
274
  }
280
275
  return line;
281
276
  }
@@ -303,10 +298,11 @@ export function formatReport(statuses: DepStatus[]): string {
303
298
  }
304
299
 
305
300
  const missing = statuses.filter((s) => !s.available);
301
+ const emoji = supportsEmoji();
306
302
  if (missing.length === 0) {
307
- lines.push("🎉 All dependencies are available!");
303
+ lines.push(emoji ? "🎉 All dependencies are available!" : "[ok] All dependencies are available!");
308
304
  } else {
309
- lines.push(`**${missing.length} missing** — run \`/bootstrap\` to install interactively.`);
305
+ lines.push(`${emoji ? "⚠️ " : "[!] "}**${missing.length} missing** — run \`/bootstrap\` to install interactively.`);
310
306
  }
311
307
 
312
308
  return lines.join("\n");
@@ -25,6 +25,7 @@ import { dirname, join } from "node:path";
25
25
  import { fileURLToPath } from "node:url";
26
26
  import { homedir, tmpdir } from "node:os";
27
27
  import type { ExtensionAPI } from "@styrene-lab/pi-coding-agent";
28
+ import { resolveOmegonSubprocess } from "../lib/omegon-subprocess.ts";
28
29
  import { checkAllProviders, type AuthResult } from "../01-auth/auth.ts";
29
30
  import { loadPiConfig } from "../lib/model-preferences.ts";
30
31
  import {
@@ -362,11 +363,15 @@ export default function (pi: ExtensionAPI) {
362
363
 
363
364
  if (sub === "status") {
364
365
  const statuses = checkAll();
365
- cmdCtx.say(formatReport(statuses));
366
366
  const profile = loadOperatorProfile(getConfigRoot(cmdCtx));
367
- cmdCtx.say(profile
367
+ const profileLine = profile
368
368
  ? `\nOperator capability profile: ${profile.setupComplete ? "configured" : "defaulted"}`
369
- : "\nOperator capability profile: not configured");
369
+ : "\nOperator capability profile: not configured";
370
+ // Merge into a single say() call — the pi TUI showStatus() deduplication
371
+ // pattern replaces the previous notification when two consecutive say()
372
+ // calls are made synchronously, so splitting these would silently discard
373
+ // the dependency report.
374
+ cmdCtx.say(formatReport(statuses) + profileLine);
370
375
  return;
371
376
  }
372
377
 
@@ -409,10 +414,48 @@ export default function (pi: ExtensionAPI) {
409
414
  await ctx.reload();
410
415
  },
411
416
  });
417
+
418
+ // --- /restart: full process restart ---
419
+ pi.registerCommand("restart", {
420
+ description: "Restart Omegon (clears cache, spawns fresh process)",
421
+ handler: async (_args, ctx) => {
422
+ clearJitiCache(ctx);
423
+ ctx.ui.notify("Restarting Omegon…", "info");
424
+ await new Promise((r) => setTimeout(r, 500));
425
+ restartOmegon();
426
+ },
427
+ });
412
428
  }
413
429
 
414
430
  // ── /update helpers ──────────────────────────────────────────────────────
415
431
 
432
+ /**
433
+ * Replace the current Omegon process with a fresh instance.
434
+ *
435
+ * Spawns a new detached Omegon process with inherited stdio, then exits
436
+ * the current process. The user sees the terminal briefly reset and the
437
+ * new session starts automatically — no manual re-launch needed.
438
+ */
439
+ function restartOmegon(): never {
440
+ const { command, argvPrefix } = resolveOmegonSubprocess();
441
+ // Pass through any user-facing args from the original invocation
442
+ // (skip argv[0]=node, argv[1]=omegon.mjs which argvPrefix covers)
443
+ const userArgs = process.argv.slice(2).filter(a =>
444
+ // Strip injected resource flags — the new process injects its own
445
+ !a.startsWith("--extensions-dir=") &&
446
+ !a.startsWith("--themes-dir=") &&
447
+ !a.startsWith("--skills-dir=") &&
448
+ !a.startsWith("--prompts-dir=")
449
+ );
450
+ const child = spawn(command, [...argvPrefix, ...userArgs], {
451
+ stdio: "inherit",
452
+ detached: true,
453
+ env: process.env,
454
+ });
455
+ child.unref();
456
+ process.exit(0);
457
+ }
458
+
416
459
  /** Run a command, collect stdout+stderr, resolve with exit code. */
417
460
  function run(
418
461
  cmd: string, args: string[], opts?: { cwd?: string },
@@ -609,11 +652,15 @@ async function updateDevMode(
609
652
  }
610
653
  steps.push(formatVerification(verification));
611
654
 
612
- // ── Step 7: clear cache + explicit restart handoff ───────────────
655
+ // ── Step 7: clear cache + restart ────────────────────────────────
613
656
  const cleared = clearJitiCache(ctx);
614
657
  if (cleared > 0) steps.push(`✓ cleared ${cleared} cached transpilations`);
615
- steps.push("✓ update complete — restart Omegon now (/exit, then `omegon`) to load the rebuilt runtime");
658
+ steps.push("✓ update complete — restarting Omegon");
616
659
  ctx.ui.notify(steps.join("\n"), "info");
660
+
661
+ // Brief pause so the user sees the summary before the terminal resets
662
+ await new Promise((r) => setTimeout(r, 1500));
663
+ restartOmegon();
617
664
  }
618
665
 
619
666
  /** Installed mode: npm install -g omegon@latest → verify → cache clear → restart handoff. */
@@ -687,9 +734,12 @@ async function updateInstalledMode(
687
734
  `✅ Updated to ${PKG}@${latestVersion}.` +
688
735
  `\n${formatVerification(verification)}` +
689
736
  (cleared > 0 ? `\nCleared ${cleared} cached transpilations.` : "") +
690
- "\nRestart Omegon to use the new version (/exit, then omegon).",
737
+ "\nRestarting Omegon",
691
738
  "info"
692
739
  );
740
+
741
+ await new Promise((r) => setTimeout(r, 1500));
742
+ restartOmegon();
693
743
  }
694
744
 
695
745
  async function interactiveSetup(pi: ExtensionAPI, ctx: CommandContext): Promise<void> {
@@ -973,6 +1023,21 @@ function patchPathForCargo(): void {
973
1023
  }
974
1024
  }
975
1025
 
1026
+ /** After Determinate Nix install, add nix to PATH so subsequent installs work. */
1027
+ function patchPathForNix(): void {
1028
+ const nixPaths = [
1029
+ "/nix/var/nix/profiles/default/bin",
1030
+ join(homedir(), ".nix-profile", "bin"),
1031
+ ];
1032
+ const current = process.env.PATH ?? "";
1033
+ const parts = current.split(":");
1034
+ for (const nixBin of nixPaths) {
1035
+ if (existsSync(nixBin) && !parts.includes(nixBin)) {
1036
+ process.env.PATH = `${nixBin}:${process.env.PATH}`;
1037
+ }
1038
+ }
1039
+ }
1040
+
976
1041
  async function installDeps(ctx: CommandContext, deps: DepStatus[]): Promise<void> {
977
1042
  // Sort so prerequisites come first (e.g., cargo before mdserve)
978
1043
  const sorted = sortByRequires(deps);
@@ -1008,8 +1073,11 @@ async function installDeps(ctx: CommandContext, deps: DepStatus[]): Promise<void
1008
1073
  (line) => ctx.ui.notify(line),
1009
1074
  );
1010
1075
 
1011
- // Rustup installs to ~/.cargo/bin patch PATH immediately so the rest
1012
- // of the install sequence (e.g. mdserve) can find cargo.
1076
+ // Patch PATH immediately after installing bootstrapping deps so the rest
1077
+ // of the install sequence can find them without a new shell.
1078
+ if (dep.id === "nix" && exitCode === 0) {
1079
+ patchPathForNix();
1080
+ }
1013
1081
  if (dep.id === "cargo" && exitCode === 0) {
1014
1082
  patchPathForCargo();
1015
1083
  }
@@ -20,7 +20,7 @@ import { truncateTail, DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize } from "
20
20
  import { Text } from "@styrene-lab/pi-tui";
21
21
  import { Type } from "@sinclair/typebox";
22
22
  import { spawn, execFile } from "node:child_process";
23
- import { registerCleaveProc, deregisterCleaveProc, killCleaveProc, killAllCleaveSubprocesses } from "./subprocess-tracker.ts";
23
+ import { registerCleaveProc, deregisterCleaveProc, killCleaveProc, killAllCleaveSubprocesses, cleanupOrphanedProcesses } from "./subprocess-tracker.ts";
24
24
  import * as fs from "node:fs";
25
25
  import * as path from "node:path";
26
26
  import { promisify } from "node:util";
@@ -1647,6 +1647,21 @@ export function createAssessStructuredExecutors(pi: ExtensionAPI, overrides?: As
1647
1647
  // ─── Extension ──────────────────────────────────────────────────────────────
1648
1648
 
1649
1649
  export default function cleaveExtension(pi: ExtensionAPI) {
1650
+ // ── Guard: skip cleave in child processes ───────────────────────
1651
+ // Cleave children are spawned with PI_CHILD=1. If we load cleave
1652
+ // in children, they can spawn NESTED children — exponential process
1653
+ // growth. Children should never invoke cleave tools.
1654
+ if (process.env.PI_CHILD) return;
1655
+
1656
+ // ── Kill orphaned children from previous sessions ───────────────
1657
+ // If a previous omegon session was killed (SIGKILL, crash, machine
1658
+ // reboot), its detached children may still be alive. Clean them up
1659
+ // before doing anything else.
1660
+ const orphansKilled = cleanupOrphanedProcesses();
1661
+ if (orphansKilled > 0) {
1662
+ console.warn(`[cleave] killed ${orphansKilled} orphaned subprocess(es) from a previous session`);
1663
+ }
1664
+
1650
1665
  // ── Initialize dashboard state ──────────────────────────────────
1651
1666
  emitCleaveState(pi, "idle");
1652
1667
 
@@ -1,16 +1,113 @@
1
1
  /**
2
2
  * cleave/subprocess-tracker — Process registry for cleave subprocesses.
3
3
  *
4
- * Mirrors the extraction-v2 pattern: all spawned child processes are tracked
5
- * in a Set, killed by process group (SIGTERM to -pid), and cleaned up on
6
- * session_shutdown. Prevents orphaned `pi` processes when assessments time
7
- * out or sessions exit mid-dispatch.
4
+ * All spawned child processes are tracked in a Set and killed on:
5
+ * 1. Explicit call to killAllCleaveSubprocesses() (from session_shutdown)
6
+ * 2. process.on('exit') safety net (catches crashes, SIGTERM, SIGINT,
7
+ * uncaught exceptions anything session_shutdown misses)
8
+ * 3. PID file scan on startup (catches SIGKILL to parent, machine reboot
9
+ * with processes still running)
10
+ *
11
+ * Children are spawned with `detached: true` so we can kill their entire
12
+ * process group via `kill(-pid)`. The downside: detached children survive
13
+ * parent death by default. The exit handler and PID file compensate for this.
8
14
  */
9
15
 
10
16
  import type { ChildProcess } from "node:child_process";
17
+ import { existsSync, readFileSync, writeFileSync, unlinkSync, readdirSync } from "node:fs";
18
+ import { tmpdir } from "node:os";
19
+ import { join } from "node:path";
11
20
 
12
21
  const allCleaveProcs = new Set<ChildProcess>();
13
22
 
23
+ // ── PID file ────────────────────────────────────────────────────────────────
24
+ // Each parent process writes its tracked child PIDs to a temp file.
25
+ // On startup, cleanupOrphanedProcesses() scans for files whose parent PID
26
+ // is dead and kills the orphaned children.
27
+
28
+ const PID_FILE_PREFIX = "omegon-cleave-";
29
+ const PID_FILE_SUFFIX = ".pids";
30
+
31
+ function pidFilePath(): string {
32
+ return join(tmpdir(), `${PID_FILE_PREFIX}${process.pid}${PID_FILE_SUFFIX}`);
33
+ }
34
+
35
+ /** Write current tracked PIDs to the PID file. */
36
+ function syncPidFile(): void {
37
+ const pids = [...allCleaveProcs]
38
+ .map(p => p.pid)
39
+ .filter((pid): pid is number => pid !== undefined && pid > 0);
40
+ if (pids.length === 0) {
41
+ // No tracked children — remove the file
42
+ try { unlinkSync(pidFilePath()); } catch { /* ok */ }
43
+ return;
44
+ }
45
+ try {
46
+ writeFileSync(pidFilePath(), JSON.stringify({ parentPid: process.pid, childPids: pids }));
47
+ } catch { /* best effort */ }
48
+ }
49
+
50
+ /**
51
+ * Scan for PID files from dead parents and kill their orphaned children.
52
+ * Call this during extension initialization (before any new spawns).
53
+ * Returns the number of orphaned processes killed.
54
+ */
55
+ export function cleanupOrphanedProcesses(): number {
56
+ let killed = 0;
57
+ try {
58
+ const dir = tmpdir();
59
+ const files = readdirSync(dir).filter(
60
+ f => f.startsWith(PID_FILE_PREFIX) && f.endsWith(PID_FILE_SUFFIX),
61
+ );
62
+ for (const file of files) {
63
+ const filepath = join(dir, file);
64
+ try {
65
+ const data = JSON.parse(readFileSync(filepath, "utf-8"));
66
+ const parentPid = data?.parentPid;
67
+
68
+ // Check if the parent that wrote this file is still alive
69
+ if (parentPid && parentPid !== process.pid) {
70
+ try {
71
+ process.kill(parentPid, 0); // signal 0 = existence check
72
+ continue; // Parent alive — not orphans, skip
73
+ } catch {
74
+ // Parent dead — these are orphans, kill them
75
+ }
76
+ } else if (parentPid === process.pid) {
77
+ // Our own file from a previous lifecycle (shouldn't happen), clean up
78
+ try { unlinkSync(filepath); } catch { /* ok */ }
79
+ continue;
80
+ }
81
+
82
+ const childPids = data?.childPids;
83
+ if (Array.isArray(childPids)) {
84
+ for (const pid of childPids) {
85
+ if (typeof pid !== "number" || pid <= 0) continue;
86
+ try {
87
+ // Kill the process group (detached children have their own group)
88
+ process.kill(-pid, "SIGKILL");
89
+ killed++;
90
+ } catch {
91
+ try {
92
+ process.kill(pid, "SIGKILL");
93
+ killed++;
94
+ } catch { /* already dead */ }
95
+ }
96
+ }
97
+ }
98
+ // Remove the stale PID file
99
+ try { unlinkSync(filepath); } catch { /* ok */ }
100
+ } catch {
101
+ // Malformed file — remove it
102
+ try { unlinkSync(filepath); } catch { /* ok */ }
103
+ }
104
+ }
105
+ } catch { /* best effort — tmpdir unreadable is non-fatal */ }
106
+ return killed;
107
+ }
108
+
109
+ // ── Core tracking ───────────────────────────────────────────────────────────
110
+
14
111
  /** Kill a single subprocess by process group, with fallback to direct kill. */
15
112
  export function killCleaveProc(proc: ChildProcess): void {
16
113
  try {
@@ -20,14 +117,16 @@ export function killCleaveProc(proc: ChildProcess): void {
20
117
  }
21
118
  }
22
119
 
23
- /** Add a subprocess to the tracked set. */
120
+ /** Add a subprocess to the tracked set and update the PID file. */
24
121
  export function registerCleaveProc(proc: ChildProcess): void {
25
122
  allCleaveProcs.add(proc);
123
+ syncPidFile();
26
124
  }
27
125
 
28
- /** Remove a subprocess from the tracked set. */
126
+ /** Remove a subprocess from the tracked set and update the PID file. */
29
127
  export function deregisterCleaveProc(proc: ChildProcess): void {
30
128
  allCleaveProcs.delete(proc);
129
+ syncPidFile();
31
130
  }
32
131
 
33
132
  /**
@@ -44,7 +143,7 @@ function forceKillCleaveProc(proc: ChildProcess): void {
44
143
 
45
144
  /**
46
145
  * Kill all tracked cleave subprocesses and clear the registry.
47
- * Sends SIGTERM immediately, then SIGKILL after 5 seconds to any survivors.
146
+ * Sends SIGTERM immediately, then SIGKILL after 2 seconds to any survivors.
48
147
  * Because cleave subprocesses are spawned with `detached: true`, they will
49
148
  * NOT receive SIGHUP when the parent exits — SIGKILL escalation is required.
50
149
  */
@@ -53,20 +152,54 @@ export function killAllCleaveSubprocesses(): void {
53
152
  for (const proc of snapshot) {
54
153
  killCleaveProc(proc);
55
154
  }
56
- // Escalate: SIGKILL after 5s for any process that ignored SIGTERM.
57
- // The timer is unref'd so it does not keep the Node.js event loop alive.
155
+ // Escalate: SIGKILL after 2s for any process that ignored SIGTERM.
156
+ // NOT unref'd we MUST keep the event loop alive long enough for this
157
+ // to fire, otherwise children may survive. 2s (not 5s) because at shutdown
158
+ // speed matters more than grace.
58
159
  if (snapshot.length > 0) {
59
160
  const escalation = setTimeout(() => {
60
161
  for (const proc of snapshot) {
61
162
  if (!proc.killed) forceKillCleaveProc(proc);
62
163
  }
63
- }, 5_000);
64
- escalation.unref();
164
+ }, 2_000);
165
+ // Do NOT unref — this timer must fire even during shutdown.
166
+ // The previous implementation used .unref() which allowed the process
167
+ // to exit before SIGKILL was sent, leaving orphaned children alive.
168
+ void escalation;
65
169
  }
66
170
  allCleaveProcs.clear();
171
+ syncPidFile();
67
172
  }
68
173
 
69
174
  /** Number of currently tracked subprocesses (for diagnostics). */
70
175
  export function cleaveTrackedProcCount(): number {
71
176
  return allCleaveProcs.size;
72
177
  }
178
+
179
+ // ── Process exit safety net ─────────────────────────────────────────────────
180
+ //
181
+ // This is the critical fix for orphaned `pi` processes.
182
+ //
183
+ // `process.on('exit')` fires synchronously when the parent exits for ANY
184
+ // reason: normal exit, uncaught exception, SIGTERM, SIGINT. It does NOT
185
+ // fire on SIGKILL (which is why we also have the PID file mechanism).
186
+ //
187
+ // `process.kill()` is synchronous — safe to call inside an exit handler.
188
+ // We send SIGKILL (not SIGTERM) because at this point the parent is dying
189
+ // and we can't wait for graceful shutdown.
190
+ //
191
+ // This handler fires AFTER session_shutdown (which sends SIGTERM).
192
+ // If children are already dead from SIGTERM, the SIGKILL throws ESRCH
193
+ // and we catch it — no harm done.
194
+
195
+ process.on("exit", () => {
196
+ for (const proc of allCleaveProcs) {
197
+ try {
198
+ if (proc.pid) process.kill(-proc.pid, "SIGKILL");
199
+ } catch {
200
+ try { proc.kill("SIGKILL"); } catch { /* already dead */ }
201
+ }
202
+ }
203
+ // Clean up PID file — no orphans to track if we killed everything
204
+ try { unlinkSync(pidFilePath()); } catch { /* ok */ }
205
+ });
@@ -0,0 +1,64 @@
1
+ /**
2
+ * clipboard-diag — Diagnostic command for clipboard image paste.
3
+ *
4
+ * Registers /cliptest to diagnose why Ctrl+V image paste may fail.
5
+ */
6
+ import type { ExtensionAPI } from "../../vendor/pi-mono/packages/coding-agent/src/core/extensions/types.js";
7
+
8
+ export default function clipboardDiag(pi: ExtensionAPI) {
9
+ pi.registerCommand("cliptest", {
10
+ description: "Test clipboard image access (diagnostic)",
11
+ async handler() {
12
+ const lines: string[] = ["**Clipboard Image Diagnostic**", ""];
13
+
14
+ // 1. Check native module
15
+ let clipModule: string | null = null;
16
+ let clipboard: { hasImage: () => boolean; getImageBinary: () => Promise<unknown> } | null = null;
17
+ const candidates = ["@cwilson613/clipboard", "@mariozechner/clipboard"];
18
+ for (const name of candidates) {
19
+ try {
20
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
21
+ const mod = require(name);
22
+ clipboard = mod;
23
+ clipModule = name;
24
+ break;
25
+ } catch {
26
+ // next
27
+ }
28
+ }
29
+
30
+ if (!clipboard) {
31
+ lines.push("❌ No clipboard native module found");
32
+ lines.push(` Tried: ${candidates.join(", ")}`);
33
+ } else {
34
+ lines.push(`✓ Module: ${clipModule}`);
35
+
36
+ // 2. Check hasImage
37
+ try {
38
+ const has = clipboard.hasImage();
39
+ lines.push(`${has ? "✓" : "❌"} hasImage(): ${has}`);
40
+
41
+ // 3. Try reading
42
+ if (has) {
43
+ try {
44
+ const data = await clipboard.getImageBinary();
45
+ const len = Array.isArray(data) ? data.length : (data as Uint8Array)?.length ?? 0;
46
+ lines.push(`${len > 0 ? "✓" : "❌"} getImageBinary(): ${len} bytes`);
47
+ } catch (e) {
48
+ lines.push(`❌ getImageBinary() threw: ${e instanceof Error ? e.message : String(e)}`);
49
+ }
50
+ }
51
+ } catch (e) {
52
+ lines.push(`❌ hasImage() threw: ${e instanceof Error ? e.message : String(e)}`);
53
+ }
54
+ }
55
+
56
+ // 4. Platform info
57
+ lines.push("");
58
+ lines.push(`Platform: ${process.platform}, TERM: ${process.env.TERM ?? "unset"}`);
59
+ lines.push(`DISPLAY: ${process.env.DISPLAY ?? "unset"}, WAYLAND: ${process.env.WAYLAND_DISPLAY ?? "unset"}`);
60
+
61
+ pi.sendMessage({ customType: "view", content: lines.join("\n"), display: true });
62
+ },
63
+ });
64
+ }
@@ -258,7 +258,7 @@ export class FactStore {
258
258
  fs.mkdirSync(memoryDir, { recursive: true });
259
259
  this.db = new Database(this.dbPath);
260
260
  this.db.pragma("journal_mode = WAL");
261
- this.db.pragma("busy_timeout = 5000");
261
+ this.db.pragma("busy_timeout = 10000");
262
262
  this.db.pragma("foreign_keys = ON");
263
263
  this.initSchema();
264
264
  this.runMigrations();
@@ -304,9 +304,10 @@ export function ingestLifecycleCandidatesBatch(
304
304
  factIds: [],
305
305
  };
306
306
 
307
- // Use transaction for batch processing
308
- const tx = (store as any).db.transaction(() => {
309
- for (const candidate of candidates) {
307
+ // Process candidates individually (no batch transaction) to minimize write-lock
308
+ // hold time and avoid SQLITE_BUSY when concurrent processes share the DB.
309
+ for (const candidate of candidates) {
310
+ try {
310
311
  const candidateResult = ingestLifecycleCandidate(store, mind, candidate);
311
312
 
312
313
  if (candidateResult.autoStored) {
@@ -323,9 +324,17 @@ export function ingestLifecycleCandidatesBatch(
323
324
  } else {
324
325
  result.rejected++;
325
326
  }
327
+ } catch (err: any) {
328
+ // SQLITE_BUSY: another process holds the write lock. Skip this candidate
329
+ // rather than failing the entire batch — it will be re-extracted next cycle.
330
+ if (err?.code === "SQLITE_BUSY") {
331
+ result.rejected++;
332
+ console.warn(`[project-memory] lifecycle ingest skipped (DB busy): ${candidate.content.slice(0, 60)}`);
333
+ } else {
334
+ throw err; // Re-throw non-contention errors
335
+ }
326
336
  }
327
- });
337
+ }
328
338
 
329
- tx();
330
339
  return result;
331
340
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omegon",
3
- "version": "0.6.10",
3
+ "version": "0.6.13",
4
4
  "description": "Omegon — an opinionated distribution of pi (by Mario Zechner) with extensions for lifecycle management, memory, orchestration, and visualization",
5
5
  "bin": {
6
6
  "omegon": "bin/omegon.mjs",
@@ -73,13 +73,17 @@
73
73
  "./extensions/tool-profile",
74
74
  "./extensions/vault",
75
75
  "./extensions/version-check.ts",
76
- "./extensions/web-ui"
76
+ "./extensions/web-ui",
77
+ "./extensions/clipboard-diag/index.ts"
77
78
  ],
78
79
  "skills": [
79
80
  "./skills"
80
81
  ],
81
82
  "prompts": [
82
83
  "./prompts"
84
+ ],
85
+ "themes": [
86
+ "./themes"
83
87
  ]
84
88
  },
85
89
  "dependencies": {