gentle-pi 4.3.0 → 4.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 +10 -7
- 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 +138 -155
- 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 +82 -0
- package/docs/gentle-shell.md +80 -10
- package/docs/install-wizard.md +30 -6
- package/docs/permission-modes.md +7 -2
- package/docs/readme-reference.md +72 -55
- 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 +187 -40
- package/extensions/gentle-ai.ts +1765 -1185
- package/extensions/gentle-design.ts +158 -0
- package/extensions/gentle-music.ts +354 -0
- package/extensions/gentle-shell.ts +418 -79
- 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 +3 -2
- 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 +141 -57
- package/lib/gentle-shell-launcher.ts +11 -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/jobs-sidebar-state.ts +22 -3
- package/lib/jobs-widget.ts +112 -0
- 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-freeze.ts +2 -2
- 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 +6 -7
- package/lib/shell-changes.ts +81 -5
- package/lib/shell-prompt.ts +4 -4
- package/lib/shell-sidebar-layout.ts +27 -7
- package/lib/shell-todo.ts +1 -1
- package/lib/verifier-admission.ts +5 -4
- package/lib/vim-editor-adapter.ts +14 -10
- 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 +140 -56
- package/runtime/gentle-shell-launcher.mjs +11 -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/bundled-install.mjs +209 -13
- package/scripts/gentle-ai-installer.mjs +10 -10
- package/scripts/installer-downloads.mjs +31 -7
- package/scripts/installer-preflight.mjs +6 -4
- package/scripts/installer-windows.mjs +37 -7
- package/scripts/main-channel.mjs +91 -17
- package/scripts/pi-update.mjs +37 -7
- 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 +276 -5
- package/tests/clipboard-image.test.ts +114 -0
- package/tests/command-palette.test.ts +4 -4
- 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 +369 -24
- package/tests/gentle-ai-binary.test.ts +1 -1
- package/tests/gentle-ai-installer.test.ts +51 -51
- package/tests/gentle-ai.test.ts +928 -448
- package/tests/gentle-design.test.ts +1313 -0
- package/tests/gentle-music.test.ts +452 -0
- package/tests/gentle-shell-bundled.test.ts +321 -248
- package/tests/gentle-shell-launcher.test.ts +29 -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 +227 -27
- 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-downloads.test.ts +32 -0
- 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/jobs-widget.test.ts +138 -0
- package/tests/main-channel-upgrade.test.ts +232 -2
- package/tests/main-channel.test.ts +140 -21
- package/tests/model-profiles-preservation.test.ts +2 -2
- 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 +104 -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/runtime-harness.mjs +188 -211
- 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-profile-freeze.test.ts +6 -4
- package/tests/session-scratch.test.ts +68 -2
- package/tests/session-worktree-registry.test.ts +13 -1
- package/tests/shell-bar.test.ts +18 -6
- 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 +67 -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/vim-editor-adapter.test.ts +2 -2
- 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
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import type { ImageContent } from "@earendil-works/pi-ai";
|
|
2
|
+
import { getImageDimensions, type ImageDimensions } from "@earendil-works/pi-tui";
|
|
3
|
+
import type { ClipboardImage } from "./clipboard-image.ts";
|
|
4
|
+
|
|
5
|
+
// Pasted images live in the draft as atomic `[Image#N]` chips (gentle-shell#2464).
|
|
6
|
+
// The bytes stay in memory: a chip deleted from the draft is never sent, and
|
|
7
|
+
// the chips still in the text become real image attachments on submit.
|
|
8
|
+
//
|
|
9
|
+
// The token has no whitespace on purpose: Pi's word motions, word deletion and
|
|
10
|
+
// word wrap treat any segment containing a space as whitespace unless it is
|
|
11
|
+
// one of Pi's own paste markers, so `[Image #1]` would be skipped and eaten
|
|
12
|
+
// together with the word before it.
|
|
13
|
+
|
|
14
|
+
export const IMAGE_TOKEN_PATTERN = /\[Image#(\d+)\]/g;
|
|
15
|
+
|
|
16
|
+
export interface ImageAttachment {
|
|
17
|
+
id: number;
|
|
18
|
+
token: string;
|
|
19
|
+
mimeType: string;
|
|
20
|
+
byteLength: number;
|
|
21
|
+
base64: string;
|
|
22
|
+
dimensions?: ImageDimensions;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface SubmittedImages {
|
|
26
|
+
text: string;
|
|
27
|
+
images: ImageContent[];
|
|
28
|
+
/** Chips the active model cannot receive; their tokens become a plain note. */
|
|
29
|
+
omitted: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface TextSpan { start: number; end: number }
|
|
33
|
+
|
|
34
|
+
interface Segment { segment: string; index: number; input: string }
|
|
35
|
+
|
|
36
|
+
export function imageToken(id: number): string {
|
|
37
|
+
return `[Image#${id}]`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export class ImageAttachments {
|
|
41
|
+
private readonly entries = new Map<number, ImageAttachment>();
|
|
42
|
+
// Never reset: Pi's prompt history keeps the raw submitted text, so a
|
|
43
|
+
// reused id would let a recalled `[Image#1]` bind to a different image.
|
|
44
|
+
private counter = 0;
|
|
45
|
+
|
|
46
|
+
get size(): number { return this.entries.size; }
|
|
47
|
+
|
|
48
|
+
has(id: number): boolean { return this.entries.has(id); }
|
|
49
|
+
|
|
50
|
+
add(image: ClipboardImage): ImageAttachment {
|
|
51
|
+
const id = ++this.counter;
|
|
52
|
+
const base64 = Buffer.from(image.bytes).toString("base64");
|
|
53
|
+
const attachment: ImageAttachment = {
|
|
54
|
+
id,
|
|
55
|
+
token: imageToken(id),
|
|
56
|
+
mimeType: image.mimeType,
|
|
57
|
+
byteLength: image.bytes.length,
|
|
58
|
+
base64,
|
|
59
|
+
dimensions: getImageDimensions(base64, image.mimeType) ?? undefined,
|
|
60
|
+
};
|
|
61
|
+
this.entries.set(id, attachment);
|
|
62
|
+
return attachment;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Registered chips in the order they appear in `text`, each once. */
|
|
66
|
+
referenced(text: string): ImageAttachment[] {
|
|
67
|
+
const seen = new Set<number>();
|
|
68
|
+
const result: ImageAttachment[] = [];
|
|
69
|
+
for (const match of text.matchAll(IMAGE_TOKEN_PATTERN)) {
|
|
70
|
+
const id = Number(match[1]);
|
|
71
|
+
const attachment = this.entries.get(id);
|
|
72
|
+
if (!attachment || seen.has(id)) continue;
|
|
73
|
+
seen.add(id);
|
|
74
|
+
result.push(attachment);
|
|
75
|
+
}
|
|
76
|
+
return result;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Resolve a submitted draft. Returns undefined when it holds no registered
|
|
81
|
+
* chip, so ordinary prompts pass through untouched.
|
|
82
|
+
*
|
|
83
|
+
* Pi can run input handlers after the editor already holds a new draft
|
|
84
|
+
* (a prompt queued during compaction), so only chips the current draft
|
|
85
|
+
* still shows survive; everything else, sent or deleted, is released.
|
|
86
|
+
*/
|
|
87
|
+
submit(text: string, modelAcceptsImages: boolean, currentDraft = ""): SubmittedImages | undefined {
|
|
88
|
+
const attachments = this.referenced(text);
|
|
89
|
+
const keep = new Set(this.referenced(currentDraft).map((attachment) => attachment.id));
|
|
90
|
+
for (const id of [...this.entries.keys()]) if (!keep.has(id)) this.entries.delete(id);
|
|
91
|
+
if (attachments.length === 0) return undefined;
|
|
92
|
+
if (modelAcceptsImages) {
|
|
93
|
+
return { text, images: attachments.map((attachment) => ({ type: "image", data: attachment.base64, mimeType: attachment.mimeType })), omitted: 0 };
|
|
94
|
+
}
|
|
95
|
+
const ids = new Set(attachments.map((attachment) => attachment.id));
|
|
96
|
+
const noted = text.replace(IMAGE_TOKEN_PATTERN, (token, id: string) => ids.has(Number(id)) ? `${token.slice(0, -1)} omitted: the current model does not accept images]` : token);
|
|
97
|
+
return { text: noted, images: [], omitted: attachments.length };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Drop every pending image; numbering keeps counting up. */
|
|
101
|
+
clear(): void {
|
|
102
|
+
this.entries.clear();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Character spans of the registered chips in `text`. */
|
|
106
|
+
spans(text: string): TextSpan[] {
|
|
107
|
+
if (this.entries.size === 0 || !text.includes("[Image#")) return [];
|
|
108
|
+
const spans: TextSpan[] = [];
|
|
109
|
+
for (const match of text.matchAll(IMAGE_TOKEN_PATTERN)) {
|
|
110
|
+
if (this.entries.has(Number(match[1]))) spans.push({ start: match.index, end: match.index + match[0].length });
|
|
111
|
+
}
|
|
112
|
+
return spans;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Merge every registered chip into one segment, so cursor movement, word
|
|
117
|
+
* wrap and deletion treat it as a single unit (one Backspace removes it).
|
|
118
|
+
* Segments that already span several graphemes, such as Pi's own paste
|
|
119
|
+
* markers, pass through unchanged.
|
|
120
|
+
*/
|
|
121
|
+
segment(text: string, segments: Iterable<Segment>): Iterable<Segment> {
|
|
122
|
+
const spans = this.spans(text);
|
|
123
|
+
if (spans.length === 0) return segments;
|
|
124
|
+
const merged: Segment[] = [];
|
|
125
|
+
let span = 0;
|
|
126
|
+
for (const segment of segments) {
|
|
127
|
+
while (span < spans.length && spans[span]!.end <= segment.index) span++;
|
|
128
|
+
const current = spans[span];
|
|
129
|
+
if (current && segment.index >= current.start && segment.index < current.end) {
|
|
130
|
+
if (segment.index === current.start) merged.push({ segment: text.slice(current.start, current.end), index: current.start, input: text });
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
merged.push(segment);
|
|
134
|
+
}
|
|
135
|
+
return merged;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function formatBytes(bytes: number): string {
|
|
140
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
141
|
+
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
|
|
142
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** `png · 1280×720 · 84 KB`: the chip's description when no preview is drawn. */
|
|
146
|
+
export function describeAttachment(attachment: Pick<ImageAttachment, "mimeType" | "byteLength" | "dimensions">): string {
|
|
147
|
+
const format = attachment.mimeType.replace(/^image\//, "").replace("jpeg", "jpg");
|
|
148
|
+
const size = attachment.dimensions ? `${attachment.dimensions.widthPx}×${attachment.dimensions.heightPx}` : undefined;
|
|
149
|
+
return [format, size, formatBytes(attachment.byteLength)].filter(Boolean).join(" · ");
|
|
150
|
+
}
|
|
@@ -153,6 +153,36 @@ function isTextContent(part: { type?: unknown }): part is TextContent {
|
|
|
153
153
|
return part.type === "text";
|
|
154
154
|
}
|
|
155
155
|
|
|
156
|
+
/** Completion diagnostics are facts only; never copy provider text, errors, or payloads. */
|
|
157
|
+
function emptyOutputEvidence(model: Model<Api>, assistant: AssistantMessage): Record<string, unknown> {
|
|
158
|
+
const evidence: Record<string, unknown> = { reviewerModel: `${model.provider}/${model.id}`.slice(0, 512) };
|
|
159
|
+
if (["pending", "stop", "length", "toolUse", "error", "aborted", "deferred"].includes(assistant.stopReason)) {
|
|
160
|
+
evidence.stopReason = assistant.stopReason;
|
|
161
|
+
}
|
|
162
|
+
const addCounter = (target: Record<string, number>, key: string, value: unknown) => {
|
|
163
|
+
if (typeof value === "number" && Number.isSafeInteger(value) && value >= 0) target[key] = value;
|
|
164
|
+
};
|
|
165
|
+
const usage: Record<string, number> = {};
|
|
166
|
+
for (const key of ["input", "output", "reasoning", "cacheRead", "cacheWrite", "totalTokens"] as const) {
|
|
167
|
+
addCounter(usage, key, assistant.usage?.[key]);
|
|
168
|
+
}
|
|
169
|
+
if (Object.keys(usage).length > 0) evidence.usage = usage;
|
|
170
|
+
const text = assistant.content.filter(isTextContent);
|
|
171
|
+
const thinking = assistant.content.filter((part) => part.type === "thinking");
|
|
172
|
+
const content: Record<string, number> = {};
|
|
173
|
+
addCounter(content, "blocks", assistant.content.length);
|
|
174
|
+
addCounter(content, "textBlocks", text.length);
|
|
175
|
+
addCounter(content, "thinkingBlocks", thinking.length);
|
|
176
|
+
if (text.every((part) => typeof part.text === "string")) {
|
|
177
|
+
addCounter(content, "textChars", text.reduce((sum, part) => sum + part.text.length, 0));
|
|
178
|
+
}
|
|
179
|
+
if (thinking.every((part) => typeof part.thinking === "string")) {
|
|
180
|
+
addCounter(content, "thinkingChars", thinking.reduce((sum, part) => sum + part.thinking.length, 0));
|
|
181
|
+
}
|
|
182
|
+
evidence.content = content;
|
|
183
|
+
return evidence;
|
|
184
|
+
}
|
|
185
|
+
|
|
156
186
|
/**
|
|
157
187
|
* Mirrors pi's main-loop OpenCode attribution condition exactly
|
|
158
188
|
* (core/provider-attribution.js#getSessionHeaders): the model's provider is
|
|
@@ -329,10 +359,11 @@ export async function runInProcessReviewer(request: InProcessReviewerRequest, de
|
|
|
329
359
|
|
|
330
360
|
const text = assistant.content.filter(isTextContent).map((part) => part.text).join("");
|
|
331
361
|
if (text.length === 0) {
|
|
362
|
+
const evidence = emptyOutputEvidence(model, assistant);
|
|
332
363
|
return refuse(
|
|
333
364
|
INPROCESS_REVIEWER_FAILURE.EMPTY_OUTPUT,
|
|
334
|
-
`Reviewer produced no text for ${request.routingKey} (stopReason: ${
|
|
335
|
-
|
|
365
|
+
`Reviewer produced no text for ${request.routingKey} (stopReason: ${evidence.stopReason ?? "unknown"}).`,
|
|
366
|
+
evidence,
|
|
336
367
|
);
|
|
337
368
|
}
|
|
338
369
|
|
|
@@ -2,10 +2,23 @@
|
|
|
2
2
|
export const JOBS_SIDEBAR_EVENT = "gentle-ai:jobs-sidebar";
|
|
3
3
|
export const JOBS_STATUS_KEY = "gentle-jobs";
|
|
4
4
|
|
|
5
|
+
const COLLAPSE_KEY_DEFAULT = "ctrl+shift+j";
|
|
6
|
+
|
|
7
|
+
export function jobsCollapseKey(env: NodeJS.ProcessEnv = process.env): string | undefined {
|
|
8
|
+
const value = env.GENTLE_PI_JOBS_KEY?.trim();
|
|
9
|
+
if (value === undefined) return COLLAPSE_KEY_DEFAULT;
|
|
10
|
+
return value === "" || value.toLowerCase() === "off" ? undefined : value;
|
|
11
|
+
}
|
|
12
|
+
|
|
5
13
|
export interface RunningJobDisplay {
|
|
6
14
|
id: string;
|
|
7
15
|
label: string;
|
|
8
16
|
startedAt: number;
|
|
17
|
+
// Finished-tail fields; optional so older publishers that only list running
|
|
18
|
+
// jobs keep validating (the Agents-card pattern: results linger briefly).
|
|
19
|
+
status?: "exited" | "stopped" | "failed";
|
|
20
|
+
endedAt?: number;
|
|
21
|
+
exitCode?: number | null;
|
|
9
22
|
}
|
|
10
23
|
|
|
11
24
|
export interface JobsSidebarSnapshot {
|
|
@@ -14,7 +27,13 @@ export interface JobsSidebarSnapshot {
|
|
|
14
27
|
|
|
15
28
|
export function isJobsSidebarSnapshot(value: unknown): value is JobsSidebarSnapshot {
|
|
16
29
|
if (!value || typeof value !== "object" || !Array.isArray((value as JobsSidebarSnapshot).jobs)) return false;
|
|
17
|
-
return (value as JobsSidebarSnapshot).jobs.every((job) =>
|
|
18
|
-
|
|
19
|
-
typeof job.
|
|
30
|
+
return (value as JobsSidebarSnapshot).jobs.every((job) => {
|
|
31
|
+
if (!job || typeof job !== "object") return false;
|
|
32
|
+
if (typeof job.id !== "string" || job.id.length === 0 || typeof job.label !== "string") return false;
|
|
33
|
+
if (typeof job.startedAt !== "number" || !Number.isFinite(job.startedAt)) return false;
|
|
34
|
+
if (job.status !== undefined && job.status !== "exited" && job.status !== "stopped" && job.status !== "failed") return false;
|
|
35
|
+
if (job.endedAt !== undefined && (typeof job.endedAt !== "number" || !Number.isFinite(job.endedAt))) return false;
|
|
36
|
+
if (job.exitCode !== undefined && typeof job.exitCode !== "number" && job.exitCode !== null) return false;
|
|
37
|
+
return true;
|
|
38
|
+
});
|
|
20
39
|
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { visibleWidth } from "@earendil-works/pi-tui";
|
|
2
|
+
import { JOB_GLYPH, formatJobDuration } from "./background-jobs-tools.ts";
|
|
3
|
+
import type { RunningJobDisplay } from "./jobs-sidebar-state.ts";
|
|
4
|
+
import { paintHoverable } from "./shell-hover.ts";
|
|
5
|
+
import { CARD_TONE, panelInnerWidth, renderCard, type CardTheme, type CardTone } from "./shell-card.ts";
|
|
6
|
+
import { sanitizeTerminalText } from "./terminal-theme.ts";
|
|
7
|
+
|
|
8
|
+
// Gentle Jobs widget: the sidebar card for this session's background jobs.
|
|
9
|
+
// Reads the running-job display snapshot only (id, label, startedAt), the same
|
|
10
|
+
// data the Status card used to print, so drawing it costs nothing per event.
|
|
11
|
+
// One row per job: glyph, label, elapsed right-aligned. Jobs are transient —
|
|
12
|
+
// the card paints nothing while no job runs, and the overlay (/gentle:jobs)
|
|
13
|
+
// stays the full view with stop controls and output tails.
|
|
14
|
+
|
|
15
|
+
export const JOBS_GLYPH = JOB_GLYPH;
|
|
16
|
+
|
|
17
|
+
// Finished jobs linger like the Agents card's results: newest 3 for a minute,
|
|
18
|
+
// so an exit is visible long enough to read before the card empties itself.
|
|
19
|
+
export const JOBS_FINISHED_TTL_MS = 60_000;
|
|
20
|
+
const MAX_FINISHED = 3;
|
|
21
|
+
|
|
22
|
+
export interface JobsWidgetOptions {
|
|
23
|
+
collapsed: boolean;
|
|
24
|
+
collapseKey?: string;
|
|
25
|
+
// Paint the clickable "▾ Collapse" / "▸ Expand" control in the header, the
|
|
26
|
+
// same control the Agents and Todos cards carry.
|
|
27
|
+
collapseControl?: boolean;
|
|
28
|
+
hovered?: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const GLYPH_ROLE = "accent";
|
|
32
|
+
const NAME_ROLE = "text";
|
|
33
|
+
const META_ROLE = "dim";
|
|
34
|
+
const GLYPH_GAP = " ";
|
|
35
|
+
const ELLIPSIS = "…";
|
|
36
|
+
|
|
37
|
+
// Running jobs newest first, matching the /gentle:jobs running group and the
|
|
38
|
+
// old Status group this card replaces; finished tails follow, newest first.
|
|
39
|
+
export function widgetJobs(jobs: readonly RunningJobDisplay[], now: number): RunningJobDisplay[] {
|
|
40
|
+
const running = jobs.filter((job) => job.status === undefined).sort((a, b) => b.startedAt - a.startedAt);
|
|
41
|
+
const finished = jobs.filter((job) => job.status !== undefined && now - (job.endedAt ?? 0) < JOBS_FINISHED_TTL_MS)
|
|
42
|
+
.sort((a, b) => (b.endedAt ?? 0) - (a.endedAt ?? 0)).slice(0, MAX_FINISHED);
|
|
43
|
+
return [...running, ...finished];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function clean(text: string): string {
|
|
47
|
+
return sanitizeTerminalText(text).replace(/\s+/g, " ");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function clip(text: string, width: number): string {
|
|
51
|
+
if (width <= 0) return "";
|
|
52
|
+
if (visibleWidth(text) <= width) return text;
|
|
53
|
+
// Plain clip, mirroring the status bar: pi's truncateToWidth wraps the
|
|
54
|
+
// result in resets, which would end up inside a painted segment.
|
|
55
|
+
let clipped = "";
|
|
56
|
+
for (const char of text) {
|
|
57
|
+
if (visibleWidth(clipped + char) > width - 1) break;
|
|
58
|
+
clipped += char;
|
|
59
|
+
}
|
|
60
|
+
return `${clipped}${ELLIPSIS}`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// The /gentle:jobs glyph roles: running keeps the spinner, exits mirror the
|
|
64
|
+
// overlay's outcome marks.
|
|
65
|
+
function glyph(job: RunningJobDisplay): [string, string] {
|
|
66
|
+
if (job.status === "stopped") return ["–", "dim"];
|
|
67
|
+
if (job.status === "failed") return ["✗", "error"];
|
|
68
|
+
if (job.status === "exited") return job.exitCode === 0 ? ["✓", "success"] : ["✗", "error"];
|
|
69
|
+
return [JOBS_GLYPH, GLYPH_ROLE];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function counts(jobs: readonly RunningJobDisplay[]): string {
|
|
73
|
+
const running = jobs.filter((job) => job.status === undefined).length;
|
|
74
|
+
const finished = jobs.length - running;
|
|
75
|
+
const parts: string[] = [];
|
|
76
|
+
if (running > 0) parts.push(running === 1 ? "1 running" : `${running} running`);
|
|
77
|
+
if (finished > 0) parts.push(finished === 1 ? "1 finished" : `${finished} finished`);
|
|
78
|
+
return parts.join(" · ");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The tone the card paints, so a host can locate its header row. */
|
|
82
|
+
export function jobsCardTone(_jobs: readonly RunningJobDisplay[]): CardTone {
|
|
83
|
+
return CARD_TONE.INFO;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function renderJobsCard(jobs: readonly RunningJobDisplay[], theme: CardTheme, width: number, now: number, options: JobsWidgetOptions): string[] {
|
|
87
|
+
const shown = widgetJobs(jobs, now);
|
|
88
|
+
if (shown.length === 0) return [];
|
|
89
|
+
const tone = jobsCardTone(shown);
|
|
90
|
+
const inner = panelInnerWidth(theme, width, tone);
|
|
91
|
+
const listed = options.collapsed ? [shown[0]!] : shown;
|
|
92
|
+
const body = listed.map((job) => {
|
|
93
|
+
// A finished row freezes its elapsed at the end; a running one ticks.
|
|
94
|
+
const [mark, role] = glyph(job);
|
|
95
|
+
const elapsed = formatJobDuration(Math.max(0, (job.status === undefined ? now : job.endedAt ?? now) - job.startedAt));
|
|
96
|
+
const label = clip(clean(job.label), Math.max(0, inner - visibleWidth(elapsed) - GLYPH_GAP.length - 1));
|
|
97
|
+
const head = `${theme.fg(role, mark)}${GLYPH_GAP}${theme.fg(NAME_ROLE, label)}`;
|
|
98
|
+
const tail = theme.fg(META_ROLE, " ".repeat(Math.max(1, inner - visibleWidth(head) - visibleWidth(elapsed))) + elapsed);
|
|
99
|
+
return `${head}${tail}`;
|
|
100
|
+
});
|
|
101
|
+
if (options.collapsed && shown.length > 1) body.push(theme.fg(META_ROLE, `${ELLIPSIS} ${shown.length - 1} more`));
|
|
102
|
+
const icon = options.collapsed ? "▸" : "▾";
|
|
103
|
+
const control = `${icon} ${width >= 28 ? (options.collapsed ? "Expand" : "Collapse") : ""}`.trimEnd();
|
|
104
|
+
const title = options.collapseControl ? `Jobs ${paintHoverable(theme, control, options.hovered ?? false, "accent")}` : "Jobs";
|
|
105
|
+
const hint = options.collapsed && options.collapseKey ? `${options.collapseKey} expand` : undefined;
|
|
106
|
+
return renderCard(
|
|
107
|
+
{ title, subtitle: counts(shown), body, tone, glyph: JOBS_GLYPH },
|
|
108
|
+
theme,
|
|
109
|
+
width,
|
|
110
|
+
{ expanded: true, hint, panel: true },
|
|
111
|
+
);
|
|
112
|
+
}
|
package/lib/nan-provider.ts
CHANGED
|
@@ -57,6 +57,8 @@ const CHAT_MODELS: NanChatModelConfig[] = ([
|
|
|
57
57
|
{
|
|
58
58
|
id: "deepseek-v4-flash", name: "DeepSeek V4 Flash", input: ["text", "image"], contextWindow: 1_000_000,
|
|
59
59
|
thinkingLevelMap: EXTENDED_THINKING_LEVEL_MAP, maxTokens: 16_384,
|
|
60
|
+
// NaN's DeepSeek route enforces this field, not max_completion_tokens.
|
|
61
|
+
compat: { maxTokensField: "max_tokens" },
|
|
60
62
|
},
|
|
61
63
|
{
|
|
62
64
|
id: "glm5.3-flash", name: "GLM 5.3 Flash", input: ["text", "image"], contextWindow: 1_000_000,
|
|
@@ -96,6 +98,7 @@ function cloneModel(model: NanChatModelConfig): NanChatModelConfig {
|
|
|
96
98
|
input: [...model.input],
|
|
97
99
|
cost: { ...model.cost },
|
|
98
100
|
thinkingLevelMap: model.thinkingLevelMap ? { ...model.thinkingLevelMap } : undefined,
|
|
101
|
+
...(model.compat ? { compat: { ...model.compat } } : {}),
|
|
99
102
|
};
|
|
100
103
|
}
|
|
101
104
|
|
package/lib/native-review-cli.ts
CHANGED
|
@@ -202,10 +202,10 @@ export interface NativeReviewModeResult {
|
|
|
202
202
|
// stream while still succeeding. That last qualifier is what keeps this list
|
|
203
203
|
// honest: v2.4.0 deleted reviewConsentSkippedDefaultProvenance ("Reviews are
|
|
204
204
|
// on by default; this was never explicitly chosen. ..."), which used to ride
|
|
205
|
-
// with the skip notice whenever the resolved mode source was `default`.
|
|
206
|
-
//
|
|
207
|
-
//
|
|
208
|
-
//
|
|
205
|
+
// with the skip notice whenever the resolved mode source was `default`. Reviews
|
|
206
|
+
// are on by default again since gentle-ai v4.0.0, but the pinned binary no
|
|
207
|
+
// longer has that function and cannot emit the line, so it is removed here
|
|
208
|
+
// rather than left as dead tolerance. Multi-
|
|
209
209
|
// line stderr is still expected in principle — these are separate Fprintln
|
|
210
210
|
// calls, never one joined string — which is why membership is per line.
|
|
211
211
|
export const REVIEW_CONSENT_NOTICES = Object.freeze([
|
|
@@ -1090,6 +1090,13 @@ export const NATIVE_CLI_CONTRACTS = Object.freeze({
|
|
|
1090
1090
|
// 9d2927a84ba9a596f69029e3f854b16f86ca4c8b32cc5d63683764ccf2fb394b.
|
|
1091
1091
|
// No new negotiated capability identity is asserted.
|
|
1092
1092
|
"4.2.0": Object.freeze({ start: true, finalize: true, validate: true, bindSdd: true, status: true, inventory: true, reclaim: true, recover: true, abandon: true, quarantineLegacy: true, reconcileAuthority: true, repairLegacyAlias: true, mode: true, riskEvidence: false, hint: false, delivery: true }),
|
|
1093
|
+
// v4.3.1 repeats 4.2.0: the published binary still advertises
|
|
1094
|
+
// capabilities/v2.6 under review-integration/v2, and the provider contract
|
|
1095
|
+
// stays at 1.4.0, with published tarball SHA-256
|
|
1096
|
+
// 58450471280aeb357443d6a28c819132d8c8eb78dd25b12bac64296b2f8c27c2.
|
|
1097
|
+
// The v4.3.0 tag has no release and is never pinned.
|
|
1098
|
+
// No new negotiated capability identity is asserted.
|
|
1099
|
+
"4.3.1": Object.freeze({ start: true, finalize: true, validate: true, bindSdd: true, status: true, inventory: true, reclaim: true, recover: true, abandon: true, quarantineLegacy: true, reconcileAuthority: true, repairLegacyAlias: true, mode: true, riskEvidence: false, hint: false, delivery: true }),
|
|
1093
1100
|
});
|
|
1094
1101
|
|
|
1095
1102
|
export interface NativeReviewProcessDiagnostics {
|
|
@@ -8,6 +8,14 @@
|
|
|
8
8
|
* worker as a Node child with fixed literal flags and a scrubbed environment.
|
|
9
9
|
* Linux only; macOS/Windows never spawn. No real audio, no real Pulse stream
|
|
10
10
|
* beyond the worker's own fake-test sockets; there is no physical-playback claim.
|
|
11
|
+
*
|
|
12
|
+
* Playback persistence: plays share ONE long-lived `serve` worker per bridge
|
|
13
|
+
* instance (line-delimited JSON over stdin, one bounded envelope per play).
|
|
14
|
+
* A crashed worker is replaced by a fresh spawn on the next play; abort,
|
|
15
|
+
* timeout, a malformed serve line and `dispose()` all SIGKILL it once and
|
|
16
|
+
* settle only on `close`. Requests are serialized through the worker with a
|
|
17
|
+
* bounded pending queue. Teardown is the owner's `dispose()` (session shutdown),
|
|
18
|
+
* plus the worker's own stdin-EOF exit when the owner process dies.
|
|
11
19
|
*/
|
|
12
20
|
import { spawn } from "node:child_process";
|
|
13
21
|
import type { EventEmitter } from "node:events";
|
|
@@ -21,18 +29,21 @@ const MAX_STDERR = 4096;
|
|
|
21
29
|
const DEFAULT_PROBE_TIMEOUT_MS = 1200;
|
|
22
30
|
/** 10 s maximum validated duration plus a 1 s termination margin; must outlive every accepted sound. */
|
|
23
31
|
const DEFAULT_PLAY_TIMEOUT_MS = 11000;
|
|
32
|
+
/** Bounded pending plays per serve worker (1 active + queue), mirroring the repo's small caps. */
|
|
33
|
+
const MAX_PENDING_PLAYS = 8;
|
|
24
34
|
|
|
25
35
|
export interface NativePulseProbeResult { readonly available: boolean; readonly formats: readonly ("wav")[]; }
|
|
26
36
|
export interface NativePulseSpawnOptions {
|
|
27
37
|
readonly shell: false;
|
|
28
38
|
readonly windowsHide: true;
|
|
29
39
|
readonly detached: false;
|
|
30
|
-
readonly stdio: readonly
|
|
40
|
+
readonly stdio: readonly ("ignore" | "pipe")[];
|
|
31
41
|
readonly env: NodeJS.ProcessEnv;
|
|
32
42
|
}
|
|
33
43
|
export interface NativePulseChild {
|
|
34
44
|
stdout: EventEmitter | null;
|
|
35
45
|
stderr: EventEmitter | null;
|
|
46
|
+
stdin: { write(chunk: string): boolean } | null;
|
|
36
47
|
kill(signal: string): boolean;
|
|
37
48
|
once(event: string, listener: (...args: unknown[]) => void): EventEmitter;
|
|
38
49
|
}
|
|
@@ -49,6 +60,25 @@ export interface NativePulseOptions {
|
|
|
49
60
|
|
|
50
61
|
interface Envelope { schema?: unknown; ok?: unknown; available?: unknown; formats?: unknown; played?: unknown; }
|
|
51
62
|
interface RunResult { readonly stdout: string; readonly stderr: string; readonly aborted: boolean; readonly timedOut: boolean; }
|
|
63
|
+
/** One queued/active play through the persistent serve worker. */
|
|
64
|
+
interface ServeRequest {
|
|
65
|
+
readonly snapshot: string;
|
|
66
|
+
readonly signal: AbortSignal | undefined;
|
|
67
|
+
readonly resolve: () => void;
|
|
68
|
+
readonly reject: (error: Error) => void;
|
|
69
|
+
timer?: ReturnType<typeof setTimeout> | number;
|
|
70
|
+
onAbort?: () => void;
|
|
71
|
+
aborted: boolean;
|
|
72
|
+
timedOut: boolean;
|
|
73
|
+
settled: boolean;
|
|
74
|
+
}
|
|
75
|
+
interface ServeWorker {
|
|
76
|
+
readonly child: NativePulseChild;
|
|
77
|
+
line: string;
|
|
78
|
+
active?: ServeRequest;
|
|
79
|
+
readonly queued: ServeRequest[];
|
|
80
|
+
dead: boolean;
|
|
81
|
+
}
|
|
52
82
|
|
|
53
83
|
/** Distinct from other native failures so the owner can treat a false permit as "not started". */
|
|
54
84
|
export class NativePulseNotPermittedError extends Error {
|
|
@@ -58,9 +88,8 @@ export class NativePulseNotPermittedError extends Error {
|
|
|
58
88
|
function unrefTimer(handle: ReturnType<typeof setTimeout> | number): void { (handle as { unref?: () => void }).unref?.(); }
|
|
59
89
|
function abortError(): Error { const error = new Error("Native pulse: aborted"); error.name = "AbortError"; return error; }
|
|
60
90
|
function defaultSpawn(executable: string, args: string[], options: NativePulseSpawnOptions): NativePulseChild {
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
return spawn(executable, args, { ...options, stdio }) as unknown as NativePulseChild;
|
|
91
|
+
// SAFETY: Node's ChildProcess exposes stdout/stderr/stdin/once/kill compatible with the narrower NativePulseChild interface; the cast only bridges that structural narrowing.
|
|
92
|
+
return spawn(executable, args, { ...options, stdio: [...options.stdio] }) as unknown as NativePulseChild;
|
|
64
93
|
}
|
|
65
94
|
function parseEnvelope(stdout: string): Envelope | undefined {
|
|
66
95
|
const text = stdout.trim();
|
|
@@ -78,6 +107,7 @@ export class NativePulsePlayer {
|
|
|
78
107
|
private readonly setTimer: NonNullable<NativePulseOptions["setTimeout"]>;
|
|
79
108
|
private readonly clearTimer: NonNullable<NativePulseOptions["clearTimeout"]>;
|
|
80
109
|
private formats: readonly ("wav")[] = [];
|
|
110
|
+
private worker?: ServeWorker;
|
|
81
111
|
|
|
82
112
|
constructor(options: NativePulseOptions = {}) {
|
|
83
113
|
this.options = { ...options };
|
|
@@ -107,10 +137,9 @@ export class NativePulsePlayer {
|
|
|
107
137
|
}
|
|
108
138
|
|
|
109
139
|
/** Spawns synchronously; resolves ONLY on the child `close` event (never exit/error/kill). */
|
|
110
|
-
private run(
|
|
140
|
+
private run(signal: AbortSignal | undefined, timeoutMs: number): Promise<RunResult> {
|
|
111
141
|
return new Promise<RunResult>(resolve => {
|
|
112
|
-
const args = [...FIXED_NODE_FLAGS, this.workerPath(),
|
|
113
|
-
if (snapshot !== undefined) args.push(snapshot);
|
|
142
|
+
const args = [...FIXED_NODE_FLAGS, this.workerPath(), "probe"];
|
|
114
143
|
const child = (this.options.spawn ?? defaultSpawn)(process.execPath, args, {
|
|
115
144
|
shell: false, windowsHide: true, detached: false, stdio: ["ignore", "pipe", "pipe"], env: this.childEnv(),
|
|
116
145
|
});
|
|
@@ -142,21 +171,128 @@ export class NativePulsePlayer {
|
|
|
142
171
|
/** Offline/malformed/unsupported probe resolves unavailable; it never throws private IPC. */
|
|
143
172
|
async probe(signal?: AbortSignal): Promise<NativePulseProbeResult> {
|
|
144
173
|
if (!this.supportsTarget() || signal?.aborted) { this.formats = []; return { available: false, formats: [] }; }
|
|
145
|
-
const result = await this.run(
|
|
174
|
+
const result = await this.run(signal, this.options.probeTimeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS);
|
|
146
175
|
const parsed = result.aborted || result.timedOut ? { available: false, formats: [] as const } : this.parseProbe(result.stdout);
|
|
147
176
|
this.formats = parsed.available ? ["wav"] : [];
|
|
148
177
|
return parsed;
|
|
149
178
|
}
|
|
150
179
|
|
|
151
|
-
/**
|
|
180
|
+
/** Spawns or reuses the persistent `serve` worker. A dead worker is replaced by a fresh spawn. */
|
|
181
|
+
private ensureWorker(): ServeWorker {
|
|
182
|
+
if (this.worker && !this.worker.dead) return this.worker;
|
|
183
|
+
const child = (this.options.spawn ?? defaultSpawn)(process.execPath, [...FIXED_NODE_FLAGS, this.workerPath(), "serve"], {
|
|
184
|
+
shell: false, windowsHide: true, detached: false, stdio: ["pipe", "pipe", "pipe"], env: this.childEnv(),
|
|
185
|
+
});
|
|
186
|
+
const worker: ServeWorker = { child, line: "", queued: [], dead: false };
|
|
187
|
+
this.worker = worker;
|
|
188
|
+
child.stdout?.on("data", chunk => this.onServeStdout(worker, chunk));
|
|
189
|
+
child.stderr?.on("data", () => { /* drained: a full stderr pipe must never stall the worker */ });
|
|
190
|
+
child.once("close", () => this.closeWorker(worker));
|
|
191
|
+
child.once("error", () => { /* Node emits close after spawn errors; keep waiting */ });
|
|
192
|
+
return worker;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
private armGuards(request: ServeRequest): void {
|
|
196
|
+
const timer = this.setTimer(() => { request.timedOut = true; this.killWorker(); },
|
|
197
|
+
this.options.playTimeoutMs ?? DEFAULT_PLAY_TIMEOUT_MS);
|
|
198
|
+
unrefTimer(timer);
|
|
199
|
+
request.timer = timer;
|
|
200
|
+
request.onAbort = () => { request.aborted = true; this.killWorker(); };
|
|
201
|
+
request.signal?.addEventListener("abort", request.onAbort, { once: true });
|
|
202
|
+
if (request.signal?.aborted) request.onAbort();
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
private settleSuccess(request: ServeRequest): void {
|
|
206
|
+
if (request.settled) return;
|
|
207
|
+
request.settled = true;
|
|
208
|
+
if (request.timer !== undefined) this.clearTimer(request.timer);
|
|
209
|
+
if (request.onAbort) request.signal?.removeEventListener("abort", request.onAbort);
|
|
210
|
+
request.resolve();
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Private failure: aborted requests reject with the abort error, everything else with the generic one. */
|
|
214
|
+
private settleFailure(request: ServeRequest): void {
|
|
215
|
+
if (request.settled) return;
|
|
216
|
+
request.settled = true;
|
|
217
|
+
if (request.timer !== undefined) this.clearTimer(request.timer);
|
|
218
|
+
if (request.onAbort) request.signal?.removeEventListener("abort", request.onAbort);
|
|
219
|
+
request.reject(request.aborted ? abortError() : new Error("Native pulse playback failed"));
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** SIGKILL once; the pending plays settle on the child `close` event (never exit/error/kill). */
|
|
223
|
+
private killWorker(): void {
|
|
224
|
+
const worker = this.worker;
|
|
225
|
+
if (!worker || worker.dead) return;
|
|
226
|
+
worker.dead = true;
|
|
227
|
+
try { worker.child.kill("SIGKILL"); } catch { /* close still settles every pending play */ }
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
private closeWorker(worker: ServeWorker): void {
|
|
231
|
+
worker.dead = true;
|
|
232
|
+
if (this.worker === worker) this.worker = undefined;
|
|
233
|
+
for (const request of [worker.active, ...worker.queued]) if (request) this.settleFailure(request);
|
|
234
|
+
worker.active = undefined;
|
|
235
|
+
worker.queued.length = 0;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
private dispatch(worker: ServeWorker, request: ServeRequest): void {
|
|
239
|
+
if (worker.dead) { this.settleFailure(request); return; }
|
|
240
|
+
worker.active = request;
|
|
241
|
+
const stdin = worker.child.stdin;
|
|
242
|
+
if (!stdin) { this.killWorker(); return; }
|
|
243
|
+
try { stdin.write(`${JSON.stringify({ schema: NATIVE_PULSE_SCHEMA, op: "play", path: request.snapshot })}\n`); }
|
|
244
|
+
catch { this.killWorker(); }
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
private onServeStdout(worker: ServeWorker, chunk: unknown): void {
|
|
248
|
+
worker.line += String(chunk);
|
|
249
|
+
for (;;) {
|
|
250
|
+
const index = worker.line.indexOf("\n");
|
|
251
|
+
if (index === -1) break;
|
|
252
|
+
const line = worker.line.slice(0, index);
|
|
253
|
+
worker.line = worker.line.slice(index + 1);
|
|
254
|
+
const envelope = parseEnvelope(line);
|
|
255
|
+
if (!envelope) { this.killWorker(); return; } // Protocol violation: `close` settles every pending play.
|
|
256
|
+
const request = worker.active;
|
|
257
|
+
worker.active = undefined;
|
|
258
|
+
if (envelope.ok === true && envelope.played === true) { if (request) this.settleSuccess(request); }
|
|
259
|
+
else if (request) this.settleFailure(request); // A private ok:false leaves the worker serviceable.
|
|
260
|
+
const next = worker.queued.shift();
|
|
261
|
+
if (next) this.dispatch(worker, next);
|
|
262
|
+
}
|
|
263
|
+
if (worker.line.length > MAX_STDOUT) this.killWorker(); // Unterminated envelope line over the bound.
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** `gate` (permit or predicate) runs synchronously before any spawn/write; false means no child at all.
|
|
267
|
+
* Plays serialize through one persistent worker; extras wait in a bounded queue. */
|
|
152
268
|
async play(snapshot: string, signal?: AbortSignal, gate?: (() => boolean) | { permit(): boolean }): Promise<void> {
|
|
153
269
|
if (!this.supportsTarget()) throw new Error("Native pulse: unsupported platform");
|
|
154
270
|
if (signal?.aborted) throw abortError();
|
|
155
271
|
const permitted = typeof gate === "function" ? gate() : gate?.permit();
|
|
156
272
|
if (permitted === false) throw new NativePulseNotPermittedError();
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
273
|
+
return new Promise<void>((resolve, reject) => {
|
|
274
|
+
const worker = this.ensureWorker();
|
|
275
|
+
const request: ServeRequest = { snapshot, signal, resolve, reject, aborted: false, timedOut: false, settled: false };
|
|
276
|
+
if (worker.active) {
|
|
277
|
+
if (worker.queued.length >= MAX_PENDING_PLAYS) { reject(new Error("Native pulse: too many pending plays")); return; }
|
|
278
|
+
this.armGuards(request);
|
|
279
|
+
worker.queued.push(request);
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
this.armGuards(request);
|
|
283
|
+
this.dispatch(worker, request);
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** Owner teardown: SIGKILL the persistent worker once and settle every pending play. */
|
|
288
|
+
dispose(): void {
|
|
289
|
+
const worker = this.worker;
|
|
290
|
+
this.worker = undefined;
|
|
291
|
+
if (!worker) return;
|
|
292
|
+
worker.dead = true;
|
|
293
|
+
try { worker.child.kill("SIGKILL"); } catch { /* the child is already gone */ }
|
|
294
|
+
for (const request of [worker.active, ...worker.queued]) if (request) this.settleFailure(request);
|
|
295
|
+
worker.active = undefined;
|
|
296
|
+
worker.queued.length = 0;
|
|
161
297
|
}
|
|
162
298
|
}
|
|
@@ -157,6 +157,8 @@ export interface NativeAudioBackend {
|
|
|
157
157
|
probe(signal?: AbortSignal): Promise<{ available: boolean; formats: readonly NotificationAudioFormat[] }>;
|
|
158
158
|
getNativeFormats(): readonly NotificationAudioFormat[];
|
|
159
159
|
play(snapshot: string, signal?: AbortSignal, gate?: (() => boolean) | { permit(): boolean }): Promise<void>;
|
|
160
|
+
/** Optional owner teardown: a persistent native worker (Linux pulse) kills its child. */
|
|
161
|
+
dispose?(): void;
|
|
160
162
|
}
|
|
161
163
|
interface DiscoveredSystem { executable: string; formats: readonly NotificationAudioFormat[]; }
|
|
162
164
|
interface Discovery { native: readonly NotificationAudioFormat[]; system?: DiscoveredSystem; }
|
|
@@ -240,6 +242,8 @@ export class NotificationPlayer {
|
|
|
240
242
|
}
|
|
241
243
|
return undefined;
|
|
242
244
|
}
|
|
245
|
+
/** Owner teardown: disposes the native backend, killing a persistent worker if one exists. */
|
|
246
|
+
dispose(): void { this.native.dispose?.(); }
|
|
243
247
|
async play(sound: Exclude<NotificationSound, null>, signal: AbortSignal, permit: PlaybackPermit): Promise<void> {
|
|
244
248
|
if (signal.aborted) return;
|
|
245
249
|
const discovered = await this.discover();
|