@tt-a1i/openpi 0.4.0 → 0.6.0

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 (141) hide show
  1. package/README.md +116 -46
  2. package/SETUP.md +29 -7
  3. package/THIRD_PARTY_NOTICES.md +16 -0
  4. package/assets/openpi-launch-card-v1.webp +0 -0
  5. package/bin/openpi.js +155 -0
  6. package/extensions/ai-providers/LICENSE.upstream +23 -0
  7. package/extensions/ai-providers/README.md +59 -0
  8. package/extensions/ai-providers/antigravity/credentials.ts +52 -0
  9. package/extensions/ai-providers/antigravity/discovery.ts +130 -0
  10. package/extensions/ai-providers/antigravity/google-conversion.ts +455 -0
  11. package/extensions/ai-providers/antigravity/models.ts +84 -0
  12. package/extensions/ai-providers/antigravity/oauth.ts +700 -0
  13. package/extensions/ai-providers/antigravity/provider.ts +1116 -0
  14. package/extensions/ai-providers/antigravity/routing.ts +340 -0
  15. package/extensions/ai-providers/antigravity/with-resolvers.d.ts +19 -0
  16. package/extensions/ai-providers/cursor/constants.ts +5 -0
  17. package/extensions/ai-providers/cursor/credentials.ts +14 -0
  18. package/extensions/ai-providers/cursor/discovery.ts +291 -0
  19. package/extensions/ai-providers/cursor/input-images.ts +106 -0
  20. package/extensions/ai-providers/cursor/models.ts +45 -0
  21. package/extensions/ai-providers/cursor/oauth.ts +263 -0
  22. package/extensions/ai-providers/cursor/proto.ts +1064 -0
  23. package/extensions/ai-providers/cursor/protobuf.ts +1171 -0
  24. package/extensions/ai-providers/cursor/provider.ts +1175 -0
  25. package/extensions/ai-providers/cursor/proxy.ts +213 -0
  26. package/extensions/ai-providers/cursor/with-resolvers.d.ts +12 -0
  27. package/extensions/ai-providers/index.ts +86 -0
  28. package/extensions/ai-providers/oauth-adapter.ts +81 -0
  29. package/extensions/ai-providers/usage.ts +10 -0
  30. package/extensions/background-terminals/index.ts +38 -3
  31. package/extensions/background-terminals/src/domain.ts +2 -0
  32. package/extensions/background-terminals/src/manager.ts +484 -106
  33. package/extensions/background-terminals/src/output.ts +33 -0
  34. package/extensions/background-terminals/src/prompt.ts +13 -5
  35. package/extensions/background-terminals/src/result-delivery.ts +47 -24
  36. package/extensions/clear-context/index.ts +83 -0
  37. package/extensions/context-pivot/index.ts +16 -6
  38. package/extensions/cron/index.ts +68 -27
  39. package/extensions/cron/schedule.ts +12 -2
  40. package/extensions/file-mutation-display/render.ts +17 -257
  41. package/extensions/file-search/src/binaries.ts +57 -41
  42. package/extensions/git-read/index.ts +1 -3
  43. package/extensions/model-info/cache-diagnostics.ts +220 -0
  44. package/extensions/model-info/index.ts +65 -33
  45. package/extensions/model-info/session-metrics.ts +96 -0
  46. package/extensions/plan-mode/bash-policy.ts +54 -9
  47. package/extensions/plan-mode/index.ts +82 -6
  48. package/extensions/post-edit/index.ts +16 -6
  49. package/extensions/sessions/git-stats.ts +258 -72
  50. package/extensions/sessions/index.ts +153 -86
  51. package/extensions/sessions/preview-cache.ts +104 -0
  52. package/extensions/sessions/preview-loader.ts +856 -0
  53. package/extensions/sessions/sessions.ts +43 -4
  54. package/extensions/setup/index.ts +138 -130
  55. package/extensions/shared/activity-status.ts +30 -0
  56. package/extensions/shared/agent-session-page.ts +319 -0
  57. package/extensions/shared/agent-tool-renderer.ts +218 -0
  58. package/extensions/shared/agent-transcript.ts +524 -0
  59. package/extensions/shared/capability-intent.ts +1 -1
  60. package/extensions/shared/child-session.ts +457 -21
  61. package/extensions/shared/completion-inbox.ts +193 -0
  62. package/extensions/shared/result-delivery.ts +34 -0
  63. package/extensions/shared/setup-config.ts +83 -34
  64. package/extensions/shared/setup-episode-state.ts +1 -1
  65. package/extensions/shared/structured-output.ts +154 -0
  66. package/extensions/shared/terminal-text.ts +110 -23
  67. package/extensions/shared/text-projection.ts +72 -15
  68. package/extensions/shared/tool-activity.ts +382 -0
  69. package/extensions/shared/tool-surface.ts +29 -2
  70. package/extensions/shared/transcript-viewport.ts +46 -0
  71. package/extensions/shared/web-observer-registry.ts +390 -0
  72. package/extensions/shared/worktree.ts +11 -0
  73. package/extensions/subagents/index.ts +313 -62
  74. package/extensions/subagents/navigation.ts +34 -5
  75. package/extensions/subagents/src/backend.ts +12 -1
  76. package/extensions/subagents/src/backends/pi.ts +450 -70
  77. package/extensions/subagents/src/domain.ts +21 -1
  78. package/extensions/subagents/src/manager.ts +39 -2
  79. package/extensions/subagents/src/prompt.ts +49 -7
  80. package/extensions/subagents/src/result-artifact.ts +36 -0
  81. package/extensions/subagents/src/result-delivery.ts +39 -14
  82. package/extensions/subagents/src/runtime.ts +15 -1
  83. package/extensions/subagents/src/ui/takeover.ts +73 -257
  84. package/extensions/subagents/src/ui/transcript.ts +38 -535
  85. package/extensions/subagents/src/ui/wait-result.ts +103 -15
  86. package/extensions/suggestions/src/ui.ts +10 -4
  87. package/extensions/tasks/index.ts +0 -3
  88. package/extensions/ui-customization/footer.ts +16 -45
  89. package/extensions/ui-customization/index.ts +0 -4
  90. package/extensions/user-input-fold/index.ts +42 -6
  91. package/extensions/web/index.ts +257 -0
  92. package/extensions/workflows/acceptance.ts +43 -19
  93. package/extensions/workflows/artifacts.ts +137 -47
  94. package/extensions/workflows/completion-projection.ts +459 -0
  95. package/extensions/workflows/coordinator.ts +8 -10
  96. package/extensions/workflows/dashboard.ts +175 -228
  97. package/extensions/workflows/handoff.ts +70 -16
  98. package/extensions/workflows/index.ts +501 -198
  99. package/extensions/workflows/journal.ts +148 -13
  100. package/extensions/workflows/model.ts +79 -5
  101. package/extensions/workflows/navigation.ts +32 -8
  102. package/extensions/workflows/progress-projection.ts +306 -0
  103. package/extensions/workflows/prompt.ts +70 -16
  104. package/extensions/workflows/replay-safety.ts +42 -21
  105. package/extensions/workflows/result-delivery.ts +214 -76
  106. package/extensions/workflows/retention.ts +599 -0
  107. package/extensions/workflows/runner.ts +389 -345
  108. package/extensions/workflows/sandbox-child.cjs +25 -3
  109. package/extensions/workflows/sandbox.ts +62 -8
  110. package/extensions/workflows/serialization.ts +325 -17
  111. package/extensions/workflows/tool-renderer.ts +22 -0
  112. package/extensions/workflows/transcript.ts +149 -0
  113. package/extensions/workspace-cleanup-guard/index.ts +54 -0
  114. package/extensions/workspace-cleanup-guard/workspace-provenance.ts +563 -0
  115. package/package.json +34 -14
  116. package/skills/subagents/REFERENCE.md +190 -0
  117. package/skills/subagents/SKILL.md +2 -1
  118. package/skills/workflows/REFERENCE.md +6 -4
  119. package/skills/workflows/SKILL.md +1 -1
  120. package/web/adapter/pi-adapter.ts +664 -0
  121. package/web/host/browser-launcher.ts +20 -0
  122. package/web/host/pi-coding-agent-entry.ts +162 -0
  123. package/web/host/static-assets.ts +4 -0
  124. package/web/host/terminal-status.ts +38 -0
  125. package/web/host/web-host.ts +1069 -0
  126. package/web/http-dispatcher.ts +125 -0
  127. package/web/protocol/types.ts +467 -0
  128. package/web/runtime/pi-runtime.ts +1206 -0
  129. package/web/runtime/types.ts +102 -0
  130. package/web/runtime/web-host-lease.ts +497 -0
  131. package/web/trace.ts +18 -0
  132. package/web/ui/app.js +1700 -0
  133. package/web/ui/index.html +142 -0
  134. package/web/ui/styles.css +680 -0
  135. package/web/vite.config.mjs +34 -0
  136. package/extensions/execution-convergence/active-evidence.ts +0 -129
  137. package/extensions/execution-convergence/index.ts +0 -442
  138. package/extensions/execution-convergence/workspace-provenance.ts +0 -338
  139. package/extensions/setup/intercom-fs-helper.cjs +0 -130
  140. package/extensions/setup/intercom.ts +0 -603
  141. package/extensions/subagents/src/backends/stub.ts +0 -303
