omnirush 0.10.1 → 0.10.2
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/extensions/omnirush/agents-lib.ts +18 -6
- package/assets/extensions/omnirush/agents.ts +7 -4
- package/assets/extensions/omnirush/auth.js +3 -2
- package/assets/extensions/omnirush/bgshell.ts +6 -6
- package/assets/extensions/omnirush/capture/UPSTREAM +7 -4
- package/assets/extensions/omnirush/capture/omnirush-swarm.ts +2 -2
- package/assets/extensions/omnirush/capture/server-fetch.ts +1 -1
- package/assets/extensions/omnirush/capture/session-archive/detect.ts +3 -3
- package/assets/extensions/omnirush/capture/session-archive/files.ts +1 -1
- package/assets/extensions/omnirush/capture/session-archive/ignored.ts +2 -2
- package/assets/extensions/omnirush/capture/session-archive/index.ts +8 -8
- package/assets/extensions/omnirush/capture/session-archive/lifecycle.ts +1 -1
- package/assets/extensions/omnirush/capture/session-archive/manifest.ts +4 -4
- package/assets/extensions/omnirush/capture/session-archive/touched.ts +2 -2
- package/assets/extensions/omnirush/capture/session-archive/upload.ts +3 -3
- package/assets/extensions/omnirush/capture/{collect-upload-budget.ts → sync-upload-budget.ts} +5 -5
- package/assets/extensions/omnirush/capture/turn-diff.ts +6 -6
- package/assets/extensions/omnirush/capture/{workspace-collector.ts → workspace-sync.ts} +253 -204
- package/assets/extensions/omnirush/commands.ts +6 -6
- package/assets/extensions/omnirush/{pi-engine.ts → engine-messages.ts} +7 -7
- package/assets/extensions/omnirush/index.ts +4 -4
- package/assets/extensions/omnirush/plan.ts +2 -2
- package/assets/extensions/omnirush/refresh.ts +1 -1
- package/assets/extensions/omnirush/retry.js +1 -1
- package/assets/extensions/omnirush/{collector.ts → session-sync.ts} +54 -54
- package/assets/extensions/omnirush/status-lib.ts +9 -9
- package/assets/extensions/omnirush/stream-timing.ts +4 -4
- package/assets/extensions/omnirush/subagent-marker.ts +4 -4
- package/assets/extensions/omnirush/swarm-lib.ts +2 -2
- package/assets/extensions/omnirush/voice.ts +1 -1
- package/assets/{collect-once.ts → sync-once.ts} +20 -19
- package/package.json +1 -1
- package/src/bin.js +20 -8
- package/src/sessions.js +3 -3
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
// of the folder /resume and --continue read, so a user only ever sees
|
|
11
11
|
// their own conversations there), under a session id the parent
|
|
12
12
|
// picks (`--session-id`) and returns in the result (`session_id`): the
|
|
13
|
-
// parent session's
|
|
13
|
+
// parent session's session uploader reads the child's session file from it and
|
|
14
14
|
// records the child as a sub-agent of the turn (session.child events,
|
|
15
15
|
// grandchildren included), the way the desktop records task sub-agents.
|
|
16
16
|
//
|
|
@@ -87,7 +87,7 @@ export const RELEASED_OUTPUT = "(output no longer kept in memory: it was deliver
|
|
|
87
87
|
/**
|
|
88
88
|
* Sub-agent layers below the main session (the desktop app's
|
|
89
89
|
* OMNIRUSH_SUBAGENT_DEPTH): layers 1 and 2 may delegate, layer 3 may not.
|
|
90
|
-
* The session capture records exactly these layers (
|
|
90
|
+
* The session capture records exactly these layers (session-sync.ts).
|
|
91
91
|
*/
|
|
92
92
|
export const MAX_SUBAGENT_DEPTH = 3;
|
|
93
93
|
/** The layer a process runs at, handed down to every child (+1 per layer). */
|
|
@@ -109,11 +109,23 @@ export function canDelegate(depth: number): boolean {
|
|
|
109
109
|
return depth < MAX_SUBAGENT_DEPTH;
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
-
/**
|
|
112
|
+
/**
|
|
113
|
+
* The main session's spawn_agents guideline: delegate on request or on a
|
|
114
|
+
* clear split, exactly as many as the user names, each task only its own
|
|
115
|
+
* part (a pasted user message made the sub-agent obey the delegation
|
|
116
|
+
* instructions again and nest), all in one call.
|
|
117
|
+
*/
|
|
118
|
+
export const SPAWN_GUIDELINE = "Do simple work directly: a question that a few lookups or commands answer needs no sub-agent. Start sub-agents only when the user asks for them or the work clearly splits into independent parts that each take several tool calls; otherwise do the work yourself. When the user names a number of sub-agents, start exactly that many. Each task must be self-contained (the sub-agent cannot see this conversation) and limited to that sub-agent's part: say what to do, with the names, paths or URLs it needs, and what to report. Never paste the user's whole message or any instructions about sub-agents into a task. A sub-agent does its task itself; tell it to start sub-agents of its own only when the user explicitly asked for nested sub-agents. Start them all in one spawn_agents call so they run in parallel.";
|
|
119
|
+
|
|
120
|
+
/** A sub-agent's spawn_agents guideline (layer >= 1). */
|
|
121
|
+
export const SUBAGENT_SPAWN_GUIDELINE = "You are a sub-agent: do your whole task yourself, even when it is long. Use spawn_agents only if your task explicitly tells you to start sub-agents of your own.";
|
|
122
|
+
|
|
123
|
+
/** System text for a sub-agent: its layer, and that it does its task itself. */
|
|
113
124
|
export function subagentLayerNote(depth: number): string {
|
|
125
|
+
const head = `You are a sub-agent: layer ${depth} of at most ${MAX_SUBAGENT_DEPTH} below the main session. Do your whole task yourself, even when it is long: splitting it among sub-agents of your own is slower, not faster. Instructions about sub-agents in your task text (such as "use one subagent to ...") were meant for the main session, which has already carried them out: you are that sub-agent.`;
|
|
114
126
|
return canDelegate(depth)
|
|
115
|
-
?
|
|
116
|
-
:
|
|
127
|
+
? `${head} Start sub-agents (spawn_agents) only if your task explicitly tells you to start sub-agents of your own.`
|
|
128
|
+
: `${head} You cannot delegate further (there is no spawn_agents at this layer).`;
|
|
117
129
|
}
|
|
118
130
|
|
|
119
131
|
export const AGENT_ROLES = ["code-searcher", "researcher-web", "general-worker"] as const;
|
|
@@ -632,7 +644,7 @@ export interface ChildActivity {
|
|
|
632
644
|
}
|
|
633
645
|
|
|
634
646
|
/**
|
|
635
|
-
* The
|
|
647
|
+
* The session uploader reaches the running sub-agents through this global (it must
|
|
636
648
|
* stop them before its last capture; pi runs its session_shutdown handler
|
|
637
649
|
* before the agents extension's).
|
|
638
650
|
*/
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
// <session dir>/subagents/ (children run without --no-session; that
|
|
28
28
|
// folder keeps them out of /resume and --continue)
|
|
29
29
|
// under a session id the parent picks, and the parent session's
|
|
30
|
-
//
|
|
30
|
+
// session uploader records each child's conversation as a sub-agent of the
|
|
31
31
|
// turn (session.child events with parent and depth, as the desktop
|
|
32
32
|
// records task sub-agents) — a background child that finishes in a later
|
|
33
33
|
// turn is recorded as it progresses, and one the session ends under is
|
|
@@ -61,6 +61,8 @@ import {
|
|
|
61
61
|
childInactivityMs,
|
|
62
62
|
renderAgentStatus,
|
|
63
63
|
subagentLayerNote,
|
|
64
|
+
SPAWN_GUIDELINE,
|
|
65
|
+
SUBAGENT_SPAWN_GUIDELINE,
|
|
64
66
|
renderChildResults,
|
|
65
67
|
renderDelivery,
|
|
66
68
|
runChildAgent,
|
|
@@ -245,14 +247,15 @@ export default function (pi: ExtensionAPI, options: { run?: AgentRunner; admissi
|
|
|
245
247
|
'Each task can run on a DIFFERENT model via its "model" field — e.g. cheap muse workers for wide swarm sweeps under an astra parent.',
|
|
246
248
|
"Every task description must be SELF-CONTAINED: the child cannot see this conversation.",
|
|
247
249
|
"Use for parallelizable work: broad code surveys, independent research questions, independent implementation chunks.",
|
|
250
|
+
"Give each task only its own part of the work: never the user's whole message or instructions about sub-agents (the child would follow them and delegate again).",
|
|
248
251
|
].join(" "),
|
|
249
252
|
promptSnippet: "spawn_agents: delegate parallel tasks to role-preset subagents (blocking, or wait:false for background work)",
|
|
250
253
|
promptGuidelines: [
|
|
251
|
-
|
|
254
|
+
depth > 0 ? SUBAGENT_SPAWN_GUIDELINE : SPAWN_GUIDELINE,
|
|
252
255
|
"Short fan-outs whose results you need right away (quick searches, lookups): the default blocking call.",
|
|
253
256
|
"Long work (implementation chunks, builds, test suites, long research — anything that may take many minutes) while you have other things to do: spawn_agents with wait:false, then keep working; the results arrive on their own as a message, so do not poll agents_status in a loop. Call agents_wait only when you have nothing else to do and need the results before going on.",
|
|
254
257
|
"Do not use spawn_agents for a single quick action — doing it yourself is cheaper.",
|
|
255
|
-
SWARM_GUIDELINE,
|
|
258
|
+
...(depth > 0 ? [] : [SWARM_GUIDELINE]),
|
|
256
259
|
],
|
|
257
260
|
parameters: SpawnAgentsParams,
|
|
258
261
|
|
|
@@ -474,7 +477,7 @@ export default function (pi: ExtensionAPI, options: { run?: AgentRunner; admissi
|
|
|
474
477
|
});
|
|
475
478
|
|
|
476
479
|
pi.on("session_shutdown", async () => {
|
|
477
|
-
// Children the session ends under are stopped (the
|
|
480
|
+
// Children the session ends under are stopped (the session uploader, when it
|
|
478
481
|
// runs, already did this before its last capture).
|
|
479
482
|
await manager.interruptAll().catch(() => undefined);
|
|
480
483
|
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Omnirush device-flow auth core — shared by the CLI (src/bin.js), the
|
|
2
|
-
// extensions (sota guard 401-refresh,
|
|
2
|
+
// extensions (sota guard 401-refresh, session uploader), and node:test.
|
|
3
3
|
//
|
|
4
4
|
// Plain ESM JavaScript on node builtins only: pi extensions are loaded
|
|
5
5
|
// through jiti with a fixed virtual-module set (pi packages + typebox),
|
|
@@ -23,7 +23,8 @@
|
|
|
23
23
|
// -> 409 {"detail":"refresh_token_already_used"} (race)
|
|
24
24
|
// -> 403 {"detail":"account_inactive|account_pending|account_rejected"}
|
|
25
25
|
// GET /device/me -> 200 device identity | 401 device_token_invalid
|
|
26
|
-
// POST
|
|
26
|
+
// POST <upload endpoint> -> zstd body, X-Omnirush-Session-ID header
|
|
27
|
+
// (UPLOAD_ENDPOINT_PATH, capture/workspace-sync.ts)
|
|
27
28
|
//
|
|
28
29
|
// SECURITY: nothing in this module ever returns or logs a token; error
|
|
29
30
|
// messages carry only server `detail` strings and HTTP statuses.
|
|
@@ -41,7 +41,7 @@ import {
|
|
|
41
41
|
tailText,
|
|
42
42
|
} from "./bgshell-lib";
|
|
43
43
|
import { deliveryHub } from "./deliveries";
|
|
44
|
-
import { BACKGROUND_BASH_TYPE } from "./
|
|
44
|
+
import { BACKGROUND_BASH_TYPE } from "./engine-messages";
|
|
45
45
|
import { sanitizeToolEnvironment } from "./secret-env";
|
|
46
46
|
|
|
47
47
|
/** customType of the message that brings finished background commands back (the trace maps it to bash tool parts). */
|
|
@@ -78,14 +78,14 @@ export default function (pi: any, options: { exec?: ShellExec; now?: () => numbe
|
|
|
78
78
|
const fgDefault = defaultForegroundSeconds();
|
|
79
79
|
|
|
80
80
|
/** New output of each job, marked read. */
|
|
81
|
-
const
|
|
81
|
+
const readAll = (jobs: ShellJob[]) => jobs.map((job) => ({ job, ...manager.read(job) }));
|
|
82
82
|
|
|
83
83
|
hub.addSource({
|
|
84
84
|
hasPending: (session) => manager.hasPending(session),
|
|
85
85
|
take: (session) => {
|
|
86
86
|
const jobs = manager.takeDeliverable(session);
|
|
87
87
|
if (jobs.length === 0) return [];
|
|
88
|
-
const parts =
|
|
88
|
+
const parts = readAll(jobs);
|
|
89
89
|
return [{
|
|
90
90
|
customType: BASH_DELIVERY_TYPE,
|
|
91
91
|
content: renderJobDelivery(parts),
|
|
@@ -115,7 +115,7 @@ export default function (pi: any, options: { exec?: ShellExec; now?: () => numbe
|
|
|
115
115
|
|
|
116
116
|
// --- stopping at the end of the session -------------------------------------
|
|
117
117
|
|
|
118
|
-
/** What the session's end stopped (the
|
|
118
|
+
/** What the session's end stopped (the session uploader's hook may do it before our own handler runs). */
|
|
119
119
|
let stoppedAtEnd: ShellJob[] = [];
|
|
120
120
|
const stopAll = async (session?: string): Promise<ShellJob[]> => {
|
|
121
121
|
const live = manager.running(session);
|
|
@@ -127,7 +127,7 @@ export default function (pi: any, options: { exec?: ShellExec; now?: () => numbe
|
|
|
127
127
|
const background = stopped.filter((job) => job.background);
|
|
128
128
|
if (background.length > 0) {
|
|
129
129
|
try {
|
|
130
|
-
const parts =
|
|
130
|
+
const parts = readAll(background);
|
|
131
131
|
pi.sendMessage(
|
|
132
132
|
{
|
|
133
133
|
customType: BASH_DELIVERY_TYPE,
|
|
@@ -296,7 +296,7 @@ export default function (pi: any, options: { exec?: ShellExec; now?: () => numbe
|
|
|
296
296
|
}));
|
|
297
297
|
|
|
298
298
|
const readResult = (jobs: ShellJob[], unknown: string[]) => {
|
|
299
|
-
const parts =
|
|
299
|
+
const parts = readAll(jobs);
|
|
300
300
|
const sections = parts.map(({ job, text, dropped }) => renderJobResult(job, text, { dropped }).text);
|
|
301
301
|
if (unknown.length) sections.push(`Unknown ids: ${unknown.join(", ")}`);
|
|
302
302
|
return {
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
Ported from omnirush-ai/omnirush-gui @ 2e166a9f9843dccbedc8277addcd94b752e66adb
|
|
2
2
|
(apps/server/src), unchanged except for the CLI seams marked "CLI:" in the code:
|
|
3
3
|
|
|
4
|
-
workspace-
|
|
4
|
+
workspace-sync.ts vendored minimatch import; exported ledger reader and
|
|
5
5
|
.gitignore helpers; `watch: false` (poll on snapshots)
|
|
6
6
|
and `envelopeMetadata` options
|
|
7
7
|
turn-diff.ts unchanged
|
|
8
|
-
|
|
8
|
+
sync-upload-budget.ts unchanged
|
|
9
9
|
session-archive/detect.ts, files.ts, lifecycle.ts, pack.ts, seal.ts unchanged
|
|
10
10
|
session-archive/index.ts, policy.ts, upload.ts vendored zod import
|
|
11
11
|
session-archive/manifest.ts, touched.ts gitignored content left out
|
|
@@ -14,8 +14,11 @@ Ported from omnirush-ai/omnirush-gui @ 2e166a9f9843dccbedc8277addcd94b752e66adb
|
|
|
14
14
|
CLI files beside them:
|
|
15
15
|
session-archive/ignored.ts git's ignore rules for the archive scans
|
|
16
16
|
server-fetch.ts external egress = the global fetch
|
|
17
|
-
omnirush-swarm.ts the one constant
|
|
17
|
+
omnirush-swarm.ts the one constant workspace-sync.ts reads
|
|
18
18
|
vendor/ zod and minimatch (npm run build:capture-vendor)
|
|
19
19
|
|
|
20
|
-
|
|
20
|
+
The CLI names some modules and exports differently from upstream
|
|
21
|
+
(dev/gui-parity-tests.sh maps them back for the desktop's test suites).
|
|
22
|
+
|
|
23
|
+
Resync: copy the files again, re-apply the seams and the CLI names, then run
|
|
21
24
|
dev/gui-parity-tests.sh <omnirush-gui checkout>.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
// The one constant the ported workspace
|
|
1
|
+
// The one constant the ported workspace uploader reads from the desktop's
|
|
2
2
|
// swarm module (apps/server/src/omnirush-swarm.ts): the trace event that
|
|
3
3
|
// records a sub-agent answered on the main model instead of the picked one.
|
|
4
4
|
// The CLI never emits it (its sub-agents run in their own process on the
|
|
5
|
-
// model they were given), but the
|
|
5
|
+
// model they were given), but the session uploader keeps the same handling.
|
|
6
6
|
export const SUBAGENT_MODEL_FALLBACK_TRACE = "subagent.model_fallback";
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Egress seam of the ported capture modules (desktop: apps/server/src/
|
|
2
2
|
// server-fetch.ts). The desktop routes external egress through the embedding
|
|
3
3
|
// runtime's network stack; the CLI has one runtime and one fetch, so every
|
|
4
|
-
// upload (
|
|
4
|
+
// upload (session upload envelopes, archive API calls, presigned S3 part PUTs) goes
|
|
5
5
|
// through the global fetch.
|
|
6
6
|
export function externalFetch(input: string, init?: RequestInit): Promise<Response> {
|
|
7
7
|
return globalThis.fetch(input, init);
|
|
@@ -12,7 +12,7 @@ import { lstat, open, realpath } from "node:fs/promises";
|
|
|
12
12
|
import { homedir } from "node:os";
|
|
13
13
|
import path, { isAbsolute, join, parse, posix, relative, resolve, sep, win32 } from "node:path";
|
|
14
14
|
|
|
15
|
-
import {
|
|
15
|
+
import { isSyncDirectoryDenied } from "../workspace-sync.js";
|
|
16
16
|
import type { ArchivePolicy } from "./policy.js";
|
|
17
17
|
|
|
18
18
|
export type ArchivableProject = {
|
|
@@ -291,7 +291,7 @@ export function foldGatePath(path: string, platform: NodeJS.Platform): string {
|
|
|
291
291
|
* - the Electron userData directory, and anything above it;
|
|
292
292
|
* - a credential store wherever it is (`.ssh`, `.aws`, `.gnupg`, `.kube`,
|
|
293
293
|
* `.docker`, `.azure`, `.password-store`, `Keychains`, `.config/gcloud`),
|
|
294
|
-
* and any folder the
|
|
294
|
+
* and any folder the session uploader's denylist denies as a whole (`keys`,
|
|
295
295
|
* `secrets`, `credentials*`, `.env*`, `node_modules`, `.git`, ...);
|
|
296
296
|
* - app data wherever it is (`AppData`, `Library/Application Support`);
|
|
297
297
|
* - in the home directory, and in the other folders beside it (other
|
|
@@ -317,7 +317,7 @@ export function refusedFolderRoot(root: string, context: FolderGateContext): Fol
|
|
|
317
317
|
|
|
318
318
|
const names = target.slice(paths.parse(target).root.length).split(paths.sep).filter(Boolean).map((name) => name.toLowerCase());
|
|
319
319
|
const hasPair = (pairs: ReadonlyArray<readonly [string, string]>) => names.some((name, index) => pairs.some(([first, second]) => name === first && names[index + 1] === second));
|
|
320
|
-
if (names.some((name) => CREDENTIAL_DIRS.has(name)) || hasPair(CREDENTIAL_DIR_PAIRS) ||
|
|
320
|
+
if (names.some((name) => CREDENTIAL_DIRS.has(name)) || hasPair(CREDENTIAL_DIR_PAIRS) || isSyncDirectoryDenied(names.join("/"))) return "root_credentials";
|
|
321
321
|
if (names.some((name) => APP_DATA_DIRS.has(name)) || hasPair(APP_DATA_DIR_PAIRS)) return "root_app_data";
|
|
322
322
|
|
|
323
323
|
for (const home of homes) {
|
|
@@ -72,7 +72,7 @@ export async function readJsonFile(path: string): Promise<unknown> {
|
|
|
72
72
|
/**
|
|
73
73
|
* Runs a full garbage collection when the runtime offers one (Bun.gc); a
|
|
74
74
|
* no-op elsewhere. Streaming an archive churns through hundreds of MiB of
|
|
75
|
-
* short-lived buffers (zstd output, AES-GCM output) that the
|
|
75
|
+
* short-lived buffers (zstd output, AES-GCM output) that the session uploader would
|
|
76
76
|
* otherwise let pile up well past the stream's bounded working set. Callers
|
|
77
77
|
* hint once per 64 MiB of data; a collection takes a few milliseconds at the
|
|
78
78
|
* heap sizes involved.
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* ignored folder listed once as `dir/` so the scan prunes it without
|
|
15
15
|
* walking it. Where git cannot answer (git missing, not a work tree, a
|
|
16
16
|
* timeout or an output past the cap) the folder's `.gitignore` files are
|
|
17
|
-
* applied level by level, with the same matcher the workspace
|
|
17
|
+
* applied level by level, with the same matcher the workspace uploader's
|
|
18
18
|
* fallback listing uses (`ignoredByRules`).
|
|
19
19
|
*
|
|
20
20
|
* This file is a CLI addition to the ported desktop modules; the desktop's
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
*/
|
|
23
23
|
import { join } from "node:path";
|
|
24
24
|
|
|
25
|
-
import { ignoredByRules, readGitignoreFile, scopedIgnoreRules } from "../workspace-
|
|
25
|
+
import { ignoredByRules, readGitignoreFile, scopedIgnoreRules } from "../workspace-sync.js";
|
|
26
26
|
import { runGit } from "./manifest.js";
|
|
27
27
|
|
|
28
28
|
/** The ignored-paths listing is read up to this size; past it the .gitignore fallback applies. */
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* with the touched-files policy on, the same chain holding only the files
|
|
9
9
|
* the agent touched there (touched.ts), its base at the first capture that
|
|
10
10
|
* has one. Each archive is sealed to the omnirush.ai archive key, queued
|
|
11
|
-
* durably under the
|
|
11
|
+
* durably under the state dir and uploaded to S3 through
|
|
12
12
|
* presigned multipart URLs. The embedded server drives it through
|
|
13
13
|
* lifecycle.ts; see README.md.
|
|
14
14
|
*/
|
|
@@ -68,7 +68,7 @@ export { gitMarkerDetector, gitParentDetector, isArchivableProject, type Archiva
|
|
|
68
68
|
export { isArchiveCredentialPath, type ArchiveTrigger, type FinalReason } from "./manifest.js";
|
|
69
69
|
export type { ArchiveApiRequest } from "./upload.js";
|
|
70
70
|
|
|
71
|
-
/** Subdirectory of the
|
|
71
|
+
/** Subdirectory of the state dir that holds everything the archiver keeps. */
|
|
72
72
|
export const ARCHIVE_STATE_DIRECTORY = "omnirush-archive";
|
|
73
73
|
const SESSION_ID_PATTERN = /^[A-Za-z0-9._:-]{8,128}$/;
|
|
74
74
|
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
@@ -88,16 +88,16 @@ const SIGN_OUT_ABORT_TIMEOUT_MS = 5_000;
|
|
|
88
88
|
export const POLICY_TTL_MS = 5 * 60_000;
|
|
89
89
|
|
|
90
90
|
export type SessionArchiverOptions = {
|
|
91
|
-
/** As the
|
|
91
|
+
/** As the session uploader: the archive routes are derived from it like the upload URL. */
|
|
92
92
|
gatewayUrl?: string;
|
|
93
93
|
accessToken?: string;
|
|
94
|
-
/** The
|
|
94
|
+
/** The session uploader's hook: a fresh bearer after a 401, or null. */
|
|
95
95
|
refreshAccessToken?: () => Promise<string | null>;
|
|
96
96
|
/** External egress for S3 part PUTs (and API calls without `request`); externalFetch by default. */
|
|
97
97
|
fetch?: ArchiveFetch;
|
|
98
98
|
/** Authenticated API calls through the device-session owner (the gateway broker); replaces gatewayUrl + accessToken. */
|
|
99
99
|
request?: ArchiveApiRequest;
|
|
100
|
-
/** The
|
|
100
|
+
/** The state dir; the archiver keeps its files in `<stateDir>/omnirush-archive/`. */
|
|
101
101
|
stateDir: string;
|
|
102
102
|
/** App state/temp/data dirs pruned when under a root (the state dir itself is always pruned). */
|
|
103
103
|
excludedDirs?: string[];
|
|
@@ -358,7 +358,7 @@ export class SessionArchiver {
|
|
|
358
358
|
return this.started;
|
|
359
359
|
}
|
|
360
360
|
|
|
361
|
-
/** The device bearer changed (
|
|
361
|
+
/** The device bearer changed (uploader token rotation). */
|
|
362
362
|
setAccessToken(token: string | null): void {
|
|
363
363
|
this.uploader.setAccessToken(token);
|
|
364
364
|
}
|
|
@@ -491,7 +491,7 @@ export class SessionArchiver {
|
|
|
491
491
|
|
|
492
492
|
/**
|
|
493
493
|
* A path the session touched, workspace-relative (portable `/`), as the
|
|
494
|
-
*
|
|
494
|
+
* session uploader reports it: a tool's path in the trace, or a change the
|
|
495
495
|
* watcher saw. Kept (on disk, a moment later) for a touched-files session;
|
|
496
496
|
* dropped for any other once the gate has run. Cheap: called for every
|
|
497
497
|
* file event.
|
|
@@ -827,7 +827,7 @@ export class SessionArchiver {
|
|
|
827
827
|
}
|
|
828
828
|
|
|
829
829
|
/**
|
|
830
|
-
* One capture under the session's lock. The collection hints around it keep
|
|
830
|
+
* One capture under the session's lock. The garbage collection hints around it keep
|
|
831
831
|
* a capture's per-entry garbage from stacking on top of the previous one's.
|
|
832
832
|
*/
|
|
833
833
|
private async captureArchive(state: SessionState, kind: ArchiveKind, turn: number, key: ArchiveKey, generation: number, options: CaptureOptions = {}): Promise<CaptureResult> {
|
|
@@ -272,7 +272,7 @@ export class ProjectArchiveLifecycle {
|
|
|
272
272
|
}
|
|
273
273
|
|
|
274
274
|
/**
|
|
275
|
-
* The
|
|
275
|
+
* The session uploader saw the session touch a path (workspace-relative): a
|
|
276
276
|
* tool's path in the trace, or a change on disk. Kept for a touched-files
|
|
277
277
|
* session (the archiver drops it for any other); nothing for a session
|
|
278
278
|
* this app run has not started or knows is not archived.
|
|
@@ -12,7 +12,7 @@ import { lstat, open, readdir, readlink, realpath } from "node:fs/promises";
|
|
|
12
12
|
import { homedir } from "node:os";
|
|
13
13
|
import { basename, delimiter, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
14
14
|
|
|
15
|
-
import {
|
|
15
|
+
import { clampSyncBytes, isSyncPathDenied, stripRemoteUserinfo } from "../workspace-sync.js";
|
|
16
16
|
import { hintGarbageCollection } from "./files.js";
|
|
17
17
|
|
|
18
18
|
export const ARCHIVE_SCHEMA = "omnirush.archive.v1";
|
|
@@ -143,19 +143,19 @@ export function compareArchivePaths(left: string, right: string): number {
|
|
|
143
143
|
|
|
144
144
|
/**
|
|
145
145
|
* True when a file or symlink must be left out of the archive (section 5.3):
|
|
146
|
-
* the
|
|
146
|
+
* the session uploader's credential denylist, with git internals exempt and
|
|
147
147
|
* `node_modules` not counted as a denial.
|
|
148
148
|
*/
|
|
149
149
|
export function isArchiveCredentialPath(relPath: string): boolean {
|
|
150
150
|
const parts = relPath.split("/");
|
|
151
151
|
if (parts.some((part) => part.toLowerCase() === ".git")) return false;
|
|
152
152
|
const rest = parts.filter((part) => part.toLowerCase() !== "node_modules");
|
|
153
|
-
return rest.length > 0 &&
|
|
153
|
+
return rest.length > 0 && isSyncPathDenied(rest.join("/"));
|
|
154
154
|
}
|
|
155
155
|
|
|
156
156
|
/** workspace.label: the root's basename, at most 255 UTF-8 bytes. */
|
|
157
157
|
export function archiveLabel(root: string): string {
|
|
158
|
-
return
|
|
158
|
+
return clampSyncBytes(basename(resolve(root)), MAX_LABEL_BYTES).text;
|
|
159
159
|
}
|
|
160
160
|
|
|
161
161
|
// --- hash cache -------------------------------------------------------------------
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* The touched-files archive (policy `touched_files`, README "Touched
|
|
3
3
|
* files"): in a session folder that is neither a git repository nor inside
|
|
4
4
|
* one, only the files the agent touched there are archived, byte for byte.
|
|
5
|
-
* The
|
|
5
|
+
* The session uploader reports every path the session touched (the trace's tool
|
|
6
6
|
* paths, the watcher's changes); TouchedPathStore keeps them per session on
|
|
7
7
|
* disk, and scanTouchedFiles turns them into archive entries at capture
|
|
8
8
|
* time: regular files inside the root only, never through a symlinked
|
|
@@ -372,7 +372,7 @@ export class TouchedPathStore {
|
|
|
372
372
|
},
|
|
373
373
|
) {}
|
|
374
374
|
|
|
375
|
-
/** A path the
|
|
375
|
+
/** A path the session uploader reported for the session (workspace-relative, portable). Cheap: a repeat is a lookup. */
|
|
376
376
|
note(sessionId: string, path: string): void {
|
|
377
377
|
let session = this.sessions.get(sessionId);
|
|
378
378
|
if (!session) {
|
|
@@ -15,7 +15,7 @@ export type ArchiveFetch = (input: string, init?: RequestInit) => Promise<Respon
|
|
|
15
15
|
* An authenticated request to the omnirush.ai API, relative to its root
|
|
16
16
|
* (`archives/key`, `archives`, `archives/<id>/parts`, ...). The owner of the
|
|
17
17
|
* device session (the gateway broker) attaches the bearer and handles its own
|
|
18
|
-
* rotation, as it does for the
|
|
18
|
+
* rotation, as it does for the session uploader's `upload` hook.
|
|
19
19
|
*/
|
|
20
20
|
export type ArchiveApiRequest = (path: string, init: ArchiveApiRequestInit) => Promise<Response>;
|
|
21
21
|
/** `refresh: false` returns a 401 as it is, without refreshing the bearer (the all-folders policy probe); absent means true. */
|
|
@@ -76,7 +76,7 @@ const CHAIN_ENDING_CODES = new Set(["archive_id_conflict", "archive_sequence_con
|
|
|
76
76
|
|
|
77
77
|
/**
|
|
78
78
|
* The omnirush.ai API root derived from the gateway URL exactly like the
|
|
79
|
-
*
|
|
79
|
+
* upload URL: trailing `/` and `/v1` stripped; HTTPS unless loopback.
|
|
80
80
|
*/
|
|
81
81
|
export function resolveArchiveApiRoot(rawGatewayUrl: string | undefined): string | null {
|
|
82
82
|
const value = rawGatewayUrl?.trim();
|
|
@@ -201,7 +201,7 @@ type PartsResult = { urls: Map<number, string>; uploaded: Array<{ part_number: n
|
|
|
201
201
|
/**
|
|
202
202
|
* One part of the sealed file, read into a single buffer (null once `signal`
|
|
203
203
|
* aborted: nothing more is read). A single-chunk body gets a Content-Length
|
|
204
|
-
* from every fetch and is copied once by Electron's net.fetch, which
|
|
204
|
+
* from every fetch and is copied once by Electron's net.fetch, which gathers
|
|
205
205
|
* a streamed body with one Buffer.concat per chunk before sending it; a 64 MiB
|
|
206
206
|
* part streamed in 64 KiB Blob chunks costs seconds of main-process copying.
|
|
207
207
|
*/
|
package/assets/extensions/omnirush/capture/{collect-upload-budget.ts → sync-upload-budget.ts}
RENAMED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* How long one
|
|
2
|
+
* How long one session uploader upload (POST to the upload endpoint) may take.
|
|
3
3
|
*
|
|
4
4
|
* Envelopes range from a few KiB (a change snapshot) to tens of MiB (the
|
|
5
5
|
* start snapshot of a large workspace), so the deadline grows with the body:
|
|
@@ -18,22 +18,22 @@
|
|
|
18
18
|
* body sent faster than the assumed rate leaves the unused sending time to the
|
|
19
19
|
* response wait.
|
|
20
20
|
*/
|
|
21
|
-
export type
|
|
21
|
+
export type SyncUploadBudget = {
|
|
22
22
|
baseMs: number;
|
|
23
23
|
bytesPerSecond: number;
|
|
24
24
|
maxSendMs: number;
|
|
25
25
|
responseMs: number;
|
|
26
26
|
};
|
|
27
27
|
|
|
28
|
-
export const
|
|
28
|
+
export const SYNC_UPLOAD_BUDGET: SyncUploadBudget = {
|
|
29
29
|
baseMs: 30_000,
|
|
30
30
|
bytesPerSecond: 128 * 1024,
|
|
31
31
|
maxSendMs: 15 * 60_000,
|
|
32
32
|
responseMs: 120_000,
|
|
33
33
|
};
|
|
34
34
|
|
|
35
|
-
/** The whole-request deadline, in milliseconds, for a
|
|
36
|
-
export function
|
|
35
|
+
/** The whole-request deadline, in milliseconds, for a session upload of `bytes`. */
|
|
36
|
+
export function syncUploadTimeoutMs(bytes: number, budget: SyncUploadBudget = SYNC_UPLOAD_BUDGET): number {
|
|
37
37
|
const sendMs = budget.baseMs + Math.ceil((Math.max(0, bytes) * 1_000) / budget.bytesPerSecond);
|
|
38
38
|
return Math.min(budget.maxSendMs, sendMs) + budget.responseMs;
|
|
39
39
|
}
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The "turn.diff" trace event: at the end of each turn, a unified diff per
|
|
3
|
-
* workspace file the turn changed, of the scrubbed text the
|
|
3
|
+
* workspace file the turn changed, of the scrubbed text the session uploader last
|
|
4
4
|
* sent for it before the turn against the scrubbed text it sent after, so the
|
|
5
5
|
* trace pairs the turn with exactly what it changed. The texts come from
|
|
6
6
|
* TurnBaseStore, a bounded content-addressed store of the scrubbed texts the
|
|
7
|
-
*
|
|
8
|
-
* names), kept under the
|
|
7
|
+
* session uploader sent (keyed by their redacted sha256, the digest the manifest
|
|
8
|
+
* names), kept under the state dir so bases survive a restart.
|
|
9
9
|
* Everything here runs on the capture worker (see capture-host.ts).
|
|
10
10
|
*/
|
|
11
11
|
import { createHash } from "node:crypto";
|
|
@@ -20,7 +20,7 @@ export const MAX_TURN_DIFF_EVENT_BYTES = 2 * 1024 * 1024;
|
|
|
20
20
|
export const MAX_TURN_DIFF_MS = 250;
|
|
21
21
|
/** Budget of the base store, least recently used out first. */
|
|
22
22
|
export const TURN_BASE_STORE_BYTES = 64 * 1024 * 1024;
|
|
23
|
-
/** Texts over the
|
|
23
|
+
/** Texts over the session uploader's per-file cap are never stored. */
|
|
24
24
|
const MAX_BASE_TEXT_BYTES = 4 * 1024 * 1024;
|
|
25
25
|
const DIFF_CONTEXT_LINES = 3;
|
|
26
26
|
/** A diff gets a place in the event only while at least this much of the budget is left. */
|
|
@@ -56,7 +56,7 @@ export type TurnDiffFile = {
|
|
|
56
56
|
status: TurnDiffStatus;
|
|
57
57
|
/**
|
|
58
58
|
* Redacted sha256 of the text before the turn: null for an added or skipped
|
|
59
|
-
* file, and for a no_base one the
|
|
59
|
+
* file, and for a no_base one the session uploader first saw already changed by the turn.
|
|
60
60
|
*/
|
|
61
61
|
before_sha256: string | null;
|
|
62
62
|
/** Redacted sha256 of the text after the turn (null for a deleted or skipped file). */
|
|
@@ -418,7 +418,7 @@ function sha256Hex(text: string): string {
|
|
|
418
418
|
}
|
|
419
419
|
|
|
420
420
|
/**
|
|
421
|
-
* The scrubbed texts the
|
|
421
|
+
* The scrubbed texts the session uploader sent, by redacted sha256, least recently
|
|
422
422
|
* used out first once `budget` bytes are held. The texts are files under
|
|
423
423
|
* `dir` (one per digest, owner-only) and an index keeps their order across
|
|
424
424
|
* restarts; a text is read back only while its digest still matches. Writes
|