omnirush 0.9.0 → 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.
@@ -7,9 +7,16 @@
7
7
  // - appends an `omnirush.plan` custom entry to the session JSONL (the
8
8
  // tool call itself, with the whole list, reaches the trace as a tool
9
9
  // part of the session's engine messages),
10
- // - renders one line in the footer via ctx.ui.setStatus,
10
+ // - renders one line in the footer via ctx.ui.setStatus ("tasks 1/3 ·
11
+ // ▸ …": a checklist, never a mode), marked idle between requests and
12
+ // stale when the agent keeps working without updating it; a finished
13
+ // list clears,
11
14
  // - returns the full list as the tool result.
12
15
  //
16
+ // A sub-agent (OMNIRUSH_PARENT_SESSION set) runs in its parent's folder:
17
+ // it keeps its list in its own session only, so it never overwrites the
18
+ // parent's plan.json.
19
+ //
13
20
  // Full-replace semantics (like codex update_plan): every call sends
14
21
  // the WHOLE list with current statuses.
15
22
 
@@ -27,6 +34,7 @@ import {
27
34
  planProgress,
28
35
  renderPlanFooter,
29
36
  renderPlanItems,
37
+ type PlanFooterPhase,
30
38
  type PlanItem,
31
39
  } from "./plan-lib";
32
40
 
