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.
- package/assets/CHANGELOG.md +35 -0
- package/assets/extensions/omnirush/agents-lib.ts +370 -53
- package/assets/extensions/omnirush/agents.ts +92 -11
- package/assets/extensions/omnirush/bgshell-lib.ts +32 -0
- package/assets/extensions/omnirush/capture/workspace-collector.ts +86 -22
- package/assets/extensions/omnirush/collector.ts +203 -32
- package/assets/extensions/omnirush/index.ts +22 -8
- package/assets/extensions/omnirush/mcp.ts +103 -8
- package/assets/extensions/omnirush/memory-lib.ts +516 -0
- package/assets/extensions/omnirush/pi-engine.ts +50 -2
- package/assets/extensions/omnirush/plan-lib.ts +38 -9
- package/assets/extensions/omnirush/plan.ts +85 -30
- package/assets/extensions/omnirush/subagent-marker.ts +21 -0
- package/package.json +1 -1
- package/scripts/smoke-packages.py +1 -0
- package/src/bin.js +28 -1
- package/src/sessions.js +222 -0
|
@@ -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
|
-
|
|
54
|
-
|
|
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: "
|
|
84
|
+
label: "Tasks",
|
|
66
85
|
description: [
|
|
67
|
-
"Maintain the ordered task list for the current
|
|
68
|
-
"Send the FULL list with current statuses every time
|
|
69
|
-
"
|
|
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
|
|
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
|
|
80
|
-
if (!
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
101
|
-
|
|
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(
|
|
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
|
-
//
|
|
110
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
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 },
|
package/src/sessions.js
ADDED
|
@@ -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
|
+
}
|