gentle-pi 3.3.0 → 3.5.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 +88 -59
- package/assets/orchestrator-delegation.md +1 -1
- package/bin/gentle-shell.mjs +198 -0
- 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-agents-activity.md +95 -0
- package/docs/gentle-shell.md +26 -2
- package/docs/readme-reference.md +99 -6
- package/extensions/ask-user-choice.ts +70 -22
- package/extensions/ask-user-question.ts +338 -0
- package/extensions/gentle-agents.ts +41 -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/agents-rpc-publisher.ts +342 -0
- package/lib/agents-runner.ts +7 -2
- 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/gentle-shell-launcher.ts +482 -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/rpc-host.ts +36 -0
- 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 +5 -1
- package/runtime/gentle-shell-launcher.mjs +483 -0
- package/runtime/native-review-cli.mjs +35 -9
- package/runtime/review-integration-v2.mjs +4 -1
- package/scripts/build-runtime-modules.mjs +1 -0
- package/scripts/gentle-ai-installer.mjs +10 -10
- package/scripts/install-gentle-ai.mjs +14 -7
- package/scripts/install-tui-mode-setting.mjs +78 -1
- package/scripts/verify-package-files.mjs +6 -3
- package/tests/agents-rpc-publisher.test.ts +407 -0
- package/tests/agents-runner.test.ts +10 -0
- package/tests/animation-policy.test.ts +42 -0
- package/tests/ask-user-choice.test.ts +129 -0
- package/tests/ask-user-question.test.ts +661 -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 +161 -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-bin.test.ts +188 -0
- package/tests/gentle-shell-launcher.test.ts +718 -0
- package/tests/gentle-shell.test.ts +355 -2
- package/tests/inprocess-reviewer.test.ts +92 -0
- package/tests/install-tui-mode-guard.test.ts +99 -0
- package/tests/install-tui-mode-setting.test.ts +39 -1
- 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/rpc-host.test.ts +77 -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,338 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext } 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 QuestionData, 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
|
+
import { isInteractiveRpcHost } from "../lib/rpc-host.ts";
|
|
13
|
+
|
|
14
|
+
const QUESTION_TOOL_NAME = "ask_user_question";
|
|
15
|
+
const ASK_USER_QUESTION_BLOCKED_EVENT = "gentle-pi:ask-user-question:blocked";
|
|
16
|
+
|
|
17
|
+
/** Maximum characters kept from a renderCall question summary. */
|
|
18
|
+
const CALL_SUMMARY_LIMIT = 120;
|
|
19
|
+
|
|
20
|
+
/** Structured details returned by the tool for UI rendering and callers. */
|
|
21
|
+
interface QuestionnaireDetails {
|
|
22
|
+
cancelled?: boolean;
|
|
23
|
+
answers?: AnswerRow[];
|
|
24
|
+
error?: QuestionnaireError;
|
|
25
|
+
errorKind?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Content plus details returned by `execute`. */
|
|
29
|
+
interface QuestionnaireToolResult {
|
|
30
|
+
content: Array<{ type: "text"; text: string }>;
|
|
31
|
+
details: QuestionnaireDetails;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Invalid-parameter result. `AgentToolResult` has no `isError` field, so this
|
|
36
|
+
* follows the repository convention for rejected tool input: a leading error
|
|
37
|
+
* sentence in `content` plus a machine-readable payload in `details`
|
|
38
|
+
* (`extensions/gentle-todo.ts` returns `Error: ...` with `details.error`).
|
|
39
|
+
*/
|
|
40
|
+
function invalidQuestionnaireResult(error: QuestionnaireError): QuestionnaireToolResult {
|
|
41
|
+
return {
|
|
42
|
+
content: [{ type: "text", text: `Invalid questionnaire: ${error.message}` }],
|
|
43
|
+
details: { error, errorKind: error.code },
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Non-interactive result; parity with ask_user_choice's TUI-only guard. */
|
|
48
|
+
function unavailableResult(): QuestionnaireToolResult {
|
|
49
|
+
return {
|
|
50
|
+
content: [{ type: "text", text: "Error: ask_user_question is unavailable outside the interactive TUI" }],
|
|
51
|
+
details: { errorKind: "unavailable_outside_tui" },
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Same cancellation shape the TUI questionnaire returns for its Escape key. */
|
|
56
|
+
function cancelledResult(): QuestionnaireToolResult {
|
|
57
|
+
return {
|
|
58
|
+
content: [{ type: "text", text: "User cancelled the questionnaire" }],
|
|
59
|
+
details: { cancelled: true },
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Label for the trailing "finish this question" entry in a multiSelect round. */
|
|
64
|
+
const MULTI_SELECT_DONE_LABEL = "Done";
|
|
65
|
+
|
|
66
|
+
/** One toggle round's select prompt: `[x] label` / `[ ] label` plus Done. */
|
|
67
|
+
function multiSelectRoundOptions(question: QuestionData, toggled: readonly boolean[]): string[] {
|
|
68
|
+
return [
|
|
69
|
+
...question.options.map((choiceOption, index) => `${toggled[index] ? "[x]" : "[ ]"} ${choiceOption.label}`),
|
|
70
|
+
MULTI_SELECT_DONE_LABEL,
|
|
71
|
+
];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Floor for the toggle-round safety cap so a misbehaving or chatty host
|
|
76
|
+
* cannot spin this loop forever, and every question -- however few options
|
|
77
|
+
* it has -- still gets room to toggle, untoggle, and retoggle before Done.
|
|
78
|
+
*/
|
|
79
|
+
const MULTI_SELECT_ROUND_CAP_FLOOR = 32;
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Hard bound on toggle rounds for one question: every option must be
|
|
83
|
+
* toggleable at least once, plus one round for the explicit Done and one
|
|
84
|
+
* spare round for a single correction (an un-toggle), so the cap scales
|
|
85
|
+
* with the option count (`options.length + 2`), floored at
|
|
86
|
+
* `MULTI_SELECT_ROUND_CAP_FLOOR` for small questions.
|
|
87
|
+
*/
|
|
88
|
+
function multiSelectRoundCap(optionsLength: number): number {
|
|
89
|
+
return Math.max(MULTI_SELECT_ROUND_CAP_FLOOR, optionsLength + 2);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Committed multiSelect answer from the current toggle state (explicit Done, or every option toggled on). */
|
|
93
|
+
function finishMultiSelect(question: QuestionData, toggled: readonly boolean[]): AnswerRow {
|
|
94
|
+
return {
|
|
95
|
+
questionIndex: -1, // overwritten by the caller with the question's position
|
|
96
|
+
question: question.question,
|
|
97
|
+
kind: "multi",
|
|
98
|
+
answer: null,
|
|
99
|
+
selected: question.options.filter((_choiceOption, index) => toggled[index]).map((choiceOption) => choiceOption.label),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Resolves one multiSelect question by looping `ctx.ui.select` over toggle
|
|
105
|
+
* rounds, bounded by `multiSelectRoundCap`. Returns `undefined` on
|
|
106
|
+
* cancellation, when the cap is hit without an explicit Done, and when the
|
|
107
|
+
* host returns an answer that matches none of the current round's options:
|
|
108
|
+
* a partial toggle state must never commit silently in either case.
|
|
109
|
+
*/
|
|
110
|
+
export async function askMultiSelect(
|
|
111
|
+
ctx: Pick<ExtensionContext, "ui">,
|
|
112
|
+
question: QuestionData,
|
|
113
|
+
): Promise<AnswerRow | undefined> {
|
|
114
|
+
const toggled = question.options.map(() => false);
|
|
115
|
+
const roundCap = multiSelectRoundCap(question.options.length);
|
|
116
|
+
for (let round = 0; round < roundCap; round++) {
|
|
117
|
+
const roundOptions = multiSelectRoundOptions(question, toggled);
|
|
118
|
+
const picked = await ctx.ui.select(`${question.header}: ${question.question}`, roundOptions);
|
|
119
|
+
if (picked === undefined) return undefined;
|
|
120
|
+
const pickedIndex = roundOptions.indexOf(picked);
|
|
121
|
+
if (pickedIndex === question.options.length) return finishMultiSelect(question, toggled); // explicit Done
|
|
122
|
+
if (pickedIndex === -1) return undefined; // unrecognised answer: never commit a partial state
|
|
123
|
+
toggled[pickedIndex] = !toggled[pickedIndex];
|
|
124
|
+
if (toggled.every(Boolean)) return finishMultiSelect(question, toggled);
|
|
125
|
+
}
|
|
126
|
+
// The safety cap was spent without an explicit Done: refuse to commit a
|
|
127
|
+
// partial selection silently and cancel instead.
|
|
128
|
+
return undefined;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Resolves one single-select question through `ctx.ui.select`. Returns `undefined` on cancellation. */
|
|
132
|
+
async function askSingleSelect(
|
|
133
|
+
ctx: Pick<ExtensionContext, "ui">,
|
|
134
|
+
question: QuestionData,
|
|
135
|
+
): Promise<AnswerRow | undefined> {
|
|
136
|
+
const labels = question.options.map((choiceOption) => choiceOption.label);
|
|
137
|
+
const picked = await ctx.ui.select(`${question.header}: ${question.question}`, labels);
|
|
138
|
+
if (picked === undefined) return undefined;
|
|
139
|
+
const chosen = question.options.find((choiceOption) => choiceOption.label === picked);
|
|
140
|
+
return {
|
|
141
|
+
questionIndex: -1, // overwritten by the caller with the question's position
|
|
142
|
+
question: question.question,
|
|
143
|
+
kind: "option",
|
|
144
|
+
answer: picked,
|
|
145
|
+
...(chosen?.preview !== undefined ? { preview: chosen.preview } : {}),
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Interactive-RPC-host fallback for the TUI questionnaire: one
|
|
151
|
+
* `ctx.ui.select` dialog per question (looped for multiSelect), keeping the
|
|
152
|
+
* exact TUI result shapes. A cancel on any question cancels the whole
|
|
153
|
+
* questionnaire, matching the TUI Escape key. There is no schema-declared
|
|
154
|
+
* free-text option (`lib/questionnaire/schema.ts` has none), so the
|
|
155
|
+
* always-available "Type something." row has no RPC-dialog equivalent here.
|
|
156
|
+
*/
|
|
157
|
+
async function askThroughDialogs(
|
|
158
|
+
ctx: Pick<ExtensionContext, "ui">,
|
|
159
|
+
params: QuestionParams,
|
|
160
|
+
): Promise<QuestionnaireToolResult> {
|
|
161
|
+
const answers: AnswerRow[] = [];
|
|
162
|
+
for (let questionIndex = 0; questionIndex < params.questions.length; questionIndex++) {
|
|
163
|
+
const question = params.questions[questionIndex]!;
|
|
164
|
+
const answer = question.multiSelect
|
|
165
|
+
? await askMultiSelect(ctx, question)
|
|
166
|
+
: await askSingleSelect(ctx, question);
|
|
167
|
+
if (answer === undefined) return cancelledResult();
|
|
168
|
+
answers.push({ ...answer, questionIndex });
|
|
169
|
+
}
|
|
170
|
+
return { content: [{ type: "text", text: answersText(answers) }], details: { answers } };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Human-readable body for one answer. A custom answer on a multiSelect
|
|
175
|
+
* question keeps the toggled options, so the text must name them explicitly:
|
|
176
|
+
* the free-text value alone would silently drop the user's selections. Plain
|
|
177
|
+
* custom answers (no selections) stay concise.
|
|
178
|
+
*/
|
|
179
|
+
function answerBody(answer: AnswerRow): string {
|
|
180
|
+
if (answer.kind === "multi") return `selected: ${(answer.selected ?? []).join(", ")}`;
|
|
181
|
+
if (answer.kind === "custom") {
|
|
182
|
+
const body = `(custom) ${answer.answer ?? ""}`;
|
|
183
|
+
const selected = answer.selected ?? [];
|
|
184
|
+
return selected.length > 0 ? `${body} — selected: ${selected.join(", ")}` : body;
|
|
185
|
+
}
|
|
186
|
+
return answer.answer ?? "";
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Compact LLM-facing transcript of the committed answers. Each row keeps the
|
|
191
|
+
* original one-based question index so a partially answered questionnaire
|
|
192
|
+
* (the last question committed early) still reads in order.
|
|
193
|
+
*/
|
|
194
|
+
function answersText(answers: AnswerRow[]): string {
|
|
195
|
+
if (answers.length === 0) return "The user answered the questionnaire.";
|
|
196
|
+
const lines: string[] = [];
|
|
197
|
+
for (const answer of answers) {
|
|
198
|
+
const prefix = `${answer.questionIndex + 1}. ${answer.question}`;
|
|
199
|
+
lines.push(`${prefix} — ${answerBody(answer)}`);
|
|
200
|
+
if (answer.preview !== undefined) lines.push(` selected preview: ${answer.preview}`);
|
|
201
|
+
}
|
|
202
|
+
return lines.join("\n");
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Single-line summary of one question for the collapsed tool call row. */
|
|
206
|
+
function callSummary(question: unknown, index: number): string {
|
|
207
|
+
const source = typeof question === "object" && question !== null ? question as { header?: unknown; options?: unknown } : {};
|
|
208
|
+
const header = typeof source.header === "string" ? source.header : "";
|
|
209
|
+
const labels = Array.isArray(source.options)
|
|
210
|
+
? source.options
|
|
211
|
+
.map((option) => (typeof option === "object" && option !== null && typeof (option as { label?: unknown }).label === "string"
|
|
212
|
+
? (option as { label: string }).label
|
|
213
|
+
: ""))
|
|
214
|
+
.filter((label) => label.length > 0)
|
|
215
|
+
: [];
|
|
216
|
+
const labelsPart = labels.length > 0 ? ` (${labels.join(", ")})` : "";
|
|
217
|
+
return `${index + 1}. ${header}${labelsPart}`;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function truncate(text: string, limit: number): string {
|
|
221
|
+
return text.length <= limit ? text : `${text.slice(0, Math.max(0, limit - 1))}…`;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Register the first-party questionnaire tool.
|
|
226
|
+
*
|
|
227
|
+
* Name-collision semantics (live-verified against the installed Pi runtime):
|
|
228
|
+
* - Tool names are exclusive across extensions. Pi has no precedence, override,
|
|
229
|
+
* or silent shadowing: loading two extensions that register the same tool
|
|
230
|
+
* name fails the whole load with a hard error
|
|
231
|
+
* (`Tool "ask_user_question" conflicts with <other extension>`; the runtime
|
|
232
|
+
* exits non-zero). The name is either free or fatal, full stop.
|
|
233
|
+
* - `registerTool` writes into the calling extension's own tool map keyed by
|
|
234
|
+
* name, so re-registering inside one extension overwrites that entry
|
|
235
|
+
* (`loader.js:240`). That same-name write is the only one Pi tolerates.
|
|
236
|
+
* - This first-party tool ships as THE `ask_user_question` provider. A competing
|
|
237
|
+
* provider such as the third-party `@juicesharp/rpiv-ask-user-question`
|
|
238
|
+
* package fails the load by design and must be removed from the user's Pi
|
|
239
|
+
* settings; that deletion is the documented migration path, not a runtime
|
|
240
|
+
* precedence choice.
|
|
241
|
+
*/
|
|
242
|
+
export default function askUserQuestion(pi: ExtensionAPI): void {
|
|
243
|
+
pi.registerTool({
|
|
244
|
+
name: QUESTION_TOOL_NAME,
|
|
245
|
+
renderShell: "self",
|
|
246
|
+
label: "Ask User Question",
|
|
247
|
+
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.",
|
|
248
|
+
promptGuidelines: [
|
|
249
|
+
"Use ask_user_question to collect decisions in one batch: ask one to four questions at a time, each with two to four options.",
|
|
250
|
+
"Keep each header a short chip of at most 16 characters and each option label at most 60 characters.",
|
|
251
|
+
"Add a preview to an option when the user needs to compare rich detail side-by-side with the options.",
|
|
252
|
+
"Set multiSelect when the choices are not mutually exclusive.",
|
|
253
|
+
"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.",
|
|
254
|
+
"Never use this tool for decisions that must not be delegated to the user.",
|
|
255
|
+
],
|
|
256
|
+
parameters: QuestionParamsSchema,
|
|
257
|
+
executionMode: "sequential",
|
|
258
|
+
async execute(
|
|
259
|
+
_toolCallId: string,
|
|
260
|
+
params: QuestionParams,
|
|
261
|
+
_signal: AbortSignal | undefined,
|
|
262
|
+
_onUpdate: undefined,
|
|
263
|
+
ctx,
|
|
264
|
+
): Promise<QuestionnaireToolResult> {
|
|
265
|
+
const error = validateQuestionnaire(params);
|
|
266
|
+
if (error) return invalidQuestionnaireResult(error);
|
|
267
|
+
if (ctx.mode !== "tui") {
|
|
268
|
+
if (!isInteractiveRpcHost(ctx.mode, process.env)) return unavailableResult();
|
|
269
|
+
try {
|
|
270
|
+
pi.events.emit(ASK_USER_QUESTION_BLOCKED_EVENT, { active: true });
|
|
271
|
+
return await askThroughDialogs(ctx, params);
|
|
272
|
+
}
|
|
273
|
+
finally {
|
|
274
|
+
pi.events.emit(ASK_USER_QUESTION_BLOCKED_EVENT, { active: false });
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
let selection: QuestionnaireResult | undefined;
|
|
279
|
+
try {
|
|
280
|
+
pi.events.emit(ASK_USER_QUESTION_BLOCKED_EVENT, { active: true });
|
|
281
|
+
selection = await ctx.ui.custom<QuestionnaireResult>((tui, theme, keybindings, done) => {
|
|
282
|
+
const view = new QuestionnaireView({
|
|
283
|
+
questions: params.questions,
|
|
284
|
+
theme,
|
|
285
|
+
keybindings,
|
|
286
|
+
onComplete: (result) => done(result),
|
|
287
|
+
});
|
|
288
|
+
// Native dock swap, never an overlay: the transcript stays scrollable
|
|
289
|
+
// while the questionnaire owns focus. No `overlay` option is passed.
|
|
290
|
+
const container = createNativeFullscreenInteraction({
|
|
291
|
+
keyboardTarget: view,
|
|
292
|
+
requestRender: () => tui.requestRender(),
|
|
293
|
+
});
|
|
294
|
+
container.addChild(new DynamicBorder((text: string) => theme.fg("accent", text)));
|
|
295
|
+
container.addChild(view);
|
|
296
|
+
container.addChild(new DynamicBorder((text: string) => theme.fg("accent", text)));
|
|
297
|
+
return container;
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
finally {
|
|
301
|
+
pi.events.emit(ASK_USER_QUESTION_BLOCKED_EVENT, { active: false });
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
if (selection === undefined || selection.cancelled) {
|
|
305
|
+
return {
|
|
306
|
+
content: [{ type: "text", text: "User cancelled the questionnaire" }],
|
|
307
|
+
details: { cancelled: true },
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
return {
|
|
311
|
+
content: [{ type: "text", text: answersText(selection.answers) }],
|
|
312
|
+
details: { answers: selection.answers },
|
|
313
|
+
};
|
|
314
|
+
},
|
|
315
|
+
renderCall(args: QuestionParams, theme) {
|
|
316
|
+
const questions = Array.isArray(args.questions) ? args.questions : [];
|
|
317
|
+
const summary = truncate(questions.map((question, index) => callSummary(question, index)).join(" "), CALL_SUMMARY_LIMIT);
|
|
318
|
+
return new Text(
|
|
319
|
+
theme.fg("toolTitle", theme.bold("ask_user_question ")) +
|
|
320
|
+
theme.fg("muted", summary),
|
|
321
|
+
0,
|
|
322
|
+
0,
|
|
323
|
+
);
|
|
324
|
+
},
|
|
325
|
+
renderResult(result, _options, theme) {
|
|
326
|
+
const details = result.details as QuestionnaireDetails | undefined;
|
|
327
|
+
if (details?.cancelled === true) return new Text(theme.fg("warning", "Cancelled"), 0, 0);
|
|
328
|
+
const answers = Array.isArray(details?.answers) ? details.answers : [];
|
|
329
|
+
if (answers.length === 0) return new Text(theme.fg("warning", "No answers"), 0, 0);
|
|
330
|
+
const lines = answers.map((answer) => {
|
|
331
|
+
if (answer.kind === "multi") return theme.fg("success", `✓ ${answer.question} — ${(answer.selected ?? []).join(", ")}`);
|
|
332
|
+
if (answer.kind === "custom") return theme.fg("success", `✓ ${answer.question} — ${answerBody(answer)}`);
|
|
333
|
+
return theme.fg("success", `✓ ${answer.question} — ${answer.answer ?? ""}`);
|
|
334
|
+
});
|
|
335
|
+
return new Text(lines.join("\n"), 0, 0);
|
|
336
|
+
},
|
|
337
|
+
});
|
|
338
|
+
}
|
|
@@ -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";
|
|
@@ -27,6 +28,8 @@ import { historyDir, loadHistory, loadStoredTask, pruneHistory, saveTask } from
|
|
|
27
28
|
import { sessionToMarkdown } from "../lib/agents-transcript.ts";
|
|
28
29
|
import { AgentsView } from "../lib/agents-view.ts";
|
|
29
30
|
import { PresencePublisher } from "../lib/orchestrator-presence.ts";
|
|
31
|
+
import { createRpcActivityPublisher, type RpcActivityPublisher } from "../lib/agents-rpc-publisher.ts";
|
|
32
|
+
import { isInteractiveRpcHost } from "../lib/rpc-host.ts";
|
|
30
33
|
import { createNativeFullscreenInteraction } from "../lib/native-fullscreen-interaction.ts";
|
|
31
34
|
import { AGENTS_GLYPH, renderAgentsCard, widgetExpiryMs, widgetRows } from "../lib/agents-widget.ts";
|
|
32
35
|
import { CARD_TONE, renderCard } from "../lib/shell-card.ts";
|
|
@@ -475,6 +478,12 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
|
|
|
475
478
|
let sidebarTui: TUI | undefined;
|
|
476
479
|
let sessions: ExtensionContext["sessionManager"] | undefined;
|
|
477
480
|
let presence: PresencePublisher | undefined;
|
|
481
|
+
let rpcActivityPublisher: RpcActivityPublisher | undefined;
|
|
482
|
+
// Messages already surfaced to the user this session through the RPC
|
|
483
|
+
// activity publisher's `onError`, so a recurring push failure (the
|
|
484
|
+
// coalescing window retries every burst) notifies at most once per
|
|
485
|
+
// session instead of flooding the UI. Reset on every `session_start`.
|
|
486
|
+
let notifiedRpcActivityErrors: Set<string> | undefined;
|
|
478
487
|
const overlays = new Set<AgentsView>();
|
|
479
488
|
const publishActivity = () => {
|
|
480
489
|
if (!sessions) return;
|
|
@@ -529,6 +538,12 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
|
|
|
529
538
|
// started before /new or /resume stays in the store and comes back with
|
|
530
539
|
// its session. Before the first session_start there is nothing to scope by.
|
|
531
540
|
const activeSessionId = (): string | undefined => (sessions === undefined ? undefined : sessions.getSessionId() ?? "");
|
|
541
|
+
// Pi 0.86.1 adds this event; the package's pinned 0.85.1 types predate it.
|
|
542
|
+
installBackgroundCacheWarming(pi as unknown as Parameters<typeof installBackgroundCacheWarming>[0], () => ({
|
|
543
|
+
sessionId: activeSessionId(),
|
|
544
|
+
ownedTaskIds,
|
|
545
|
+
tasks: store.list(activeSessionId()),
|
|
546
|
+
}));
|
|
532
547
|
const visibleTasks = (): TaskRecord[] => store.list(activeSessionId());
|
|
533
548
|
type SessionTransport = { generation: number; sessionId: string; sessionManager: ExtensionContext["sessionManager"]; client: SessionTransportClient; listener: SessionTransportListener; registry: SessionTransportRegistry; close(): Promise<void> };
|
|
534
549
|
let transportGeneration = 0;
|
|
@@ -1168,7 +1183,7 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
|
|
|
1168
1183
|
if (observe) metricTasks.set(task.id, metrics);
|
|
1169
1184
|
ownedTaskIds.add(task.id);
|
|
1170
1185
|
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}.
|
|
1186
|
+
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
1187
|
// A tool call aborted by the host (a human interrupting the turn, a timeout)
|
|
1173
1188
|
// would otherwise leave the child running and end the call with no result and
|
|
1174
1189
|
// no recorded reason. Cancel through the runner so the lifecycle runs and the
|
|
@@ -1443,12 +1458,37 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
|
|
|
1443
1458
|
publishActivity();
|
|
1444
1459
|
} catch { presence = undefined; }
|
|
1445
1460
|
void startSessionTransport(ctx);
|
|
1461
|
+
// The desktop app's own pi process: publish live subagent state through
|
|
1462
|
+
// setWidget's RPC-mode string[] path. Plain headless RPC (no variable) and
|
|
1463
|
+
// TUI are untouched -- the TUI card above the editor is showWidget's own
|
|
1464
|
+
// factory push, ignored by pi's RPC transport since it is not an array.
|
|
1465
|
+
rpcActivityPublisher?.stop();
|
|
1466
|
+
rpcActivityPublisher = undefined;
|
|
1467
|
+
notifiedRpcActivityErrors = new Set();
|
|
1468
|
+
if (ctx.hasUI && isInteractiveRpcHost(ctx.mode, deps.env)) {
|
|
1469
|
+
rpcActivityPublisher = createRpcActivityPublisher({
|
|
1470
|
+
store,
|
|
1471
|
+
ui: { setWidget: (key, lines) => ctx.ui.setWidget(key, lines) },
|
|
1472
|
+
now: deps.now,
|
|
1473
|
+
schedule: deps.schedule,
|
|
1474
|
+
parentSessionId: activeSessionId(),
|
|
1475
|
+
onError: (error) => {
|
|
1476
|
+
const message = `Gentle Agents activity push failed: ${error instanceof Error ? error.message : String(error)}`;
|
|
1477
|
+
if (notifiedRpcActivityErrors?.has(message)) return;
|
|
1478
|
+
notifiedRpcActivityErrors?.add(message);
|
|
1479
|
+
ctx.ui.notify(message, "warning");
|
|
1480
|
+
},
|
|
1481
|
+
});
|
|
1482
|
+
rpcActivityPublisher.start();
|
|
1483
|
+
}
|
|
1446
1484
|
});
|
|
1447
1485
|
pi.on("session_shutdown", async () => {
|
|
1448
1486
|
completions.dropAll();
|
|
1449
1487
|
activeAgentRuns = 0;
|
|
1450
1488
|
presence?.dispose();
|
|
1451
1489
|
presence = undefined;
|
|
1490
|
+
rpcActivityPublisher?.stop();
|
|
1491
|
+
rpcActivityPublisher = undefined;
|
|
1452
1492
|
cancelClock?.();
|
|
1453
1493
|
for (const view of overlays) { view.handleInput("q"); view.dispose(); }
|
|
1454
1494
|
overlays.clear();
|
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;
|