@omercnet/paseo-omp 0.2.1 → 0.3.0-next.92.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.
Files changed (63) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +25 -13
  3. package/SUPPORT.md +6 -2
  4. package/TESTING.md +21 -18
  5. package/client/composer-pill-settings.tsx +157 -0
  6. package/client/external-url.ts +15 -0
  7. package/client/mcp-authorization.tsx +169 -0
  8. package/client/mcp-popover.tsx +155 -0
  9. package/client/memory-panel.tsx +8 -3
  10. package/client/memory-popover.tsx +8 -4
  11. package/client/omp-config-surface.tsx +189 -29
  12. package/client/omp-plugin-manager.tsx +302 -131
  13. package/client/omp-store-picker.tsx +89 -0
  14. package/client/omp-store-state.ts +45 -0
  15. package/client/paseo-types.ts +9 -0
  16. package/client/provider-diagnostics-state.ts +18 -7
  17. package/client/quota-popover.tsx +8 -3
  18. package/client/quota-state.ts +16 -7
  19. package/client/sessions-popover.tsx +8 -3
  20. package/docs/alpha-release-checklist.md +6 -8
  21. package/docs/configuration.md +8 -4
  22. package/docs/core-provider-issue-audit.md +3 -2
  23. package/docs/images/mcp-authorization-compact.png +0 -0
  24. package/docs/images/mcp-controls-wide.png +0 -0
  25. package/docs/images/plugin-manager.png +0 -0
  26. package/docs/images/workspace-settings.png +0 -0
  27. package/docs/installation.md +35 -19
  28. package/index.client.tsx +339 -123
  29. package/index.server.ts +44 -14
  30. package/package.json +7 -8
  31. package/paseo-plugin.json +2 -2
  32. package/scripts/prepare-dependencies.mjs +24 -0
  33. package/server/mcp-browser.ts +95 -0
  34. package/server/memory.ts +2 -2
  35. package/server/omp-config.ts +16 -7
  36. package/server/omp-plugins.ts +70 -21
  37. package/server/omp-settings.ts +232 -24
  38. package/server/paths.ts +128 -11
  39. package/server/provider/catalog.ts +3 -4
  40. package/server/provider/connection.ts +213 -9
  41. package/server/provider/host-tools.ts +71 -0
  42. package/server/provider/omp-rpc.ts +82 -15
  43. package/server/provider/profile-providers.ts +249 -0
  44. package/server/provider/registration.ts +11 -0
  45. package/server/provider/session-descriptors.ts +306 -1
  46. package/server/provider/session.ts +704 -249
  47. package/server/provider/subsessions.ts +4 -1
  48. package/server/provider/timeline-projector.ts +70 -33
  49. package/server/provider-diagnostics.ts +122 -36
  50. package/server/quota.ts +3 -2
  51. package/server/sessions.ts +2 -2
  52. package/shared/composer-pill-settings.ts +28 -0
  53. package/shared/external-url.ts +21 -0
  54. package/shared/hub.ts +3 -3
  55. package/shared/mcp.ts +47 -0
  56. package/shared/memory.ts +2 -1
  57. package/shared/omp-config.ts +5 -1
  58. package/shared/omp-plugins.ts +74 -33
  59. package/shared/omp-settings.ts +8 -1
  60. package/shared/omp-store.ts +58 -0
  61. package/shared/provider-diagnostics.ts +12 -3
  62. package/shared/quota.ts +2 -1
  63. package/shared/sessions.ts +2 -1
@@ -304,6 +304,7 @@ export class OmpSubsessionProjector {
304
304
  private readonly scheduler: OmpTimelineScheduler,
305
305
  private readonly onActivityChange: () => void,
306
306
  private readonly outputRedactionValues: readonly string[],
307
+ private readonly pluginTimelineEnabled = false,
307
308
  ) {
308
309
  this.dataFilter = new OmpPublicDataSerializer(outputRedactionValues);
309
310
  }
@@ -540,6 +541,8 @@ export class OmpSubsessionProjector {
540
541
  this.emit,
541
542
  this.scheduler,
542
543
  this.outputRedactionValues,
544
+ false,
545
+ this.pluginTimelineEnabled,
543
546
  ),
544
547
  };
545
548
  this.children.set(sessionId, child);
