@openlines/opl-pi-sht 0.2.1 → 0.2.3

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,6 +14,14 @@
14
14
 
15
15
  A portable collection of various Pi coding agent extensions. Repository directories and config files use `opl-`; established Pi-facing commands and tool names stay compatible.
16
16
 
17
+ <div align="center">
18
+
19
+ >
20
+ > ## v0.2.3 (22 Sept 2026) - updated `opl-modes` extension compatibility with latest pi release [now requires pi >= 0.87.0]<br/>see [CHANGELOG.md](CHANGELOG.md) for more details.
21
+ >
22
+
23
+ </div>
24
+
17
25
  ## Installation
18
26
 
19
27
  ### Pi package
@@ -76,6 +84,7 @@ Copy mode overwrites matching destinations. Link mode skips existing destination
76
84
  | [`opl-webaccess`](extensions/opl-webaccess/README.md) | Search plus readable URL/PDF retrieval with session recovery. | `web_search`, `fetch_content`, `get_search_content`; `opl-webaccess.json`. |
77
85
  | [`opl-browser`](extensions/opl-browser/README.md) | Chromium automation via Playwright with structured extraction of rendered pages; single dispatcher tool replacing the chrome-devtools MCP. | `browser` (action-based); `opl-browser.json`. |
78
86
  | [`opl-ctxtrim`](extensions/opl-ctxtrim/README.md) | Trims verbose`ctx_*` tool-schema descriptions on outbound provider requests (~67% smaller schema, ~4,700-6,300 tokens/request). Built specifically for the [context-mode](https://github.com/mksglu/context-mode) extension. | No commands/tools; no config. |
87
+ | [`opl-guardian`](extensions/opl-guardian/README.md) | Drops malformed assistant tool calls before session persistence or replay, with project-local JSONL evidence. | No commands/tools; no config. |
79
88
  | [`opl-todo`](extensions/opl-todo/README.md) | Branch-aware task tool, overlay, and task list. | `todo`, `/todos`; `opl-todo.json`. |
80
89
  | [`opl-questionnaire`](extensions/opl-questionnaire/README.md) | Interactive structured-choice tool. | `questionnaire`; no config. |
81
90
  | [`opl-input`](extensions/opl-input/README.md) | Configurable replacement editor - enhanced [pikit chat-input](https://github.com/adrianapan/pikit) (because pet is life, and configurable). ![pet](images/ss-input-pet.png) | No commands/tools;`opl-input.json`. |
@@ -104,9 +113,10 @@ Cold prompt-cache write, measured /init session (opl-modes lazy tools + MCP adap
104
113
 
105
114
  ### Run the agent without babysitting it
106
115
 
107
- - **`opl-modes`** chat and plan modes swap the active toolset for read-only lists and gate Bash to safe inspection patterns **per shell segment**, so `cat f && node -e '...'`, `cat x & rm -rf /tmp/x`, and `echo "$(node -e ...)"` can no longer ride the first command's allowance. Destructive checks still fire inside otherwise-safe commands (anchored to command position, so `du -sh` and `find . -name '*.sh'` stay allowed; `find -delete`, `find -fprint`, `git log --output`, `sort -o`, `npm audit fix`, `git clean`, `sudo`, and quote-obfuscated `r"m"`/`-del"ete"` are blocked; `env`/`printenv` are not safe-listed). The plan to execute lifecycle keeps exploration and mutation cleanly separated. Add custom modes (like below) for your workflow needs.
116
+ - **`opl-modes`** chat and plan modes swap the active toolset for read-only lists and gate Bash to safe inspection patterns **per shell segment**, so `cat f && node -e '...'`, `cat x & rm -rf /tmp/x`, and `echo "$(node -e ...)"` can no longer ride the first command's allowance. Destructive checks still fire inside otherwise-safe commands (anchored to command position, so `du -sh` and `find . -name '*.sh'` stay allowed; `find -delete`, `find -fprint`, `git log --output`, `sort -o`, `npm audit fix`, `git clean`, `sudo`, and quote-obfuscated `r"m"`/`-del"ete"` are blocked; `env`/`printenv` are not safe-listed). The plan to execute lifecycle keeps exploration and mutation cleanly separated. Add custom modes (like below) for your workflow needs. Execute-mode auto-exit needs pi >= 0.87.0 (the `agent_before_settle` boundary).
108
117
  ![custom mode sample](images/ss-mode-custom.png)
109
118
  - `load_tools` activation is bounded by the current mode, so a read-only mode cannot be tricked into enabling a write-capable tool.
119
+ - **`opl-guardian`** stops a malformed provider tool-call record from poisoning the session: it removes calls missing an ID or name before Pi persists or replays them, while preserving dropped records in `err/guardian.jsonl` for an upstream report.
110
120
 
111
121
 
112
122
  ### Move through work faster
@@ -137,7 +147,7 @@ Cold prompt-cache write, measured /init session (opl-modes lazy tools + MCP adap
137
147
  | If you want to... | Install |
138
148
  | ---------------------------------- | --------------------------------------------------------------- |
139
149
  | Cut token cost with minimal change | `opl-ctxtrim`, `opl-modes` |
140
- | Run the agent safely on real repos | `opl-modes` (pulls in the `opl-input` + `opl-footer` UI bundle) |
150
+ | Run the agent safely on real repos | `opl-modes` (pulls in the `opl-input` + `opl-footer` UI bundle), `opl-guardian` |
141
151
  | Research and drive the web | `opl-webaccess`, `opl-browser` |
142
152
  | Choose models with data | `opl-simplebench` |
143
153
  | The full, coordinated experience | all ten |
@@ -161,6 +171,7 @@ Installing extensions adds tool schemas (name + description + JSON parameters) t
161
171
  | `opl-init` | command only (no tool) | ~0 |
162
172
  | `opl-input` | UI only | ~0 |
163
173
  | `opl-footer` | UI only | ~0 |
174
+ | `opl-guardian` | none (session-integrity guard) | ~0 |
164
175
  | `opl-ctxtrim` | none (payload transformer) | net negative |
165
176
 
166
177
  Command descriptions add roughly another ~120 tokens collectively, and only if your build surfaces them in the prompt or help block.
@@ -218,4 +229,4 @@ A Pi package (npm or Git) still needs the one-time `npx playwright install chrom
218
229
  npm test
219
230
  ```
220
231
 
221
- Run one extension suite with `npm run test:opl-<name>` for `browser`, `footer`, `init`, `input`, `modes`, `questionnaire`, `todo`, `webaccess`, `simplebench`, or `ctxtrim`. Every helper, functional, and selected-entrypoint smoke check uses Bun's named-test reporter; output includes per-test status, timings, and pass/fail totals. Functional tests cover deterministic helpers where practical; smoke tests bundle entrypoints and parse config. They do not test live TUI behavior, provider credentials, network access, or PDF extraction.
232
+ Run one extension suite with `npm run test:opl-<name>` for `browser`, `footer`, `guardian`, `init`, `input`, `modes`, `questionnaire`, `todo`, `webaccess`, `simplebench`, or `ctxtrim`. Every helper, functional, and selected-entrypoint smoke check uses Bun's named-test reporter; output includes per-test status, timings, and pass/fail totals. Functional tests cover deterministic helpers where practical; smoke tests bundle entrypoints and parse config. They do not test live TUI behavior, provider credentials, network access, or PDF extraction.
@@ -0,0 +1,52 @@
1
+ # opl-guardian
2
+
3
+ Prevents malformed assistant tool calls from entering Pi session history. It runs on every provider and model through Pi's `message_end` hook, removes protocol-invalid calls before Pi persists or replays them, and records each removal as JSON Lines evidence.
4
+
5
+ >
6
+ > This is the first feature that opl-guardian can do for now. If I happen to encounter another error/issue that can be (possibly) fixed via extensions, I might add that up in this extension.
7
+ >
8
+
9
+ ## Commands, tools, and configuration
10
+
11
+ No commands, tools, or configuration. Loading the extension is the entire interface and adds no prompt or tool-schema overhead.
12
+
13
+ ## What it guards
14
+
15
+ A tool call is malformed when its `id` or `name` is blank after trimming whitespace. Such a record cannot be safely replayed through OpenAI-compatible tool-call APIs: a later provider request may reject it and block the session until its history is abandoned.
16
+
17
+ `opl-guardian` does not reject an unfamiliar but non-empty tool name. That is a normal model/tool-resolution error, not an invalid protocol envelope.
18
+
19
+ | Assistant response | Guardian behavior |
20
+ | --- | --- |
21
+ | No malformed calls | Leaves the response unchanged. |
22
+ | Valid and malformed calls | Drops only malformed calls, preserves valid calls and `toolUse`, appends a diagnostic, and lets Pi execute the valid calls normally. |
23
+ | Only malformed calls | Removes every call, returns a text-only `stop` response, and tells the user to send another prompt. |
24
+
25
+ When a UI is available, every incident also produces a warning notification.
26
+
27
+ ## Forensic JSONL
28
+
29
+ Each incident appends one JSON object to:
30
+
31
+ ```text
32
+ <project cwd>/err/guardian.jsonl
33
+ ```
34
+
35
+ A record includes timestamp, session ID, project CWD, provider, model, response ID when available, and the removed `toolCall` blocks. It does not log prompts, assistant text, thinking, or tool results.
36
+
37
+ Tool-call arguments can still contain project paths, commands, or user text. Add `err/` to the project’s version-control ignore policy if it is not already ignored, and treat the file as local diagnostic data.
38
+
39
+ If the log write fails, guardian still removes the malformed calls. The session diagnostic and UI warning report that the forensic file could not be written rather than claiming it was saved.
40
+
41
+ ## Architecture
42
+
43
+ ```text
44
+ guardian.ts Pure malformed-call filter and JSONL incident-record builder.
45
+ index.ts Pi message_end handler, project-local JSONL append, UI notification.
46
+ ```
47
+
48
+ The extension has no runtime dependencies. Run its checks with:
49
+
50
+ ```bash
51
+ npm run test:opl-guardian
52
+ ```
@@ -0,0 +1,70 @@
1
+ import type { AssistantMessage, ToolCall } from "@earendil-works/pi-ai";
2
+
3
+ export interface IncidentContext {
4
+ sessionId: string;
5
+ cwd: string;
6
+ }
7
+
8
+ export interface GuardianIncident {
9
+ timestamp: string;
10
+ kind: "malformed_tool_call";
11
+ sessionId: string;
12
+ cwd: string;
13
+ provider: string;
14
+ model: string;
15
+ responseId?: string;
16
+ action: "dropped";
17
+ removedToolCalls: ToolCall[];
18
+ }
19
+
20
+ export interface GuardResult {
21
+ message: AssistantMessage;
22
+ removedToolCalls: ToolCall[];
23
+ }
24
+
25
+ function hasValue(value: unknown): value is string {
26
+ return typeof value === "string" && value.trim().length > 0;
27
+ }
28
+
29
+ export function isMalformedToolCall(block: unknown): block is ToolCall {
30
+ if (!block || typeof block !== "object" || (block as { type?: unknown }).type !== "toolCall") {
31
+ return false;
32
+ }
33
+ const toolCall = block as ToolCall;
34
+ return !hasValue(toolCall.id) || !hasValue(toolCall.name);
35
+ }
36
+
37
+ export function guardAssistantMessage(message: AssistantMessage): GuardResult | undefined {
38
+ const removedToolCalls = message.content.filter(isMalformedToolCall);
39
+ if (removedToolCalls.length === 0) return undefined;
40
+
41
+ const content = message.content.filter((block) => !isMalformedToolCall(block));
42
+ const hasValidToolCall = content.some((block) => block.type === "toolCall");
43
+
44
+ return {
45
+ removedToolCalls,
46
+ message: {
47
+ ...message,
48
+ content,
49
+ stopReason: hasValidToolCall ? "toolUse" : "stop",
50
+ },
51
+ };
52
+ }
53
+
54
+ export function buildIncidentRecord(
55
+ message: AssistantMessage,
56
+ context: IncidentContext,
57
+ removedToolCalls: ToolCall[],
58
+ ): GuardianIncident {
59
+ return {
60
+ timestamp: new Date(message.timestamp).toISOString(),
61
+ kind: "malformed_tool_call",
62
+ sessionId: context.sessionId,
63
+ cwd: context.cwd,
64
+ provider: message.provider,
65
+ model: message.model,
66
+ ...(message.responseId ? { responseId: message.responseId } : {}),
67
+ action: "dropped",
68
+ removedToolCalls,
69
+ };
70
+ }
@@ -0,0 +1,81 @@
1
+ import type { AssistantMessage } from "@earendil-works/pi-ai";
2
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
+ import { appendFile, chmod, mkdir, stat } from "node:fs/promises";
4
+ import { join } from "node:path";
5
+
6
+ import {
7
+ buildIncidentRecord,
8
+ guardAssistantMessage,
9
+ type GuardianIncident,
10
+ } from "./guardian.js";
11
+
12
+ const LOG_PATH = join("err", "guardian.jsonl");
13
+
14
+ export async function appendIncident(cwd: string, incident: GuardianIncident): Promise<void> {
15
+ const directory = join(cwd, "err");
16
+ const path = join(cwd, LOG_PATH);
17
+ await mkdir(directory, { recursive: true });
18
+
19
+ let existed = true;
20
+ try {
21
+ await stat(path);
22
+ } catch {
23
+ existed = false;
24
+ }
25
+
26
+ await appendFile(path, `${JSON.stringify(incident)}\n`, "utf8");
27
+ if (!existed && process.platform !== "win32") {
28
+ await chmod(path, 0o600).catch(() => {});
29
+ }
30
+ }
31
+
32
+ function diagnostic(removed: number, logError?: unknown): string {
33
+ if (logError) {
34
+ const detail = logError instanceof Error ? logError.message : String(logError);
35
+ return `[opl-guardian] Dropped malformed provider tool call(s), but could not write err/guardian.jsonl: ${detail}.`;
36
+ }
37
+ return `[opl-guardian] Dropped ${removed} malformed provider tool call(s). Details: err/guardian.jsonl.`;
38
+ }
39
+
40
+ function appendDiagnostic(message: AssistantMessage, text: string, invalidOnly: boolean): AssistantMessage {
41
+ const content = invalidOnly
42
+ ? [{ type: "text" as const, text: `${text} No tool was executed; send another prompt to continue.` }]
43
+ : [...message.content, { type: "text" as const, text }];
44
+ return { ...message, content, stopReason: invalidOnly ? "stop" : "toolUse" };
45
+ }
46
+
47
+ export async function guardMessageEnd(
48
+ message: AssistantMessage,
49
+ context: { cwd: string; sessionId: string },
50
+ ): Promise<{ message: AssistantMessage; diagnostic: string } | undefined> {
51
+ const guarded = guardAssistantMessage(message);
52
+ if (!guarded) return undefined;
53
+
54
+ let logError: unknown;
55
+ try {
56
+ await appendIncident(context.cwd, buildIncidentRecord(message, context, guarded.removedToolCalls));
57
+ } catch (error) {
58
+ logError = error;
59
+ }
60
+
61
+ const invalidOnly = !guarded.message.content.some((block) => block.type === "toolCall");
62
+ const notice = diagnostic(guarded.removedToolCalls.length, logError);
63
+ return {
64
+ message: appendDiagnostic(guarded.message, notice, invalidOnly),
65
+ diagnostic: notice,
66
+ };
67
+ }
68
+
69
+ export default function (pi: ExtensionAPI) {
70
+ pi.on("message_end", async (event, ctx) => {
71
+ if (event.message.role !== "assistant") return;
72
+ const guarded = await guardMessageEnd(event.message, {
73
+ cwd: ctx.cwd,
74
+ sessionId: ctx.sessionManager.getSessionId(),
75
+ });
76
+ if (!guarded) return;
77
+
78
+ ctx.ui?.notify(guarded.diagnostic, "warning");
79
+ return { message: guarded.message };
80
+ });
81
+ }
@@ -11,7 +11,7 @@ Unified mode manager for Pi. It provides normal, read-only chat, read-only plann
11
11
  - `--chat` and `--plan` start Pi in the corresponding read-only mode.
12
12
  - The configured cycle shortcut rotates through enabled visible modes. Execute mode is excluded from cycling because it requires an active plan. The default `shift+tab` is also Pi's built-in `app.thinking.cycle`, so both fire on one press; rebind either action in `~/.pi/agent/keybindings.json` (or pick another `shortcuts.cycleMode`) if you want them separate.
13
13
 
14
- Plans are Markdown files under `.pi/plans/` with the `plan-` filename prefix. A plan name must contain at least one letter or digit, so `/plan .` or `/plan --` is rejected instead of producing an untitled `plan-.md`. `plan_complete` is available in execute mode and in any mode that sets `allowPlanComplete`. On completion, the plan file is deleted when `cleanup.cleanupOnComplete` is enabled. If execution ends without `plan_complete`, execute mode is exited automatically — but only when a turn actually finished: an aborted (ESC) or errored turn keeps execute mode active so the plan can be resumed.
14
+ Plans are Markdown files under `.pi/plans/` with the `plan-` filename prefix. A plan name must contain at least one letter or digit, so `/plan .` or `/plan --` is rejected instead of producing an untitled `plan-.md`. `plan_complete` is available in execute mode and in any mode that sets `allowPlanComplete`. On completion, the plan file is deleted when `cleanup.cleanupOnComplete` is enabled. If execution ends without `plan_complete`, execute mode is exited automatically at Pi's `agent_before_settle` boundary (added in Pi 0.87.0) — but only when the run settled as `completed`: an aborted (ESC) or errored run keeps execute mode active so the plan can be resumed. On Pi < 0.87.0 this auto-exit is unavailable because the boundary is never emitted; `plan_complete` still exits the mode on any version.
15
15
 
16
16
  ## Extension features
17
17
 
@@ -23,6 +23,7 @@ import type {
23
23
  ToolCallEventResult,
24
24
  ToolResultEvent,
25
25
  AgentEndEvent,
26
+ AgentBeforeSettleEvent,
26
27
  } from "@earendil-works/pi-coding-agent";
27
28
  import { DynamicBorder } from "@earendil-works/pi-coding-agent";
28
29
  import type { Model } from "@earendil-works/pi-ai";
@@ -668,26 +669,23 @@ export default function modeSwitcher(pi: ExtensionAPI) {
668
669
  enterOffMode(ctx, "Plan implemented. Plan mode OFF.");
669
670
  });
670
671
 
671
- // ─── Event: agent_end (extract plan text in PLAN mode; auto-exit execute) ───
672
-
673
- pi.on("agent_end", async (event: AgentEndEvent, ctx: ExtensionContext) => {
674
- // If plan_complete was never called, exit execute mode automatically.
675
- // (If it was called, mode is already "off" here — this check is a no-op.)
676
- if (getMode() === "execute") {
677
- // An aborted turn (ESC) is a pause, not a finished execution: stay in execute mode
678
- // so the plan can be resumed instead of silently dropping out mid-plan. Pi emits an
679
- // assistant message with stopReason "aborted" even when the abort lands before the
680
- // first token, so there is always a message to read here.
681
- const lastAssistant = [...event.messages].reverse().find((m) => m.role === "assistant") as
682
- | { stopReason?: string }
683
- | undefined;
684
- // Only a turn that actually finished counts as done: "aborted"/"error" (and a missing
685
- // assistant message) are pauses, so execute mode survives ESC and provider failures.
686
- const reason = lastAssistant?.stopReason;
687
- if (reason && reason !== "aborted" && reason !== "error") enterOffMode(ctx, "Execution complete. Mode OFF.");
688
- return;
672
+ // ─── Event: agent_before_settle (auto-exit execute when the run is truly done) ───
673
+
674
+ // Pi 0.87 boundary: fires after any automatic retry, compaction-and-retry, or queued
675
+ // follow-up work, so execute mode is not dropped mid-plan at an intermediate agent_end.
676
+ pi.on("agent_before_settle", async (event: AgentBeforeSettleEvent, ctx: ExtensionContext) => {
677
+ // If plan_complete was never called, exit execute mode automatically once Pi has fully
678
+ // settled. (If it was called, mode is already "off" here — this check is a no-op.)
679
+ // "aborted" (ESC) and "error" (provider failure) are pauses, not finished executions;
680
+ // only a completed run exits execute mode.
681
+ if (getMode() === "execute" && event.outcome === "completed") {
682
+ enterOffMode(ctx, "Execution complete. Mode OFF.");
689
683
  }
684
+ });
685
+
686
+ // ─── Event: agent_end (extract plan text in PLAN mode) ───
690
687
 
688
+ pi.on("agent_end", async (event: AgentEndEvent, ctx: ExtensionContext) => {
691
689
  if (getMode() !== "plan") return;
692
690
  if (!ctx.hasUI) return;
693
691
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openlines/opl-pi-sht",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "description": "opl-pi-sht (shouldHaveThat) Pi coding-agent extension kit: token-efficient context, safe modes, fingerprinted /init, model benchmarking, web access, and browser automation.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -40,9 +40,10 @@
40
40
  },
41
41
  "type": "module",
42
42
  "scripts": {
43
- "test": "npm run test:opl-browser && npm run test:opl-footer && npm run test:opl-init && npm run test:opl-input && npm run test:opl-modes && npm run test:opl-questionnaire && npm run test:opl-todo && npm run test:opl-webaccess && npm run test:opl-simplebench && npm run test:opl-ctxtrim",
43
+ "test": "npm run test:opl-browser && npm run test:opl-footer && npm run test:opl-guardian && npm run test:opl-init && npm run test:opl-input && npm run test:opl-modes && npm run test:opl-questionnaire && npm run test:opl-todo && npm run test:opl-webaccess && npm run test:opl-simplebench && npm run test:opl-ctxtrim",
44
44
  "test:opl-browser": "bun test tests/opl-browser-helpers.test.mjs && OPL_EXTENSION=opl-browser bun test tests/extension-smoke.test.mjs",
45
45
  "test:opl-footer": "bun test tests/opl-footer-helpers.test.mjs && OPL_EXTENSION=opl-footer bun test tests/extension-smoke.test.mjs",
46
+ "test:opl-guardian": "bun test tests/opl-guardian.test.mjs && OPL_EXTENSION=opl-guardian bun test tests/extension-smoke.test.mjs",
46
47
  "test:opl-init": "bun test tests/opl-init-crawl.test.mjs tests/opl-init-fingerprint.test.mjs tests/opl-init-packet.test.mjs && OPL_EXTENSION=opl-init bun test tests/extension-smoke.test.mjs",
47
48
  "test:opl-input": "bun test tests/opl-input-style.test.mjs && OPL_EXTENSION=opl-input bun test tests/extension-smoke.test.mjs",
48
49
  "test:opl-modes": "bun test tests/opl-modes-helpers.test.mjs tests/opl-modes-lifecycle.test.mjs && OPL_EXTENSION=opl-modes bun test tests/extension-smoke.test.mjs",