@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 +8 -2
- package/package.json +6 -2
- package/src/btw/index.ts +28 -5
- package/src/btw/session.test.ts +39 -0
- package/src/btw/session.ts +59 -1
- package/src/btw/widget.test.ts +26 -3
- package/src/btw/widget.ts +32 -7
- package/src/extension.test.ts +5 -5
- package/src/unattended.test.ts +24 -27
- package/src/unattended.ts +28 -41
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.
|
|
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.
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
},
|
package/src/btw/session.test.ts
CHANGED
|
@@ -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 = [
|
package/src/btw/session.ts
CHANGED
|
@@ -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(
|
|
233
|
+
await session.prompt(prompt, { source: "extension" });
|
|
176
234
|
return {
|
|
177
235
|
text: text.trim() || lastAssistantText(session.messages),
|
|
178
236
|
thinking: thinking.trim(),
|
package/src/btw/widget.test.ts
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
62
|
-
expect(
|
|
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
|
|
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(
|
|
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);
|
package/src/extension.test.ts
CHANGED
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
|
-
import type
|
|
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((
|
|
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((
|
|
71
|
+
expect(getUnattendedMode(pi.events)).toBe("off");
|
|
72
72
|
expect(statuses.at(-1)).toBeUndefined();
|
|
73
73
|
});
|
|
74
74
|
});
|
package/src/unattended.test.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
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
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
expect((
|
|
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
|
-
|
|
44
|
-
|
|
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
|
-
|
|
49
|
-
|
|
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
|
-
|
|
56
|
-
|
|
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
|
-
|
|
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
|
|
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[
|
|
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
|
-
|
|
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)
|
|
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 =
|
|
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",
|
|
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 =
|
|
165
|
-
|
|
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 (
|
|
180
|
-
|
|
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
|
-
|
|
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.",
|