gentle-pi 3.4.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 +25 -0
- package/bin/gentle-shell.mjs +198 -0
- package/docs/gentle-agents-activity.md +95 -0
- package/docs/readme-reference.md +67 -0
- package/extensions/ask-user-choice.ts +70 -22
- package/extensions/ask-user-question.ts +131 -3
- package/extensions/gentle-agents.ts +33 -0
- package/lib/agents-rpc-publisher.ts +342 -0
- package/lib/agents-runner.ts +7 -2
- package/lib/gentle-shell-launcher.ts +482 -0
- package/lib/rpc-host.ts +36 -0
- package/package.json +5 -1
- package/runtime/gentle-shell-launcher.mjs +483 -0
- package/scripts/build-runtime-modules.mjs +1 -0
- package/scripts/install-gentle-ai.mjs +14 -7
- package/scripts/install-tui-mode-setting.mjs +78 -1
- package/scripts/verify-package-files.mjs +4 -0
- package/tests/agents-rpc-publisher.test.ts +407 -0
- package/tests/agents-runner.test.ts +10 -0
- package/tests/ask-user-choice.test.ts +129 -0
- package/tests/ask-user-question.test.ts +227 -1
- package/tests/gentle-agents.test.ts +90 -0
- package/tests/gentle-shell-bin.test.ts +188 -0
- package/tests/gentle-shell-launcher.test.ts +718 -0
- package/tests/install-tui-mode-guard.test.ts +99 -0
- package/tests/install-tui-mode-setting.test.ts +39 -1
- package/tests/package-manifest.test.ts +2 -2
- package/tests/rpc-host.test.ts +77 -0
|
@@ -1,14 +1,15 @@
|
|
|
1
|
-
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { DynamicBorder } from "@earendil-works/pi-coding-agent";
|
|
3
3
|
import { Text } from "@earendil-works/pi-tui";
|
|
4
4
|
import { createNativeFullscreenInteraction } from "../lib/native-fullscreen-interaction.ts";
|
|
5
|
-
import { type QuestionParams, QuestionParamsSchema } from "../lib/questionnaire/schema.ts";
|
|
5
|
+
import { type QuestionData, type QuestionParams, QuestionParamsSchema } from "../lib/questionnaire/schema.ts";
|
|
6
6
|
import {
|
|
7
7
|
QuestionnaireView,
|
|
8
8
|
type AnswerRow,
|
|
9
9
|
type QuestionnaireResult,
|
|
10
10
|
} from "../lib/questionnaire/questionnaire-view.ts";
|
|
11
11
|
import { validateQuestionnaire, type QuestionnaireError } from "../lib/questionnaire/validate.ts";
|
|
12
|
+
import { isInteractiveRpcHost } from "../lib/rpc-host.ts";
|
|
12
13
|
|
|
13
14
|
const QUESTION_TOOL_NAME = "ask_user_question";
|
|
14
15
|
const ASK_USER_QUESTION_BLOCKED_EVENT = "gentle-pi:ask-user-question:blocked";
|
|
@@ -51,6 +52,124 @@ function unavailableResult(): QuestionnaireToolResult {
|
|
|
51
52
|
};
|
|
52
53
|
}
|
|
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
|
+
|
|
54
173
|
/**
|
|
55
174
|
* Human-readable body for one answer. A custom answer on a multiSelect
|
|
56
175
|
* question keeps the toggled options, so the text must name them explicitly:
|
|
@@ -145,7 +264,16 @@ export default function askUserQuestion(pi: ExtensionAPI): void {
|
|
|
145
264
|
): Promise<QuestionnaireToolResult> {
|
|
146
265
|
const error = validateQuestionnaire(params);
|
|
147
266
|
if (error) return invalidQuestionnaireResult(error);
|
|
148
|
-
if (ctx.mode !== "tui")
|
|
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
|
+
}
|
|
149
277
|
|
|
150
278
|
let selection: QuestionnaireResult | undefined;
|
|
151
279
|
try {
|
|
@@ -28,6 +28,8 @@ import { historyDir, loadHistory, loadStoredTask, pruneHistory, saveTask } from
|
|
|
28
28
|
import { sessionToMarkdown } from "../lib/agents-transcript.ts";
|
|
29
29
|
import { AgentsView } from "../lib/agents-view.ts";
|
|
30
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";
|
|
31
33
|
import { createNativeFullscreenInteraction } from "../lib/native-fullscreen-interaction.ts";
|
|
32
34
|
import { AGENTS_GLYPH, renderAgentsCard, widgetExpiryMs, widgetRows } from "../lib/agents-widget.ts";
|
|
33
35
|
import { CARD_TONE, renderCard } from "../lib/shell-card.ts";
|
|
@@ -476,6 +478,12 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
|
|
|
476
478
|
let sidebarTui: TUI | undefined;
|
|
477
479
|
let sessions: ExtensionContext["sessionManager"] | undefined;
|
|
478
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;
|
|
479
487
|
const overlays = new Set<AgentsView>();
|
|
480
488
|
const publishActivity = () => {
|
|
481
489
|
if (!sessions) return;
|
|
@@ -1450,12 +1458,37 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
|
|
|
1450
1458
|
publishActivity();
|
|
1451
1459
|
} catch { presence = undefined; }
|
|
1452
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
|
+
}
|
|
1453
1484
|
});
|
|
1454
1485
|
pi.on("session_shutdown", async () => {
|
|
1455
1486
|
completions.dropAll();
|
|
1456
1487
|
activeAgentRuns = 0;
|
|
1457
1488
|
presence?.dispose();
|
|
1458
1489
|
presence = undefined;
|
|
1490
|
+
rpcActivityPublisher?.stop();
|
|
1491
|
+
rpcActivityPublisher = undefined;
|
|
1459
1492
|
cancelClock?.();
|
|
1460
1493
|
for (const view of overlays) { view.handleInput("q"); view.dispose(); }
|
|
1461
1494
|
overlays.clear();
|
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
import { isFinished, THREAD_ITEM, TASK_STATUS, type TaskRecord, type TaskStatus, type TaskStore, type TaskSummary, type ThreadItem } from "./agents-protocol.ts";
|
|
2
|
+
|
|
3
|
+
// Publishes live Gentle Agents subagent state to an interactive RPC host
|
|
4
|
+
// (the desktop app), the RPC equivalent of the above-editor TUI card. Pi's
|
|
5
|
+
// only fire-and-forget, structured-enough RPC push is `setWidget(key, string[])`
|
|
6
|
+
// (component factories are ignored in RPC mode), so this module projects the
|
|
7
|
+
// TaskStore through the same field-whitelist discipline as
|
|
8
|
+
// `lib/orchestrator-presence.ts`, bounds the payload, and coalesces bursts of
|
|
9
|
+
// task events into one `setWidget` call per window.
|
|
10
|
+
|
|
11
|
+
/** Schema tag carried on every published line so a client can version the payload shape. */
|
|
12
|
+
export const ACTIVITY_SCHEMA = "gentle-agents.activity/v1";
|
|
13
|
+
/** Same widget key the TUI card uses. RPC's `setWidget` only accepts a component
|
|
14
|
+
* factory (ignored) or a `string[]` (sent), so the two publications never collide
|
|
15
|
+
* on the wire even though they share a key. */
|
|
16
|
+
export const ACTIVITY_WIDGET_KEY = "gentle-agents";
|
|
17
|
+
|
|
18
|
+
/** Prompts are the field most likely to carry a long user request; keep only a preview. */
|
|
19
|
+
const PROMPT_LIMIT = 200;
|
|
20
|
+
/** Tool args/output can be arbitrarily large; bound each independently. */
|
|
21
|
+
const TOOL_ARGS_LIMIT = 500;
|
|
22
|
+
const TOOL_OUTPUT_LIMIT = 500;
|
|
23
|
+
/** Text/thinking/note thread item text can be arbitrarily large (a whole streamed reply); bound it too. */
|
|
24
|
+
const TEXT_ITEM_LIMIT = 2000;
|
|
25
|
+
/** Summary fields that can carry a long, freeform diagnostic or step name. */
|
|
26
|
+
const SUMMARY_FIELD_LIMIT = 500;
|
|
27
|
+
/** Thread items kept per task in one push, most recent last. */
|
|
28
|
+
const DEFAULT_THREAD_ITEMS = 40;
|
|
29
|
+
/** Total payload bound before shrinking kicks in. */
|
|
30
|
+
export const DEFAULT_MAX_BYTES = 256 * 1024;
|
|
31
|
+
/** How long a burst of task/summary changes is coalesced before one push. */
|
|
32
|
+
const DEFAULT_COALESCE_MS = 150;
|
|
33
|
+
|
|
34
|
+
export interface RpcTaskSummary {
|
|
35
|
+
id: string;
|
|
36
|
+
agent: string;
|
|
37
|
+
label: string;
|
|
38
|
+
prompt: string;
|
|
39
|
+
status: TaskStatus;
|
|
40
|
+
createdAt: number;
|
|
41
|
+
startedAt: number | null;
|
|
42
|
+
endedAt: number | null;
|
|
43
|
+
lastStep: string;
|
|
44
|
+
lastActivityAt: number;
|
|
45
|
+
turns: number;
|
|
46
|
+
toolCalls: number;
|
|
47
|
+
error: string | null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface RpcThreadTextItem {
|
|
51
|
+
kind: typeof THREAD_ITEM.TEXT | typeof THREAD_ITEM.THINKING | typeof THREAD_ITEM.NOTE;
|
|
52
|
+
text: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface RpcThreadToolItem {
|
|
56
|
+
kind: typeof THREAD_ITEM.TOOL;
|
|
57
|
+
name: string;
|
|
58
|
+
/** JSON-stringified and truncated; never the raw args object. */
|
|
59
|
+
args: string;
|
|
60
|
+
running: boolean;
|
|
61
|
+
isError: boolean;
|
|
62
|
+
output: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export type RpcThreadItem = RpcThreadTextItem | RpcThreadToolItem;
|
|
66
|
+
|
|
67
|
+
export interface RpcThread {
|
|
68
|
+
version: number;
|
|
69
|
+
dropped: number;
|
|
70
|
+
items: RpcThreadItem[];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface RpcTask {
|
|
74
|
+
summary: RpcTaskSummary;
|
|
75
|
+
thread: RpcThread;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface RpcActivity {
|
|
79
|
+
schema: typeof ACTIVITY_SCHEMA;
|
|
80
|
+
summary: TaskSummary;
|
|
81
|
+
tasks: RpcTask[];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function truncate(text: string, limit: number): string {
|
|
85
|
+
return text.length <= limit ? text : `${text.slice(0, Math.max(0, limit - 1))}…`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function safeStringify(value: unknown): string {
|
|
89
|
+
try {
|
|
90
|
+
return JSON.stringify(value) ?? "null";
|
|
91
|
+
} catch {
|
|
92
|
+
return "[unserializable]";
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function projectThreadItem(item: ThreadItem): RpcThreadItem {
|
|
97
|
+
if (item.kind === THREAD_ITEM.TOOL) {
|
|
98
|
+
return {
|
|
99
|
+
kind: item.kind,
|
|
100
|
+
name: item.name,
|
|
101
|
+
args: truncate(safeStringify(item.args), TOOL_ARGS_LIMIT),
|
|
102
|
+
running: item.running,
|
|
103
|
+
isError: item.isError,
|
|
104
|
+
output: truncate(item.output, TOOL_OUTPUT_LIMIT),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
return { kind: item.kind, text: truncate(item.text, TEXT_ITEM_LIMIT) };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function projectTaskSummary(task: TaskRecord): RpcTaskSummary {
|
|
111
|
+
return {
|
|
112
|
+
id: task.id,
|
|
113
|
+
agent: task.agent,
|
|
114
|
+
label: truncate(task.label, SUMMARY_FIELD_LIMIT),
|
|
115
|
+
prompt: truncate(task.prompt, PROMPT_LIMIT),
|
|
116
|
+
status: task.status,
|
|
117
|
+
createdAt: task.createdAt,
|
|
118
|
+
startedAt: task.startedAt,
|
|
119
|
+
endedAt: task.endedAt,
|
|
120
|
+
lastStep: truncate(task.lastStep, SUMMARY_FIELD_LIMIT),
|
|
121
|
+
lastActivityAt: task.lastActivityAt,
|
|
122
|
+
turns: task.turns,
|
|
123
|
+
toolCalls: task.toolCalls,
|
|
124
|
+
error: task.error === null ? null : truncate(task.error, SUMMARY_FIELD_LIMIT),
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Ordering rank: running, then waiting, then queued, ahead of every finished status. */
|
|
129
|
+
const STATUS_RANK: Partial<Record<TaskStatus, number>> = {
|
|
130
|
+
[TASK_STATUS.RUNNING]: 0,
|
|
131
|
+
[TASK_STATUS.WAITING]: 1,
|
|
132
|
+
[TASK_STATUS.QUEUED]: 2,
|
|
133
|
+
};
|
|
134
|
+
const FINISHED_RANK = 3;
|
|
135
|
+
|
|
136
|
+
function orderTasks(tasks: readonly TaskRecord[]): TaskRecord[] {
|
|
137
|
+
return [...tasks].sort((a, b) => {
|
|
138
|
+
const rankA = STATUS_RANK[a.status] ?? FINISHED_RANK;
|
|
139
|
+
const rankB = STATUS_RANK[b.status] ?? FINISHED_RANK;
|
|
140
|
+
if (rankA !== rankB) return rankA - rankB;
|
|
141
|
+
// Within the active ranks, TaskStore#list's own recency order is kept
|
|
142
|
+
// (Array#sort is stable). Finished tasks sort by endedAt desc.
|
|
143
|
+
return rankA === FINISHED_RANK ? (b.endedAt ?? 0) - (a.endedAt ?? 0) : 0;
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export interface ProjectRpcActivityOptions {
|
|
148
|
+
/** Restrict to one parent session's tasks, matching `TaskStore#list`/`#summary`. */
|
|
149
|
+
parentSessionId?: string;
|
|
150
|
+
/** Thread items kept per task, most recent last. */
|
|
151
|
+
maxThreadItems?: number;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Whitelisted, ordered, size-bounded projection of `store` for one RPC push. Pure: reads the store, never mutates it. */
|
|
155
|
+
export function projectRpcActivity(store: TaskStore, opts: ProjectRpcActivityOptions = {}): RpcActivity {
|
|
156
|
+
const { parentSessionId, maxThreadItems = DEFAULT_THREAD_ITEMS } = opts;
|
|
157
|
+
const tasks = orderTasks(store.list(parentSessionId));
|
|
158
|
+
return {
|
|
159
|
+
schema: ACTIVITY_SCHEMA,
|
|
160
|
+
summary: store.summary(parentSessionId),
|
|
161
|
+
tasks: tasks.map((task) => {
|
|
162
|
+
const thread = store.thread(task.id);
|
|
163
|
+
const kept = maxThreadItems >= thread.items.length ? thread.items : thread.items.slice(thread.items.length - maxThreadItems);
|
|
164
|
+
return { summary: projectTaskSummary(task), thread: { version: thread.version, dropped: thread.dropped, items: kept.map(projectThreadItem) } };
|
|
165
|
+
}),
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function byteLength(value: string): number {
|
|
170
|
+
return Buffer.byteLength(value, "utf8");
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function halveThreadItems(task: RpcTask): RpcTask {
|
|
174
|
+
if (task.thread.items.length <= 1) return task;
|
|
175
|
+
const nextLength = Math.max(1, Math.floor(task.thread.items.length / 2));
|
|
176
|
+
return { ...task, thread: { ...task.thread, items: task.thread.items.slice(task.thread.items.length - nextLength) } };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function emptyFinishedThread(task: RpcTask): RpcTask {
|
|
180
|
+
return isFinished(task.summary.status) ? { ...task, thread: { ...task.thread, items: [] } } : task;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function lastFinishedIndex(tasks: readonly RpcTask[]): number {
|
|
184
|
+
for (let index = tasks.length - 1; index >= 0; index -= 1) {
|
|
185
|
+
if (isFinished(tasks[index]!.summary.status)) return index;
|
|
186
|
+
}
|
|
187
|
+
return -1;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Serializes `activity` as one JSON line, shrinking it under `maxBytes` when
|
|
192
|
+
* needed, in order: first halve every task's kept thread items (repeatedly,
|
|
193
|
+
* down to one item each), then empty finished tasks' threads, then drop
|
|
194
|
+
* whole finished tasks (oldest-finished first, by `endedAt`), and finally --
|
|
195
|
+
* once only active (running/waiting/queued) tasks with one item each
|
|
196
|
+
* remain -- empty every remaining task's thread too, emitting a
|
|
197
|
+
* summary-only payload. Never throws; a payload nothing can shrink further
|
|
198
|
+
* is returned as-is, oversized.
|
|
199
|
+
*/
|
|
200
|
+
export function encodeActivityLines(activity: RpcActivity, maxBytes: number = DEFAULT_MAX_BYTES): string[] {
|
|
201
|
+
let working = activity;
|
|
202
|
+
let line = JSON.stringify(working);
|
|
203
|
+
if (byteLength(line) <= maxBytes) return [line];
|
|
204
|
+
|
|
205
|
+
while (byteLength(line) > maxBytes && working.tasks.some((task) => task.thread.items.length > 1)) {
|
|
206
|
+
working = { ...working, tasks: working.tasks.map(halveThreadItems) };
|
|
207
|
+
line = JSON.stringify(working);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
while (byteLength(line) > maxBytes && working.tasks.some((task) => isFinished(task.summary.status) && task.thread.items.length > 0)) {
|
|
211
|
+
working = { ...working, tasks: working.tasks.map(emptyFinishedThread) };
|
|
212
|
+
line = JSON.stringify(working);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
while (byteLength(line) > maxBytes) {
|
|
216
|
+
const dropIndex = lastFinishedIndex(working.tasks);
|
|
217
|
+
if (dropIndex === -1) break;
|
|
218
|
+
working = { ...working, tasks: working.tasks.filter((_task, index) => index !== dropIndex) };
|
|
219
|
+
line = JSON.stringify(working);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Last resort: every finished task is already dropped, but the
|
|
223
|
+
// remaining active tasks' single-item threads still don't fit. No
|
|
224
|
+
// summary itself is ever dropped -- only its thread.
|
|
225
|
+
while (byteLength(line) > maxBytes && working.tasks.some((task) => task.thread.items.length > 0)) {
|
|
226
|
+
working = { ...working, tasks: working.tasks.map((task) => ({ ...task, thread: { ...task.thread, items: [] } })) };
|
|
227
|
+
line = JSON.stringify(working);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return [line];
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export interface RpcActivityPublisherUi {
|
|
234
|
+
setWidget(key: string, lines: string[]): void;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export interface RpcActivityPublisherDeps {
|
|
238
|
+
store: TaskStore;
|
|
239
|
+
ui: RpcActivityPublisherUi;
|
|
240
|
+
/** Unused by the publisher itself; accepted for symmetry with the rest of `AgentsDeps`. */
|
|
241
|
+
now?: () => number;
|
|
242
|
+
/** Same convention as `AgentsDeps.schedule`: returns a cancel function. */
|
|
243
|
+
schedule?: (fn: () => void, ms: number) => () => void;
|
|
244
|
+
coalesceMs?: number;
|
|
245
|
+
maxBytes?: number;
|
|
246
|
+
maxThreadItems?: number;
|
|
247
|
+
parentSessionId?: string;
|
|
248
|
+
/** Never let a `setWidget` failure escape the publisher; logs go here instead. */
|
|
249
|
+
onError?: (error: unknown) => void;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export interface RpcActivityPublisher {
|
|
253
|
+
start(): void;
|
|
254
|
+
stop(): void;
|
|
255
|
+
/** Publish immediately, cancelling any pending coalescing timer. */
|
|
256
|
+
flush(): void;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function defaultSchedule(fn: () => void, ms: number): () => void {
|
|
260
|
+
const timer = setTimeout(fn, ms);
|
|
261
|
+
(timer as unknown as { unref?: () => void }).unref?.();
|
|
262
|
+
return () => clearTimeout(timer);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Subscribes to `store.subscribeSummary` (task add/remove/status changes)
|
|
267
|
+
* and to `store.subscribe(id)` for every known task, coalescing bursts into
|
|
268
|
+
* one bounded `setWidget(ACTIVITY_WIDGET_KEY, lines)` push per window.
|
|
269
|
+
*/
|
|
270
|
+
export function createRpcActivityPublisher(deps: RpcActivityPublisherDeps): RpcActivityPublisher {
|
|
271
|
+
const {
|
|
272
|
+
store,
|
|
273
|
+
ui,
|
|
274
|
+
schedule = defaultSchedule,
|
|
275
|
+
coalesceMs = DEFAULT_COALESCE_MS,
|
|
276
|
+
maxBytes = DEFAULT_MAX_BYTES,
|
|
277
|
+
maxThreadItems = DEFAULT_THREAD_ITEMS,
|
|
278
|
+
parentSessionId,
|
|
279
|
+
onError = () => {},
|
|
280
|
+
} = deps;
|
|
281
|
+
|
|
282
|
+
let cancelTimer: (() => void) | undefined;
|
|
283
|
+
const taskUnsubscribes = new Map<string, () => void>();
|
|
284
|
+
let summaryUnsubscribe: (() => void) | undefined;
|
|
285
|
+
let started = false;
|
|
286
|
+
|
|
287
|
+
const publish = () => {
|
|
288
|
+
try {
|
|
289
|
+
const activity = projectRpcActivity(store, { parentSessionId, maxThreadItems });
|
|
290
|
+
const lines = encodeActivityLines(activity, maxBytes);
|
|
291
|
+
ui.setWidget(ACTIVITY_WIDGET_KEY, lines);
|
|
292
|
+
} catch (error) {
|
|
293
|
+
onError(error);
|
|
294
|
+
}
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
const scheduleFlush = () => {
|
|
298
|
+
if (cancelTimer) return;
|
|
299
|
+
cancelTimer = schedule(() => {
|
|
300
|
+
cancelTimer = undefined;
|
|
301
|
+
publish();
|
|
302
|
+
}, coalesceMs);
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
const trackTask = (id: string) => {
|
|
306
|
+
if (taskUnsubscribes.has(id)) return;
|
|
307
|
+
taskUnsubscribes.set(id, store.subscribe(id, scheduleFlush));
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
const syncTasks = () => {
|
|
311
|
+
for (const task of store.list(parentSessionId)) trackTask(task.id);
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
return {
|
|
315
|
+
start() {
|
|
316
|
+
if (started) return;
|
|
317
|
+
started = true;
|
|
318
|
+
syncTasks();
|
|
319
|
+
summaryUnsubscribe = store.subscribeSummary(() => {
|
|
320
|
+
syncTasks();
|
|
321
|
+
scheduleFlush();
|
|
322
|
+
});
|
|
323
|
+
scheduleFlush();
|
|
324
|
+
},
|
|
325
|
+
stop() {
|
|
326
|
+
if (!started) return;
|
|
327
|
+
started = false;
|
|
328
|
+
cancelTimer?.();
|
|
329
|
+
cancelTimer = undefined;
|
|
330
|
+
summaryUnsubscribe?.();
|
|
331
|
+
summaryUnsubscribe = undefined;
|
|
332
|
+
for (const unsubscribe of taskUnsubscribes.values()) unsubscribe();
|
|
333
|
+
taskUnsubscribes.clear();
|
|
334
|
+
publish();
|
|
335
|
+
},
|
|
336
|
+
flush() {
|
|
337
|
+
cancelTimer?.();
|
|
338
|
+
cancelTimer = undefined;
|
|
339
|
+
publish();
|
|
340
|
+
},
|
|
341
|
+
};
|
|
342
|
+
}
|
package/lib/agents-runner.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { isSessionChangeEvidence, type SessionChangeEvidence } from "./session-c
|
|
|
2
2
|
import type { Duplex, Readable, Writable } from "node:stream";
|
|
3
3
|
import { stripVTControlCharacters } from "node:util";
|
|
4
4
|
import { RESEARCH_SELECTION_ENV } from "./sdd-research-capabilities.ts";
|
|
5
|
+
import { withoutInteractiveHost } from "./rpc-host.ts";
|
|
5
6
|
import { AGENT_MODE, formatModelRef, type AgentDefinition, type AgentMode, type ModelRef } from "./agents-config.ts";
|
|
6
7
|
import { CHILD_QUERY_MAX_INFLIGHT, CHILD_QUERY_TIMEOUT_MS, parseChildFrame, validChildMessage, validChildQueryId } from "./agents-messaging.ts";
|
|
7
8
|
import { ParentStandingReviewPermissionBroker } from "./review-session-standing-permission-ipc.ts";
|
|
@@ -491,13 +492,17 @@ export class AgentRunner {
|
|
|
491
492
|
const detached = this.processControl.platform !== "win32";
|
|
492
493
|
const hasParentPermissionChannel = request.authorizeParentStandingReviewPermission !== undefined;
|
|
493
494
|
const permissionChannelStdio = this.processControl.platform === "win32" ? "overlapped" : "pipe";
|
|
494
|
-
|
|
495
|
+
// Subagent children are always headless: strip the desktop app's
|
|
496
|
+
// interactive-host signal even if it leaked into `request.env`, so a
|
|
497
|
+
// child spawned from an interactive RPC host never mistakes itself for
|
|
498
|
+
// one (`lib/rpc-host.ts`).
|
|
499
|
+
const env = withoutInteractiveHost({
|
|
495
500
|
...request.env,
|
|
496
501
|
...(request.extensionPaths ? { [RESEARCH_SELECTION_ENV]: JSON.stringify(request.researchSelection ?? null) } : {}),
|
|
497
502
|
[CHILD_MARKER]: "1",
|
|
498
503
|
[IPC_MARKER]: `${this.deps.now()}-${Math.random().toString(36).slice(2)}`,
|
|
499
504
|
...(hasParentPermissionChannel ? { GENTLE_PI_AGENTS_PARENT_PERMISSION_FD: "3" } : {}),
|
|
500
|
-
};
|
|
505
|
+
});
|
|
501
506
|
delete env[REMEDIATION_PLAN_ENV];
|
|
502
507
|
if (request.sddRemediation) env[REMEDIATION_PLAN_ENV] = JSON.stringify({ plan: request.sddRemediation.plan, scope: request.sddRemediation.scope, selection: request.sddChange });
|
|
503
508
|
let child: ChildLike;
|