gentle-pi 4.3.0 → 4.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -5
- package/assets/agents/gentle-ai-worker.md +1 -1
- package/assets/design-canvas/canvas.css +648 -0
- package/assets/design-canvas/canvas.js +1497 -0
- package/assets/design-canvas/index.html +88 -0
- package/assets/design-canvas/inspector.js +290 -0
- package/assets/orchestrator-delegation-reference.md +2 -2
- package/assets/orchestrator-verification.md +1 -1
- package/assets/orchestrator.md +2 -2
- package/bin/engram-guard +1 -1
- package/bin/gentle-shell.mjs +101 -151
- package/docs/bundled-install.md +47 -42
- package/docs/delegated-verification.md +6 -0
- package/docs/gentle-agents-activity.md +5 -1
- package/docs/gentle-music.md +61 -0
- package/docs/gentle-shell.md +77 -7
- package/docs/install-wizard.md +30 -6
- package/docs/permission-modes.md +7 -2
- package/docs/readme-reference.md +19 -15
- package/docs/session-profile-format.md +26 -0
- package/docs/voice.md +259 -0
- package/docs/yolo-mode.md +17 -2
- package/extensions/ask-user-choice.ts +4 -1
- package/extensions/gentle-agents.ts +173 -38
- package/extensions/gentle-ai.ts +214 -69
- package/extensions/gentle-design.ts +158 -0
- package/extensions/gentle-music.ts +289 -0
- package/extensions/gentle-shell.ts +332 -73
- package/extensions/gentle-voice.ts +11 -0
- package/extensions/session-scratch.ts +41 -22
- package/extensions/skill-registry.ts +63 -8
- package/lib/agent-profiles.ts +26 -0
- package/lib/agents-config.ts +35 -0
- package/lib/agents-protocol.ts +57 -1
- package/lib/agents-runner.ts +52 -3
- package/lib/agents-session-transport.ts +101 -6
- package/lib/agents-widget.ts +14 -1
- package/lib/auto-permission-memory.ts +48 -18
- package/lib/auto-permission-runtime.ts +21 -6
- package/lib/clipboard-image.ts +147 -0
- package/lib/command-palette-catalog.ts +2 -0
- package/lib/commit-slice-signal.ts +1 -1
- package/lib/context-backstop.ts +36 -0
- package/lib/design-canvas-server.ts +947 -0
- package/lib/design-comments.ts +437 -0
- package/lib/design-handoffs.ts +313 -0
- package/lib/design-open-browser.ts +58 -0
- package/lib/design-sources.ts +120 -0
- package/lib/external-editor.ts +31 -0
- package/lib/gentle-design.ts +513 -0
- package/lib/gentle-shell-bundled.ts +123 -49
- package/lib/gentle-shell-launcher.ts +9 -4
- package/lib/gentle-shell-startup-update.ts +61 -9
- package/lib/gentle-shell-uninstall.ts +33 -1
- package/lib/image-attachments.ts +150 -0
- package/lib/inprocess-reviewer.ts +33 -2
- package/lib/nan-provider.ts +3 -0
- package/lib/native-review-cli.ts +11 -4
- package/lib/notification-audio-native.ts +149 -13
- package/lib/notification-audio.ts +4 -0
- package/lib/notification-pulse-worker.ts +82 -18
- package/lib/notification-service.ts +5 -1
- package/lib/orchestrator-presence.ts +78 -8
- package/lib/orchestrator-scope.ts +10 -0
- package/lib/orchestrator-state.ts +11 -3
- package/lib/permission-mode-policy.ts +2 -2
- package/lib/review-host-relay.ts +11 -2
- package/lib/review-reminder-receipt.ts +24 -1
- package/lib/session-profile-append-controller.ts +713 -0
- package/lib/session-profile-disk-reader.ts +182 -0
- package/lib/session-profile-persistence.ts +15 -7
- package/lib/session-scratch.ts +52 -3
- package/lib/session-worktree-registry.ts +7 -0
- package/lib/shell-bar.ts +4 -3
- package/lib/shell-changes.ts +81 -5
- package/lib/shell-prompt.ts +4 -4
- package/lib/shell-sidebar-layout.ts +26 -6
- package/lib/shell-todo.ts +1 -1
- package/lib/verifier-admission.ts +5 -4
- package/lib/vim-editor-adapter.ts +11 -8
- package/lib/voice-artifacts.ts +188 -0
- package/lib/voice-command.ts +576 -0
- package/lib/voice-install.ts +455 -0
- package/lib/voice-platform.ts +99 -0
- package/lib/voice-push-to-talk.ts +262 -0
- package/lib/voice-session.ts +460 -0
- package/lib/voice-settings.ts +50 -0
- package/lib/voice-ui.ts +158 -0
- package/lib/yolo-session-policy.ts +58 -22
- package/package.json +1 -1
- package/runtime/gentle-shell-bundled.mjs +122 -48
- package/runtime/gentle-shell-launcher.mjs +9 -4
- package/runtime/gentle-shell-startup-update.mjs +61 -9
- package/runtime/gentle-shell-uninstall.mjs +33 -1
- package/runtime/native-review-cli.mjs +11 -4
- package/scripts/bundled-engram-migration.mjs +0 -6
- package/scripts/gentle-ai-installer.mjs +10 -10
- package/scripts/installer-downloads.mjs +7 -7
- package/scripts/installer-preflight.mjs +5 -3
- package/scripts/installer-windows.mjs +37 -7
- package/scripts/main-channel.mjs +91 -17
- package/scripts/pi-update.mjs +34 -6
- package/scripts/verify-package-files.mjs +26 -2
- package/scripts/voice-worker.cjs +476 -0
- package/skills/design/SKILL.md +110 -0
- package/tests/agent-profiles.test.ts +24 -0
- package/tests/agents-config.test.ts +61 -0
- package/tests/agents-fake-child.ts +8 -1
- package/tests/agents-protocol.test.ts +32 -0
- package/tests/agents-runner.test.ts +226 -5
- package/tests/agents-session-transport.test.ts +53 -3
- package/tests/ask-user-choice.test.ts +30 -0
- package/tests/auto-mode.test.ts +17 -0
- package/tests/auto-permission-memory-diagnostics.test.ts +56 -0
- package/tests/auto-permission-runtime.test.ts +85 -1
- package/tests/autonomous-guard.test.ts +20 -0
- package/tests/bundled-install.test.ts +3 -3
- package/tests/clipboard-image.test.ts +114 -0
- package/tests/command-palette.test.ts +1 -1
- package/tests/context-backstop.test.ts +70 -4
- package/tests/delegate-for-reason-contract.test.ts +2 -1
- package/tests/design-canvas-assets.test.ts +710 -0
- package/tests/design-canvas-server.test.ts +1096 -0
- package/tests/design-comments.test.ts +201 -0
- package/tests/design-handoffs.test.ts +282 -0
- package/tests/design-open-browser.test.ts +70 -0
- package/tests/design-sources.test.ts +132 -0
- package/tests/engram-safety.test.ts +132 -57
- package/tests/external-editor.test.ts +84 -0
- package/tests/fixtures/primary-session-prompt/gentleman.golden.md +3 -3
- package/tests/gentle-agents.test.ts +365 -20
- package/tests/gentle-ai-binary.test.ts +1 -1
- package/tests/gentle-ai-installer.test.ts +51 -51
- package/tests/gentle-ai.test.ts +292 -13
- package/tests/gentle-design.test.ts +1313 -0
- package/tests/gentle-music.test.ts +335 -0
- package/tests/gentle-shell-bundled.test.ts +301 -248
- package/tests/gentle-shell-launcher.test.ts +28 -2
- package/tests/gentle-shell-startup-update.test.ts +77 -1
- package/tests/gentle-shell-uninstall.test.ts +59 -0
- package/tests/gentle-shell.test.ts +183 -8
- package/tests/gentle-todo.test.ts +11 -0
- package/tests/image-attachments.test.ts +119 -0
- package/tests/image-paste-chips.test.ts +236 -0
- package/tests/inprocess-reviewer.test.ts +55 -2
- package/tests/installer-bundles.test.ts +12 -6
- package/tests/installer-posix-bootstrap.test.ts +8 -8
- package/tests/installer-preflight.test.ts +16 -0
- package/tests/installer-server.test.ts +2 -2
- package/tests/installer-windows-bootstrap.test.ts +61 -3
- package/tests/installer-windows-diagnostics.test.ts +111 -0
- package/tests/main-channel-upgrade.test.ts +232 -2
- package/tests/main-channel.test.ts +140 -21
- package/tests/nan-provider.test.ts +36 -0
- package/tests/native-review-capability-contract.test.ts +11 -4
- package/tests/native-review-parity.test.ts +1 -1
- package/tests/notification-audio-native.test.ts +128 -9
- package/tests/notification-customize.test.ts +17 -5
- package/tests/odd-routing-canonical-ratchet.test.ts +2 -1
- package/tests/orchestrator-budget.test.ts +5 -2
- package/tests/orchestrator-presence.test.ts +100 -2
- package/tests/orchestrator-rdd-ownership.test.ts +2 -2
- package/tests/orchestrator-scope.test.ts +14 -0
- package/tests/orchestrator-state.test.ts +29 -0
- package/tests/package-files.test.ts +34 -0
- package/tests/package-manifest.test.ts +6 -6
- package/tests/permission-mode-policy.test.ts +10 -1
- package/tests/pi-update.test.ts +99 -4
- package/tests/rdd-aware-verification-contract.test.ts +1 -1
- package/tests/review-host-relay-routing.test.ts +88 -1
- package/tests/review-host-relay.test.ts +9 -2
- package/tests/review-json-arguments.test.ts +20 -3
- package/tests/review-loadout-rdd-off-sdk.test.ts +23 -2
- package/tests/review-relay-transport-agent.test.ts +26 -0
- package/tests/review-reminder-receipt.test.ts +31 -1
- package/tests/session-changes.test.ts +2 -1
- package/tests/session-profile-append-controller-fork.test.ts +458 -0
- package/tests/session-profile-append-controller-pi.test.ts +290 -0
- package/tests/session-profile-append-controller.test.ts +731 -0
- package/tests/session-profile-disk-reader-pi.test.ts +183 -0
- package/tests/session-profile-disk-reader.test.ts +311 -0
- package/tests/session-scratch.test.ts +68 -2
- package/tests/session-worktree-registry.test.ts +13 -1
- package/tests/shell-bar.test.ts +14 -2
- package/tests/shell-changes.test.ts +148 -1
- package/tests/shell-prompt.test.ts +3 -3
- package/tests/shell-render-cache.test.ts +134 -1
- package/tests/shell-sidebar-layout.test.ts +50 -0
- package/tests/short-check-routing-contract.test.ts +1 -1
- package/tests/skill-collision-prefixes.test.ts +3 -1
- package/tests/skill-registry-watcher.test.ts +17 -5
- package/tests/skill-registry.test.ts +47 -3
- package/tests/task-size-routing-contract.test.ts +1 -1
- package/tests/test-strength-contract.test.ts +1 -1
- package/tests/verifier-admission.test.ts +18 -1
- package/tests/voice-command.test.ts +773 -0
- package/tests/voice-install.test.ts +323 -0
- package/tests/voice-platform.test.ts +176 -0
- package/tests/voice-push-to-talk.test.ts +444 -0
- package/tests/voice-session.test.ts +434 -0
- package/tests/voice-settings.test.ts +49 -0
- package/tests/voice-ui.test.ts +97 -0
- package/tests/voice-worker.test.ts +445 -0
- package/tests/weak-model-economy-prompt.test.ts +32 -0
- package/tests/windows-hidden-processes.test.ts +25 -2
- package/tests/yolo-customize.test.ts +5 -1
- package/tests/yolo-mode.test.ts +1 -1
- package/tests/yolo-session-policy-capture.test.ts +104 -0
|
@@ -1,11 +1,17 @@
|
|
|
1
|
-
import { chmod, constants, link, lstat, mkdir, open, opendir, realpath, unlink, writeFile } from "node:fs/promises";
|
|
1
|
+
import { chmod, constants, link, lstat, mkdir, open, opendir, readdir, realpath, unlink, writeFile } from "node:fs/promises";
|
|
2
2
|
import { basename, dirname, join, resolve } from "node:path";
|
|
3
3
|
import { createHash, randomBytes } from "node:crypto";
|
|
4
4
|
import { createConnection, createServer, type Server, type Socket } from "node:net";
|
|
5
5
|
|
|
6
6
|
const DIR_MODE = 0o700, FILE_MODE = 0o600, MAX_RECORD_BYTES = 8192, MAX_ENTRIES = 64, MAX_SOCKET_PATH_BYTES = 100;
|
|
7
|
+
// Over-cap debris collection bounds: a presence directory may hold at most
|
|
8
|
+
// this many schema-matching records before sweeping is refused, and each
|
|
9
|
+
// liveness probe gets a short bounded connect attempt. A genuine flood of
|
|
10
|
+
// live listeners stays over the cap and keeps failing busy, unchanged.
|
|
11
|
+
const SWEEP_MAX_FILES = 4096, SWEEP_PROBE_TIMEOUT_MS = 1_000, SWEEP_PROBE_CONCURRENCY = 8;
|
|
7
12
|
const RUNTIME_TMP = "/tmp", RUNTIME_PREFIX = "gentle-pi-", PROFILE_HASH_HEX_LENGTH = 32, SOCKET_NAME_BYTES = 27;
|
|
8
13
|
const SESSION = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/, TOKEN = /^[A-Za-z0-9_-]{22}$/;
|
|
14
|
+
const PRESENCE_FILE = /^([A-Za-z0-9][A-Za-z0-9_-]{0,127})\.([A-Za-z0-9_-]{22})\.json$/;
|
|
9
15
|
const OPEN_FLAGS = constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0);
|
|
10
16
|
type Code = "invalid_session" | "not_found" | "invalid_presence" | "unsafe_path" | "io_error" | "busy";
|
|
11
17
|
|
|
@@ -131,7 +137,25 @@ export class SessionPresenceRegistry {
|
|
|
131
137
|
return (await this.listActivations(excludeSessionId)).map(({ sessionId }) => Object.freeze({ sessionId, reachability: "unknown" as const }));
|
|
132
138
|
}
|
|
133
139
|
|
|
134
|
-
|
|
140
|
+
// An over-cap failure is stale debris until proven otherwise: sessions that
|
|
141
|
+
// exit uncleanly never remove their record, so a long-lived profile accrues
|
|
142
|
+
// them until every scan fails busy (gentle-shell#2501: 133 records, oldest
|
|
143
|
+
// four weeks old, 126 provably dead). Sweep once — bounded, liveness-based —
|
|
144
|
+
// and retry; a flood of live listeners stays over the cap and still fails.
|
|
145
|
+
private async withStaleSweep<T>(scan: () => Promise<T>): Promise<T> {
|
|
146
|
+
try { return await scan(); }
|
|
147
|
+
catch (error) {
|
|
148
|
+
if (!(error instanceof SessionPresenceError) || error.code !== "busy") throw error;
|
|
149
|
+
if (!await this.sweepStaleRecords().catch(() => false)) throw error;
|
|
150
|
+
return scan();
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
listActivations(excludeSessionId?: string): Promise<readonly PresenceRecord[]> {
|
|
155
|
+
return this.withStaleSweep(() => this.scanActivations(excludeSessionId));
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
private async scanActivations(excludeSessionId?: string): Promise<readonly PresenceRecord[]> {
|
|
135
159
|
const excluded = excludeSessionId === undefined ? undefined : sessionId(excludeSessionId);
|
|
136
160
|
await privateDirectory(this.paths.root);
|
|
137
161
|
await privateDirectory(this.paths.presence);
|
|
@@ -142,9 +166,12 @@ export class SessionPresenceRegistry {
|
|
|
142
166
|
try {
|
|
143
167
|
dir = await opendir(this.paths.presence);
|
|
144
168
|
for await (const entry of dir) {
|
|
145
|
-
|
|
146
|
-
|
|
169
|
+
const match = PRESENCE_FILE.exec(entry.name);
|
|
170
|
+
// Foreign-schema records (orchestrator headers, activity, and discovery
|
|
171
|
+
// sidecars) share this directory without ever being activations, so
|
|
172
|
+
// only schema-matching entries count toward the spam guard.
|
|
147
173
|
if (!match) continue;
|
|
174
|
+
if (++count > MAX_ENTRIES) fail("busy", "presence registry is busy");
|
|
148
175
|
try { await this.beforeCandidateOpen?.(); } catch { fail("io_error", "transport I/O failed"); }
|
|
149
176
|
try {
|
|
150
177
|
const loaded = await this.read(join(this.paths.presence, entry.name), match[1], match[2]);
|
|
@@ -160,7 +187,11 @@ export class SessionPresenceRegistry {
|
|
|
160
187
|
return [...newest.values()].sort((left, right) => left.sessionId.localeCompare(right.sessionId));
|
|
161
188
|
}
|
|
162
189
|
|
|
163
|
-
|
|
190
|
+
resolve(id: string): Promise<PresenceRecord> {
|
|
191
|
+
return this.withStaleSweep(() => this.scanResolve(id));
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
private async scanResolve(id: string): Promise<PresenceRecord> {
|
|
164
195
|
id = sessionId(id);
|
|
165
196
|
await privateDirectory(this.paths.root);
|
|
166
197
|
await privateDirectory(this.paths.presence);
|
|
@@ -170,9 +201,11 @@ export class SessionPresenceRegistry {
|
|
|
170
201
|
try {
|
|
171
202
|
dir = await opendir(this.paths.presence);
|
|
172
203
|
for await (const entry of dir) {
|
|
173
|
-
if (++count > MAX_ENTRIES) fail("busy", "presence registry is busy");
|
|
174
204
|
const match = new RegExp(`^${id}\\.([A-Za-z0-9_-]{22})\\.json$`).exec(entry.name);
|
|
205
|
+
// Same shared-directory rule as listActivations: foreign-schema
|
|
206
|
+
// records are skipped before counting toward the cap.
|
|
175
207
|
if (!match) continue;
|
|
208
|
+
if (++count > MAX_ENTRIES) fail("busy", "presence registry is busy");
|
|
176
209
|
try { await this.beforeCandidateOpen?.(); } catch { fail("io_error", "transport I/O failed"); }
|
|
177
210
|
try {
|
|
178
211
|
const loaded = await this.read(join(this.paths.presence, entry.name), id, match[1]);
|
|
@@ -215,6 +248,68 @@ export class SessionPresenceRegistry {
|
|
|
215
248
|
}
|
|
216
249
|
}
|
|
217
250
|
|
|
251
|
+
// One bounded connect attempt against a candidate's own socket endpoint.
|
|
252
|
+
// Endpoints reaching this probe were validated into this registry's sockets
|
|
253
|
+
// directory and lstat-verified as same-user sockets by advertises(), so the
|
|
254
|
+
// probe cannot reach foreign services. Only an active refusal proves the
|
|
255
|
+
// record dead: an ambiguous outcome (timeout, unexpected error) keeps it.
|
|
256
|
+
private liveness(endpoint: string): Promise<"live" | "dead" | "unclear"> {
|
|
257
|
+
return new Promise((resolveProbe) => {
|
|
258
|
+
const socket = createConnection(endpoint);
|
|
259
|
+
const settle = (verdict: "live" | "dead" | "unclear") => {
|
|
260
|
+
clearTimeout(timer);
|
|
261
|
+
socket.destroy();
|
|
262
|
+
resolveProbe(verdict);
|
|
263
|
+
};
|
|
264
|
+
const timer = setTimeout(() => settle("unclear"), SWEEP_PROBE_TIMEOUT_MS);
|
|
265
|
+
socket.once("connect", () => settle("live"));
|
|
266
|
+
socket.once("error", (error: NodeJS.ErrnoException) => settle(error.code === "ETIMEDOUT" ? "unclear" : "dead"));
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Garbage-collects provably dead records once the directory is over cap:
|
|
271
|
+
// no live endpoint (advertises false, i.e. the socket is gone) or a refused
|
|
272
|
+
// connect. Removes the record files only — socket files belong to their
|
|
273
|
+
// owning session. Returns whether anything was removed, so the caller
|
|
274
|
+
// retries the scan exactly once; nothing here runs on a healthy directory.
|
|
275
|
+
private async sweepStaleRecords(): Promise<boolean> {
|
|
276
|
+
const names = (await readdir(this.paths.presence)).filter((name) => PRESENCE_FILE.test(name));
|
|
277
|
+
if (names.length === 0 || names.length > SWEEP_MAX_FILES) return false;
|
|
278
|
+
let removed = 0;
|
|
279
|
+
const remove = async (file: string) => {
|
|
280
|
+
try { await unlink(file); removed += 1; }
|
|
281
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") removed += 1; }
|
|
282
|
+
};
|
|
283
|
+
const live: PresenceRecord[] = [];
|
|
284
|
+
for (const name of names) {
|
|
285
|
+
const match = PRESENCE_FILE.exec(name);
|
|
286
|
+
if (!match) continue;
|
|
287
|
+
try {
|
|
288
|
+
const loaded = await this.read(join(this.paths.presence, name), match[1], match[2]);
|
|
289
|
+
const record = loaded?.record;
|
|
290
|
+
// A record that no longer validates (malformed, pruned endpoint) is
|
|
291
|
+
// debris; an unsafe_path one is left for its owner, never touched.
|
|
292
|
+
if (!record) { await remove(join(this.paths.presence, name)); continue; }
|
|
293
|
+
if (await this.advertises(record)) live.push(record);
|
|
294
|
+
else await remove(join(this.paths.presence, name));
|
|
295
|
+
} catch (error) {
|
|
296
|
+
// Malformed content is debris from an interrupted write and is
|
|
297
|
+
// collected; unsafe-path records are left for their owner, never
|
|
298
|
+
// touched by the sweep.
|
|
299
|
+
if (error instanceof SessionPresenceError && error.code === "invalid_presence") await remove(join(this.paths.presence, name));
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
let next = 0;
|
|
303
|
+
const worker = async () => {
|
|
304
|
+
while (next < live.length) {
|
|
305
|
+
const record = live[next++];
|
|
306
|
+
if (await this.liveness(record.endpoint) === "dead") await remove(this.presencePath(record));
|
|
307
|
+
}
|
|
308
|
+
};
|
|
309
|
+
await Promise.all(Array.from({ length: Math.min(SWEEP_PROBE_CONCURRENCY, live.length) }, worker));
|
|
310
|
+
return removed > 0;
|
|
311
|
+
}
|
|
312
|
+
|
|
218
313
|
private token(endpoint: unknown) {
|
|
219
314
|
if (typeof endpoint !== "string" || dirname(endpoint) !== this.paths.sockets || resolve(endpoint) !== endpoint || Buffer.byteLength(endpoint) > MAX_SOCKET_PATH_BYTES) fail("unsafe_path", "unsafe transport path");
|
|
220
315
|
const name = basename(endpoint), token = name.endsWith(".sock") ? name.slice(0, -5) : "";
|
package/lib/agents-widget.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { visibleWidth } from "@earendil-works/pi-tui";
|
|
2
2
|
import { isFinished, TASK_STATUS, type TaskRecord, type TaskStatus } from "./agents-protocol.ts";
|
|
3
3
|
import { formatCost, formatTokens } from "./shell-bar.ts";
|
|
4
|
+
import { paintHoverable } from "./shell-hover.ts";
|
|
4
5
|
import { CARD_TONE, panelExtraRows, panelInnerWidth, renderCard, type CardTheme, type CardTone } from "./shell-card.ts";
|
|
5
6
|
|
|
6
7
|
// Gentle Agents widget: the card above the editor. Reads task records only
|
|
@@ -17,6 +18,10 @@ export interface AgentsWidgetOptions {
|
|
|
17
18
|
// "… N more" line so the card never pushes the editor off the screen.
|
|
18
19
|
maxRows?: number;
|
|
19
20
|
viewKey?: string;
|
|
21
|
+
// Paint the clickable "▾ Collapse" / "▸ Expand" control in the header, the
|
|
22
|
+
// same control the Todos card carries; hovered paints its hover role.
|
|
23
|
+
collapseControl?: boolean;
|
|
24
|
+
hovered?: boolean;
|
|
20
25
|
}
|
|
21
26
|
|
|
22
27
|
interface StatusLook {
|
|
@@ -297,6 +302,11 @@ function batchElapsed(tasks: readonly TaskRecord[], now: number): string | undef
|
|
|
297
302
|
return formatElapsed(end - Math.min(...starts));
|
|
298
303
|
}
|
|
299
304
|
|
|
305
|
+
/** The tone the card paints for these tasks, so a host can locate its header row. */
|
|
306
|
+
export function agentsCardTone(tasks: readonly TaskRecord[], now: number): CardTone {
|
|
307
|
+
return tone(widgetTasks(tasks, now));
|
|
308
|
+
}
|
|
309
|
+
|
|
300
310
|
export function renderAgentsCard(tasks: readonly TaskRecord[], theme: CardTheme, width: number, now: number, options: AgentsWidgetOptions): string[] {
|
|
301
311
|
const shown = widgetTasks(tasks, now);
|
|
302
312
|
if (shown.length === 0) return [];
|
|
@@ -310,8 +320,11 @@ export function renderAgentsCard(tasks: readonly TaskRecord[], theme: CardTheme,
|
|
|
310
320
|
const hint = options.collapsed && options.collapseKey ? `${options.collapseKey} expand` : shown.length > 1 ? batchElapsed(shown, now) : undefined;
|
|
311
321
|
const body = listed.flatMap((task) => row(task, theme, cols, now, options.maxRows === undefined));
|
|
312
322
|
if (hidden > 0) body.push(overflowRow(hidden, theme, options.viewKey));
|
|
323
|
+
const icon = options.collapsed ? "▸" : "▾";
|
|
324
|
+
const control = `${icon} ${width >= 28 ? (options.collapsed ? "Expand" : "Collapse") : ""}`.trimEnd();
|
|
325
|
+
const title = options.collapseControl ? `Agents ${paintHoverable(theme, control, options.hovered ?? false, "accent")}` : "Agents";
|
|
313
326
|
return renderCard(
|
|
314
|
-
{ title
|
|
327
|
+
{ title, subtitle: counts(shown), body, tone: tone(shown), glyph: AGENTS_GLYPH },
|
|
315
328
|
theme,
|
|
316
329
|
width,
|
|
317
330
|
{ expanded: true, hint, panel: true },
|
|
@@ -6,23 +6,41 @@ export interface AutoPermissionMemoryContext {
|
|
|
6
6
|
project: string;
|
|
7
7
|
cwd: string;
|
|
8
8
|
}
|
|
9
|
+
/** First failed provenance predicate, in evaluation order. */
|
|
10
|
+
export type AutoPermissionMemorySourceRejection = "tool-missing" | "tool-ambiguous" | "metadata-missing" | "origin" | "source-format" | "path";
|
|
11
|
+
/** Closed, content-free observation of the latest binder decision; never an authorization input. */
|
|
12
|
+
export type AutoPermissionMemoryDiagnostic =
|
|
13
|
+
| {stage:"admission" | "write"; reason:"untrusted-source"; sourceRejection:AutoPermissionMemorySourceRejection}
|
|
14
|
+
| ({sourceRejection?:undefined} & (
|
|
15
|
+
| {stage:"admission"; reason:"admitted" | "invalid-request" | "unexpected-error"}
|
|
16
|
+
| {stage:"resolution"; reason:"bound" | "provider-error" | "stale-identity" | "invalid-request" | "invalid-result" | "unexpected-error"}
|
|
17
|
+
| {stage:"write"; reason:"attached" | "stale-binding" | "source-mismatch" | "destination-mismatch" | "invalid-arguments" | "unexpected-error"}
|
|
18
|
+
| {stage:"clear"; reason:"binding-cleared"}));
|
|
9
19
|
type Call = Pick<ToolCallEvent,"toolName"|"input"|"toolCallId">;
|
|
10
20
|
type Identity = {manager: ExtensionContext["sessionManager"]; id:string; cwd:string; epoch:number; anchor:string; source:string};
|
|
21
|
+
type Source = {source:string; rejection?:undefined} | {source?:undefined; rejection:AutoPermissionMemorySourceRejection};
|
|
11
22
|
/** Live native-provider evidence only: never restore bindings from transcript text. */
|
|
12
23
|
export class AutoPermissionMemory {
|
|
13
24
|
private readonly tools: () => ToolInfo[];
|
|
14
25
|
constructor(tools: () => ToolInfo[]) { this.tools=tools; }
|
|
15
26
|
private pending?: Identity & {callId:string};
|
|
16
27
|
private binding?: Identity & {project:string};
|
|
17
|
-
|
|
18
|
-
|
|
28
|
+
private last?: AutoPermissionMemoryDiagnostic;
|
|
29
|
+
/** Snapshot of the latest decision; mutating it never affects the binder. */
|
|
30
|
+
diagnostic(): AutoPermissionMemoryDiagnostic | undefined { return this.last && {...this.last}; }
|
|
31
|
+
clear(): void { this.reset();this.last={stage:"clear",reason:"binding-cleared"}; }
|
|
32
|
+
private reset(): void { this.pending=undefined;this.binding=undefined; }
|
|
33
|
+
private inspect(name:string):Source {
|
|
19
34
|
const matches=this.tools().filter(tool=>tool.name===name);
|
|
20
|
-
if(matches.length!==1)return
|
|
35
|
+
if(matches.length!==1)return {rejection:matches.length ? "tool-ambiguous" : "tool-missing"};
|
|
21
36
|
const s=matches[0].sourceInfo;
|
|
22
|
-
if(!s
|
|
23
|
-
|
|
24
|
-
|
|
37
|
+
if(!s)return {rejection:"metadata-missing"};
|
|
38
|
+
if(s.origin!=="package")return {rejection:"origin"};
|
|
39
|
+
if(!/^npm:gentle-engram(?:@[^\s/]+)?$/.test(s.source))return {rejection:"source-format"};
|
|
40
|
+
if(typeof s.path!=="string" || !s.path || s.path.startsWith("builtin:") || s.path.startsWith("<"))return {rejection:"path"};
|
|
41
|
+
return {source:JSON.stringify([s.path,s.source,s.scope,s.origin,s.baseDir ?? null])};
|
|
25
42
|
}
|
|
43
|
+
private source(name:string):string|undefined { return this.inspect(name).source; }
|
|
26
44
|
private valid(value:Identity,ctx:ExtensionContext,epoch:number):boolean {
|
|
27
45
|
return value.manager===ctx.sessionManager && value.id===ctx.sessionManager.getSessionId() && value.cwd===ctx.cwd &&
|
|
28
46
|
value.epoch===epoch && this.source("mem_current_project")===value.source &&
|
|
@@ -30,40 +48,52 @@ export class AutoPermissionMemory {
|
|
|
30
48
|
}
|
|
31
49
|
admitted(event:Call,ctx:ExtensionContext,epoch:number):void {
|
|
32
50
|
if(event.toolName!=="mem_current_project")return;
|
|
33
|
-
this.
|
|
51
|
+
this.reset();
|
|
34
52
|
try {
|
|
35
|
-
const source=this.
|
|
53
|
+
const {source,rejection}=this.inspect(event.toolName),id=ctx.sessionManager.getSessionId();
|
|
36
54
|
const args=event.input as Record<string,unknown>;
|
|
37
55
|
const anchor=ctx.sessionManager.getBranch().at(-1)?.id;
|
|
56
|
+
if(rejection){this.last={stage:"admission",reason:"untrusted-source",sourceRejection:rejection};return;}
|
|
38
57
|
if(!source || !id || !anchor || !args || args.cwd!==ctx.cwd ||
|
|
39
|
-
Object.keys(args).some(key=>key!=="cwd"))return;
|
|
58
|
+
Object.keys(args).some(key=>key!=="cwd")){this.last={stage:"admission",reason:"invalid-request"};return;}
|
|
40
59
|
this.pending={manager:ctx.sessionManager,id,cwd:ctx.cwd,epoch,anchor,source,callId:event.toolCallId};
|
|
41
|
-
|
|
60
|
+
this.last={stage:"admission",reason:"admitted"};
|
|
61
|
+
} catch { this.reset();this.last={stage:"admission",reason:"unexpected-error"}; }
|
|
42
62
|
}
|
|
43
63
|
result(event:ToolResultEvent,ctx:ExtensionContext,epoch:number):void {
|
|
44
64
|
if(event.toolName!=="mem_current_project")return;
|
|
45
65
|
const pending=this.pending;
|
|
46
66
|
if(!pending || event.toolCallId!==pending.callId)return;
|
|
47
|
-
this.
|
|
67
|
+
this.reset();
|
|
48
68
|
try {
|
|
49
|
-
if(event.isError
|
|
69
|
+
if(event.isError){this.last={stage:"resolution",reason:"provider-error"};return;}
|
|
70
|
+
if(!this.valid(pending,ctx,epoch)){this.last={stage:"resolution",reason:"stale-identity"};return;}
|
|
71
|
+
if((event.input as Record<string,unknown>)?.cwd!==ctx.cwd){this.last={stage:"resolution",reason:"invalid-request"};return;}
|
|
50
72
|
const data=(event.details as {data?:Record<string,unknown>} | undefined)?.data;
|
|
51
73
|
if(!data || data.cwd!==ctx.cwd || typeof data.project!=="string" || !data.project.trim() || data.project.length>256 ||
|
|
52
74
|
/[\p{Cc}\p{Cf}\p{Cs}]/u.test(data.project) || typeof data.project_source!=="string" || !["git_remote","dir_basename"].includes(data.project_source) ||
|
|
53
75
|
(data.available_projects!=null && (!Array.isArray(data.available_projects) || data.available_projects.length>0)) ||
|
|
54
|
-
Object.keys(data).some(key=>/error|warning|fallback/i.test(key)))return;
|
|
76
|
+
Object.keys(data).some(key=>/error|warning|fallback/i.test(key))){this.last={stage:"resolution",reason:"invalid-result"};return;}
|
|
55
77
|
this.binding={...pending,project:data.project};
|
|
56
|
-
|
|
78
|
+
this.last={stage:"resolution",reason:"bound"};
|
|
79
|
+
} catch { this.reset();this.last={stage:"resolution",reason:"unexpected-error"}; }
|
|
57
80
|
}
|
|
81
|
+
/** Without a binding the diagnostic keeps the cause that prevented one. */
|
|
58
82
|
context(event:Pick<Call,"toolName"|"input">,ctx:ExtensionContext,epoch:number):AutoPermissionMemoryContext|undefined {
|
|
59
83
|
try {
|
|
60
84
|
if(event.toolName!=="mem_save" && event.toolName!=="mem_session_summary")return undefined;
|
|
61
85
|
const binding=this.binding,args=event.input as Record<string,unknown>;
|
|
62
|
-
if(!binding
|
|
63
|
-
|
|
86
|
+
if(!binding)return undefined;
|
|
87
|
+
if(!this.valid(binding,ctx,epoch)){this.last={stage:"write",reason:"stale-binding"};return undefined;}
|
|
88
|
+
const {source,rejection}=this.inspect(event.toolName);
|
|
89
|
+
if(rejection){this.last={stage:"write",reason:"untrusted-source",sourceRejection:rejection};return undefined;}
|
|
90
|
+
if(source!==binding.source){this.last={stage:"write",reason:"source-mismatch"};return undefined;}
|
|
91
|
+
if(!args || args.cwd!==ctx.cwd || args.project!==binding.project || (args.scope!==undefined && args.scope!=="project")){
|
|
92
|
+
this.last={stage:"write",reason:"destination-mismatch"};return undefined;}
|
|
64
93
|
const allowed=event.toolName==="mem_save" ? ["cwd","project","title","content","type","scope","topic_key","capture_prompt"] : ["cwd","project","content"];
|
|
65
|
-
if(Object.keys(args).some(key=>!allowed.includes(key)))return undefined;
|
|
94
|
+
if(Object.keys(args).some(key=>!allowed.includes(key))){this.last={stage:"write",reason:"invalid-arguments"};return undefined;}
|
|
95
|
+
this.last={stage:"write",reason:"attached"};
|
|
66
96
|
return {provider:"gentle-engram",operation:event.toolName,project:binding.project,cwd:binding.cwd};
|
|
67
|
-
} catch { return undefined; }
|
|
97
|
+
} catch { this.last={stage:"write",reason:"unexpected-error"};return undefined; }
|
|
68
98
|
}
|
|
69
99
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { lstatSync, realpathSync } from "node:fs";
|
|
2
2
|
import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
|
|
3
3
|
import type { ExtensionAPI, ExtensionContext, InputEvent, ToolCallEvent, ToolCallEventResult, ToolResultEvent } from "@earendil-works/pi-coding-agent";
|
|
4
4
|
import { gentlePiConfigHome } from "./agent-home.ts";
|
|
@@ -18,7 +18,7 @@ export function updateAutoPrompt(options: AppendableSystemPromptOptions | undefi
|
|
|
18
18
|
if (active) appendSystemPromptOnce(options, AUTO_DIRECTIVE);
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
type ModeController = {mode(ctx: ExtensionContext): Promise<string>; epoch(): number};
|
|
21
|
+
type ModeController = {mode(ctx: ExtensionContext, admission?: { toolCallId: string }): Promise<string>; epoch(): number};
|
|
22
22
|
type Verdict = {status: string; decision?: string; reason?: string; code?: string; usage?: unknown; usageKnown?: boolean; actualModel?: unknown; requestedModel?: unknown; diagnostic?: unknown};
|
|
23
23
|
/** Project a closed metadata schema; injected classifiers cannot add payloads to diagnostics. */
|
|
24
24
|
function safeDiagnostic(value: unknown): AutoPermissionDiagnostic | undefined {
|
|
@@ -96,7 +96,8 @@ function within(path: string, root: string): boolean {
|
|
|
96
96
|
function canonical(path: string): string {
|
|
97
97
|
const absolute = resolve(path);
|
|
98
98
|
let ancestor = absolute;
|
|
99
|
-
|
|
99
|
+
// A dangling symlink exists as a directory entry: let realpath fail closed.
|
|
100
|
+
while (!lstatSync(ancestor,{throwIfNoEntry:false})) { const parent = dirname(ancestor); if (parent === ancestor) break; ancestor = parent; }
|
|
100
101
|
return resolve(realpathSync(ancestor), relative(ancestor, absolute));
|
|
101
102
|
}
|
|
102
103
|
/** True only for an existing symlink-free in-project regular file larger than the reader accepts.
|
|
@@ -368,8 +369,22 @@ export class AutoPermissionRuntime {
|
|
|
368
369
|
private protectedMutation(event: Pick<ToolCallEvent,"toolName"|"input">, ctx: ExtensionContext): boolean {
|
|
369
370
|
const args=event.input as Record<string,unknown>;
|
|
370
371
|
if (!args || typeof args!=="object") return false;
|
|
372
|
+
const projectHome=resolve(ctx.cwd,".pi","gentle-ai");
|
|
371
373
|
if ((event.toolName==="write" || event.toolName==="edit") && typeof args.path==="string") {
|
|
372
|
-
try {
|
|
374
|
+
try {
|
|
375
|
+
const target=canonical(resolve(ctx.cwd,args.path)),home=canonical(this.configHome);
|
|
376
|
+
// Keep policy aliases and containing directories protected, including project restrictions.
|
|
377
|
+
if([resolve(this.configHome,"permission-mode.json"),resolve(this.configHome,"runtime-guardrails.json"),
|
|
378
|
+
resolve(projectHome,"runtime-guardrails.json")].some(path=>within(canonical(path),target)))return true;
|
|
379
|
+
if(!within(target,home))return false;
|
|
380
|
+
// A shared inode can alias policy without changing the canonical path.
|
|
381
|
+
if((lstatSync(target,{throwIfNoEntry:false})?.nlink ?? 0)>1)return true;
|
|
382
|
+
// Only updater-owned data returns to ordinary permission checks. Compare the
|
|
383
|
+
// resolved target to literal destinations under home: canonicalizing exemptions
|
|
384
|
+
// would let a dev-binary.json symlink exempt the authority file it points at.
|
|
385
|
+
return !["dev-binary.json","channel.json"].some(path=>target===resolve(home,path)) &&
|
|
386
|
+
!["main","tools/go"].some(path=>within(target,resolve(home,path)));
|
|
387
|
+
} catch { return true; }
|
|
373
388
|
}
|
|
374
389
|
if (["bash","bash_background","monitor"].includes(event.toolName) && typeof args.command==="string") {
|
|
375
390
|
// Best-effort shell recognition supplements classification, not OS containment.
|
|
@@ -395,7 +410,7 @@ export class AutoPermissionRuntime {
|
|
|
395
410
|
if(this.protectedMutation(event,ctx))return block("Permission configuration is human-controlled. Use the mode settings UI; model tools cannot change it.");
|
|
396
411
|
// Revocation while identity lookup is pending must not become a fresh grant.
|
|
397
412
|
const generation=this.generation, epoch=this.modes.epoch(), owner=this.owner;
|
|
398
|
-
const mode=await this.modes.mode(ctx);
|
|
413
|
+
const mode=await this.modes.mode(ctx,{toolCallId:event.toolCallId});
|
|
399
414
|
if(generation!==this.generation || epoch!==this.modes.epoch() || owner!==this.owner || ctx.signal?.aborted)
|
|
400
415
|
return block("Auto decision expired during mode lookup. The action was not run.");
|
|
401
416
|
if(mode!=="auto") { this.memory.admitted(event,ctx,epoch);return undefined; }
|
|
@@ -452,7 +467,7 @@ export class AutoPermissionRuntime {
|
|
|
452
467
|
if(result.status==="available" || result.code!=="provider-error" || controller.signal.aborted)break;
|
|
453
468
|
}
|
|
454
469
|
}finally{ctx.signal?.removeEventListener("abort",onAbort);this.active.delete(controller);}
|
|
455
|
-
const stillAuto=await this.modes.mode(ctx)==="auto";
|
|
470
|
+
const stillAuto=await this.modes.mode(ctx,{toolCallId:event.toolCallId})==="auto";
|
|
456
471
|
if(!current() || controller.signal.aborted || !stillAuto)return block("Auto decision expired because the action, mode, request or session changed. The action was not run.");
|
|
457
472
|
if(result.status!=="available" || !["allow","deny"].includes(result.decision ?? "")){
|
|
458
473
|
// Contention and revocation are not failed safety judgements or provider outages.
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { getNativeClipboard, type NativeClipboard } from "@earendil-works/pi-tui";
|
|
4
|
+
|
|
5
|
+
// Clipboard image reader for the prompt's attachment chips (gentle-shell#2464).
|
|
6
|
+
// Pi reads the same backends but does not export its reader, and its handler
|
|
7
|
+
// writes the image to a temp file and inserts the path. This reader keeps the
|
|
8
|
+
// bytes in memory and returns null whenever Pi's own handler should decide
|
|
9
|
+
// instead: no image, an unsupported format, or files copied in a file manager.
|
|
10
|
+
|
|
11
|
+
export interface ClipboardImage {
|
|
12
|
+
bytes: Uint8Array;
|
|
13
|
+
mimeType: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Resolves to stdout, or undefined when the command is missing, fails, or times out. */
|
|
17
|
+
export type ClipboardCommandRunner = (command: string, args: readonly string[], options?: { timeoutMs?: number }) => Promise<Buffer | undefined>;
|
|
18
|
+
|
|
19
|
+
export interface ClipboardImageOptions {
|
|
20
|
+
env?: NodeJS.ProcessEnv;
|
|
21
|
+
platform?: NodeJS.Platform;
|
|
22
|
+
run?: ClipboardCommandRunner;
|
|
23
|
+
native?: () => NativeClipboard | undefined;
|
|
24
|
+
procVersion?: () => string | undefined;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const SUPPORTED_IMAGE_MIME_TYPES = ["image/png", "image/jpeg", "image/webp", "image/gif"] as const;
|
|
28
|
+
|
|
29
|
+
const LIST_TIMEOUT_MS = 1000;
|
|
30
|
+
const READ_TIMEOUT_MS = 5000;
|
|
31
|
+
const POWERSHELL_TIMEOUT_MS = 10_000;
|
|
32
|
+
const MAX_CLIPBOARD_BYTES = 64 * 1024 * 1024;
|
|
33
|
+
|
|
34
|
+
// Encodes the Windows clipboard image as PNG on stdout, so nothing touches disk.
|
|
35
|
+
const POWERSHELL_SCRIPT = [
|
|
36
|
+
"Add-Type -AssemblyName System.Windows.Forms",
|
|
37
|
+
"Add-Type -AssemblyName System.Drawing",
|
|
38
|
+
"$img = [System.Windows.Forms.Clipboard]::GetImage()",
|
|
39
|
+
"if (-not $img) { exit 0 }",
|
|
40
|
+
"$ms = New-Object System.IO.MemoryStream",
|
|
41
|
+
"$img.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png)",
|
|
42
|
+
"[Convert]::ToBase64String($ms.ToArray())",
|
|
43
|
+
].join("; ");
|
|
44
|
+
|
|
45
|
+
export const runClipboardCommand: ClipboardCommandRunner = (command, args, options) => new Promise((resolve) => {
|
|
46
|
+
execFile(command, [...args], { encoding: "buffer", timeout: options?.timeoutMs ?? READ_TIMEOUT_MS, maxBuffer: MAX_CLIPBOARD_BYTES, windowsHide: true }, (error, stdout) => {
|
|
47
|
+
resolve(error ? undefined : stdout);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
/** Identify a supported image by its signature, never by a clipboard-declared type. */
|
|
52
|
+
export function sniffImageMimeType(bytes: Uint8Array): string | undefined {
|
|
53
|
+
const starts = (...signature: number[]) => signature.every((byte, index) => bytes[index] === byte);
|
|
54
|
+
if (starts(0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a)) return "image/png";
|
|
55
|
+
if (starts(0xff, 0xd8, 0xff)) return "image/jpeg";
|
|
56
|
+
if (starts(0x47, 0x49, 0x46, 0x38)) return "image/gif";
|
|
57
|
+
if (starts(0x52, 0x49, 0x46, 0x46) && bytes[8] === 0x57 && bytes[9] === 0x45 && bytes[10] === 0x42 && bytes[11] === 0x50) return "image/webp";
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function isWsl(env: NodeJS.ProcessEnv, procVersion: () => string | undefined = readProcVersion): boolean {
|
|
62
|
+
if (env.WSL_DISTRO_NAME || env.WSL_INTEROP) return true;
|
|
63
|
+
return /microsoft|wsl/i.test(procVersion() ?? "");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function readProcVersion(): string | undefined {
|
|
67
|
+
try { return readFileSync("/proc/version", "utf8"); } catch { return undefined; }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function supported(bytes: Uint8Array | null): ClipboardImage | null {
|
|
71
|
+
if (!bytes || bytes.length === 0) return null;
|
|
72
|
+
const mimeType = sniffImageMimeType(bytes);
|
|
73
|
+
return mimeType ? { bytes, mimeType } : null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// A failed read is "backend unavailable", exactly as Pi treats it, so the next backend still runs.
|
|
77
|
+
function supportedRead(bytes: Buffer | undefined): ClipboardImage | null | undefined {
|
|
78
|
+
return bytes === undefined ? undefined : supported(bytes);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function preferredImageType(listing: Buffer): string | undefined {
|
|
82
|
+
const types = listing.toString("utf8").split(/\r?\n/).map((type) => type.trim()).filter(Boolean);
|
|
83
|
+
return SUPPORTED_IMAGE_MIME_TYPES.find((wanted) => types.some((type) => type.split(";")[0]?.trim().toLowerCase() === wanted));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// undefined: backend unavailable, try the next one. null: backend answered, no usable image.
|
|
87
|
+
async function viaWlPaste(run: ClipboardCommandRunner): Promise<ClipboardImage | null | undefined> {
|
|
88
|
+
const listing = await run("wl-paste", ["--list-types"], { timeoutMs: LIST_TIMEOUT_MS });
|
|
89
|
+
if (listing === undefined) return undefined;
|
|
90
|
+
const type = preferredImageType(listing);
|
|
91
|
+
if (!type) return null;
|
|
92
|
+
return supportedRead(await run("wl-paste", ["--type", type, "--no-newline"]));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function viaXclip(run: ClipboardCommandRunner): Promise<ClipboardImage | null | undefined> {
|
|
96
|
+
const listing = await run("xclip", ["-selection", "clipboard", "-t", "TARGETS", "-o"], { timeoutMs: LIST_TIMEOUT_MS });
|
|
97
|
+
if (listing === undefined) return undefined;
|
|
98
|
+
const type = preferredImageType(listing);
|
|
99
|
+
if (!type) return null;
|
|
100
|
+
return supportedRead(await run("xclip", ["-selection", "clipboard", "-t", type, "-o"]));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function viaPowerShell(run: ClipboardCommandRunner): Promise<ClipboardImage | null> {
|
|
104
|
+
const output = await run("powershell.exe", ["-NoLogo", "-NoProfile", "-NonInteractive", "-STA", "-Command", POWERSHELL_SCRIPT], { timeoutMs: POWERSHELL_TIMEOUT_MS });
|
|
105
|
+
const base64 = output?.toString("utf8").trim();
|
|
106
|
+
if (!base64) return null;
|
|
107
|
+
return supported(new Uint8Array(Buffer.from(base64, "base64")));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Files copied in a file manager stay Pi's: it pastes their paths.
|
|
111
|
+
async function hasCopiedFiles(native: NativeClipboard | undefined): Promise<boolean> {
|
|
112
|
+
const files = await native?.getFilePaths?.().catch(() => undefined);
|
|
113
|
+
return Boolean(files && files.length > 0);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function viaNative(native: NativeClipboard | undefined): Promise<ClipboardImage | null> {
|
|
117
|
+
if (!native) return null;
|
|
118
|
+
return supported((await native.getImage().catch(() => undefined)) ?? null);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Read a supported image from the clipboard, mirroring Pi's backend order:
|
|
123
|
+
* Wayland, then X11, then the Windows clipboard on WSL, then the native
|
|
124
|
+
* clipboard. Returns null when Pi's own paste handler should run instead.
|
|
125
|
+
*/
|
|
126
|
+
export async function readClipboardImage(options: ClipboardImageOptions = {}): Promise<ClipboardImage | null> {
|
|
127
|
+
const env = options.env ?? process.env;
|
|
128
|
+
const platform = options.platform ?? process.platform;
|
|
129
|
+
const run = options.run ?? runClipboardCommand;
|
|
130
|
+
const native = options.native ?? getNativeClipboard;
|
|
131
|
+
if (env.TERMUX_VERSION) return null;
|
|
132
|
+
try {
|
|
133
|
+
const nativeClipboard = native();
|
|
134
|
+
if (await hasCopiedFiles(nativeClipboard)) return null;
|
|
135
|
+
if (platform !== "linux") return await viaNative(nativeClipboard);
|
|
136
|
+
const wsl = isWsl(env, options.procVersion);
|
|
137
|
+
let image: ClipboardImage | null | undefined;
|
|
138
|
+
if (env.WAYLAND_DISPLAY || env.XDG_SESSION_TYPE === "wayland" || wsl) image = await viaWlPaste(run);
|
|
139
|
+
if (image === undefined) image = await viaXclip(run);
|
|
140
|
+
// The Linux clipboard never receives Windows screenshots (Win+Shift+S).
|
|
141
|
+
if (!image && wsl) image = (await viaPowerShell(run)) ?? image;
|
|
142
|
+
if (image === undefined) image = await viaNative(nativeClipboard);
|
|
143
|
+
return image ?? null;
|
|
144
|
+
} catch {
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
@@ -42,6 +42,8 @@ export const COMMAND_PALETTE_CATALOG: readonly CommandPaletteCatalogGroup[] = [
|
|
|
42
42
|
{ command: "gentle:jobs", label: "Background jobs" },
|
|
43
43
|
{ command: "gentle:usage", label: "Subscription usage" },
|
|
44
44
|
{ command: "gentle:review-session-permission", label: "Review session permission" },
|
|
45
|
+
{ command: "gentle:voice", label: "Voice dictation · local Parakeet" },
|
|
46
|
+
{ command: "gentle:design", label: "Design · HTML artboards" },
|
|
45
47
|
],
|
|
46
48
|
},
|
|
47
49
|
{
|
|
@@ -45,7 +45,7 @@ type Git = (args: string[], cwd: string) => Promise<string | undefined>;
|
|
|
45
45
|
|
|
46
46
|
// Asynchronous, so the parent stays responsive while git answers.
|
|
47
47
|
const runGit: Git = (args, cwd) => new Promise((resolve) => {
|
|
48
|
-
execFile("git", args, { cwd, encoding: "utf8", timeout: 2_000 }, (error, stdout) => resolve(error ? undefined : stdout.trim()));
|
|
48
|
+
execFile("git", args, { cwd, encoding: "utf8", timeout: 2_000, windowsHide: true }, (error, stdout) => resolve(error ? undefined : stdout.trim()));
|
|
49
49
|
});
|
|
50
50
|
|
|
51
51
|
async function defaultBranch(cwd: string, git: Git): Promise<string | undefined> {
|
package/lib/context-backstop.ts
CHANGED
|
@@ -12,6 +12,9 @@ export const CONTEXT_BACKSTOP_TOKENS = 150_000;
|
|
|
12
12
|
export const CONTEXT_BACKSTOP_FRACTION = 0.15;
|
|
13
13
|
export const CONTEXT_BACKSTOP_TOKENS_ENV = "GENTLE_PI_CONTEXT_BACKSTOP_TOKENS";
|
|
14
14
|
export const CONTEXT_BACKSTOP_FRACTION_ENV = "GENTLE_PI_CONTEXT_BACKSTOP_FRACTION";
|
|
15
|
+
// Sampling window for the measured context (see createThrottledContextMeasure).
|
|
16
|
+
export const CONTEXT_BACKSTOP_MEASURE_MS_ENV = "GENTLE_PI_CONTEXT_BACKSTOP_MEASURE_MS";
|
|
17
|
+
export const CONTEXT_BACKSTOP_MEASURE_MS_DEFAULT = 5_000;
|
|
15
18
|
|
|
16
19
|
/** The part of Pi's `ctx.getContextUsage()` the backstop reads; null tokens are unknown (e.g. right after compaction). */
|
|
17
20
|
export interface MeasuredContext {
|
|
@@ -69,6 +72,39 @@ export function createContextBackstopSignal(env: Env = process.env): (session: u
|
|
|
69
72
|
};
|
|
70
73
|
}
|
|
71
74
|
|
|
75
|
+
export interface ThrottledContextMeasureOptions {
|
|
76
|
+
now?: () => number;
|
|
77
|
+
/** Injected window in ms; 0 samples on every call (tests). Omitted resolves the env, then the 5 s default. */
|
|
78
|
+
minIntervalMs?: number;
|
|
79
|
+
env?: Env;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* One measured context per session per window. `ctx.getContextUsage()` rebuilds the whole session projection
|
|
84
|
+
* (O(session) per call), so the backstop samples it instead of paying that on every tool result. The first
|
|
85
|
+
* sample of a session is immediate — a resumed session already past the backstop must not wait a window — and
|
|
86
|
+
* every call inside the window skips the measurement entirely, so the backstop sees `undefined` (unknown
|
|
87
|
+
* tokens change nothing). A throwing measure returns `undefined`; the failed attempt still consumed its
|
|
88
|
+
* window, so a persistently failing host cannot turn every tool result into a retry.
|
|
89
|
+
*/
|
|
90
|
+
export function createThrottledContextMeasure(options: ThrottledContextMeasureOptions = {}): (session: unknown, measure: () => MeasuredContext | undefined) => MeasuredContext | undefined {
|
|
91
|
+
const now = options.now ?? (() => Date.now());
|
|
92
|
+
const env = options.env ?? process.env;
|
|
93
|
+
const minIntervalMs = options.minIntervalMs ?? envNumber(env, CONTEXT_BACKSTOP_MEASURE_MS_ENV, positive, CONTEXT_BACKSTOP_MEASURE_MS_DEFAULT);
|
|
94
|
+
const lastMeasuredAt = new Map<unknown, number>();
|
|
95
|
+
return (session, measure) => {
|
|
96
|
+
const previous = lastMeasuredAt.get(session);
|
|
97
|
+
const at = now();
|
|
98
|
+
if (previous !== undefined && at - previous < minIntervalMs) return undefined;
|
|
99
|
+
lastMeasuredAt.set(session, at);
|
|
100
|
+
try {
|
|
101
|
+
return measure();
|
|
102
|
+
} catch {
|
|
103
|
+
return undefined;
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
72
108
|
/** Appends the notice after the (possibly already patched) tool output, keeping structured content and the error flag. */
|
|
73
109
|
export function appendContextBackstopNotice(
|
|
74
110
|
event: Pick<ToolResultEvent, "content" | "structuredContent">,
|