@@ -552,7 +555,7 @@ export class OmpSubsessionProjector {
552
555
  type: "session.opened",
553
556
  sessionId,
554
557
  parentSessionId,
555
- capabilities: [],
558
+ capabilities: ["session.subsession"],
556
559
  restoration: "parent",
557
560
  cwd: this.cwd,
558
561
  title,
@@ -4,8 +4,9 @@ import type {
4
4
  ProviderTimelineItem,
5
5
  ProviderToolCallDetail,
6
6
  } from "@getpaseo/plugin/server/provider";
7
+ import { OMP_MCP_AUTH_TIMELINE_KIND } from "../../shared/mcp";
7
8
  import { isOmpImageMimeType, isValidImagePayload, type OmpImageMimeType } from "./image";
8
- import type { OmpMessage, OmpRpcEvent } from "./omp-rpc";
9
+ import { OMP_MAX_CONTENT_PARTS, type OmpMessage, type OmpRpcEvent } from "./omp-rpc";
9
10
  import {
10
11
  boundedJsonBytes,
11
12
  type JsonValue,
@@ -15,7 +16,6 @@ import {
15
16
  } from "./security";
16
17
 
17
18
  const STREAM_FRAME_MS = 32;
18
- const MAX_STREAM_CONTENT_BLOCKS = 64;
19
19
  const MAX_STREAM_TEXT_LENGTH = 4 * 1024 * 1024;
20
20
  const MAX_ACTIVE_TOOLS = 64;
21
21
  const MAX_TODOS = 256;
@@ -54,6 +54,7 @@ type StreamSnapshot = {
54
54
  nativeIdentity?: string;
55
55
  published: boolean;
56
56
  retainedBytes: number;
57
+ textBytes: number;
57
58
  publishedBytes: number;
58
59
  blocks: Map<number, StreamBlockSnapshot>;
59
60
  dirtyBlocks: Set<number>;
@@ -196,6 +197,17 @@ function publishableHttpUrl(value: string | undefined): string | undefined {
196
197
  return undefined;
197
198
  }
198
199
  }
200
+ function isOmpOAuthLaunchUrl(value: string | undefined): boolean {
201
+ const url = publishableHttpUrl(value);
202
+ if (!url) return false;
203
+ const parsed = new URL(url);
204
+ return (
205
+ (parsed.hostname === "localhost" ||
206
+ parsed.hostname === "127.0.0.1" ||
207
+ parsed.hostname === "[::1]") &&
208
+ parsed.pathname === "/launch"
209
+ );
210
+ }
199
211
 
200
212
  function todoPublicId(nativeId: string | undefined, index: number): string {
201
213
  if (!nativeId) return `omp:todo:${index}`;
@@ -240,7 +252,7 @@ function nativeImageResult(
240
252
  boundedJsonBytes(
241
253
  value,
242
254
  MAX_NATIVE_IMAGE_RESULT_BYTES,
243
- MAX_STREAM_CONTENT_BLOCKS,
255
+ OMP_MAX_CONTENT_PARTS,
244
256
  8 * 1024 * 1024,
245
257
  512,
246
258
  ) === Number.POSITIVE_INFINITY
@@ -350,6 +362,7 @@ export class OmpTimelineProjector {
350
362
  private readonly replayCandidates = new Map<string, ReplayCandidate>();
351
363
  private readonly replayOverflowCandidates = new Map<string, string>();
352
364
  private projectingReplay = false;
365
+ private readonly replayToolCalls = new Map<string, { toolName: string; args: unknown }>();
353
366
  private customSequence = 0;
354
367
  private compactionSequence = 0;
355
368
  private activeCompaction: CompactionSlot | null = null;
@@ -366,6 +379,7 @@ export class OmpTimelineProjector {
366
379
  private closed = false;
367
380
 
368
381
  private readonly dataFilter: OmpPublicDataSerializer;
382
+ private browserAuthorizationIssuer: ((url: string) => string | undefined) | null = null;
369
383
 
370
384
  constructor(
371
385
  private readonly sessionId: string,
@@ -373,10 +387,14 @@ export class OmpTimelineProjector {
373
387
  private readonly scheduler: OmpTimelineScheduler = defaultOmpTimelineScheduler,
374
388
  outputRedactionValues: readonly string[] = [],
375
389
  private readonly conversationRevertEnabled = false,
390
+ private readonly pluginTimelineEnabled = false,
376
391
  private readonly hostToolLabels: ReadonlyMap<string, string> = new Map(),
377
392
  ) {
378
393
  this.dataFilter = new OmpPublicDataSerializer(outputRedactionValues);
379
394
  }
395
+ setBrowserAuthorizationIssuer(issue: ((url: string) => string | undefined) | null): void {
396
+ this.browserAuthorizationIssuer = issue;
397
+ }
380
398
 
381
399
  project(event: OmpRpcEvent, turnId: string, bypassReplayFilter = false): void {
382
400
  if (this.closed) return;
@@ -601,15 +619,40 @@ export class OmpTimelineProjector {
601
619
  return;
602
620
  }
603
621
  if (event.type === "extension_ui_request" && event.method === "open_url") {
604
- const url = publishableHttpUrl(event.launchUrl ?? event.url);
622
+ const url = publishableHttpUrl(event.url);
605
623
  if (!url) return;
606
624
  this.noticeSequence += 1;
607
- const message = [event.instructions, url].filter(Boolean).join("\n");
625
+ const instructions = event.instructions
626
+ ? this.dataFilter.text(event.instructions, 64 * 1024)
627
+ : undefined;
628
+ const publicUrl = this.dataFilter.text(url, 16_384);
629
+ const isMcpAuthorization = isOmpOAuthLaunchUrl(event.launchUrl);
630
+ if (this.pluginTimelineEnabled && isMcpAuthorization && publishableHttpUrl(publicUrl)) {
631
+ const browserAuthorizationToken = this.browserAuthorizationIssuer?.(publicUrl);
632
+ this.publish({
633
+ type: "plugin",
634
+ id: `omp:ui:${this.noticeSequence}`,
635
+ pluginId: "paseo-omp",
636
+ kind: OMP_MCP_AUTH_TIMELINE_KIND,
637
+ version: 1,
638
+ data: {
639
+ url: publicUrl,
640
+ ...(instructions ? { instructions } : {}),
641
+ loopbackCallback: true,
642
+ ...(browserAuthorizationToken ? { browserAuthorizationToken } : {}),
643
+ },
644
+ });
645
+ return;
646
+ }
647
+ const remoteHint = isMcpAuthorization
648
+ ? "Remote client: if the localhost callback cannot connect, paste the final redirect URL or authorization code into the OMP prompt in this chat."
649
+ : undefined;
650
+ const message = [instructions, publicUrl, remoteHint].filter(Boolean).join("\n");
608
651
  this.publish({
609
652
  type: "notification",
610
653
  id: `omp:ui:${this.noticeSequence}`,
611
654
  level: "info",
612
- message: this.dataFilter.text(message, 64 * 1024),
655
+ message,
613
656
  });
614
657
  return;
615
658
  }
@@ -783,6 +826,7 @@ export class OmpTimelineProjector {
783
826
  this.replayOverflowCandidates.clear();
784
827
  this.projectingReplay = false;
785
828
  this.activeToolBytes = 0;
829
+ this.replayToolCalls.clear();
786
830
  this.tools.clear();
787
831
  this.commandText = "";
788
832
  this.commandPublishedText = "";
@@ -795,6 +839,7 @@ export class OmpTimelineProjector {
795
839
  const nativeIdentity = assistantIdentity(message);
796
840
  this.replaySequence += 1;
797
841
  if (message.role === "user") {
842
+ this.replayToolCalls.clear();
798
843
  if (this.replayTurnId) this.finishTurn(this.replayTurnId);
799
844
  this.replayTurnId = `omp:replay-turn:${this.replaySequence}`;
800
845
  const text =
@@ -823,15 +868,7 @@ export class OmpTimelineProjector {
823
868
  for (const part of message.content) {
824
869
  if (part.type !== "toolCall" || !part.id || !part.name || part.arguments === undefined)
825
870
  continue;
826
- this.project(
827
- {
828
- type: "tool_execution_start",
829
- toolCallId: part.id,
830
- toolName: part.name,
831
- args: part.arguments,
832
- },
833
- this.replayTurnId,
834
- );
871
+ this.replayToolCalls.set(part.id, { toolName: part.name, args: part.arguments });
835
872
  }
836
873
  }
837
874
  } finally {
@@ -842,13 +879,15 @@ export class OmpTimelineProjector {
842
879
  }
843
880
  this.replayTurnId ??= `omp:replay-turn:${this.replaySequence}`;
844
881
  if (message.role === "toolResult") {
882
+ const replayCall = this.replayToolCalls.get(message.toolCallId);
883
+ this.replayToolCalls.delete(message.toolCallId);
845
884
  if (!this.tools.has(message.toolCallId)) {
846
885
  this.project(
847
886
  {
848
887
  type: "tool_execution_start",
849
888
  toolCallId: message.toolCallId,
850
- toolName: message.toolName,
851
- args: null,
889
+ toolName: replayCall?.toolName ?? message.toolName,
890
+ args: replayCall?.args ?? null,
852
891
  },
853
892
  this.replayTurnId,
854
893
  );
@@ -880,6 +919,7 @@ export class OmpTimelineProjector {
880
919
 
881
920
  finishReplay(): void {
882
921
  if (this.replayTurnId) this.finishTurn(this.replayTurnId);
922
+ this.replayToolCalls.clear();
883
923
  this.replayTurnId = null;
884
924
  }
885
925
  acceptLiveTurn(turnId: string): void {
@@ -1027,9 +1067,11 @@ export class OmpTimelineProjector {
1027
1067
  if (this.currentTurnId) this.publishCommand(this.currentTurnId);
1028
1068
  this.retireCompactions("OMP compaction ended when the session closed");
1029
1069
  this.closed = true;
1070
+ this.browserAuthorizationIssuer = null;
1030
1071
  this.clearFlushTimer();
1031
1072
  this.stream = null;
1032
1073
  this.tools.clear();
1074
+ this.replayToolCalls.clear();
1033
1075
  this.activeToolBytes = 0;
1034
1076
  this.replayCandidates.clear();
1035
1077
  this.replayOverflowCandidates.clear();
@@ -1091,6 +1133,7 @@ export class OmpTimelineProjector {
1091
1133
  ...(nativeIdentity ? { nativeIdentity } : {}),
1092
1134
  published: false,
1093
1135
  retainedBytes: 0,
1136
+ textBytes: 0,
1094
1137
  publishedBytes: 0,
1095
1138
  blocks: new Map(),
1096
1139
  dirtyBlocks: new Set(),
@@ -1155,7 +1198,7 @@ export class OmpTimelineProjector {
1155
1198
  return;
1156
1199
  }
1157
1200
  if (!Array.isArray(message.content)) return;
1158
- const blockCount = Math.min(message.content.length, MAX_STREAM_CONTENT_BLOCKS);
1201
+ const blockCount = Math.min(message.content.length, OMP_MAX_CONTENT_PARTS);
1159
1202
  for (let index = 0; index < blockCount; index += 1) {
1160
1203
  const block = blockText(message, index);
1161
1204
  if (block) this.setBlock(stream, index, block);
@@ -1215,20 +1258,13 @@ export class OmpTimelineProjector {
1215
1258
  if (snapshot.kind === "image" && utf8Bytes(snapshot.text) > MAX_IMAGE_ENCODED_LENGTH + 256)
1216
1259
  return;
1217
1260
  const previous = stream.blocks.get(contentIndex);
1218
- let retainedBytes = utf8Bytes(snapshot.text);
1219
- let textBytes = snapshot.kind === "image" ? 0 : retainedBytes;
1220
- for (const [index, block] of stream.blocks) {
1221
- if (index === contentIndex) continue;
1222
- const blockBytes = utf8Bytes(block.text);
1223
- retainedBytes += blockBytes;
1224
- if (block.kind !== "image") textBytes += blockBytes;
1225
- if (
1226
- retainedBytes + stream.publishedBytes > MAX_STREAM_TOTAL_BYTES ||
1227
- textBytes > MAX_STREAM_TEXT_LENGTH
1228
- ) {
1229
- return;
1230
- }
1231
- }
1261
+ const previousBytes = previous ? utf8Bytes(previous.text) : 0;
1262
+ const snapshotBytes = utf8Bytes(snapshot.text);
1263
+ const retainedBytes = stream.retainedBytes - previousBytes + snapshotBytes;
1264
+ const textBytes =
1265
+ stream.textBytes -
1266
+ (previous && previous.kind !== "image" ? previousBytes : 0) +
1267
+ (snapshot.kind === "image" ? 0 : snapshotBytes);
1232
1268
  if (
1233
1269
  retainedBytes + stream.publishedBytes > MAX_STREAM_TOTAL_BYTES ||
1234
1270
  textBytes > MAX_STREAM_TEXT_LENGTH
@@ -1237,6 +1273,7 @@ export class OmpTimelineProjector {
1237
1273
  }
1238
1274
  if (previous?.kind === snapshot.kind && previous.text === snapshot.text) return;
1239
1275
  stream.retainedBytes = retainedBytes;
1276
+ stream.textBytes = textBytes;
1240
1277
  stream.blocks.set(contentIndex, {
1241
1278
  ...snapshot,
1242
1279
  ...(previous?.kind === snapshot.kind ? { publishedText: previous.publishedText } : {}),
@@ -1248,7 +1285,7 @@ export class OmpTimelineProjector {
1248
1285
  return (
1249
1286
  Number.isSafeInteger(contentIndex) &&
1250
1287
  contentIndex >= 0 &&
1251
- contentIndex < MAX_STREAM_CONTENT_BLOCKS
1288
+ contentIndex < OMP_MAX_CONTENT_PARTS
1252
1289
  );
1253
1290
  }
1254
1291
 
@@ -17,7 +17,7 @@ import type {
17
17
  OmpVersionStatus,
18
18
  PathState,
19
19
  } from "../shared/provider-diagnostics";
20
- import { ompAgentDir } from "./paths";
20
+ import { currentOmpEnvironment, ompAgentDir, ompDataDir, ompSessionDir } from "./paths";
21
21
 
22
22
  const VERSION_TIMEOUT_MS = 3_000;
23
23
  const HELP_TIMEOUT_MS = 3_000;
@@ -190,7 +190,11 @@ export async function killWindowsProcessTree(
190
190
 
191
191
  let child: ProbeChildProcess;
192
192
  try {
193
- child = spawnFn(taskkillPath, ["/pid", String(pid), "/t", "/f"], buildProbeEnv(process.env));
193
+ child = spawnFn(
194
+ taskkillPath,
195
+ ["/pid", String(pid), "/t", "/f"],
196
+ buildProbeEnv(currentOmpEnvironment()),
197
+ );
194
198
  } catch {
195
199
  return false;
196
200
  }
@@ -243,7 +247,7 @@ export function defaultSpawn(
243
247
  return killWindowsProcessTree(
244
248
  child.pid,
245
249
  defaultSpawn,
246
- process.env.SystemRoot ?? WINDOWS_DEFAULT_SYSTEM_ROOT,
250
+ currentOmpEnvironment().SystemRoot ?? WINDOWS_DEFAULT_SYSTEM_ROOT,
247
251
  graceMs,
248
252
  );
249
253
  }
@@ -569,7 +573,7 @@ export async function probeOmpAvailability(options: OmpAvailabilityProbeOptions)
569
573
  status: "missing" | "unrunnable" | "incompatible" | "available";
570
574
  diagnostic?: string;
571
575
  }> {
572
- const environment = options.environment ?? process.env;
576
+ const environment = options.environment ?? currentOmpEnvironment();
573
577
  const [command, ...prefixArgs] = options.command;
574
578
  const resolvedPath = await resolveExecutablePath(
575
579
  command,
@@ -810,31 +814,57 @@ async function computeMcpDiagnostics(agentDir: string): Promise<OmpMcpDiagnostic
810
814
  export interface ProcessDiagnosticsFs {
811
815
  readdir(path: string): Promise<string[]>;
812
816
  lstat(path: string): Promise<Stats>;
817
+ readMetadata(path: string, maxBytes: number): Promise<BoundedFileRead>;
813
818
  }
814
819
 
815
- const processDiagnosticsFs: ProcessDiagnosticsFs = { readdir, lstat };
820
+ const processDiagnosticsFs: ProcessDiagnosticsFs = {
821
+ readdir,
822
+ lstat,
823
+ readMetadata: readBoundedNoSymlinkFile,
824
+ };
825
+ const MAX_PROCESS_METADATA_BYTES = 64 * 1024;
826
+ const MAX_PROCESS_METADATA_ENTRIES = 4_096;
827
+ const ProcessStateSchema = z.object({
828
+ daemon: z.object({
829
+ state: z.string().max(32),
830
+ exitedAt: z.number().finite().nonnegative().nullish(),
831
+ }),
832
+ });
833
+ const ACTIVE_PROCESS_STATES = new Set(["starting", "running", "ready", "restarting"]);
834
+ const HISTORICAL_PROCESS_STATES = new Set(["exited", "failed", "stopped"]);
816
835
 
817
836
  /**
818
- * Counts only regular, non-symlink meta.json entries. Missing/wrong entries are expected and
819
- * ignored; any other read/stat failure makes the result partial rather than silently hiding it.
837
+ * Classifies bounded, regular, non-symlink Hub metadata. A recorded active state is not a
838
+ * liveness check. Historical files remain useful evidence and are never counted as running.
839
+ * Only aggregate counts leave this reader; metadata arguments, paths and values are discarded.
820
840
  */
821
841
  export async function computeProcessDiagnostics(
822
842
  hubRunRoot: string,
823
843
  fs: ProcessDiagnosticsFs = processDiagnosticsFs,
824
844
  ): Promise<OmpProcessDiagnostics> {
845
+ const unavailableCounts = {
846
+ trackedCount: null,
847
+ activeCount: null,
848
+ historicalCount: null,
849
+ unknownCount: null,
850
+ };
825
851
  let projectHashes: string[];
826
852
  try {
827
853
  const rootStats = await fs.lstat(hubRunRoot);
828
854
  if (rootStats.isSymbolicLink() || !rootStats.isDirectory()) {
829
- return { status: "unavailable", trackedCount: null };
855
+ return { status: "unavailable", ...unavailableCounts };
830
856
  }
831
857
  projectHashes = await fs.readdir(hubRunRoot);
832
858
  } catch (error) {
833
- return { status: isEnoent(error) ? "unavailable" : "unknown", trackedCount: null };
859
+ return { status: isEnoent(error) ? "unavailable" : "unknown", ...unavailableCounts };
834
860
  }
835
861
  let trackedCount = 0;
836
- let partial = false;
837
- for (const hash of projectHashes) {
862
+ let activeCount = 0;
863
+ let historicalCount = 0;
864
+ let unknownCount = 0;
865
+ let partial = projectHashes.length > MAX_PROCESS_METADATA_ENTRIES;
866
+ let visitedEntries = 0;
867
+ for (const hash of projectHashes.slice(0, MAX_PROCESS_METADATA_ENTRIES)) {
838
868
  const projectDir = join(hubRunRoot, hash);
839
869
  const daemonsDir = join(projectDir, "daemons");
840
870
  try {
@@ -855,15 +885,51 @@ export async function computeProcessDiagnostics(
855
885
  continue;
856
886
  }
857
887
  for (const name of daemonNames) {
888
+ if (++visitedEntries > MAX_PROCESS_METADATA_ENTRIES) {
889
+ partial = true;
890
+ break;
891
+ }
858
892
  try {
859
- const metaStats = await fs.lstat(join(daemonsDir, name, "meta.json"));
860
- if (!metaStats.isSymbolicLink() && metaStats.isFile()) trackedCount += 1;
893
+ const daemonDir = join(daemonsDir, name);
894
+ const directoryStats = await fs.lstat(daemonDir);
895
+ if (directoryStats.isSymbolicLink() || !directoryStats.isDirectory()) continue;
896
+ const metaPath = join(daemonDir, "meta.json");
897
+ const metaStats = await fs.lstat(metaPath);
898
+ if (metaStats.isSymbolicLink() || !metaStats.isFile()) continue;
899
+ trackedCount += 1;
900
+ let category: "active" | "historical" | "unknown" = "unknown";
901
+ try {
902
+ const file = await fs.readMetadata(metaPath, MAX_PROCESS_METADATA_BYTES);
903
+ if (file.state === "available") {
904
+ const parsed = ProcessStateSchema.safeParse(JSON.parse(file.text));
905
+ if (parsed.success) {
906
+ const { state, exitedAt } = parsed.data.daemon;
907
+ if (HISTORICAL_PROCESS_STATES.has(state) || exitedAt != null) category = "historical";
908
+ else if (ACTIVE_PROCESS_STATES.has(state)) category = "active";
909
+ }
910
+ }
911
+ } catch {
912
+ // Read/access/parse errors have an honest unknown bucket, never a guessed active state.
913
+ }
914
+ if (category === "historical") historicalCount += 1;
915
+ else if (category === "active") activeCount += 1;
916
+ else {
917
+ unknownCount += 1;
918
+ partial = true;
919
+ }
861
920
  } catch (error) {
862
921
  if (!isEnoent(error)) partial = true;
863
922
  }
864
923
  }
924
+ if (visitedEntries > MAX_PROCESS_METADATA_ENTRIES) break;
865
925
  }
866
- return { status: partial ? "partial" : "ok", trackedCount };
926
+ return {
927
+ status: partial ? "partial" : "ok",
928
+ trackedCount,
929
+ activeCount,
930
+ historicalCount,
931
+ unknownCount,
932
+ };
867
933
  }
868
934
 
869
935
  function homeRelative(path: string, homeDir: string): string | null {
@@ -880,16 +946,23 @@ function sanitizeRootPath(path: string, homeDir: string): string {
880
946
  return homeRelative(path, homeDir) ?? "<custom path>";
881
947
  }
882
948
 
883
- function sanitizeDerivedPath(rawRoot: string, sanitizedRoot: string, fullPath: string): string {
949
+ function sanitizeDerivedPath(
950
+ rawRoot: string,
951
+ sanitizedRoot: string,
952
+ fullPath: string,
953
+ fallbackHomeDir?: string,
954
+ ): string {
884
955
  const suffix = relative(rawRoot, fullPath);
885
956
  if (suffix === "" || suffix === ".." || suffix.startsWith(`..${sep}`) || isAbsolute(suffix)) {
886
- return sanitizedRoot;
957
+ return fallbackHomeDir ? sanitizeRootPath(fullPath, fallbackHomeDir) : sanitizedRoot;
887
958
  }
888
959
  return `${sanitizedRoot}/${suffix.split(sep).join("/")}`;
889
960
  }
890
961
 
891
962
  export interface ProviderDiagnosticsDeps {
892
963
  agentDir: string;
964
+ dataDir?: string;
965
+ sessionDir?: string;
893
966
  command: string;
894
967
  pathDirs: readonly string[];
895
968
  cwd: string;
@@ -910,6 +983,8 @@ export async function computeOmpProviderHealth(
910
983
  deps: ProviderDiagnosticsDeps,
911
984
  ): Promise<OmpProviderHealth> {
912
985
  const agentDir = resolve(deps.agentDir);
986
+ const dataDir = resolve(deps.dataDir ?? agentDir);
987
+ const sessionRoot = resolve(deps.sessionDir ?? join(dataDir, SESSION_DIR_NAME));
913
988
  const homeDir = resolve(deps.homeDir);
914
989
  const versionTimeoutMs = deps.versionTimeoutMs ?? VERSION_TIMEOUT_MS;
915
990
  const helpTimeoutMs = deps.helpTimeoutMs ?? HELP_TIMEOUT_MS;
@@ -935,6 +1010,7 @@ export async function computeOmpProviderHealth(
935
1010
  versionTimeoutMs,
936
1011
  killGraceMs,
937
1012
  maxVersionBytes,
1013
+ deps.cwd,
938
1014
  )
939
1015
  : null,
940
1016
  installed
@@ -946,6 +1022,7 @@ export async function computeOmpProviderHealth(
946
1022
  helpTimeoutMs,
947
1023
  killGraceMs,
948
1024
  maxHelpBytes,
1025
+ deps.cwd,
949
1026
  )
950
1027
  : null,
951
1028
  ]);
@@ -957,9 +1034,8 @@ export async function computeOmpProviderHealth(
957
1034
  const lsp = helpRun ? computeLspDiagnostics(helpRun) : { status: "unknown" as const };
958
1035
  const processCleanupFailed = Boolean(versionRun?.cleanupFailed || helpRun?.cleanupFailed);
959
1036
 
960
- const agentDbPath = join(agentDir, AGENT_DB_FILENAME);
961
- const historyDbPath = join(agentDir, HISTORY_DB_FILENAME);
962
- const sessionRoot = join(agentDir, SESSION_DIR_NAME);
1037
+ const agentDbPath = join(dataDir, AGENT_DB_FILENAME);
1038
+ const historyDbPath = join(dataDir, HISTORY_DB_FILENAME);
963
1039
  const [
964
1040
  configResult,
965
1041
  agentRootState,
@@ -1000,7 +1076,7 @@ export async function computeOmpProviderHealth(
1000
1076
  agentRootState,
1001
1077
  configPath: sanitizeDerivedPath(agentDir, sanitizedAgentRoot, configResult.path),
1002
1078
  configState,
1003
- sessionRoot: sanitizeDerivedPath(agentDir, sanitizedAgentRoot, sessionRoot),
1079
+ sessionRoot: sanitizeDerivedPath(agentDir, sanitizedAgentRoot, sessionRoot, homeDir),
1004
1080
  sessionRootState,
1005
1081
  },
1006
1082
  databases: {
@@ -1013,11 +1089,11 @@ export async function computeOmpProviderHealth(
1013
1089
  };
1014
1090
  }
1015
1091
 
1016
- let cachedHealth: { value: OmpProviderHealth; expiresAt: number } | null = null;
1017
- let inFlightHealth: Promise<OmpProviderHealth> | null = null;
1092
+ const cachedHealth = new Map<string, { value: OmpProviderHealth; expiresAt: number }>();
1093
+ const inFlightHealth = new Map<string, Promise<OmpProviderHealth>>();
1018
1094
 
1019
- function defaultHubRunRoot(): string {
1020
- return process.env.PASEO_OMP_RUN_DIR ?? join(homedir(), ".omp", "run", "daemons");
1095
+ function defaultHubRunRoot(environment: NodeJS.ProcessEnv = currentOmpEnvironment()): string {
1096
+ return environment.PASEO_OMP_RUN_DIR ?? join(homedir(), ".omp", "run", "daemons");
1021
1097
  }
1022
1098
 
1023
1099
  /**
@@ -1029,29 +1105,39 @@ function defaultHubRunRoot(): string {
1029
1105
  export async function resolveGetOmpProviderHealth(
1030
1106
  input: RpcInput<typeof getOmpProviderHealth>,
1031
1107
  ): Promise<OmpProviderHealth> {
1108
+ const cwd = input.cwd ?? process.cwd();
1109
+ const environment = currentOmpEnvironment();
1110
+ const agentDir = ompAgentDir(environment);
1111
+ const dataDir = ompDataDir(environment);
1112
+ const sessionDir = ompSessionDir(environment);
1032
1113
  const now = Date.now();
1033
- if (!input.force && cachedHealth && cachedHealth.expiresAt > now) return cachedHealth.value;
1034
- if (inFlightHealth) return inFlightHealth;
1114
+ const key = JSON.stringify([cwd, agentDir, dataDir, sessionDir]);
1115
+ const cached = cachedHealth.get(key);
1116
+ if (!input.force && cached && cached.expiresAt > now) return cached.value;
1117
+ const inFlight = inFlightHealth.get(key);
1118
+ if (inFlight) return inFlight;
1035
1119
 
1036
1120
  const computation = computeOmpProviderHealth({
1037
- agentDir: ompAgentDir(),
1038
- command: process.env.OMP_COMMAND ?? "omp",
1039
- pathDirs: (process.env.PATH ?? "").split(delimiter),
1040
- cwd: process.cwd(),
1121
+ agentDir,
1122
+ dataDir,
1123
+ sessionDir,
1124
+ command: environment.OMP_COMMAND ?? "omp",
1125
+ pathDirs: (environment.PATH ?? "").split(delimiter),
1126
+ cwd,
1041
1127
  platform: process.platform,
1042
- pathExt: process.env.PATHEXT ?? WINDOWS_DEFAULT_PATHEXT,
1043
- env: process.env,
1128
+ pathExt: environment.PATHEXT ?? WINDOWS_DEFAULT_PATHEXT,
1129
+ env: environment,
1044
1130
  spawnFn: defaultSpawn,
1045
- hubRunRoot: defaultHubRunRoot(),
1131
+ hubRunRoot: defaultHubRunRoot(environment),
1046
1132
  homeDir: homedir(),
1047
1133
  })
1048
1134
  .then((value) => {
1049
- cachedHealth = { value, expiresAt: Date.now() + HEALTH_CACHE_TTL_MS };
1135
+ cachedHealth.set(key, { value, expiresAt: Date.now() + HEALTH_CACHE_TTL_MS });
1050
1136
  return value;
1051
1137
  })
1052
1138
  .finally(() => {
1053
- inFlightHealth = null;
1139
+ inFlightHealth.delete(key);
1054
1140
  });
1055
- inFlightHealth = computation;
1141
+ inFlightHealth.set(key, computation);
1056
1142
  return computation;
1057
1143
  }
package/server/quota.ts CHANGED
@@ -1,8 +1,9 @@
1
+ import { join } from "node:path";
1
2
  import { DatabaseSync } from "node:sqlite";
2
3
  import type { RpcInput } from "@getpaseo/plugin";
3
4
  import { z } from "zod";
4
5
  import type { listOmpQuotas, OmpQuota } from "../shared/quota";
5
- import { ompAgentDir } from "./paths";
6
+ import { ompDataDir } from "./paths";
6
7
 
7
8
  const QuotaRowSchema = z.object({
8
9
  provider: z.string(),
@@ -50,5 +51,5 @@ export function listOmpQuotasFrom(path: string): OmpQuota[] {
50
51
  export function resolveListOmpQuotas(_input: RpcInput<typeof listOmpQuotas>): {
51
52
  quotas: OmpQuota[];
52
53
  } {
53
- return { quotas: listOmpQuotasFrom(`${ompAgentDir()}/agent.db`) };
54
+ return { quotas: listOmpQuotasFrom(join(ompDataDir(), "agent.db")) };
54
55
  }
@@ -3,7 +3,7 @@ import { DatabaseSync } from "node:sqlite";
3
3
  import type { RpcInput } from "@getpaseo/plugin";
4
4
  import { z } from "zod";
5
5
  import type { listOmpSessions, OmpSessionEntry } from "../shared/sessions";
6
- import { ompAgentDir } from "./paths";
6
+ import { ompDataDir } from "./paths";
7
7
 
8
8
  const PROMPT_LIMIT = 400;
9
9
  const ROW_LIMIT = 100;
@@ -54,5 +54,5 @@ export function listOmpSessionsFrom(path: string, cwd: string): OmpSessionEntry[
54
54
  export function resolveListOmpSessions({ cwd }: RpcInput<typeof listOmpSessions>): {
55
55
  sessions: OmpSessionEntry[];
56
56
  } {
57
- return { sessions: listOmpSessionsFrom(join(ompAgentDir(), "history.db"), cwd) };
57
+ return { sessions: listOmpSessionsFrom(join(ompDataDir(), "history.db"), cwd) };
58
58
  }
@@ -0,0 +1,28 @@
1
+ import { defineSettings } from "@getpaseo/plugin";
2
+ import { z } from "zod";
3
+
4
+ export const DEFAULT_COMPOSER_PILL_SETTINGS = {
5
+ mcp: true,
6
+ hub: true,
7
+ memory: true,
8
+ sessions: true,
9
+ quota: true,
10
+ } as const;
11
+
12
+ export const composerPillSettingsSchema = z.object({
13
+ mcp: z.boolean().default(DEFAULT_COMPOSER_PILL_SETTINGS.mcp),
14
+ hub: z.boolean().default(DEFAULT_COMPOSER_PILL_SETTINGS.hub),
15
+ memory: z.boolean().default(DEFAULT_COMPOSER_PILL_SETTINGS.memory),
16
+ sessions: z.boolean().default(DEFAULT_COMPOSER_PILL_SETTINGS.sessions),
17
+ quota: z.boolean().default(DEFAULT_COMPOSER_PILL_SETTINGS.quota),
18
+ });
19
+
20
+ export type ComposerPillSettings = z.infer<typeof composerPillSettingsSchema>;
21
+ export type ComposerPillKey = keyof ComposerPillSettings;
22
+
23
+ export const composerPillSettings = defineSettings({
24
+ id: "composer-pills",
25
+ scope: "host",
26
+ version: 1,
27
+ schema: composerPillSettingsSchema,
28
+ });
@@ -0,0 +1,21 @@
1
+ export type ExternalUrlOpener = (url: string) => Promise<void>;
2
+
3
+ export function selectExternalUrlOpener(
4
+ paseoOpener: ExternalUrlOpener | undefined,
5
+ fallback: ExternalUrlOpener,
6
+ ): ExternalUrlOpener {
7
+ return paseoOpener ?? fallback;
8
+ }
9
+
10
+ export function validatedHttpUrl(value: string): string {
11
+ let url: URL;
12
+ try {
13
+ url = new URL(value);
14
+ } catch {
15
+ throw new Error("Only absolute HTTP(S) URLs are supported.");
16
+ }
17
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
18
+ throw new Error("Only absolute HTTP(S) URLs are supported.");
19
+ }
20
+ return url.href;
21
+ }