omnirush 0.8.6 → 0.9.1
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/assets/CHANGELOG.md +120 -0
- package/assets/extensions/omnirush/agents-lib.ts +501 -63
- package/assets/extensions/omnirush/agents.ts +140 -115
- package/assets/extensions/omnirush/bgshell-lib.ts +432 -0
- package/assets/extensions/omnirush/bgshell.ts +392 -0
- package/assets/extensions/omnirush/capture/workspace-collector.ts +86 -22
- package/assets/extensions/omnirush/collector.ts +275 -43
- package/assets/extensions/omnirush/commands.ts +2 -0
- package/assets/extensions/omnirush/deliveries.ts +145 -0
- package/assets/extensions/omnirush/guard/UPSTREAM +2 -0
- package/assets/extensions/omnirush/guard/git-command-policy.ts +885 -0
- package/assets/extensions/omnirush/guard-lib.ts +230 -0
- package/assets/extensions/omnirush/guard.ts +340 -0
- package/assets/extensions/omnirush/index.ts +32 -6
- package/assets/extensions/omnirush/mcp.ts +103 -8
- package/assets/extensions/omnirush/memory-lib.ts +516 -0
- package/assets/extensions/omnirush/pi-engine.ts +115 -3
- package/assets/extensions/omnirush/plan-lib.ts +38 -9
- package/assets/extensions/omnirush/plan.ts +85 -30
- package/assets/extensions/omnirush/sota.ts +52 -4
- package/assets/extensions/omnirush/status-lib.ts +3 -0
- package/assets/extensions/omnirush/subagent-marker.ts +21 -0
- package/assets/extensions/omnirush/subagents-lib.ts +623 -0
- package/assets/extensions/omnirush/subagents.ts +305 -0
- package/assets/extensions/omnirush/swarm-lib.ts +142 -0
- package/assets/extensions/omnirush/swarm.ts +95 -0
- package/assets/extensions/omnirush/voice/capture.ts +502 -0
- package/assets/extensions/omnirush/voice/core/UPSTREAM +16 -0
- package/assets/extensions/omnirush/voice/core/file-source.ts +70 -0
- package/assets/extensions/omnirush/voice/core/index.ts +21 -0
- package/assets/extensions/omnirush/voice/core/keyterms.ts +117 -0
- package/assets/extensions/omnirush/voice/core/resample.ts +63 -0
- package/assets/extensions/omnirush/voice/core/segmenter.ts +231 -0
- package/assets/extensions/omnirush/voice/core/session.ts +403 -0
- package/assets/extensions/omnirush/voice/core/text.ts +81 -0
- package/assets/extensions/omnirush/voice/core/transcriber.ts +135 -0
- package/assets/extensions/omnirush/voice/core/types.ts +102 -0
- package/assets/extensions/omnirush/voice/core/wav.ts +95 -0
- package/assets/extensions/omnirush/voice/keys.ts +435 -0
- package/assets/extensions/omnirush/voice/kitty.ts +64 -0
- package/assets/extensions/omnirush/voice/pvrecorder-worker.cjs +43 -0
- package/assets/extensions/omnirush/voice/settings.ts +67 -0
- package/assets/extensions/omnirush/voice.ts +838 -0
- package/assets/extensions/omnirush/yolo-lib.ts +80 -0
- package/assets/extensions/omnirush/yolo.ts +85 -0
- package/package.json +7 -3
- package/scripts/brand-engine.js +526 -0
- package/scripts/build-all-packages.py +29 -1
- package/scripts/smoke-packages.py +33 -1
- package/src/bin.js +232 -33
- package/src/compat.js +272 -0
- package/src/lib.js +64 -0
- package/src/sessions.js +222 -0
- package/scripts/patch-pi-branding.js +0 -251
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
// Guarded mode: which tool calls ask first. Pure (no I/O, no UI) so the
|
|
2
|
+
// rules are tested directly; guard.ts does the asking.
|
|
3
|
+
//
|
|
4
|
+
// Same rules as the desktop app's "guarded" approval mode (omnirush-gui
|
|
5
|
+
// managed-policy-rules.ts + git-command-policy.ts, vendored in ./guard, and
|
|
6
|
+
// the desktop engine's default asks):
|
|
7
|
+
//
|
|
8
|
+
// shell commands, per segment of the command line (&&, ||, ;, |, $(...),
|
|
9
|
+
// if/while/for bodies, and through bash -c / sh -c / eval / env -S / su -c /
|
|
10
|
+
// xargs / find -exec):
|
|
11
|
+
// read git status/log/diff/fetch, gh pr list ... -> runs
|
|
12
|
+
// write git commit/push/checkout/reset/rebase ...,
|
|
13
|
+
// gh pr create/merge ... -> asks; "allow
|
|
14
|
+
// for this session"
|
|
15
|
+
// covers the family
|
|
16
|
+
// destructive git push --force/--delete, reset --hard,
|
|
17
|
+
// clean -f, branch -D, rebase -i, stash drop,
|
|
18
|
+
// filter-branch, gh repo delete, rm -rf ... -> asks every time
|
|
19
|
+
// sudo / doas, and shell interpreters (bash -c ...) -> ask like a write
|
|
20
|
+
// file tools:
|
|
21
|
+
// reading .env / .env.* (not .env.example) -> asks
|
|
22
|
+
// any path outside the project folder -> asks
|
|
23
|
+
// (reading skills, omnirush's own docs and the saved output of a
|
|
24
|
+
// truncated command does not: the agent itself points there)
|
|
25
|
+
// the same tool call a 3rd time in a row (doom loop) -> asks
|
|
26
|
+
//
|
|
27
|
+
// Everything else runs, as in the desktop app. Heredoc bodies, comments and
|
|
28
|
+
// quoted strings are data, never commands.
|
|
29
|
+
|
|
30
|
+
import path from "node:path";
|
|
31
|
+
|
|
32
|
+
import { classifyShellCommand, splitShellCommand, type ClassifiedCommand } from "./guard/git-command-policy";
|
|
33
|
+
|
|
34
|
+
export type GateKind = "write" | "destructive" | "env" | "outside" | "loop";
|
|
35
|
+
|
|
36
|
+
export interface Gate {
|
|
37
|
+
kind: GateKind;
|
|
38
|
+
/** What "allow for this session" covers (e.g. "git push", "rm -rf"); null = always asks. */
|
|
39
|
+
family: string | null;
|
|
40
|
+
/** One line for the prompt: why it asks. */
|
|
41
|
+
reason: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface GateDecision {
|
|
45
|
+
/** Every reason the call asks; empty = runs without asking. */
|
|
46
|
+
gates: Gate[];
|
|
47
|
+
/** True when any gate is destructive: no "allow for this session". */
|
|
48
|
+
alwaysAsk: boolean;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const SHELL_TOOLS = new Set(["bash", "powershell"]);
|
|
52
|
+
/** Status and polling tools: asking the same thing again is their job, not a loop. */
|
|
53
|
+
const POLLING_TOOLS = new Set(["bash_output", "bash_wait", "agents_status", "agents_wait", "agents_result"]);
|
|
54
|
+
const PATH_TOOLS = new Set(["read", "write", "edit", "grep", "find", "ls"]);
|
|
55
|
+
const INTERPRETERS = new Set(["bash", "sh", "zsh", "dash", "ksh", "mksh", "ash", "fish", "eval", "su"]);
|
|
56
|
+
const PRIVILEGE = new Set(["sudo", "doas"]);
|
|
57
|
+
|
|
58
|
+
function programOf(token: string | undefined): string {
|
|
59
|
+
return (token ?? "").replaceAll("\\", "/").split("/").pop()?.toLowerCase().replace(/\.exe$/, "") ?? "";
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** `sudo …`, `doas …` and interpreter wrappers (`bash -c '…'`, `eval …`, `env -S …`) ask like a write command. */
|
|
63
|
+
function wrapperGates(command: string): Gate[] {
|
|
64
|
+
const gates: Gate[] = [];
|
|
65
|
+
for (const segment of splitShellCommand(command)) {
|
|
66
|
+
const tokens = segment.tokens.filter((token) => !/^[A-Za-z_][A-Za-z0-9_]*=/.test(token));
|
|
67
|
+
const first = programOf(tokens[0]);
|
|
68
|
+
if (PRIVILEGE.has(first)) {
|
|
69
|
+
gates.push({ kind: "write", family: first, reason: `runs with ${first} (system-level)` });
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
const interpreter =
|
|
73
|
+
(INTERPRETERS.has(first) && (first === "eval" || tokens.slice(1).some((t) => /^-[A-Za-z]*c[A-Za-z]*$/.test(t) || t === "--command" || t.startsWith("--command="))))
|
|
74
|
+
|| (first === "env" && tokens.slice(1).some((t) => t === "-S" || t.startsWith("-S") || t.startsWith("--split-string")));
|
|
75
|
+
if (interpreter) gates.push({ kind: "write", family: `${first} -c`, reason: `runs a command line through ${first}` });
|
|
76
|
+
}
|
|
77
|
+
return gates;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function shellGates(command: string): Gate[] {
|
|
81
|
+
const gates: Gate[] = [];
|
|
82
|
+
let classified: ClassifiedCommand[] = [];
|
|
83
|
+
try {
|
|
84
|
+
classified = classifyShellCommand(command);
|
|
85
|
+
} catch {
|
|
86
|
+
// Unparseable: ask rather than guess.
|
|
87
|
+
return [{ kind: "write", family: null, reason: "a command line omnirush could not read" }];
|
|
88
|
+
}
|
|
89
|
+
for (const entry of classified) {
|
|
90
|
+
if (entry.kind === "destructive") {
|
|
91
|
+
gates.push({ kind: "destructive", family: entry.family, reason: `${entry.family ?? "a destructive command"} is destructive (always asks)` });
|
|
92
|
+
} else if (entry.kind === "write") {
|
|
93
|
+
gates.push({ kind: "write", family: entry.family, reason: `${entry.family} changes the repository` });
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
gates.push(...wrapperGates(command));
|
|
97
|
+
return gates;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The desktop engine's `read *.env` / `read *.env.*` asks, minus `*.env.example`. */
|
|
101
|
+
export function isEnvFile(file: string): boolean {
|
|
102
|
+
const base = path.basename(file).toLowerCase();
|
|
103
|
+
if (base.endsWith(".env.example")) return false;
|
|
104
|
+
return /\.env$/.test(base) || /\.env\./.test(base);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function inside(root: string, target: string): boolean {
|
|
108
|
+
const relative = path.relative(root, target);
|
|
109
|
+
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function expandHome(p: string, home: string): string {
|
|
113
|
+
return p === "~" ? home : p.startsWith("~/") ? path.join(home, p.slice(2)) : p;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const READ_ONLY_TOOLS = new Set(["read", "grep", "find", "ls"]);
|
|
117
|
+
/** The core's saved full output of a truncated shell command (it tells the model to read it). */
|
|
118
|
+
const OUTPUT_LOG = /^(?:omnirush|pi)-bash-[0-9a-f]+\.log$/;
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Places the agent itself points the model at, readable without asking:
|
|
122
|
+
* skills (the agent dir's skills folders), omnirush's own docs (the package
|
|
123
|
+
* root) and the saved output of truncated commands in the temp folder.
|
|
124
|
+
*/
|
|
125
|
+
export interface ReadRoots {
|
|
126
|
+
dirs: string[];
|
|
127
|
+
tmp: string;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function readableOutsideProject(target: string, roots: ReadRoots | undefined): boolean {
|
|
131
|
+
if (!roots) return false;
|
|
132
|
+
if (roots.dirs.some((dir) => dir && inside(dir, target))) return true;
|
|
133
|
+
return path.dirname(target) === path.resolve(roots.tmp) && OUTPUT_LOG.test(path.basename(target));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function pathGates(toolName: string, input: Record<string, unknown>, cwd: string, home: string, readRoots?: ReadRoots): Gate[] {
|
|
137
|
+
const raw = [input.path, input.file_path, input.filePath].find((value) => typeof value === "string" && value.trim()) as string | undefined;
|
|
138
|
+
if (!raw) return [];
|
|
139
|
+
const target = path.resolve(cwd, expandHome(raw.trim(), home));
|
|
140
|
+
const gates: Gate[] = [];
|
|
141
|
+
if (!inside(cwd, target) && !(READ_ONLY_TOOLS.has(toolName) && readableOutsideProject(target, readRoots))) {
|
|
142
|
+
const dir = ["write", "edit", "read"].includes(toolName) ? path.dirname(target) : target;
|
|
143
|
+
gates.push({ kind: "outside", family: `outside the project: ${dir}`, reason: `${toolName} outside the project folder (${target})` });
|
|
144
|
+
}
|
|
145
|
+
if (toolName !== "ls" && toolName !== "find" && isEnvFile(target)) {
|
|
146
|
+
gates.push({ kind: "env", family: `${toolName} ${path.basename(target)}`, reason: `${toolName} of an environment file (${path.basename(target)})` });
|
|
147
|
+
}
|
|
148
|
+
return gates;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Stable text of a call, for the doom-loop check. */
|
|
152
|
+
export function callSignature(toolName: string, input: unknown): string {
|
|
153
|
+
try {
|
|
154
|
+
return `${toolName}:${JSON.stringify(input)}`;
|
|
155
|
+
} catch {
|
|
156
|
+
return `${toolName}:?`;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** The command line a shell tool call runs. */
|
|
161
|
+
export function shellCommandOf(toolName: string, input: Record<string, unknown>): string | null {
|
|
162
|
+
if (!SHELL_TOOLS.has(toolName)) return null;
|
|
163
|
+
const command = input.command;
|
|
164
|
+
return typeof command === "string" ? command : null;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Which gates a tool call hits in guarded mode. `recent` is the signatures of
|
|
169
|
+
* the calls before it, newest last (the third identical call in a row asks).
|
|
170
|
+
*/
|
|
171
|
+
export function evaluateToolCall(
|
|
172
|
+
toolName: string,
|
|
173
|
+
input: Record<string, unknown>,
|
|
174
|
+
{ cwd, home, recent = [], readRoots }: { cwd: string; home: string; recent?: string[]; readRoots?: ReadRoots },
|
|
175
|
+
): GateDecision {
|
|
176
|
+
const gates: Gate[] = [];
|
|
177
|
+
const command = shellCommandOf(toolName, input);
|
|
178
|
+
if (command !== null) gates.push(...shellGates(command));
|
|
179
|
+
if (PATH_TOOLS.has(toolName)) gates.push(...pathGates(toolName, input, cwd, home, readRoots));
|
|
180
|
+
const signature = callSignature(toolName, input);
|
|
181
|
+
if (!POLLING_TOOLS.has(toolName) && recent.length >= 2 && recent.at(-1) === signature && recent.at(-2) === signature) {
|
|
182
|
+
gates.push({ kind: "loop", family: `repeat ${toolName}`, reason: "the same call a third time in a row" });
|
|
183
|
+
}
|
|
184
|
+
// One gate per family (a line with two pushes asks once).
|
|
185
|
+
const seen = new Set<string>();
|
|
186
|
+
const unique = gates.filter((gate) => {
|
|
187
|
+
const key = `${gate.kind}:${gate.family}`;
|
|
188
|
+
if (seen.has(key)) return false;
|
|
189
|
+
seen.add(key);
|
|
190
|
+
return true;
|
|
191
|
+
});
|
|
192
|
+
return { gates: unique, alwaysAsk: unique.some((gate) => gate.kind === "destructive" || gate.family === null) };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Gates the session already allowed are dropped (destructive ones never are). */
|
|
196
|
+
export function pendingGates(decision: GateDecision, allowed: ReadonlySet<string>): Gate[] {
|
|
197
|
+
return decision.gates.filter((gate) => gate.kind === "destructive" || gate.family === null || !allowed.has(gate.family));
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export const ALLOW_ONCE = "Allow once";
|
|
201
|
+
export const ALLOW_SESSION = "Allow for this session";
|
|
202
|
+
export const DENY = "Deny";
|
|
203
|
+
|
|
204
|
+
/** Short text of what is about to run, for the prompt. */
|
|
205
|
+
export function describeCall(toolName: string, input: Record<string, unknown>, max = 600): string {
|
|
206
|
+
const command = shellCommandOf(toolName, input);
|
|
207
|
+
let text: string;
|
|
208
|
+
if (command !== null) text = `$ ${command}`;
|
|
209
|
+
else {
|
|
210
|
+
const target = [input.path, input.file_path, input.filePath].find((v) => typeof v === "string");
|
|
211
|
+
text = target ? `${toolName} ${target}` : `${toolName} ${callSignature(toolName, input).slice(toolName.length + 1)}`;
|
|
212
|
+
}
|
|
213
|
+
return text.length > max ? `${text.slice(0, max - 1)}…` : text;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** The prompt title shown in the TUI (and to rpc clients). */
|
|
217
|
+
export function promptTitle(toolName: string, input: Record<string, unknown>, gates: Gate[], agent?: string | null): string {
|
|
218
|
+
const who = agent ? `Sub-agent ${agent} wants to run:` : "omnirush wants to run:";
|
|
219
|
+
return [`guarded mode — ${who}`, "", describeCall(toolName, input), "", ...gates.map((gate) => `• ${gate.reason}`)].join("\n");
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export const GUARDED_BLOCK_HINT = "blocked in guarded mode; rerun with --yolo to allow";
|
|
223
|
+
|
|
224
|
+
/** What the model reads when a call is not allowed. */
|
|
225
|
+
export function blockReason(gates: Gate[], how: "denied" | "no-ui"): string {
|
|
226
|
+
const what = gates.map((gate) => gate.reason).join("; ");
|
|
227
|
+
return how === "denied"
|
|
228
|
+
? `The user denied this call (guarded mode: ${what}). Do not retry it; ask the user how to proceed.`
|
|
229
|
+
: `${GUARDED_BLOCK_HINT} (${what}). Nobody can approve it in this run; do not retry it.`;
|
|
230
|
+
}
|
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Approval modes: guarded (default) and yolo.
|
|
3
|
+
*
|
|
4
|
+
* Guarded: a tool call that hits one of the desktop app's guarded rules
|
|
5
|
+
* (guard-lib.ts: git/gh writes, destructive commands, sudo, shell
|
|
6
|
+
* interpreters, .env reads, paths outside the project, a repeated call) is
|
|
7
|
+
* paused and the user is asked, with the command shown:
|
|
8
|
+
* Allow once / Allow for this session / Deny
|
|
9
|
+
* (destructive commands always ask: Allow once / Deny).
|
|
10
|
+
*
|
|
11
|
+
* TUI the prompt shows in the terminal.
|
|
12
|
+
* rpc the prompt goes to the client as an extension_ui_request
|
|
13
|
+
* (method "select"; title starts with "guarded mode"); no
|
|
14
|
+
* answer within 5 minutes = deny.
|
|
15
|
+
* -p / json nobody can answer: the call is blocked with "blocked in
|
|
16
|
+
* guarded mode; rerun with --yolo to allow" (to the model, and
|
|
17
|
+
* on stderr for the user).
|
|
18
|
+
* sub-agent a child process asks the nearest interactive omnirush above
|
|
19
|
+
* it over a private local socket (OMNIRUSH_APPROVAL_SOCKET); the
|
|
20
|
+
* prompt names the sub-agent. With no interactive parent the
|
|
21
|
+
* call is blocked as above. Never silently allowed.
|
|
22
|
+
*
|
|
23
|
+
* Yolo (--yolo, /yolo on, /mode yolo, OMNIRUSH_YOLO=1): nothing asks.
|
|
24
|
+
*
|
|
25
|
+
* /mode [guarded|yolo] and /approvals [reset] show or switch the mode; the
|
|
26
|
+
* footer shows it; /status has a mode line.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import crypto from "node:crypto";
|
|
30
|
+
import fs from "node:fs";
|
|
31
|
+
import net from "node:net";
|
|
32
|
+
import os from "node:os";
|
|
33
|
+
import path from "node:path";
|
|
34
|
+
|
|
35
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
36
|
+
|
|
37
|
+
import { omniDir } from "./auth";
|
|
38
|
+
import {
|
|
39
|
+
ALLOW_ONCE,
|
|
40
|
+
ALLOW_SESSION,
|
|
41
|
+
DENY,
|
|
42
|
+
GUARDED_BLOCK_HINT,
|
|
43
|
+
blockReason,
|
|
44
|
+
callSignature,
|
|
45
|
+
describeCall,
|
|
46
|
+
evaluateToolCall,
|
|
47
|
+
pendingGates,
|
|
48
|
+
promptTitle,
|
|
49
|
+
type Gate,
|
|
50
|
+
type ReadRoots,
|
|
51
|
+
} from "./guard-lib";
|
|
52
|
+
import { MODE_STATUS_KEY, currentMode, formatModeStatus, saveYoloSetting, setSessionYolo, yoloBanner } from "./yolo-lib";
|
|
53
|
+
|
|
54
|
+
export const APPROVAL_SOCKET_ENV = "OMNIRUSH_APPROVAL_SOCKET";
|
|
55
|
+
export const APPROVAL_TOKEN_ENV = "OMNIRUSH_APPROVAL_TOKEN";
|
|
56
|
+
const RPC_TIMEOUT_MS = 5 * 60_000;
|
|
57
|
+
|
|
58
|
+
export type Answer = "once" | "session" | "deny" | "no-ui";
|
|
59
|
+
|
|
60
|
+
export interface ApprovalRequest {
|
|
61
|
+
toolName: string;
|
|
62
|
+
input: Record<string, unknown>;
|
|
63
|
+
gates: Gate[];
|
|
64
|
+
alwaysAsk: boolean;
|
|
65
|
+
/** Sub-agent label, when a child asks. */
|
|
66
|
+
agent?: string | null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export const GUARDED_NOTICE = "guarded mode: git writes, destructive commands and sudo ask first — /mode yolo (or --yolo) allows everything";
|
|
70
|
+
|
|
71
|
+
/** Ask one question in this process's UI; Esc, a closed dialog or the rpc timeout deny. */
|
|
72
|
+
async function askInUi(ctx: any, request: ApprovalRequest): Promise<Answer> {
|
|
73
|
+
const options = request.alwaysAsk ? [ALLOW_ONCE, DENY] : [ALLOW_ONCE, ALLOW_SESSION, DENY];
|
|
74
|
+
const title = promptTitle(request.toolName, request.input, request.gates, request.agent);
|
|
75
|
+
let choice: string | undefined;
|
|
76
|
+
try {
|
|
77
|
+
choice = await ctx.ui.select(title, options, ctx.mode === "rpc" ? { timeout: RPC_TIMEOUT_MS } : undefined);
|
|
78
|
+
} catch {
|
|
79
|
+
choice = undefined;
|
|
80
|
+
}
|
|
81
|
+
if (choice === ALLOW_ONCE) return "once";
|
|
82
|
+
if (choice === ALLOW_SESSION) return "session";
|
|
83
|
+
return "deny";
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** A child's question to the nearest interactive omnirush above it. */
|
|
87
|
+
export function askParent(request: ApprovalRequest, env: NodeJS.ProcessEnv = process.env): Promise<Answer> {
|
|
88
|
+
const address = (env[APPROVAL_SOCKET_ENV] || "").trim();
|
|
89
|
+
if (!address) return Promise.resolve("no-ui");
|
|
90
|
+
return new Promise((resolve) => {
|
|
91
|
+
let settled = false;
|
|
92
|
+
let buffer = "";
|
|
93
|
+
const done = (answer: Answer) => {
|
|
94
|
+
if (settled) return;
|
|
95
|
+
settled = true;
|
|
96
|
+
socket.destroy();
|
|
97
|
+
resolve(answer);
|
|
98
|
+
};
|
|
99
|
+
const socket = net.createConnection(address, () => {
|
|
100
|
+
socket.write(`${JSON.stringify({ token: env[APPROVAL_TOKEN_ENV] || "", ...request })}\n`);
|
|
101
|
+
});
|
|
102
|
+
socket.setEncoding("utf8");
|
|
103
|
+
socket.on("data", (chunk: string) => {
|
|
104
|
+
buffer += chunk;
|
|
105
|
+
const newline = buffer.indexOf("\n");
|
|
106
|
+
if (newline < 0) return;
|
|
107
|
+
try {
|
|
108
|
+
const answer = JSON.parse(buffer.slice(0, newline))?.answer;
|
|
109
|
+
done(answer === "once" || answer === "session" || answer === "deny" ? answer : answer === "no-ui" ? "no-ui" : "deny");
|
|
110
|
+
} catch {
|
|
111
|
+
done("deny");
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
// The parent went away: nobody can answer.
|
|
115
|
+
socket.on("error", () => done("no-ui"));
|
|
116
|
+
socket.on("close", () => done("no-ui"));
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export interface GuardState {
|
|
121
|
+
allowed: Set<string>;
|
|
122
|
+
recent: string[];
|
|
123
|
+
uiCtx: any;
|
|
124
|
+
queue: Promise<unknown>;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function createGuardState(): GuardState {
|
|
128
|
+
return { allowed: new Set(), recent: [], uiCtx: null, queue: Promise.resolve() };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Decide one request in this process: yolo allows; families the session
|
|
133
|
+
* allowed pass; otherwise this UI asks (one prompt at a time), or the parent
|
|
134
|
+
* does, or nobody can.
|
|
135
|
+
*/
|
|
136
|
+
export async function decide(state: GuardState, request: ApprovalRequest, ctx: any, env: NodeJS.ProcessEnv = process.env): Promise<Answer> {
|
|
137
|
+
if (currentMode(env) === "yolo") return "once";
|
|
138
|
+
const gates = pendingGates({ gates: request.gates, alwaysAsk: request.alwaysAsk }, state.allowed);
|
|
139
|
+
if (gates.length === 0) return "session";
|
|
140
|
+
const pending = { ...request, gates };
|
|
141
|
+
const ui = ctx?.hasUI && ctx?.ui?.select ? ctx : state.uiCtx?.hasUI ? state.uiCtx : null;
|
|
142
|
+
let answer: Answer;
|
|
143
|
+
if (ui) {
|
|
144
|
+
const turn = state.queue.then(async () => {
|
|
145
|
+
// Allowed for the session while this one waited in line?
|
|
146
|
+
if (currentMode(env) === "yolo") return "once" as Answer;
|
|
147
|
+
const still = pendingGates({ gates: pending.gates, alwaysAsk: pending.alwaysAsk }, state.allowed);
|
|
148
|
+
if (still.length === 0) return "session" as Answer;
|
|
149
|
+
return askInUi(ui, { ...pending, gates: still });
|
|
150
|
+
});
|
|
151
|
+
state.queue = turn.catch(() => undefined);
|
|
152
|
+
answer = await turn;
|
|
153
|
+
} else {
|
|
154
|
+
answer = await askParent(pending, env);
|
|
155
|
+
}
|
|
156
|
+
if (answer === "session") {
|
|
157
|
+
for (const gate of gates) if (gate.family && gate.kind !== "destructive") state.allowed.add(gate.family);
|
|
158
|
+
}
|
|
159
|
+
return answer;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Serve sub-agents' questions (children inherit the address and token). */
|
|
163
|
+
function startApprovalServer(state: GuardState): (() => void) | null {
|
|
164
|
+
const token = crypto.randomBytes(24).toString("hex");
|
|
165
|
+
let dir: string | null = null;
|
|
166
|
+
let address: string;
|
|
167
|
+
if (process.platform === "win32") {
|
|
168
|
+
address = `\\\\.\\pipe\\omnirush-approval-${process.pid}-${crypto.randomBytes(6).toString("hex")}`;
|
|
169
|
+
} else {
|
|
170
|
+
dir = fs.mkdtempSync(path.join(os.tmpdir(), "omnirush-approval-"));
|
|
171
|
+
fs.chmodSync(dir, 0o700);
|
|
172
|
+
address = path.join(dir, "s");
|
|
173
|
+
}
|
|
174
|
+
const server = net.createServer((socket) => {
|
|
175
|
+
socket.setEncoding("utf8");
|
|
176
|
+
let buffer = "";
|
|
177
|
+
socket.on("data", async (chunk: string) => {
|
|
178
|
+
buffer += chunk;
|
|
179
|
+
const newline = buffer.indexOf("\n");
|
|
180
|
+
if (newline < 0) return;
|
|
181
|
+
const line = buffer.slice(0, newline);
|
|
182
|
+
buffer = "";
|
|
183
|
+
let answer: Answer = "deny";
|
|
184
|
+
try {
|
|
185
|
+
const message = JSON.parse(line);
|
|
186
|
+
if (message?.token === token && typeof message.toolName === "string" && Array.isArray(message.gates)) {
|
|
187
|
+
const request: ApprovalRequest = {
|
|
188
|
+
toolName: message.toolName,
|
|
189
|
+
input: message.input && typeof message.input === "object" ? message.input : {},
|
|
190
|
+
gates: message.gates,
|
|
191
|
+
alwaysAsk: message.alwaysAsk === true,
|
|
192
|
+
agent: typeof message.agent === "string" ? message.agent : "sub-agent",
|
|
193
|
+
};
|
|
194
|
+
answer = await decide(state, request, null);
|
|
195
|
+
}
|
|
196
|
+
} catch {
|
|
197
|
+
answer = "deny";
|
|
198
|
+
}
|
|
199
|
+
try {
|
|
200
|
+
socket.end(`${JSON.stringify({ answer })}\n`);
|
|
201
|
+
} catch {
|
|
202
|
+
/* the child went away */
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
socket.on("error", () => undefined);
|
|
206
|
+
});
|
|
207
|
+
server.on("error", () => undefined);
|
|
208
|
+
try {
|
|
209
|
+
server.listen(address);
|
|
210
|
+
} catch {
|
|
211
|
+
return null;
|
|
212
|
+
}
|
|
213
|
+
server.unref();
|
|
214
|
+
process.env[APPROVAL_SOCKET_ENV] = address;
|
|
215
|
+
process.env[APPROVAL_TOKEN_ENV] = token;
|
|
216
|
+
const stop = () => {
|
|
217
|
+
try {
|
|
218
|
+
server.close();
|
|
219
|
+
} catch {
|
|
220
|
+
/* closed */
|
|
221
|
+
}
|
|
222
|
+
if (dir) fs.rmSync(dir, { recursive: true, force: true });
|
|
223
|
+
};
|
|
224
|
+
process.once("exit", stop);
|
|
225
|
+
return stop;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function showMode(ctx: any): void {
|
|
229
|
+
try {
|
|
230
|
+
ctx?.ui?.setStatus?.(MODE_STATUS_KEY, currentMode());
|
|
231
|
+
} catch {
|
|
232
|
+
/* no UI */
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Skills folders, omnirush's own package (docs) and the temp folder's saved command output. */
|
|
237
|
+
function defaultReadRoots(): ReadRoots {
|
|
238
|
+
let agentDir = "";
|
|
239
|
+
try {
|
|
240
|
+
agentDir = getAgentDir();
|
|
241
|
+
} catch {
|
|
242
|
+
agentDir = (process.env.OMNIRUSH_CODING_AGENT_DIR || process.env.PI_CODING_AGENT_DIR || "").trim();
|
|
243
|
+
}
|
|
244
|
+
const dirs = agentDir ? [path.join(agentDir, "skills"), path.join(agentDir, "omnirush-skills")] : [];
|
|
245
|
+
const packageRoot = (process.env.OMNIRUSH_PACKAGE_ROOT || "").trim();
|
|
246
|
+
if (packageRoot) dirs.push(packageRoot);
|
|
247
|
+
return { dirs, tmp: os.tmpdir() };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export default function (pi: any, options: { home?: string; readRoots?: ReadRoots } = {}) {
|
|
251
|
+
const state = createGuardState();
|
|
252
|
+
const home = options.home ?? os.homedir();
|
|
253
|
+
const readRoots = options.readRoots ?? defaultReadRoots();
|
|
254
|
+
let server: (() => void) | null = null;
|
|
255
|
+
|
|
256
|
+
pi.on("session_start", (_event: any, ctx: any) => {
|
|
257
|
+
state.allowed.clear();
|
|
258
|
+
state.recent = [];
|
|
259
|
+
if (ctx?.hasUI) {
|
|
260
|
+
state.uiCtx = ctx;
|
|
261
|
+
// The nearest interactive omnirush answers its sub-agents.
|
|
262
|
+
server ??= startApprovalServer(state);
|
|
263
|
+
}
|
|
264
|
+
showMode(ctx);
|
|
265
|
+
if (ctx?.hasUI && currentMode() === "guarded") ctx.ui?.notify?.(GUARDED_NOTICE, "info");
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
pi.on("tool_call", async (event: any, ctx: any) => {
|
|
269
|
+
if (ctx?.hasUI) state.uiCtx = ctx;
|
|
270
|
+
const toolName = String(event?.toolName ?? "");
|
|
271
|
+
const input = event?.input && typeof event.input === "object" ? event.input : {};
|
|
272
|
+
const decision = evaluateToolCall(toolName, input, { cwd: ctx?.cwd || process.cwd(), home, recent: state.recent, readRoots });
|
|
273
|
+
state.recent = [...state.recent, callSignature(toolName, input)].slice(-2);
|
|
274
|
+
if (currentMode() === "yolo" || decision.gates.length === 0) return undefined;
|
|
275
|
+
const agent = (process.env.OMNIRUSH_SUBAGENT_LABEL || "").trim() || null;
|
|
276
|
+
const answer = await decide(state, { toolName, input, gates: decision.gates, alwaysAsk: decision.alwaysAsk, agent }, ctx);
|
|
277
|
+
if (answer === "once" || answer === "session") return undefined;
|
|
278
|
+
const gates = pendingGates(decision, new Set());
|
|
279
|
+
if (answer === "no-ui") {
|
|
280
|
+
try {
|
|
281
|
+
process.stderr.write(`omnirush: ${GUARDED_BLOCK_HINT}: ${describeCall(toolName, input, 200)}\n`);
|
|
282
|
+
} catch {
|
|
283
|
+
/* stderr closed */
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
return { block: true, reason: blockReason(gates, answer === "deny" ? "denied" : "no-ui") };
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
const modeCommand = async (args: string, ctx: any) => {
|
|
290
|
+
const sub = String(args ?? "").trim().toLowerCase();
|
|
291
|
+
const notify = (text: string, level: "info" | "warning" | "error" = "info") => ctx?.ui?.notify?.(text, level);
|
|
292
|
+
if (sub === "" || sub === "status") {
|
|
293
|
+
const grants = [...state.allowed];
|
|
294
|
+
notify(
|
|
295
|
+
[
|
|
296
|
+
formatModeStatus(),
|
|
297
|
+
grants.length ? `allowed for this session: ${grants.join(", ")}` : null,
|
|
298
|
+
"/mode guarded|yolo switches (saved for the next runs); /approvals reset forgets this session's allows.",
|
|
299
|
+
].filter(Boolean).join("\n"),
|
|
300
|
+
);
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
if (sub === "reset") {
|
|
304
|
+
state.allowed.clear();
|
|
305
|
+
notify("Guarded mode: this session's allows are forgotten; those commands ask again.");
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
const yolo = sub === "yolo" || sub === "on" || sub === "full";
|
|
309
|
+
if (!yolo && sub !== "guarded" && sub !== "off") {
|
|
310
|
+
notify("Usage: /mode [guarded|yolo|status] · /approvals [reset]", "error");
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
try {
|
|
314
|
+
saveYoloSetting(omniDir(), yolo);
|
|
315
|
+
} catch (error: any) {
|
|
316
|
+
notify(`/mode: could not save the setting (${error?.message ?? error})`, "error");
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
setSessionYolo(yolo);
|
|
320
|
+
showMode(ctx);
|
|
321
|
+
notify(yolo ? `${yoloBanner()} (saved)` : `${GUARDED_NOTICE} (saved)`, yolo ? "warning" : "info");
|
|
322
|
+
};
|
|
323
|
+
const completions = (prefix: string) => {
|
|
324
|
+
const typed = prefix.trim();
|
|
325
|
+
const items = ["guarded", "yolo", "status", "reset"]
|
|
326
|
+
.filter((value) => value.startsWith(typed) && value !== typed)
|
|
327
|
+
.map((value) => ({ value, label: value }));
|
|
328
|
+
return items.length ? items : null;
|
|
329
|
+
};
|
|
330
|
+
pi.registerCommand("mode", {
|
|
331
|
+
description: "Approval mode: /mode [guarded|yolo|status] (guarded asks before git writes and destructive commands)",
|
|
332
|
+
getArgumentCompletions: completions,
|
|
333
|
+
handler: modeCommand,
|
|
334
|
+
});
|
|
335
|
+
pi.registerCommand("approvals", {
|
|
336
|
+
description: "Approval mode and this session's allows: /approvals [guarded|yolo|reset]",
|
|
337
|
+
getArgumentCompletions: completions,
|
|
338
|
+
handler: modeCommand,
|
|
339
|
+
});
|
|
340
|
+
}
|
|
@@ -5,26 +5,52 @@
|
|
|
5
5
|
// extension (without it, none of these files were ever registered).
|
|
6
6
|
// EVERY module that registers commands/tools/handlers must be wired here:
|
|
7
7
|
// a module left out of this list is silently dead in sessions.
|
|
8
|
+
//
|
|
9
|
+
// A sub-agent (a headless spawn_agents child: OMNIRUSH_PARENT_SESSION is
|
|
10
|
+
// set) does not load what only the interactive top agent uses — the slash
|
|
11
|
+
// commands (commands), the session capture (collector: the parent records
|
|
12
|
+
// its children) and voice input (TUI only) — so each of the many child
|
|
13
|
+
// processes a fan-out starts stays smaller. They are imported on demand,
|
|
14
|
+
// in the same order as before, so the top agent registers exactly what it
|
|
15
|
+
// did.
|
|
8
16
|
|
|
9
17
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
10
|
-
import collector from "./collector";
|
|
11
|
-
import commands from "./commands";
|
|
12
|
-
import mcp from "./mcp";
|
|
13
18
|
import recorder from "./recorder";
|
|
14
19
|
import sota from "./sota";
|
|
20
|
+
import mcp from "./mcp";
|
|
15
21
|
import agents from "./agents";
|
|
22
|
+
import bgshell from "./bgshell";
|
|
23
|
+
import subagents from "./subagents";
|
|
24
|
+
import swarm from "./swarm";
|
|
16
25
|
import plan from "./plan";
|
|
17
26
|
import webfetch from "./webfetch";
|
|
18
27
|
import websearch from "./websearch";
|
|
28
|
+
import guard from "./guard";
|
|
29
|
+
import yolo from "./yolo";
|
|
30
|
+
|
|
31
|
+
/** A spawn_agents child process (the parent session captures it; no TUI). */
|
|
32
|
+
export function isSubagentProcess(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
33
|
+
return Boolean(String(env.OMNIRUSH_PARENT_SESSION ?? "").trim());
|
|
34
|
+
}
|
|
19
35
|
|
|
20
|
-
export default function (pi: ExtensionAPI) {
|
|
21
|
-
|
|
36
|
+
export default async function (pi: ExtensionAPI) {
|
|
37
|
+
const subagent = isSubagentProcess();
|
|
38
|
+
if (!subagent) (await import("./commands")).default(pi as any);
|
|
22
39
|
recorder(pi as any);
|
|
23
40
|
sota(pi as any);
|
|
24
|
-
collector(pi as any);
|
|
41
|
+
if (!subagent) (await import("./collector")).default(pi as any);
|
|
42
|
+
// Children still mark their own session as a sub-agent's (the collector
|
|
43
|
+
// does that for the top agent's sessions; children do not load it).
|
|
44
|
+
else (await import("./subagent-marker")).default(pi as any);
|
|
25
45
|
mcp(pi as any);
|
|
26
46
|
webfetch(pi as any);
|
|
27
47
|
websearch(pi as any);
|
|
28
48
|
plan(pi as any);
|
|
29
49
|
agents(pi as any);
|
|
50
|
+
subagents(pi as any);
|
|
51
|
+
bgshell(pi as any);
|
|
52
|
+
if (!subagent) (await import("./voice")).default(pi as any);
|
|
53
|
+
yolo(pi as any);
|
|
54
|
+
guard(pi as any);
|
|
55
|
+
swarm(pi as any);
|
|
30
56
|
}
|