omnirush 0.8.6 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/assets/CHANGELOG.md +85 -0
  2. package/assets/extensions/omnirush/agents-lib.ts +134 -13
  3. package/assets/extensions/omnirush/agents.ts +51 -107
  4. package/assets/extensions/omnirush/bgshell-lib.ts +400 -0
  5. package/assets/extensions/omnirush/bgshell.ts +392 -0
  6. package/assets/extensions/omnirush/collector.ts +72 -11
  7. package/assets/extensions/omnirush/commands.ts +2 -0
  8. package/assets/extensions/omnirush/deliveries.ts +145 -0
  9. package/assets/extensions/omnirush/guard/UPSTREAM +2 -0
  10. package/assets/extensions/omnirush/guard/git-command-policy.ts +885 -0
  11. package/assets/extensions/omnirush/guard-lib.ts +230 -0
  12. package/assets/extensions/omnirush/guard.ts +340 -0
  13. package/assets/extensions/omnirush/index.ts +12 -0
  14. package/assets/extensions/omnirush/pi-engine.ts +65 -1
  15. package/assets/extensions/omnirush/sota.ts +52 -4
  16. package/assets/extensions/omnirush/status-lib.ts +3 -0
  17. package/assets/extensions/omnirush/subagents-lib.ts +623 -0
  18. package/assets/extensions/omnirush/subagents.ts +305 -0
  19. package/assets/extensions/omnirush/swarm-lib.ts +142 -0
  20. package/assets/extensions/omnirush/swarm.ts +95 -0
  21. package/assets/extensions/omnirush/voice/capture.ts +502 -0
  22. package/assets/extensions/omnirush/voice/core/UPSTREAM +16 -0
  23. package/assets/extensions/omnirush/voice/core/file-source.ts +70 -0
  24. package/assets/extensions/omnirush/voice/core/index.ts +21 -0
  25. package/assets/extensions/omnirush/voice/core/keyterms.ts +117 -0
  26. package/assets/extensions/omnirush/voice/core/resample.ts +63 -0
  27. package/assets/extensions/omnirush/voice/core/segmenter.ts +231 -0
  28. package/assets/extensions/omnirush/voice/core/session.ts +403 -0
  29. package/assets/extensions/omnirush/voice/core/text.ts +81 -0
  30. package/assets/extensions/omnirush/voice/core/transcriber.ts +135 -0
  31. package/assets/extensions/omnirush/voice/core/types.ts +102 -0
  32. package/assets/extensions/omnirush/voice/core/wav.ts +95 -0
  33. package/assets/extensions/omnirush/voice/keys.ts +435 -0
  34. package/assets/extensions/omnirush/voice/kitty.ts +64 -0
  35. package/assets/extensions/omnirush/voice/pvrecorder-worker.cjs +43 -0
  36. package/assets/extensions/omnirush/voice/settings.ts +67 -0
  37. package/assets/extensions/omnirush/voice.ts +838 -0
  38. package/assets/extensions/omnirush/yolo-lib.ts +80 -0
  39. package/assets/extensions/omnirush/yolo.ts +85 -0
  40. package/package.json +7 -3
  41. package/scripts/brand-engine.js +526 -0
  42. package/scripts/build-all-packages.py +29 -1
  43. package/scripts/smoke-packages.py +32 -1
  44. package/src/bin.js +205 -33
  45. package/src/compat.js +272 -0
  46. package/src/lib.js +64 -0
  47. package/scripts/patch-pi-branding.js +0 -251
