@sassywares/pi-extensions 0.3.22 → 0.3.23

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
@@ -154,6 +154,7 @@ A backend is only trusted after it proves both halves of the contract: reads ins
154
154
  `/modes` shows the verdict. The mode badge sits on a footer line of its own, aligned to the right edge: `▶ work`, `⏸ ask · ro(sandbox-exec)`, or `⏸ ask · bash off`. To place it there without reimplementing pi's footer, the extension delegates to pi's own `FooterComponent` and appends the badge line, so directory, session name, token stats, model, and context usage stay exactly as pi renders them. If a future pi release changes what that component reads, the extension restores the native footer and falls back to a left-aligned status line.
155
155
 
156
156
  - `--ask` starts a session in ask mode; the mode persists across resume.
157
+ - Switching mid-session applies immediately: the toolset is swapped and bash starts or stops being sandboxed. When ask mode is switched off, the model is told once that work mode is active, so it stops repeating an earlier claim that it was in ask mode. Only the newest mode notice is kept in context, and the ask prompt is refreshed on every turn, so a long session does not accumulate stale instructions.
157
158
  - `shift+tab` is not used, because pi binds it to the thinking level. To match an editor that uses it for modes, bind `app.thinking.cycle` to something else in `~/.pi/agent/keybindings.json` and add a shortcut there.
158
159
  - Ask mode is a guardrail for local changes, not a jail. Network access stays open, so a command can still change remote state if it holds a credential. Use pi's containerized setup when the boundary has to hold against a hostile model.
159
160
  - **Windows is unsupported by design.** Ask mode needs an OS-level read-only sandbox that Windows does not offer cheaply, so the extension registers nothing there. `/ask` and `/work` do not exist, and every tool behaves as before.
@@ -14,7 +14,6 @@
14
14
  * Windows is intentionally unsupported: no modes are registered there.
15
15
  */
16
16
 
17
- import type { AgentMessage } from "@earendil-works/pi-agent-core";
18
17
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
19
18
  import { createWrappedOperations, detectBackend, type SandboxBackend } from "./backends.ts";
20
19
  import { createModesFooter, type ModeBadge } from "./footer.ts";
@@ -24,8 +23,8 @@ type Mode = "work" | "ask";
24
23
  /** Tools that may run in ask mode. Everything else is hidden and blocked. */
25
24
  const ASK_TOOLS = ["read", "grep", "find", "ls", "bash", "web_search", "web_fetch", "questionnaire"];
26
25
 
27
- const CONTEXT_CUSTOM_TYPE = "modes-ask-context";
28
- const CONTEXT_MARKER = "[ASK MODE ACTIVE]";
26
+ const ASK_CONTEXT_TYPE = "modes-ask-context";
27
+ const WORK_CONTEXT_TYPE = "modes-work-context";
29
28
  const STATE_CUSTOM_TYPE = "modes";
30
29
  const SHORTCUT = "alt+m";
31
30
 
@@ -33,15 +32,25 @@ interface PersistedState {
33
32
  mode?: Mode;
34
33
  }
35
34
 
36
- const ASK_PROMPT = `${CONTEXT_MARKER}
35
+ const ASK_PROMPT = `[ASK MODE ACTIVE]
37
36
  You are in ask mode: reading, searching, and answering only.
38
37
 
39
38
  Rules:
40
39
  - Do not modify files. The edit and write tools are not available.
41
40
  - bash runs inside a read-only sandbox. Reading, searching, git history, and network access work; anything that writes fails with a permission error. Do not retry a failed write - report the limitation instead.
42
41
  - Answer from evidence: cite file paths with line numbers, and URLs for web results.
