@sassywares/pi-extensions 0.3.21 → 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
@@ -151,9 +151,10 @@ Writes do not fail quietly and they are not matched by pattern. `rm`, `sed -i`,
151
151
 
152
152
  A backend is only trusted after it proves both halves of the contract: reads inside the working directory must succeed **and** a probe write there must fail. A backend that is installed but cannot enforce (for example bubblewrap on a distribution that blocks unprivileged user namespaces) is discarded, and ask mode continues without bash rather than falling back to a command allowlist. The working directory always wins over the temp allowance: if the project itself lives under the temp directory, temp writes are denied too and `/modes` reports `temp dir is read-only`.
153
153
 
154
- `/modes` shows the verdict, and the footer status line shows `⏸ ask · ro(sandbox-exec)`, `⏸ ask · bash off`, or `▶ work`.
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.
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Right-aligned mode badge.
3
+ *
4
+ * `ctx.ui.setStatus()` has no placement: pi appends status texts as a line of
5
+ * their own, left-aligned, and its sanitizer collapses runs of spaces, so a
6
+ * badge cannot be pushed to the right edge from there.
7
+ *
8
+ * This footer keeps pi's own rendering (it delegates to the exported
9
+ * `FooterComponent`) and appends one extra line with the badge aligned to the
10
+ * right edge. Nothing about the directory, session name, token stats, or model
11
+ * line is reimplemented here, so it stays correct as pi's footer evolves.
12
+ *
13
+ * If a future pi version changes what `FooterComponent` reads from its session
14
+ * argument, the render below throws; `onError` then tells the extension to
15
+ * restore the native footer and fall back to the plain status line.
16
+ */
17
+
18
+ import type { Component } from "@earendil-works/pi-tui";
19
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
20
+ import type { ExtensionContext, ReadonlyFooterDataProvider, Theme } from "@earendil-works/pi-coding-agent";
21
+ import { FooterComponent } from "@earendil-works/pi-coding-agent";
22
+
23
+ export interface ModeBadge {
24
+ text: string;
25
+ tone: "warning" | "error" | "accent";
26
+ }
27
+
28
+ export interface ModesFooterOptions {
29
+ /** Session auto-compaction, shown by the footer as the `(auto)` suffix. */
30
+ autoCompact: boolean;
31
+ /** Called once if pi's footer cannot be rendered through this shim. */
32
+ onError: () => void;
33
+ }
34
+
35
+ /**
36
+ * The subset of `AgentSession` that `FooterComponent` reads. Everything except
37
+ * a few computed values is already on the extension context.
38
+ */
39
+ function sessionShim(ctx: ExtensionContext) {
40
+ return {
41
+ model: ctx.model,
42
+ state: { model: ctx.model, thinkingLevel: ctx.thinkingLevel },
43
+ routedModel: undefined,
44
+ getContextUsage: () => ctx.getContextUsage(),
45
+ modelRuntime: {
46
+ // Extensions cannot observe subscription backing; the provider name is
47
+ // the only signal available.
48
+ isUsingSubscription: (provider: string) => provider === "kimi-coding",
49
+ },
50
+ sessionManager: ctx.sessionManager,
51
+ } as unknown as ConstructorParameters<typeof FooterComponent>[0];
52
+ }
53
+
54
+ export function createModesFooter(
55
+ ctx: ExtensionContext,
56
+ theme: Theme,
57
+ footerData: ReadonlyFooterDataProvider,
58
+ badge: () => ModeBadge,
59
+ options: ModesFooterOptions,
60
+ ): Component & { dispose?(): void } {
61
+ const inner = new FooterComponent(sessionShim(ctx), footerData);
62
+ inner.setAutoCompactEnabled(options.autoCompact);
63
+ let failed = false;
64
+
65
+ function badgeLine(width: number): string {
66
+ const current = badge();
67
+ const text = current.text;
68
+ if (visibleWidth(text) >= width) return theme.fg(current.tone, truncateToWidth(text, width, "…"));
69
+ const padding = Math.max(1, width - visibleWidth(text));
70
+ return " ".repeat(padding) + theme.fg(current.tone, text);
71
+ }
72
+
73
+ return {
74
+ invalidate() {
75
+ inner.invalidate();
76
+ },
77
+ render(width: number): string[] {
78
+ if (failed) return [badgeLine(width)];
79
+ try {
80
+ return [...inner.render(width), badgeLine(width)];
81
+ } catch {
82
+ failed = true;
83
+ options.onError();
84
+ return [badgeLine(width)];
85
+ }
86
+ },
87
+ };
88
+ }
@@ -14,17 +14,17 @@
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";
19
+ import { createModesFooter, type ModeBadge } from "./footer.ts";
20
20
 
