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,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;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { gentlePiConfigHome } from "./agent-home.ts";
|
|
5
|
+
|
|
6
|
+
export const ANIMATION_POLICY = { QUALITY: "quality", PERFORMANCE: "performance", POTATO: "potato" } as const;
|
|
7
|
+
export type AnimationPolicy = (typeof ANIMATION_POLICY)[keyof typeof ANIMATION_POLICY];
|
|
8
|
+
const SOURCE = { GLOBAL: "global_file", DEFAULT: "default" } as const;
|
|
9
|
+
export const ANIMATION_SCHEMA = "gentle-pi.animations/v1";
|
|
10
|
+
|
|
11
|
+
interface AnimationOptions { gentlePiConfigHome?: string }
|
|
12
|
+
export interface AnimationResolution {
|
|
13
|
+
policy: AnimationPolicy;
|
|
14
|
+
source: (typeof SOURCE)[keyof typeof SOURCE];
|
|
15
|
+
malformed: boolean;
|
|
16
|
+
globalFile: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function parseAnimationPolicyFile(raw: string): AnimationPolicy | undefined {
|
|
20
|
+
try {
|
|
21
|
+
const value: unknown = JSON.parse(raw);
|
|
22
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined;
|
|
23
|
+
if (!("schema" in value) || value.schema !== ANIMATION_SCHEMA || !("policy" in value) || Object.keys(value).length !== 2) return undefined;
|
|
24
|
+
return value.policy === "quality" || value.policy === "performance" || value.policy === "potato" ? value.policy : undefined;
|
|
25
|
+
} catch { return undefined; }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function resolveAnimationPolicy(options: AnimationOptions = {}): AnimationResolution {
|
|
29
|
+
const globalFile = join(options.gentlePiConfigHome ?? gentlePiConfigHome(), "animations.json");
|
|
30
|
+
try {
|
|
31
|
+
const policy = parseAnimationPolicyFile(readFileSync(globalFile, "utf8"));
|
|
32
|
+
return { policy: policy ?? "quality", source: SOURCE.GLOBAL, malformed: policy === undefined, globalFile };
|
|
33
|
+
} catch (error) {
|
|
34
|
+
const missing = typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
|
|
35
|
+
return { policy: "quality", source: missing ? SOURCE.DEFAULT : SOURCE.GLOBAL, malformed: !missing, globalFile };
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Same-directory rename prevents readers from seeing a partially written policy. */
|
|
40
|
+
export function writeAnimationPolicy(policy: AnimationPolicy, options: AnimationOptions = {}): string {
|
|
41
|
+
const home = options.gentlePiConfigHome ?? gentlePiConfigHome();
|
|
42
|
+
const path = join(home, "animations.json");
|
|
43
|
+
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
44
|
+
mkdirSync(home, { recursive: true });
|
|
45
|
+
try {
|
|
46
|
+
writeFileSync(temporary, `${JSON.stringify({ schema: ANIMATION_SCHEMA, policy })}\n`, { flag: "wx", mode: 0o600 });
|
|
47
|
+
renameSync(temporary, path);
|
|
48
|
+
} finally {
|
|
49
|
+
try { unlinkSync(temporary); } catch { /* Rename already consumed the temporary file. */ }
|
|
50
|
+
}
|
|
51
|
+
return path;
|
|
52
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { TaskRecord } from "./agents-protocol.ts";
|
|
2
|
+
|
|
3
|
+
const ACTION = { WARM: "warm", STOP: "stop" } as const;
|
|
4
|
+
type WarmingAction = (typeof ACTION)[keyof typeof ACTION];
|
|
5
|
+
interface WarmingDecision {
|
|
6
|
+
warmCost: number;
|
|
7
|
+
missCost: number;
|
|
8
|
+
continuationProbability: number;
|
|
9
|
+
action: WarmingAction;
|
|
10
|
+
}
|
|
11
|
+
interface WarmingOverride { action: WarmingAction }
|
|
12
|
+
interface WarmingEvents {
|
|
13
|
+
on(event: "cache_warming_decision", handler: (event: WarmingDecision) => WarmingOverride | undefined): unknown;
|
|
14
|
+
}
|
|
15
|
+
export interface WarmingState {
|
|
16
|
+
sessionId: string | undefined;
|
|
17
|
+
ownedTaskIds: ReadonlySet<string>;
|
|
18
|
+
tasks: Array<Pick<TaskRecord, "id" | "parentSessionId" | "mode" | "status">>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Pi 0.86.1's native minimum expected savings, in dollars. Ownership raises
|
|
22
|
+
// continuation probability to 1, not permission to spend without a benefit.
|
|
23
|
+
const MINIMUM_EXPECTED_SAVINGS = 0.05;
|
|
24
|
+
|
|
25
|
+
export function installBackgroundCacheWarming(pi: WarmingEvents, state: () => WarmingState): void {
|
|
26
|
+
pi.on("cache_warming_decision", (event) => {
|
|
27
|
+
const { sessionId, ownedTaskIds, tasks } = state();
|
|
28
|
+
if (!sessionId || !tasks.some(task => task.parentSessionId === sessionId &&
|
|
29
|
+
ownedTaskIds.has(task.id) && task.mode === "background" &&
|
|
30
|
+
(task.status === "queued" || task.status === "running"))) return;
|
|
31
|
+
// Only native candidates arrive here: Pi owns opt-out, replay safety,
|
|
32
|
+
// provider TTLs, fixed horizons, scheduling and usage outside context.
|
|
33
|
+
const economic = Number.isFinite(event.warmCost) && event.warmCost >= 0 &&
|
|
34
|
+
Number.isFinite(event.missCost) && event.missCost > 0 &&
|
|
35
|
+
event.missCost - event.warmCost >= MINIMUM_EXPECTED_SAVINGS;
|
|
36
|
+
return { action: economic ? ACTION.WARM : ACTION.STOP };
|
|
37
|
+
});
|
|
38
|
+
}
|
|
@@ -26,6 +26,7 @@ export const COMMAND_PALETTE_CATALOG: readonly CommandPaletteCatalogGroup[] = [
|
|
|
26
26
|
{ command: "gentle:review-mode", label: "Review mode (receipt-driven development)" },
|
|
27
27
|
{ command: "gentle:background-subagents", label: "Background subagents" },
|
|
28
28
|
{ command: "gentle:double-esc-cancel", label: "Require double Esc to cancel" },
|
|
29
|
+
{ command: "gentle:animations", label: "Animation mode" },
|
|
29
30
|
{ command: "gentle:telemetry", label: "Telemetry" },
|
|
30
31
|
{ command: "gentle:banner", label: "Startup banner" },
|
|
31
32
|
{ command: "gentle:banner-color", label: "Banner color" },
|