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
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { DynamicBorder } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
4
|
+
import { createNativeFullscreenInteraction } from "../lib/native-fullscreen-interaction.ts";
|
|
5
|
+
import { type QuestionParams, QuestionParamsSchema } from "../lib/questionnaire/schema.ts";
|
|
6
|
+
import {
|
|
7
|
+
QuestionnaireView,
|
|
8
|
+
type AnswerRow,
|
|
9
|
+
type QuestionnaireResult,
|
|
10
|
+
} from "../lib/questionnaire/questionnaire-view.ts";
|
|
11
|
+
import { validateQuestionnaire, type QuestionnaireError } from "../lib/questionnaire/validate.ts";
|
|
12
|
+
|
|
13
|
+
const QUESTION_TOOL_NAME = "ask_user_question";
|
|
14
|
+
const ASK_USER_QUESTION_BLOCKED_EVENT = "gentle-pi:ask-user-question:blocked";
|
|
15
|
+
|
|
16
|
+
/** Maximum characters kept from a renderCall question summary. */
|
|
17
|
+
const CALL_SUMMARY_LIMIT = 120;
|
|
18
|
+
|
|
19
|
+
/** Structured details returned by the tool for UI rendering and callers. */
|
|
20
|
+
interface QuestionnaireDetails {
|
|
21
|
+
cancelled?: boolean;
|
|
22
|
+
answers?: AnswerRow[];
|
|
23
|
+
error?: QuestionnaireError;
|
|
24
|
+
errorKind?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Content plus details returned by `execute`. */
|
|
28
|
+
interface QuestionnaireToolResult {
|
|
29
|
+
content: Array<{ type: "text"; text: string }>;
|
|
30
|
+
details: QuestionnaireDetails;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Invalid-parameter result. `AgentToolResult` has no `isError` field, so this
|
|
35
|
+
* follows the repository convention for rejected tool input: a leading error
|
|
36
|
+
* sentence in `content` plus a machine-readable payload in `details`
|
|
37
|
+
* (`extensions/gentle-todo.ts` returns `Error: ...` with `details.error`).
|
|
38
|
+
*/
|
|
39
|
+
function invalidQuestionnaireResult(error: QuestionnaireError): QuestionnaireToolResult {
|
|
40
|
+
return {
|
|
41
|
+
content: [{ type: "text", text: `Invalid questionnaire: ${error.message}` }],
|
|
42
|
+
details: { error, errorKind: error.code },
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Non-interactive result; parity with ask_user_choice's TUI-only guard. */
|
|
47
|
+
function unavailableResult(): QuestionnaireToolResult {
|
|
48
|
+
return {
|
|
49
|
+
content: [{ type: "text", text: "Error: ask_user_question is unavailable outside the interactive TUI" }],
|
|
50
|
+
details: { errorKind: "unavailable_outside_tui" },
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Human-readable body for one answer. A custom answer on a multiSelect
|
|
56
|
+
* question keeps the toggled options, so the text must name them explicitly:
|
|
57
|
+
* the free-text value alone would silently drop the user's selections. Plain
|
|
58
|
+
* custom answers (no selections) stay concise.
|
|
59
|
+
*/
|
|
60
|
+
function answerBody(answer: AnswerRow): string {
|
|
61
|
+
if (answer.kind === "multi") return `selected: ${(answer.selected ?? []).join(", ")}`;
|
|
62
|
+
if (answer.kind === "custom") {
|
|
63
|
+
const body = `(custom) ${answer.answer ?? ""}`;
|
|
64
|
+
const selected = answer.selected ?? [];
|
|
65
|
+
return selected.length > 0 ? `${body} — selected: ${selected.join(", ")}` : body;
|
|
66
|
+
}
|
|
67
|
+
return answer.answer ?? "";
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Compact LLM-facing transcript of the committed answers. Each row keeps the
|
|
72
|
+
* original one-based question index so a partially answered questionnaire
|
|
73
|
+
* (the last question committed early) still reads in order.
|
|
74
|
+
*/
|
|
75
|
+
function answersText(answers: AnswerRow[]): string {
|
|
76
|
+
if (answers.length === 0) return "The user answered the questionnaire.";
|
|
77
|
+
const lines: string[] = [];
|
|
78
|
+
for (const answer of answers) {
|
|
79
|
+
const prefix = `${answer.questionIndex + 1}. ${answer.question}`;
|
|
80
|
+
lines.push(`${prefix} — ${answerBody(answer)}`);
|
|
81
|
+
if (answer.preview !== undefined) lines.push(` selected preview: ${answer.preview}`);
|
|
82
|
+
}
|
|
83
|
+
return lines.join("\n");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Single-line summary of one question for the collapsed tool call row. */
|
|
87
|
+
function callSummary(question: unknown, index: number): string {
|
|
88
|
+
const source = typeof question === "object" && question !== null ? question as { header?: unknown; options?: unknown } : {};
|
|
89
|
+
const header = typeof source.header === "string" ? source.header : "";
|
|
90
|
+
const labels = Array.isArray(source.options)
|
|
91
|
+
? source.options
|
|
92
|
+
.map((option) => (typeof option === "object" && option !== null && typeof (option as { label?: unknown }).label === "string"
|
|
93
|
+
? (option as { label: string }).label
|
|
94
|
+
: ""))
|
|
95
|
+
.filter((label) => label.length > 0)
|
|
96
|
+
: [];
|
|
97
|
+
const labelsPart = labels.length > 0 ? ` (${labels.join(", ")})` : "";
|
|
98
|
+
return `${index + 1}. ${header}${labelsPart}`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function truncate(text: string, limit: number): string {
|
|
102
|
+
return text.length <= limit ? text : `${text.slice(0, Math.max(0, limit - 1))}…`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Register the first-party questionnaire tool.
|
|
107
|
+
*
|
|
108
|
+
* Name-collision semantics (live-verified against the installed Pi runtime):
|
|
109
|
+
* - Tool names are exclusive across extensions. Pi has no precedence, override,
|
|
110
|
+
* or silent shadowing: loading two extensions that register the same tool
|
|
111
|
+
* name fails the whole load with a hard error
|
|
112
|
+
* (`Tool "ask_user_question" conflicts with <other extension>`; the runtime
|
|
113
|
+
* exits non-zero). The name is either free or fatal, full stop.
|
|
114
|
+
* - `registerTool` writes into the calling extension's own tool map keyed by
|
|
115
|
+
* name, so re-registering inside one extension overwrites that entry
|
|
116
|
+
* (`loader.js:240`). That same-name write is the only one Pi tolerates.
|
|
117
|
+
* - This first-party tool ships as THE `ask_user_question` provider. A competing
|
|
118
|
+
* provider such as the third-party `@juicesharp/rpiv-ask-user-question`
|
|
119
|
+
* package fails the load by design and must be removed from the user's Pi
|
|
120
|
+
* settings; that deletion is the documented migration path, not a runtime
|
|
121
|
+
* precedence choice.
|
|
122
|
+
*/
|
|
123
|
+
export default function askUserQuestion(pi: ExtensionAPI): void {
|
|
124
|
+
pi.registerTool({
|
|
125
|
+
name: QUESTION_TOOL_NAME,
|
|
126
|
+
renderShell: "self",
|
|
127
|
+
label: "Ask User Question",
|
|
128
|
+
description: "Ask one to four structured questions in a single call, each with two to four ordered options, and read the user's answers back in one result.",
|
|
129
|
+
promptGuidelines: [
|
|
130
|
+
"Use ask_user_question to collect decisions in one batch: ask one to four questions at a time, each with two to four options.",
|
|
131
|
+
"Keep each header a short chip of at most 16 characters and each option label at most 60 characters.",
|
|
132
|
+
"Add a preview to an option when the user needs to compare rich detail side-by-side with the options.",
|
|
133
|
+
"Set multiSelect when the choices are not mutually exclusive.",
|
|
134
|
+
"The free-text \"Type something.\" row is always available and is also how the user bails out into a normal conversation; never rely on it as a hidden escape hatch.",
|
|
135
|
+
"Never use this tool for decisions that must not be delegated to the user.",
|
|
136
|
+
],
|
|
137
|
+
parameters: QuestionParamsSchema,
|
|
138
|
+
executionMode: "sequential",
|
|
139
|
+
async execute(
|
|
140
|
+
_toolCallId: string,
|
|
141
|
+
params: QuestionParams,
|
|
142
|
+
_signal: AbortSignal | undefined,
|
|
143
|
+
_onUpdate: undefined,
|
|
144
|
+
ctx,
|
|
145
|
+
): Promise<QuestionnaireToolResult> {
|
|
146
|
+
const error = validateQuestionnaire(params);
|
|
147
|
+
if (error) return invalidQuestionnaireResult(error);
|
|
148
|
+
if (ctx.mode !== "tui") return unavailableResult();
|
|
149
|
+
|
|
150
|
+
let selection: QuestionnaireResult | undefined;
|
|
151
|
+
try {
|
|
152
|
+
pi.events.emit(ASK_USER_QUESTION_BLOCKED_EVENT, { active: true });
|
|
153
|
+
selection = await ctx.ui.custom<QuestionnaireResult>((tui, theme, keybindings, done) => {
|
|
154
|
+
const view = new QuestionnaireView({
|
|
155
|
+
questions: params.questions,
|
|
156
|
+
theme,
|
|
157
|
+
keybindings,
|
|
158
|
+
onComplete: (result) => done(result),
|
|
159
|
+
});
|
|
160
|
+
// Native dock swap, never an overlay: the transcript stays scrollable
|
|
161
|
+
// while the questionnaire owns focus. No `overlay` option is passed.
|
|
162
|
+
const container = createNativeFullscreenInteraction({
|
|
163
|
+
keyboardTarget: view,
|
|
164
|
+
requestRender: () => tui.requestRender(),
|
|
165
|
+
});
|
|
166
|
+
container.addChild(new DynamicBorder((text: string) => theme.fg("accent", text)));
|
|
167
|
+
container.addChild(view);
|
|
168
|
+
container.addChild(new DynamicBorder((text: string) => theme.fg("accent", text)));
|
|
169
|
+
return container;
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
finally {
|
|
173
|
+
pi.events.emit(ASK_USER_QUESTION_BLOCKED_EVENT, { active: false });
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (selection === undefined || selection.cancelled) {
|
|
177
|
+
return {
|
|
178
|
+
content: [{ type: "text", text: "User cancelled the questionnaire" }],
|
|
179
|
+
details: { cancelled: true },
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
return {
|
|
183
|
+
content: [{ type: "text", text: answersText(selection.answers) }],
|
|
184
|
+
details: { answers: selection.answers },
|
|
185
|
+
};
|
|
186
|
+
},
|
|
187
|
+
renderCall(args: QuestionParams, theme) {
|
|
188
|
+
const questions = Array.isArray(args.questions) ? args.questions : [];
|
|
189
|
+
const summary = truncate(questions.map((question, index) => callSummary(question, index)).join(" "), CALL_SUMMARY_LIMIT);
|
|
190
|
+
return new Text(
|
|
191
|
+
theme.fg("toolTitle", theme.bold("ask_user_question ")) +
|
|
192
|
+
theme.fg("muted", summary),
|
|
193
|
+
0,
|
|
194
|
+
0,
|
|
195
|
+
);
|
|
196
|
+
},
|
|
197
|
+
renderResult(result, _options, theme) {
|
|
198
|
+
const details = result.details as QuestionnaireDetails | undefined;
|
|
199
|
+
if (details?.cancelled === true) return new Text(theme.fg("warning", "Cancelled"), 0, 0);
|
|
200
|
+
const answers = Array.isArray(details?.answers) ? details.answers : [];
|
|
201
|
+
if (answers.length === 0) return new Text(theme.fg("warning", "No answers"), 0, 0);
|
|
202
|
+
const lines = answers.map((answer) => {
|
|
203
|
+
if (answer.kind === "multi") return theme.fg("success", `✓ ${answer.question} — ${(answer.selected ?? []).join(", ")}`);
|
|
204
|
+
if (answer.kind === "custom") return theme.fg("success", `✓ ${answer.question} — ${answerBody(answer)}`);
|
|
205
|
+
return theme.fg("success", `✓ ${answer.question} — ${answer.answer ?? ""}`);
|
|
206
|
+
});
|
|
207
|
+
return new Text(lines.join("\n"), 0, 0);
|
|
208
|
+
},
|
|
209
|
+
});
|
|
210
|
+
}
|
|
@@ -17,6 +17,7 @@ import { invalidateSidebar } from "../lib/shell-sidebar-layout.ts";
|
|
|
17
17
|
import { createCompletionQueue } from "../lib/agents-completion-delivery.ts";
|
|
18
18
|
import { AGENT_MODE, discoverAgents, parseAgentDefinition, loadAgentsConfig, resolveAgentProfile, withPinnedModelProfiles, type AgentDefinition, type AgentMode } from "../lib/agents-config.ts";
|
|
19
19
|
import { resolveBackgroundSubagentsPolicy } from "../lib/background-subagents-policy.ts";
|
|
20
|
+
import { installBackgroundCacheWarming } from "../lib/background-cache-warming.ts";
|
|
20
21
|
import { isFinished, TASK_EVENT, TASK_STATUS, TaskStore, type AskRequest, type TaskRecord } from "../lib/agents-protocol.ts";
|
|
21
22
|
import { AgentRunner, piCommand, abortReasonText, plannedCommands, type RemediationPlan, type RemediationScope, REMEDIATION_PLAN_ENV, parseRemediationPlan, type AskAnswer, type RunnerDeps, type SddChangeSelection, type TaskRequest } from "../lib/agents-runner.ts";
|
|
22
23
|
import { ChildMessenger, type IpcEndpoint } from "../lib/agents-messaging.ts";
|
|
@@ -529,6 +530,12 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
|
|
|
529
530
|
// started before /new or /resume stays in the store and comes back with
|
|
530
531
|
// its session. Before the first session_start there is nothing to scope by.
|
|
531
532
|
const activeSessionId = (): string | undefined => (sessions === undefined ? undefined : sessions.getSessionId() ?? "");
|
|
533
|
+
// Pi 0.86.1 adds this event; the package's pinned 0.85.1 types predate it.
|
|
534
|
+
installBackgroundCacheWarming(pi as unknown as Parameters<typeof installBackgroundCacheWarming>[0], () => ({
|
|
535
|
+
sessionId: activeSessionId(),
|
|
536
|
+
ownedTaskIds,
|
|
537
|
+
tasks: store.list(activeSessionId()),
|
|
538
|
+
}));
|
|
532
539
|
const visibleTasks = (): TaskRecord[] => store.list(activeSessionId());
|
|
533
540
|
type SessionTransport = { generation: number; sessionId: string; sessionManager: ExtensionContext["sessionManager"]; client: SessionTransportClient; listener: SessionTransportListener; registry: SessionTransportRegistry; close(): Promise<void> };
|
|
534
541
|
let transportGeneration = 0;
|
|
@@ -1168,7 +1175,7 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
|
|
|
1168
1175
|
if (observe) metricTasks.set(task.id, metrics);
|
|
1169
1176
|
ownedTaskIds.add(task.id);
|
|
1170
1177
|
store.subscribe(task.id, () => { publishActivity(); requestRender(); });
|
|
1171
|
-
if (request.mode === AGENT_MODE.BACKGROUND) return text(`Started ${task.agent} in the background as task ${task.id}.
|
|
1178
|
+
if (request.mode === AGENT_MODE.BACKGROUND) return text(`Started ${task.agent} in the background as task ${task.id}. Retain that id; completion is pushed automatically. Never sleep or periodically poll subagent_status/subagent_result for completion or cache maintenance. Inspect status only at a real orchestration decision boundary; never relaunch equivalent queued/running work.`, taskDetails(task));
|
|
1172
1179
|
// A tool call aborted by the host (a human interrupting the turn, a timeout)
|
|
1173
1180
|
// would otherwise leave the child running and end the call with no result and
|
|
1174
1181
|
// no recorded reason. Cancel through the runner so the lifecycle runs and the
|
package/extensions/gentle-ai.ts
CHANGED
|
@@ -199,6 +199,7 @@ import {
|
|
|
199
199
|
nativeReviewRecoverAuthorization,
|
|
200
200
|
normalizeNativeReviewCwd,
|
|
201
201
|
NativeReviewCliError,
|
|
202
|
+
nativeUntrackedSelection,
|
|
202
203
|
NativeReviewConsentBindingError,
|
|
203
204
|
NativeReviewConsentRequiredError,
|
|
204
205
|
NativeReviewIntegrationError,
|
|
@@ -1023,18 +1024,26 @@ async function resolveReviewAssessmentPlan(
|
|
|
1023
1024
|
|
|
1024
1025
|
let assessment: ReviewAssessmentV1 | undefined;
|
|
1025
1026
|
let unassessableDetail: string | undefined;
|
|
1027
|
+
let unassessableCode = "native-assess-unavailable";
|
|
1026
1028
|
if (nativeReviewCli?.assess === undefined) {
|
|
1027
1029
|
unassessableDetail = "native review assess is unavailable: the installed gentle-ai binary does not expose the assess command.";
|
|
1028
1030
|
} else {
|
|
1029
1031
|
try {
|
|
1030
1032
|
const request: NativeReviewAssessRequest = {
|
|
1031
1033
|
cwd,
|
|
1034
|
+
...nativeUntrackedSelection(input),
|
|
1032
1035
|
...(input.baseRef === undefined ? {} : { baseRef: input.baseRef, committedOnly: true as const }),
|
|
1033
1036
|
...(signal === undefined ? {} : { signal }),
|
|
1034
1037
|
};
|
|
1035
1038
|
assessment = await nativeReviewCli.assess(request);
|
|
1036
1039
|
} catch (error) {
|
|
1037
|
-
|
|
1040
|
+
const nativeError = asNativeReviewCliError(error);
|
|
1041
|
+
unassessableCode = nativeError?.code ?? unassessableCode;
|
|
1042
|
+
// Only the sanitized process surface may supply native evidence.
|
|
1043
|
+
// Arbitrary thrown messages can contain argv or environment values.
|
|
1044
|
+
unassessableDetail = nativeError?.diagnostics.stderr
|
|
1045
|
+
? `native review assess failed: ${nativeError.diagnostics.stderr}`
|
|
1046
|
+
: "native review assess failed; no sanitized stderr diagnostic is available.";
|
|
1038
1047
|
}
|
|
1039
1048
|
}
|
|
1040
1049
|
|
|
@@ -1050,7 +1059,7 @@ async function resolveReviewAssessmentPlan(
|
|
|
1050
1059
|
return {
|
|
1051
1060
|
schema: "gentle-pi.review-assessment-plan/v1",
|
|
1052
1061
|
risk,
|
|
1053
|
-
reasons: assessment?.reasons ?? (unassessableDetail === undefined ? [] : [{ code:
|
|
1062
|
+
reasons: assessment?.reasons ?? (unassessableDetail === undefined ? [] : [{ code: unassessableCode, path: "", detail: unassessableDetail }]),
|
|
1054
1063
|
changedPaths: assessment?.changedPaths ?? 0,
|
|
1055
1064
|
changedLines: assessment?.changedLines ?? 0,
|
|
1056
1065
|
candidate: assessment === undefined ? null : { kind: assessment.candidate.kind, baseRef: assessment.candidate.baseRef },
|
|
@@ -4826,7 +4835,7 @@ interface ReviewScopeParameters {
|
|
|
4826
4835
|
// as `gentle_review` operation `assess` (not a dedicated tool), taking its
|
|
4827
4836
|
// optional fields through the controller's existing generic `input` JSON
|
|
4828
4837
|
// string, exactly like START's `{"mode":...,"baseRef":...}`.
|
|
4829
|
-
interface ReviewAssessInput {
|
|
4838
|
+
interface ReviewAssessInput extends Pick<NativeReviewAssessRequest, "untrackedScope" | "expectedUntrackedInventory" | "intendedUntracked"> {
|
|
4830
4839
|
baseRef?: string;
|
|
4831
4840
|
committedOnly?: boolean;
|
|
4832
4841
|
writerModelId?: string;
|
|
@@ -4845,7 +4854,7 @@ function isNativeReviewOutcome(value: unknown): value is NativeReviewOutcome {
|
|
|
4845
4854
|
function parseReviewAssessInput(operation: ReviewControllerOperation, raw: string | undefined): ReviewAssessInput {
|
|
4846
4855
|
if (raw === undefined) return {};
|
|
4847
4856
|
const value = parseControllerJson(raw, operation);
|
|
4848
|
-
const allowed = new Set(["baseRef", "committedOnly", "writerModelId", "writerEffort", "nativeReviewOutcome"]);
|
|
4857
|
+
const allowed = new Set(["baseRef", "committedOnly", "writerModelId", "writerEffort", "nativeReviewOutcome", "untrackedScope", "expectedUntrackedInventory", "intendedUntracked"]);
|
|
4849
4858
|
const unexpected = Object.keys(value).find((key) => !allowed.has(key));
|
|
4850
4859
|
if (unexpected !== undefined) throw new Error(`Review controller ${operation} input does not accept ${unexpected}`);
|
|
4851
4860
|
const { baseRef, committedOnly, writerModelId, writerEffort, nativeReviewOutcome } = value;
|
|
@@ -4859,6 +4868,7 @@ function parseReviewAssessInput(operation: ReviewControllerOperation, raw: strin
|
|
|
4859
4868
|
return {
|
|
4860
4869
|
...(baseRef === undefined ? {} : { baseRef: baseRef as string }),
|
|
4861
4870
|
...(committedOnly === undefined ? {} : { committedOnly: committedOnly as boolean }),
|
|
4871
|
+
...nativeUntrackedSelection(value),
|
|
4862
4872
|
...(writerModelId === undefined ? {} : { writerModelId: writerModelId as string }),
|
|
4863
4873
|
...(writerEffort === undefined ? {} : { writerEffort: writerEffort as string }),
|
|
4864
4874
|
...(nativeReviewOutcome === undefined ? {} : { nativeReviewOutcome: nativeReviewOutcome as NativeReviewOutcome }),
|
|
@@ -6754,6 +6764,11 @@ async function executeReviewHostRelayCapture(
|
|
|
6754
6764
|
route: RetainedNativeCaptureRoute | undefined,
|
|
6755
6765
|
signal?: AbortSignal,
|
|
6756
6766
|
modelRegistry?: InProcessReviewerRegistry,
|
|
6767
|
+
// The caller's live session id: forwarded into the relay request so an
|
|
6768
|
+
// OpenCode-routed reviewer completion carries its x-opencode-session
|
|
6769
|
+
// attribution header. Appended last so every existing positional call site
|
|
6770
|
+
// keeps compiling unchanged.
|
|
6771
|
+
reviewerSessionId?: string,
|
|
6757
6772
|
): Promise<Record<string, unknown>> {
|
|
6758
6773
|
try {
|
|
6759
6774
|
if (slot.submission === undefined) {
|
|
@@ -6777,6 +6792,7 @@ async function executeReviewHostRelayCapture(
|
|
|
6777
6792
|
submission: slot.submission,
|
|
6778
6793
|
...launch,
|
|
6779
6794
|
...(modelRegistry === undefined ? {} : { reviewerRegistry: modelRegistry }),
|
|
6795
|
+
...(reviewerSessionId === undefined ? {} : { reviewerSessionId }),
|
|
6780
6796
|
...(signal === undefined ? {} : { signal }),
|
|
6781
6797
|
};
|
|
6782
6798
|
})());
|
|
@@ -7379,6 +7395,11 @@ async function executeReviewCaptureOperation(
|
|
|
7379
7395
|
// inserted) so every existing positional call site — none of which pass an
|
|
7380
7396
|
// eighth argument — keeps compiling unchanged.
|
|
7381
7397
|
modelRegistry?: InProcessReviewerRegistry,
|
|
7398
|
+
// The caller's live session id, threaded into every relay request this
|
|
7399
|
+
// capture launches so an OpenCode-routed reviewer model carries its
|
|
7400
|
+
// x-opencode-session attribution header. Appended last for the same
|
|
7401
|
+
// positional-call-site reason as modelRegistry above.
|
|
7402
|
+
reviewerSessionId?: string,
|
|
7382
7403
|
): Promise<Record<string, unknown>> {
|
|
7383
7404
|
const parameters = parseReviewCaptureParameters(parametersValue);
|
|
7384
7405
|
if (nativeReviewCli === null || nativeReviewCli.targetStatus === undefined) {
|
|
@@ -7438,7 +7459,7 @@ async function executeReviewCaptureOperation(
|
|
|
7438
7459
|
mutation_outcome: "none",
|
|
7439
7460
|
};
|
|
7440
7461
|
}
|
|
7441
|
-
return withCorrectionTarget(await executeReviewHostRelayCapture(hostRelaySlots[0]!, nativeReviewCli, cwd, selected.binding, retainedUntrackedSelections, route, signal, modelRegistry));
|
|
7462
|
+
return withCorrectionTarget(await executeReviewHostRelayCapture(hostRelaySlots[0]!, nativeReviewCli, cwd, selected.binding, retainedUntrackedSelections, route, signal, modelRegistry, reviewerSessionId));
|
|
7442
7463
|
}
|
|
7443
7464
|
|
|
7444
7465
|
// gentle-pi#311 P3: gentle-ai's v9 contract renders the refuter and
|
|
@@ -7464,7 +7485,7 @@ async function executeReviewCaptureOperation(
|
|
|
7464
7485
|
mutation_outcome: "none",
|
|
7465
7486
|
};
|
|
7466
7487
|
}
|
|
7467
|
-
return withCorrectionTarget(await executeReviewHostRelayCapture(hostMediatedRoleSlots[0]!, nativeReviewCli, cwd, selected.binding, retainedUntrackedSelections, route, signal, modelRegistry));
|
|
7488
|
+
return withCorrectionTarget(await executeReviewHostRelayCapture(hostMediatedRoleSlots[0]!, nativeReviewCli, cwd, selected.binding, retainedUntrackedSelections, route, signal, modelRegistry, reviewerSessionId));
|
|
7468
7489
|
}
|
|
7469
7490
|
|
|
7470
7491
|
if (selected.input.captureOperation === "review.capture-correction-plan") {
|
|
@@ -7545,6 +7566,10 @@ async function executeReviewCaptureGroupOperation(
|
|
|
7545
7566
|
requireRegisteredRoute = false,
|
|
7546
7567
|
// gentle-pi#311 P2: see executeReviewCaptureOperation's matching parameter.
|
|
7547
7568
|
modelRegistry?: InProcessReviewerRegistry,
|
|
7569
|
+
// The caller's live session id, set on every grouped relay request so an
|
|
7570
|
+
// OpenCode-routed reviewer model carries its x-opencode-session attribution
|
|
7571
|
+
// header. Appended last for the same positional-call-site reason as above.
|
|
7572
|
+
reviewerSessionId?: string,
|
|
7548
7573
|
): Promise<Record<string, unknown>> {
|
|
7549
7574
|
const parameters = parseReviewCaptureGroupParameters(parametersValue);
|
|
7550
7575
|
if (nativeReviewCli === null || nativeReviewCli.targetStatus === undefined) return { ...captureGroupRejected("native target STATUS is unavailable"), outcome: "native-status-unsupported" };
|
|
@@ -7587,6 +7612,7 @@ async function executeReviewCaptureGroupOperation(
|
|
|
7587
7612
|
submission: slot.submission!,
|
|
7588
7613
|
...reviewHostRelaySelection(slot.lens, readModelConfig(cwd)),
|
|
7589
7614
|
...(modelRegistry === undefined ? {} : { reviewerRegistry: modelRegistry }),
|
|
7615
|
+
...(reviewerSessionId === undefined ? {} : { reviewerSessionId }),
|
|
7590
7616
|
...(signal === undefined ? {} : { signal }),
|
|
7591
7617
|
}));
|
|
7592
7618
|
let prepared: readonly ReviewHostRelayPreparedResult[];
|
|
@@ -8317,11 +8343,13 @@ async function executeReviewControllerOperation(
|
|
|
8317
8343
|
// native START are resolved, and re-derive the target for that range,
|
|
8318
8344
|
// so all three agree on one base-diff identity. Adopting the offer
|
|
8319
8345
|
// later left the workspace target and the base-diff candidate view
|
|
8320
|
-
// disagreeing, and START failed with identity-mismatch.
|
|
8321
|
-
//
|
|
8322
|
-
// play
|
|
8323
|
-
//
|
|
8324
|
-
|
|
8346
|
+
// disagreeing, and START failed with identity-mismatch. An explicit
|
|
8347
|
+
// caller baseRef still wins (it already is the adopted range), but an
|
|
8348
|
+
// in-play untracked selection now also pays this second read-only
|
|
8349
|
+
// STATUS: the renegotiated target is a base-diff projection, so the
|
|
8350
|
+
// candidate view must be materialized WITH the offered base instead of
|
|
8351
|
+
// the base-less view that tripped candidate-target-projection-drift.
|
|
8352
|
+
if (canonicalBaseRef === undefined) {
|
|
8325
8353
|
const offeredBaseRef = offeredCommittedRangeBaseRef(target);
|
|
8326
8354
|
if (offeredBaseRef !== undefined) {
|
|
8327
8355
|
const renegotiated = await negotiatedStatusForHostTransport(nativeReviewCli, {
|
|
@@ -8329,6 +8357,8 @@ async function executeReviewControllerOperation(
|
|
|
8329
8357
|
...(parameters.lineageId === undefined ? {} : { lineageId: parameters.lineageId }),
|
|
8330
8358
|
baseRef: offeredBaseRef,
|
|
8331
8359
|
committedOnly: true,
|
|
8360
|
+
...(untrackedSelection.untrackedScope === undefined ? {} : untrackedSelection),
|
|
8361
|
+
...(untrackedSubmission === undefined ? {} : { intendedUntrackedSelection: untrackedSubmission }),
|
|
8332
8362
|
...(signal === undefined ? {} : { signal }),
|
|
8333
8363
|
}, retainedUntrackedSelections, defaultCwd);
|
|
8334
8364
|
if (renegotiated.transport !== undefined) return hostTransportUnavailable(parameters.operation, renegotiated.transport);
|
|
@@ -8375,11 +8405,7 @@ async function executeReviewControllerOperation(
|
|
|
8375
8405
|
let nativeStartAttempted = false;
|
|
8376
8406
|
try {
|
|
8377
8407
|
const candidateRequest = { contributorRoot: defaultCwd, replayKey, ...(canonicalBaseRef === undefined ? {} : { baseRef: canonicalBaseRef, committedOnly: true }) };
|
|
8378
|
-
candidateView = candidateViews?.createOrReuse({ ...candidateRequest,
|
|
8379
|
-
if (candidateView !== undefined && candidateIntendedUntracked.length === 0 && candidateView.candidateTree !== target.projection.currentCandidateTree) {
|
|
8380
|
-
candidateView.cleanup();
|
|
8381
|
-
candidateView = candidateViews?.createOrReuse({ ...candidateRequest, intendedUntracked: [] });
|
|
8382
|
-
}
|
|
8408
|
+
candidateView = candidateViews?.createOrReuse({ ...candidateRequest, intendedUntracked: candidateIntendedUntracked });
|
|
8383
8409
|
if (candidateView !== undefined) assertNativeStartCandidateBinding(candidateView, target);
|
|
8384
8410
|
let result: NativeStartResult;
|
|
8385
8411
|
try {
|
|
@@ -8860,6 +8886,10 @@ function createGentleAiExtensionForTesting(
|
|
|
8860
8886
|
((sessionKey: PendingReviewConsentSessionKey) => processRetainedNativeStatusSelections.get(sessionKey) ?? processRetainedNativeStatusSelections.set(sessionKey, new Map()).get(sessionKey)!)(pendingReviewConsentSessionKey(ctx, pendingReviewConsentFallbackKey)),
|
|
8861
8887
|
true,
|
|
8862
8888
|
ctx.modelRegistry,
|
|
8889
|
+
// The live session id rides into the reviewer side-call so an
|
|
8890
|
+
// OpenCode-routed completion carries its attribution headers
|
|
8891
|
+
// (pi adds those inside the main agent loop; this is not that loop).
|
|
8892
|
+
reviewSessionManagerAndId(ctx)?.sessionId,
|
|
8863
8893
|
);
|
|
8864
8894
|
return { content: [{ type: "text", text: JSON.stringify(details) }], details };
|
|
8865
8895
|
},
|
|
@@ -8899,6 +8929,10 @@ function createGentleAiExtensionForTesting(
|
|
|
8899
8929
|
((sessionKey: PendingReviewConsentSessionKey) => processRetainedNativeStatusSelections.get(sessionKey) ?? processRetainedNativeStatusSelections.set(sessionKey, new Map()).get(sessionKey)!)(pendingReviewConsentSessionKey(ctx, pendingReviewConsentFallbackKey)),
|
|
8900
8930
|
true,
|
|
8901
8931
|
ctx.modelRegistry,
|
|
8932
|
+
// The live session id rides into the reviewer side-call so an
|
|
8933
|
+
// OpenCode-routed completion carries its attribution headers
|
|
8934
|
+
// (pi adds those inside the main agent loop; this is not that loop).
|
|
8935
|
+
reviewSessionManagerAndId(ctx)?.sessionId,
|
|
8902
8936
|
);
|
|
8903
8937
|
return {
|
|
8904
8938
|
content: [{ type: "text", text: JSON.stringify(details) }],
|
|
@@ -9696,9 +9730,16 @@ function createGentleAiExtensionForTesting(
|
|
|
9696
9730
|
// background subagents may be launched at all, so nothing in Pi may write
|
|
9697
9731
|
// it. The only writer is this handler, reached only by explicit invocation.
|
|
9698
9732
|
pi.registerCommand("gentle:background-subagents", {
|
|
9699
|
-
description: "Show or set the managed background-subagents policy (status|enable|disable). Every sub-action is user-initiated only; Pi automation never toggles it.",
|
|
9733
|
+
description: "Show or set the managed background-subagents policy; no argument opens a selectable menu (status|enable|disable). Every sub-action is user-initiated only; Pi automation never toggles it.",
|
|
9734
|
+
// No argument opens a selectable menu when an interactive UI is present;
|
|
9735
|
+
// headless callers and fakes without ui.select keep the status fallback.
|
|
9700
9736
|
handler: async (args, ctx) => {
|
|
9701
|
-
|
|
9737
|
+
let subAction = args.trim().length === 0 ? "status" : args.trim();
|
|
9738
|
+
if (args.trim().length === 0 && ctx.hasUI && typeof ctx.ui.select === "function") {
|
|
9739
|
+
const selected = await ctx.ui.select("Background subagents policy", ["status", "enable", "disable"]);
|
|
9740
|
+
if (selected === undefined) return;
|
|
9741
|
+
subAction = selected;
|
|
9742
|
+
}
|
|
9702
9743
|
if (subAction !== "status" && subAction !== "enable" && subAction !== "disable") {
|
|
9703
9744
|
ctx.ui.notify(`Unknown /gentle:background-subagents sub-action "${subAction}". Use status, enable, or disable.`, "warning");
|
|
9704
9745
|
return;
|