42
+ - This instruction overrides earlier turns. If the conversation shows file edits, write tools, or unrestricted bash, that happened before ask mode was enabled and says nothing about the current turn.
43
43
  - If a change is needed, describe it precisely and tell the user to switch to work mode (/work or alt+m).`;
44
44
 
45
+ /**
46
+ * Sent once on the first turn after ask mode is switched off. Without it the
47
+ * model keeps answering from its own earlier "I am in ask mode" reply, because
48
+ * the ask instructions are gone and nothing replaces them.
49
+ */
50
+ const WORK_PROMPT = `[WORK MODE ACTIVE]
51
+ Ask mode was switched off for this session. You can edit files, write new files, and run any bash command again.
52
+ Ignore any earlier statement that you were in ask mode: it no longer applies.`;
53
+
45
54
  export default function modesExtension(pi: ExtensionAPI): void {
46
55
  // Ask mode depends on an OS-level sandbox that Windows does not provide.
47
56
  // Rather than ship a weaker guardrail, the extension does nothing there.
@@ -52,6 +61,7 @@ export default function modesExtension(pi: ExtensionAPI): void {
52
61
  let backendReason = "not detected yet";
53
62
  let backendChecked = false;
54
63
  let workTools: string[] | undefined;
64
+ let announceWork = false;
55
65
  let footerStatus: "none" | "custom" | "fallback" = "none";
56
66
  let footerTui: { requestRender(): void } | undefined;
57
67
  const wrappedCalls = new Set<string>();
@@ -156,6 +166,7 @@ export default function modesExtension(pi: ExtensionAPI): void {
156
166
  if (next === "ask") {
157
167
  await ensureBackend(ctx.cwd);
158
168
  mode = "ask";
169
+ announceWork = false;
159
170
  workTools = pi.getActiveTools();
160
171
  pi.setActiveTools(askToolset());
161
172
  updateStatus(ctx);
@@ -169,6 +180,7 @@ export default function modesExtension(pi: ExtensionAPI): void {
169
180
  }
170
181
 
171
182
  mode = "work";
183
+ announceWork = true;
172
184
  pi.setActiveTools(workTools ?? pi.getActiveTools());
173
185
  workTools = undefined;
174
186
  updateStatus(ctx);
@@ -250,21 +262,48 @@ export default function modesExtension(pi: ExtensionAPI): void {
250
262
  });
251
263
 
252
264
  pi.on("before_agent_start", async (event) => {
253
- if (mode !== "ask") return undefined;
254
265
  if (event.messageType !== "user") return undefined;
266
+
267
+ if (mode === "ask") {
268
+ return {
269
+ message: {
270
+ customType: ASK_CONTEXT_TYPE,
271
+ content: ASK_PROMPT,
272
+ display: false,
273
+ },
274
+ };
275
+ }
276
+
277
+ // Announce the switch to work once, so the model stops repeating its own
278
+ // earlier claim that it was in ask mode.
279
+ if (!announceWork) return undefined;
280
+ announceWork = false;
255
281
  return {
256
282
  message: {
257
- customType: CONTEXT_CUSTOM_TYPE,
258
- content: ASK_PROMPT,
283
+ customType: WORK_CONTEXT_TYPE,
284
+ content: WORK_PROMPT,
259
285
  display: false,
260
286
  },
261
287
  };
262
288
  });
263
289
 
264
- // Drop ask-mode context once the session leaves ask mode.
290
+ // Keep only the newest mode notice, and only if it matches the current mode.
291
+ // This drops stale ask instructions after a switch and stops the per-turn ask
292
+ // prompt from accumulating for the length of a session.
265
293
  pi.on("context", async (event) => {
266
- if (mode === "ask") return undefined;
267
- return { messages: event.messages.filter((message) => !isAskContext(message)) };
294
+ const wanted = mode === "ask" ? ASK_CONTEXT_TYPE : WORK_CONTEXT_TYPE;
295
+ let keep = -1;
296
+ event.messages.forEach((message, index) => {
297
+ if ((message as { customType?: string }).customType === wanted) keep = index;
298
+ });
299
+
300
+ return {
301
+ messages: event.messages.filter((message, index) => {
302
+ const customType = (message as { customType?: string }).customType;
303
+ if (customType !== ASK_CONTEXT_TYPE && customType !== WORK_CONTEXT_TYPE) return true;
304
+ return index === keep;
305
+ }),
306
+ };
268
307
  });
269
308
 
270
309
  pi.on("session_start", async (_event, ctx) => {
@@ -278,6 +317,7 @@ export default function modesExtension(pi: ExtensionAPI): void {
278
317
 
279
318
  mode = state?.data?.mode ?? "work";
280
319
  if (pi.getFlag("ask") === true) mode = "ask";
320
+ announceWork = false;
281
321
 
282
322
  workTools = undefined;
283
323
  if (mode === "ask") {
@@ -289,21 +329,3 @@ export default function modesExtension(pi: ExtensionAPI): void {
289
329
  updateStatus(ctx);
290
330
  });
291
331
  }
292
-
293
- /** True for the hidden ask-mode prompt and for user messages that carry it. */
294
- function isAskContext(message: AgentMessage): boolean {
295
- const candidate = message as AgentMessage & { customType?: string };
296
- if (candidate.customType === CONTEXT_CUSTOM_TYPE) return true;
297
- if (candidate.role === "assistant") return false;
298
- const content = (candidate as { content?: unknown }).content;
299
- if (typeof content === "string") return content.includes(CONTEXT_MARKER);
300
- if (!Array.isArray(content)) return false;
301
- return content.some(
302
- (part) =>
303
- !!part &&
304
- typeof part === "object" &&
305
- (part as { type?: unknown }).type === "text" &&
306
- typeof (part as { text?: unknown }).text === "string" &&
307
- (part as { text: string }).text.includes(CONTEXT_MARKER),
308
- );
309
- }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sassywares/pi-extensions",
3
- "version": "0.3.22",
3
+ "version": "0.3.23",
4
4
  "description": "A zesty power pack for pi: auto-titled sessions, real web access, clean handoffs, isolated subagents, and ask/work modes.",
5
5
  "type": "module",
6
6
  "license": "MIT",