gentle-pi 3.3.0 → 3.4.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 +63 -59
- package/assets/orchestrator-delegation.md +1 -1
- package/docs/assets/brand/gentle-shell-banner.gif +0 -0
- package/docs/assets/diagrams/odd-workflow.svg +74 -0
- package/docs/assets/features/agents-view.png +0 -0
- package/docs/assets/features/changes-view.png +0 -0
- package/docs/assets/features/command-palette.png +0 -0
- package/docs/assets/features/profiles-routing.png +0 -0
- package/docs/gentle-shell.md +26 -2
- package/docs/readme-reference.md +32 -6
- package/extensions/ask-user-question.ts +210 -0
- package/extensions/gentle-agents.ts +8 -1
- package/extensions/gentle-ai.ts +59 -18
- package/extensions/gentle-shell.ts +99 -10
- package/extensions/quiet-tools.ts +28 -5
- package/extensions/startup-banner.ts +25 -10
- package/lib/animation-policy.ts +52 -0
- package/lib/background-cache-warming.ts +38 -0
- package/lib/command-palette-catalog.ts +1 -0
- package/lib/inprocess-reviewer.ts +38 -1
- package/lib/native-review-cli.ts +36 -10
- package/lib/questionnaire/questionnaire-view.ts +603 -0
- package/lib/questionnaire/schema.ts +82 -0
- package/lib/questionnaire/validate.ts +141 -0
- package/lib/review-candidate-view-owner.ts +20 -5
- package/lib/review-candidate-view.ts +9 -2
- package/lib/review-host-relay.ts +10 -0
- package/lib/review-integration-v2.ts +4 -1
- package/lib/shell-bar.ts +13 -0
- package/lib/shell-sidebar-layout.ts +10 -4
- package/lib/shell-usage-view.ts +5 -2
- package/lib/shell-usage.ts +120 -6
- package/package.json +1 -1
- package/runtime/native-review-cli.mjs +35 -9
- package/runtime/review-integration-v2.mjs +4 -1
- package/scripts/gentle-ai-installer.mjs +10 -10
- package/scripts/verify-package-files.mjs +2 -3
- package/tests/animation-policy.test.ts +42 -0
- package/tests/ask-user-question.test.ts +435 -0
- package/tests/background-cache-warming.test.ts +60 -0
- package/tests/background-subagents.test.ts +68 -0
- package/tests/command-palette.test.ts +9 -0
- package/tests/gentle-agents.test.ts +71 -2
- package/tests/gentle-ai-binary.test.ts +1 -1
- package/tests/gentle-ai-installer.test.ts +47 -47
- package/tests/gentle-ai.test.ts +56 -4
- package/tests/gentle-shell.test.ts +355 -2
- package/tests/inprocess-reviewer.test.ts +92 -0
- package/tests/native-review-capability-contract.test.ts +16 -1
- package/tests/native-review-parity.test.ts +19 -0
- package/tests/package-manifest.test.ts +6 -17
- package/tests/questionnaire-schema.test.ts +274 -0
- package/tests/questionnaire-view.test.ts +446 -0
- package/tests/rdd-status-line.test.ts +21 -4
- package/tests/review-candidate-owner-retry.test.ts +63 -0
- package/tests/review-candidate-view.test.ts +15 -0
- package/tests/review-controller-native-routing.test.ts +86 -0
- package/tests/review-host-relay.test.ts +21 -0
- package/tests/review-integration-v2.test.ts +30 -0
- package/tests/review-ledger-contract.test.ts +1 -2
- package/tests/review-relay-transport-agent.test.ts +107 -2
- package/tests/review-risk-assessment.test.ts +104 -0
- package/tests/shell-bar.test.ts +8 -0
- package/tests/shell-sidebar-layout.test.ts +60 -5
- package/tests/shell-usage.test.ts +129 -0
- package/tests/skill-collision-prefixes.test.ts +1 -1
- package/tests/startup-banner.test.ts +93 -2
- package/docs/assets/brand/gentle-pi-banner.png +0 -0
- package/skills/release/SKILL.md +0 -137
|
@@ -5,7 +5,7 @@ import { statSync } from "node:fs";
|
|
|
5
5
|
import { profilesFilePath, readProfilesFileResult } from "../lib/agent-profiles.ts";
|
|
6
6
|
import * as os from "node:os";
|
|
7
7
|
import { join } from "node:path";
|
|
8
|
-
import { buildShellHeaderModel, renderShellBar, renderShellHeaderBar, renderShellSidebarBar, shellEnabled, type ShellBarModel, type ShellBarTheme } from "../lib/shell-bar.ts";
|
|
8
|
+
import { buildShellHeaderModel, renderShellBar, renderShellHeaderBar, renderShellHeaderRule, renderShellSidebarBar, shellEnabled, type ShellBarModel, type ShellBarTheme } from "../lib/shell-bar.ts";
|
|
9
9
|
import { CHANGE_STATUS, RootBranchLabels, renderChangesWidget, type ChangedFile, type ChangesModel, type GitRunner, type WorktreeChanges } from "../lib/shell-changes.ts";
|
|
10
10
|
import { WorktreeChangesView } from "../lib/shell-changes-view.ts";
|
|
11
11
|
import { SessionWorktreeRegistry, resolveSessionWorktree, worktreeGitEnvironment, type WorktreeResolver } from "../lib/session-worktree-registry.ts";
|
|
@@ -16,6 +16,7 @@ import { agentsViewKey } from "../lib/agents-keys.ts";
|
|
|
16
16
|
import { GentleAiDevBinaryOverrideError, resolveGentleAiDevBinaryOverride } from "../lib/gentle-ai-binary.ts";
|
|
17
17
|
import { DOUBLE_ESC_CANCEL_HINT, framePromptLines, IDLE_ESC_CLEAR_HINT, PROMPT_HINT, PROMPT_STATE, SHELL_PULSE_MS, withPromptHint, type PromptState } from "../lib/shell-prompt.ts";
|
|
18
18
|
import { gentlePiConfigHome } from "../lib/agent-home.ts";
|
|
19
|
+
import { resolveAnimationPolicy, writeAnimationPolicy, type AnimationPolicy } from "../lib/animation-policy.ts";
|
|
19
20
|
import {
|
|
20
21
|
DOUBLE_ESC_CANCEL_WINDOW_MS,
|
|
21
22
|
resolveDoubleEscCancelPolicy,
|
|
@@ -23,7 +24,7 @@ import {
|
|
|
23
24
|
type DoubleEscCancelPolicy,
|
|
24
25
|
type DoubleEscCancelResolution,
|
|
25
26
|
} from "../lib/double-esc-cancel-policy.ts";
|
|
26
|
-
import { accountIdFromToken, CODEX_PROVIDER, CODEX_USAGE_URL, NAN_PROVIDER, NAN_QUOTA_URL, parseCodexUsage, parseNanQuota, parseUsageHeaders, UsageStore, type ProviderUsage } from "../lib/shell-usage.ts";
|
|
27
|
+
import { accountIdFromToken, CODEX_PROVIDER, CODEX_USAGE_URL, NAN_PROVIDER, NAN_QUOTA_URL, parseCodexUsage, parseNanQuota, parseProviderUsage, parseUsageHeaders, parseUsageSource, UsageSourceRegistry, UsageStore, USAGE_SOURCE_EVENT, type ProviderUsage, type UsageSource } from "../lib/shell-usage.ts";
|
|
27
28
|
import { UsageView } from "../lib/shell-usage-view.ts";
|
|
28
29
|
import { sidebarHeader, sidebarPart } from "../lib/shell-sidebar.ts";
|
|
29
30
|
import { installSidebar, invalidateSidebar } from "../lib/shell-sidebar-layout.ts";
|
|
@@ -229,6 +230,7 @@ export function extractQueuedText(combined: string, draft: string): string | und
|
|
|
229
230
|
export class GentlePromptEditor extends CustomEditor {
|
|
230
231
|
private promptState: PromptState = PROMPT_STATE.IDLE;
|
|
231
232
|
private tick = 0;
|
|
233
|
+
private animationPolicy: AnimationPolicy = "quality";
|
|
232
234
|
private pulse: NodeJS.Timeout | undefined;
|
|
233
235
|
private readonly deps: PromptEditorDeps;
|
|
234
236
|
// CustomEditor keeps its own `keybindings` private, so this class holds
|
|
@@ -248,6 +250,23 @@ export class GentlePromptEditor extends CustomEditor {
|
|
|
248
250
|
this.keybindingsManager = keybindings;
|
|
249
251
|
}
|
|
250
252
|
|
|
253
|
+
setAnimationPolicy(policy: AnimationPolicy): void {
|
|
254
|
+
if (this.animationPolicy === policy) return;
|
|
255
|
+
this.animationPolicy = policy;
|
|
256
|
+
this.stopPulse();
|
|
257
|
+
if (this.promptState === PROMPT_STATE.WORKING) this.startPulse();
|
|
258
|
+
this.deps.requestRender();
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
private startPulse(): void {
|
|
262
|
+
if (this.animationPolicy === "potato") return;
|
|
263
|
+
this.pulse = setInterval(() => {
|
|
264
|
+
this.tick += 1;
|
|
265
|
+
this.deps.requestRender();
|
|
266
|
+
}, this.animationPolicy === "performance" ? 1000 : SHELL_PULSE_MS);
|
|
267
|
+
this.pulse.unref();
|
|
268
|
+
}
|
|
269
|
+
|
|
251
270
|
setWorking(working: boolean): void {
|
|
252
271
|
this.promptState = working ? PROMPT_STATE.WORKING : PROMPT_STATE.IDLE;
|
|
253
272
|
this.stopPulse();
|
|
@@ -256,11 +275,7 @@ export class GentlePromptEditor extends CustomEditor {
|
|
|
256
275
|
} else {
|
|
257
276
|
this.pendingIdleClearDeadline = undefined;
|
|
258
277
|
this.pendingIdleClearText = undefined;
|
|
259
|
-
this.
|
|
260
|
-
this.tick += 1;
|
|
261
|
-
this.deps.requestRender();
|
|
262
|
-
}, SHELL_PULSE_MS);
|
|
263
|
-
this.pulse.unref();
|
|
278
|
+
this.startPulse();
|
|
264
279
|
}
|
|
265
280
|
this.deps.requestRender();
|
|
266
281
|
}
|
|
@@ -743,28 +758,67 @@ export async function fetchNanUsage(apiKey: string | undefined, fetchFn: typeof
|
|
|
743
758
|
}
|
|
744
759
|
}
|
|
745
760
|
|
|
761
|
+
// A registered source is foreign code running inside a fire-and-forget
|
|
762
|
+
// refresh: it must degrade exactly like the built-in fetchers above, never
|
|
763
|
+
// throw past this call, and never leave an unhandled rejection behind.
|
|
764
|
+
async function fetchFromSource(source: UsageSource, apiKey: string | undefined, fetchFn: typeof fetch, now: number): Promise<ProviderUsage | undefined> {
|
|
765
|
+
try {
|
|
766
|
+
const result = await source.fetch(apiKey, fetchFn, now);
|
|
767
|
+
return result === undefined ? undefined : parseProviderUsage(result, source.provider);
|
|
768
|
+
} catch {
|
|
769
|
+
return undefined;
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
|
|
746
773
|
export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = process.env, overrides: Partial<ShellDeps> = {}): void {
|
|
747
774
|
installSessionChangeCapture(pi, env, overrides.resolveWorktree ?? resolveSessionWorktree);
|
|
748
775
|
if (!shellEnabled(env)) return;
|
|
749
776
|
const deps: ShellDeps = { ...defaultShellDeps, activeProfile: createActiveProfileReader(env), ...overrides };
|
|
750
777
|
const usage = new UsageStore();
|
|
778
|
+
// Providers gentle-shell has never heard of get a usage source too, when
|
|
779
|
+
// the extension that owns them registers one on pi.events; see the
|
|
780
|
+
// USAGE_SOURCE_EVENT subscription below.
|
|
781
|
+
const usageSources = new UsageSourceRegistry();
|
|
751
782
|
let renderHost: ShellRenderHost | undefined;
|
|
752
783
|
// The 5-minute rule is per provider: one provider's fetch cannot leave the
|
|
753
784
|
// next one waiting for an interval it never used.
|
|
754
785
|
const usageFetchedAt = new Map<string, number>();
|
|
755
786
|
const refreshUsage = async (ctx: ExtensionContext, force: boolean) => {
|
|
756
787
|
const provider = ctx.model?.provider;
|
|
757
|
-
if (provider
|
|
788
|
+
if (!provider) return;
|
|
789
|
+
const source = usageSources.get(provider);
|
|
790
|
+
if (!source && provider !== CODEX_PROVIDER && provider !== NAN_PROVIDER) return;
|
|
758
791
|
const now = deps.now();
|
|
759
792
|
if (!force && now - (usageFetchedAt.get(provider) ?? 0) < USAGE_REFRESH_MS) return;
|
|
760
793
|
usageFetchedAt.set(provider, now);
|
|
761
794
|
const apiKey = await ctx.modelRegistry.getApiKeyForProvider(provider).catch(() => undefined);
|
|
762
|
-
const fetched =
|
|
795
|
+
const fetched = source
|
|
796
|
+
? await fetchFromSource(source, apiKey, deps.fetch, deps.now())
|
|
797
|
+
: provider === NAN_PROVIDER
|
|
798
|
+
? await fetchNanUsage(apiKey, deps.fetch, deps.now())
|
|
799
|
+
: await fetchCodexUsage(apiKey, deps.fetch, deps.now());
|
|
763
800
|
if (!fetched) return;
|
|
801
|
+
// A registered source can be replaced while its own fetch is still in
|
|
802
|
+
// flight; the identity captured above is this call's source, so a stale
|
|
803
|
+
// answer that outlives its replacement is discarded instead of
|
|
804
|
+
// overwriting whatever the replacement already recorded.
|
|
805
|
+
if (source && usageSources.get(provider) !== source) return;
|
|
764
806
|
usage.record(fetched);
|
|
765
807
|
renderHost?.invalidateSidebar?.();
|
|
766
808
|
renderHost?.requestRender();
|
|
767
809
|
};
|
|
810
|
+
// Subscribed once, for the life of the extension: a registration can
|
|
811
|
+
// arrive before the first session_start (the owning extension's factory
|
|
812
|
+
// runs first) or after it (its own session_start fires later, or it
|
|
813
|
+
// registers lazily). Either order is fine: a registration for the
|
|
814
|
+
// currently active provider forces exactly one refresh, so the panel
|
|
815
|
+
// never waits for the 5-minute window or the next turn to notice it.
|
|
816
|
+
pi.events.on(USAGE_SOURCE_EVENT, (payload) => {
|
|
817
|
+
const source = parseUsageSource(payload);
|
|
818
|
+
if (!source) return;
|
|
819
|
+
usageSources.register(source);
|
|
820
|
+
if (currentContext?.model?.provider === source.provider) void refreshUsage(currentContext, true);
|
|
821
|
+
});
|
|
768
822
|
pi.on("after_provider_response", (event) => {
|
|
769
823
|
const parsed = parseUsageHeaders(event.headers, deps.now());
|
|
770
824
|
if (!parsed) return;
|
|
@@ -785,6 +839,7 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p
|
|
|
785
839
|
theme,
|
|
786
840
|
now: () => deps.now(),
|
|
787
841
|
active: () => (ctx.model ? { provider: ctx.model.provider } : undefined),
|
|
842
|
+
registry: () => usageSources,
|
|
788
843
|
onRefresh: () => refreshUsage(ctx, true),
|
|
789
844
|
onClose: () => done(null),
|
|
790
845
|
requestRender: () => tui.requestRender(),
|
|
@@ -818,6 +873,8 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p
|
|
|
818
873
|
// direction, and the Esc gate always describe the same effective policy
|
|
819
874
|
// even when another session or a hand edit changed the file mid-session.
|
|
820
875
|
const doubleEscCancelConfigHome = gentlePiConfigHome(env);
|
|
876
|
+
const animationOptions = { gentlePiConfigHome: doubleEscCancelConfigHome };
|
|
877
|
+
let animationPolicy = resolveAnimationPolicy(animationOptions).policy;
|
|
821
878
|
let doubleEscCancelPolicy: DoubleEscCancelPolicy = resolveDoubleEscCancelPolicy({
|
|
822
879
|
env,
|
|
823
880
|
gentlePiConfigHome: doubleEscCancelConfigHome,
|
|
@@ -889,10 +946,11 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p
|
|
|
889
946
|
const headerBar = (width: number) => renderShellHeaderBar(buildShellHeaderModel(footerModel()), theme, width, usageShortcutKey);
|
|
890
947
|
const disposeHeader = sidebarHeader(tui, {
|
|
891
948
|
digest: () => JSON.stringify(buildShellHeaderModel(footerModel())),
|
|
892
|
-
render: (width) => [headerBar(width).text],
|
|
949
|
+
render: (width) => [headerBar(width).text, renderShellHeaderRule(theme, width)],
|
|
893
950
|
invalidate() {},
|
|
894
951
|
handleMouse(event) {
|
|
895
952
|
if (event.type !== "click" || event.button !== "left") return undefined;
|
|
953
|
+
if (event.y !== 0) return undefined; // the rule row under the status line is decorative, never clickable
|
|
896
954
|
const { usageSpan } = headerBar(event.width);
|
|
897
955
|
if (!usageSpan || event.x < usageSpan.start || event.x >= usageSpan.end) return undefined;
|
|
898
956
|
void openUsage(ctx);
|
|
@@ -908,6 +966,7 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p
|
|
|
908
966
|
(created) => {
|
|
909
967
|
prompt?.dispose();
|
|
910
968
|
prompt = created;
|
|
969
|
+
prompt.setAnimationPolicy(animationPolicy);
|
|
911
970
|
},
|
|
912
971
|
{ now: () => deps.now(), doubleEscCancelEnabled: () => doubleEscCancelPolicy === "on", dispatchQueuedText: (text) => { pendingQueuedText = pendingQueuedText === undefined ? text : `${pendingQueuedText}\n\n${text}`; } },
|
|
913
972
|
);
|
|
@@ -971,6 +1030,36 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p
|
|
|
971
1030
|
handler: async (ctx) => showCommandPalette(pi, ctx, env),
|
|
972
1031
|
});
|
|
973
1032
|
}
|
|
1033
|
+
pi.registerCommand("gentle:animations", {
|
|
1034
|
+
description: "Show or set global animations; no argument opens a selectable menu (quality|performance|potato, plus status).",
|
|
1035
|
+
// No argument opens a selectable menu when an interactive UI is present;
|
|
1036
|
+
// headless callers and fakes without ui.select keep the status fallback.
|
|
1037
|
+
handler: async (args, ctx) => {
|
|
1038
|
+
let action = args.trim() || "status";
|
|
1039
|
+
if (args.trim().length === 0 && ctx.hasUI && typeof ctx.ui.select === "function") {
|
|
1040
|
+
const selected = await ctx.ui.select(
|
|
1041
|
+
`Gentle animations (current: ${animationPolicy})`,
|
|
1042
|
+
["quality", "performance", "potato", "status"],
|
|
1043
|
+
);
|
|
1044
|
+
if (selected === undefined) return;
|
|
1045
|
+
action = selected;
|
|
1046
|
+
}
|
|
1047
|
+
if (action !== "status" && action !== "quality" && action !== "performance" && action !== "potato") {
|
|
1048
|
+
ctx.ui.notify("Use /gentle:animations status|quality|performance|potato.", "warning");
|
|
1049
|
+
return;
|
|
1050
|
+
}
|
|
1051
|
+
try {
|
|
1052
|
+
if (action !== "status") writeAnimationPolicy(action, animationOptions);
|
|
1053
|
+
const result = resolveAnimationPolicy(animationOptions);
|
|
1054
|
+
animationPolicy = result.policy;
|
|
1055
|
+
prompt?.setAnimationPolicy(animationPolicy);
|
|
1056
|
+
const source = result.source === "default" ? "built-in default" : `global file ${result.globalFile}`;
|
|
1057
|
+
ctx.ui.notify(`animations: ${result.policy} (decided by ${source})${result.malformed ? "; malformed or unreadable file, falling back to quality" : ""}. Prompt applies now; startup banner applies at next creation.`, result.malformed ? "warning" : "info");
|
|
1058
|
+
} catch (error) {
|
|
1059
|
+
ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
|
|
1060
|
+
}
|
|
1061
|
+
},
|
|
1062
|
+
});
|
|
974
1063
|
// User-owned, like gentle:background-subagents and gentle:review-mode: the
|
|
975
1064
|
// only writer is this handler, reached only by explicit invocation. Unlike
|
|
976
1065
|
// those two, no argument toggles the effective policy instead of merely
|
|
@@ -535,18 +535,39 @@ interface BoundedRowSection {
|
|
|
535
535
|
tail?: boolean;
|
|
536
536
|
}
|
|
537
537
|
|
|
538
|
+
// Cache the rendered preview slice per stable tool-result object: pi re-renders
|
|
539
|
+
// every visible card each frame, so re-tokenizing the full output text per pass is
|
|
540
|
+
// pure waste. Only the returned preview slice is retained, never the full wrapped
|
|
541
|
+
// text, so an arbitrarily large output cannot pin every wrapped line for the
|
|
542
|
+
// result object's lifetime.
|
|
543
|
+
const boundedRowsLineCache = new WeakMap<object, Array<{ text: string; width: number; rows: number; tail: boolean; lines: string[] } | undefined>>();
|
|
544
|
+
|
|
538
545
|
class BoundedRows implements Component {
|
|
539
546
|
private readonly sections: readonly BoundedRowSection[];
|
|
547
|
+
private readonly cacheKey: object | undefined;
|
|
540
548
|
|
|
541
|
-
constructor(sections: readonly BoundedRowSection[]) {
|
|
549
|
+
constructor(sections: readonly BoundedRowSection[], cacheKey?: object) {
|
|
542
550
|
this.sections = sections;
|
|
551
|
+
this.cacheKey = cacheKey;
|
|
543
552
|
}
|
|
544
553
|
|
|
554
|
+
/** Renders each section through the wrapped-line cache, sliced to the section row budget; cache hits skip re-tokenizing and re-wrapping the section text. */
|
|
545
555
|
render(width: number): string[] {
|
|
546
|
-
|
|
556
|
+
const cache = this.cacheKey ? boundedRowsLineCache.get(this.cacheKey) : undefined;
|
|
557
|
+
return this.sections.flatMap(({ text, rows, tail = false }, index) => {
|
|
547
558
|
if (rows <= 0) return [];
|
|
559
|
+
const hit = cache?.[index];
|
|
560
|
+
if (hit && hit.text === text && hit.width === width && hit.rows === rows && hit.tail === tail) {
|
|
561
|
+
return [...hit.lines];
|
|
562
|
+
}
|
|
548
563
|
const rendered = new Text(text, 0, 0).render(width);
|
|
549
|
-
|
|
564
|
+
const sliced = tail ? rendered.slice(-rows) : rendered.slice(0, rows);
|
|
565
|
+
if (this.cacheKey) {
|
|
566
|
+
const slot = boundedRowsLineCache.get(this.cacheKey) ?? [];
|
|
567
|
+
slot[index] = { text, width, rows, tail, lines: sliced };
|
|
568
|
+
boundedRowsLineCache.set(this.cacheKey, slot);
|
|
569
|
+
}
|
|
570
|
+
return [...sliced];
|
|
550
571
|
});
|
|
551
572
|
}
|
|
552
573
|
|
|
@@ -641,8 +662,10 @@ function registerQuietTool(pi: ExtensionAPI, toolName: QuietToolName, commandArg
|
|
|
641
662
|
}
|
|
642
663
|
return new Text(formatToolCall(toolName, callArgs, theme), 0, 0);
|
|
643
664
|
},
|
|
665
|
+
/** Builds the card component for this render pass; collapsed cards delegate to the wrapped-line cache keyed by the tool result object. */
|
|
644
666
|
renderResult(result, options, theme, context) {
|
|
645
667
|
const renderContext = context as ToolRenderContextLike | undefined;
|
|
668
|
+
const cacheKey = typeof result === "object" && result !== null ? result : undefined;
|
|
646
669
|
const safeResult = sanitizedResult(result);
|
|
647
670
|
const text = safeText(extractTextContent(safeResult));
|
|
648
671
|
const isError = renderContext?.isError ?? options.isError ?? false;
|
|
@@ -661,7 +684,7 @@ function registerQuietTool(pi: ExtensionAPI, toolName: QuietToolName, commandArg
|
|
|
661
684
|
return new BoundedRows([
|
|
662
685
|
{ text: theme.fg("warning", partialLabel(toolName, text)), rows: 1 },
|
|
663
686
|
...(visible ? [{ text: theme.fg("muted", visible), rows: PREVIEW_LINE_LIMIT, tail: true }] : []),
|
|
664
|
-
]);
|
|
687
|
+
], cacheKey);
|
|
665
688
|
}
|
|
666
689
|
if (options.expanded && toolName === "read" && hasImageContent(safeResult) && officialRenderResult) {
|
|
667
690
|
return officialRenderResult(
|
|
@@ -686,7 +709,7 @@ function registerQuietTool(pi: ExtensionAPI, toolName: QuietToolName, commandArg
|
|
|
686
709
|
return new BoundedRows([
|
|
687
710
|
{ text: theme.fg(color, output.replace(/^\n/, "")), rows: PREVIEW_LINE_LIMIT, tail },
|
|
688
711
|
...(hint ? [{ text: theme.fg(color, hint.slice(1)), rows: 1 }] : []),
|
|
689
|
-
]);
|
|
712
|
+
], cacheKey);
|
|
690
713
|
}
|
|
691
714
|
return new Text(hint ? theme.fg(color, hint.slice(1)) : "", 0, 0);
|
|
692
715
|
},
|
|
@@ -5,6 +5,7 @@ import * as os from "node:os";
|
|
|
5
5
|
import { execFile } from "node:child_process";
|
|
6
6
|
import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
|
|
7
7
|
import { join } from "node:path";
|
|
8
|
+
import { resolveAnimationPolicy } from "../lib/animation-policy.ts";
|
|
8
9
|
|
|
9
10
|
const PI_AGENT_DIR = join(os.homedir(), ".pi", "agent");
|
|
10
11
|
const PI_NPM_DIR = join(PI_AGENT_DIR, "npm", "node_modules");
|
|
@@ -691,6 +692,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
691
692
|
|
|
692
693
|
let tick = 0;
|
|
693
694
|
let refreshStats = () => {};
|
|
695
|
+
let headerCache: { key: string; out: string[] } | null = null;
|
|
694
696
|
const state = {
|
|
695
697
|
timer: null as NodeJS.Timeout | null,
|
|
696
698
|
mode: currentIntroMode() as IntroMode,
|
|
@@ -718,18 +720,27 @@ export default function (pi: ExtensionAPI) {
|
|
|
718
720
|
setTimeout(() => {
|
|
719
721
|
ctx.ui.setHeader((tui, theme) => {
|
|
720
722
|
if (state.timer) clearInterval(state.timer);
|
|
723
|
+
headerCache = null;
|
|
721
724
|
|
|
722
725
|
refreshStats = () => tui.requestRender();
|
|
726
|
+
// Capture once: a command changes the live prompt, not this intro.
|
|
727
|
+
const animationPolicy = resolveAnimationPolicy().policy;
|
|
723
728
|
const animStart = Date.now();
|
|
724
|
-
|
|
725
|
-
tick
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
729
|
+
if (animationPolicy === "potato") {
|
|
730
|
+
tick = Number.MAX_SAFE_INTEGER;
|
|
731
|
+
state.timer = null;
|
|
732
|
+
} else {
|
|
733
|
+
const performance = animationPolicy === "performance";
|
|
734
|
+
state.timer = setInterval(() => {
|
|
735
|
+
tick += performance ? 10 : 1;
|
|
736
|
+
const finished = allStrokesReady() && tick > WRITING_END_TICK + 22;
|
|
737
|
+
if (finished || Date.now() - animStart > 5000) {
|
|
738
|
+
clearInterval(state.timer!);
|
|
739
|
+
state.timer = null;
|
|
740
|
+
}
|
|
741
|
+
try { tui.requestRender(); } catch { cleanup(); }
|
|
742
|
+
}, performance ? 250 : 25);
|
|
743
|
+
}
|
|
733
744
|
|
|
734
745
|
// Grace period: pi-tui emite resizes transitorios mientras compone su layout inicial.
|
|
735
746
|
const bootStart = Date.now();
|
|
@@ -753,8 +764,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
753
764
|
process.stdout.on("resize", resizeHandler);
|
|
754
765
|
|
|
755
766
|
return {
|
|
767
|
+
/** Renders the persistent header grid; memoized per width, tick, mode and stats so static passes reuse the built lines. */
|
|
756
768
|
render(width: number): string[] {
|
|
757
769
|
if (state.mode === "skip") return [];
|
|
770
|
+
const headerKey = `${width}|${tick}|${state.mode}|${gitBranch}|${mcpServersCount}|${extensionsCount}|${packagesCount}|${sddAgentsCount}|${ctx.cwd}|${skills.length}|${customTools.length}`;
|
|
771
|
+
if (headerCache?.key === headerKey) return headerCache.out;
|
|
758
772
|
|
|
759
773
|
const flashStartTick = 10;
|
|
760
774
|
const roseOpacity = Math.min(1, tick / 10);
|
|
@@ -1050,9 +1064,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
1050
1064
|
out.push(truncateToWidth(line, Math.max(1, width), ""));
|
|
1051
1065
|
}
|
|
1052
1066
|
|
|
1067
|
+
headerCache = { key: headerKey, out };
|
|
1053
1068
|
return out;
|
|
1054
1069
|
},
|
|
1055
|
-
invalidate() {},
|
|
1070
|
+
invalidate() { headerCache = null; },
|
|
1056
1071
|
dispose() {
|
|
1057
1072
|
cleanup();
|
|
1058
1073
|
},
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { gentlePiConfigHome } from "./agent-home.ts";
|
|
5
|
+
|
|
6
|
+
export const ANIMATION_POLICY = { QUALITY: "quality", PERFORMANCE: "performance", POTATO: "potato" } as const;
|
|
7
|
+
export type AnimationPolicy = (typeof ANIMATION_POLICY)[keyof typeof ANIMATION_POLICY];
|
|
8
|
+
const SOURCE = { GLOBAL: "global_file", DEFAULT: "default" } as const;
|
|
9
|
+
export const ANIMATION_SCHEMA = "gentle-pi.animations/v1";
|
|
10
|
+
|
|
11
|
+
interface AnimationOptions { gentlePiConfigHome?: string }
|
|
12
|
+
export interface AnimationResolution {
|
|
13
|
+
policy: AnimationPolicy;
|
|
14
|
+
source: (typeof SOURCE)[keyof typeof SOURCE];
|
|
15
|
+
malformed: boolean;
|
|
16
|
+
globalFile: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function parseAnimationPolicyFile(raw: string): AnimationPolicy | undefined {
|
|
20
|
+
try {
|
|
21
|
+
const value: unknown = JSON.parse(raw);
|
|
22
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined;
|
|
23
|
+
if (!("schema" in value) || value.schema !== ANIMATION_SCHEMA || !("policy" in value) || Object.keys(value).length !== 2) return undefined;
|
|
24
|
+
return value.policy === "quality" || value.policy === "performance" || value.policy === "potato" ? value.policy : undefined;
|
|
25
|
+
} catch { return undefined; }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function resolveAnimationPolicy(options: AnimationOptions = {}): AnimationResolution {
|
|
29
|
+
const globalFile = join(options.gentlePiConfigHome ?? gentlePiConfigHome(), "animations.json");
|
|
30
|
+
try {
|
|
31
|
+
const policy = parseAnimationPolicyFile(readFileSync(globalFile, "utf8"));
|
|
32
|
+
return { policy: policy ?? "quality", source: SOURCE.GLOBAL, malformed: policy === undefined, globalFile };
|
|
33
|
+
} catch (error) {
|
|
34
|
+
const missing = typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
|
|
35
|
+
return { policy: "quality", source: missing ? SOURCE.DEFAULT : SOURCE.GLOBAL, malformed: !missing, globalFile };
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Same-directory rename prevents readers from seeing a partially written policy. */
|
|
40
|
+
export function writeAnimationPolicy(policy: AnimationPolicy, options: AnimationOptions = {}): string {
|
|
41
|
+
const home = options.gentlePiConfigHome ?? gentlePiConfigHome();
|
|
42
|
+
const path = join(home, "animations.json");
|
|
43
|
+
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
44
|
+
mkdirSync(home, { recursive: true });
|
|
45
|
+
try {
|
|
46
|
+
writeFileSync(temporary, `${JSON.stringify({ schema: ANIMATION_SCHEMA, policy })}\n`, { flag: "wx", mode: 0o600 });
|
|
47
|
+
renameSync(temporary, path);
|
|
48
|
+
} finally {
|
|
49
|
+
try { unlinkSync(temporary); } catch { /* Rename already consumed the temporary file. */ }
|
|
50
|
+
}
|
|
51
|
+
return path;
|
|
52
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { TaskRecord } from "./agents-protocol.ts";
|
|
2
|
+
|
|
3
|
+
const ACTION = { WARM: "warm", STOP: "stop" } as const;
|
|
4
|
+
type WarmingAction = (typeof ACTION)[keyof typeof ACTION];
|
|
5
|
+
interface WarmingDecision {
|
|
6
|
+
warmCost: number;
|
|
7
|
+
missCost: number;
|
|
8
|
+
continuationProbability: number;
|
|
9
|
+
action: WarmingAction;
|
|
10
|
+
}
|
|
11
|
+
interface WarmingOverride { action: WarmingAction }
|
|
12
|
+
interface WarmingEvents {
|
|
13
|
+
on(event: "cache_warming_decision", handler: (event: WarmingDecision) => WarmingOverride | undefined): unknown;
|
|
14
|
+
}
|
|
15
|
+
export interface WarmingState {
|
|
16
|
+
sessionId: string | undefined;
|
|
17
|
+
ownedTaskIds: ReadonlySet<string>;
|
|
18
|
+
tasks: Array<Pick<TaskRecord, "id" | "parentSessionId" | "mode" | "status">>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Pi 0.86.1's native minimum expected savings, in dollars. Ownership raises
|
|
22
|
+
// continuation probability to 1, not permission to spend without a benefit.
|
|
23
|
+
const MINIMUM_EXPECTED_SAVINGS = 0.05;
|
|
24
|
+
|
|
25
|
+
export function installBackgroundCacheWarming(pi: WarmingEvents, state: () => WarmingState): void {
|
|
26
|
+
pi.on("cache_warming_decision", (event) => {
|
|
27
|
+
const { sessionId, ownedTaskIds, tasks } = state();
|
|
28
|
+
if (!sessionId || !tasks.some(task => task.parentSessionId === sessionId &&
|
|
29
|
+
ownedTaskIds.has(task.id) && task.mode === "background" &&
|
|
30
|
+
(task.status === "queued" || task.status === "running"))) return;
|
|
31
|
+
// Only native candidates arrive here: Pi owns opt-out, replay safety,
|
|
32
|
+
// provider TTLs, fixed horizons, scheduling and usage outside context.
|
|
33
|
+
const economic = Number.isFinite(event.warmCost) && event.warmCost >= 0 &&
|
|
34
|
+
Number.isFinite(event.missCost) && event.missCost > 0 &&
|
|
35
|
+
event.missCost - event.warmCost >= MINIMUM_EXPECTED_SAVINGS;
|
|
36
|
+
return { action: economic ? ACTION.WARM : ACTION.STOP };
|
|
37
|
+
});
|
|
38
|
+
}
|
|
@@ -26,6 +26,7 @@ export const COMMAND_PALETTE_CATALOG: readonly CommandPaletteCatalogGroup[] = [
|
|
|
26
26
|
{ command: "gentle:review-mode", label: "Review mode (receipt-driven development)" },
|
|
27
27
|
{ command: "gentle:background-subagents", label: "Background subagents" },
|
|
28
28
|
{ command: "gentle:double-esc-cancel", label: "Require double Esc to cancel" },
|
|
29
|
+
{ command: "gentle:animations", label: "Animation mode" },
|
|
29
30
|
{ command: "gentle:telemetry", label: "Telemetry" },
|
|
30
31
|
{ command: "gentle:banner", label: "Startup banner" },
|
|
31
32
|
{ command: "gentle:banner-color", label: "Banner color" },
|
|
@@ -59,6 +59,13 @@ export interface InProcessReviewerRequest {
|
|
|
59
59
|
readonly prompt: Buffer;
|
|
60
60
|
readonly timeoutMs: number;
|
|
61
61
|
readonly signal?: AbortSignal;
|
|
62
|
+
/**
|
|
63
|
+
* The live pi session id, threaded from the extension context. Pi's main
|
|
64
|
+
* agent loop adds OpenCode attribution headers itself; this side-call must
|
|
65
|
+
* carry them itself instead. Absent (or empty) means no attribution header,
|
|
66
|
+
* never an invented one and never an error.
|
|
67
|
+
*/
|
|
68
|
+
readonly sessionId?: string;
|
|
62
69
|
/** e.g. "review-risk" — only used to name the routing config key in refusal messages. */
|
|
63
70
|
readonly routingKey: string;
|
|
64
71
|
}
|
|
@@ -126,6 +133,30 @@ function isTextContent(part: { type?: unknown }): part is TextContent {
|
|
|
126
133
|
return part.type === "text";
|
|
127
134
|
}
|
|
128
135
|
|
|
136
|
+
/**
|
|
137
|
+
* Mirrors pi's main-loop OpenCode attribution condition exactly
|
|
138
|
+
* (core/provider-attribution.js#getSessionHeaders): the model's provider is
|
|
139
|
+
* `opencode` or `opencode-go`, or its baseUrl host is `opencode.ai`. Returns
|
|
140
|
+
* the `{ x-opencode-session, x-opencode-client }` attribution pair, or
|
|
141
|
+
* undefined when the model is not OpenCode-routed or there is no live session
|
|
142
|
+
* id — a missing session id is never an error and never invents a header.
|
|
143
|
+
* The URL parse is guarded: an unparseable baseUrl follows the provider
|
|
144
|
+
* condition alone.
|
|
145
|
+
*/
|
|
146
|
+
export function openCodeSessionAttributionHeaders(model: Model<Api>, sessionId: string | undefined): ProviderHeaders | undefined {
|
|
147
|
+
const isOpenCode = model.provider === "opencode"
|
|
148
|
+
|| model.provider === "opencode-go"
|
|
149
|
+
|| (() => {
|
|
150
|
+
try {
|
|
151
|
+
return new URL(String(model.baseUrl ?? "")).hostname === "opencode.ai";
|
|
152
|
+
} catch {
|
|
153
|
+
return false;
|
|
154
|
+
}
|
|
155
|
+
})();
|
|
156
|
+
if (!isOpenCode || typeof sessionId !== "string" || sessionId.length === 0) return undefined;
|
|
157
|
+
return { "x-opencode-session": sessionId, "x-opencode-client": "pi" };
|
|
158
|
+
}
|
|
159
|
+
|
|
129
160
|
/**
|
|
130
161
|
* Runs one reviewer completion in-process: resolve the model, authenticate,
|
|
131
162
|
* map the routing thinking label, and complete exactly one frozen prompt as
|
|
@@ -168,6 +199,12 @@ export async function runInProcessReviewer(request: InProcessReviewerRequest, de
|
|
|
168
199
|
);
|
|
169
200
|
}
|
|
170
201
|
|
|
202
|
+
// Extension side-calls bypass pi's main agent loop, which is where OpenCode
|
|
203
|
+
// attribution headers are otherwise added, so this completion carries them
|
|
204
|
+
// itself — as a default beneath the registry's own auth headers, the same
|
|
205
|
+
// merge order pi's core uses for explicit header sources.
|
|
206
|
+
const attributionHeaders = openCodeSessionAttributionHeaders(model, request.sessionId);
|
|
207
|
+
|
|
171
208
|
// The caller's own signal (if any) and a floor timeout race together:
|
|
172
209
|
// whichever fires first aborts the completion. The catch branch below
|
|
173
210
|
// tells them apart by which underlying signal actually fired, never by
|
|
@@ -188,7 +225,7 @@ export async function runInProcessReviewer(request: InProcessReviewerRequest, de
|
|
|
188
225
|
signal: combinedSignal,
|
|
189
226
|
timeoutMs: request.timeoutMs,
|
|
190
227
|
...(auth.apiKey === undefined ? {} : { apiKey: auth.apiKey }),
|
|
191
|
-
...(auth.headers === undefined ? {} : { headers: auth.headers }),
|
|
228
|
+
...(auth.headers === undefined && attributionHeaders === undefined ? {} : { headers: attributionHeaders === undefined ? auth.headers : { ...attributionHeaders, ...auth.headers } }),
|
|
192
229
|
...(reasoning.reasoning === undefined ? {} : { reasoning: reasoning.reasoning }),
|
|
193
230
|
};
|
|
194
231
|
|
package/lib/native-review-cli.ts
CHANGED
|
@@ -201,7 +201,7 @@ export interface NativeReviewModeRequest {
|
|
|
201
201
|
// explicit `committedOnly` acknowledgement, exactly like Native START's
|
|
202
202
|
// baseRef/committedOnly pairing, because both select a committed range
|
|
203
203
|
// instead of the ambient working tree.
|
|
204
|
-
export interface NativeReviewAssessRequest {
|
|
204
|
+
export interface NativeReviewAssessRequest extends NativeUntrackedSelection {
|
|
205
205
|
cwd: string;
|
|
206
206
|
baseRef?: string;
|
|
207
207
|
committedOnly?: boolean;
|
|
@@ -705,7 +705,11 @@ function isNativeUntrackedPath(value: unknown): value is string {
|
|
|
705
705
|
&& value.split("/").every((segment) => segment.length > 0 && segment !== "." && segment !== "..");
|
|
706
706
|
}
|
|
707
707
|
|
|
708
|
-
function nativeUntrackedSelection(request:
|
|
708
|
+
export function nativeUntrackedSelection(request: {
|
|
709
|
+
untrackedScope?: unknown;
|
|
710
|
+
expectedUntrackedInventory?: unknown;
|
|
711
|
+
intendedUntracked?: unknown;
|
|
712
|
+
}): NativeUntrackedSelection {
|
|
709
713
|
const { untrackedScope, expectedUntrackedInventory, intendedUntracked } = request;
|
|
710
714
|
const declared = untrackedScope !== undefined || expectedUntrackedInventory !== undefined || intendedUntracked !== undefined;
|
|
711
715
|
if (!declared) return {};
|
|
@@ -716,16 +720,19 @@ function nativeUntrackedSelection(request: NativeUntrackedSelectionRequest): Nat
|
|
|
716
720
|
) {
|
|
717
721
|
throw new TypeError("Native untracked selection must declare one scope, one inventory digest, and unique repository-relative paths");
|
|
718
722
|
}
|
|
719
|
-
|
|
723
|
+
// The guard above establishes the array and element types for both typed
|
|
724
|
+
// native requests and untyped facade input.
|
|
725
|
+
const paths = intendedUntracked as readonly string[] | undefined;
|
|
726
|
+
if (untrackedScope === NATIVE_UNTRACKED_SCOPE.EXCLUDE && (paths?.length ?? 0) > 0) {
|
|
720
727
|
throw new TypeError("Native exclude untracked selection cannot include paths");
|
|
721
728
|
}
|
|
722
|
-
if (untrackedScope === NATIVE_UNTRACKED_SCOPE.SELECT && (
|
|
729
|
+
if (untrackedScope === NATIVE_UNTRACKED_SCOPE.SELECT && (paths?.length ?? 0) === 0) {
|
|
723
730
|
throw new TypeError("Native select untracked selection requires at least one path");
|
|
724
731
|
}
|
|
725
732
|
return {
|
|
726
733
|
untrackedScope,
|
|
727
734
|
expectedUntrackedInventory,
|
|
728
|
-
intendedUntracked:
|
|
735
|
+
intendedUntracked: paths === undefined ? undefined : [...paths],
|
|
729
736
|
};
|
|
730
737
|
}
|
|
731
738
|
|
|
@@ -1018,6 +1025,13 @@ export const NATIVE_CLI_CONTRACTS = Object.freeze({
|
|
|
1018
1025
|
// and hint remain dark because neither is proven to reach the negotiated
|
|
1019
1026
|
// START path Pi consumes.
|
|
1020
1027
|
"3.4.0": Object.freeze({ start: true, finalize: true, validate: true, bindSdd: true, status: true, inventory: true, reclaim: true, recover: true, abandon: true, quarantineLegacy: true, reconcileAuthority: true, repairLegacyAlias: true, mode: true, riskEvidence: false, hint: false, delivery: true }),
|
|
1028
|
+
// v3.5.0 repeats 3.4.0: the published provider contract bundle is
|
|
1029
|
+
// byte-identical at 1.2.0, both binaries advertise capabilities/v2.6
|
|
1030
|
+
// with only build-identity differences, and no review-integration schema
|
|
1031
|
+
// changed. The v2 preflight failure identity fix does not change the
|
|
1032
|
+
// closed START/STATUS fields this row negotiates. riskEvidence and hint
|
|
1033
|
+
// remain dark; neither is proven to reach Pi's negotiated START path.
|
|
1034
|
+
"3.5.0": Object.freeze({ start: true, finalize: true, validate: true, bindSdd: true, status: true, inventory: true, reclaim: true, recover: true, abandon: true, quarantineLegacy: true, reconcileAuthority: true, repairLegacyAlias: true, mode: true, riskEvidence: false, hint: false, delivery: true }),
|
|
1021
1035
|
});
|
|
1022
1036
|
|
|
1023
1037
|
export interface NativeReviewProcessDiagnostics {
|
|
@@ -1102,8 +1116,19 @@ function decodeSelectedLenses(value: unknown, riskLevel: string, lensesRequired:
|
|
|
1102
1116
|
function enumString(value: unknown, allowed: readonly string[]): string { const parsed = stringValue(value); if (!allowed.includes(parsed)) throw new Error("unsupported enum"); return parsed; }
|
|
1103
1117
|
const NATIVE_DIAGNOSTIC_TEXT_LIMIT = 4_096;
|
|
1104
1118
|
|
|
1105
|
-
function sanitizeNativeDiagnosticText(value: string, limit = NATIVE_DIAGNOSTIC_TEXT_LIMIT): string {
|
|
1106
|
-
|
|
1119
|
+
function sanitizeNativeDiagnosticText(value: string, limit = NATIVE_DIAGNOSTIC_TEXT_LIMIT, operation?: NativeReviewOperation): string {
|
|
1120
|
+
// ASSESS diagnostics are projected into a public verification plan. Retain
|
|
1121
|
+
// native guidance, not local paths or environment assignment values.
|
|
1122
|
+
const input = operation === NATIVE_REVIEW_OPERATION.ASSESS
|
|
1123
|
+
? value
|
|
1124
|
+
.replace(/(?<![\w-])[a-z_][a-z0-9_]*=(?:"[^"\r\n]*"|'[^'\r\n]*'|[^\s]+)/gi, "[REDACTED ENV]")
|
|
1125
|
+
.replace(/--(?:password|token|secret|authorization|cookie|private[_-]key|access[_-]token|[a-z0-9_-]+[_-]token|api[_-]?key)[ \t]+(?:"[^"\r\n]*"|'[^'\r\n]*'|[^\s]+)/gi, "[REDACTED CREDENTIAL]")
|
|
1126
|
+
// Quoted paths have a clear boundary. For an unquoted path, the
|
|
1127
|
+
// remaining line is ambiguous (spaces may belong to the filename).
|
|
1128
|
+
// Redact that suffix rather than leak trailing path components.
|
|
1129
|
+
.replace(/"(?:[A-Za-z]:[\\/]|\/)[^"\r\n]*"|'(?:[A-Za-z]:[\\/]|\/)[^'\r\n]*'|(?:[A-Za-z]:[\\/]|\/)[^\r\n]*/g, "[REDACTED PATH]")
|
|
1130
|
+
: value;
|
|
1131
|
+
const normalized = input
|
|
1107
1132
|
.replace(/\x1b](?:[^\x07\x1b]|\x1b(?!\\))*?(?:\x07|\x1b\\)/g, "[REDACTED CONTROL]")
|
|
1108
1133
|
.replace(/\x1b[PX^_][\s\S]*?\x1b\\/g, "[REDACTED CONTROL]")
|
|
1109
1134
|
.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "[REDACTED CONTROL]")
|
|
@@ -1137,7 +1162,7 @@ export function sanitizeForeignNativeReviewDiagnostics(value: unknown): NativeRe
|
|
|
1137
1162
|
timed_out: booleanValue(raw.timed_out),
|
|
1138
1163
|
output_limit_exceeded: booleanValue(raw.output_limit_exceeded),
|
|
1139
1164
|
...(maxBufferBytes === undefined ? {} : { max_buffer_bytes: maxBufferBytes, configuration_hint: configurationHint! }),
|
|
1140
|
-
...(raw.stderr === undefined ? {} : { stderr: sanitizeNativeDiagnosticText(stringValue(raw.stderr)) }),
|
|
1165
|
+
...(raw.stderr === undefined ? {} : { stderr: sanitizeNativeDiagnosticText(stringValue(raw.stderr), NATIVE_DIAGNOSTIC_TEXT_LIMIT, operation) }),
|
|
1141
1166
|
};
|
|
1142
1167
|
} catch { return undefined; }
|
|
1143
1168
|
}
|
|
@@ -1154,7 +1179,7 @@ function nativeProcessDiagnostics(operation: NativeReviewOperation, code: Native
|
|
|
1154
1179
|
...(code === NATIVE_REVIEW_ERROR_CODE.OUTPUT_LIMIT && maxBufferBytes !== undefined
|
|
1155
1180
|
? { max_buffer_bytes: maxBufferBytes, configuration_hint: NATIVE_REVIEW_MAX_BUFFER_CONFIGURATION_HINT }
|
|
1156
1181
|
: {}),
|
|
1157
|
-
...(result?.stderr.trim() ? { stderr: sanitizeNativeDiagnosticText(result.stderr) } : {}),
|
|
1182
|
+
...(result?.stderr.trim() ? { stderr: sanitizeNativeDiagnosticText(result.stderr, NATIVE_DIAGNOSTIC_TEXT_LIMIT, operation) } : {}),
|
|
1158
1183
|
};
|
|
1159
1184
|
}
|
|
1160
1185
|
|
|
@@ -2535,6 +2560,7 @@ export class NativeReviewCliV216 implements NativeReviewCli {
|
|
|
2535
2560
|
// rejects -- callers (the `gentle_review` tool's `assess` operation) fail
|
|
2536
2561
|
// closed to `high`.
|
|
2537
2562
|
async assess(request: NativeReviewAssessRequest): Promise<ReviewAssessmentV1> {
|
|
2563
|
+
const selection = nativeUntrackedSelection(request);
|
|
2538
2564
|
if (request.baseRef !== undefined && !isCanonicalProcessString(request.baseRef)) throw new TypeError("Native ASSESS baseRef must be a non-empty, trimmed, NUL-free string");
|
|
2539
2565
|
if (request.baseRef !== undefined && request.committedOnly !== true) throw new TypeError("Native ASSESS baseRef requires explicit committedOnly acknowledgement");
|
|
2540
2566
|
if (request.baseRef === undefined && request.committedOnly !== undefined) throw new TypeError("Native ASSESS committedOnly requires an explicit baseRef");
|
|
@@ -2542,7 +2568,7 @@ export class NativeReviewCliV216 implements NativeReviewCli {
|
|
|
2542
2568
|
const execution = await this.invoke(
|
|
2543
2569
|
NATIVE_REVIEW_OPERATION.ASSESS,
|
|
2544
2570
|
cwd,
|
|
2545
|
-
["review", "assess", "--cwd", cwd, ...(request.baseRef === undefined ? [] : ["--base-ref", request.baseRef, "--committed-only"]), "--json"],
|
|
2571
|
+
["review", "assess", "--cwd", cwd, ...(request.baseRef === undefined ? [] : ["--base-ref", request.baseRef, "--committed-only"]), ...nativeUntrackedSelectionArguments(selection), "--json"],
|
|
2546
2572
|
false,
|
|
2547
2573
|
request.signal,
|
|
2548
2574
|
this.executablePath(NATIVE_REVIEW_OPERATION.ASSESS, false),
|