@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.
- package/README.md +116 -46
- package/SETUP.md +29 -7
- package/THIRD_PARTY_NOTICES.md +16 -0
- package/assets/openpi-launch-card-v1.webp +0 -0
- package/bin/openpi.js +155 -0
- package/extensions/ai-providers/LICENSE.upstream +23 -0
- package/extensions/ai-providers/README.md +59 -0
- package/extensions/ai-providers/antigravity/credentials.ts +52 -0
- package/extensions/ai-providers/antigravity/discovery.ts +130 -0
- package/extensions/ai-providers/antigravity/google-conversion.ts +455 -0
- package/extensions/ai-providers/antigravity/models.ts +84 -0
- package/extensions/ai-providers/antigravity/oauth.ts +700 -0
- package/extensions/ai-providers/antigravity/provider.ts +1116 -0
- package/extensions/ai-providers/antigravity/routing.ts +340 -0
- package/extensions/ai-providers/antigravity/with-resolvers.d.ts +19 -0
- package/extensions/ai-providers/cursor/constants.ts +5 -0
- package/extensions/ai-providers/cursor/credentials.ts +14 -0
- package/extensions/ai-providers/cursor/discovery.ts +291 -0
- package/extensions/ai-providers/cursor/input-images.ts +106 -0
- package/extensions/ai-providers/cursor/models.ts +45 -0
- package/extensions/ai-providers/cursor/oauth.ts +263 -0
- package/extensions/ai-providers/cursor/proto.ts +1064 -0
- package/extensions/ai-providers/cursor/protobuf.ts +1171 -0
- package/extensions/ai-providers/cursor/provider.ts +1175 -0
- package/extensions/ai-providers/cursor/proxy.ts +213 -0
- package/extensions/ai-providers/cursor/with-resolvers.d.ts +12 -0
- package/extensions/ai-providers/index.ts +86 -0
- package/extensions/ai-providers/oauth-adapter.ts +81 -0
- package/extensions/ai-providers/usage.ts +10 -0
- package/extensions/background-terminals/index.ts +38 -3
- package/extensions/background-terminals/src/domain.ts +2 -0
- package/extensions/background-terminals/src/manager.ts +484 -106
- package/extensions/background-terminals/src/output.ts +33 -0
- package/extensions/background-terminals/src/prompt.ts +13 -5
- package/extensions/background-terminals/src/result-delivery.ts +47 -24
- package/extensions/clear-context/index.ts +83 -0
- package/extensions/context-pivot/index.ts +16 -6
- package/extensions/cron/index.ts +68 -27
- package/extensions/cron/schedule.ts +12 -2
- package/extensions/file-mutation-display/render.ts +17 -257
- package/extensions/file-search/src/binaries.ts +57 -41
- package/extensions/git-read/index.ts +1 -3
- package/extensions/model-info/cache-diagnostics.ts +220 -0
- package/extensions/model-info/index.ts +65 -33
- package/extensions/model-info/session-metrics.ts +96 -0
- package/extensions/plan-mode/bash-policy.ts +54 -9
- package/extensions/plan-mode/index.ts +82 -6
- package/extensions/post-edit/index.ts +16 -6
- package/extensions/sessions/git-stats.ts +258 -72
- package/extensions/sessions/index.ts +153 -86
- package/extensions/sessions/preview-cache.ts +104 -0
- package/extensions/sessions/preview-loader.ts +856 -0
- package/extensions/sessions/sessions.ts +43 -4
- package/extensions/setup/index.ts +138 -130
- package/extensions/shared/activity-status.ts +30 -0
- package/extensions/shared/agent-session-page.ts +319 -0
- package/extensions/shared/agent-tool-renderer.ts +218 -0
- package/extensions/shared/agent-transcript.ts +524 -0
- package/extensions/shared/capability-intent.ts +1 -1
- package/extensions/shared/child-session.ts +457 -21
- package/extensions/shared/completion-inbox.ts +193 -0
- package/extensions/shared/result-delivery.ts +34 -0
- package/extensions/shared/setup-config.ts +83 -34
- package/extensions/shared/setup-episode-state.ts +1 -1
- package/extensions/shared/structured-output.ts +154 -0
- package/extensions/shared/terminal-text.ts +110 -23
- package/extensions/shared/text-projection.ts +72 -15
- package/extensions/shared/tool-activity.ts +382 -0
- package/extensions/shared/tool-surface.ts +29 -2
- package/extensions/shared/transcript-viewport.ts +46 -0
- package/extensions/shared/web-observer-registry.ts +390 -0
- package/extensions/shared/worktree.ts +11 -0
- package/extensions/subagents/index.ts +313 -62
- package/extensions/subagents/navigation.ts +34 -5
- package/extensions/subagents/src/backend.ts +12 -1
- package/extensions/subagents/src/backends/pi.ts +450 -70
- package/extensions/subagents/src/domain.ts +21 -1
- package/extensions/subagents/src/manager.ts +39 -2
- package/extensions/subagents/src/prompt.ts +49 -7
- package/extensions/subagents/src/result-artifact.ts +36 -0
- package/extensions/subagents/src/result-delivery.ts +39 -14
- package/extensions/subagents/src/runtime.ts +15 -1
- package/extensions/subagents/src/ui/takeover.ts +73 -257
- package/extensions/subagents/src/ui/transcript.ts +38 -535
- package/extensions/subagents/src/ui/wait-result.ts +103 -15
- package/extensions/suggestions/src/ui.ts +10 -4
- package/extensions/tasks/index.ts +0 -3
- package/extensions/ui-customization/footer.ts +16 -45
- package/extensions/ui-customization/index.ts +0 -4
- package/extensions/user-input-fold/index.ts +42 -6
- package/extensions/web/index.ts +257 -0
- package/extensions/workflows/acceptance.ts +43 -19
- package/extensions/workflows/artifacts.ts +137 -47
- package/extensions/workflows/completion-projection.ts +459 -0
- package/extensions/workflows/coordinator.ts +8 -10
- package/extensions/workflows/dashboard.ts +175 -228
- package/extensions/workflows/handoff.ts +70 -16
- package/extensions/workflows/index.ts +501 -198
- package/extensions/workflows/journal.ts +148 -13
- package/extensions/workflows/model.ts +79 -5
- package/extensions/workflows/navigation.ts +32 -8
- package/extensions/workflows/progress-projection.ts +306 -0
- package/extensions/workflows/prompt.ts +70 -16
- package/extensions/workflows/replay-safety.ts +42 -21
- package/extensions/workflows/result-delivery.ts +214 -76
- package/extensions/workflows/retention.ts +599 -0
- package/extensions/workflows/runner.ts +389 -345
- package/extensions/workflows/sandbox-child.cjs +25 -3
- package/extensions/workflows/sandbox.ts +62 -8
- package/extensions/workflows/serialization.ts +325 -17
- package/extensions/workflows/tool-renderer.ts +22 -0
- package/extensions/workflows/transcript.ts +149 -0
- package/extensions/workspace-cleanup-guard/index.ts +54 -0
- package/extensions/workspace-cleanup-guard/workspace-provenance.ts +563 -0
- package/package.json +34 -14
- package/skills/subagents/REFERENCE.md +190 -0
- package/skills/subagents/SKILL.md +2 -1
- package/skills/workflows/REFERENCE.md +6 -4
- package/skills/workflows/SKILL.md +1 -1
- package/web/adapter/pi-adapter.ts +664 -0
- package/web/host/browser-launcher.ts +20 -0
- package/web/host/pi-coding-agent-entry.ts +162 -0
- package/web/host/static-assets.ts +4 -0
- package/web/host/terminal-status.ts +38 -0
- package/web/host/web-host.ts +1069 -0
- package/web/http-dispatcher.ts +125 -0
- package/web/protocol/types.ts +467 -0
- package/web/runtime/pi-runtime.ts +1206 -0
- package/web/runtime/types.ts +102 -0
- package/web/runtime/web-host-lease.ts +497 -0
- package/web/trace.ts +18 -0
- package/web/ui/app.js +1700 -0
- package/web/ui/index.html +142 -0
- package/web/ui/styles.css +680 -0
- package/web/vite.config.mjs +34 -0
- package/extensions/execution-convergence/active-evidence.ts +0 -129
- package/extensions/execution-convergence/index.ts +0 -442
- package/extensions/execution-convergence/workspace-provenance.ts +0 -338
- package/extensions/setup/intercom-fs-helper.cjs +0 -130
- package/extensions/setup/intercom.ts +0 -603
- 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
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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/
|
|
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
|
|
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
|
|
86
|
+
const commandPreview = boundedNoticeText(ran, NOTICE_COMMAND_MAX_CHARS);
|
|
87
|
+
const detail = boundedNoticeText(
|
|
79
88
|
result.stderr || result.stdout || "",
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
.slice(0, 500);
|
|
89
|
+
NOTICE_DETAIL_MAX_CHARS,
|
|
90
|
+
);
|
|
83
91
|
warn(
|
|
84
|
-
`post-edit command failed (exit ${result.code}): ${
|
|
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(
|
|
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,
|
|
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>((
|
|
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) =>
|
|
123
|
+
(error, stdout) => {
|
|
124
|
+
if (error) reject(error);
|
|
125
|
+
else resolveOutput(stdout);
|
|
126
|
+
},
|
|
112
127
|
);
|
|
113
|
-
} catch {
|
|
114
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
|
145
|
-
|
|
146
|
-
|
|
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
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
|
203
|
-
|
|
204
|
-
|
|
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
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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,
|
|
411
|
+
return { cache, get, reconcile, cancel };
|
|
226
412
|
}
|