@@ -0,0 +1,305 @@
1
+ // omnirush-subagents — the sub-agent model and effort picker (see
2
+ // subagents-lib.ts): the `/subagents` command, the --subagent-model /
3
+ // --subagent-effort flags, and the resolution spawn_agents applies to every
4
+ // child it starts.
5
+ //
6
+ // /subagents the current setting, what it resolves to
7
+ // now, and the models and efforts to pick
8
+ // /subagents model <id|same> sub-agents run on this model (saved)
9
+ // /subagents effort <level|same> ... at this effort (saved)
10
+ // /subagents reset both back to "same as main"
11
+ //
12
+ // The setting is saved in <omnirush dir>/settings.json; a flag or
13
+ // OMNIRUSH_SUBAGENT_MODEL / _EFFORT overrides it for one run (non-interactive
14
+ // use), and a /subagents change in the session wins over both.
15
+
16
+ import { omniDir } from "./auth";
17
+ import type { ChildTask } from "./agents-lib";
18
+ import {
19
+ displayName,
20
+ effectiveSetting,
21
+ effortOptions,
22
+ ENV_MAIN_EFFORT,
23
+ ENV_MAIN_MODEL,
24
+ ENV_SETTING_EFFORT,
25
+ ENV_SETTING_MODEL,
26
+ fallbackNote,
27
+ isModelId,
28
+ splitModelEffort,
29
+ isRefused,
30
+ levelsFor,
31
+ loadCatalog,
32
+ nearestEffort,
33
+ nextSetting,
34
+ parseEffort,
35
+ PROVIDER_ID,
36
+ readSetting,
37
+ resolveSubagentModel,
38
+ type AgentModel,
39
+ type CatalogModel,
40
+ type SettingSource,
41
+ type SubagentSetting,
42
+ writeSetting,
43
+ } from "./subagents-lib";
44
+
45
+ export const MODEL_FLAG = "subagent-model";
46
+ export const EFFORT_FLAG = "subagent-effort";
47
+
48
+ /** Changes made with /subagents in this session (they win over flags and the environment). */
49
+ const sessionOverrides = new WeakMap<object, Partial<SubagentSetting>>();
50
+
51
+ function flagValue(pi: any, name: string): string | undefined {
52
+ try {
53
+ const value = typeof pi?.getFlag === "function" ? pi.getFlag(name) : undefined;
54
+ return typeof value === "string" ? value : undefined;
55
+ } catch {
56
+ return undefined;
57
+ }
58
+ }
59
+
60
+ /** The setting this run uses, and where each field comes from. */
61
+ export function currentSetting(pi: any, dir: string = omniDir()): { setting: SubagentSetting; source: { model: SettingSource | "session"; effort: SettingSource | "session" } } {
62
+ const resolved = effectiveSetting({
63
+ saved: readSetting(dir),
64
+ env: process.env,
65
+ flags: { model: flagValue(pi, MODEL_FLAG), effort: flagValue(pi, EFFORT_FLAG) },
66
+ });
67
+ const override = sessionOverrides.get(pi) ?? {};
68
+ const setting = { ...resolved.setting };
69
+ const source: { model: SettingSource | "session"; effort: SettingSource | "session" } = { ...resolved.source };
70
+ if (override.model !== undefined) {
71
+ setting.model = override.model;
72
+ source.model = "session";
73
+ }
74
+ if (override.effort !== undefined) {
75
+ setting.effort = override.effort;
76
+ source.effort = "session";
77
+ }
78
+ return { setting, source };
79
+ }
80
+
81
+ /** This agent's model and effort (the delegating agent). */
82
+ function ownModel(pi: any, ctx: any): AgentModel {
83
+ const model = ctx?.model;
84
+ let effort: string | null = typeof ctx?.thinkingLevel === "string" ? ctx.thinkingLevel : null;
85
+ if (!effort) {
86
+ try {
87
+ effort = typeof pi?.getThinkingLevel === "function" ? pi.getThinkingLevel() ?? null : null;
88
+ } catch {
89
+ effort = null;
90
+ }
91
+ }
92
+ // The parent session's current selection (as modelSelectionFromContext
93
+ // reads it): an id may carry its effort as "model:effort".
94
+ const rawId = typeof model?.id === "string" ? model.id : typeof model?.modelID === "string" ? model.modelID : null;
95
+ const split = rawId ? splitModelEffort(rawId) : null;
96
+ return {
97
+ provider: typeof model?.provider === "string" ? model.provider : null,
98
+ model: split ? split.model : null,
99
+ effort: split?.effort ?? effort,
100
+ };
101
+ }
102
+
103
+ /** The main session's model and effort: handed down by the parent, or this agent's own at the top. */
104
+ export function mainModel(pi: any, ctx: any, env: NodeJS.ProcessEnv = process.env): AgentModel {
105
+ const handed = (env[ENV_MAIN_MODEL] ?? "").trim();
106
+ if (isModelId(handed)) {
107
+ return { provider: PROVIDER_ID, model: handed, effort: (env[ENV_MAIN_EFFORT] ?? "").trim() || null };
108
+ }
109
+ return ownModel(pi, ctx);
110
+ }
111
+
112
+ /**
113
+ * Every task of a spawn_agents call with the model and effort it runs on:
114
+ * the picked sub-agent model (or the task's own), the fallback to the main
115
+ * model when the picked one is not available, and the environment that hands
116
+ * the setting and the main model down to nested layers.
117
+ */
118
+ export function subagentTasks(pi: any, ctx: any, tasks: ChildTask[], options: { dir?: string; env?: NodeJS.ProcessEnv; now?: number } = {}): ChildTask[] {
119
+ const dir = options.dir ?? omniDir();
120
+ const env = options.env ?? process.env;
121
+ const { setting } = currentSetting(pi, dir);
122
+ const catalog = loadCatalog(dir);
123
+ const delegating = ownModel(pi, ctx);
124
+ const main = mainModel(pi, ctx, env);
125
+ const handDown: Record<string, string> = {
126
+ [ENV_SETTING_MODEL]: setting.model ?? "same",
127
+ [ENV_SETTING_EFFORT]: setting.effort ?? "same",
128
+ };
129
+ if (main.provider === PROVIDER_ID && main.model) {
130
+ handDown[ENV_MAIN_MODEL] = main.model;
131
+ if (main.effort) handDown[ENV_MAIN_EFFORT] = main.effort;
132
+ }
133
+ return tasks.map((task) => {
134
+ const resolution = resolveSubagentModel({
135
+ setting,
136
+ catalog,
137
+ delegating,
138
+ main,
139
+ explicit: task.model ?? null,
140
+ refused: (model) => Boolean(isRefused(dir, model, options.now)),
141
+ });
142
+ const next: ChildTask = { ...task, env: { ...(task.env ?? {}), ...handDown } };
143
+ if (resolution.model) next.model = resolution.model;
144
+ else delete next.model;
145
+ if (resolution.model && resolution.effort) next.effort = resolution.effort;
146
+ if (resolution.fallback) next.fallback = resolution.fallback;
147
+ if (resolution.gatewayFallback) next.gatewayFallback = resolution.gatewayFallback;
148
+ return next;
149
+ });
150
+ }
151
+
152
+ function describeSource(source: SettingSource | "session"): string {
153
+ switch (source) {
154
+ case "flag": return " (from --subagent-* flag)";
155
+ case "env": return " (from OMNIRUSH_SUBAGENT_* environment)";
156
+ case "session": return " (changed in this session)";
157
+ default: return "";
158
+ }
159
+ }
160
+
161
+ /** The /subagents status text. */
162
+ export function renderSubagentStatus(input: {
163
+ setting: SubagentSetting;
164
+ source: { model: SettingSource | "session"; effort: SettingSource | "session" };
165
+ catalog: readonly CatalogModel[] | null;
166
+ main: AgentModel;
167
+ refused?: (model: string) => { reason: string } | null;
168
+ }): string {
169
+ const { setting, source, catalog, main } = input;
170
+ const lines: string[] = [];
171
+ const mainName = main.model ? displayName(catalog, main.model) : "the main agent's model";
172
+ lines.push(`Sub-agents: model ${setting.model ? `${displayName(catalog, setting.model)} (${setting.model})` : "same as main"}${describeSource(source.model)}`);
173
+ lines.push(`Sub-agent effort: ${setting.effort ?? "same as main"}${describeSource(source.effort)}`);
174
+ const resolution = resolveSubagentModel({
175
+ setting,
176
+ catalog,
177
+ delegating: main,
178
+ main,
179
+ refused: (model) => Boolean(input.refused?.(model)),
180
+ });
181
+ if (resolution.model) {
182
+ const effort = resolution.effort ? ` at ${resolution.effort}` : "";
183
+ lines.push(`Now: sub-agents run on ${displayName(catalog, resolution.model)}${effort}${resolution.fallback ? ` — ${fallbackNote(resolution.fallback)}` : ""}.`);
184
+ if (resolution.gatewayFallback) {
185
+ lines.push(`If omnirush.ai refuses ${displayName(catalog, resolution.model)}, they continue on ${mainName}.`);
186
+ }
187
+ }
188
+ lines.push("");
189
+ if (catalog) {
190
+ lines.push("Models (your account):");
191
+ for (const model of catalog) {
192
+ lines.push(` ${model.id.padEnd(28)} ${model.name}${model.levels.length ? ` — ${model.levels.join(", ")}` : ""}`);
193
+ }
194
+ } else {
195
+ lines.push("The account's model list is not available (offline start): any model id is accepted.");
196
+ }
197
+ lines.push("");
198
+ lines.push("Change: /subagents model <id|same>, /subagents effort <level|same>, /subagents reset");
199
+ return lines.join("\n");
200
+ }
201
+
202
+ export default function (pi: any) {
203
+ pi.registerFlag?.(MODEL_FLAG, {
204
+ description: 'Model the sub-agents (spawn_agents) run on for this run: a model id of your account, or "same" (the main agent\'s)',
205
+ type: "string",
206
+ });
207
+ pi.registerFlag?.(EFFORT_FLAG, {
208
+ description: 'Effort of the sub-agents for this run: minimal, low, medium, high, xhigh, max (limited to the model\'s levels), or "same" (the main agent\'s)',
209
+ type: "string",
210
+ });
211
+
212
+ const words = ["model", "effort", "reset"];
213
+ pi.registerCommand("subagents", {
214
+ description: "Model and effort the sub-agents run on: /subagents model <id|same>, /subagents effort <level|same>",
215
+ getArgumentCompletions: (prefix: string) => {
216
+ const text = String(prefix ?? "");
217
+ const [first = "", ...rest] = text.split(/\s+/);
218
+ if (rest.length === 0) {
219
+ // A complete word offers nothing, so Enter runs the command at once.
220
+ if (first === "reset") return null;
221
+ const items = words.filter((word) => word.startsWith(first)).map((word) => ({ value: word === "reset" ? word : `${word} `, label: word }));
222
+ return items.length ? items : null;
223
+ }
224
+ const partial = rest.join(" ");
225
+ const dir = omniDir();
226
+ const catalog = loadCatalog(dir);
227
+ let values: string[] = [];
228
+ if (first === "model") values = ["same", ...(catalog ?? []).map((model) => model.id)];
229
+ else if (first === "effort") values = ["same", ...effortOptions(catalog, currentSetting(pi, dir).setting.model)];
230
+ // A complete value offers nothing: Enter then runs `/subagents model gpt-6-sol`
231
+ // instead of first accepting the same text from the list.
232
+ if (values.includes(partial)) return null;
233
+ const items = values.filter((value) => value.startsWith(partial)).map((value) => ({ value: `${first} ${value}`, label: value }));
234
+ return items.length ? items : null;
235
+ },
236
+ handler: async (args: string, ctx: any) => {
237
+ const notify = (text: string, level: "info" | "warning" | "error" = "info") => {
238
+ // Print and json runs have no UI: the text goes to the terminal.
239
+ if (ctx?.hasUI !== false && ctx?.ui?.notify) ctx.ui.notify(text, level);
240
+ else process.stdout.write(`${text}\n`);
241
+ };
242
+ const dir = omniDir();
243
+ const catalog = loadCatalog(dir);
244
+ const [command = "", value = ""] = String(args ?? "").trim().split(/\s+/);
245
+ const { setting: current } = currentSetting(pi, dir);
246
+ const main = mainModel(pi, ctx);
247
+ const show = () => {
248
+ const now = currentSetting(pi, dir);
249
+ notify(renderSubagentStatus({ ...now, catalog, main, refused: (model) => isRefused(dir, model) }));
250
+ };
251
+ if (!command) return show();
252
+
253
+ let patch: Partial<SubagentSetting>;
254
+ if (command === "reset") {
255
+ patch = { model: null, effort: null };
256
+ } else if (command === "model") {
257
+ if (!value) return notify("Usage: /subagents model <id|same>", "warning");
258
+ if (/^(same|main|default|inherit)$/i.test(value)) {
259
+ patch = { model: null };
260
+ } else {
261
+ if (!isModelId(value)) return notify(`Not a model id: ${value}`, "error");
262
+ if (catalog && !catalog.some((model) => model.id === value)) {
263
+ return notify(`${value} is not in your account's models: ${catalog.map((model) => model.id).join(", ")}`, "error");
264
+ }
265
+ patch = { model: value };
266
+ }
267
+ } else if (command === "effort") {
268
+ if (!value) return notify("Usage: /subagents effort <level|same>", "warning");
269
+ if (/^(same|main|default|inherit)$/i.test(value)) {
270
+ patch = { effort: null };
271
+ } else {
272
+ const effort = parseEffort(value);
273
+ const offered = effortOptions(catalog, current.model);
274
+ if (!effort || !offered.includes(effort)) {
275
+ const who = current.model ? displayName(catalog, current.model) : "your models";
276
+ return notify(`${value} is not an effort ${who} offers: ${offered.join(", ")} (or same)`, "error");
277
+ }
278
+ patch = { effort };
279
+ }
280
+ } else {
281
+ return notify("Usage: /subagents [model <id|same> | effort <level|same> | reset]", "warning");
282
+ }
283
+
284
+ const next = nextSetting(catalog, current, patch);
285
+ // Only what changed is saved: a flag or environment value stays a one-run override.
286
+ const saved = readSetting(dir);
287
+ writeSetting(dir, {
288
+ model: patch.model !== undefined ? next.model : saved.model,
289
+ effort: patch.effort !== undefined || next.effort !== current.effort ? next.effort : saved.effort,
290
+ });
291
+ const override = { ...(sessionOverrides.get(pi) ?? {}) };
292
+ if (patch.model !== undefined) override.model = next.model;
293
+ if (patch.effort !== undefined || next.effort !== current.effort) override.effort = next.effort;
294
+ sessionOverrides.set(pi, override);
295
+ if (patch.model !== undefined && current.effort && next.effort === null) {
296
+ notify(`Sub-agent effort ${current.effort} is not offered by ${next.model ? displayName(catalog, next.model) : "the main model"}: back to same as main.`, "warning");
297
+ }
298
+ if (next.effort && next.model === null && main.model) {
299
+ const mapped = nearestEffort(next.effort, levelsFor(catalog, main.model));
300
+ if (mapped && mapped !== next.effort) notify(`${displayName(catalog, main.model)} has no ${next.effort}: its sub-agents run at ${mapped}.`, "info");
301
+ }
302
+ show();
303
+ },
304
+ });
305
+ }
@@ -0,0 +1,142 @@
1
+ // swarm-lib — the sub-agent swarm board, as in the omnirush desktop
2
+ // (omnirush-gui apps/server/src/omnirush-swarm.ts).
3
+ //
4
+ // Only a real swarm gets a board: 3 or more parallel sub-agents, or the user
5
+ // asking for a swarm or for several agents. The procedure is the on-demand
6
+ // omnirush-swarm skill (SWARM_SKILL); the always-on prompt only carries
7
+ // SWARM_GUIDELINE, which says when to load it. The board is
8
+ // `.omnirush/swarm.md` in the workspace, never the project root, kept out of
9
+ // git by `.omnirush/.gitignore`, and archived to `.omnirush/swarms/` when the
10
+ // swarm ends. Pure helpers plus small file operations on an explicit
11
+ // workspace root, so node:test covers them without an agent run.
12
+
13
+ import { existsSync } from "node:fs";
14
+ import { appendFile, mkdir, readFile, rename, writeFile } from "node:fs/promises";
15
+ import path from "node:path";
16
+
17
+ /** The folder omnirush keeps its workspace files in (also the project settings folder). */
18
+ export const WORKSPACE_DIR = ".omnirush";
19
+ /** The board of a running swarm, relative to the workspace root. */
20
+ export const SWARM_FILE = `${WORKSPACE_DIR}/swarm.md`;
21
+ /** Finished boards (`<stamp>.md`), relative to the workspace root. */
22
+ export const SWARM_ARCHIVE_DIR = `${WORKSPACE_DIR}/swarms`;
23
+ /** `.omnirush/.gitignore` entries: the board, its archive and (in a file omnirush creates) that file itself. */
24
+ export const SWARM_GITIGNORE_LINES = ["/.gitignore", "/swarm.md", "/swarms/"] as const;
25
+ /** Fewest parallel sub-agents that make a swarm. */
26
+ export const SWARM_MIN_AGENTS = 3;
27
+ export const SWARM_SKILL_NAME = "omnirush-swarm";
28
+ /**
29
+ * Swarm layer of this process, handed to children through the environment:
30
+ * unset in a main session, 1 in its sub-agents, 2 in theirs.
31
+ */
32
+ export const SWARM_LAYER_ENV = "OMNIRUSH_SWARM_LAYER";
33
+
34
+ export const SWARM_SKILL_DESCRIPTION = `Required before starting ${SWARM_MIN_AGENTS} or more sub-agents for one request (a swarm, or the user asks for several agents): the shared-board procedure. Never for 1-2 sub-agents or a small request.`;
35
+
36
+ /** The always-on line (a spawn_agents guideline). */
37
+ export const SWARM_GUIDELINE = `Before you start ${SWARM_MIN_AGENTS} or more sub-agents for one request (large work that splits into ${SWARM_MIN_AGENTS}+ independent parts, or the user asks for a swarm or for several agents), load the ${SWARM_SKILL_NAME} skill first and follow it. Otherwise answer directly, or use at most 1-2 sub-agents with spawn_agents and no board or coordination file.`;
38
+
39
+ export const SWARM_SKILL = `# Sub-agent swarm
40
+
41
+ Use this only for a real swarm: ${SWARM_MIN_AGENTS} or more sub-agents working in parallel, or the user asked for a swarm or for several agents. For 1-2 sub-agents, stop here: call spawn_agents directly and do not create or touch a board.
42
+
43
+ The board is \`${SWARM_FILE}\` in the workspace, never a file in the project root. omnirush keeps \`${SWARM_FILE}\` and \`${SWARM_ARCHIVE_DIR}/\` out of git. Ignore any \`swarm.md\` in the project root: it is not this swarm's board.
44
+
45
+ 1. If \`${SWARM_FILE}\` already exists, it is left over from an earlier swarm: move it to \`${SWARM_ARCHIVE_DIR}/<YYYYMMDD-HHMMSS>.md\` and start a fresh board.
46
+ 2. Before delegating, create \`${SWARM_FILE}\` with: \`# Goal\` (one paragraph), \`## Tasks\` (a table: id, task, owner, status, result; ids like T1, T2, and T1.1 for a sub-task of T1; status is todo, running, done or blocked), \`## Findings\` (shared facts, one bullet each, with the task id), \`## Decisions\` (choices every agent must follow).
47
+ 3. Give each sub-agent one task id. Its spawn_agents task text names the id, the task, and says: read \`${SWARM_FILE}\` first; set your row to running; when done, set it to done with a one-line result and append your findings under your id; edit only your own rows and sections; report back briefly.
48
+ 4. Sub-agents may split their own task: they add sub-task rows (T1.1, T1.2) and give the same instructions to their own sub-agents. Keep nesting to at most 3 layers.
49
+ 5. Start the independent tasks together in one spawn_agents call (one entry per task) with the default blocking wait, not wait:false: the board must be complete before you merge. Give tasks that edit the same files to one agent.
50
+ 6. When every task is done, read \`${SWARM_FILE}\`, check and merge the results, and answer the user.
51
+ 7. Then archive the board: move \`${SWARM_FILE}\` to \`${SWARM_ARCHIVE_DIR}/<YYYYMMDD-HHMMSS>.md\` (create the folder if needed), so no later request picks it up. If the user asked to keep the board, name the archived path in your answer.`;
52
+
53
+ /** The omnirush-swarm skill's SKILL.md. */
54
+ export function swarmSkillMarkdown(): string {
55
+ return `---\nname: ${SWARM_SKILL_NAME}\ndescription: ${JSON.stringify(SWARM_SKILL_DESCRIPTION)}\n---\n\n${SWARM_SKILL}\n`;
56
+ }
57
+
58
+ /** Writes `<skillsDir>/omnirush-swarm/SKILL.md` unless it is already current; returns the skills folder. */
59
+ export async function writeSwarmSkill(skillsDir: string): Promise<string> {
60
+ const directory = path.join(skillsDir, SWARM_SKILL_NAME);
61
+ const file = path.join(directory, "SKILL.md");
62
+ const content = swarmSkillMarkdown();
63
+ if ((await readFile(file, "utf8").catch(() => null)) !== content) {
64
+ await mkdir(directory, { recursive: true });
65
+ const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
66
+ await writeFile(tmp, content, "utf8");
67
+ await rename(tmp, file);
68
+ }
69
+ return skillsDir;
70
+ }
71
+
72
+ /** The swarm layer this process runs at (0: a main session). */
73
+ export function swarmLayer(env: NodeJS.ProcessEnv = process.env): number {
74
+ const layer = Number(String(env[SWARM_LAYER_ENV] ?? "").trim());
75
+ return Number.isSafeInteger(layer) && layer > 0 ? layer : 0;
76
+ }
77
+
78
+ /** System text for a sub-agent of a running swarm (appended while the board exists). */
79
+ export function swarmSubagentNote(layer: number): string {
80
+ return [
81
+ `You are a sub-agent (layer ${layer}) in a swarm coordinated through \`${SWARM_FILE}\` in the workspace.`,
82
+ `Read \`${SWARM_FILE}\` before you start. Find your task id in your task (or the row that matches your task), set its status to running, and when you finish set it to done with a one-line result and append your findings under your task id. Edit only your own rows and sections, and re-read the file right before each edit because other agents write to it too. Do not move or archive the board: the main agent does that.`,
83
+ layer < 3
84
+ ? "If your task splits into independent parts that each take real effort, you may delegate them with spawn_agents: add sub-task rows (for example T1.1, T1.2) first and give each sub-agent the same instructions. Otherwise do the work yourself."
85
+ : "Do not delegate further: do the work yourself.",
86
+ ].join("\n");
87
+ }
88
+
89
+ function slashes(value: unknown): string {
90
+ return typeof value === "string" ? value.replace(/\\/g, "/") : "";
91
+ }
92
+
93
+ /** Whether a tool call reads the omnirush-swarm skill (pi loads a skill by reading its SKILL.md). */
94
+ export function readsSwarmSkill(toolName: string, input: unknown): boolean {
95
+ if (toolName !== "read" || typeof input !== "object" || input === null) return false;
96
+ return slashes((input as Record<string, unknown>).path).endsWith(`/${SWARM_SKILL_NAME}/SKILL.md`);
97
+ }
98
+
99
+ /** Whether a tool call writes or moves the swarm board. */
100
+ export function touchesBoard(toolName: string, input: unknown): boolean {
101
+ if (typeof input !== "object" || input === null) return false;
102
+ const args = input as Record<string, unknown>;
103
+ if (toolName === "write" || toolName === "edit") {
104
+ const file = slashes(args.path);
105
+ return file === SWARM_FILE || file.endsWith(`/${SWARM_FILE}`);
106
+ }
107
+ if (toolName === "bash") return slashes(args.command).includes(SWARM_FILE);
108
+ return false;
109
+ }
110
+
111
+ /** Keeps the board and its archive out of git; a user's own `.omnirush/.gitignore` only gains the missing lines. */
112
+ export async function ignoreBoard(root: string): Promise<void> {
113
+ const folder = path.join(root, WORKSPACE_DIR);
114
+ const file = path.join(folder, ".gitignore");
115
+ await mkdir(folder, { recursive: true });
116
+ const current = await readFile(file, "utf8").catch(() => null);
117
+ if (current === null) {
118
+ await writeFile(file, `# omnirush: the swarm board and its archive stay out of git\n${SWARM_GITIGNORE_LINES.join("\n")}\n`, "utf8");
119
+ return;
120
+ }
121
+ const lines = new Set(current.split(/\r?\n/).map((line) => line.trim()));
122
+ // A user's own file (project settings live here too) is theirs to track.
123
+ const missing = SWARM_GITIGNORE_LINES.filter((line) => line !== "/.gitignore" && !lines.has(line));
124
+ if (missing.length) await appendFile(file, `${current && !current.endsWith("\n") ? "\n" : ""}${missing.join("\n")}\n`, "utf8");
125
+ }
126
+
127
+ /** A board archive name for `date`: `.omnirush/swarms/YYYYMMDD-HHMMSS.md` (UTC). */
128
+ export function swarmArchiveName(date: Date = new Date()): string {
129
+ const stamp = date.toISOString().replace(/[-:]/g, "").replace("T", "-").slice(0, 15);
130
+ return `${SWARM_ARCHIVE_DIR}/${stamp}.md`;
131
+ }
132
+
133
+ /** Moves the board to `.omnirush/swarms/<stamp>.md`; returns the new relative path, or null without a board. */
134
+ export async function archiveBoard(root: string, now: Date = new Date()): Promise<string | null> {
135
+ const board = path.join(root, SWARM_FILE);
136
+ if (!existsSync(board)) return null;
137
+ await mkdir(path.join(root, SWARM_ARCHIVE_DIR), { recursive: true });
138
+ let name = swarmArchiveName(now);
139
+ for (let n = 2; existsSync(path.join(root, name)); n += 1) name = swarmArchiveName(now).replace(/\.md$/, `-${n}.md`);
140
+ await rename(board, path.join(root, name));
141
+ return name;
142
+ }
@@ -0,0 +1,95 @@
1
+ // swarm — the sub-agent swarm board (see swarm-lib.ts).
2
+ //
3
+ // - contributes the on-demand omnirush-swarm skill to main sessions
4
+ // (resources_discover; sub-agents never start a swarm);
5
+ // the always-on prompt only has the spawn_agents guideline that says when
6
+ // to load it (SWARM_GUIDELINE, agents.ts)
7
+ // - a main session runs a swarm once it reads that skill or writes the
8
+ // board: a board left by an earlier swarm is archived, `.omnirush/` gets
9
+ // its .gitignore, and children spawned from then on inherit
10
+ // OMNIRUSH_SWARM_LAYER, so only a running swarm's sub-agents are told
11
+ // about the board (while it exists)
12
+ // - when the main session settles, a board the main agent left is moved to
13
+ // `.omnirush/swarms/`, so a later request never finds a stale one
14
+
15
+ import path from "node:path";
16
+ import { existsSync } from "node:fs";
17
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
18
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
19
+
20
+ import {
21
+ SWARM_FILE,
22
+ SWARM_LAYER_ENV,
23
+ archiveBoard,
24
+ ignoreBoard,
25
+ readsSwarmSkill,
26
+ swarmLayer,
27
+ swarmSubagentNote,
28
+ touchesBoard,
29
+ writeSwarmSkill,
30
+ } from "./swarm-lib";
31
+
32
+ export default function (pi: ExtensionAPI, options: { skillsDir?: string; env?: NodeJS.ProcessEnv } = {}) {
33
+ const env = options.env ?? process.env;
34
+ /** This process's layer in a swarm (0: a main session, which may start one). */
35
+ const layer = swarmLayer(env);
36
+ // Grandchildren of a swarm sub-agent are one layer further down.
37
+ if (layer > 0) env[SWARM_LAYER_ENV] = String(layer + 1);
38
+ /** A main session's swarm is running (set by the skill read or a board write). */
39
+ let running = false;
40
+ let cwd = process.cwd();
41
+
42
+ const rootOf = (ctx: any): string => {
43
+ if (ctx?.cwd) cwd = String(ctx.cwd);
44
+ return cwd;
45
+ };
46
+
47
+ const start = async (root: string, archiveStale: boolean) => {
48
+ if (layer > 0 || running) return;
49
+ running = true;
50
+ env[SWARM_LAYER_ENV] = "1";
51
+ try {
52
+ if (archiveStale) await archiveBoard(root);
53
+ await ignoreBoard(root);
54
+ } catch {
55
+ // Best effort: the swarm runs either way.
56
+ }
57
+ };
58
+
59
+ const end = async (root: string) => {
60
+ if (!running) return;
61
+ running = false;
62
+ delete env[SWARM_LAYER_ENV];
63
+ await archiveBoard(root).catch(() => null);
64
+ };
65
+
66
+ pi.on("resources_discover", async () => {
67
+ // Sub-agents never start a swarm (a running swarm's get the board note),
68
+ // so the skill is only listed for a main session.
69
+ if (String(env.OMNIRUSH_PARENT_SESSION ?? "").trim()) return {};
70
+ try {
71
+ const skillsDir = options.skillsDir ?? path.join(getAgentDir(), "omnirush-skills");
72
+ return { skillPaths: [await writeSwarmSkill(skillsDir)] };
73
+ } catch {
74
+ return {};
75
+ }
76
+ });
77
+
78
+ pi.on("tool_call", async (event: any, ctx: any) => {
79
+ const toolName = String(event?.toolName ?? "");
80
+ if (readsSwarmSkill(toolName, event?.input)) await start(rootOf(ctx), true);
81
+ else if (touchesBoard(toolName, event?.input)) await start(rootOf(ctx), false);
82
+ });
83
+
84
+ // A swarm sub-agent follows the board while it exists.
85
+ pi.on("before_agent_start", async (event: any, ctx: any) => {
86
+ if (layer === 0 || !existsSync(path.join(rootOf(ctx), SWARM_FILE))) return;
87
+ const base = typeof event?.systemPrompt === "string" ? event.systemPrompt : "";
88
+ return { systemPrompt: `${base}\n\n${swarmSubagentNote(layer)}` };
89
+ });
90
+
91
+ pi.on("agent_settled", async (_event: any, ctx: any) => {
92
+ if (typeof ctx?.isIdle === "function" && !ctx.isIdle()) return;
93
+ await end(rootOf(ctx));
94
+ });
95
+ }