omnirush 0.9.0 → 0.10.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.
@@ -0,0 +1,222 @@
1
+ // Sub-agent sessions out of the user's resume list.
2
+ //
3
+ // spawn_agents children are headless agent runs with session files of
4
+ // their own. Up to 0.9.0 they were written next to the user's sessions
5
+ // (<agentDir>/sessions/<cwd-slug>/), so the core's session picker
6
+ // (/resume) listed every sub-agent run and --continue picked whichever
7
+ // session file changed last — often a background sub-agent's rather than
8
+ // the user's conversation. Children now go to <session dir>/subagents/
9
+ // (agents-lib.ts subagentSessionDir), which the core never lists.
10
+ //
11
+ // moveLegacySubagentSessions puts the children older releases left in a
12
+ // session dir into that folder too (moved, never deleted: the collector
13
+ // still finds them there when a resumed session records its sub-agents).
14
+ // It runs at every agent launch before the core picks a session, and
15
+ // stays cheap on large folders: a marker per session dir records when it
16
+ // was last sorted, and only files changed since then are opened (their
17
+ // first lines only). Files changed in the last minute are left for the
18
+ // next launch, in case an older release is still writing them.
19
+ //
20
+ // A child is recognised from the top of its session file, by the same
21
+ // signals the collector uses (pi-engine.ts subagentTask): the marker entry
22
+ // a sub-agent writes into its own session before its task
23
+ // (SUBAGENT_ENTRY_TYPE), or — sessions of releases that wrote no marker —
24
+ // a first user message "Task: <task>" (SUBAGENT_TASK_PREFIX), confirmed by
25
+ // the role prompt every sub-agent has run with since 0.5.4 (appended to
26
+ // the system prompt: "You are a <role> subagent ..."), which the file
27
+ // itself carries, so no other session has to be read.
28
+
29
+ import fs from "node:fs";
30
+ import path from "node:path";
31
+
32
+ /** Same name as agents-lib.ts SUBAGENT_SESSIONS_DIR. */
33
+ export const SUBAGENT_SESSIONS_DIR = "subagents";
34
+ /** Same values as pi-engine.ts SUBAGENT_ENTRY_TYPE / SUBAGENT_TASK_PREFIX. */
35
+ export const SUBAGENT_ENTRY_TYPE = "omnirush-subagent";
36
+ export const SUBAGENT_TASK_PREFIX = "Task: ";
37
+ /** Per session dir: when it was last sorted (a .json file: the core only lists *.jsonl). */
38
+ export const SORT_MARKER = ".subagents-sorted.json";
39
+ /** Files changed more recently than this are left for the next launch. */
40
+ export const SETTLE_MS = 60_000;
41
+ /** How much of a session file is read looking for its first user message. */
42
+ const HEAD_LIMIT = 1024 * 1024;
43
+ const CHUNK = 64 * 1024;
44
+
45
+ const ROLE_PROMPT = /\bYou are an? [\w-]+ subagent\b/;
46
+
47
+ function textOf(content) {
48
+ if (typeof content === "string") return content;
49
+ if (!Array.isArray(content)) return "";
50
+ return content.map((part) => (part && typeof part.text === "string" ? part.text : "")).join("");
51
+ }
52
+
53
+ /**
54
+ * Whether a session file is a sub-agent run: a sub-agent marker entry
55
+ * before its first user message, or a first user message "Task: ..."
56
+ * after a system prompt with a sub-agent role. Reads the file only up to
57
+ * that first user message.
58
+ */
59
+ export function isSubagentSession(file, fsImpl = fs) {
60
+ let fd;
61
+ try {
62
+ fd = fsImpl.openSync(file, "r");
63
+ } catch {
64
+ return false;
65
+ }
66
+ try {
67
+ const buffer = Buffer.alloc(CHUNK);
68
+ let pending = "";
69
+ let read = 0;
70
+ let header = null;
71
+ let rolePrompt = false;
72
+ while (read < HEAD_LIMIT) {
73
+ const bytes = fsImpl.readSync(fd, buffer, 0, CHUNK, read);
74
+ if (bytes <= 0) break;
75
+ read += bytes;
76
+ pending += buffer.toString("utf8", 0, bytes);
77
+ let newline;
78
+ while ((newline = pending.indexOf("\n")) !== -1) {
79
+ const line = pending.slice(0, newline);
80
+ pending = pending.slice(newline + 1);
81
+ if (!line.trim()) continue;
82
+ let entry;
83
+ try {
84
+ entry = JSON.parse(line);
85
+ } catch {
86
+ return false;
87
+ }
88
+ if (!header) {
89
+ // A fork (parentSession) is the user's own branch of a session.
90
+ if (entry?.type !== "session" || entry.parentSession) return false;
91
+ header = entry;
92
+ continue;
93
+ }
94
+ if (entry?.type === "custom" && entry.customType === SUBAGENT_ENTRY_TYPE) return true;
95
+ if (entry?.type !== "message") continue;
96
+ const message = entry.message;
97
+ if (message?.role === "system") {
98
+ const addendum = message.sections?.addendum;
99
+ if (typeof addendum === "string" && ROLE_PROMPT.test(addendum)) rolePrompt = true;
100
+ continue;
101
+ }
102
+ if (message?.role === "user") return rolePrompt && textOf(message.content).startsWith(SUBAGENT_TASK_PREFIX);
103
+ }
104
+ }
105
+ return false;
106
+ } catch {
107
+ return false;
108
+ } finally {
109
+ try {
110
+ fsImpl.closeSync(fd);
111
+ } catch {
112
+ /* closed */
113
+ }
114
+ }
115
+ }
116
+
117
+ function readMarker(dir, fsImpl) {
118
+ try {
119
+ const value = JSON.parse(fsImpl.readFileSync(path.join(dir, SORT_MARKER), "utf8"));
120
+ return Number.isFinite(value?.sortedUpTo) ? value.sortedUpTo : 0;
121
+ } catch {
122
+ return 0;
123
+ }
124
+ }
125
+
126
+ /**
127
+ * Move the sub-agent sessions older releases left in `dir` into
128
+ * dir/subagents/. Returns how many were moved. Never throws.
129
+ */
130
+ export function sortSessionDir(dir, { fsImpl = fs, now = Date.now } = {}) {
131
+ let moved = 0;
132
+ try {
133
+ const since = readMarker(dir, fsImpl);
134
+ const cutoff = now() - SETTLE_MS;
135
+ let names;
136
+ try {
137
+ names = fsImpl.readdirSync(dir);
138
+ } catch {
139
+ return 0;
140
+ }
141
+ let sawFiles = false;
142
+ for (const name of names) {
143
+ if (!name.endsWith(".jsonl")) continue;
144
+ sawFiles = true;
145
+ const file = path.join(dir, name);
146
+ let stats;
147
+ try {
148
+ stats = fsImpl.statSync(file);
149
+ } catch {
150
+ continue;
151
+ }
152
+ if (!stats.isFile()) continue;
153
+ // Sorted already (a user session): only changes since are looked at.
154
+ if (stats.mtimeMs < since) continue;
155
+ // Maybe still being written by an older release: next launch.
156
+ if (stats.mtimeMs >= cutoff) continue;
157
+ if (!isSubagentSession(file, fsImpl)) continue;
158
+ const target = path.join(dir, SUBAGENT_SESSIONS_DIR);
159
+ try {
160
+ fsImpl.mkdirSync(target, { recursive: true });
161
+ const dest = path.join(target, name);
162
+ if (fsImpl.existsSync(dest)) continue;
163
+ fsImpl.renameSync(file, dest);
164
+ moved++;
165
+ } catch {
166
+ /* left where it is; tried again next launch */
167
+ }
168
+ }
169
+ if (sawFiles) {
170
+ const tmp = path.join(dir, `${SORT_MARKER}.${process.pid}.tmp`);
171
+ fsImpl.writeFileSync(tmp, JSON.stringify({ version: 1, sortedUpTo: cutoff }) + "\n");
172
+ fsImpl.renameSync(tmp, path.join(dir, SORT_MARKER));
173
+ }
174
+ } catch {
175
+ /* best effort: never in the way of a launch */
176
+ }
177
+ return moved;
178
+ }
179
+
180
+ /**
181
+ * Sort every project's session dir under <agentDir>/sessions (the picker's
182
+ * "all" scope lists them all) plus any custom session dir in use.
183
+ */
184
+ export function moveLegacySubagentSessions({ agentDir, extraDirs = [], fsImpl = fs, now = Date.now } = {}) {
185
+ const dirs = [];
186
+ const root = path.join(agentDir, "sessions");
187
+ try {
188
+ for (const entry of fsImpl.readdirSync(root, { withFileTypes: true })) {
189
+ if (entry.isDirectory()) dirs.push(path.join(root, entry.name));
190
+ }
191
+ } catch {
192
+ /* no sessions yet */
193
+ }
194
+ for (const dir of extraDirs) {
195
+ if (typeof dir === "string" && dir && !dirs.includes(path.resolve(dir))) dirs.push(path.resolve(dir));
196
+ }
197
+ let moved = 0;
198
+ for (const dir of dirs) moved += sortSessionDir(dir, { fsImpl, now });
199
+ return moved;
200
+ }
201
+
202
+ /**
203
+ * The custom session dir an agent launch uses, if any: --session-dir,
204
+ * else OMNIRUSH_/PI_CODING_AGENT_SESSION_DIR, else the "sessionDir"
205
+ * setting (as the core resolves it).
206
+ */
207
+ export function customSessionDir(args, env, settingsFile, home, fsImpl = fs) {
208
+ const expand = (value) => (value === "~" ? home : value.startsWith("~/") ? path.join(home, value.slice(2)) : value);
209
+ for (let i = 0; i < args.length; i++) {
210
+ if (args[i] === "--session-dir" && args[i + 1]) return expand(args[i + 1]);
211
+ if (args[i].startsWith("--session-dir=")) return expand(args[i].slice("--session-dir=".length));
212
+ }
213
+ const fromEnv = env.OMNIRUSH_CODING_AGENT_SESSION_DIR || env.PI_CODING_AGENT_SESSION_DIR;
214
+ if (fromEnv) return expand(fromEnv);
215
+ try {
216
+ const value = JSON.parse(fsImpl.readFileSync(settingsFile, "utf8"))?.sessionDir;
217
+ if (typeof value === "string" && value.trim()) return expand(value.trim());
218
+ } catch {
219
+ /* no settings */
220
+ }
221
+ return null;
222
+ }