pi-openappa 0.0.0-stage → 0.2.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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 aemonge
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,159 @@
1
- # Temporary Holding Version
1
+ # pi-openappa
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ A thin [OpenAPPA](https://openappa.com) guard extension for
4
+ [Pi](https://pi.dev): every tool call in a protected session is checked by the
5
+ APPA runtime before it runs, and every tool result before the model sees it.
6
+ No policy logic lives here — the APPA runtime owns every decision; this
7
+ extension only translates events and enforces the answer.
8
+
9
+ ```text
10
+ pi event ──▶ adapter ──▶ `appa hook` ──▶ APPA runtime
11
+ allow / deny / replace
12
+ pi event ◀── enforce ◀── decision ◀────────────┘
13
+ ```
14
+
15
+ ## Requirements
16
+
17
+ - The `appa` binary on `PATH`: `curl -fsSL https://openappa.com/install.sh | sh`
18
+ (the extension installs it automatically when missing) — version 0.31.x
19
+ verified; see `docs/wire-notes.md` for the recorded contract
20
+ - An APPA runtime listening on loopback (default `127.0.0.1:8787`)
21
+ - A policy (`appa.toml`) that declares the tools your sessions may use
22
+
23
+ ## Install
24
+
25
+ ```sh
26
+ pi install npm:pi-openappa # published on npm; indexed by the Pi gallery
27
+ pi install ./pi-openappa # from a checkout
28
+ ```
29
+
30
+ ## Smoke test
31
+
32
+ Ship and verify in one pass:
33
+
34
+ ```sh
35
+ just deploy # sync the lockfile, run checks, npm publish
36
+ just remove # drop a local-checkout install, if present
37
+ pi install npm:pi-openappa # install the published package
38
+ pi # any session: protection on, appa auto-installs
39
+ appa --version # OK — the runtime is on PATH
40
+ ```
41
+
42
+ `pi install` only registers the package — extension code runs when a session
43
+ starts, so `appa` appears after that first session, not before.
44
+
45
+ ## Protect sessions
46
+
47
+ Protection is **on by default**: every Pi session is guarded unless you opt
48
+ out. Opt-outs, most specific first:
49
+
50
+ - **Per launch:** `APPA_GATE=0 pi` (and `APPA_GATE=1 pi` to force it on).
51
+ - **Per project:** create `<project>/.pi/no-openappa` — sessions started in
52
+ that directory run unguarded.
53
+ - **Globally:** `/appa off` once (marker `~/.config/pi-openappa/off`) — every
54
+ session everywhere runs unguarded until `/appa on`.
55
+
56
+ The project marker `<project>/.pi/openappa` names that project's policy
57
+ (absolute or cwd-relative) and re-enables protection even when globally off;
58
+ empty content falls back to `APPA_CONFIG` or APPA's default:
59
+
60
+ ```sh
61
+ cd your-project && mkdir -p .pi && echo "appa.toml" > .pi/openappa
62
+ ```
63
+
64
+ A protected session brings the runtime up on its own: `session_start` invokes
65
+ `appa hook --ensure-runtime`, passing `--config "$APPA_CONFIG"` when set and
66
+ otherwise letting APPA use its own default policy (`~/.config/appa/appa.toml`).
67
+ Protected sessions therefore need zero manual server management. A custom
68
+ `APPA_RUNTIME_URL` names a runtime that is *yours* to start — the hook
69
+ refuses with exactly that reason instead of guessing.
70
+
71
+ When the default `appa` is missing from `PATH`, a protected session installs
72
+ it itself — once, with a UI notice — by running the same official script
73
+ (`curl -fsSL https://openappa.com/install.sh | sh`), then retries starting
74
+ the runtime. Sessions that name a custom `APPA_HOOK_BIN` are never
75
+ auto-installed.
76
+
77
+ While protected, a runtime that cannot answer blocks the call and the reason
78
+ is returned to the model — **silence never means yes**; `/appa off` always
79
+ works, even with every tool call blocked. One exception: with **no policy
80
+ anywhere** (`APPA_CONFIG`, marker content, and `~/.config/appa/appa.toml` all
81
+ absent) and no runtime answering, the session runs **unprotected** with one
82
+ startup warning naming the fixes — an unconfigured guard must not lock you
83
+ out of your own machine. A named policy that fails stays fail-closed.
84
+ Opted-out sessions never invoke the hook.
85
+
86
+ ## Configuration
87
+
88
+ | Variable | Default | Meaning |
89
+ |---|---|---|
90
+ | `APPA_GATE` | unset | `1` forces protection on for this launch; `0` forces it off |
91
+ | `.pi/openappa` | absent | Project marker: names that project's policy and forces protection on |
92
+ | `.pi/no-openappa` | absent | Project opt-out: sessions started there run unguarded |
93
+ | `APPA_RUNTIME_URL` | `http://127.0.0.1:8787` | Runtime endpoint (loopback only) |
94
+ | `APPA_CONFIG` | unset | `appa.toml` the session auto-starts the runtime with |
95
+ | `APPA_HOOK_BIN` | `appa` | Hook binary to invoke |
96
+ | `APPA_INSTALL_CMD` | `curl -fsSL https://openappa.com/install.sh \| sh` | Auto-install command for a missing default `appa` (pin a mirror or offline copy) |
97
+ | `APPA_HOOK_TIMEOUT_MS` | `15000` | Kill the hook after this long; the call is then blocked |
98
+ | `APPA_INSTALL_TIMEOUT_MS` | `120000` | Kill a stuck auto-install after this long |
99
+
100
+ `/appa` reports protection, opt-out state, and runtime health; `/appa off`
101
+ disables protection globally (marker `~/.config/pi-openappa/off`) and
102
+ `/appa on` re-enables it, taking effect immediately including the
103
+ current session.
104
+
105
+ ## Event mapping
106
+
107
+ | Pi event | APPA event | Effect |
108
+ |---|---|---|
109
+ | `session_start` | `SessionStart` | opens the trajectory; warns when the runtime is down |
110
+ | `before_agent_start` | `UserPromptSubmit` | turn boundary (never gates) |
111
+ | `tool_call` | `PreToolUse` | deny blocks the call; the reason reaches the model |
112
+ | `tool_result` | `PostToolUse` | replace swaps the result the model sees |
113
+ | `turn_end` | `Stop` | reported (`--turn-end`), never gates |
114
+
115
+ Pi built-in tool names map to Claude Code policy names (`bash`→`Bash`,
116
+ `find`→`Glob`, …); custom and MCP tools pass through under their own names and
117
+ policies declare them verbatim.
118
+
119
+ ## Limitations
120
+
121
+ - **Load order matters in theory**: the runtime digests tool arguments at
122
+ `PreToolUse`. If another extension rewrites `event.input` after this
123
+ handler runs, the executed arguments differ from the checked ones and the
124
+ runtime withholds the result (`byte_mismatch`). Load pi-openappa last if
125
+ you combine it with argument-rewriting extensions.
126
+ - **Policy edits reach new sessions only**: trajectories keep the policy they
127
+ opened with, by runtime design. When iterating on a policy, restart the
128
+ runtime (and start fresh sessions) — a stale runtime or an old trajectory
129
+ will keep serving the old policy.
130
+ - **Subagents**: spawning is mediated as a plain tool call (deny blocks the
131
+ spawn). Child trajectories are not linked into the parent's label chain
132
+ yet; a gated child Pi process opens its own root trajectory.
133
+ - **On by default**: installing this extension guards every session and may
134
+ download and run openappa.com's install script once on first run. The
135
+ opt-outs above and `APPA_INSTALL_CMD` are the escapes.
136
+ - **Auto-install is `curl \| sh`**: a protected session with the default
137
+ binary missing downloads and runs the script with user privileges, at most
138
+ once per session start. Pre-install `appa` or pin `APPA_INSTALL_CMD` to
139
+ avoid it.
140
+ - The adapter never starts a runtime for opted-out sessions; auto-start needs
141
+ either `APPA_CONFIG` or an installed APPA deployment.
142
+ - OpenAPPA is Preview & RFC: wire surfaces may break without shims. The
143
+ entire wire contract lives in `src/hook-client.ts` and `src/adapter.ts`
144
+ (verified facts in `docs/wire-notes.md`), and the adapter core is
145
+ runtime-agnostic so a pi-durable wiring can reuse it unchanged.
146
+
147
+ ## Development
148
+
149
+ ```sh
150
+ npm test # node --test, mock-driven, no runtime needed
151
+ npm run typecheck
152
+ ```
153
+
154
+ The adapter core (`src/adapter.ts`) has no Pi imports by design; the Pi
155
+ wiring is `extensions/index.ts` only.
156
+
157
+ ## License
158
+
159
+ MIT
@@ -0,0 +1,80 @@
1
+ # Wire notes: `appa hook` contract (verified 2026-10-06, appa 0.31.1)
2
+
3
+ All facts below were probed live against `appa runtime` on 127.0.0.1:8791 with a
4
+ minimal one-tool policy. Probes: SessionStart, UserPromptSubmit, PreToolUse (allow,
5
+ undeclared-refuse), PostToolUse (byte-mismatch withhold), Stop --turn-end, plus
6
+ ungated and runtime-down probes.
7
+
8
+ ## Transport
9
+
10
+ - Subprocess: `appa hook` (env inherited; gate and URL read from env)
11
+ - Gate: inert unless `APPA_GATE=1` — ungated always exits 0, no side effects
12
+ - URL: `APPA_RUNTIME_URL` (default `127.0.0.1:8787`, loopback only)
13
+ - Turn-end: pass `--turn-end` (non-blocking report; exit 0 even when down)
14
+ - Payload: Claude Code hook JSON on stdin
15
+
16
+ ## Payload fields (accepted and observed)
17
+
18
+ - `session_id` — becomes trajectory `cc:<session_id>` in the runtime
19
+ - `hook_event_name` — `SessionStart` | `UserPromptSubmit` | `PreToolUse` |
20
+ `PostToolUse` | `Stop`
21
+ - `source` (SessionStart): `startup` | `resume`
22
+ - `prompt` (UserPromptSubmit)
23
+ - `tool_name`, `tool_input` (PreToolUse / PostToolUse)
24
+ - `tool_response` (PostToolUse)
25
+ - `cwd`
26
+
27
+ ## Decision protocol (observed)
28
+
29
+ - Exit 0, no stdout (or `{}`) → proceed. SessionStart/UserPromptSubmit behave so.
30
+ - Exit 0 + `{"hookSpecificOutput":{"hookEventName":"PreToolUse",
31
+ "permissionDecision":"allow","permissionDecisionReason":"..."}}` → proceed.
32
+ - Exit 2 + stderr `OpenAPPA hook blocked: <reason>` → block; reason goes to the
33
+ model. Observed for an undeclared tool (HTTP 409 upstream, stdout carries
34
+ `{"error": "<reason>"}`).
35
+ - Exit 0 + `{"decision":"block","reason":"...",
36
+ "hookSpecificOutput":{"hookEventName":"PostToolUse","updatedToolOutput":{...}}}`
37
+ → PostToolUse replacement: `updatedToolOutput` is what the model must see
38
+ (observed: withheld-result notice replacing a mismatched result).
39
+ - Runtime down: PreToolUse exit 2 (fail-closed, stderr names 127.0.0.1:8787);
40
+ Stop --turn-end exit 0 + stderr warning.
41
+
42
+ ## Hard constraints
43
+
44
+ 1. **Byte-exact echo**: PostToolUse `tool_input` must be byte-identical to the
45
+ PreToolUse `tool_input` (runtime digests it canonically; mismatch → result
46
+ withheld with `byte_mismatch`). The extension must remember the exact object
47
+ it sent per tool call id.
48
+ 2. **Tool naming**: the runtime sees `host/claude-code/<Name>`; policies are
49
+ written in Claude-Code naming. Map pi built-ins: bash→Bash,
50
+ powershell→PowerShell, read→Read, edit→Edit, write→Write, grep→Grep,
51
+ find→Glob, ls→LS. Unknown/custom tools pass through verbatim; policy authors
52
+ declare them under the pi name.
53
+ 3. **tool_response shape**: Claude-Code-shaped for tools where pi provides the
54
+ data (bash: `stdout`/`stderr`/`exitcode`/`interrupted` from details when
55
+ present); otherwise generic `{ "output": <joined text>, "isError": <bool> }`.
56
+
57
+ ## Session mapping
58
+
59
+ pi `session_start.reason`: `startup|new` → `startup`; `resume|fork|reload` →
60
+ `resume`.
61
+
62
+ ## Not yet observed (client parses defensively)
63
+
64
+ - `permissionDecision: "deny"` exact shape (a declared-but-denied call)
65
+ - `deliver_value` / remedy offers on PreToolUse
66
+ - Subagent (`SubagentStop`, child trajectories) — out of v1 scope
67
+
68
+ ## Auto-start (verified 2026-10-06)
69
+
70
+ - `appa hook --ensure-runtime --config <path>` with nothing on the default
71
+ port boots a runtime on `127.0.0.1:8787`; the hook exits 0.
72
+ - `--ensure-runtime` with a custom `APPA_RUNTIME_URL` exits 2:
73
+ `the runtime could not be started: nothing answers <url>, and a runtime at
74
+ a URL the session named is the user's own to start` — custom URLs are
75
+ user-managed by design; nothing binds 8787 in that case.
76
+
77
+ ## Test seam
78
+
79
+ `appa replay <dir>` checks `.appa` trace files against a policy; mock-driven unit
80
+ tests remain our primary seam (no runtime needed).
@@ -0,0 +1,263 @@
1
+ /**
2
+ * pi-openappa — Pi extension wiring for OpenAPPA.
3
+ *
4
+ * Thin by design: translate Pi events to `appa hook` invocations and enforce
5
+ * the answer. No policy logic lives here; the APPA runtime owns every
6
+ * decision. Protection is opt-in per session (APPA_GATE=1 at launch) and
7
+ * fail-closed while gated: if the runtime cannot answer, the call is blocked.
8
+ *
9
+ * Wire facts and constraints: docs/wire-notes.md
10
+ */
11
+
12
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
13
+ import {
14
+ parseCallDecision,
15
+ parseResultDecision,
16
+ postToolUsePayload,
17
+ preToolUsePayload,
18
+ promptPayload,
19
+ sessionStartPayload,
20
+ stopPayload,
21
+ toolResponseFrom,
22
+ } from "../src/adapter.ts";
23
+ import { invokeAppaHook, resolveHookBin, type HookOutcome } from "../src/hook-client.ts";
24
+ import { installAppa, type InstallOutcome } from "../src/installer.ts";
25
+ import {
26
+ appaDefaultPolicyExists,
27
+ captureGate,
28
+ checkHealth,
29
+ setGloballyOff,
30
+ type GateState,
31
+ } from "../src/gate.ts";
32
+
33
+ interface TextPart {
34
+ type: "text";
35
+ text: string;
36
+ }
37
+
38
+ export default function (pi: ExtensionAPI): void {
39
+ /** Launch-fixed protection state; null until the session starts. */
40
+ let gate: GateState | null = null;
41
+ let sessionId = "";
42
+ /** No policy anywhere and nothing answered: this session runs unprotected. */
43
+ let unprotected = false;
44
+ /**
45
+ * toolCallId → the exact `input` object sent in the PreToolUse payload.
46
+ * PostToolUse must echo it byte-identically or the runtime withholds the
47
+ * result (byte_mismatch, see wire notes).
48
+ */
49
+ const pendingInputs = new Map<string, Record<string, unknown>>();
50
+
51
+ const gated = (): boolean => gate?.gated === true && !unprotected;
52
+
53
+ pi.on("session_start", async (event, ctx) => {
54
+ gate = captureGate(process.env, ctx.cwd);
55
+ sessionId = ctx.sessionManager.getSessionId();
56
+ if (!gate.gated) return;
57
+ unprotected = false;
58
+
59
+ const payload = sessionStartPayload(sessionId, event.reason, ctx.cwd);
60
+ const options = {
61
+ ensureRuntime: true,
62
+ ...(gate.config !== undefined ? { config: gate.config } : {}),
63
+ };
64
+ const notify = (message: string, kind: "info" | "warning"): void => {
65
+ if (ctx.hasUI) ctx.ui.notify(message, kind);
66
+ };
67
+ let outcome = await invokeAppaHook(payload, options);
68
+ let warnedAlready = false;
69
+ // A missing default `appa` is self-provisioned: run the official install
70
+ // script once, then retry bringing the runtime up. Custom APPA_HOOK_BINs
71
+ // are the user's own and never auto-installed.
72
+ if (outcome.binaryMissing && resolveHookBin(process.env) === "appa") {
73
+ notify(
74
+ "`appa` was not found — installing the OpenAPPA runtime " +
75
+ "(https://openappa.com/install.sh)…",
76
+ "info",
77
+ );
78
+ const install = await installAppa();
79
+ if (install.exitCode !== 0) {
80
+ warnedAlready = true;
81
+ notify(
82
+ `OpenAPPA auto-install failed: ${outcomeTail(install)}. ` +
83
+ "Tool calls stay blocked; install `appa` manually (see the README) or run /appa off.",
84
+ "warning",
85
+ );
86
+ } else {
87
+ const retry = await invokeAppaHook(payload, options);
88
+ if (retry.binaryMissing) {
89
+ warnedAlready = true;
90
+ notify(
91
+ "OpenAPPA auto-install finished but `appa` is still not on PATH. " +
92
+ `Installer output: ${outcomeTail(install)} — restart the session once PATH has it.`,
93
+ "warning",
94
+ );
95
+ }
96
+ outcome = retry;
97
+ }
98
+ }
99
+ if (outcome.exitCode !== 0) {
100
+ // No policy anywhere and nothing answering: run this session
101
+ // unprotected with one warning instead of fail-closed (the configured
102
+ // default). A named policy that fails stays fail-closed below.
103
+ if (
104
+ resolveHookBin(process.env) === "appa" &&
105
+ gate.config === undefined &&
106
+ !appaDefaultPolicyExists(process.env) &&
107
+ !(await checkHealth(gate.runtimeUrl)).ok
108
+ ) {
109
+ unprotected = true;
110
+ if (!warnedAlready) {
111
+ notify(
112
+ "OpenAPPA is on by default, but no policy exists (APPA_CONFIG, " +
113
+ ".pi/openappa, or ~/.config/appa/appa.toml) and no runtime answers " +
114
+ `at ${gate.runtimeUrl} — this session runs unprotected. ` +
115
+ "Write a policy, or run /appa off.",
116
+ "warning",
117
+ );
118
+ }
119
+ return;
120
+ }
121
+ if (ctx.hasUI && !warnedAlready) {
122
+ const remedy =
123
+ gate.config === undefined
124
+ ? " Provide a policy (this project's .pi/openappa, APPA_CONFIG, or ~/.config/appa/appa.toml), or run /appa off."
125
+ : "";
126
+ ctx.ui.notify(
127
+ `OpenAPPA gated but the runtime did not answer (${gate.runtimeUrl}): ` +
128
+ `${outcome.stderr.trim() || `exit ${outcome.exitCode}`}.${remedy} ` +
129
+ "Tool calls will be blocked until it answers.",
130
+ "warning",
131
+ );
132
+ }
133
+ }
134
+ });
135
+
136
+ pi.on("before_agent_start", async (event, ctx) => {
137
+ if (!gated()) return;
138
+ // The prompt event establishes the turn boundary; it does not gate.
139
+ await invokeAppaHook(promptPayload(sessionId, event.prompt, ctx.cwd));
140
+ });
141
+
142
+ pi.on("tool_call", async (event, ctx) => {
143
+ if (!gated()) return;
144
+ const payload = preToolUsePayload(sessionId, event.toolName, event.input, ctx.cwd);
145
+ const outcome = await invokeAppaHook(payload);
146
+ const decision = parseCallDecision(outcome.exitCode, outcome.stdout, outcome.stderr);
147
+ if (decision.type === "deny") {
148
+ return { block: true, reason: `appa: ${decision.reason}` };
149
+ }
150
+ pendingInputs.set(event.toolCallId, event.input);
151
+ return undefined;
152
+ });
153
+
154
+ pi.on("tool_result", async (event, ctx) => {
155
+ if (!gated()) return;
156
+ const sentInput = pendingInputs.get(event.toolCallId);
157
+ pendingInputs.delete(event.toolCallId);
158
+ const response = toolResponseFrom({
159
+ text: joinContent(event.content),
160
+ isError: event.isError,
161
+ details: event.details,
162
+ });
163
+ const payload = postToolUsePayload(
164
+ sessionId,
165
+ event.toolName,
166
+ sentInput ?? event.input,
167
+ response,
168
+ ctx.cwd,
169
+ );
170
+ const outcome = await invokeAppaHook(payload);
171
+ const decision = parseResultDecision(outcome.exitCode, outcome.stdout, outcome.stderr);
172
+ if (decision.type === "pass") {
173
+ return undefined;
174
+ }
175
+ return {
176
+ content: [{ type: "text", text: decision.text } satisfies TextPart],
177
+ isError: decision.isError,
178
+ };
179
+ });
180
+
181
+ pi.on("turn_end", async () => {
182
+ if (!gated()) return;
183
+ // Turn completion is reported, never gated (matching --turn-end).
184
+ await invokeAppaHook(stopPayload(sessionId), { turnEnd: true });
185
+ });
186
+
187
+ pi.on("session_shutdown", () => {
188
+ pendingInputs.clear();
189
+ });
190
+
191
+ pi.registerCommand("appa", {
192
+ description: "Show OpenAPPA status; `appa on|off` toggles protection globally",
193
+ handler: async (args, ctx) => {
194
+ const arg = args.trim();
195
+ if (arg === "on" || arg === "off") {
196
+ setGloballyOff(process.env, arg === "off");
197
+ gate = captureGate(process.env, ctx.cwd);
198
+ if (ctx.hasUI) {
199
+ ctx.ui.notify(
200
+ arg === "on"
201
+ ? "OpenAPPA protection re-enabled: on by default for every session."
202
+ : "OpenAPPA protection disabled globally (/appa on re-enables).",
203
+ "info",
204
+ );
205
+ }
206
+ return;
207
+ }
208
+ const state = gate ?? captureGate(process.env, ctx.cwd);
209
+ const mode =
210
+ state.source === "project"
211
+ ? "project (.pi/openappa)"
212
+ : state.source === "env-on"
213
+ ? "launch (APPA_GATE=1)"
214
+ : state.source === "env-off"
215
+ ? "launch opt-out (APPA_GATE=0)"
216
+ : state.source === "project-off"
217
+ ? "project opt-out (.pi/no-openappa)"
218
+ : state.source === "global-off"
219
+ ? "global opt-out (/appa on re-enables)"
220
+ : "on by default (/appa off disables)";
221
+ const lines: string[] = [];
222
+ lines.push(state.gated ? `Protection: ON (session ${sessionId || "not started"})` : `Protection: off — ${mode}`);
223
+ if (unprotected) {
224
+ lines.push("Session: unprotected — no policy found; see the startup warning.");
225
+ }
226
+ if (state.gated && !unprotected) lines.push(`Mode: ${mode}`);
227
+ if (state.config !== undefined) lines.push(`Policy: ${state.config}`);
228
+ lines.push(`Runtime: ${state.runtimeUrl}`);
229
+ const health = await checkHealth(state.runtimeUrl);
230
+ lines.push(`Health: ${health.ok ? "ok" : `unreachable (${health.detail})`}`);
231
+ if (state.gated && !health.ok) {
232
+ lines.push("While the runtime is down, gated tool calls are blocked (fail-closed).");
233
+ }
234
+ const message = lines.join("\n");
235
+ if (ctx.hasUI) {
236
+ ctx.ui.notify(message, health.ok || !state.gated ? "info" : "warning");
237
+ }
238
+ },
239
+ });
240
+ }
241
+
242
+ /** Last ~200 chars of installer output, whitespace-normalized, for notices. */
243
+ function outcomeTail(outcome: InstallOutcome): string {
244
+ const text = `${outcome.stderr} ${outcome.stdout}`.trim().replace(/\s+/g, " ");
245
+ if (text === "") return "(no output)";
246
+ return text.length > 200 ? `…${text.slice(-200)}` : text;
247
+ }
248
+
249
+ function joinContent(content: ReadonlyArray<unknown>): string {
250
+ const parts: string[] = [];
251
+ for (const item of content) {
252
+ if (
253
+ typeof item === "object" &&
254
+ item !== null &&
255
+ (item as { type?: unknown }).type === "text"
256
+ ) {
257
+ parts.push(String((item as { text?: unknown }).text ?? ""));
258
+ } else {
259
+ parts.push("[non-text content omitted]");
260
+ }
261
+ }
262
+ return parts.join("\n");
263
+ }
package/justfile ADDED
@@ -0,0 +1,39 @@
1
+ # pi-openappa — a Pi extension for OpenAPPA
2
+ # Just runs from the repo root; recipes are boring on purpose.
3
+
4
+ # Run everything a change should pass (default)
5
+ default: check
6
+
7
+ # Tests + typecheck together
8
+ check: test typecheck
9
+
10
+ # Mock-driven test suite (no APPA runtime needed)
11
+ test:
12
+ npm test
13
+
14
+ # TypeScript check, no emit
15
+ typecheck:
16
+ npm run typecheck
17
+
18
+ # Install this checkout as a local Pi package (loads live from this path)
19
+ install:
20
+ pi install {{justfile_directory()}}
21
+
22
+ # Reconcile Pi package installations after dependency changes
23
+ update:
24
+ pi update --extensions
25
+
26
+ # Remove this checkout from Pi's packages
27
+ remove:
28
+ pi remove {{justfile_directory()}}
29
+
30
+ # Publish to npm (requires `npm login`; the Pi gallery indexes the pi-package keyword)
31
+ publish: check
32
+ npm publish
33
+
34
+ # Ship a version: sync the lockfile version, run every check, publish to npm
35
+ # (run the README smoke test right after)
36
+ deploy:
37
+ npm install --package-lock-only --no-audit --no-fund
38
+ just check
39
+ npm publish
package/package.json CHANGED
@@ -1,6 +1,37 @@
1
1
  {
2
2
  "name": "pi-openappa",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.0",
4
+ "description": "Thin OpenAPPA guard extension for Pi: gates tool calls through the APPA runtime. No policy logic lives here.",
5
+ "keywords": [
6
+ "pi-package",
7
+ "openappa",
8
+ "appa",
9
+ "security",
10
+ "guardrails",
11
+ "policy"
12
+ ],
13
+ "license": "MIT",
14
+ "author": "aemonge",
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/aemonge-dev/pi-openappa.git"
18
+ },
19
+ "homepage": "https://github.com/aemonge-dev/pi-openappa#readme",
20
+ "bugs": "https://github.com/aemonge-dev/pi-openappa/issues",
21
+ "type": "module",
22
+ "pi": {
23
+ "extensions": ["./extensions/index.ts"]
24
+ },
25
+ "main": "./extensions/index.ts",
26
+ "scripts": {
27
+ "test": "node --test 'test/*.test.ts'",
28
+ "typecheck": "tsc --noEmit"
29
+ },
30
+ "peerDependencies": {
31
+ "@earendil-works/pi-coding-agent": "*"
32
+ },
33
+ "devDependencies": {
34
+ "@earendil-works/pi-coding-agent": "^1.0.4",
35
+ "typescript": "^5.9.3"
36
+ }
37
+ }