pi-openappa 0.2.0 → 0.3.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 CHANGED
@@ -14,92 +14,118 @@ pi event ◀── enforce ◀── decision ◀─────────
14
14
 
15
15
  ## Requirements
16
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
17
+ - The `appa` binary on `PATH` ([install](https://openappa.com)) — version
18
+ 0.31.x verified; see `docs/wire-notes.md` for the recorded contract
20
19
  - 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
20
+ - A policy (`appa.toml`) that declares the tools your sessions may use —
21
+ `/appa init` writes a starter one, or copy `templates/appa.toml`
22
22
 
23
23
  ## Install
24
24
 
25
25
  ```sh
26
- pi install npm:pi-openappa # published on npm; indexed by the Pi gallery
26
+ pi install npm:pi-openappa # once published
27
27
  pi install ./pi-openappa # from a checkout
28
28
  ```
29
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
30
  ## Protect sessions
46
31
 
47
- Protection is **on by default**: every Pi session is guarded unless you opt
48
- out. Opt-outs, most specific first:
32
+ Protection is opt-in, in one of three ways:
49
33
 
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:
34
+ - **Project-scoped (recommended):** create `<project>/.pi/openappa` — sessions
35
+ started in that directory are protected, sessions elsewhere are not. The
36
+ marker's optional content names that project's policy (absolute or
37
+ cwd-relative); empty content falls back to `APPA_CONFIG` or APPA's default:
59
38
 
60
39
  ```sh
61
40
  cd your-project && mkdir -p .pi && echo "appa.toml" > .pi/openappa
62
41
  ```
63
42
 
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
43
+ - **Built-in:** run `/appa on` once — every Pi session everywhere is
44
+ protected. `/appa off` disables.
45
+ - **Per launch:** `APPA_GATE=1 pi` (the `clappa`-style launcher route).
46
+
47
+ A gated session brings the runtime up on its own: `session_start` sends
48
+ `appa hook --ensure-runtime` (passing `--config` when a policy is resolved),
49
+ but **does not wait for it** — the boot runs on a serialized background lane,
50
+ so a cold runtime, a first-run install, or a slow start never sits in Pi's
51
+ session-start path. Ordering is preserved: the SessionStart payload always
52
+ lands before the first `PreToolUse`, and a runtime that cannot answer fails
53
+ that first call closed with the reason (silence never means yes). A custom
68
54
  `APPA_RUNTIME_URL` names a runtime that is *yours* to start — the hook
69
55
  refuses with exactly that reason instead of guessing.
70
56
 
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.
57
+ While gated, a runtime that cannot answer blocks the call and the reason is
58
+ returned to the model — **silence never means yes**. If no policy exists the
59
+ startup warning names the exact outs; `/appa off` always works, even with
60
+ every tool call blocked. Ungated sessions never invoke the hook.
85
61
 
86
62
  ## Configuration
87
63
 
88
- | Variable | Default | Meaning |
64
+ | Source | Default | Meaning |
89
65
  |---|---|---|
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 |
66
+ | `APPA_GATE` | unset | `1` protects this session (read once at launch) |
67
+ | `.pi/openappa` | absent | Project marker: gates sessions started in that directory; optional content = policy path |
68
+ | `~/.config/pi-openappa/settings.json` | absent | File settings: `config`, `runtimeUrl`, `hookBin`, `hookTimeoutMs`; env always wins, project markers beat `config` |
93
69
  | `APPA_RUNTIME_URL` | `http://127.0.0.1:8787` | Runtime endpoint (loopback only) |
94
70
  | `APPA_CONFIG` | unset | `appa.toml` the session auto-starts the runtime with |
95
71
  | `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
72
  | `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
73
 
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
74
+ Policy resolution order: `APPA_CONFIG`, then the project marker's content,
75
+ then `settings.json`'s `config`, then APPA's own default
76
+ (`~/.config/appa/appa.toml`).
77
+
78
+ ## Starter policy: `/appa init`
79
+
80
+ `templates/appa.toml` is a complete, self-contained policy written for Pi.
81
+ It gates **flows, not tools**: sources mark data, sinks check it.
82
+
83
+ | When a session carries… | it is refused at… |
84
+ |---|---|
85
+ | web text (`curl`/`wget`, `web_explore`, context7) | editing any existing file except docs/openspec/`*.md` (research lands in docs, it doesn't rewrite code); writing tests, devops (Makefile, justfile, scripts, CI, Dockerfile), infra (terraform, k8s, helm…), or credential-shaped paths; `git push` / `gh …`; `mem_save`/`mem_update` (poisoned memory must not persist) |
86
+ | `.env`/credentials (read or commanded) | web tools (a secret-narrowed session cannot prove a query shareable) — trusted docs domains carved out for `curl`; bash output returns masked via `redact-secrets` |
87
+ | anything | editing appa's own policy files |
88
+ | nothing (clean session) | nowhere — reads, edits, tests, commits, pushes all flow |
89
+
90
+ An undeclared tool is refused before it runs (deny-by-default; a `*`
91
+ wildcard requires an annotator, so refusing is the only annotator-free
92
+ stance — the commented `builtin = "llm"` block shows the classifier
93
+ upgrade). Selectors use **Pi argument names** (`Read(path:…)`, not Claude
94
+ Code's `file_path` — the stock battery's never match a Pi call), and the
95
+ policy is **static rules only**: nothing host-coupled, nothing that can be
96
+ unreachable. Taint lives in the trajectory label and only narrows — the
97
+ manual reset is a **new session** (resume/fork inherit it; there is no
98
+ `/untaint` by design).
99
+
100
+ Install it one of three ways:
101
+
102
+ ```sh
103
+ /appa init # inside Pi: writes ~/.config/appa/appa.toml (+ settings.json)
104
+ /appa init project # writes ./appa.toml and the .pi/openappa marker
105
+ cp templates/appa.toml ~/.config/appa/appa.toml # from a checkout
106
+ ```
107
+
108
+ `init` never clobbers: rerun with `--force` to overwrite. Subcommands
109
+ autocomplete (`on`, `off`, `init`, `init project`, `status`).
110
+
111
+ ### Policy-as-code
112
+
113
+ The policy is tested like code — no runtime needed, only the `appa` CLI:
114
+
115
+ ```sh
116
+ just policy-check # templates/appa.toml loads
117
+ just policy-coverage # every known Pi tool declared (Day E: refusals fail)
118
+ just policy-test # replay the usage-day traces (Days A–D, F)
119
+ ```
120
+
121
+ `traces/*.appa` are line-based replays (`<canonical-tool> {` / one `arg:`
122
+ JSON value per line / `}` / `expect allow|withhold|deny`); calls in one
123
+ file share a trajectory, so taint accumulates exactly as live. All three
124
+ gates run in `just check`.
125
+
126
+ `/appa` reports protection, always-on state, and runtime health; `/appa on`
127
+ and `/appa off` toggle always-on protection (marker:
128
+ `~/.config/pi-openappa/always-on`), taking effect immediately including the
103
129
  current session.
104
130
 
105
131
  ## Event mapping
@@ -130,14 +156,7 @@ policies declare them verbatim.
130
156
  - **Subagents**: spawning is mediated as a plain tool call (deny blocks the
131
157
  spawn). Child trajectories are not linked into the parent's label chain
132
158
  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
159
+ - The adapter never starts a runtime for ungated sessions; auto-start needs
141
160
  either `APPA_CONFIG` or an installed APPA deployment.
142
161
  - OpenAPPA is Preview & RFC: wire surfaces may break without shims. The
143
162
  entire wire contract lives in `src/hook-client.ts` and `src/adapter.ts`
@@ -3,12 +3,16 @@
3
3
  *
4
4
  * Thin by design: translate Pi events to `appa hook` invocations and enforce
5
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.
6
+ * decision. Protection is opt-in per session (APPA_GATE=1 at launch, a
7
+ * project marker, or always-on) and fail-closed while gated: if the runtime
8
+ * cannot answer, the call is blocked.
8
9
  *
9
10
  * Wire facts and constraints: docs/wire-notes.md
10
11
  */
11
12
 
13
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
14
+ import { dirname, join } from "node:path";
15
+ import { fileURLToPath } from "node:url";
12
16
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
13
17
  import {
14
18
  parseCallDecision,
@@ -20,13 +24,14 @@ import {
20
24
  stopPayload,
21
25
  toolResponseFrom,
22
26
  } from "../src/adapter.ts";
23
- import { invokeAppaHook, resolveHookBin, type HookOutcome } from "../src/hook-client.ts";
24
- import { installAppa, type InstallOutcome } from "../src/installer.ts";
27
+ import type { InvokeOptions } from "../src/hook-client.ts";
28
+ import { invokeAppaHook } from "../src/hook-client.ts";
25
29
  import {
26
- appaDefaultPolicyExists,
30
+ appaDefaultConfigPath,
27
31
  captureGate,
28
32
  checkHealth,
29
- setGloballyOff,
33
+ setAlwaysOn,
34
+ settingsPath,
30
35
  type GateState,
31
36
  } from "../src/gate.ts";
32
37
 
@@ -35,12 +40,18 @@ interface TextPart {
35
40
  text: string;
36
41
  }
37
42
 
43
+ /** The starter policy `/appa init` writes (templates/appa.toml in this package). */
44
+ function templateToml(): string {
45
+ return readFileSync(
46
+ fileURLToPath(new URL("../templates/appa.toml", import.meta.url)),
47
+ "utf8",
48
+ );
49
+ }
50
+
38
51
  export default function (pi: ExtensionAPI): void {
39
52
  /** Launch-fixed protection state; null until the session starts. */
40
53
  let gate: GateState | null = null;
41
54
  let sessionId = "";
42
- /** No policy anywhere and nothing answered: this session runs unprotected. */
43
- let unprotected = false;
44
55
  /**
45
56
  * toolCallId → the exact `input` object sent in the PreToolUse payload.
46
57
  * PostToolUse must echo it byte-identically or the runtime withholds the
@@ -48,101 +59,78 @@ export default function (pi: ExtensionAPI): void {
48
59
  */
49
60
  const pendingInputs = new Map<string, Record<string, unknown>>();
50
61
 
51
- const gated = (): boolean => gate?.gated === true && !unprotected;
62
+ /**
63
+ * Serialized hook lane. Every `appa hook` invocation for this session runs
64
+ * through here in order, so the SessionStart payload (with
65
+ * `--ensure-runtime`) always lands before the first PreToolUse even though
66
+ * session_start does not wait for it. Startup stays off the fast-start
67
+ * lane: the runtime boot cost is paid by whichever tool call needs the
68
+ * runtime first, and a runtime that cannot answer fails that call closed
69
+ * with the reason — never a slower session start.
70
+ */
71
+ let lane: Promise<unknown> = Promise.resolve();
72
+ const enqueue = <T>(run: () => Promise<T>): Promise<T> => {
73
+ const outcome = lane.then(run, run);
74
+ lane = outcome.then(
75
+ () => undefined,
76
+ () => undefined,
77
+ );
78
+ return outcome;
79
+ };
80
+
81
+ const gated = (): boolean => gate?.gated === true;
82
+
83
+ const hookOptions = (state: GateState, extra: InvokeOptions = {}): InvokeOptions => {
84
+ const options: InvokeOptions = { ...extra };
85
+ if (state.hookTimeoutMs !== undefined) options.timeoutMs = state.hookTimeoutMs;
86
+ return options;
87
+ };
52
88
 
53
89
  pi.on("session_start", async (event, ctx) => {
54
90
  gate = captureGate(process.env, ctx.cwd);
55
91
  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",
92
+ if (!gated()) return;
93
+ const state = gate;
94
+ const options: InvokeOptions = { ensureRuntime: true };
95
+ if (state.config !== undefined) options.config = state.config;
96
+ if (state.hookTimeoutMs !== undefined) options.timeoutMs = state.hookTimeoutMs;
97
+ // Backgrounded on purpose (see `lane`): a cold runtime boot — or a first
98
+ // run that still has to install or update the runtime — must not sit in
99
+ // Pi's session-start path. Failure surfaces as a warning when it settles.
100
+ void enqueue(async () => {
101
+ const outcome = await invokeAppaHook(
102
+ sessionStartPayload(sessionId, event.reason, ctx.cwd),
103
+ options,
77
104
  );
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.",
105
+ if (outcome.exitCode !== 0 && ctx.hasUI) {
106
+ const remedy =
107
+ options.config === undefined
108
+ ? " Provide a policy (this project's .pi/openappa, /appa init, APPA_CONFIG, or ~/.config/appa/appa.toml), or run /appa off."
109
+ : "";
110
+ ctx.ui.notify(
111
+ `OpenAPPA gated but the runtime did not answer (${state.runtimeUrl}): ` +
112
+ `${outcome.stderr.trim() || `exit ${outcome.exitCode}`}.${remedy} ` +
113
+ "Tool calls will be blocked until it answers.",
84
114
  "warning",
85
115
  );
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
116
  }
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
- }
117
+ });
134
118
  });
135
119
 
136
120
  pi.on("before_agent_start", async (event, ctx) => {
137
121
  if (!gated()) return;
138
122
  // The prompt event establishes the turn boundary; it does not gate.
139
- await invokeAppaHook(promptPayload(sessionId, event.prompt, ctx.cwd));
123
+ await enqueue(() =>
124
+ invokeAppaHook(promptPayload(sessionId, event.prompt, ctx.cwd), hookOptions(gate!)),
125
+ );
140
126
  });
141
127
 
142
128
  pi.on("tool_call", async (event, ctx) => {
143
129
  if (!gated()) return;
144
130
  const payload = preToolUsePayload(sessionId, event.toolName, event.input, ctx.cwd);
145
- const outcome = await invokeAppaHook(payload);
131
+ const outcome = await enqueue(() =>
132
+ invokeAppaHook(payload, hookOptions(gate!)),
133
+ );
146
134
  const decision = parseCallDecision(outcome.exitCode, outcome.stdout, outcome.stderr);
147
135
  if (decision.type === "deny") {
148
136
  return { block: true, reason: `appa: ${decision.reason}` };
@@ -167,7 +155,9 @@ export default function (pi: ExtensionAPI): void {
167
155
  response,
168
156
  ctx.cwd,
169
157
  );
170
- const outcome = await invokeAppaHook(payload);
158
+ const outcome = await enqueue(() =>
159
+ invokeAppaHook(payload, hookOptions(gate!)),
160
+ );
171
161
  const decision = parseResultDecision(outcome.exitCode, outcome.stdout, outcome.stderr);
172
162
  if (decision.type === "pass") {
173
163
  return undefined;
@@ -181,7 +171,9 @@ export default function (pi: ExtensionAPI): void {
181
171
  pi.on("turn_end", async () => {
182
172
  if (!gated()) return;
183
173
  // Turn completion is reported, never gated (matching --turn-end).
184
- await invokeAppaHook(stopPayload(sessionId), { turnEnd: true });
174
+ await enqueue(() =>
175
+ invokeAppaHook(stopPayload(sessionId), hookOptions(gate!, { turnEnd: true })),
176
+ );
185
177
  });
186
178
 
187
179
  pi.on("session_shutdown", () => {
@@ -189,41 +181,69 @@ export default function (pi: ExtensionAPI): void {
189
181
  });
190
182
 
191
183
  pi.registerCommand("appa", {
192
- description: "Show OpenAPPA status; `appa on|off` toggles protection globally",
184
+ description:
185
+ "OpenAPPA guard: status, `appa on|off` always-on, `appa init [project]` writes a starter policy",
186
+ getArgumentCompletions: (argumentPrefix) => {
187
+ const items = [
188
+ {
189
+ value: "on",
190
+ label: "on",
191
+ description: "Protect every future Pi session (always-on marker)",
192
+ },
193
+ { value: "off", label: "off", description: "Disable always-on protection" },
194
+ {
195
+ value: "init",
196
+ label: "init",
197
+ description: "Write the starter policy to ~/.config/appa/appa.toml",
198
+ },
199
+ {
200
+ value: "init project",
201
+ label: "init project",
202
+ description: "Write ./appa.toml plus the .pi/openappa project marker",
203
+ },
204
+ { value: "status", label: "status", description: "Show protection and runtime health" },
205
+ ];
206
+ const prefix = argumentPrefix.trim();
207
+ const hits = prefix === "" ? items : items.filter((item) => item.value.startsWith(prefix));
208
+ return hits.length > 0 ? hits : null;
209
+ },
193
210
  handler: async (args, ctx) => {
194
- const arg = args.trim();
211
+ const tokens = args.trim().split(/\s+/).filter((token) => token !== "");
212
+ const arg = tokens[0] ?? "";
213
+
195
214
  if (arg === "on" || arg === "off") {
196
- setGloballyOff(process.env, arg === "off");
215
+ setAlwaysOn(process.env, arg === "on");
197
216
  gate = captureGate(process.env, ctx.cwd);
198
217
  if (ctx.hasUI) {
199
218
  ctx.ui.notify(
200
219
  arg === "on"
201
- ? "OpenAPPA protection re-enabled: on by default for every session."
202
- : "OpenAPPA protection disabled globally (/appa on re-enables).",
220
+ ? "OpenAPPA always-on enabled: every future Pi session is protected."
221
+ : "OpenAPPA always-on disabled.",
203
222
  "info",
204
223
  );
205
224
  }
206
225
  return;
207
226
  }
227
+
228
+ if (arg === "init") {
229
+ const scope = tokens[1] === "project" ? "project" : "user";
230
+ const force = tokens.includes("--force");
231
+ await runInit(scope, force, ctx);
232
+ return;
233
+ }
234
+
208
235
  const state = gate ?? captureGate(process.env, ctx.cwd);
209
236
  const mode =
210
237
  state.source === "project"
211
238
  ? "project (.pi/openappa)"
212
- : state.source === "env-on"
239
+ : state.source === "env"
213
240
  ? "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)";
241
+ : state.source === "always-on"
242
+ ? "always-on (/appa off to disable)"
243
+ : "off — /appa on enables it for every session";
221
244
  const lines: string[] = [];
222
245
  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}`);
246
+ if (state.gated) lines.push(`Mode: ${mode}`);
227
247
  if (state.config !== undefined) lines.push(`Policy: ${state.config}`);
228
248
  lines.push(`Runtime: ${state.runtimeUrl}`);
229
249
  const health = await checkHealth(state.runtimeUrl);
@@ -237,13 +257,59 @@ export default function (pi: ExtensionAPI): void {
237
257
  }
238
258
  },
239
259
  });
240
- }
241
260
 
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;
261
+ /** `/appa init` — write the starter policy (and settings) without clobbering. */
262
+ async function runInit(
263
+ scope: "user" | "project",
264
+ force: boolean,
265
+ ctx: { cwd: string; hasUI: boolean; ui: { notify: (message: string, type?: "info" | "warning" | "error") => void } },
266
+ ): Promise<void> {
267
+ const notify = (message: string, type: "info" | "warning" | "error" = "info") => {
268
+ if (ctx.hasUI) ctx.ui.notify(message, type);
269
+ };
270
+ try {
271
+ if (scope === "project") {
272
+ const policy = join(ctx.cwd, "appa.toml");
273
+ const marker = join(ctx.cwd, ".pi", "openappa");
274
+ if (existsSync(policy) && !force) {
275
+ notify(`${policy} already exists; rerun with --force to overwrite it.`, "warning");
276
+ return;
277
+ }
278
+ mkdirSync(dirname(policy), { recursive: true });
279
+ writeFileSync(policy, templateToml());
280
+ if (!existsSync(marker)) {
281
+ mkdirSync(dirname(marker), { recursive: true });
282
+ writeFileSync(marker, "appa.toml\n");
283
+ }
284
+ notify(
285
+ `Wrote ${policy} and the .pi/openappa marker. Sessions started in this directory are protected; new sessions pick the policy up (trajectories keep the policy they opened with).`,
286
+ );
287
+ return;
288
+ }
289
+ const policy = appaDefaultConfigPath(process.env);
290
+ if (existsSync(policy) && !force) {
291
+ notify(
292
+ `${policy} already exists; rerun with --force to overwrite it, or /appa init project for one project.`,
293
+ "warning",
294
+ );
295
+ return;
296
+ }
297
+ mkdirSync(dirname(policy), { recursive: true });
298
+ writeFileSync(policy, templateToml());
299
+ const settings = settingsPath(process.env);
300
+ const lines: string[] = [`Wrote the starter policy to ${policy}.`];
301
+ if (!existsSync(settings)) {
302
+ mkdirSync(dirname(settings), { recursive: true });
303
+ writeFileSync(settings, `${JSON.stringify({ config: policy }, null, 2)}\n`);
304
+ lines.push(`Wrote ${settings} pointing at it.`);
305
+ }
306
+ lines.push("Protect sessions with /appa on, APPA_GATE=1, or /appa init project.");
307
+ notify(lines.join(" "));
308
+ } catch (error) {
309
+ const detail = error instanceof Error ? error.message : String(error);
310
+ notify(`/appa init failed: ${detail}`, "error");
311
+ }
312
+ }
247
313
  }
248
314
 
249
315
  function joinContent(content: ReadonlyArray<unknown>): string {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-openappa",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Thin OpenAPPA guard extension for Pi: gates tool calls through the APPA runtime. No policy logic lives here.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -19,6 +19,13 @@
19
19
  "homepage": "https://github.com/aemonge-dev/pi-openappa#readme",
20
20
  "bugs": "https://github.com/aemonge-dev/pi-openappa/issues",
21
21
  "type": "module",
22
+ "files": [
23
+ "extensions/",
24
+ "src/",
25
+ "templates/",
26
+ "traces/",
27
+ "docs/"
28
+ ],
22
29
  "pi": {
23
30
  "extensions": ["./extensions/index.ts"]
24
31
  },