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
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { fileURLToPath } from "node:url";
|
|
2
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { Type } from "typebox";
|
|
4
|
+
import { appendSystemPromptOnce } from "../lib/append-system-prompt.ts";
|
|
5
|
+
import type { DesignSessionContent } from "../lib/design-canvas-server.ts";
|
|
6
|
+
import { DESIGN_COMMENTS_TOOL_NAME, DESIGN_HELP, DESIGN_PROMPT_HINT, DESIGN_SKILL_NAME, DESIGN_SYNC_REQUEST, DESIGN_TOOL_NAME, createDesignCanvas, designRunMessage, designUsage, hasSystemBoard, listDesigns, parseDesignArgs, planDesignImplement, runDesignCommentsTool, runDesignTool, sendDesignImplement, type DesignCanvasDeps, type DesignCommentsToolParams, type DesignToolParams } from "../lib/gentle-design.ts";
|
|
7
|
+
|
|
8
|
+
// Gentle Design: /gentle:design <brief> runs the bundled gentle-design skill,
|
|
9
|
+
// which writes self-contained HTML artboards under design/<slug>/ with
|
|
10
|
+
// whatever model the session uses. open (and no arguments, when designs
|
|
11
|
+
// exist) shows them on a loopback canvas whose comments come back to the
|
|
12
|
+
// session and persist in design/<slug>/comments.json; stop and session
|
|
13
|
+
// shutdown close it. The gentle_design_comments tool lets any model list and
|
|
14
|
+
// resolve those comments. sync runs the skill's design-system mode, which
|
|
15
|
+
// writes design/system.md and design/system.html; implement (and the canvas
|
|
16
|
+
// Implement button) hands one artboard off to the project's real stack.
|
|
17
|
+
// The gentle_design tool gives the model list/open/stop/implement on the same
|
|
18
|
+
// canvas, and a system-prompt hint makes the skill the default design flow.
|
|
19
|
+
// list also reports the project's design sources and whether design/system.md
|
|
20
|
+
// is missing or stale, so the skill syncs first or warns (S13).
|
|
21
|
+
// Every handoff (command, tool, canvas) is recorded in design/<slug>/handoffs.json
|
|
22
|
+
// as sent and chosen; the tool's handoff action records in_progress and
|
|
23
|
+
// implemented (with the running app's loopback previewUrl) for the canvas (S15).
|
|
24
|
+
|
|
25
|
+
export const DESIGN_COMMAND_NAME = "gentle:design";
|
|
26
|
+
|
|
27
|
+
const DesignCommentsParams = Type.Object({
|
|
28
|
+
action: Type.Union([Type.Literal("list"), Type.Literal("resolve")], { description: "list: open comments; resolve: mark comments resolved after applying them" }),
|
|
29
|
+
slug: Type.Optional(Type.String({ description: "Design slug (directory under design/); required for resolve" })),
|
|
30
|
+
file: Type.Optional(Type.String({ description: "Artboard file within the design, such as 01-calm.html (list only)" })),
|
|
31
|
+
ids: Type.Optional(Type.Array(Type.String(), { maxItems: 500, description: "Comment ids to resolve" })),
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const DesignParams = Type.Object({
|
|
35
|
+
action: Type.Union([Type.Literal("list"), Type.Literal("open"), Type.Literal("stop"), Type.Literal("implement"), Type.Literal("handoff")], { description: "list: designs, artboards, chosen artboards and handoff states, design sources, design/system.md state and canvas state; open: show a design on the canvas; stop: close the canvas; implement: get the handoff for one artboard; handoff: report progress on a handed-off artboard" }),
|
|
36
|
+
slug: Type.Optional(Type.String({ description: "Design slug for open (directory under design/); defaults to the most recent design" })),
|
|
37
|
+
target: Type.Optional(Type.String({ description: "Artboard for implement or handoff, as <slug>/<artboard>, such as login/01-split.html" })),
|
|
38
|
+
status: Type.Optional(Type.Union([Type.Literal("in_progress"), Type.Literal("implemented")], { description: "handoff only: in_progress when you start implementing, implemented when it is done" })),
|
|
39
|
+
note: Type.Optional(Type.String({ maxLength: 1000, description: "handoff only: a short note shown on the canvas" })),
|
|
40
|
+
previewUrl: Type.Optional(Type.String({ maxLength: 2048, description: "handoff only: the running app's loopback URL (http or https on localhost, 127.0.0.1 or [::1], any port), so the canvas can compare it with the artboard" })),
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
export interface GentleDesignDeps extends DesignCanvasDeps {
|
|
44
|
+
/** Absolute path of the bundled skill, named in the message when Pi has not loaded it. */
|
|
45
|
+
skillPath?: string;
|
|
46
|
+
/** Process environment; a delegated child session (GENTLE_PI_AGENTS_CHILD=1) gets no prompt hint. */
|
|
47
|
+
env?: NodeJS.ProcessEnv;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** A stale or replaced context counts as busy, so a comment is queued rather than interrupting. */
|
|
51
|
+
function isIdle(ctx: ExtensionContext | undefined): boolean {
|
|
52
|
+
try {
|
|
53
|
+
return ctx?.isIdle() ?? true;
|
|
54
|
+
} catch {
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export default function gentleDesign(pi: ExtensionAPI, deps: GentleDesignDeps = {}): void {
|
|
60
|
+
const skillPath = deps.skillPath ?? fileURLToPath(new URL("../skills/design/SKILL.md", import.meta.url));
|
|
61
|
+
// Canvas comments arrive between commands; the last command's context answers whether the agent is busy.
|
|
62
|
+
let latest: ExtensionContext | undefined;
|
|
63
|
+
// Answers whether the message waits as a follow-up, which a handoff records as queued (S15).
|
|
64
|
+
// A canvas comment with a screenshot is text plus an image; handoffs stay plain text.
|
|
65
|
+
const deliver = (message: DesignSessionContent) => {
|
|
66
|
+
const queued = !isIdle(latest);
|
|
67
|
+
pi.sendUserMessage(message, queued ? { deliverAs: "followUp" } : undefined);
|
|
68
|
+
return { queued };
|
|
69
|
+
};
|
|
70
|
+
const canvas = createDesignCanvas(deliver, deps);
|
|
71
|
+
|
|
72
|
+
const open = async (ctx: ExtensionContext, slug?: string) => {
|
|
73
|
+
const notice = await canvas.open(ctx.cwd, slug);
|
|
74
|
+
ctx.ui.notify(notice.message, notice.level);
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const skillLoaded = () => pi.getCommands().some((command) => command.name === `skill:${DESIGN_SKILL_NAME}`);
|
|
78
|
+
|
|
79
|
+
// sendUserMessage expands /skill:<name> only when Pi loaded that skill;
|
|
80
|
+
// an unknown skill command would reach the model verbatim.
|
|
81
|
+
const runSkill = (request: string, ctx: ExtensionContext) => {
|
|
82
|
+
const content = designRunMessage(request, { loaded: skillLoaded(), path: skillPath });
|
|
83
|
+
pi.sendUserMessage(content, ctx.isIdle() ? { expandPromptTemplates: true } : { expandPromptTemplates: true, deliverAs: "followUp" });
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const handle = async (args: string, ctx: ExtensionContext) => {
|
|
87
|
+
latest = ctx;
|
|
88
|
+
const parsed = parseDesignArgs(args);
|
|
89
|
+
if (parsed.kind === "usage") {
|
|
90
|
+
if (listDesigns(ctx.cwd).length > 0 || hasSystemBoard(ctx.cwd)) await open(ctx);
|
|
91
|
+
else ctx.ui.notify(designUsage([]), "info");
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
if (parsed.kind === "subcommand") {
|
|
95
|
+
if (parsed.name === "open") return open(ctx, parsed.rest || undefined);
|
|
96
|
+
if (parsed.name === "stop") {
|
|
97
|
+
ctx.ui.notify((await canvas.stop()) ? "Design canvas stopped." : "No design canvas is running.", "info");
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
if (parsed.name === "sync") {
|
|
101
|
+
if (parsed.rest) ctx.ui.notify("/gentle:design sync takes no arguments.", "warning");
|
|
102
|
+
else runSkill(DESIGN_SYNC_REQUEST, ctx);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
const plan = await planDesignImplement(ctx.cwd, parsed.rest);
|
|
106
|
+
if (plan.kind === "send") {
|
|
107
|
+
for (const warning of plan.warnings) ctx.ui.notify(warning, "warning");
|
|
108
|
+
try {
|
|
109
|
+
await sendDesignImplement(ctx.cwd, plan, deliver);
|
|
110
|
+
} catch (error) {
|
|
111
|
+
ctx.ui.notify(error instanceof Error ? error.message : String(error), "warning");
|
|
112
|
+
}
|
|
113
|
+
} else ctx.ui.notify(plan.notice.message, plan.notice.level);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
runSkill(parsed.brief, ctx);
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
pi.registerCommand(DESIGN_COMMAND_NAME, { description: DESIGN_HELP, handler: handle });
|
|
120
|
+
pi.registerTool({
|
|
121
|
+
name: DESIGN_COMMENTS_TOOL_NAME,
|
|
122
|
+
label: "Design comments",
|
|
123
|
+
description: "List the open canvas comments stored in design/<slug>/comments.json, or mark comments resolved once you have applied them.",
|
|
124
|
+
promptSnippet: "List open gentle-design canvas comments and resolve them after applying them.",
|
|
125
|
+
parameters: DesignCommentsParams,
|
|
126
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
127
|
+
const result = await runDesignCommentsTool(ctx.cwd, params as DesignCommentsToolParams);
|
|
128
|
+
return { content: [{ type: "text" as const, text: result.text }], details: result.details };
|
|
129
|
+
},
|
|
130
|
+
});
|
|
131
|
+
pi.registerTool({
|
|
132
|
+
name: DESIGN_TOOL_NAME,
|
|
133
|
+
label: "Gentle Design",
|
|
134
|
+
description: "Drive the gentle-design canvas. Call list before designing: it reports the project's design sources and whether design/system.md is missing or older than them (sync first, or offer to sync again). list also gives the designs under design/ (artboards, system board, whether the canvas runs and its URL); open a design on the local canvas (optional slug, default the most recent) or refresh it after writing or changing artboards, returning the URL; stop the canvas; implement one artboard (target <slug>/<artboard>), which returns the handoff instructions to follow and records it as the design's chosen artboard; handoff reports your progress on that artboard (status in_progress when you start, implemented when done, with previewUrl of the running app on localhost, 127.0.0.1 or [::1] when there is one), which the canvas shows live. Write briefs and run the design-system sync by following the gentle-design skill, not this tool.",
|
|
135
|
+
promptSnippet: "List gentle-design designs, open or stop their canvas, get an artboard's implementation handoff, and report its progress.",
|
|
136
|
+
parameters: DesignParams,
|
|
137
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
138
|
+
// Comments from a canvas the tool opened are queued while this run is busy.
|
|
139
|
+
latest = ctx;
|
|
140
|
+
const result = await runDesignTool(ctx.cwd, params as DesignToolParams, canvas);
|
|
141
|
+
return { content: result.texts.map((text) => ({ type: "text" as const, text })), details: result.details };
|
|
142
|
+
},
|
|
143
|
+
});
|
|
144
|
+
// gentle-shell#1485: appendSystemPrompt reaches every provider. Delegated
|
|
145
|
+
// children only run their task, so they never get the default design flow.
|
|
146
|
+
const env = deps.env ?? process.env;
|
|
147
|
+
pi.on("before_agent_start", (event) => {
|
|
148
|
+
if (env.GENTLE_PI_AGENTS_CHILD === "1") return undefined;
|
|
149
|
+
const options = event.systemPromptOptions;
|
|
150
|
+
const available = options?.skills?.some((skill) => skill.name === DESIGN_SKILL_NAME) || skillLoaded();
|
|
151
|
+
if (available) appendSystemPromptOnce(options, DESIGN_PROMPT_HINT);
|
|
152
|
+
return undefined;
|
|
153
|
+
});
|
|
154
|
+
pi.on("session_shutdown", async () => {
|
|
155
|
+
latest = undefined;
|
|
156
|
+
await canvas.stop();
|
|
157
|
+
});
|
|
158
|
+
}
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
import { spawn as nodeSpawn } from "node:child_process";
|
|
2
|
+
import { accessSync, constants, statSync } from "node:fs";
|
|
3
|
+
import { delimiter, join } from "node:path";
|
|
4
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import type { Component } from "@earendil-works/pi-tui";
|
|
6
|
+
|
|
7
|
+
// Gentle Music: /gentle:music hands the whole terminal to gentle-stream's own
|
|
8
|
+
// TUI, the way Pi's external editor (ctrl+g) does: stop Pi's TUI, run the
|
|
9
|
+
// child with inherited stdio, then restart Pi and force a full repaint.
|
|
10
|
+
// gentle-stream's `b` returns to Pi with the music still playing (a background
|
|
11
|
+
// player owns the audio); `q` stops it. Single control words run
|
|
12
|
+
// `gentle-stream ctl …` with captured output and never take the terminal.
|
|
13
|
+
//
|
|
14
|
+
// Runtime imports stay node-only so the command loads without Pi's packages.
|
|
15
|
+
|
|
16
|
+
export const MUSIC_COMMAND_NAME = "gentle:music";
|
|
17
|
+
export const GENTLE_STREAM_SPEC = "gentle-stream[samples] @ git+https://github.com/Gentleman-Programming/gentle-stream";
|
|
18
|
+
export const MUSIC_CONTROL_COMMANDS = ["next", "pause", "play", "stop", "status"] as const;
|
|
19
|
+
export type MusicControlCommand = (typeof MUSIC_CONTROL_COMMANDS)[number];
|
|
20
|
+
|
|
21
|
+
const UV_INSTALL_URL = "https://docs.astral.sh/uv/getting-started/installation/";
|
|
22
|
+
const CTL_TIMEOUT_MS = 30_000;
|
|
23
|
+
const SHUTDOWN_TIMEOUT_MS = 2_000;
|
|
24
|
+
const CTL_STDIO = ["ignore", "pipe", "pipe"] as const;
|
|
25
|
+
const CTL_DONE: Record<Exclude<MusicControlCommand, "status">, string> = {
|
|
26
|
+
next: "♪ next track",
|
|
27
|
+
pause: "♪ paused",
|
|
28
|
+
play: "♪ playing",
|
|
29
|
+
stop: "♪ music stopped",
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/** How to run gentle-stream: the executable plus the arguments that precede gentle-stream's own. */
|
|
33
|
+
export interface GentleStreamCommand {
|
|
34
|
+
command: string;
|
|
35
|
+
prefix: string[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export type MusicWhich = (name: string) => string | undefined;
|
|
39
|
+
|
|
40
|
+
/** The slice of a spawned child /gentle:music reads; `node:child_process` satisfies it. */
|
|
41
|
+
export interface MusicChild {
|
|
42
|
+
on(event: "error", listener: (error: Error) => void): unknown;
|
|
43
|
+
on(event: "close", listener: (code: number | null, signal?: NodeJS.Signals | null) => void): unknown;
|
|
44
|
+
stdout?: { on(event: "data", listener: (chunk: Buffer | string) => void): unknown } | null;
|
|
45
|
+
stderr?: { on(event: "data", listener: (chunk: Buffer | string) => void): unknown } | null;
|
|
46
|
+
kill(signal?: NodeJS.Signals): boolean;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export type MusicSpawn = (command: string, args: readonly string[], options: { stdio: "inherit" | typeof CTL_STDIO }) => MusicChild;
|
|
50
|
+
|
|
51
|
+
export interface MusicSignals {
|
|
52
|
+
on(signal: "SIGINT", listener: () => void): unknown;
|
|
53
|
+
off(signal: "SIGINT", listener: () => void): unknown;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Whether this Pi process used /gentle:music; shared across reloads so quitting still stops the music. */
|
|
57
|
+
export interface MusicUsage {
|
|
58
|
+
used: boolean;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The slice of Pi's TUI the handover drives. */
|
|
62
|
+
interface TerminalOwner {
|
|
63
|
+
stop(): void;
|
|
64
|
+
start(): void;
|
|
65
|
+
requestRender(force?: boolean): void;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface GentleMusicDeps {
|
|
69
|
+
which?: MusicWhich;
|
|
70
|
+
spawn?: MusicSpawn;
|
|
71
|
+
signals?: MusicSignals;
|
|
72
|
+
usage?: MusicUsage;
|
|
73
|
+
shutdownTimeoutMs?: number;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** First executable named `name` on PATH, honoring PATHEXT on Windows. */
|
|
77
|
+
export function findOnPath(name: string, env: NodeJS.ProcessEnv = process.env, platform: NodeJS.Platform = process.platform): string | undefined {
|
|
78
|
+
const dirs = (env.PATH ?? env.Path ?? "").split(delimiter).filter((entry) => entry.length > 0);
|
|
79
|
+
// Without a shell only real executables start on Windows: .cmd/.bat shims would fail to spawn.
|
|
80
|
+
const extensions = platform === "win32"
|
|
81
|
+
? (env.PATHEXT || ".COM;.EXE").split(";").filter((extension) => /^\.(com|exe)$/i.test(extension))
|
|
82
|
+
: [""];
|
|
83
|
+
for (const dir of dirs) {
|
|
84
|
+
for (const extension of extensions) {
|
|
85
|
+
const candidate = join(dir, name + extension);
|
|
86
|
+
try {
|
|
87
|
+
if (!statSync(candidate).isFile()) continue;
|
|
88
|
+
if (platform !== "win32") accessSync(candidate, constants.X_OK);
|
|
89
|
+
return candidate;
|
|
90
|
+
} catch {
|
|
91
|
+
// Missing or not executable: keep looking.
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return undefined;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** gentle-stream on PATH, else the published spec through uvx, else through `uv tool run`. */
|
|
99
|
+
export function resolveGentleStream(which: MusicWhich): GentleStreamCommand | undefined {
|
|
100
|
+
const installed = which("gentle-stream");
|
|
101
|
+
if (installed) return { command: installed, prefix: [] };
|
|
102
|
+
const uvx = which("uvx");
|
|
103
|
+
if (uvx) return { command: uvx, prefix: ["--from", GENTLE_STREAM_SPEC, "gentle-stream"] };
|
|
104
|
+
const uv = which("uv");
|
|
105
|
+
if (uv) return { command: uv, prefix: ["tool", "run", "--from", GENTLE_STREAM_SPEC, "gentle-stream"] };
|
|
106
|
+
return undefined;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function installHint(): string {
|
|
110
|
+
return `/gentle:music needs gentle-stream, and neither gentle-stream nor uv is on PATH. Install uv (${UV_INSTALL_URL}) and run /gentle:music again, or install gentle-stream with: uv tool install '${GENTLE_STREAM_SPEC}'`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export type MusicRequest = { kind: "tui"; mood?: string } | { kind: "ctl"; command: MusicControlCommand };
|
|
114
|
+
|
|
115
|
+
/** Only an exact single control word is a subcommand; any other text is a mood. */
|
|
116
|
+
export function parseMusicArgs(args: string): MusicRequest {
|
|
117
|
+
const text = args.trim();
|
|
118
|
+
if (!text) return { kind: "tui" };
|
|
119
|
+
if ((MUSIC_CONTROL_COMMANDS as readonly string[]).includes(text)) return { kind: "ctl", command: text as MusicControlCommand };
|
|
120
|
+
return { kind: "tui", mood: text };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** gentle-stream's own flags: the session model as `provider/id` and the mood as one argument. */
|
|
124
|
+
export function musicTuiArgs(input: { model: { provider?: unknown; id?: unknown } | undefined; mood?: string }): string[] {
|
|
125
|
+
const args: string[] = [];
|
|
126
|
+
const { provider, id } = input.model ?? {};
|
|
127
|
+
if (typeof provider === "string" && provider && typeof id === "string" && id) args.push("--model", `${provider}/${id}`);
|
|
128
|
+
if (input.mood) args.push("--mood", input.mood);
|
|
129
|
+
return args;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** One line from `gentle-stream ctl status` JSON; undefined when it is not that JSON. */
|
|
133
|
+
export function formatMusicStatus(stdout: string): string | undefined {
|
|
134
|
+
let status: unknown;
|
|
135
|
+
try {
|
|
136
|
+
status = JSON.parse(stdout.trim());
|
|
137
|
+
} catch {
|
|
138
|
+
return undefined;
|
|
139
|
+
}
|
|
140
|
+
if (!status || typeof status !== "object" || Array.isArray(status)) return undefined;
|
|
141
|
+
const { running, playing, name, genre } = status as Record<string, unknown>;
|
|
142
|
+
if (running !== true) return "nothing is playing";
|
|
143
|
+
const parts = [name, genre].filter((part): part is string => typeof part === "string" && part.length > 0);
|
|
144
|
+
return `♪ ${[...parts, playing === true ? "playing" : "paused"].join(" · ")}`;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const defaultSpawn: MusicSpawn = (command, args, options) => nodeSpawn(command, [...args], { stdio: options.stdio === "inherit" ? "inherit" : [...options.stdio] });
|
|
148
|
+
const defaultSignals: MusicSignals = { on: (signal, listener) => process.on(signal, listener), off: (signal, listener) => process.off(signal, listener) };
|
|
149
|
+
const USAGE_KEY = Symbol.for("gentle-pi.gentle-music.usage");
|
|
150
|
+
|
|
151
|
+
function processUsage(): MusicUsage {
|
|
152
|
+
const holder = globalThis as { [USAGE_KEY]?: MusicUsage };
|
|
153
|
+
return (holder[USAGE_KEY] ??= { used: false });
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function errorMessage(error: unknown): string {
|
|
157
|
+
return error instanceof Error ? error.message : String(error);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
type InheritResult = { code: number | null; signal?: NodeJS.Signals | null } | { error: unknown };
|
|
161
|
+
|
|
162
|
+
function runInherit(spawn: MusicSpawn, command: string, args: readonly string[]): Promise<InheritResult> {
|
|
163
|
+
return new Promise((resolve) => {
|
|
164
|
+
try {
|
|
165
|
+
const child = spawn(command, args, { stdio: "inherit" });
|
|
166
|
+
child.on("error", (error) => resolve({ error }));
|
|
167
|
+
child.on("close", (code, signal) => resolve({ code, signal }));
|
|
168
|
+
} catch (error) {
|
|
169
|
+
resolve({ error });
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
type CapturedResult = { code: number | null; stdout: string; stderr: string } | { error: unknown };
|
|
175
|
+
|
|
176
|
+
function runCaptured(spawn: MusicSpawn, command: string, args: readonly string[], timeoutMs: number): Promise<CapturedResult> {
|
|
177
|
+
return new Promise((resolve) => {
|
|
178
|
+
let stdout = "";
|
|
179
|
+
let stderr = "";
|
|
180
|
+
let settled = false;
|
|
181
|
+
const finish = (result: CapturedResult) => {
|
|
182
|
+
if (settled) return;
|
|
183
|
+
settled = true;
|
|
184
|
+
clearTimeout(timer);
|
|
185
|
+
resolve(result);
|
|
186
|
+
};
|
|
187
|
+
let child: MusicChild;
|
|
188
|
+
try {
|
|
189
|
+
child = spawn(command, args, { stdio: CTL_STDIO });
|
|
190
|
+
} catch (error) {
|
|
191
|
+
resolve({ error });
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
const timer = setTimeout(() => {
|
|
195
|
+
child.kill();
|
|
196
|
+
finish({ error: new Error(`timed out after ${timeoutMs} ms`) });
|
|
197
|
+
}, timeoutMs);
|
|
198
|
+
child.stdout?.on("data", (chunk) => { stdout += chunk.toString(); });
|
|
199
|
+
child.stderr?.on("data", (chunk) => { stderr += chunk.toString(); });
|
|
200
|
+
child.on("error", (error) => finish({ error }));
|
|
201
|
+
child.on("close", (code) => finish({ code, stdout, stderr }));
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Pi's external-editor handover: stop the TUI, run the child, and always restart with a full repaint. */
|
|
206
|
+
async function handOverTerminal<T>(tui: TerminalOwner, signals: MusicSignals, run: () => Promise<T>): Promise<T> {
|
|
207
|
+
// Ctrl+C belongs to the child while it owns the terminal (Pi's ctrl+z suspend does the same).
|
|
208
|
+
const ignoreSigint = () => {};
|
|
209
|
+
signals.on("SIGINT", ignoreSigint);
|
|
210
|
+
try {
|
|
211
|
+
tui.stop();
|
|
212
|
+
return await run();
|
|
213
|
+
} finally {
|
|
214
|
+
signals.off("SIGINT", ignoreSigint);
|
|
215
|
+
tui.start();
|
|
216
|
+
tui.requestRender(true);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export default function gentleMusic(pi: ExtensionAPI, deps: GentleMusicDeps = {}): void {
|
|
221
|
+
const which = deps.which ?? ((name: string) => findOnPath(name));
|
|
222
|
+
const spawn = deps.spawn ?? defaultSpawn;
|
|
223
|
+
const signals = deps.signals ?? defaultSignals;
|
|
224
|
+
const usage = deps.usage ?? processUsage();
|
|
225
|
+
const shutdownTimeoutMs = deps.shutdownTimeoutMs ?? SHUTDOWN_TIMEOUT_MS;
|
|
226
|
+
|
|
227
|
+
const openTui = async (ctx: ExtensionContext, stream: GentleStreamCommand, mood: string | undefined) => {
|
|
228
|
+
const args = [...stream.prefix, ...musicTuiArgs({ model: ctx.model, mood })];
|
|
229
|
+
const result = await ctx.ui.custom<InheritResult>((tui, _theme, _keybindings, done) => {
|
|
230
|
+
// A failing handover must still settle the custom UI, or Pi would wait for it forever.
|
|
231
|
+
void handOverTerminal(tui, signals, () => runInherit(spawn, stream.command, args)).then(done, (error: unknown) => done({ error }));
|
|
232
|
+
// The editor slot stays empty while the child owns the terminal.
|
|
233
|
+
const placeholder: Component = { render: () => [], invalidate: () => {} };
|
|
234
|
+
return placeholder;
|
|
235
|
+
});
|
|
236
|
+
if ("error" in result) ctx.ui.notify(`/gentle:music could not start gentle-stream: ${errorMessage(result.error)}`, "error");
|
|
237
|
+
else if (result.code !== 0) ctx.ui.notify(result.code === null ? `gentle-stream was stopped by ${result.signal ?? "a signal"}.` : `gentle-stream exited with code ${result.code}.`, "error");
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
const control = async (ctx: ExtensionContext, stream: GentleStreamCommand, command: MusicControlCommand) => {
|
|
241
|
+
const result = await runCaptured(spawn, stream.command, [...stream.prefix, "ctl", command], CTL_TIMEOUT_MS);
|
|
242
|
+
if ("error" in result) {
|
|
243
|
+
ctx.ui.notify(`gentle-stream ctl ${command} failed: ${errorMessage(result.error)}`, "error");
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
if (result.code !== 0) {
|
|
247
|
+
const reason = result.stderr.trim().split("\n")[0] || `gentle-stream ctl ${command} exited with code ${result.code}`;
|
|
248
|
+
ctx.ui.notify(reason, "warning");
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
if (command !== "status") {
|
|
252
|
+
ctx.ui.notify(CTL_DONE[command], "info");
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
const summary = formatMusicStatus(result.stdout);
|
|
256
|
+
if (summary) ctx.ui.notify(summary, "info");
|
|
257
|
+
else ctx.ui.notify(`gentle-stream status was not readable: ${result.stdout.trim().split("\n")[0] ?? ""}`, "warning");
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
pi.registerCommand(MUSIC_COMMAND_NAME, {
|
|
261
|
+
description: "Open gentle-stream full screen (optional mood text); b returns with the music playing, q stops it. next, pause, play, stop, status control the music without leaving Pi.",
|
|
262
|
+
handler: async (args, ctx) => {
|
|
263
|
+
if (!ctx.hasUI) return;
|
|
264
|
+
if (ctx.mode !== "tui") {
|
|
265
|
+
ctx.ui.notify("/gentle:music needs the interactive terminal.", "warning");
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
const stream = resolveGentleStream(which);
|
|
269
|
+
if (!stream) {
|
|
270
|
+
ctx.ui.notify(installHint(), "warning");
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
usage.used = true;
|
|
274
|
+
const request = parseMusicArgs(args);
|
|
275
|
+
if (request.kind === "ctl") await control(ctx, stream, request.command);
|
|
276
|
+
else await openTui(ctx, stream, request.mood);
|
|
277
|
+
},
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
// Quitting Pi stops music it started, so no audio outlives the shell.
|
|
281
|
+
// Switching or reloading sessions keeps the same process, so the music plays on.
|
|
282
|
+
pi.on("session_shutdown", async (event) => {
|
|
283
|
+
if ((event as { reason?: string }).reason !== "quit" || !usage.used) return;
|
|
284
|
+
const stream = resolveGentleStream(which);
|
|
285
|
+
if (!stream) return;
|
|
286
|
+
usage.used = false;
|
|
287
|
+
await runCaptured(spawn, stream.command, [...stream.prefix, "ctl", "stop"], shutdownTimeoutMs);
|
|
288
|
+
});
|
|
289
|
+
}
|