@stibbons/pi-opensuperwhisper 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +62 -2
- package/package.json +46 -4
- package/src/app.ts +29 -0
- package/src/hook.ts +45 -0
- package/src/index.ts +123 -0
- package/src/message.ts +40 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Daniel Holzmann
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,63 @@
|
|
|
1
|
-
#
|
|
1
|
+
# pi-opensuperwhisper
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Answer [pi](https://github.com/badlogic/pi-mono) and [oh-my-pi](https://github.com/can1357/oh-my-pi) agents from the [OpenSuperWhisper](https://github.com/my-monkeys/OpenSuperWhisper) panel on macOS.
|
|
4
|
+
|
|
5
|
+
When a run is finished and waiting for you, OpenSuperWhisper's Agents panel opens with the agent's last message. Dictate (or type) your reply there. It shows up in the session as a normal user message and starts the next run. You don't have to go back to the terminal.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
pi:
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pi install npm:@stibbons/pi-opensuperwhisper
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
oh-my-pi:
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
omp plugin install @stibbons/pi-opensuperwhisper
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
There's nothing to configure. Turn the panel on or off in OpenSuperWhisper's own settings.
|
|
22
|
+
|
|
23
|
+
## Requirements
|
|
24
|
+
|
|
25
|
+
- macOS with OpenSuperWhisper running. On any other platform the extension does nothing.
|
|
26
|
+
- **The "Claude Code" agent kind must be enabled in OpenSuperWhisper.** OpenSuperWhisper doesn't know pi or oh-my-pi yet, so the extension identifies itself as Claude Code. For the same reason the panel labels these requests "Claude Code". This is temporary until OpenSuperWhisper gets its own pi agent kind.
|
|
27
|
+
- The interactive terminal UI. In print, json and rpc modes the extension does nothing.
|
|
28
|
+
|
|
29
|
+
## How it behaves
|
|
30
|
+
|
|
31
|
+
- **Panel opens** when the run is fully settled: on pi after `agent_settled`, on oh-my-pi after a terminal `agent_end` once nothing else is queued. It doesn't open while the agent will still continue on its own (retries, compaction, queued follow-ups), after you abort a run with Esc or Ctrl+C, after a run that ended in an error, in oh-my-pi subagent sessions, or when the last message has no visible text. The panel shows only the message's text, without thinking or tool calls.
|
|
32
|
+
- **Your reply** is sent as the next user message, without the panel's "The user answered by voice…" prefix.
|
|
33
|
+
- **"Let it stop", or the panel timing out,** leaves the agent idle with no side effects.
|
|
34
|
+
- **The terminal always wins.** Typing in the terminal, starting a new run, switching or resuming sessions, or quitting cancels the panel request. Exactly one run happens, even if a panel reply arrives at the same moment.
|
|
35
|
+
- **Fails open.** If OpenSuperWhisper isn't running, its files are missing or stale, or anything goes wrong, the extension does nothing and logs nothing. It never blocks the agent or the terminal.
|
|
36
|
+
- **No network.** The extension only talks to the local OpenSuperWhisper app.
|
|
37
|
+
|
|
38
|
+
## How it works
|
|
39
|
+
|
|
40
|
+
The extension uses OpenSuperWhisper's own Claude Code integration path. It finds the running app through `~/Library/Application Support/fr.my-monkey.opensuperwhisper/agents/` (`app.pid`, `app.executable`), then runs `<app> agent-hook stop` with a Claude Code–style Stop request on stdin and `CLAUDE_PROJECT_DIR` set. If you answer, the app prints the reply on stdout. Cancelling a request kills that process, and the app closes the panel within about a second.
|
|
41
|
+
|
|
42
|
+
## Development
|
|
43
|
+
|
|
44
|
+
The package ships TypeScript source; there's no build step. pi loads it through jiti, oh-my-pi through Bun.
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
bun install
|
|
48
|
+
bun test # behaviour tests against a fake agent-hook script
|
|
49
|
+
bun run typecheck # tsc --noEmit
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Try it against the real app from a checkout:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
pi -e ./src/index.ts
|
|
56
|
+
omp -e ./src/index.ts
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
See [docs/WHAT.md](docs/WHAT.md) for the design and [docs/WHY.md](docs/WHY.md) for the motivation.
|
|
60
|
+
|
|
61
|
+
## License
|
|
62
|
+
|
|
63
|
+
MIT
|
package/package.json
CHANGED
|
@@ -1,6 +1,48 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stibbons/pi-opensuperwhisper",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Answer pi and oh-my-pi agents from the OpenSuperWhisper panel on macOS",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Daniel Holzmann <daniel@danielholzmann.com>",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/dVelopment/pi-opensuperwhisper.git"
|
|
10
|
+
},
|
|
11
|
+
"publishConfig": {
|
|
12
|
+
"access": "public"
|
|
13
|
+
},
|
|
14
|
+
"keywords": [
|
|
15
|
+
"pi-package",
|
|
16
|
+
"pi",
|
|
17
|
+
"oh-my-pi",
|
|
18
|
+
"omp",
|
|
19
|
+
"opensuperwhisper",
|
|
20
|
+
"voice",
|
|
21
|
+
"macos"
|
|
22
|
+
],
|
|
23
|
+
"type": "module",
|
|
24
|
+
"files": [
|
|
25
|
+
"src",
|
|
26
|
+
"README.md",
|
|
27
|
+
"LICENSE"
|
|
28
|
+
],
|
|
29
|
+
"pi": {
|
|
30
|
+
"extensions": [
|
|
31
|
+
"./src/index.ts"
|
|
32
|
+
]
|
|
33
|
+
},
|
|
34
|
+
"omp": {
|
|
35
|
+
"extensions": [
|
|
36
|
+
"./src/index.ts"
|
|
37
|
+
]
|
|
38
|
+
},
|
|
39
|
+
"scripts": {
|
|
40
|
+
"test": "bun test",
|
|
41
|
+
"typecheck": "tsc --noEmit"
|
|
42
|
+
},
|
|
43
|
+
"devDependencies": {
|
|
44
|
+
"@earendil-works/pi-coding-agent": "1.3.0",
|
|
45
|
+
"@types/bun": "1.4.3",
|
|
46
|
+
"typescript": "7.0.2"
|
|
47
|
+
}
|
|
48
|
+
}
|
package/src/app.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { accessSync, constants, readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Finds the running OpenSuperWhisper app through its agent registry (`app.pid`, `app.executable`).
|
|
6
|
+
* Returns the app executable, or nothing when the files are missing or malformed, the pid is
|
|
7
|
+
* dead, or the executable is missing or not runnable.
|
|
8
|
+
*/
|
|
9
|
+
export function locateApp(agentsDir: string): string | undefined {
|
|
10
|
+
try {
|
|
11
|
+
const pid = readFileSync(join(agentsDir, "app.pid"), "utf8").trim();
|
|
12
|
+
if (!/^[1-9]\d*$/.test(pid) || !isAlive(Number(pid))) return undefined;
|
|
13
|
+
const executable = readFileSync(join(agentsDir, "app.executable"), "utf8").trim();
|
|
14
|
+
if (!statSync(executable).isFile()) return undefined;
|
|
15
|
+
accessSync(executable, constants.X_OK);
|
|
16
|
+
return executable;
|
|
17
|
+
} catch {
|
|
18
|
+
return undefined;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function isAlive(pid: number): boolean {
|
|
23
|
+
try {
|
|
24
|
+
process.kill(pid, 0);
|
|
25
|
+
return true;
|
|
26
|
+
} catch (error) {
|
|
27
|
+
return (error as NodeJS.ErrnoException).code === "EPERM";
|
|
28
|
+
}
|
|
29
|
+
}
|
package/src/hook.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
/** The Claude Code–style Stop payload `agent-hook stop` reads from stdin. */
|
|
4
|
+
export interface StopRequest {
|
|
5
|
+
hook_event_name: "Stop";
|
|
6
|
+
session_id: string;
|
|
7
|
+
cwd: string;
|
|
8
|
+
last_assistant_message: string;
|
|
9
|
+
stop_hook_active: false;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface StopHook {
|
|
13
|
+
/** Resolves with the hook's stdout when it exits 0, or nothing on any failure. */
|
|
14
|
+
readonly stdout: Promise<string | undefined>;
|
|
15
|
+
/** Stops the hook (SIGTERM). Safe to call after it has exited. */
|
|
16
|
+
kill(): void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Spawns `<executable> agent-hook stop` without blocking, writes the Stop request to its stdin
|
|
21
|
+
* and collects its stdout. `CLAUDE_PROJECT_DIR` makes the app accept the request as Claude Code.
|
|
22
|
+
*/
|
|
23
|
+
export function runStopHook(executable: string, request: StopRequest): StopHook {
|
|
24
|
+
const { promise, resolve } = Promise.withResolvers<string | undefined>();
|
|
25
|
+
const child = spawn(executable, ["agent-hook", "stop"], {
|
|
26
|
+
env: { ...process.env, CLAUDE_PROJECT_DIR: request.cwd },
|
|
27
|
+
stdio: ["pipe", "pipe", "ignore"],
|
|
28
|
+
});
|
|
29
|
+
let stdout = "";
|
|
30
|
+
child.stdout.setEncoding("utf8");
|
|
31
|
+
child.stdout.on("data", (chunk: string) => {
|
|
32
|
+
stdout += chunk;
|
|
33
|
+
});
|
|
34
|
+
child.on("error", () => resolve(undefined));
|
|
35
|
+
child.on("close", (code) => resolve(code === 0 ? stdout : undefined));
|
|
36
|
+
// The hook may exit before reading stdin; a broken pipe must not crash the host.
|
|
37
|
+
child.stdin.on("error", () => {});
|
|
38
|
+
child.stdin.end(JSON.stringify(request));
|
|
39
|
+
return {
|
|
40
|
+
stdout: promise,
|
|
41
|
+
kill: () => {
|
|
42
|
+
child.kill("SIGTERM");
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import type { AgentEndEvent, ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { locateApp } from "./app.ts";
|
|
5
|
+
import { runStopHook, type StopHook } from "./hook.ts";
|
|
6
|
+
import { type AssistantMessageLike, extractText, parseReply } from "./message.ts";
|
|
7
|
+
|
|
8
|
+
export interface ExtensionOptions {
|
|
9
|
+
/** OpenSuperWhisper's agent registry directory (holds `app.pid` and `app.executable`). */
|
|
10
|
+
agentsDir: string;
|
|
11
|
+
platform: NodeJS.Platform;
|
|
12
|
+
/** Safety net: kill a hook that outlives the app's own maximum wait (590 s). */
|
|
13
|
+
hardKillMs: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const DEFAULT_OPTIONS: ExtensionOptions = {
|
|
17
|
+
agentsDir: join(homedir(), "Library", "Application Support", "fr.my-monkey.opensuperwhisper", "agents"),
|
|
18
|
+
platform: process.platform,
|
|
19
|
+
hardKillMs: 600_000,
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export function createExtension(overrides: Partial<ExtensionOptions> = {}): (pi: ExtensionAPI) => void {
|
|
23
|
+
const options = { ...DEFAULT_OPTIONS, ...overrides };
|
|
24
|
+
return (pi) => {
|
|
25
|
+
// At most one pending panel request per session. Bumping the generation orphans any reply
|
|
26
|
+
// still in flight, so typed input racing a panel reply produces exactly one run.
|
|
27
|
+
let pending: StopHook | undefined;
|
|
28
|
+
let generation = 0;
|
|
29
|
+
|
|
30
|
+
function cancelPending(): void {
|
|
31
|
+
generation++;
|
|
32
|
+
pending?.kill();
|
|
33
|
+
pending = undefined;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Opens the panel for a settled run, unless a guard says the user shouldn't be asked. */
|
|
37
|
+
function requestReply(ctx: ExtensionContext, message: AssistantMessageLike | undefined, sessionId: string): void {
|
|
38
|
+
cancelPending();
|
|
39
|
+
if (options.platform !== "darwin" || ctx.mode !== "tui") return;
|
|
40
|
+
if (!message || message.stopReason === "aborted" || message.stopReason === "error") return;
|
|
41
|
+
const text = extractText(message);
|
|
42
|
+
if (!text) return;
|
|
43
|
+
const executable = locateApp(options.agentsDir);
|
|
44
|
+
if (!executable) return;
|
|
45
|
+
const request = generation;
|
|
46
|
+
pending = runStopHook(executable, {
|
|
47
|
+
hook_event_name: "Stop",
|
|
48
|
+
session_id: sessionId,
|
|
49
|
+
cwd: ctx.cwd,
|
|
50
|
+
last_assistant_message: text,
|
|
51
|
+
stop_hook_active: false,
|
|
52
|
+
});
|
|
53
|
+
// Safety net in case the hook outlives the app's own wait (30–590 s).
|
|
54
|
+
const hardKill = setTimeout(() => {
|
|
55
|
+
if (request === generation) cancelPending();
|
|
56
|
+
}, options.hardKillMs);
|
|
57
|
+
hardKill.unref();
|
|
58
|
+
void pending.stdout.then((stdout) => {
|
|
59
|
+
clearTimeout(hardKill);
|
|
60
|
+
if (request !== generation) return;
|
|
61
|
+
pending = undefined;
|
|
62
|
+
const reply = stdout && parseReply(stdout);
|
|
63
|
+
if (!reply) return;
|
|
64
|
+
try {
|
|
65
|
+
pi.sendUserMessage(reply);
|
|
66
|
+
} catch {
|
|
67
|
+
// Fail open: the session went away mid-wait. An unhandled rejection would crash a Node host.
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Anything that starts or replaces a run makes the panel's question outdated. Our own
|
|
73
|
+
// injected reply arrives as "extension" input and must not cancel anything.
|
|
74
|
+
pi.on("input", (event) => {
|
|
75
|
+
if (event.source !== "extension") cancelPending();
|
|
76
|
+
});
|
|
77
|
+
pi.on("agent_start", cancelPending);
|
|
78
|
+
pi.on("session_shutdown", cancelPending);
|
|
79
|
+
pi.on("session_before_switch", cancelPending);
|
|
80
|
+
|
|
81
|
+
// pi: fires once the run is fully settled (no retry, compaction or queued continuation left).
|
|
82
|
+
pi.on("agent_settled", (event, ctx) => {
|
|
83
|
+
if (isOmp(ctx) || event.aborted) return;
|
|
84
|
+
const messages = ctx.sessionManager.getBranch().flatMap((entry) => (entry.type === "message" ? [entry.message] : []));
|
|
85
|
+
requestReply(ctx, lastAssistant(messages), ctx.sessionManager.getHeader()?.id ?? "");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// omp: has no settled event for extensions, so treat a terminal agent_end as settled once
|
|
89
|
+
// omp has finished handling it and nothing is queued. Subagent sessions never ask the user.
|
|
90
|
+
pi.on("agent_end", async (event, ctx) => {
|
|
91
|
+
if (!isOmp(ctx) || ctx.agent.kind !== "main") return;
|
|
92
|
+
const { messages, willContinue } = event as OmpAgentEndEvent;
|
|
93
|
+
if (willContinue) return;
|
|
94
|
+
const { promise, resolve } = Promise.withResolvers<void>();
|
|
95
|
+
setTimeout(resolve, 0);
|
|
96
|
+
await promise;
|
|
97
|
+
if (!ctx.isIdle() || ctx.hasPendingMessages()) return;
|
|
98
|
+
requestReply(ctx, lastAssistant(messages), ctx.sessionManager.getSessionId());
|
|
99
|
+
});
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** omp-only members of the extension API (checked against oh-my-pi 18.12.6). pi has none of them. */
|
|
104
|
+
interface OmpContext extends ExtensionContext {
|
|
105
|
+
getAsyncJobSnapshot(): unknown;
|
|
106
|
+
agent: { kind: "main" | "sub" };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
interface OmpAgentEndEvent extends AgentEndEvent {
|
|
110
|
+
/** True when omp will continue on its own (retry, compaction recovery, reminders, async wakes). */
|
|
111
|
+
willContinue?: boolean;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Detects the host from its API, not the JS runtime: pi can run under Bun too. */
|
|
115
|
+
function isOmp(ctx: ExtensionContext): ctx is OmpContext {
|
|
116
|
+
return typeof (ctx as Partial<OmpContext>).getAsyncJobSnapshot === "function";
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function lastAssistant(messages: readonly { role: string }[]): AssistantMessageLike | undefined {
|
|
120
|
+
return messages.findLast((message): message is AssistantMessageLike => message.role === "assistant");
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export default createExtension();
|
package/src/message.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/** The parts of a pi/omp assistant message this extension reads. */
|
|
2
|
+
export interface AssistantMessageLike {
|
|
3
|
+
role: string;
|
|
4
|
+
content?: unknown;
|
|
5
|
+
stopReason?: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Returns the visible text of an assistant message: its text blocks joined by a blank line,
|
|
10
|
+
* trimmed. Thinking, tool-call and image blocks are left out. Returns nothing when no text is left.
|
|
11
|
+
*/
|
|
12
|
+
export function extractText(message: AssistantMessageLike): string | undefined {
|
|
13
|
+
if (!Array.isArray(message.content)) return undefined;
|
|
14
|
+
const text = message.content
|
|
15
|
+
.filter((block): block is { type: "text"; text: string } => block?.type === "text" && typeof block.text === "string")
|
|
16
|
+
.map((block) => block.text.trim())
|
|
17
|
+
.filter((text) => text !== "")
|
|
18
|
+
.join("\n\n");
|
|
19
|
+
return text === "" ? undefined : text;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const REPLY_PREFIX = "The user answered by voice, through OpenSuperWhisper:\n\n";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Parses the stdout of `agent-hook stop`. Returns the user's reply, or nothing when the
|
|
26
|
+
* panel was dismissed, timed out, or printed anything other than a block decision.
|
|
27
|
+
*/
|
|
28
|
+
export function parseReply(stdout: string): string | undefined {
|
|
29
|
+
let output: unknown;
|
|
30
|
+
try {
|
|
31
|
+
output = JSON.parse(stdout);
|
|
32
|
+
} catch {
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
if (typeof output !== "object" || output === null) return undefined;
|
|
36
|
+
const { decision, reason } = output as { decision?: unknown; reason?: unknown };
|
|
37
|
+
if (decision !== "block" || typeof reason !== "string") return undefined;
|
|
38
|
+
const text = reason.startsWith(REPLY_PREFIX) ? reason.slice(REPLY_PREFIX.length) : reason;
|
|
39
|
+
return text.trim() === "" ? undefined : text;
|
|
40
|
+
}
|