omnirush 0.3.2 → 0.4.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.
@@ -22,7 +22,9 @@
22
22
  // node_modules, build outputs, .git (full reproducibility) — with
23
23
  // ONLY the secrets denylist (.env*, keys, credentials, private
24
24
  // keys) excluded. Binaries are captured (base64), symlinks are
25
- // stored as records (path + target) so replay recreates them, and
25
+ // stored as {path, content: <target>, encoding: "symlink"} so the
26
+ // backend's text-content validation accepts them and replay
27
+ // recreates them, and
26
28
  // the POSIX mode/executable bit rides each file entry for replay
27
29
  // (stored-and-ignored on Windows). Redaction also scrubs
28
30
  // credentials embedded in URLs (e.g. .git/config remotes).
@@ -105,13 +107,13 @@ type SnapshotType = "start" | "change" | "trace" | "end";
105
107
 
106
108
  export type CollectorFile = {
107
109
  path: string;
108
- /** utf8 text (default) or base64 when encoding is "base64". Absent for
109
- * symlink records, which carry `target` instead. */
110
- content?: string;
111
- /** Absent = utf8 text. "base64" for binary content. */
112
- encoding?: "base64";
113
- /** Symlink record: the link target as stored on disk. */
114
- target?: string;
110
+ /** utf8 text by default. For binaries: base64. For symlinks: the link
111
+ * target verbatim (the backend requires text content on every file
112
+ * entry, so symlink records are text entries with a marker). */
113
+ content: string;
114
+ /** Absent = utf8 text. "base64" for binary content, "symlink" for
115
+ * symlink records (content = link target). */
116
+ encoding?: "base64" | "symlink";
115
117
  /** POSIX mode bits (octal string, e.g. "755"). Absent for symlinks. */
116
118
  mode?: string;
117
119
  };
