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.
@@ -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") return unavailableResult();
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
+ }
@@ -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
- const env = {
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;