pi-openappa 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/src/adapter.ts ADDED
@@ -0,0 +1,284 @@
1
+ /**
2
+ * Pure translation between host events and the OpenAPPA `appa hook` wire.
3
+ *
4
+ * This module must stay runtime-agnostic: no Pi imports, no I/O. Everything
5
+ * that knows about `appa hook` subprocess semantics lives in hook-client.ts;
6
+ * everything that knows about Pi lives in extensions/index.ts.
7
+ *
8
+ * Wire facts are documented and verified in docs/wire-notes.md (appa 0.31.1).
9
+ */
10
+
11
+ export type HookEventName =
12
+ | "SessionStart"
13
+ | "UserPromptSubmit"
14
+ | "PreToolUse"
15
+ | "PostToolUse"
16
+ | "Stop";
17
+
18
+ export interface AppaHookPayload {
19
+ session_id: string;
20
+ hook_event_name: HookEventName;
21
+ source?: "startup" | "resume";
22
+ prompt?: string;
23
+ tool_name?: string;
24
+ tool_input?: Record<string, unknown>;
25
+ tool_response?: Record<string, unknown>;
26
+ cwd?: string;
27
+ }
28
+
29
+ /** Decision for a tool call (PreToolUse). */
30
+ export type AppaCallDecision =
31
+ | { type: "allow" }
32
+ | { type: "deny"; reason: string };
33
+
34
+ /** Decision for a tool result (PostToolUse). */
35
+ export type AppaResultDecision =
36
+ | { type: "pass" }
37
+ | { type: "replace"; text: string; isError: boolean };
38
+
39
+ /**
40
+ * Pi built-in tool names → Claude Code policy names. The claude-code codec
41
+ * and battery argument selectors are written against these; custom tools pass
42
+ * through verbatim and policies declare them under the host name.
43
+ */
44
+ const TOOL_NAME_MAP: Readonly<Record<string, string>> = {
45
+ bash: "Bash",
46
+ powershell: "PowerShell",
47
+ read: "Read",
48
+ edit: "Edit",
49
+ write: "Write",
50
+ grep: "Grep",
51
+ find: "Glob",
52
+ ls: "LS",
53
+ };
54
+
55
+ export function mapToolName(piName: string): string {
56
+ return TOOL_NAME_MAP[piName] ?? piName;
57
+ }
58
+
59
+ /** pi session_start reason → Claude Code SessionStart source. */
60
+ export function mapSessionSource(
61
+ reason: "startup" | "reload" | "new" | "resume" | "fork",
62
+ ): "startup" | "resume" {
63
+ return reason === "resume" || reason === "fork" || reason === "reload"
64
+ ? "resume"
65
+ : "startup";
66
+ }
67
+
68
+ export function sessionStartPayload(
69
+ sessionId: string,
70
+ reason: "startup" | "reload" | "new" | "resume" | "fork",
71
+ cwd: string,
72
+ ): AppaHookPayload {
73
+ return {
74
+ session_id: sessionId,
75
+ hook_event_name: "SessionStart",
76
+ source: mapSessionSource(reason),
77
+ cwd,
78
+ };
79
+ }
80
+
81
+ export function promptPayload(
82
+ sessionId: string,
83
+ prompt: string,
84
+ cwd: string,
85
+ ): AppaHookPayload {
86
+ return {
87
+ session_id: sessionId,
88
+ hook_event_name: "UserPromptSubmit",
89
+ prompt,
90
+ cwd,
91
+ };
92
+ }
93
+
94
+ /**
95
+ * Build the PreToolUse payload. The returned payload's `tool_input` reference
96
+ * must be echoed byte-identically at PostToolUse time: the runtime digests it
97
+ * canonically and withholds results that mismatch (byte_mismatch). Callers
98
+ * must keep the exact `input` object they passed here until the call settles.
99
+ */
100
+ export function preToolUsePayload(
101
+ sessionId: string,
102
+ piToolName: string,
103
+ input: Record<string, unknown>,
104
+ cwd: string,
105
+ ): AppaHookPayload {
106
+ return {
107
+ session_id: sessionId,
108
+ hook_event_name: "PreToolUse",
109
+ tool_name: mapToolName(piToolName),
110
+ tool_input: input,
111
+ cwd,
112
+ };
113
+ }
114
+
115
+ /**
116
+ * Build the PostToolUse payload. `input` MUST be the exact object previously
117
+ * passed to preToolUsePayload for this call (see its doc comment).
118
+ */
119
+ export function postToolUsePayload(
120
+ sessionId: string,
121
+ piToolName: string,
122
+ input: Record<string, unknown>,
123
+ response: Record<string, unknown>,
124
+ cwd: string,
125
+ ): AppaHookPayload {
126
+ return {
127
+ session_id: sessionId,
128
+ hook_event_name: "PostToolUse",
129
+ tool_name: mapToolName(piToolName),
130
+ tool_input: input,
131
+ tool_response: response,
132
+ cwd,
133
+ };
134
+ }
135
+
136
+ export function stopPayload(sessionId: string): AppaHookPayload {
137
+ return { session_id: sessionId, hook_event_name: "Stop" };
138
+ }
139
+
140
+ /**
141
+ * Host tool result → Claude-Code-shaped `tool_response`. Structured fields are
142
+ * copied when the host provides them; every result also carries the joined
143
+ * text and error flag so unknown tools stay observable.
144
+ */
145
+ export function toolResponseFrom(parts: {
146
+ text?: string;
147
+ isError?: boolean;
148
+ details?: unknown;
149
+ }): Record<string, unknown> {
150
+ const response: Record<string, unknown> = {
151
+ output: parts.text ?? "",
152
+ isError: parts.isError === true,
153
+ };
154
+ if (isRecord(parts.details)) {
155
+ for (const key of ["stdout", "stderr", "exitcode", "interrupted"] as const) {
156
+ const value = parts.details[key];
157
+ if (value !== undefined) response[key] = value;
158
+ }
159
+ }
160
+ return response;
161
+ }
162
+
163
+ /** Render a runtime-supplied `updatedToolOutput` as model-facing text. */
164
+ export function renderToolOutput(output: Record<string, unknown>): string {
165
+ const stdout = typeof output.stdout === "string" ? output.stdout : undefined;
166
+ const stderr = typeof output.stderr === "string" ? output.stderr : undefined;
167
+ if (stdout !== undefined || stderr !== undefined) {
168
+ const parts: string[] = [];
169
+ if (stdout !== undefined && stdout !== "") parts.push(stdout);
170
+ if (stderr !== undefined && stderr !== "") parts.push(stderr);
171
+ if (parts.length > 0) return parts.join("\n");
172
+ }
173
+ return JSON.stringify(output, null, 2);
174
+ }
175
+
176
+ const BLOCKED_PREFIX = "OpenAPPA hook blocked: ";
177
+
178
+ function reasonFromStderr(stderr: string): string {
179
+ const trimmed = stderr.trim();
180
+ if (trimmed.startsWith(BLOCKED_PREFIX)) {
181
+ return trimmed.slice(BLOCKED_PREFIX.length);
182
+ }
183
+ return trimmed;
184
+ }
185
+
186
+ interface StdoutDecision {
187
+ permissionDecision?: string;
188
+ permissionDecisionReason?: string;
189
+ decision?: string;
190
+ reason?: string;
191
+ error?: string;
192
+ hookSpecificOutput?: {
193
+ hookEventName?: string;
194
+ permissionDecision?: string;
195
+ permissionDecisionReason?: string;
196
+ updatedToolOutput?: Record<string, unknown>;
197
+ };
198
+ }
199
+
200
+ function parseStdout(stdout: string): StdoutDecision | undefined {
201
+ const text = stdout.trim();
202
+ if (text === "" || text === "{}") return undefined;
203
+ try {
204
+ const parsed: unknown = JSON.parse(text);
205
+ return isRecord(parsed) ? (parsed as StdoutDecision) : undefined;
206
+ } catch {
207
+ return undefined;
208
+ }
209
+ }
210
+
211
+ function isRecord(value: unknown): value is Record<string, unknown> {
212
+ return typeof value === "object" && value !== null && !Array.isArray(value);
213
+ }
214
+
215
+ /**
216
+ * Map a finished `appa hook` invocation for a PreToolUse event to a call
217
+ * decision. Fail-closed: every unrecognized outcome denies.
218
+ */
219
+ export function parseCallDecision(
220
+ exitCode: number,
221
+ stdout: string,
222
+ stderr: string,
223
+ ): AppaCallDecision {
224
+ const out = parseStdout(stdout);
225
+ if (exitCode === 0 && out === undefined) return { type: "allow" };
226
+ if (out?.error !== undefined && exitCode !== 0) {
227
+ return { type: "deny", reason: String(out.error) };
228
+ }
229
+ const permission = out?.hookSpecificOutput?.permissionDecision ?? out?.permissionDecision;
230
+ if (permission === "deny") {
231
+ return {
232
+ type: "deny",
233
+ reason:
234
+ out?.hookSpecificOutput?.permissionDecisionReason ??
235
+ out?.permissionDecisionReason ??
236
+ out?.reason ??
237
+ "denied by APPA policy",
238
+ };
239
+ }
240
+ if (out?.decision === "block" || out?.decision === "deny_call") {
241
+ return { type: "deny", reason: out?.reason ?? "blocked by APPA policy" };
242
+ }
243
+ if (exitCode === 0 && (permission === "allow" || permission === undefined)) {
244
+ if (out?.decision !== undefined && out.decision !== "allow") {
245
+ // A decision we do not understand is treated as a denial, not a pass.
246
+ return {
247
+ type: "deny",
248
+ reason: out?.reason ?? `unrecognized APPA decision: ${out.decision}`,
249
+ };
250
+ }
251
+ return { type: "allow" };
252
+ }
253
+ return {
254
+ type: "deny",
255
+ reason: reasonFromStderr(stderr) || `appa hook exited ${exitCode}`,
256
+ };
257
+ }
258
+
259
+ /**
260
+ * Map a finished `appa hook` invocation for a PostToolUse event to a result
261
+ * decision. The runtime replaces results through `updatedToolOutput`; exit 2
262
+ * or an unreadable outcome withholds the result (isError) rather than passing
263
+ * possibly-unauthorized content through.
264
+ */
265
+ export function parseResultDecision(
266
+ exitCode: number,
267
+ stdout: string,
268
+ stderr: string,
269
+ ): AppaResultDecision {
270
+ const out = parseStdout(stdout);
271
+ const updated = out?.hookSpecificOutput?.updatedToolOutput;
272
+ if (exitCode === 0 && isRecord(updated)) {
273
+ return { type: "replace", text: renderToolOutput(updated), isError: false };
274
+ }
275
+ if (exitCode === 0 && (out === undefined || out.decision === undefined || out.decision === "allow")) {
276
+ return { type: "pass" };
277
+ }
278
+ const reason =
279
+ out?.reason ??
280
+ (out?.error !== undefined ? String(out.error) : undefined) ??
281
+ reasonFromStderr(stderr) ??
282
+ (exitCode === 0 ? "tool result withheld by APPA policy" : `appa hook exited ${exitCode}`);
283
+ return { type: "replace", text: reason, isError: true };
284
+ }
package/src/gate.ts ADDED
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Session gate: protection is opt-in, fixed at session start.
3
+ *
4
+ * Three ways a session becomes protected, most specific first for reporting:
5
+ * - a project marker `<cwd>/.pi/openappa` (project-scoped; optional content
6
+ * names that project's policy, absolute or cwd-relative),
7
+ * - launched with APPA_GATE=1 (the launcher route, mirroring `clappa`), or
8
+ * - always-on mode, persisted by `/appa on` (marker file below).
9
+ *
10
+ * An explicit APPA_CONFIG always wins as the policy source; otherwise a
11
+ * project marker's content is used; otherwise APPA's own default. The gate is
12
+ * captured once per session so a session cannot disable its own protection
13
+ * mid-run; `/appa on|off` are deliberate user commands and do re-resolve.
14
+ */
15
+
16
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
17
+ import { dirname, isAbsolute, join, resolve } from "node:path";
18
+
19
+ export const DEFAULT_RUNTIME_URL = "http://127.0.0.1:8787";
20
+
21
+ /** Base config directory honoring XDG_CONFIG_HOME, falling back to ~/.config. */
22
+ function baseConfigDir(env: NodeJS.ProcessEnv): string {
23
+ const xdg = env.XDG_CONFIG_HOME;
24
+ if (xdg !== undefined && xdg !== "") return xdg;
25
+ return join(env.HOME ?? "", ".config");
26
+ }
27
+
28
+ export function alwaysOnMarkerPath(env: NodeJS.ProcessEnv): string {
29
+ return join(baseConfigDir(env), "pi-openappa", "always-on");
30
+ }
31
+
32
+ export function projectMarkerPath(cwd: string): string {
33
+ return join(cwd, ".pi", "openappa");
34
+ }
35
+
36
+ export function isAlwaysOn(env: NodeJS.ProcessEnv): boolean {
37
+ return existsSync(alwaysOnMarkerPath(env));
38
+ }
39
+
40
+ export function setAlwaysOn(env: NodeJS.ProcessEnv, on: boolean): void {
41
+ const marker = alwaysOnMarkerPath(env);
42
+ if (on) {
43
+ mkdirSync(dirname(marker), { recursive: true });
44
+ writeFileSync(marker, "");
45
+ } else {
46
+ rmSync(marker, { force: true });
47
+ }
48
+ }
49
+
50
+ export type GateSource = "project" | "env" | "always-on" | "off";
51
+
52
+ export interface GateState {
53
+ /** Protection active for this session. */
54
+ gated: boolean;
55
+ /** Most specific reason this session is (or is not) protected. */
56
+ source: GateSource;
57
+ /** Policy for auto-start: explicit env wins, then project marker content. */
58
+ config?: string;
59
+ /** Runtime URL for health reporting (the hook binary reads it from env). */
60
+ runtimeUrl: string;
61
+ /** Hook binary used for reporting. */
62
+ hookBin: string;
63
+ }
64
+
65
+ /** Read a project marker's optional policy path; empty content resolves to none. */
66
+ function projectConfig(cwd: string): string | undefined {
67
+ try {
68
+ const content = readFileSync(projectMarkerPath(cwd), "utf8").trim();
69
+ if (content === "") return undefined;
70
+ return isAbsolute(content) ? content : resolve(cwd, content);
71
+ } catch {
72
+ return undefined;
73
+ }
74
+ }
75
+
76
+ export function captureGate(env: NodeJS.ProcessEnv, cwd?: string): GateState {
77
+ const base = {
78
+ runtimeUrl: env.APPA_RUNTIME_URL ?? DEFAULT_RUNTIME_URL,
79
+ hookBin: env.APPA_HOOK_BIN ?? "appa",
80
+ };
81
+ const explicitConfig =
82
+ env.APPA_CONFIG !== undefined && env.APPA_CONFIG !== ""
83
+ ? env.APPA_CONFIG
84
+ : undefined;
85
+ const projectGated = cwd !== undefined && existsSync(projectMarkerPath(cwd));
86
+ const projectCfg =
87
+ projectGated && cwd !== undefined ? projectConfig(cwd) : undefined;
88
+ const config = explicitConfig ?? projectCfg;
89
+
90
+ if (env.APPA_GATE === "1" || projectGated) {
91
+ return {
92
+ gated: true,
93
+ source: projectGated ? "project" : "env",
94
+ ...base,
95
+ ...(config !== undefined ? { config } : {}),
96
+ };
97
+ }
98
+ if (isAlwaysOn(env)) {
99
+ return {
100
+ gated: true,
101
+ source: "always-on",
102
+ ...base,
103
+ ...(explicitConfig !== undefined ? { config: explicitConfig } : {}),
104
+ };
105
+ }
106
+ return { gated: false, source: "off", ...base };
107
+ }
108
+
109
+ export interface HealthResult {
110
+ ok: boolean;
111
+ detail: string;
112
+ }
113
+
114
+ export async function checkHealth(
115
+ runtimeUrl: string,
116
+ timeoutMs = 2000,
117
+ ): Promise<HealthResult> {
118
+ try {
119
+ const response = await fetch(new URL("health", withSlash(runtimeUrl)), {
120
+ signal: AbortSignal.timeout(timeoutMs),
121
+ });
122
+ const body = (await response.text()).trim();
123
+ if (response.ok && body === "ok") {
124
+ return { ok: true, detail: "ok" };
125
+ }
126
+ return { ok: false, detail: body !== "" ? body : `HTTP ${response.status}` };
127
+ } catch (error) {
128
+ const detail = error instanceof Error ? error.message : String(error);
129
+ return { ok: false, detail };
130
+ }
131
+ }
132
+
133
+ function withSlash(url: string): string {
134
+ return url.endsWith("/") ? url : `${url}/`;
135
+ }
@@ -0,0 +1,133 @@
1
+ /**
2
+ * The only module that talks to OpenAPPA: it runs `appa hook` and returns the
3
+ * raw outcome. It never decides — adapter.ts interprets, extensions wire.
4
+ *
5
+ * Design rules:
6
+ * - Never throw for policy or transport outcomes; every failure becomes a
7
+ * HookOutcome the decision parsers treat as fail-closed.
8
+ * - Gate and URL come from the inherited environment (APPA_GATE is checked by
9
+ * the gate module before any invocation happens; APPA_RUNTIME_URL and other
10
+ * APPA_* conventions flow straight through to the binary).
11
+ */
12
+
13
+ import { spawn } from "node:child_process";
14
+ import type { AppaHookPayload } from "./adapter.ts";
15
+
16
+ export interface HookOutcome {
17
+ exitCode: number;
18
+ stdout: string;
19
+ stderr: string;
20
+ timedOut: boolean;
21
+ }
22
+
23
+ export interface InvokeOptions {
24
+ /** Report a finished turn (non-blocking `--turn-end`). */
25
+ turnEnd?: boolean;
26
+ /** Bring the runtime up before posting (SessionStart semantics). */
27
+ ensureRuntime?: boolean;
28
+ /** Config the started runtime serves; requires ensureRuntime. */
29
+ config?: string;
30
+ /** Override for the `appa` binary; defaults to APPA_HOOK_BIN or "appa". */
31
+ bin?: string;
32
+ /** Kill the hook after this many ms; defaults to APPA_HOOK_TIMEOUT_MS or 15000. */
33
+ timeoutMs?: number;
34
+ }
35
+
36
+ const DEFAULT_TIMEOUT_MS = 15_000;
37
+
38
+ export function resolveHookBin(env: NodeJS.ProcessEnv): string {
39
+ return env.APPA_HOOK_BIN ?? "appa";
40
+ }
41
+
42
+ export function resolveTimeoutMs(env: NodeJS.ProcessEnv): number {
43
+ const raw = Number(env.APPA_HOOK_TIMEOUT_MS);
44
+ return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_TIMEOUT_MS;
45
+ }
46
+
47
+ export async function invokeAppaHook(
48
+ payload: AppaHookPayload,
49
+ options: InvokeOptions = {},
50
+ ): Promise<HookOutcome> {
51
+ const bin = options.bin ?? resolveHookBin(process.env);
52
+ const timeoutMs = options.timeoutMs ?? resolveTimeoutMs(process.env);
53
+ const args = ["hook"];
54
+ if (options.turnEnd === true) args.push("--turn-end");
55
+ if (options.ensureRuntime === true) args.push("--ensure-runtime");
56
+ if (options.config !== undefined) args.push("--config", options.config);
57
+
58
+ return await new Promise<HookOutcome>((resolve) => {
59
+ let child;
60
+ try {
61
+ child = spawn(bin, args, {
62
+ env: { ...process.env, APPA_GATE: "1" },
63
+ stdio: ["pipe", "pipe", "pipe"],
64
+ });
65
+ } catch (error) {
66
+ resolve(spawnFailure(error));
67
+ return;
68
+ }
69
+
70
+ let stdout = "";
71
+ let stderr = "";
72
+ let timedOut = false;
73
+ let settled = false;
74
+
75
+ const timer = setTimeout(() => {
76
+ timedOut = true;
77
+ child.kill("SIGKILL");
78
+ }, timeoutMs);
79
+
80
+ child.stdout?.on("data", (chunk: Buffer) => {
81
+ stdout += chunk.toString("utf8");
82
+ });
83
+ child.stderr?.on("data", (chunk: Buffer) => {
84
+ stderr += chunk.toString("utf8");
85
+ });
86
+
87
+ const finish = (exitCode: number) => {
88
+ if (settled) return;
89
+ settled = true;
90
+ clearTimeout(timer);
91
+ if (timedOut) {
92
+ resolve({
93
+ exitCode: -1,
94
+ stdout,
95
+ stderr: `${stderr}appa hook timed out after ${timeoutMs}ms`.trim(),
96
+ timedOut: true,
97
+ });
98
+ return;
99
+ }
100
+ resolve({ exitCode, stdout, stderr, timedOut: false });
101
+ };
102
+
103
+ child.on("error", (error) => {
104
+ resolve(mergeOutcome(spawnFailure(error), stderr));
105
+ settled = true;
106
+ clearTimeout(timer);
107
+ });
108
+ child.on("close", (code) => finish(code ?? -1));
109
+
110
+ try {
111
+ child.stdin?.end(JSON.stringify(payload));
112
+ } catch (error) {
113
+ child.kill("SIGKILL");
114
+ resolve(mergeOutcome(spawnFailure(error), stderr));
115
+ settled = true;
116
+ clearTimeout(timer);
117
+ }
118
+ });
119
+ }
120
+
121
+ function spawnFailure(error: unknown): HookOutcome {
122
+ const detail = error instanceof Error ? error.message : String(error);
123
+ return {
124
+ exitCode: -1,
125
+ stdout: "",
126
+ stderr: `appa hook failed to start: ${detail}`,
127
+ timedOut: false,
128
+ };
129
+ }
130
+
131
+ function mergeOutcome(base: HookOutcome, stderrSoFar: string): HookOutcome {
132
+ return { ...base, stderr: `${stderrSoFar}${base.stderr}`.trim() };
133
+ }