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 +7 -3
- package/extensions/bootstrap/deps.ts +63 -67
- package/extensions/bootstrap/index.ts +76 -8
- package/extensions/cleave/index.ts +16 -1
- package/extensions/cleave/subprocess-tracker.ts +144 -11
- package/extensions/clipboard-diag/index.ts +64 -0
- package/extensions/project-memory/factstore.ts +1 -1
- package/extensions/project-memory/lifecycle.ts +14 -5
- package/package.json +6 -2
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: "
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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
|
-
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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
|
|
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(
|
|
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
|
-
|
|
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 +
|
|
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 —
|
|
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
|
-
"\
|
|
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
|
-
//
|
|
1012
|
-
// of the install sequence
|
|
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
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
|
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
|
|
57
|
-
//
|
|
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
|
-
},
|
|
64
|
-
|
|
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 =
|
|
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
|
-
//
|
|
308
|
-
|
|
309
|
-
|
|
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.
|
|
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": {
|