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
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();
|
|
@@ -3,8 +3,18 @@
|
|
|
3
3
|
* owned Node child (`node --experimental-strip-types --max-old-space-size=32`)
|
|
4
4
|
* and speaks a single bounded JSON envelope on stdout:
|
|
5
5
|
* probe -> { schema, ok, available, formats }
|
|
6
|
-
*
|
|
7
|
-
*
|
|
6
|
+
* Serve protocol (persistent mode): line-delimited JSON requests on stdin,
|
|
7
|
+
* `{ schema, op: "play", path }`, answered by one bounded
|
|
8
|
+
* `{ schema, ok, played }` envelope per play on stdout. Requests carry a
|
|
9
|
+
* snapshot PATH reference, not inline bytes: the worker re-reads the owner's
|
|
10
|
+
* private snapshot with the same bounded nofollow `readSnapshot()` on every
|
|
11
|
+
* play, so the fail-closed validation of snapshots is preserved verbatim and
|
|
12
|
+
* no audio bytes ever cross the stdio channel (request lines stay tiny and
|
|
13
|
+
* bounded). Protocol violations crash the worker with a nonzero exit and the
|
|
14
|
+
* owner respawns it on the next play; stdin EOF (owner death) exits cleanly
|
|
15
|
+
* and an idle worker self-retires, so teardown needs no owner round trip.
|
|
16
|
+
* Failures are private: a generic `{ schema, ok:false }` and, for protocol
|
|
17
|
+
* violations, a nonzero exit.
|
|
8
18
|
* It reads only argv, the environment and the owner-supplied snapshot; no SDK,
|
|
9
19
|
* UI, extension, config-owner, cookie-content logging or OS audio surface.
|
|
10
20
|
*/
|
|
@@ -15,18 +25,25 @@ import { playPulseWav } from "./notification-pulse-stream.ts";
|
|
|
15
25
|
|
|
16
26
|
const SCHEMA = "gentle.audio.pulse/v1";
|
|
17
27
|
const MAX_SNAPSHOT_BYTES = 2 * 1024 * 1024;
|
|
28
|
+
/** Request lines carry only a schema/op/path triple; anything longer is a protocol violation. */
|
|
29
|
+
const MAX_REQUEST_BYTES = 4096;
|
|
30
|
+
/** Bounded request backlog; an overflow is a protocol violation (crash, owner restarts). */
|
|
31
|
+
const MAX_PENDING_REQUESTS = 16;
|
|
32
|
+
/** An idle worker self-retires so one owner process never accumulates workers; unref'd, never busy. */
|
|
33
|
+
const SERVE_IDLE_EXIT_MS = 60000;
|
|
18
34
|
|
|
19
35
|
function emit(payload: Record<string, unknown>): void {
|
|
20
36
|
process.stdout.write(`${JSON.stringify({ schema: SCHEMA, ...payload })}\n`);
|
|
21
37
|
}
|
|
22
38
|
|
|
23
|
-
/** Re-read the trusted owner snapshot with the same bounded nofollow pattern.
|
|
24
|
-
|
|
39
|
+
/** Re-read the trusted owner snapshot with the same bounded nofollow pattern.
|
|
40
|
+
* `buffer` is a caller-owned scratch space so persistent serving never
|
|
41
|
+
* reallocates the 2 MiB read window per play; the returned bytes are a copy. */
|
|
42
|
+
async function readSnapshot(path: string, buffer: Buffer): Promise<Buffer> {
|
|
25
43
|
const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
26
44
|
try {
|
|
27
45
|
const stat = await handle.stat();
|
|
28
46
|
if (!stat.isFile() || stat.size < 44 || stat.size > MAX_SNAPSHOT_BYTES) throw new Error("invalid snapshot");
|
|
29
|
-
const buffer = Buffer.alloc(MAX_SNAPSHOT_BYTES + 1);
|
|
30
47
|
const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
|
|
31
48
|
if (bytesRead !== stat.size) throw new Error("invalid snapshot");
|
|
32
49
|
return Buffer.from(buffer.subarray(0, bytesRead));
|
|
@@ -35,6 +52,63 @@ async function readSnapshot(path: string): Promise<Buffer> {
|
|
|
35
52
|
}
|
|
36
53
|
}
|
|
37
54
|
|
|
55
|
+
/** Protocol violation: one generic envelope so the waiting owner settles, then a crash. */
|
|
56
|
+
function crash(): never {
|
|
57
|
+
emit({ ok: false });
|
|
58
|
+
process.exit(1);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function serve(): Promise<void> {
|
|
62
|
+
const buffer = Buffer.alloc(MAX_SNAPSHOT_BYTES + 1);
|
|
63
|
+
const queue: string[] = [];
|
|
64
|
+
let running = false;
|
|
65
|
+
let idleTimer: ReturnType<typeof setTimeout> | undefined;
|
|
66
|
+
const armIdle = () => {
|
|
67
|
+
clearTimeout(idleTimer);
|
|
68
|
+
idleTimer = setTimeout(() => { if (queue.length === 0 && !running) process.exit(0); }, SERVE_IDLE_EXIT_MS);
|
|
69
|
+
idleTimer.unref?.();
|
|
70
|
+
};
|
|
71
|
+
const runOne = async (line: string): Promise<void> => {
|
|
72
|
+
let request: { schema?: unknown; op?: unknown; path?: unknown };
|
|
73
|
+
try {
|
|
74
|
+
const parsed: unknown = JSON.parse(line);
|
|
75
|
+
if (typeof parsed !== "object" || parsed === null) return crash();
|
|
76
|
+
request = parsed as { schema?: unknown; op?: unknown; path?: unknown };
|
|
77
|
+
} catch { return crash(); }
|
|
78
|
+
if (request.schema !== SCHEMA || request.op !== "play" || typeof request.path !== "string" || request.path.length === 0) return crash();
|
|
79
|
+
try {
|
|
80
|
+
const bytes = await readSnapshot(request.path, buffer);
|
|
81
|
+
await playPulseWav(bytes, undefined, { clientOptions: { env: process.env } });
|
|
82
|
+
emit({ ok: true, played: true });
|
|
83
|
+
} catch { emit({ ok: false }); } // A private play failure leaves the healthy worker serviceable.
|
|
84
|
+
};
|
|
85
|
+
const drain = async (): Promise<void> => {
|
|
86
|
+
if (running) return;
|
|
87
|
+
running = true;
|
|
88
|
+
while (queue.length > 0) { const line = queue.shift()!; armIdle(); await runOne(line); }
|
|
89
|
+
running = false;
|
|
90
|
+
armIdle();
|
|
91
|
+
};
|
|
92
|
+
process.stdin.setEncoding("utf8");
|
|
93
|
+
let text = "";
|
|
94
|
+
process.stdin.on("data", (chunk: string) => {
|
|
95
|
+
armIdle();
|
|
96
|
+
text += chunk;
|
|
97
|
+
for (;;) {
|
|
98
|
+
const index = text.indexOf("\n");
|
|
99
|
+
if (index === -1) break;
|
|
100
|
+
const line = text.slice(0, index);
|
|
101
|
+
text = text.slice(index + 1);
|
|
102
|
+
if (line.length > MAX_REQUEST_BYTES || queue.push(line) > MAX_PENDING_REQUESTS) crash();
|
|
103
|
+
}
|
|
104
|
+
if (text.length > MAX_REQUEST_BYTES) crash(); // Unterminated request line over the bound.
|
|
105
|
+
void drain();
|
|
106
|
+
});
|
|
107
|
+
process.stdin.on("end", () => process.exit(0));
|
|
108
|
+
process.stdin.on("error", () => process.exit(1));
|
|
109
|
+
armIdle();
|
|
110
|
+
}
|
|
111
|
+
|
|
38
112
|
async function main(): Promise<void> {
|
|
39
113
|
const mode = process.argv[2];
|
|
40
114
|
if (mode === "probe") {
|
|
@@ -46,21 +120,11 @@ async function main(): Promise<void> {
|
|
|
46
120
|
}
|
|
47
121
|
return;
|
|
48
122
|
}
|
|
49
|
-
if (mode === "
|
|
50
|
-
|
|
51
|
-
if (snapshot === undefined) { emit({ ok: false }); process.exitCode = 1; return; }
|
|
52
|
-
try {
|
|
53
|
-
const bytes = await readSnapshot(snapshot);
|
|
54
|
-
await playPulseWav(bytes, undefined, { clientOptions: { env: process.env } });
|
|
55
|
-
emit({ ok: true, played: true });
|
|
56
|
-
} catch {
|
|
57
|
-
emit({ ok: false });
|
|
58
|
-
process.exitCode = 1;
|
|
59
|
-
}
|
|
123
|
+
if (mode === "serve") {
|
|
124
|
+
await serve();
|
|
60
125
|
return;
|
|
61
126
|
}
|
|
62
|
-
|
|
63
|
-
process.exitCode = 1;
|
|
127
|
+
crash();
|
|
64
128
|
}
|
|
65
129
|
|
|
66
130
|
await main();
|
|
@@ -15,7 +15,7 @@ export interface NotificationServiceDependencies {
|
|
|
15
15
|
policy?: NotificationOptions;
|
|
16
16
|
read?: () => NotificationResolution;
|
|
17
17
|
write?: (settings: NotificationSettings) => string;
|
|
18
|
-
backend?: Pick<NotificationPlayer, "play" | "availability" | "capabilities"
|
|
18
|
+
backend?: Pick<NotificationPlayer, "play" | "availability" | "capabilities"> & { dispose?: () => void };
|
|
19
19
|
setTimeout?: SchedulerOptions["setTimeout"];
|
|
20
20
|
clearTimeout?: SchedulerOptions["clearTimeout"];
|
|
21
21
|
}
|
|
@@ -175,6 +175,10 @@ export function claimNotificationOwner(retireRuntime: () => void, deps: Notifica
|
|
|
175
175
|
const retire = () => {
|
|
176
176
|
if (disposed) return;
|
|
177
177
|
shutdown(); disposed = true; retireRuntime();
|
|
178
|
+
// End of life for the claim also ends the audio backend: the persistent
|
|
179
|
+
// serve worker (if one was spawned) is killed here; a re-bind through
|
|
180
|
+
// attach() keeps it, and an abandoned claim self-retires idle after 60 s.
|
|
181
|
+
backend.dispose?.();
|
|
178
182
|
if (shared.ownerLease === lease) { shared.ownerLease = undefined; shared.retire = undefined; }
|
|
179
183
|
};
|
|
180
184
|
shared.retire = retire;
|
|
@@ -171,18 +171,25 @@ function reason(error: unknown) {
|
|
|
171
171
|
return code === "ENOENT" ? "missing" : code ? "io-error" : error instanceof SyntaxError ? "malformed" : (error as Error).message;
|
|
172
172
|
}
|
|
173
173
|
function atomicWrite(root: string, name: string, bytes: string) {
|
|
174
|
-
|
|
174
|
+
// Ancestor validation is the heartbeat's cached responsibility; the destination
|
|
175
|
+
// identity check remains per-write so a replaced or linked file is never overwritten.
|
|
175
176
|
const destination = join(root, name);
|
|
176
177
|
try { regular(fs.lstatSync(destination)); }
|
|
177
178
|
catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
|
|
178
179
|
const temp = join(root, `.${name}.${randomUUID()}.tmp`);
|
|
179
180
|
const fd = fs.openSync(temp, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL, 0o600);
|
|
181
|
+
let identity: { dev: number; ino: number };
|
|
182
|
+
try {
|
|
183
|
+
fs.writeFileSync(fd, bytes);
|
|
184
|
+
const stat = fs.fstatSync(fd);
|
|
185
|
+
identity = { dev: stat.dev, ino: stat.ino };
|
|
186
|
+
} finally { fs.closeSync(fd); }
|
|
180
187
|
try {
|
|
181
|
-
try { fs.writeFileSync(fd, bytes); } finally { fs.closeSync(fd); }
|
|
182
188
|
fs.renameSync(temp, destination);
|
|
183
189
|
} finally {
|
|
184
190
|
try { fs.unlinkSync(temp); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
|
|
185
191
|
}
|
|
192
|
+
return identity;
|
|
186
193
|
}
|
|
187
194
|
|
|
188
195
|
export interface PresencePage {
|
|
@@ -301,6 +308,11 @@ export class PresencePublisher {
|
|
|
301
308
|
private heartbeat?: ReturnType<typeof setInterval>;
|
|
302
309
|
private disposed = false;
|
|
303
310
|
private owned = new Map<string, { dev: number; ino: number }>();
|
|
311
|
+
/** Root paths whose ancestor chain was validated for this session identity.
|
|
312
|
+
* Seeded by the one synchronous rootFor() in start(); refreshed asynchronously
|
|
313
|
+
* by the heartbeat so writes never probe directories on the caller's thread. */
|
|
314
|
+
private readonly validRoots = new Set<string>();
|
|
315
|
+
private validating = false;
|
|
304
316
|
/** Timer I/O failures stop publication; consumers still apply the recent TTL. */
|
|
305
317
|
error?: string;
|
|
306
318
|
|
|
@@ -313,10 +325,10 @@ export class PresencePublisher {
|
|
|
313
325
|
static start(options: { profile: string; sessionId: string; label: string; labelSource?: () => string; activity: readonly ActivityInput[] }) {
|
|
314
326
|
const publisher = new PresencePublisher(options);
|
|
315
327
|
try {
|
|
316
|
-
rootFor(options.profile, true);
|
|
328
|
+
publisher.validRoots.add(rootFor(options.profile, true));
|
|
317
329
|
publisher.pending = JSON.stringify(projectActivity(options.activity));
|
|
318
330
|
publisher.flush();
|
|
319
|
-
publisher.heartbeat = setInterval(() => publisher.
|
|
331
|
+
publisher.heartbeat = setInterval(() => publisher.pulse(), 5000);
|
|
320
332
|
publisher.heartbeat.unref();
|
|
321
333
|
return publisher;
|
|
322
334
|
} catch (error) { publisher.dispose(); throw error; }
|
|
@@ -384,12 +396,68 @@ export class PresencePublisher {
|
|
|
384
396
|
if (this.disposed) return;
|
|
385
397
|
try { action(); } catch (error) { this.error = reason(error); this.dispose(); }
|
|
386
398
|
}
|
|
399
|
+
private rootPath(kind: "header" | "activity" | "discovery" | "catalog") {
|
|
400
|
+
return join(resolve(this.profile), "gentle-agents", kind === "catalog" ? "catalog" : "presence");
|
|
401
|
+
}
|
|
402
|
+
/** Publishes from the warm cache synchronously, then refreshes the validated
|
|
403
|
+
* ancestor chain asynchronously without ever overlapping itself. */
|
|
404
|
+
private pulse() {
|
|
405
|
+
this.guarded(() => this.publishHeader());
|
|
406
|
+
if (this.validating) return;
|
|
407
|
+
this.validating = true;
|
|
408
|
+
void this.revalidate().finally(() => { this.validating = false; }).catch(() => { /* revalidate never rejects */ });
|
|
409
|
+
}
|
|
410
|
+
/** Async mirror of rootFor(): same ancestor chain, modes, and ownership, so the
|
|
411
|
+
* sync write path stays cache-only. A failed validation only cools the cache. */
|
|
412
|
+
private async revalidate() {
|
|
413
|
+
const absolute = resolve(this.profile);
|
|
414
|
+
const shared = join(absolute, "gentle-agents");
|
|
415
|
+
const chains: { root: string; create: boolean }[] = [{ root: join(shared, "presence"), create: false }];
|
|
416
|
+
if (this.catalog) chains.push({ root: join(shared, "catalog"), create: true });
|
|
417
|
+
for (const { root, create } of chains) {
|
|
418
|
+
try {
|
|
419
|
+
for (let path = absolute;; path = dirname(path)) {
|
|
420
|
+
const stat = await fs.promises.lstat(path);
|
|
421
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error("unsafe-directory");
|
|
422
|
+
if (dirname(path) === path) break;
|
|
423
|
+
}
|
|
424
|
+
if (create) {
|
|
425
|
+
try { await fs.promises.mkdir(root, { mode: 0o700 }); }
|
|
426
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; }
|
|
427
|
+
}
|
|
428
|
+
await this.checkedDirectory(shared, false);
|
|
429
|
+
await this.checkedDirectory(root, true);
|
|
430
|
+
this.validRoots.add(root);
|
|
431
|
+
} catch { this.validRoots.delete(root); }
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
private async checkedDirectory(path: string, privateMode: boolean) {
|
|
435
|
+
const stat = await fs.promises.lstat(path);
|
|
436
|
+
if (!stat.isDirectory() || stat.isSymbolicLink() || (process.platform !== "win32"
|
|
437
|
+
&& ((stat.mode & (privateMode ? 0o077 : 0o022)) !== 0 || stat.uid !== process.getuid?.()))) throw new Error("unsafe-directory");
|
|
438
|
+
}
|
|
387
439
|
private write(kind: "header" | "activity" | "discovery" | "catalog", bytes: string) {
|
|
388
|
-
|
|
440
|
+
// A cold cache validates synchronously exactly once for that path — the same
|
|
441
|
+
// rootFor() validation the old per-write path performed — then caches and
|
|
442
|
+
// writes, so the first publication after a cold start is never deferred to a
|
|
443
|
+
// heartbeat. A failed validation (vanished or unsafe tree) is the only skip
|
|
444
|
+
// path; the heartbeat's async refresh re-warms the cache once the tree returns.
|
|
445
|
+
const root = this.rootPath(kind);
|
|
446
|
+
if (!this.validRoots.has(root)) {
|
|
447
|
+
try { rootFor(this.profile, kind === "catalog", kind === "catalog"); }
|
|
448
|
+
catch { return; }
|
|
449
|
+
this.validRoots.add(root);
|
|
450
|
+
}
|
|
389
451
|
const name = kind === "catalog" ? `${this.target.sessionHash}.${this.target.incarnation}.json` : filename(this.target, kind);
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
452
|
+
let identity: { dev: number; ino: number };
|
|
453
|
+
try { identity = atomicWrite(root, name, bytes); }
|
|
454
|
+
catch (error) {
|
|
455
|
+
// A tree that vanished mid-flight is skipped, not fatal; other failures stay fatal.
|
|
456
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
457
|
+
this.validRoots.delete(root);
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
this.owned.set(join(root, name), identity);
|
|
393
461
|
}
|
|
394
462
|
private writeDiscovery() {
|
|
395
463
|
if (!this.discovery) return;
|
|
@@ -402,6 +470,7 @@ export class PresencePublisher {
|
|
|
402
470
|
} catch { /* Metadata failure must not withdraw an existing activity peer. */ }
|
|
403
471
|
}
|
|
404
472
|
private publishHeader() {
|
|
473
|
+
if (!this.validRoots.has(this.rootPath("header"))) return;
|
|
405
474
|
const header = { ...this.header, label: label(this.labelSource?.() ?? this.displayLabel), heartbeat: Date.now() };
|
|
406
475
|
if (!validHeader(header)) throw new Error("malformed-header");
|
|
407
476
|
this.write("header", JSON.stringify(header));
|
|
@@ -410,6 +479,7 @@ export class PresencePublisher {
|
|
|
410
479
|
}
|
|
411
480
|
private flush() {
|
|
412
481
|
if (this.pending === this.published) return;
|
|
482
|
+
if (!this.validRoots.has(this.rootPath("activity"))) return;
|
|
413
483
|
const activity: Activity = JSON.parse(this.pending);
|
|
414
484
|
const generation = (this.header?.generation ?? 0) + 1;
|
|
415
485
|
const bytes = JSON.stringify({ schema: 1, ...this.target, generation, activity });
|
|
@@ -62,6 +62,16 @@ export class OrchestratorScopeCache {
|
|
|
62
62
|
this.facts.clear();
|
|
63
63
|
this.snapshot = undefined;
|
|
64
64
|
}
|
|
65
|
+
/** Snapshot for deferred (render-cadence trailing) publications: it never
|
|
66
|
+
* touches the resolver. A deferred publish reuses the last event-time
|
|
67
|
+
* projection as-is even when membership changed since — its Git resolution
|
|
68
|
+
* must never race a peer metadata query, whose contract adds no caller Git
|
|
69
|
+
* probes. Returns undefined when nothing has been projected yet (or the
|
|
70
|
+
* cache was cleared), in which case the caller omits the scope rather than
|
|
71
|
+
* probing; the next event-time publication refreshes the facts. */
|
|
72
|
+
cachedScope(): RecordedScope | undefined {
|
|
73
|
+
return this.snapshot;
|
|
74
|
+
}
|
|
65
75
|
project(cwd: string, tasks: readonly { id: string; cwd: string }[], roots: readonly string[]): RecordedScope {
|
|
66
76
|
const registered = [...new Set(roots)];
|
|
67
77
|
const key = createHash("sha256").update(JSON.stringify([cwd, tasks.map(t => [t.id, t.cwd]), registered])).digest("hex");
|
|
@@ -89,11 +89,19 @@ export class OrchestratorStateCache {
|
|
|
89
89
|
const previous = this.value?.aliases;
|
|
90
90
|
// Null/null marks a new publication with no declaration yet; legacy absence
|
|
91
91
|
// cannot establish historical first-task knowledge.
|
|
92
|
+
// An explicitly published work topic is owner-curated public task
|
|
93
|
+
// classification, so it refreshes the current alias when the caller omits
|
|
94
|
+
// the subject (#2553). State-only refreshes and automatic republications of
|
|
95
|
+
// unchanged state must not re-derive it, and an explicit subject in the
|
|
96
|
+
// same publication always wins.
|
|
97
|
+
const topic = input !== undefined && !declared && state?.work?.topic
|
|
98
|
+
? normalizeTaskSubject(state.work.topic) : undefined;
|
|
92
99
|
let aliases = previous;
|
|
93
|
-
if (declared) {
|
|
100
|
+
if (declared || topic) {
|
|
101
|
+
const current = declared ?? topic;
|
|
94
102
|
let initialAlias = previous?.initialAlias ?? null;
|
|
95
|
-
if (!this.value || previous?.currentAlias === null) initialAlias =
|
|
96
|
-
aliases = { initialAlias, currentAlias:
|
|
103
|
+
if (!this.value || previous?.currentAlias === null) initialAlias = current;
|
|
104
|
+
aliases = { initialAlias, currentAlias: current };
|
|
97
105
|
} else if (!this.value) aliases = { initialAlias: null, currentAlias: null };
|
|
98
106
|
const cwd = input === undefined && this.value ? this.value.cwd : manager.getCwd();
|
|
99
107
|
const record = decodePublishedState({ schema: state?.work ? 2 : 1, sessionId: manager.getSessionId(),
|
|
@@ -3,7 +3,7 @@ import { randomUUID } from "node:crypto";
|
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { gentlePiConfigHome } from "./agent-home.ts";
|
|
5
5
|
|
|
6
|
-
export type PermissionMode = "normal" | "auto" | "yolo";
|
|
6
|
+
export type PermissionMode = "normal" | "auto" | "yolo" | "super-yolo";
|
|
7
7
|
export const PERMISSION_MODE_SCHEMA = "gentle-pi.permission-mode/v1";
|
|
8
8
|
export interface PermissionModeOptions { gentlePiConfigHome?: string }
|
|
9
9
|
export interface PermissionModeResolution {
|
|
@@ -13,7 +13,7 @@ export interface PermissionModeResolution {
|
|
|
13
13
|
globalFile: string;
|
|
14
14
|
}
|
|
15
15
|
export function isPermissionMode(value: unknown): value is PermissionMode {
|
|
16
|
-
return value === "normal" || value === "auto" || value === "yolo";
|
|
16
|
+
return value === "normal" || value === "auto" || value === "yolo" || value === "super-yolo";
|
|
17
17
|
}
|
|
18
18
|
/** Never consult merged Pi settings or repository configuration. */
|
|
19
19
|
export function permissionModePolicyFile(options: PermissionModeOptions = {}): string {
|