@xynogen/pix-commands 0.4.1 → 0.6.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
@@ -3,7 +3,7 @@
3
3
  Pi extension providing focused slash commands:
4
4
 
5
5
  - `/clear` — flush Pi's cached model data.
6
- - `/btw <question>` — ask an isolated side question without interrupting the main agent.
6
+ - `/btw [--ctx] <question>` — ask an isolated side question without interrupting the main agent (`--ctx` folds in recent main-session turns).
7
7
  - `/afk` — toggle AFK mode for unattended runs (yellow auto-allow, red/root auto-deny).
8
8
  - `/yolo` — toggle YOLO mode for unattended runs (auto-approve nearly everything incl. red/root; capable models only, session consent required).
9
9
 
@@ -17,13 +17,19 @@ Deletes `~/.cache/pi` to flush stale model-data cache, then prompts you to run `
17
17
 
18
18
  ```text
19
19
  /btw what is the difference between a mutex and a semaphore?
20
+ /btw --ctx given what we just did, what should I test first?
20
21
  ```
21
22
 
23
+ Run `/btw` with no question to print usage. The live
24
+ above-editor widget lingers finished asides for `collapse.delaySec × 3` (errors
25
+ `× 9`), so a fire-and-leave aside stays visible while you are away; the durable
26
+ answer card in the log never expires.
27
+
22
28
  The child session:
23
29
 
24
30
  - starts with an empty conversation and a lean Pix system prompt;
25
31
  - snapshots the main session's model, thinking level, active tools, credentials, extensions, and working directory;
26
- - never imports the main conversation;
32
+ - never imports the main conversation **unless you pass `--ctx`**, which folds the last 10 main-session turns in as a one-shot, read-only preamble (visible in the question, still a fresh isolated session — nothing is written back to the main thread);
27
33
  - publishes its Markdown answer in a visually distinct side-thread card;
28
34
  - keeps rendered BTW answers out of future main-agent LLM context;
29
35
  - supports multiple concurrent side questions.
package/package.json CHANGED
@@ -1,9 +1,13 @@
1
1
  {
2
2
  "name": "@xynogen/pix-commands",
3
- "version": "0.4.1",
3
+ "version": "0.6.0",
4
4
  "description": "Pi extension — slash commands for cache clearing and isolated side questions",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
7
+ "exports": {
8
+ ".": "./src/index.ts",
9
+ "./extension": "./src/extension.ts"
10
+ },
7
11
  "scripts": {
8
12
  "test": "bun test"
9
13
  },
@@ -47,6 +51,6 @@
47
51
  "dependencies": {
48
52
  "@xynogen/pix-data": "^0.4.0",
49
53
  "@xynogen/pix-pretty": "^1.13.0",
50
- "@xynogen/pix-runtime": "^0.7.0"
54
+ "@xynogen/pix-runtime": "^0.8.0"
51
55
  }
52
56
  }
package/src/btw/index.ts CHANGED
@@ -6,8 +6,9 @@ import type {
6
6
  } from "@earendil-works/pi-coding-agent";
7
7
  import { Text, type TUI } from "@earendil-works/pi-tui";
8
8
  import { getSessionContextUsage } from "@xynogen/pix-pretty/widget-format";
9
+ import { collapseDelayMs } from "@xynogen/pix-runtime/collapse";
9
10
  import { type BtwMessageDetails, registerBtwRenderer } from "./render.ts";
10
- import { runBtw, snapshotMainSettings } from "./session.ts";
11
+ import { BTW_CTX_TURNS, buildContextPreamble, runBtw, snapshotMainSettings } from "./session.ts";
11
12
  import { type BtwWidgetJob, hasVisibleJobs, renderBtwWidget } from "./widget.ts";
12
13
 
13
14
  const STATUS_KEY = "pix-btw";
@@ -68,7 +69,8 @@ export function registerBtw(pi: ExtensionAPI): void {
68
69
  if (!active || !latestUi) return;
69
70
  const now = Date.now();
70
71
  const allJobs = [...jobs.values()];
71
- if (!hasVisibleJobs(allJobs.map(toWidgetJob), now)) {
72
+ const baseMs = collapseDelayMs();
73
+ if (!hasVisibleJobs(allJobs.map(toWidgetJob), now, baseMs)) {
72
74
  latestUi.setStatus(STATUS_KEY, undefined);
73
75
  latestUi.setWidget(WIDGET_KEY, undefined);
74
76
  if (refreshTimer) clearInterval(refreshTimer);
@@ -85,7 +87,11 @@ export function registerBtw(pi: ExtensionAPI): void {
85
87
  render: (width: number) => {
86
88
  const w = width || tui.terminal.columns;
87
89
  const widgetJobs = [...jobs.values()].map(toWidgetJob);
88
- text.setText(renderBtwWidget(widgetJobs, theme, frame, Date.now(), w).join("\n"));
90
+ text.setText(
91
+ renderBtwWidget(widgetJobs, theme, frame, Date.now(), w, collapseDelayMs()).join(
92
+ "\n",
93
+ ),
94
+ );
89
95
  return text.render(w);
90
96
  },
91
97
  invalidate: () => text.invalidate(),
@@ -119,13 +125,29 @@ export function registerBtw(pi: ExtensionAPI): void {
119
125
  pi.registerCommand("btw", {
120
126
  description: "Ask an isolated side question without interrupting the main agent",
121
127
  handler: async (rawArgs, ctx) => {
122
- const question = rawArgs.trim();
123
128
  latestUi = ctx.ui;
129
+
130
+ // Flag: --ctx folds in the last N main-session turns as read-only context.
131
+ const tokens = rawArgs.trim().split(/\s+/).filter(Boolean);
132
+ const withCtx = tokens.includes("--ctx");
133
+ const question = tokens.filter((t) => t !== "--ctx").join(" ");
134
+
124
135
  if (!question) {
125
- ctx.ui.notify("Usage: /btw <question>", "warning");
136
+ ctx.ui.notify(
137
+ [
138
+ "Usage: /btw [--ctx] <question>",
139
+ ` --ctx include the last ${BTW_CTX_TURNS} main-session turns as read-only context`,
140
+ "By default the aside is fully isolated (sees no main conversation).",
141
+ ].join("\n"),
142
+ "warning",
143
+ );
126
144
  return;
127
145
  }
128
146
 
147
+ const contextPreamble = withCtx
148
+ ? buildContextPreamble(ctx.sessionManager.buildContextEntries())
149
+ : undefined;
150
+
129
151
  let snapshot: ReturnType<typeof snapshotMainSettings>;
130
152
  try {
131
153
  snapshot = snapshotMainSettings(ctx, pi.getThinkingLevel(), pi.getActiveTools());
@@ -156,6 +178,7 @@ export function registerBtw(pi: ExtensionAPI): void {
156
178
  question,
157
179
  snapshot,
158
180
  ctx,
181
+ contextPreamble,
159
182
  onSession: (session) => {
160
183
  job.session = session;
161
184
  },
@@ -2,7 +2,9 @@ import { describe, expect, test } from "bun:test";
2
2
  import type { Api, Model } from "@earendil-works/pi-ai";
3
3
  import type { LoadExtensionsResult } from "@earendil-works/pi-coding-agent";
4
4
  import {
5
+ BTW_CTX_TURNS,
5
6
  BTW_SYSTEM_PROMPT,
7
+ buildContextPreamble,
6
8
  lastAssistantText,
7
9
  makeLeanExtensions,
8
10
  selectBtwTools,
@@ -67,6 +69,43 @@ describe("makeLeanExtensions", () => {
67
69
  });
68
70
  });
69
71
 
72
+ describe("buildContextPreamble", () => {
73
+ const msg = (role: string, text: string) => ({
74
+ type: "message",
75
+ message: { role, content: [{ type: "text", text }] },
76
+ });
77
+
78
+ test("flattens recent user/assistant turns into a labeled preamble", () => {
79
+ const out = buildContextPreamble([msg("user", "hi"), msg("assistant", "hello")]);
80
+ expect(out).toContain("User: hi");
81
+ expect(out).toContain("Assistant: hello");
82
+ expect(out).toContain("read-only context");
83
+ });
84
+
85
+ test("keeps only the last N turns", () => {
86
+ const entries = Array.from({ length: BTW_CTX_TURNS + 5 }, (_, i) => msg("user", `q${i}`));
87
+ const out = buildContextPreamble(entries);
88
+ expect(out).not.toContain("q0"); // dropped
89
+ expect(out).toContain(`q${BTW_CTX_TURNS + 4}`); // kept
90
+ expect(out).toContain(`most recent ${BTW_CTX_TURNS} turn`);
91
+ });
92
+
93
+ test("skips non-message entries, tool noise, and string content", () => {
94
+ const out = buildContextPreamble([
95
+ { type: "model_change", message: undefined },
96
+ { type: "message", message: { role: "tool", content: "ignored" } },
97
+ { type: "message", message: { role: "user", content: "plain string" } },
98
+ ]);
99
+ expect(out).toContain("User: plain string");
100
+ expect(out).not.toContain("ignored");
101
+ });
102
+
103
+ test("returns empty when there are no usable turns", () => {
104
+ expect(buildContextPreamble([{ type: "compaction", message: undefined }])).toBe("");
105
+ expect(buildContextPreamble([])).toBe("");
106
+ });
107
+ });
108
+
70
109
  describe("lastAssistantText", () => {
71
110
  test("returns text from the latest assistant response", () => {
72
111
  const messages = [
@@ -28,6 +28,8 @@ export interface BtwRunOptions {
28
28
  question: string;
29
29
  snapshot: BtwSnapshot;
30
30
  ctx: ExtensionContext;
31
+ /** Optional read-only preamble (e.g. recent main-session turns from `--ctx`). */
32
+ contextPreamble?: string;
31
33
  onSession?: (session: AgentSession) => void;
32
34
  onTextDelta?: (delta: string, fullText: string) => void;
33
35
  onToolStart?: (toolName: string) => void;
@@ -84,6 +86,59 @@ export function makeLeanExtensions(base: LoadExtensionsResult): LoadExtensionsRe
84
86
  };
85
87
  }
86
88
 
89
+ /** How many recent conversation turns `--ctx` folds into the aside. */
90
+ export const BTW_CTX_TURNS = 10;
91
+
92
+ /** One flattened conversation line: role + its text (tool noise dropped). */
93
+ interface CtxLine {
94
+ role: string;
95
+ text: string;
96
+ }
97
+
98
+ /**
99
+ * Flatten the main session's recent user/assistant turns into a plain-text
100
+ * preamble for `--ctx`. Reads `buildContextEntries()` (the active,
101
+ * compaction-aware list), keeps only message entries with extractable text,
102
+ * and returns the last `turns` of them. Returns "" when there's nothing to show
103
+ * so callers can skip the preamble entirely.
104
+ *
105
+ * Kept a pure function over a minimal entry shape (not the full SessionManager)
106
+ * so it unit-tests without a Pi host.
107
+ */
108
+ export function buildContextPreamble(
109
+ entries: readonly { type: string; message?: unknown }[],
110
+ turns = BTW_CTX_TURNS,
111
+ ): string {
112
+ const lines: CtxLine[] = [];
113
+ for (const entry of entries) {
114
+ if (entry.type !== "message") continue;
115
+ const msg = entry.message as { role?: string; content?: unknown } | undefined;
116
+ if (!msg || (msg.role !== "user" && msg.role !== "assistant")) continue;
117
+ const text = extractMessageText(msg.content);
118
+ if (text) lines.push({ role: msg.role, text });
119
+ }
120
+ const recent = lines.slice(-Math.max(1, turns));
121
+ if (recent.length === 0) return "";
122
+ const body = recent
123
+ .map((l) => `${l.role === "user" ? "User" : "Assistant"}: ${l.text}`)
124
+ .join("\n\n");
125
+ return `Recent conversation from the main session (most recent ${recent.length} turn(s), read-only context):\n\n${body}`;
126
+ }
127
+
128
+ /** Pull plain text out of a message's content (string or content-block array). */
129
+ function extractMessageText(content: unknown): string {
130
+ if (typeof content === "string") return content.trim();
131
+ if (!Array.isArray(content)) return "";
132
+ return content
133
+ .filter((b): b is { type: "text"; text: string } => {
134
+ const block = b as { type?: string; text?: unknown };
135
+ return block.type === "text" && typeof block.text === "string";
136
+ })
137
+ .map((b) => b.text)
138
+ .join("\n")
139
+ .trim();
140
+ }
141
+
87
142
  /** Extract the final assistant text if no streaming deltas were observed. */
88
143
  export function lastAssistantText(messages: readonly unknown[]): string {
89
144
  for (let index = messages.length - 1; index >= 0; index--) {
@@ -171,8 +226,11 @@ export async function runBtw(options: BtwRunOptions): Promise<BtwRunResult> {
171
226
  }
172
227
  });
173
228
 
229
+ const prompt = options.contextPreamble
230
+ ? `${options.contextPreamble}\n\n---\n\nQuestion: ${question}`
231
+ : question;
174
232
  try {
175
- await session.prompt(question, { source: "extension" });
233
+ await session.prompt(prompt, { source: "extension" });
176
234
  return {
177
235
  text: text.trim() || lastAssistantText(session.messages),
178
236
  thinking: thinking.trim(),
@@ -1,7 +1,11 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import {
3
3
  type BtwWidgetJob,
4
+ DEFAULT_LINGER_BASE_MS,
5
+ ERROR_LINGER_SCALE,
4
6
  hasVisibleJobs,
7
+ lingerWindows,
8
+ OK_LINGER_SCALE,
5
9
  renderBtwWidget,
6
10
  shouldShowFinished,
7
11
  type WidgetTheme,
@@ -47,8 +51,9 @@ describe("BTW widget layout", () => {
47
51
 
48
52
  test("finished jobs linger with a check, then drop after the window", () => {
49
53
  const done = job({ status: "completed", completedAt: 1_000, toolUses: 2, turnCount: 1 });
54
+ // Default base 10s × 3 = 30s ok-window.
50
55
  expect(shouldShowFinished(done, 1_500)).toBe(true);
51
- expect(shouldShowFinished(done, 10_000)).toBe(false);
56
+ expect(shouldShowFinished(done, 40_000)).toBe(false); // 39s elapsed > 30s window
52
57
 
53
58
  const out = render([done], 1_500);
54
59
  expect(out).toContain("\u2713");
@@ -58,8 +63,26 @@ describe("BTW widget layout", () => {
58
63
 
59
64
  test("errors linger longer and show the message", () => {
60
65
  const failed = job({ status: "error", completedAt: 1_000, error: "boom" });
61
- expect(shouldShowFinished(failed, 6_000)).toBe(true); // past the 5s ok-window
62
- expect(render([failed], 6_000)).toContain("boom");
66
+ // Default base 10s × 9 = 90s error-window; 40s elapsed is past ok (30s) but well inside error.
67
+ expect(shouldShowFinished(failed, 40_000)).toBe(true);
68
+ expect(render([failed], 40_000)).toContain("boom");
69
+ });
70
+
71
+ test("linger windows scale off the config collapse delay", () => {
72
+ // Defaults derive from the fallback base.
73
+ expect(lingerWindows()).toEqual({
74
+ ok: DEFAULT_LINGER_BASE_MS * OK_LINGER_SCALE,
75
+ error: DEFAULT_LINGER_BASE_MS * ERROR_LINGER_SCALE,
76
+ });
77
+ // A custom base (e.g. config collapse.delaySec = 4 → 4000ms) scales both.
78
+ expect(lingerWindows(4_000)).toEqual({ ok: 12_000, error: 36_000 });
79
+ // Non-positive base falls back to the default so windows never collapse to 0.
80
+ expect(lingerWindows(0)).toEqual(lingerWindows());
81
+
82
+ // shouldShowFinished honors the threaded base: a 4s base drops an ok job at 13s.
83
+ const done = job({ status: "completed", completedAt: 0 });
84
+ expect(shouldShowFinished(done, 11_000, 4_000)).toBe(true); // < 12s
85
+ expect(shouldShowFinished(done, 13_000, 4_000)).toBe(false); // > 12s
63
86
  });
64
87
 
65
88
  test("overflow collapses excess rows into a +N more line", () => {
package/src/btw/widget.ts CHANGED
@@ -46,21 +46,45 @@ export interface WidgetTheme {
46
46
  }
47
47
 
48
48
  const MAX_WIDGET_LINES = 12;
49
- const FINISHED_LINGER_MS = 5_000;
50
- const ERROR_LINGER_MS = 15_000;
51
49
  const ERROR_STATUSES = new Set<BtwJobStatus>(["error", "stopped"]);
52
50
 
51
+ // /btw is fire-and-leave, so a finished job lingers far longer than a normal
52
+ // collapsing tool card. Both windows scale off the shared collapse delay
53
+ // (config `collapse.delaySec`, default 10s) so one knob tunes everything:
54
+ // ok linger = base × 3 (30s at default)
55
+ // error linger = base × 9 (90s at default)
56
+ // The durable answer card in render.ts is the permanent record; these only
57
+ // govern the transient above-editor line.
58
+ export const OK_LINGER_SCALE = 3;
59
+ export const ERROR_LINGER_SCALE = 9;
60
+ /** Fallback base when no config delay is threaded in (pure-module/test use). */
61
+ export const DEFAULT_LINGER_BASE_MS = 10_000;
62
+
63
+ /** Resolve the ok/error linger windows from a base collapse delay. */
64
+ export function lingerWindows(baseMs: number = DEFAULT_LINGER_BASE_MS): {
65
+ ok: number;
66
+ error: number;
67
+ } {
68
+ const base = baseMs > 0 ? baseMs : DEFAULT_LINGER_BASE_MS;
69
+ return { ok: base * OK_LINGER_SCALE, error: base * ERROR_LINGER_SCALE };
70
+ }
71
+
53
72
  /** True while a finished job should still linger in the widget. */
54
- export function shouldShowFinished(job: BtwWidgetJob, now: number): boolean {
73
+ export function shouldShowFinished(job: BtwWidgetJob, now: number, baseMs?: number): boolean {
55
74
  if (job.status === "running" || job.completedAt == null) return false;
56
- const linger = ERROR_STATUSES.has(job.status) ? ERROR_LINGER_MS : FINISHED_LINGER_MS;
75
+ const w = lingerWindows(baseMs);
76
+ const linger = ERROR_STATUSES.has(job.status) ? w.error : w.ok;
57
77
  return now - job.completedAt < linger;
58
78
  }
59
79
 
60
80
  /** Any job worth painting right now (running or lingering finished). */
61
- export function hasVisibleJobs(jobs: Iterable<BtwWidgetJob>, now: number): boolean {
81
+ export function hasVisibleJobs(
82
+ jobs: Iterable<BtwWidgetJob>,
83
+ now: number,
84
+ baseMs?: number,
85
+ ): boolean {
62
86
  for (const job of jobs) {
63
- if (job.status === "running" || shouldShowFinished(job, now)) return true;
87
+ if (job.status === "running" || shouldShowFinished(job, now, baseMs)) return true;
64
88
  }
65
89
  return false;
66
90
  }
@@ -115,9 +139,10 @@ export function renderBtwWidget(
115
139
  frame: number,
116
140
  now: number,
117
141
  width: number,
142
+ baseMs?: number,
118
143
  ): string[] {
119
144
  const running = jobs.filter((j) => j.status === "running");
120
- const finished = jobs.filter((j) => j.status !== "running" && shouldShowFinished(j, now));
145
+ const finished = jobs.filter((j) => j.status !== "running" && shouldShowFinished(j, now, baseMs));
121
146
  if (running.length === 0 && finished.length === 0) return [];
122
147
 
123
148
  const truncate = (line: string) => truncateToWidth(line, width);
@@ -1,12 +1,11 @@
1
1
  import { afterEach, describe, expect, test } from "bun:test";
2
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { createEventBus, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
3
  import { icon } from "@xynogen/pix-pretty/icon-catalog";
4
+ import { getUnattendedMode } from "@xynogen/pix-runtime";
4
5
  import extension from "./extension.ts";
5
6
 
6
7
  afterEach(() => {
7
8
  delete (globalThis as { __pixOnce?: WeakMap<object, Set<string>> }).__pixOnce;
8
- delete (globalThis as { __pixAfk?: boolean }).__pixAfk;
9
- delete (globalThis as { __pixYolo?: boolean }).__pixYolo;
10
9
  });
11
10
 
12
11
  describe("pix-commands registration", () => {
@@ -15,6 +14,7 @@ describe("pix-commands registration", () => {
15
14
  const handlers = new Map<string, (args: string, ctx: never) => Promise<void>>();
16
15
  const renderers: string[] = [];
17
16
  const pi = {
17
+ events: createEventBus(),
18
18
  registerCommand(
19
19
  name: string,
20
20
  options: { handler?: (args: string, ctx: never) => Promise<void> },
@@ -63,12 +63,12 @@ describe("pix-commands registration", () => {
63
63
  if (!handler) throw new Error("/afk not registered");
64
64
 
65
65
  await handler("", ctx as never);
66
- expect((globalThis as { __pixAfk?: boolean }).__pixAfk).toBe(true);
66
+ expect(getUnattendedMode(pi.events)).toBe("afk");
67
67
  expect(statuses.at(-1)).toBe(`<error>${icon("afk")} AFK</error>`);
68
68
  expect(notices.at(-1)).toContain("yellow gates auto-allow");
69
69
 
70
70
  await handler("", ctx as never);
71
- expect((globalThis as { __pixAfk?: boolean }).__pixAfk).toBe(false);
71
+ expect(getUnattendedMode(pi.events)).toBe("off");
72
72
  expect(statuses.at(-1)).toBeUndefined();
73
73
  });
74
74
  });
@@ -1,4 +1,5 @@
1
- import { afterEach, describe, expect, test } from "bun:test";
1
+ import { describe, expect, test } from "bun:test";
2
+ import { createEventBus } from "@earendil-works/pi-coding-agent";
2
3
  import {
3
4
  confirmYoloConsent,
4
5
  getMode,
@@ -8,26 +9,17 @@ import {
8
9
  YOLO_MIN_SCORE,
9
10
  } from "./unattended.ts";
10
11
 
11
- type G = { __pixAfk?: boolean; __pixYolo?: boolean; __pixYoloConsent?: boolean };
12
-
13
- afterEach(() => {
14
- delete (globalThis as G).__pixAfk;
15
- delete (globalThis as G).__pixYolo;
16
- delete (globalThis as G).__pixYoloConsent;
17
- });
18
-
19
12
  describe("unattended mode state", () => {
20
- test("setMode keeps the two globals mutually exclusive", () => {
21
- setMode("afk");
22
- expect(getMode()).toBe("afk");
23
- expect((globalThis as G).__pixYolo).toBe(false);
13
+ test("setMode updates one session", () => {
14
+ const events = createEventBus();
15
+ setMode(events, "afk");
16
+ expect(getMode(events)).toBe("afk");
24
17
 
25
- setMode("yolo");
26
- expect(getMode()).toBe("yolo");
27
- expect((globalThis as G).__pixAfk).toBe(false);
18
+ setMode(events, "yolo");
19
+ expect(getMode(events)).toBe("yolo");
28
20
 
29
- setMode("off");
30
- expect(getMode()).toBe("off");
21
+ setMode(events, "off");
22
+ expect(getMode(events)).toBe("off");
31
23
  });
32
24
  });
33
25
 
@@ -40,20 +32,23 @@ describe("modelScore", () => {
40
32
 
41
33
  describe("unattendedBanner", () => {
42
34
  test("off => no banner", () => {
43
- setMode("off");
44
- expect(unattendedBanner()).toBeUndefined();
35
+ const events = createEventBus();
36
+ setMode(events, "off");
37
+ expect(unattendedBanner(events)).toBeUndefined();
45
38
  });
46
39
 
47
40
  test("afk banner names auto-deny of red and root", () => {
48
- setMode("afk");
49
- const b = unattendedBanner() ?? "";
41
+ const events = createEventBus();
42
+ setMode(events, "afk");
43
+ const b = unattendedBanner(events) ?? "";
50
44
  expect(b).toContain('mode="afk"');
51
45
  expect(b).toContain("auto-DENY");
52
46
  });
53
47
 
54
48
  test("yolo banner demands red/root self-justification", () => {
55
- setMode("yolo");
56
- const b = unattendedBanner() ?? "";
49
+ const events = createEventBus();
50
+ setMode(events, "yolo");
51
+ const b = unattendedBanner(events) ?? "";
57
52
  expect(b).toContain('mode="yolo"');
58
53
  expect(b).toContain("blast radius");
59
54
  expect(b).toContain("reversible");
@@ -68,12 +63,14 @@ describe("YOLO score threshold", () => {
68
63
 
69
64
  describe("confirmYoloConsent (session gate)", () => {
70
65
  test("short-circuits true once consent is recorded for the session", async () => {
71
- (globalThis as G).__pixYoloConsent = true;
66
+ const events = createEventBus();
67
+ const { setYoloConsent } = await import("@xynogen/pix-runtime");
68
+ setYoloConsent(events, true);
72
69
  // No ui passed: if it did not short-circuit it would return false.
73
- expect(await confirmYoloConsent({})).toBe(true);
70
+ expect(await confirmYoloConsent(events, {})).toBe(true);
74
71
  });
75
72
 
76
73
  test("refuses when there is no ui to render the warning", async () => {
77
- expect(await confirmYoloConsent({})).toBe(false);
74
+ expect(await confirmYoloConsent(createEventBus(), {})).toBe(false);
78
75
  });
79
76
  });
package/src/unattended.ts CHANGED
@@ -16,21 +16,21 @@
16
16
  * cached PAM ticket; the password cannot be auto-typed).
17
17
  */
18
18
 
19
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
19
+ import type { EventBus, ExtensionAPI } from "@earendil-works/pi-coding-agent";
20
20
  import { lookupBenchmark } from "@xynogen/pix-data";
21
21
  import { showOverlay } from "@xynogen/pix-pretty/gate-overlay";
22
22
  import { icon } from "@xynogen/pix-pretty/icon-catalog";
23
+ import {
24
+ getUnattendedMode,
25
+ hasYoloConsent,
26
+ setUnattendedMode,
27
+ setYoloConsent,
28
+ type UnattendedMode,
29
+ } from "@xynogen/pix-runtime";
23
30
 
24
31
  /** Minimum model score allowed to hold YOLO. Below this, red+root auto-approve is off-limits. */
25
32
  export const YOLO_MIN_SCORE = 75;
26
33
 
27
- export type UnattendedMode = "off" | "afk" | "yolo";
28
- type UnattendedGlobal = typeof globalThis & {
29
- __pixAfk?: boolean;
30
- __pixYolo?: boolean;
31
- /** Session-scoped: user acknowledged the YOLO damage warning this session. */
32
- __pixYoloConsent?: boolean;
33
- };
34
34
  type StatusUI = {
35
35
  theme: { fg(color: "error", text: string): string };
36
36
  setStatus(key: string, text: string | undefined): void;
@@ -40,21 +40,9 @@ type ModelCtx = { model?: { id?: string } };
40
40
 
41
41
  const STATUS_KEY = "unattended";
42
42
 
43
- export function getMode(): UnattendedMode {
44
- const g = globalThis as UnattendedGlobal;
45
- if (g.__pixYolo === true) return "yolo";
46
- if (g.__pixAfk === true) return "afk";
47
- return "off";
48
- }
49
-
50
- /** Set the mode; the two globals are mutually exclusive so gate/sudo never see both. */
51
- export function setMode(mode: UnattendedMode): void {
52
- const g = globalThis as UnattendedGlobal;
53
- g.__pixAfk = mode === "afk";
54
- g.__pixYolo = mode === "yolo";
55
- }
43
+ export { getUnattendedMode as getMode, setUnattendedMode as setMode };
56
44
 
57
- function syncStatus(ui: StatusUI): void {
45
+ function syncStatus(events: EventBus, ui: StatusUI): void {
58
46
  // ponytail: reuses the existing "afk"/"warn" glyphs instead of adding a YOLO
59
47
  // icon to pix-pretty (that is a public-API minor bump + approval).
60
48
  const labels: Record<UnattendedMode, string | undefined> = {
@@ -62,7 +50,7 @@ function syncStatus(ui: StatusUI): void {
62
50
  afk: `${icon("afk")} AFK`,
63
51
  off: undefined,
64
52
  };
65
- const label = labels[getMode()];
53
+ const label = labels[getUnattendedMode(events)];
66
54
  ui.setStatus(STATUS_KEY, label ? ui.theme.fg("error", label) : undefined);
67
55
  }
68
56
 
@@ -73,9 +61,8 @@ function syncStatus(ui: StatusUI): void {
73
61
  * across sessions — a fresh session asks again (moral speed-bump, not a
74
62
  * once-and-forget click-through).
75
63
  */
76
- export async function confirmYoloConsent(ctx: ModelCtx): Promise<boolean> {
77
- const g = globalThis as UnattendedGlobal;
78
- if (g.__pixYoloConsent === true) return true;
64
+ export async function confirmYoloConsent(events: EventBus, ctx: ModelCtx): Promise<boolean> {
65
+ if (hasYoloConsent(events)) return true;
79
66
  const ui = (ctx as { ui?: unknown }).ui as Parameters<typeof showOverlay>[0] | undefined;
80
67
  if (!ui) return false;
81
68
  const result = await showOverlay(ui, {
@@ -101,7 +88,7 @@ export async function confirmYoloConsent(ctx: ModelCtx): Promise<boolean> {
101
88
  ],
102
89
  });
103
90
  const ok = result.action === "approved";
104
- if (ok) g.__pixYoloConsent = true;
91
+ if (ok) setYoloConsent(events, true);
105
92
  return ok;
106
93
  }
107
94
 
@@ -116,8 +103,8 @@ export function modelScore(ctx: ModelCtx): number | null {
116
103
  * Per-turn awareness banner. Injected via `before_agent_start` so the model is
117
104
  * told, every turn, that the human safety net is off and what it now owns.
118
105
  */
119
- export function unattendedBanner(): string | undefined {
120
- const mode = getMode();
106
+ export function unattendedBanner(events: EventBus): string | undefined {
107
+ const mode = getUnattendedMode(events);
121
108
  if (mode === "yolo") {
122
109
  return [
123
110
  '<pix-unattended mode="yolo">',
@@ -148,11 +135,11 @@ export function unattendedBanner(): string | undefined {
148
135
  }
149
136
 
150
137
  export default function registerUnattended(pi: ExtensionAPI): void {
151
- pi.on("session_start", (_event, ctx) => syncStatus(ctx.ui));
138
+ pi.on("session_start", (_event, ctx) => syncStatus(pi.events, ctx.ui));
152
139
 
153
140
  // Every turn: prepend the awareness banner while a mode is active.
154
- pi.on("before_agent_start", async (event) => {
155
- const banner = unattendedBanner();
141
+ pi.on("before_agent_start", (event) => {
142
+ const banner = unattendedBanner(pi.events);
156
143
  if (!banner) return undefined;
157
144
  const existing = (event as { systemPrompt?: string }).systemPrompt ?? "";
158
145
  return { systemPrompt: `${banner}\n\n${existing}` };
@@ -161,9 +148,9 @@ export default function registerUnattended(pi: ExtensionAPI): void {
161
148
  pi.registerCommand("afk", {
162
149
  description: "Toggle AFK mode — yellow gates auto-allow; red and root auto-deny",
163
150
  handler: async (_args, ctx) => {
164
- const turningOn = getMode() !== "afk";
165
- setMode(turningOn ? "afk" : "off");
166
- syncStatus(ctx.ui);
151
+ const turningOn = getUnattendedMode(pi.events) !== "afk";
152
+ setUnattendedMode(pi.events, turningOn ? "afk" : "off");
153
+ syncStatus(pi.events, ctx.ui);
167
154
  ctx.ui.notify(
168
155
  turningOn
169
156
  ? "AFK mode on — yellow gates auto-allow; red and root auto-deny."
@@ -176,9 +163,9 @@ export default function registerUnattended(pi: ExtensionAPI): void {
176
163
  pi.registerCommand("yolo", {
177
164
  description: "Toggle YOLO mode — auto-approve everything including red and root",
178
165
  handler: async (_args, ctx) => {
179
- if (getMode() === "yolo") {
180
- setMode("off");
181
- syncStatus(ctx.ui);
166
+ if (getUnattendedMode(pi.events) === "yolo") {
167
+ setUnattendedMode(pi.events, "off");
168
+ syncStatus(pi.events, ctx.ui);
182
169
  ctx.ui.notify("YOLO mode off — approval prompts restored.", "info");
183
170
  return;
184
171
  }
@@ -199,12 +186,12 @@ export default function registerUnattended(pi: ExtensionAPI): void {
199
186
  );
200
187
  return;
201
188
  }
202
- if (!(await confirmYoloConsent(ctx as ModelCtx))) {
189
+ if (!(await confirmYoloConsent(pi.events, ctx as ModelCtx))) {
203
190
  ctx.ui.notify("YOLO cancelled — approval prompts remain in place.", "info");
204
191
  return;
205
192
  }
206
- setMode("yolo");
207
- syncStatus(ctx.ui);
193
+ setUnattendedMode(pi.events, "yolo");
194
+ syncStatus(pi.events, ctx.ui);
208
195
  ctx.ui.notify(
209
196
  `YOLO mode on (model score ${score}) — every gate including red and root ` +
210
197
  "auto-approves. No human will stop a destructive action. You are accountable.",