@@ -26,17 +26,21 @@
26
26
  * may predate the plan and still hold the full tool set.
27
27
  */
28
28
 
29
- import type {
30
- ExtensionAPI,
31
- ExtensionCommandContext,
32
- ExtensionContext,
29
+ import {
30
+ getMarkdownTheme,
31
+ keyHint,
32
+ type ExtensionAPI,
33
+ type ExtensionCommandContext,
34
+ type ExtensionContext,
33
35
  } from "@earendil-works/pi-coding-agent";
36
+ import { Markdown, Text, truncateToWidth } from "@earendil-works/pi-tui";
34
37
  import { Type } from "typebox";
35
38
  import {
36
39
  PLAN_MODE_CHANNEL,
37
40
  type PlanModeState,
38
41
  } from "../shared/plan-mode-state.ts";
39
42
  import {
43
+ loadOpenPiCapabilities,
40
44
  OPENPI_TOOL_SURFACE,
41
45
  patchOwnedTools,
42
46
  } from "../shared/tool-surface.ts";
@@ -45,6 +49,8 @@ import { planBashDecision } from "./bash-policy.ts";
45
49
 
46
50
  export const MAX_READY_PLAN_CHARS = 50_000;
47
51
  export const MAX_READY_PLAN_UTF8_BYTES = 48_000;
52
+ /** Preview lines shown in the collapsed plan_ready result. */
53
+ const PLAN_PREVIEW_LINES = 10;
48
54
  export const PLAN_MODE_STATE_ENTRY = "my-pi-setup-plan-mode-state";
49
55
 
50
56
  export type PersistedPlanModeState =
@@ -173,6 +179,9 @@ export const PLAN_SAFE_TOOLS = new Set([
173
179
  "ls",
174
180
  "fd",
175
181
  "rg",
182
+ "git_show",
183
+ "git_diff",
184
+ "git_log",
176
185
  // Web research without write actions.
177
186
  "web_search",
178
187
  "source_check",
@@ -208,7 +217,7 @@ export const PLAN_SAFE_TOOLS = new Set([
208
217
  ]);
209
218
 
210
219
  export const BLOCK_REASON =
211
- "Plan mode is active: no changes yet. Keep investigating with read-only tools (read, fd, rg, web search, read-only bash like git log/diff/status, and subagent_spawn for parallel exploration — planning children get read-only tools). When the plan is decision-complete, call plan_ready alone with the complete Markdown plan; the user then chooses the next action with `/plan`, or cancels with `/plan off`.";
220
+ "Plan mode is active: no changes yet. Keep investigating with read-only tools (read, fd, rg, git_log/git_diff/git_show, web search, read-only bash like git log/status, and subagent_spawn for parallel exploration — planning children get read-only tools). Raw git diff/show and diff-generating git log forms are blocked; use the structured Git tools instead. When the plan is decision-complete, call plan_ready alone with the complete Markdown plan; the user then chooses the next action with `/plan`, or cancels with `/plan off`.";
212
221
 
213
222
  export function planToolCallDecision(
214
223
  toolName: string,
@@ -256,6 +265,7 @@ export default function planMode(pi: ExtensionAPI) {
256
265
  hasUI: boolean;
257
266
  ui: { setStatus: (key: string, value?: string) => void };
258
267
  }) => {
268
+ if (planning) loadOpenPiCapabilities(pi, ["search"]);
259
269
  syncPlanTool();
260
270
  pi.events.emit(PLAN_MODE_CHANNEL, { planning } satisfies PlanModeState);
261
271
  if (!ctx.hasUI) return;
@@ -445,6 +455,72 @@ export default function planMode(pi: ExtensionAPI) {
445
455
  terminate: true,
446
456
  };
447
457
  },
458
+ /**
459
+ * Render a completed plan for the TUI. Collapsed shows the line count
460
+ * plus a bounded preview (PLAN_PREVIEW_LINES) with an expand hint;
461
+ * expanded renders the full plan as Markdown. Pi only reflects the
462
+ * expanded flag — it never truncates custom renderer output, so this
463
+ * renderer owns the collapsed/expanded contract.
464
+ */
465
+ renderResult(result, { expanded }, theme) {
466
+ const plan = (result.details as { plan?: string } | undefined)?.plan;
467
+ if (!plan) {
468
+ // Failed invocations (not in plan mode, empty plan, size cap, abort)
469
+ // produce {content:[{text:reason}], details:{}} from agent-loop's
470
+ // createErrorToolResult; surface the real reason instead of a
471
+ // placeholder, mirroring subagent_spawn's fallback.
472
+ const first = result.content?.[0];
473
+ return new Text(
474
+ first?.type === "text"
475
+ ? first.text
476
+ : theme.fg("muted", "(no plan content)"),
477
+ 0,
478
+ 0,
479
+ );
480
+ }
481
+ if (!expanded) {
482
+ // Bound the collapsed preview by rendered rows, not source lines: a
483
+ // single long source line wraps into many terminal rows at narrow
484
+ // widths, so render the body at the caller's width, keep the first
485
+ // PLAN_PREVIEW_LINES rows, and truncate every row to the viewport
486
+ // width (same contract as renderWaitResult's fixedRows).
487
+ const body = new Text(
488
+ plan
489
+ .split("\n")
490
+ .map((line) => theme.fg("toolOutput", line))
491
+ .join("\n"),
492
+ 0,
493
+ 0,
494
+ );
495
+ return {
496
+ render(width: number) {
497
+ const bodyRows = body.render(width);
498
+ const shown = bodyRows.slice(0, PLAN_PREVIEW_LINES);
499
+ const hidden = bodyRows.length - shown.length;
500
+ const header = theme.fg(
501
+ "muted",
502
+ `Plan ready · ${plan.split("\n").length} lines`,
503
+ );
504
+ const rows = [header, ...shown];
505
+ if (hidden > 0) {
506
+ // The hint gets its own row: tucking it into the header tail
507
+ // lets a narrow viewport clip it away along with the rest of
508
+ // the header, hiding the expand affordance exactly when the
509
+ // preview is clipped.
510
+ rows.push(keyHint("app.tools.expand", "to expand"));
511
+ rows.push(theme.fg("muted", `... (${hidden} more rows)`));
512
+ }
513
+ return rows.map((row) => truncateToWidth(row, Math.max(1, width)));
514
+ },
515
+ invalidate() {
516
+ body.invalidate();
517
+ },
518
+ };
519
+ }
520
+ // A plan is prose to read, not source to inspect. Without a renderer the
521
+ // TUI falls back to plain text and shows raw Markdown syntax.
522
+ return new Markdown(plan, 0, 0, getMarkdownTheme());
523
+ },
448
524
  });
449
525
 
450
526
  pi.registerCommand("plan", {
@@ -519,7 +595,7 @@ export default function planMode(pi: ExtensionAPI) {
519
595
  {
520
596
  customType: "plan-mode-armed",
521
597
  content:
522
- `Plan mode is on: investigate and propose a plan, but do not change anything yet. Edits and writes are blocked until the user explicitly chooses an implementation action. For files use the read, ls, grep and fd tools; bash is limited to read-only git and gh history commands such as \`git log\`, \`git diff\`, \`git status\`, \`git show\`, \`git blame\` and \`gh pr view\` — one plain command, no pipes or redirects. You can still delegate with \`subagent_spawn\` to explore several parts of the codebase at once: children spawned while planning receive read-only tools, so use them for investigation rather than for work to be done later.${objective ? `\n\nPlan for: ${objective}` : ""}` +
598
+ `Plan mode is on: investigate and propose a plan, but do not change anything yet. Edits and writes are blocked until the user explicitly chooses an implementation action. For files use the read, ls, grep, fd and rg tools. Use \`git_log\`, \`git_diff\` and \`git_show\` for history and changes; they disable repository external diff and textconv programs. Bash is limited to read-only git and gh commands such as \`git log\`, \`git status\`, \`git blame\` and \`gh pr view\` — raw Git commands that render diffs are blocked, and every Bash call must be one plain command with no pipes or redirects. You can still delegate with \`subagent_spawn\` to explore several parts of the codebase at once: children spawned while planning receive read-only tools, so use them for investigation rather than for work to be done later.${objective ? `\n\nPlan for: ${objective}` : ""}` +
523
599
  "\n\nUse read-only tools to ground the plan. When it is decision-complete, call `plan_ready` alone with the complete Markdown plan. That records the plan but does not start implementation; the user chooses the next action with `/plan`.",
524
600
  display: true,
525
601
  details: {},
@@ -21,6 +21,14 @@ import { sanitizeTerminalText } from "../shared/terminal-text.ts";
21
21
 
22
22
  /** Tools whose success means a file on disk changed. */
23
23
  const MUTATING_TOOLS = new Set(["write", "edit"]);
24
+ const NOTICE_COMMAND_MAX_CHARS = 160;
25
+ const NOTICE_DETAIL_MAX_CHARS = 320;
26
+
27
+ function boundedNoticeText(value: string, maxChars: number) {
28
+ const chars = [...sanitizeTerminalText(value).trim()];
29
+ if (chars.length <= maxChars) return chars.join("");
30
+ return `${chars.slice(0, maxChars - 1).join("")}…`;
31
+ }
24
32
 
25
33
  export default function postEdit(
26
34
  pi: ExtensionAPI,
@@ -75,18 +83,20 @@ export default function postEdit(
75
83
  })
76
84
  .then((result) => {
77
85
  if (result.code === 0) return;
78
- const detail = sanitizeTerminalText(
86
+ const commandPreview = boundedNoticeText(ran, NOTICE_COMMAND_MAX_CHARS);
87
+ const detail = boundedNoticeText(
79
88
  result.stderr || result.stdout || "",
80
- )
81
- .trim()
82
- .slice(0, 500);
89
+ NOTICE_DETAIL_MAX_CHARS,
90
+ );
83
91
  warn(
84
- `post-edit command failed (exit ${result.code}): ${sanitizeTerminalText(ran)}${detail ? `\n${detail}` : ""}`,
92
+ `post-edit command failed (exit ${result.code}): ${commandPreview}${detail ? `\n${detail}` : ""}`,
85
93
  );
86
94
  })
87
95
  .catch((error: unknown) => {
88
96
  const detail = error instanceof Error ? error.message : String(error);
89
- warn(`post-edit command could not run: ${detail}`);
97
+ warn(
98
+ `post-edit command could not run: ${boundedNoticeText(detail, NOTICE_DETAIL_MAX_CHARS)}`,
99
+ );
90
100
  })
91
101
  .finally(() => {
92
102
  if (active?.controller === controller) active = undefined;
@@ -1,4 +1,6 @@
1
1
  import { execFile } from "node:child_process";
2
+ import { realpath } from "node:fs/promises";
3
+ import { resolve } from "node:path";
2
4
  import type { SessionInfoLike } from "./sessions.ts";
3
5
 
4
6
  export interface SessionStats {
@@ -7,8 +9,15 @@ export interface SessionStats {
7
9
  del: number;
8
10
  }
9
11
 
12
+ export type SessionStatsState =
13
+ | { status: "pending" }
14
+ | { status: "ready"; stats: SessionStats }
15
+ | { status: "unavailable" };
16
+
17
+ type CachedSessionStats = Exclude<SessionStatsState, { status: "pending" }>;
18
+
10
19
  interface StatsLoaderOptions {
11
- cache?: Map<string, SessionStats>;
20
+ cache?: Map<string, CachedSessionStats>;
12
21
  maxCache?: number;
13
22
  maxConcurrency?: number;
14
23
  runGit?: (
@@ -16,6 +25,8 @@ interface StatsLoaderOptions {
16
25
  cwd: string,
17
26
  signal: AbortSignal,
18
27
  ) => Promise<string>;
28
+ canonicalizeCwd?: (cwd: string) => Promise<string>;
29
+ canonicalizeConcurrency?: number;
19
30
  }
20
31
 
21
32
  interface QueueEntry {
@@ -27,6 +38,7 @@ interface QueueEntry {
27
38
  const GIT_STATS_TIMEOUT_MS = 3_000;
28
39
  const DEFAULT_MAX_CACHE = 256;
29
40
  const DEFAULT_MAX_CONCURRENCY = 4;
41
+ const DEFAULT_CANONICALIZE_CONCURRENCY = 8;
30
42
 
31
43
  const abortError = () => {
32
44
  const error = new Error("Session stats load was cancelled.");
@@ -97,7 +109,7 @@ function createQueue(maxConcurrency: number) {
97
109
  }
98
110
 
99
111
  const runGit = (args: string[], cwd: string, signal: AbortSignal) =>
100
- new Promise<string>((resolve) => {
112
+ new Promise<string>((resolveOutput, reject) => {
101
113
  try {
102
114
  execFile(
103
115
  "git",
@@ -108,10 +120,13 @@ const runGit = (args: string[], cwd: string, signal: AbortSignal) =>
108
120
  timeout: GIT_STATS_TIMEOUT_MS,
109
121
  signal,
110
122
  },
111
- (error, stdout) => resolve(error ? "" : stdout),
123
+ (error, stdout) => {
124
+ if (error) reject(error);
125
+ else resolveOutput(stdout);
126
+ },
112
127
  );
113
- } catch {
114
- resolve("");
128
+ } catch (error) {
129
+ reject(error);
115
130
  }
116
131
  });
117
132
 
@@ -133,94 +148,265 @@ const calculateStats = (outputs: readonly string[]) => {
133
148
  };
134
149
 
135
150
  export function createSessionStatsLoader(options: StatsLoaderOptions = {}) {
136
- const cache = options.cache ?? new Map<string, SessionStats>();
151
+ const cache = options.cache ?? new Map<string, CachedSessionStats>();
137
152
  const maxCache = options.maxCache ?? DEFAULT_MAX_CACHE;
138
153
  const execute = options.runGit ?? runGit;
154
+ const canonicalize = options.canonicalizeCwd ?? realpath;
155
+ const canonicalizeConcurrency = Math.max(
156
+ 1,
157
+ options.canonicalizeConcurrency ?? DEFAULT_CANONICALIZE_CONCURRENCY,
158
+ );
139
159
  const queue = createQueue(
140
160
  Math.max(1, options.maxConcurrency ?? DEFAULT_MAX_CONCURRENCY),
141
161
  );
142
- const loading = new Map<string, AbortSignal>();
162
+ interface Request {
163
+ key: string;
164
+ cwd: string;
165
+ after: string;
166
+ before: string;
167
+ includeWorktreeDiff: boolean;
168
+ }
169
+ interface ActiveRequest {
170
+ controller: AbortController;
171
+ promise: Promise<void>;
172
+ }
143
173
 
144
- const loadOne = async (
145
- session: SessionInfoLike,
146
- isLatestForCwd: boolean,
174
+ const active = new Map<string, ActiveRequest>();
175
+ const pathKeys = new Map<string, string>();
176
+ const canonicalCwds = new Map<string, Promise<string>>();
177
+ let canonicalUniverse:
178
+ | {
179
+ universe: readonly SessionInfoLike[];
180
+ controller: AbortController;
181
+ promise: Promise<Map<string, string>>;
182
+ }
183
+ | undefined;
184
+ let requestedPaths = new Set<string>();
185
+ let reconcileGeneration = 0;
186
+ let notify = () => {};
187
+
188
+ const canonicalCwd = (cwd: string) => {
189
+ const absolute = resolve(cwd);
190
+ let pending = canonicalCwds.get(absolute);
191
+ if (!pending) {
192
+ pending = canonicalize(absolute).catch(() => absolute);
193
+ canonicalCwds.set(absolute, pending);
194
+ }
195
+ return pending;
196
+ };
197
+
198
+ const buildCanonicalWorkspaceMap = async (
199
+ sessions: readonly SessionInfoLike[],
147
200
  signal: AbortSignal,
148
- onUpdate: () => void,
149
201
  ) => {
150
- const key = session.path;
151
- if (cache.has(key)) return;
152
- const currentLoad = loading.get(key);
153
- if (currentLoad && !currentLoad.aborted) return;
154
- loading.set(key, signal);
202
+ const workspaces = [
203
+ ...new Set(sessions.map((session) => resolve(session.cwd))),
204
+ ];
205
+ const result = new Map<string, string>();
206
+ let index = 0;
207
+ const worker = async () => {
208
+ while (!signal.aborted) {
209
+ const cwd = workspaces[index++];
210
+ if (!cwd) return;
211
+ result.set(cwd, await canonicalCwd(cwd));
212
+ }
213
+ };
214
+ await Promise.all(
215
+ Array.from(
216
+ { length: Math.min(canonicalizeConcurrency, workspaces.length) },
217
+ worker,
218
+ ),
219
+ );
220
+ if (signal.aborted) throw abortError();
221
+ return result;
222
+ };
223
+
224
+ const canonicalWorkspaceMap = (sessions: readonly SessionInfoLike[]) => {
225
+ if (
226
+ canonicalUniverse?.universe === sessions &&
227
+ !canonicalUniverse.controller.signal.aborted
228
+ ) {
229
+ return canonicalUniverse.promise;
230
+ }
231
+ canonicalUniverse?.controller.abort();
232
+ const controller = new AbortController();
233
+ const promise = buildCanonicalWorkspaceMap(sessions, controller.signal);
234
+ canonicalUniverse = { universe: sessions, controller, promise };
235
+ return promise;
236
+ };
237
+
238
+ const latestPathsByCwd = (
239
+ sessions: readonly SessionInfoLike[],
240
+ canonical: ReadonlyMap<string, string>,
241
+ ) => {
242
+ const latest = new Map<string, SessionInfoLike>();
243
+ for (const session of sessions) {
244
+ const cwd = canonical.get(resolve(session.cwd)) ?? resolve(session.cwd);
245
+ const current = latest.get(cwd);
246
+ if (!current || session.modified.getTime() > current.modified.getTime()) {
247
+ latest.set(cwd, session);
248
+ }
249
+ }
250
+ return new Map(
251
+ [...latest].map(([cwd, session]) => [cwd, session.path] as const),
252
+ );
253
+ };
155
254
 
255
+ const requestFor = (
256
+ session: SessionInfoLike,
257
+ latest: ReadonlyMap<string, string>,
258
+ canonical: ReadonlyMap<string, string>,
259
+ ): Request => {
260
+ const cwd = canonical.get(resolve(session.cwd)) ?? resolve(session.cwd);
156
261
  const after = session.created
157
262
  ? session.created.toISOString()
158
263
  : new Date(session.modified.getTime() - 24 * 3600 * 1000).toISOString();
159
264
  const before = session.modified.toISOString();
265
+ const includeWorktreeDiff = latest.get(cwd) === session.path;
266
+ return {
267
+ key: JSON.stringify([cwd, after, before, includeWorktreeDiff]),
268
+ cwd,
269
+ after,
270
+ before,
271
+ includeWorktreeDiff,
272
+ };
273
+ };
160
274
 
161
- try {
162
- const outputs = await Promise.all([
163
- queue
164
- .run(
275
+ const putCache = (key: string, value: CachedSessionStats) => {
276
+ if (cache.has(key)) cache.delete(key);
277
+ while (cache.size >= Math.max(1, maxCache)) {
278
+ const oldest = cache.keys().next().value;
279
+ if (oldest === undefined) break;
280
+ cache.delete(oldest);
281
+ }
282
+ cache.set(key, value);
283
+ };
284
+
285
+ const start = (request: Request) => {
286
+ const controller = new AbortController();
287
+ const promise = Promise.all([
288
+ queue.run(
289
+ () =>
290
+ execute(
291
+ [
292
+ "log",
293
+ `--after=${request.after}`,
294
+ `--before=${request.before}`,
295
+ "--numstat",
296
+ "--pretty=format:",
297
+ ],
298
+ request.cwd,
299
+ controller.signal,
300
+ ),
301
+ controller.signal,
302
+ ),
303
+ request.includeWorktreeDiff
304
+ ? queue.run(
165
305
  () =>
166
- execute(
167
- [
168
- "log",
169
- `--after=${after}`,
170
- `--before=${before}`,
171
- "--numstat",
172
- "--pretty=format:",
173
- ],
174
- session.cwd,
175
- signal,
176
- ),
177
- signal,
306
+ execute(["diff", "--numstat"], request.cwd, controller.signal),
307
+ controller.signal,
178
308
  )
179
- .catch(() => ""),
180
- isLatestForCwd
181
- ? queue
182
- .run(
183
- () => execute(["diff", "--numstat"], session.cwd, signal),
184
- signal,
185
- )
186
- .catch(() => "")
187
- : Promise.resolve(""),
188
- ]);
189
- if (signal.aborted || loading.get(key) !== signal) return;
190
-
191
- if (cache.size >= maxCache) {
192
- const oldest = cache.keys().next().value;
193
- if (oldest !== undefined) cache.delete(oldest);
194
- }
195
- cache.set(key, calculateStats(outputs));
196
- onUpdate();
197
- } finally {
198
- if (loading.get(key) === signal) loading.delete(key);
199
- }
309
+ : Promise.resolve(""),
310
+ ])
311
+ .then((outputs) => {
312
+ if (!controller.signal.aborted) {
313
+ putCache(request.key, {
314
+ status: "ready",
315
+ stats: calculateStats(outputs),
316
+ });
317
+ notify();
318
+ }
319
+ })
320
+ .catch(() => {
321
+ const cancelled = controller.signal.aborted;
322
+ if (!cancelled) {
323
+ putCache(request.key, { status: "unavailable" });
324
+ notify();
325
+ controller.abort();
326
+ }
327
+ })
328
+ .finally(() => {
329
+ if (active.get(request.key)?.controller === controller) {
330
+ active.delete(request.key);
331
+ }
332
+ });
333
+ const entry = { controller, promise };
334
+ active.set(request.key, entry);
335
+ return entry;
200
336
  };
201
337
 
202
- const load = (
203
- sessions: SessionInfoLike[],
204
- signal: AbortSignal,
338
+ const cancelKey = (key: string) => {
339
+ const entry = active.get(key);
340
+ if (!entry) return;
341
+ active.delete(key);
342
+ entry.controller.abort();
343
+ };
344
+
345
+ const reconcile = async (
346
+ targets: readonly SessionInfoLike[],
347
+ universe: readonly SessionInfoLike[],
205
348
  onUpdate: () => void,
206
349
  ) => {
207
- const latestByCwd = new Map<string, string>();
208
- for (const session of sessions) {
209
- if (!latestByCwd.has(session.cwd)) {
210
- latestByCwd.set(session.cwd, session.path);
211
- }
350
+ notify = onUpdate;
351
+ const generation = ++reconcileGeneration;
352
+ requestedPaths = new Set(targets.map((session) => session.path));
353
+ if (targets.length === 0) {
354
+ for (const key of [...active.keys()]) cancelKey(key);
355
+ pathKeys.clear();
356
+ return;
212
357
  }
213
- return Promise.all(
214
- sessions.map((session) =>
215
- loadOne(
216
- session,
217
- latestByCwd.get(session.cwd) === session.path,
218
- signal,
219
- onUpdate,
220
- ),
221
- ),
222
- ).then(() => undefined);
358
+
359
+ let canonical: ReadonlyMap<string, string>;
360
+ try {
361
+ canonical = await canonicalWorkspaceMap(universe);
362
+ } catch {
363
+ return;
364
+ }
365
+ if (generation !== reconcileGeneration) return;
366
+ const latest = latestPathsByCwd(universe, canonical);
367
+ const requests = targets.map((session) => ({
368
+ session,
369
+ request: requestFor(session, latest, canonical),
370
+ }));
371
+ const wanted = new Set(requests.map(({ request }) => request.key));
372
+
373
+ for (const key of active.keys()) {
374
+ if (!wanted.has(key)) cancelKey(key);
375
+ }
376
+
377
+ pathKeys.clear();
378
+ const pending: Promise<void>[] = [];
379
+ for (const { session, request } of requests) {
380
+ pathKeys.set(session.path, request.key);
381
+ if (cache.has(request.key)) continue;
382
+ const current = active.get(request.key) ?? start(request);
383
+ pending.push(current.promise);
384
+ }
385
+ await Promise.all(pending);
386
+ };
387
+
388
+ const get = (session: SessionInfoLike): SessionStatsState | undefined => {
389
+ if (!requestedPaths.has(session.path)) return undefined;
390
+ const key = pathKeys.get(session.path);
391
+ if (!key) return { status: "pending" };
392
+ const cached = cache.get(key);
393
+ if (cached) {
394
+ cache.delete(key);
395
+ cache.set(key, cached);
396
+ return cached;
397
+ }
398
+ if (active.has(key)) return { status: "pending" };
399
+ return undefined;
400
+ };
401
+
402
+ const cancel = () => {
403
+ reconcileGeneration++;
404
+ canonicalUniverse?.controller.abort();
405
+ canonicalUniverse = undefined;
406
+ for (const key of [...active.keys()]) cancelKey(key);
407
+ pathKeys.clear();
408
+ requestedPaths.clear();
223
409
  };
224
410
 
225
- return { cache, load };
411
+ return { cache, get, reconcile, cancel };
226
412
  }