@@ -42,7 +50,7 @@ const PlanParams = Type.Object({
42
50
  Type.Object({
43
51
  title: Type.String({ description: "Short task title (imperative, e.g. 'add failing test first')" }),
44
52
  status: Type.Optional(StringEnum(["pending", "in-progress", "done"] as const, {
45
- description: "Task status; defaults to pending. Exactly one item should be in-progress while working.",
53
+ description: "Task status; defaults to pending. Exactly one item should be in-progress while working; mark it done as soon as it is finished.",
46
54
  })),
47
55
  }),
48
56
  { description: "The FULL ordered task list with current statuses (replaces the previous plan)" },
@@ -50,10 +58,21 @@ const PlanParams = Type.Object({
50
58
  });
51
59
 
52
60
  export default function (pi: ExtensionAPI) {
53
- /** Show the footer for a plan (or clear it when the plan is gone). */
54
- const showFooter = (items: PlanItem[] | null, ctx: any) => {
61
+ // Footer state: the latest list, whether it was updated during the
62
+ // running request ("active") or is left over ("idle"), and how many
63
+ // other tool calls ran since its last update.
64
+ let items: PlanItem[] | null = null;
65
+ let phase: PlanFooterPhase = "idle";
66
+ let toolCallsSinceUpdate = 0;
67
+ let lastLine: string | null | undefined;
68
+ let lastCtx: any = null;
69
+
70
+ const showFooter = (ctx: any = lastCtx) => {
71
+ if (ctx) lastCtx = ctx;
72
+ const line = renderPlanFooter(items, { phase, toolCallsSinceUpdate });
73
+ if (line === lastLine) return;
74
+ lastLine = line;
55
75
  try {
56
- const line = items ? renderPlanFooter(items) : null;
57
76
  ctx?.ui?.setStatus?.(PLAN_STATUS_KEY, line ?? undefined);
58
77
  } catch {
59
78
  /* non-TUI host */
@@ -62,55 +81,84 @@ export default function (pi: ExtensionAPI) {
62
81
 
63
82
  pi.registerTool({
64
83
  name: PLAN_TOOL_NAME,
65
- label: "Plan",
84
+ label: "Tasks",
66
85
  description: [
67
- "Maintain the ordered task list for the current work (todo list).",
68
- "Send the FULL list with current statuses every time; keep exactly one item in-progress while working and mark items done as you complete them.",
69
- "The list persists for the session and is visible to the user in the footer.",
86
+ "Maintain the ordered task list (todo list) for the current request.",
87
+ "Send the FULL list with current statuses every time.",
88
+ "Keep it current while you work: mark an item in-progress when you start it and done as soon as it is finished (not all at the end), and send the final list when the work is complete.",
89
+ "The user sees the list's progress in the footer.",
70
90
  ].join(" "),
71
91
  promptSnippet: "plan: maintain an ordered task list (todo) with statuses",
72
92
  promptGuidelines: [
73
- "Use plan for multi-step work (3+ steps or user-requested tracking): send the full task list before starting, mark items done as you finish them, and keep exactly one item in-progress.",
93
+ "Use plan for multi-step work (3+ steps or user-requested tracking): send the full task list before starting, then update it as you go — mark each item done when you finish it and the next one in-progress, keeping exactly one item in-progress.",
94
+ "Before you end your reply, send the final list (every finished item done); drop items you decided not to do.",
74
95
  "Do not use plan for single trivial actions.",
75
96
  ],
76
97
  parameters: PlanParams,
77
98
 
78
99
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
79
- const items = normalizePlanItems(params.items);
80
- if (!items) {
100
+ const next = normalizePlanItems(params.items);
101
+ if (!next) {
81
102
  throw new Error(
82
103
  "invalid plan items: provide a non-empty array of {title, status} objects (status pending | in-progress | done)",
83
104
  );
84
105
  }
85
106
  const updatedAt = new Date().toISOString();
86
107
  const cwd = ctx?.cwd ? resolve(ctx.cwd) : process.cwd();
87
- try {
88
- const planPath = join(cwd, AGENT_DIR, "plan.json");
89
- await mkdir(dirname(planPath), { recursive: true });
90
- await writeFile(planPath, JSON.stringify(buildPlanFile(items, updatedAt), null, 2) + "\n", "utf8");
91
- } catch {
92
- // Persistence is best effort: the session entry + footer still
93
- // carry the plan (and the tool result returns it).
108
+ if (!isSubagent()) {
109
+ try {
110
+ const planPath = join(cwd, AGENT_DIR, "plan.json");
111
+ const sessionId = String(ctx?.sessionManager?.getSessionId?.() ?? "") || undefined;
112
+ await mkdir(dirname(planPath), { recursive: true });
113
+ await writeFile(planPath, JSON.stringify(buildPlanFile(next, updatedAt, sessionId), null, 2) + "\n", "utf8");
114
+ } catch {
115
+ // Persistence is best effort: the session entry + footer still
116
+ // carry the plan (and the tool result returns it).
117
+ }
94
118
  }
95
119
  try {
96
- pi.appendEntry(PLAN_ENTRY_TYPE, { items, updated_at: updatedAt });
120
+ pi.appendEntry(PLAN_ENTRY_TYPE, { items: next, updated_at: updatedAt });
97
121
  } catch {
98
122
  /* sessions may be unavailable in some hosts */
99
123
  }
100
- showFooter(items, ctx);
101
- const { done, total } = planProgress(items);
124
+ items = next;
125
+ phase = "active";
126
+ toolCallsSinceUpdate = 0;
127
+ showFooter(ctx);
128
+ const { done, total } = planProgress(next);
129
+ const reminder = done < total
130
+ ? "\nKeep this list current: mark each item done as soon as it is finished and the next one in-progress."
131
+ : "";
102
132
  return {
103
- content: [{ type: "text", text: renderPlanItems(items) }],
104
- details: { done, total, items },
133
+ content: [{ type: "text", text: renderPlanItems(next) + reminder }],
134
+ details: { done, total, items: next },
105
135
  };
106
136
  },
107
137
  });
108
138
 
109
- // Restore the footer after reload/resume: the session's latest plan
110
- // entry wins, falling back to the persisted plan.json.
139
+ // Other tool calls age the list: after enough of them without an
140
+ // update the footer stops pointing at a task as if it were current.
141
+ pi.on("tool_execution_end", (event: any, ctx: any) => {
142
+ if (!items || event?.toolName === PLAN_TOOL_NAME) return;
143
+ toolCallsSinceUpdate += 1;
144
+ showFooter(ctx);
145
+ });
146
+
147
+ // The request is over: an unfinished list is shown as idle (a finished
148
+ // one is already cleared) until the model updates it again.
149
+ pi.on("agent_end", (_event: any, ctx: any) => {
150
+ phase = "idle";
151
+ showFooter(ctx);
152
+ });
153
+
154
+ // Restore after reload/resume from THIS session's latest plan entry
155
+ // (a new session starts with no list, even when the folder still holds
156
+ // another session's plan.json). Restored lists are idle.
111
157
  pi.on("session_start", async (_event: any, ctx: any) => {
158
+ items = null;
159
+ phase = "idle";
160
+ toolCallsSinceUpdate = 0;
112
161
  try {
113
- let items: PlanItem[] | null = null;
114
162
  const entries = ctx?.sessionManager?.getEntries?.() ?? [];
115
163
  for (let index = entries.length - 1; index >= 0; index -= 1) {
116
164
  const entry = entries[index];
@@ -119,17 +167,24 @@ export default function (pi: ExtensionAPI) {
119
167
  if (items) break;
120
168
  }
121
169
  }
122
- if (!items && ctx?.cwd) {
170
+ const sessionId = String(ctx?.sessionManager?.getSessionId?.() ?? "");
171
+ if (!items && sessionId && ctx?.cwd) {
123
172
  try {
124
173
  const text = await readFile(join(resolve(ctx.cwd), AGENT_DIR, "plan.json"), "utf8");
125
- items = parsePlanFile(text)?.items ?? null;
174
+ const file = parsePlanFile(text);
175
+ if (file?.session_id === sessionId) items = file.items;
126
176
  } catch {
127
177
  /* no persisted plan */
128
178
  }
129
179
  }
130
- showFooter(items, ctx);
131
180
  } catch {
132
181
  /* footer is cosmetic */
133
182
  }
183
+ lastLine = undefined;
184
+ showFooter(ctx);
134
185
  });
135
186
  }
187
+
188
+ function isSubagent(): boolean {
189
+ return Boolean((process.env.OMNIRUSH_PARENT_SESSION || "").trim());
190
+ }
@@ -0,0 +1,21 @@
1
+ // A sub-agent process (spawn_agents sets OMNIRUSH_PARENT_SESSION) marks its
2
+ // own session with an `omnirush-subagent` entry naming its parent, before
3
+ // the task: should that session ever be continued as a main session, its
4
+ // first prompt is known to be the parent agent's (collector.ts records it
5
+ // as synthetic). Kept out of collector.ts because sub-agent processes do
6
+ // not load the collector (index.ts keeps children light).
7
+
8
+ import { SUBAGENT_ENTRY_TYPE } from "./pi-engine";
9
+
10
+ export default function markSubagentSession(pi: any, parentSession: string = (process.env.OMNIRUSH_PARENT_SESSION || "").trim()): void {
11
+ if (!parentSession) return;
12
+ pi.on("session_start", (_event: any, ctx: any) => {
13
+ try {
14
+ const entries = ctx?.sessionManager?.getEntries?.() ?? [];
15
+ if (entries.some((entry: any) => entry?.type === "custom" && entry.customType === SUBAGENT_ENTRY_TYPE)) return;
16
+ pi.appendEntry(SUBAGENT_ENTRY_TYPE, { parent_session_id: parentSession });
17
+ } catch {
18
+ /* never in the child's way */
19
+ }
20
+ });
21
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omnirush",
3
- "version": "0.9.0",
3
+ "version": "0.9.1",
4
4
  "description": "Omnirush \u2014 free daily tokens for the most powerful coding model on earth.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -28,6 +28,7 @@ REQUIRED_PATHS = [
28
28
  "src/bin.js",
29
29
  "src/lib.js",
30
30
  "src/compat.js",
31
+ "src/sessions.js",
31
32
  "scripts/postinstall.js",
32
33
  "scripts/brand-engine.js",
33
34
  "assets/extensions/omnirush/index.ts",
package/src/bin.js CHANGED
@@ -55,6 +55,7 @@ import {
55
55
  } from "./lib.js";
56
56
  import { installManagedTools } from "./tools.js";
57
57
  import { engineEnvAliases, migrateAgentDir, projectConfigDirName, resolveAgentDir } from "./compat.js";
58
+ import { customSessionDir, moveLegacySubagentSessions } from "./sessions.js";
58
59
  import { ensureRuntime, managedBunPath } from "../scripts/postinstall.js";
59
60
  import {
60
61
  clearAuth,
@@ -162,6 +163,23 @@ function ensureDirs() {
162
163
  installDefaultThinkingLevel();
163
164
  }
164
165
 
166
+ /**
167
+ * Sub-agent sessions older releases wrote next to the user's are moved to
168
+ * their session dir's subagents/ folder before the core picks a session,
169
+ * so /resume lists only the user's sessions and --continue resumes the
170
+ * user's last one (src/sessions.js). Not on a run that fell back to the
171
+ * pre-0.9 folder, which is left as it was.
172
+ */
173
+ function tidySubagentSessions(args) {
174
+ if (AGENT.legacyDir && PI_AGENT_DIR === AGENT.legacyDir) return;
175
+ try {
176
+ const custom = customSessionDir(args, process.env, PI_SETTINGS_JSON, os.homedir());
177
+ moveLegacySubagentSessions({ agentDir: PI_AGENT_DIR, extraDirs: custom ? [custom] : [] });
178
+ } catch {
179
+ /* best effort */
180
+ }
181
+ }
182
+
165
183
  // Product default effort (P2): xhigh. Fills an ABSENT
166
184
  // defaultThinkingLevel in the agent settings only — an explicit user
167
185
  // choice always wins; the models' thinkingLevelMap is untouched.
@@ -886,6 +904,7 @@ async function cmdRun() {
886
904
  // flags win when present.
887
905
  // `omnirush run` takes no other arguments; only yolo's --approve passes.
888
906
  const yolo = launchYolo(process.argv.slice(3).filter((a) => a === "--yolo" || a === "--no-yolo"));
907
+ tidySubagentSessions([]);
889
908
  const piArgs = withModelDefaults(yolo.args, resolveModelDefaults(process.env));
890
909
  spawnAgent(cmd, [...args, ...piArgs], {
891
910
  stdio: "inherit",
@@ -950,7 +969,8 @@ Session commands (inside the agent):
950
969
  /mcp MCP server state (/mcp reconnect [server])
951
970
  /subagents model and effort of the sub-agents (/subagents model <id|same>,
952
971
  /subagents effort <level|same>; saved for the next runs)
953
- /agents sub-agents of this session (/agents cancel <id|all>)
972
+ /agents sub-agents of this session (/agents cancel <id|all>,
973
+ /agents memory: the memory they may use)
954
974
  /jobs background shell commands (/jobs kill <id|all>)
955
975
  /voice dictate prompts: hold Space to talk (/voice hold|tap|off|status)
956
976
  /mode approval mode: guarded (asks before git writes and destructive
@@ -972,6 +992,12 @@ Environment:
972
992
  OMNIRUSH_BASH_FOREGROUND_SECONDS
973
993
  how long a shell command runs in the foreground before
974
994
  it continues in the background (default 300)
995
+ OMNIRUSH_AGENT_MIN_FREE / OMNIRUSH_AGENT_MEMORY_BUDGET / OMNIRUSH_AGENT_CRITICAL_FREE
996
+ sub-agents start while this much RAM stays free
997
+ (default 20%), all agents stay under this budget
998
+ (default 50% of RAM), and none start below this
999
+ (default 10%); e.g. 25% or 6G. OMNIRUSH_AGENT_MEMORY=off
1000
+ starts them without checking memory
975
1001
  OMNIRUSH_<SETTING> the agent core's own settings (e.g. OMNIRUSH_CACHE_RETENTION;
976
1002
  the older PI_<SETTING> names still work)
977
1003
 
@@ -1058,6 +1084,7 @@ async function runCore(args) {
1058
1084
  const { cmd: bin, args: runtimeArgs } = await runtimeFor(entry);
1059
1085
  const yolo = launchYolo(args);
1060
1086
  const piArgs = withModelDefaults(yolo.args, resolveModelDefaults(process.env));
1087
+ tidySubagentSessions(piArgs);
1061
1088
  spawnAgent(bin, [...runtimeArgs, ...piArgs], {
1062
1089
  stdio: "inherit",
1063
1090
  env: { ...process.env, ...childEnv(auth), ...yolo.env },
@@ -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
+ }