@@ -127,8 +129,7 @@ export type ChangeJournalEntry = {
127
129
  path: string;
128
130
  status: "present" | "deleted";
129
131
  content?: string;
130
- encoding?: "base64";
131
- target?: string;
132
+ encoding?: "base64" | "symlink";
132
133
  mode?: string;
133
134
  /** Present when the record carries one slice of a bigger file. */
134
135
  slice?: { index: number; total: number; bytes: number; sha256: string };
@@ -587,9 +588,11 @@ async function readContentChunks(absolute: string, size: number): Promise<Conten
587
588
  * text is redacted, binaries base64-encoded ("encoding": "base64"), and
588
589
  * the POSIX mode rides along (octal string) so replay can restore the
589
590
  * executable bit (stored-and-ignored on Windows). Content bigger than
590
- * MAX_SLICE_BYTES is sliced (see buildFileEntries). Symlinks become
591
- * records { path, target } (target verbatim; replay recreates the link).
592
- * Returns [] when the entry vanished or is a special file.
591
+ * MAX_SLICE_BYTES is sliced (see buildFileEntries). Symlinks become text
592
+ * entries { path, content: <target>, encoding: "symlink" } — the
593
+ * backend's per-entry validation requires text content, and replay
594
+ * recreates the actual link from the target. Returns [] when the entry
595
+ * vanished or is a special file.
593
596
  */
594
597
  async function readWorkspaceEntries(root: string, path: string): Promise<CollectorFile[]> {
595
598
  try {
@@ -598,7 +601,7 @@ async function readWorkspaceEntries(root: string, path: string): Promise<Collect
598
601
  const file = await lstat(absolute);
599
602
  if (file.isSymbolicLink()) {
600
603
  const target = await readlink(absolute);
601
- return [{ path, target }];
604
+ return [{ path, content: target, encoding: "symlink" }];
602
605
  }
603
606
  if (!file.isFile()) return [];
604
607
  const mode = file.mode & 0o777;
@@ -672,7 +675,7 @@ export async function collectFiles(
672
675
  for (const path of paths) {
673
676
  if (used >= byteLimit) break;
674
677
  for (const file of await readWorkspaceEntries(root, path)) {
675
- const size = Buffer.byteLength(file.content ?? file.target ?? "");
678
+ const size = Buffer.byteLength(file.content ?? "");
676
679
  if (used + size > byteLimit) continue;
677
680
  files.push(file);
678
681
  used += size;
@@ -778,7 +781,7 @@ export async function packEnvelopeParts(
778
781
  const lone = batchToFit.pop()!;
779
782
  log?.("OmniRush file exceeds the upload limits and cannot be shipped as a single part; skipped", {
780
783
  path: lone.path,
781
- bytes: Buffer.byteLength(lone.content ?? lone.target ?? ""),
784
+ bytes: Buffer.byteLength(lone.content ?? ""),
782
785
  });
783
786
  return { measured: null, carry };
784
787
  }
@@ -810,6 +813,26 @@ export async function packEnvelopeParts(
810
813
  }
811
814
 
812
815
  export class WorkspaceCollector {
816
+ /**
817
+ * Bounded first-chunk read of an error response body for diagnostics:
818
+ * at most 512 bytes, decoded and whitespace-collapsed. Never throws.
819
+ * Error bodies are small JSON from the manager; the bound exists so a
820
+ * pathological response cannot balloon a log line (or memory).
821
+ */
822
+ private static async errorBodySnippet(response: any): Promise<string> {
823
+ try {
824
+ const reader = response?.body?.getReader?.();
825
+ if (!reader) return "";
826
+ const { value } = await reader.read();
827
+ reader.cancel().catch(() => undefined);
828
+ if (!value) return "";
829
+ const text = Buffer.from(value).toString("utf8").replace(/\s+/g, " ").trim();
830
+ return text.length > 200 ? `${text.slice(0, 200)}…` : text;
831
+ } catch {
832
+ return "";
833
+ }
834
+ }
835
+
813
836
  private readonly collectUrl: string | null;
814
837
  private token: string;
815
838
  private readonly fetcher: typeof fetch;
@@ -1110,9 +1133,16 @@ export class WorkspaceCollector {
1110
1133
  if (!options?.force && Math.floor(file.mtimeMs) <= state.watcherStartedAtMs) return;
1111
1134
  state.knownPaths.add(path);
1112
1135
  if (file.isSymbolicLink()) {
1113
- // Symlink record: path + target so replay recreates the link.
1136
+ // Symlink record: the target as text content with the symlink
1137
+ // encoding — replay recreates the actual link from it.
1114
1138
  const target = await readlink(absolute);
1115
- entry = { path, at: new Date().toISOString(), status: "present", target };
1139
+ entry = {
1140
+ path,
1141
+ at: new Date().toISOString(),
1142
+ status: "present",
1143
+ content: target,
1144
+ encoding: "symlink",
1145
+ };
1116
1146
  } else if (!file.isFile()) {
1117
1147
  return;
1118
1148
  } else {
@@ -1171,7 +1201,7 @@ export class WorkspaceCollector {
1171
1201
  // identical re-capture adds upload fat without a state change. The
1172
1202
  // replay result is identical either way.
1173
1203
  const journalSignature = (record: ChangeJournalEntry) =>
1174
- JSON.stringify([record.status, record.content, record.encoding, record.target, record.mode]);
1204
+ JSON.stringify([record.status, record.content, record.encoding, record.mode]);
1175
1205
  const pendingRecords = [entry, ...records];
1176
1206
  for (const record of pendingRecords) {
1177
1207
  let duplicate = false;
@@ -1247,7 +1277,7 @@ export class WorkspaceCollector {
1247
1277
  return { files, payload, compressed: await compressZstd(payload) };
1248
1278
  };
1249
1279
  await this.packAndUpload(state, snapshotType, this.iterTreeFiles(state, paths), {
1250
- sizeOf: (file) => Buffer.byteLength(file.content ?? file.target ?? "") + file.path.length + 64,
1280
+ sizeOf: (file) => Buffer.byteLength(file.content ?? "") + file.path.length + 64,
1251
1281
  measure,
1252
1282
  upload: async (measured) => {
1253
1283
  const ok = await this.uploadEnvelope(state, snapshotType, measured.files, measured);
@@ -1258,12 +1288,12 @@ export class WorkspaceCollector {
1258
1288
  this.log("warn", "OmniRush file exceeds the upload rail and cannot be shipped as a single part; skipped", {
1259
1289
  sessionId: state.id,
1260
1290
  path: file.path,
1261
- bytes: Buffer.byteLength(file.content ?? file.target ?? ""),
1291
+ bytes: Buffer.byteLength(file.content ?? ""),
1262
1292
  });
1263
1293
  state.trace.push({
1264
1294
  at: new Date().toISOString(),
1265
1295
  type: "file.unshippable",
1266
- data: { path: file.path, bytes: Buffer.byteLength(file.content ?? file.target ?? "") },
1296
+ data: { path: file.path, bytes: Buffer.byteLength(file.content ?? "") },
1267
1297
  });
1268
1298
  },
1269
1299
  });
@@ -1292,9 +1322,7 @@ export class WorkspaceCollector {
1292
1322
  const slicedByPath = new Map<string, ChangeJournalEntry[]>();
1293
1323
  for (const entry of entries) {
1294
1324
  if (entry.status !== "present") continue;
1295
- if (entry.target !== undefined) {
1296
- lastByPath.set(entry.path, { path: entry.path, target: entry.target });
1297
- } else if (entry.slice) {
1325
+ if (entry.slice) {
1298
1326
  const group = slicedByPath.get(entry.path) ?? [];
1299
1327
  group.push(entry);
1300
1328
  slicedByPath.set(entry.path, group);
@@ -1346,7 +1374,7 @@ export class WorkspaceCollector {
1346
1374
  const bySequence = () => ({ id: state.id, segment: state.segment, resumed: state.resumed, sequence: state.sequence });
1347
1375
  await this.packAndUpload(state, "change", snapshot, {
1348
1376
  sizeOf: (entry) =>
1349
- Buffer.byteLength(entry.content ?? entry.target ?? "") + entry.path.length + 96,
1377
+ Buffer.byteLength(entry.content ?? "") + entry.path.length + 96,
1350
1378
  measure: async (batch) => {
1351
1379
  const files = filesForSlice(batch);
1352
1380
  const payload = buildEnvelopePayload(bySequence(), "change", files);
@@ -1361,7 +1389,7 @@ export class WorkspaceCollector {
1361
1389
  this.log("warn", "OmniRush change record exceeds the upload limits and cannot be shipped; skipped", {
1362
1390
  sessionId: state.id,
1363
1391
  path: entry.path,
1364
- bytes: Buffer.byteLength(entry.content ?? entry.target ?? ""),
1392
+ bytes: Buffer.byteLength(entry.content ?? ""),
1365
1393
  });
1366
1394
  state.trace.push({
1367
1395
  at: new Date().toISOString(),
@@ -1561,8 +1589,15 @@ export class WorkspaceCollector {
1561
1589
  }
1562
1590
  }
1563
1591
  if (!response.ok) {
1564
- await response.body?.cancel().catch(() => undefined);
1565
- throw new Error(`collector upload failed with status ${response.status}`);
1592
+ // Diagnosable from a tester's screenshot alone: status, context,
1593
+ // and (bounded) the server's error body. A 401 here means the
1594
+ // refresh-retry above also failed — the caller should re-login.
1595
+ const snippet = await WorkspaceCollector.errorBodySnippet(response);
1596
+ throw new Error(
1597
+ `collector upload failed: HTTP ${response.status}` +
1598
+ `${snippet ? ` — ${snippet}` : ""}` +
1599
+ ` (session ${state.id}, ${snapshotType}, sequence ${state.sequence + 1})`,
1600
+ );
1566
1601
  }
1567
1602
  state.sentBytes += payload.length;
1568
1603
  state.sequence += 1;
@@ -42,10 +42,18 @@ export default function (pi: any) {
42
42
  return ok ? refresher.auth?.accessToken ?? null : null;
43
43
  }
44
44
  : undefined,
45
- log: (level, message) => {
45
+ log: (level, message, attributes) => {
46
46
  if (level === "warn") {
47
47
  try {
48
- process.stderr.write(`omnirush collector: ${message}\n`);
48
+ // Attributes carry the diagnosis (HTTP status, error message,
49
+ // path, byte counts) — without them a remote failure is
50
+ // undebuggable ("collection operation failed" and nothing
51
+ // else). Attribute values are telemetry-safe (ids, sizes,
52
+ // error strings; never tokens).
53
+ const detail = attributes && Object.keys(attributes).length > 0
54
+ ? ` ${JSON.stringify(attributes)}`
55
+ : "";
56
+ process.stderr.write(`omnirush collector: ${message}${detail}\n`);
49
57
  } catch {
50
58
  /* stderr may be closed late in shutdown */
51
59
  }
@@ -1,48 +1,173 @@
1
1
  /**
2
- * Omnirush commands: /grant (today's token grant + usage) and /verify
3
- * (prove the served model is genuine — full implementation lands with the
4
- * gateway's model-attestation endpoint), plus the footer status line that
5
- * keeps grant/model info visible while the agent runs.
2
+ * Omnirush commands:
3
+ * /grant today's token grant + usage (headers when a request ran,
4
+ * /device/me otherwise)
5
+ * /usage full usage from the manager (grant date, token counters,
6
+ * devices)
7
+ * /status one-screen session summary (identity, model/effort, grant,
8
+ * collector, origin, version)
9
+ * /verify prove the served model is genuine — full implementation
10
+ * lands with the gateway's model-attestation endpoint (M2)
11
+ * plus the footer status line that keeps grant/model info visible while
12
+ * the agent runs.
13
+ *
14
+ * Handler contract (pi): handler(args: string, ctx: ExtensionCommandContext)
15
+ * — the FIRST parameter is the argument string, ctx comes SECOND. Getting
16
+ * this wrong silently no-ops the command (ctx.ui is undefined on a string),
17
+ * which is exactly how /grant and /verify broke the first time.
6
18
  */
7
19
 
20
+ import { readSessionLedger, SESSION_LEDGER_FILE } from "./collector-lib";
21
+ import { deviceMe, omniDir, resolveOrigin } from "./auth";
22
+ import { sharedRefresher } from "./refresh";
23
+ import { formatCollectorLine, formatStatusLines } from "./status-lib";
24
+ import {
25
+ formatDeviceLines,
26
+ formatGrantLines,
27
+ formatGrantStatusLine,
28
+ formatTokenCount,
29
+ formatUsageDetails,
30
+ formatUsageSummary,
31
+ gatewayUsage,
32
+ grantFromUsage,
33
+ } from "./usage";
34
+
8
35
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
9
- import { deviceMe, resolveOrigin } from "./auth";
10
- import { formatGrantLines, formatGrantStatusLine, gatewayUsage } from "./usage";
11
36
 
12
- /** Fetch the signed-in identity + server-side usage, best effort. */
13
- async function fetchIdentity(): Promise<any> {
14
- const token = (process.env.OMNIRUSH_TOKEN || "").trim();
15
- if (!token) return null;
37
+ const CLI_VERSION = (process.env.OMNIRUSH_VERSION || "").trim() || "unknown";
38
+
39
+ interface MeResult {
40
+ me: any;
41
+ error?: string;
42
+ }
43
+
44
+ /**
45
+ * Fetch the signed-in identity + server-side usage with the broker
46
+ * pattern: 401 triggers one single-flight refresh (both tokens rotate,
47
+ * persisted 0600) and a single retry with the rotated token. Never
48
+ * throws; errors come back as `error` strings safe to display.
49
+ */
50
+ async function fetchMe(): Promise<MeResult> {
51
+ const origin = resolveOrigin(process.env);
52
+ const refresher = sharedRefresher();
53
+ // Prefer the refresher's live token — it is already rotated after any
54
+ // in-flight 401 recovery — and fall back to the launcher-provided one.
55
+ const token = refresher.auth?.accessToken || (process.env.OMNIRUSH_TOKEN || "").trim();
56
+ if (!token) return { me: null, error: "not signed in — run `omnirush login` first" };
16
57
  try {
17
- return await deviceMe(resolveOrigin(process.env), {
18
- accessToken: token,
19
- fetchImpl: globalThis.fetch,
20
- });
21
- } catch {
22
- return null;
58
+ let me = await deviceMe(origin, { accessToken: token, fetchImpl: globalThis.fetch });
59
+ if (me !== null) return { me };
60
+ // 401: single-flight refresh, retry once with the rotated token.
61
+ const refreshed = await refresher.refresh(token).catch(() => false);
62
+ const next = refresher.auth?.accessToken;
63
+ if (refreshed && next && next !== token) {
64
+ me = await deviceMe(origin, { accessToken: next, fetchImpl: globalThis.fetch });
65
+ if (me !== null) return { me };
66
+ }
67
+ return { me: null, error: "credentials rejected — run `omnirush login` again" };
68
+ } catch (error) {
69
+ return { me: null, error: `manager unreachable (${error?.message ?? error})` };
23
70
  }
24
71
  }
25
72
 
73
+ function identityOf(me: any): string | null {
74
+ if (!me || typeof me !== "object") return null;
75
+ for (const key of ["email", "device_name", "user_id", "id"] as const) {
76
+ const value = me[key];
77
+ if (typeof value === "string" && value) return value;
78
+ }
79
+ return null;
80
+ }
81
+
82
+ /** The grant summary string shared by /grant and /status, or null. */
83
+ function grantSummary(me: any): { text: string; date?: string } | null {
84
+ const usage = gatewayUsage();
85
+ const fromHeaders = grantFromUsage(usage);
86
+ const meUsage = me?.usage ?? null;
87
+ const limit = fromHeaders?.limitTokens ?? Number(meUsage?.token_limit);
88
+ const remaining = fromHeaders?.remainingTokens ?? Number(meUsage?.remaining_tokens);
89
+ if (!Number.isFinite(limit) || !Number.isFinite(remaining)) return null;
90
+ const used = Number(meUsage?.used_tokens);
91
+ const usedPart = Number.isFinite(used) ? ` (used ${formatTokenCount(used)})` : "";
92
+ return {
93
+ text: `${formatTokenCount(remaining)} of ${formatTokenCount(limit)} tokens remaining${usedPart}`,
94
+ date: typeof meUsage?.grant_date === "string" ? meUsage.grant_date : undefined,
95
+ };
96
+ }
97
+
98
+ function notify(ctx: any, text: string, level: "info" | "error" = "info"): void {
99
+ ctx?.ui?.notify?.(text, level);
100
+ }
101
+
26
102
  export default function (pi: ExtensionAPI) {
27
103
  pi.registerCommand("grant", {
28
104
  description: "Show today's Omnirush token grant and usage",
29
- handler: async (ctx: any) => {
105
+ handler: async (_args: string, ctx: any) => {
30
106
  const usage = gatewayUsage();
31
- const me = await fetchIdentity();
107
+ const { me } = await fetchMe();
32
108
  const sessionId = ctx?.sessionManager?.getSessionId?.() ?? undefined;
33
109
  const lines = formatGrantLines({ usage, me, sessionId });
34
- ctx.ui?.notify?.(
35
- ["Omnirush usage:", ...lines.map((line) => ` ${line}`)].join("\n"),
36
- );
110
+ notify(ctx, ["Omnirush usage:", ...lines.map((line) => ` ${line}`)].join("\n"));
111
+ },
112
+ });
113
+
114
+ pi.registerCommand("usage", {
115
+ description: "Show detailed Omnirush usage from the manager (counters + devices)",
116
+ handler: async (_args: string, ctx: any) => {
117
+ const { me, error } = await fetchMe();
118
+ if (!me) {
119
+ notify(ctx, `Omnirush usage unavailable: ${error}`, "error");
120
+ return;
121
+ }
122
+ const lines = [
123
+ "Omnirush usage (from the manager):",
124
+ ...formatUsageDetails({ me }).map((line) => ` ${line}`),
125
+ ];
126
+ const summary = formatUsageSummary(me);
127
+ if (summary) lines.push(` summary: ${summary}`);
128
+ lines.push("", ...formatDeviceLines(me));
129
+ notify(ctx, lines.join("\n"));
130
+ },
131
+ });
132
+
133
+ pi.registerCommand("status", {
134
+ description: "Show the Omnirush session status (identity, model, grant, collector)",
135
+ handler: async (_args: string, ctx: any) => {
136
+ const { me } = await fetchMe();
137
+ const usage = gatewayUsage();
138
+ const grant = grantSummary(me);
139
+ const sessionId = String(ctx?.sessionManager?.getSessionId?.() ?? "");
140
+ let record = null;
141
+ try {
142
+ const ledger = await readSessionLedger(`${omniDir()}/${SESSION_LEDGER_FILE}`);
143
+ record = ledger.sessions[sessionId] ?? null;
144
+ } catch {
145
+ /* collector state is best effort */
146
+ }
147
+ const model = ctx?.model;
148
+ const statusLines = formatStatusLines({
149
+ signedIn: Boolean(me),
150
+ identity: identityOf(me),
151
+ model: model ? `${model.provider ?? "omnirush"}/${model.id}` : null,
152
+ thinkingLevel: typeof ctx?.thinkingLevel === "string" ? ctx.thinkingLevel : null,
153
+ effort: usage?.["x-omnirush-effort"] ?? null,
154
+ grant: grant?.text ?? null,
155
+ grantDate: grant?.date ?? null,
156
+ collector: { sessionId, record },
157
+ origin: resolveOrigin(process.env),
158
+ version: CLI_VERSION,
159
+ });
160
+ notify(ctx, ["Omnirush status:", ...statusLines.map((line) => ` ${line}`)].join("\n"));
37
161
  },
38
162
  });
39
163
 
40
164
  pi.registerCommand("verify", {
41
165
  description: "Verify the model serving your requests is genuine GPT-6 Astra",
42
- handler: async (ctx: any) => {
166
+ handler: async (_args: string, ctx: any) => {
43
167
  // M2: POST /omnirush/verify → signed attestation compared against
44
168
  // observed model behavior. v0 placeholder.
45
- ctx.ui?.notify?.(
169
+ notify(
170
+ ctx,
46
171
  "verify: model attestation arrives with the gateway lane (M2).",
47
172
  );
48
173
  },
@@ -3,10 +3,13 @@
3
3
  // pi discovers either extensions/*.ts files or extensions/<dir>/index.ts —
4
4
  // this index makes the whole omnirush extension directory load as one
5
5
  // extension (without it, none of these files were ever registered).
6
+ // EVERY module that registers commands/tools/handlers must be wired here:
7
+ // a module left out of this list is silently dead in sessions.
6
8
 
7
9
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
8
10
  import collector from "./collector";
9
11
  import commands from "./commands";
12
+ import mcp from "./mcp";
10
13
  import recorder from "./recorder";
11
14
  import sota from "./sota";
12
15
 
@@ -15,4 +18,5 @@ export default function (pi: ExtensionAPI) {
15
18
  recorder(pi as any);
16
19
  sota(pi as any);
17
20
  collector(pi as any);
21
+ mcp(pi as any);
18
22
  }