21
21
  type Mode = "work" | "ask";
22
22
 
23
23
  /** Tools that may run in ask mode. Everything else is hidden and blocked. */
24
24
  const ASK_TOOLS = ["read", "grep", "find", "ls", "bash", "web_search", "web_fetch", "questionnaire"];
25
25
 
26
- const CONTEXT_CUSTOM_TYPE = "modes-ask-context";
27
- const CONTEXT_MARKER = "[ASK MODE ACTIVE]";
26
+ const ASK_CONTEXT_TYPE = "modes-ask-context";
27
+ const WORK_CONTEXT_TYPE = "modes-work-context";
28
28
  const STATE_CUSTOM_TYPE = "modes";
29
29
  const SHORTCUT = "alt+m";
30
30
 
@@ -32,15 +32,25 @@ interface PersistedState {
32
32
  mode?: Mode;
33
33
  }
34
34
 
35
- const ASK_PROMPT = `${CONTEXT_MARKER}
35
+ const ASK_PROMPT = `[ASK MODE ACTIVE]
36
36
  You are in ask mode: reading, searching, and answering only.
37
37
 
38
38
  Rules:
39
39
  - Do not modify files. The edit and write tools are not available.
40
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.
41
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.
42
43
  - If a change is needed, describe it precisely and tell the user to switch to work mode (/work or alt+m).`;
43
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
+
44
54
  export default function modesExtension(pi: ExtensionAPI): void {
45
55
  // Ask mode depends on an OS-level sandbox that Windows does not provide.
46
56
  // Rather than ship a weaker guardrail, the extension does nothing there.
@@ -51,6 +61,9 @@ export default function modesExtension(pi: ExtensionAPI): void {
51
61
  let backendReason = "not detected yet";
52
62
  let backendChecked = false;
53
63
  let workTools: string[] | undefined;
64
+ let announceWork = false;
65
+ let footerStatus: "none" | "custom" | "fallback" = "none";
66
+ let footerTui: { requestRender(): void } | undefined;
54
67
  const wrappedCalls = new Set<string>();
55
68
 
56
69
  function configuredAskTools(): string[] {
@@ -71,13 +84,55 @@ export default function modesExtension(pi: ExtensionAPI): void {
71
84
  pi.appendEntry(STATE_CUSTOM_TYPE, { mode } satisfies PersistedState);
72
85
  }
73
86
 
87
+ function badgeSpec(): ModeBadge {
88
+ if (mode === "work") return { text: "▶ work", tone: "accent" };
89
+ if (backend) return { text: `⏸ ask · ro(${backend.id})`, tone: "warning" };
90
+ return { text: "⏸ ask · bash off", tone: "error" };
91
+ }
92
+
74
93
  function updateStatus(ctx: ExtensionContext): void {
75
- if (mode === "work") {
76
- ctx.ui.setStatus("modes", ctx.ui.theme.fg("accent", "▶ work"));
94
+ // The custom footer draws the badge itself, right-aligned.
95
+ if (footerStatus === "custom") {
96
+ ctx.ui.setStatus("modes", undefined);
97
+ footerTui?.requestRender();
77
98
  return;
78
99
  }
79
- const text = backend ? `⏸ ask · ro(${backend.id})` : "⏸ ask · bash off";
80
- ctx.ui.setStatus("modes", ctx.ui.theme.fg(backend ? "warning" : "error", text));
100
+ const badge = badgeSpec();
101
+ ctx.ui.setStatus("modes", ctx.ui.theme.fg(badge.tone, badge.text));
102
+ }
103
+
104
+ /**
105
+ * Install a footer that keeps pi's rendering and appends the badge aligned to
106
+ * the right edge. Falls back to the native footer plus a status line if pi's
107
+ * footer cannot be driven from here.
108
+ */
109
+ function installFooter(ctx: ExtensionContext): void {
110
+ if (ctx.mode !== "tui" || footerStatus === "custom") return;
111
+ const autoCompact = pi.getSettings().compaction?.enabled !== false;
112
+
113
+ ctx.ui.setFooter((tui, theme, footerData) => {
114
+ footerTui = tui;
115
+ const unsubscribe = footerData.onBranchChange(() => tui.requestRender());
116
+ return {
117
+ ...createModesFooter(ctx, theme, footerData, badgeSpec, {
118
+ autoCompact,
119
+ onError: () => {
120
+ footerStatus = "fallback";
121
+ footerTui = undefined;
122
+ queueMicrotask(() => {
123
+ ctx.ui.setFooter(undefined);
124
+ updateStatus(ctx);
125
+ });
126
+ },
127
+ }),
128
+ dispose() {
129
+ unsubscribe();
130
+ footerTui = undefined;
131
+ },
132
+ };
133
+ });
134
+ footerStatus = "custom";
135
+ ctx.ui.setStatus("modes", undefined);
81
136
  }
82
137
 
83
138
  function describe(): string {
@@ -111,6 +166,7 @@ export default function modesExtension(pi: ExtensionAPI): void {
111
166
  if (next === "ask") {
112
167
  await ensureBackend(ctx.cwd);
113
168
  mode = "ask";
169
+ announceWork = false;
114
170
  workTools = pi.getActiveTools();
115
171
  pi.setActiveTools(askToolset());
116
172
  updateStatus(ctx);
@@ -124,6 +180,7 @@ export default function modesExtension(pi: ExtensionAPI): void {
124
180
  }
125
181
 
126
182
  mode = "work";
183
+ announceWork = true;
127
184
  pi.setActiveTools(workTools ?? pi.getActiveTools());
128
185
  workTools = undefined;
129
186
  updateStatus(ctx);
@@ -205,21 +262,48 @@ export default function modesExtension(pi: ExtensionAPI): void {
205
262
  });
206
263
 
207
264
  pi.on("before_agent_start", async (event) => {
208
- if (mode !== "ask") return undefined;
209
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;
210
281
  return {
211
282
  message: {
212
- customType: CONTEXT_CUSTOM_TYPE,
213
- content: ASK_PROMPT,
283
+ customType: WORK_CONTEXT_TYPE,
284
+ content: WORK_PROMPT,
214
285
  display: false,
215
286
  },
216
287
  };
217
288
  });
218
289
 
219
- // 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.
220
293
  pi.on("context", async (event) => {
221
- if (mode === "ask") return undefined;
222
- 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
+ };
223
307
  });
224
308
 
225
309
  pi.on("session_start", async (_event, ctx) => {
@@ -233,6 +317,7 @@ export default function modesExtension(pi: ExtensionAPI): void {
233
317
 
234
318
  mode = state?.data?.mode ?? "work";
235
319
  if (pi.getFlag("ask") === true) mode = "ask";
320
+ announceWork = false;
236
321
 
237
322
  workTools = undefined;
238
323
  if (mode === "ask") {
@@ -240,24 +325,7 @@ export default function modesExtension(pi: ExtensionAPI): void {
240
325
  workTools = pi.getActiveTools();
241
326
  pi.setActiveTools(askToolset());
242
327
  }
328
+ installFooter(ctx);
243
329
  updateStatus(ctx);
244
330
  });
245
331
  }
246
-
247
- /** True for the hidden ask-mode prompt and for user messages that carry it. */
248
- function isAskContext(message: AgentMessage): boolean {
249
- const candidate = message as AgentMessage & { customType?: string };
250
- if (candidate.customType === CONTEXT_CUSTOM_TYPE) return true;
251
- if (candidate.role === "assistant") return false;
252
- const content = (candidate as { content?: unknown }).content;
253
- if (typeof content === "string") return content.includes(CONTEXT_MARKER);
254
- if (!Array.isArray(content)) return false;
255
- return content.some(
256
- (part) =>
257
- !!part &&
258
- typeof part === "object" &&
259
- (part as { type?: unknown }).type === "text" &&
260
- typeof (part as { text?: unknown }).text === "string" &&
261
- (part as { text: string }).text.includes(CONTEXT_MARKER),
262
- );
263
- }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sassywares/pi-extensions",
3
- "version": "0.3.21",
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",
@@ -36,6 +36,7 @@
36
36
  },
37
37
  "peerDependencies": {
38
38
  "@earendil-works/pi-coding-agent": "*",
39
+ "@earendil-works/pi-tui": "*",
39
40
  "typebox": "*"
40
41
  },
41
42
  "pi": {