@blackbelt-technology/pi-agent-dashboard 0.4.0 → 0.4.1
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/AGENTS.md +30 -8
- package/README.md +386 -494
- package/docs/architecture.md +63 -9
- package/package.json +8 -5
- package/packages/extension/package.json +6 -4
- package/packages/extension/src/__tests__/ask-user-tool.test.ts +40 -8
- package/packages/extension/src/__tests__/bridge-entry-id-pi-070.test.ts +174 -0
- package/packages/extension/src/__tests__/event-forwarder.test.ts +30 -0
- package/packages/extension/src/__tests__/fork-entryid-timing.test.ts +64 -76
- package/packages/extension/src/__tests__/multiselect-list.test.ts +137 -0
- package/packages/extension/src/__tests__/no-session-replacement-calls.test.ts +99 -0
- package/packages/extension/src/ask-user-tool.ts +5 -4
- package/packages/extension/src/bridge.ts +102 -15
- package/packages/extension/src/multiselect-list.ts +146 -0
- package/packages/extension/src/multiselect-polyfill.ts +43 -0
- package/packages/extension/src/server-launcher.ts +15 -3
- package/packages/server/package.json +5 -5
- package/packages/server/src/__tests__/fixtures/fork-jsonl-roundtrip.jsonl +8 -0
- package/packages/server/src/__tests__/fork-jsonl-roundtrip.test.ts +49 -0
- package/packages/server/src/__tests__/pi-version-skew.test.ts +72 -0
- package/packages/server/src/__tests__/restart-helper.test.ts +34 -6
- package/packages/server/src/cli.ts +56 -9
- package/packages/server/src/pi-version-skew.ts +12 -1
- package/packages/server/src/restart-helper.ts +13 -2
- package/packages/shared/package.json +1 -1
- package/packages/shared/src/__tests__/no-hardcoded-node-modules-paths.test.ts +176 -0
- package/packages/shared/src/__tests__/no-raw-node-import.test.ts +146 -0
- package/packages/shared/src/__tests__/node-spawn.test.ts +210 -0
- package/packages/shared/src/__tests__/resolve-tool-cli.test.ts +105 -0
- package/packages/shared/src/__tests__/state-replay-entry-id.test.ts +69 -0
- package/packages/shared/src/platform/index.ts +1 -0
- package/packages/shared/src/platform/node-spawn.ts +154 -0
- package/packages/shared/src/protocol.ts +23 -0
- package/packages/shared/src/state-replay.ts +9 -0
- package/packages/shared/src/tool-registry/definitions.ts +92 -0
|
@@ -1,100 +1,88 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Tests for
|
|
2
|
+
* Tests for bridge entryId stamping on message_end events.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* HISTORY: Originally this file modelled pi <0.69's synchronous emit pattern,
|
|
5
|
+
* where the bridge's `queueMicrotask` deferral ran BEFORE
|
|
6
|
+
* sessionManager.appendMessage. That design no longer reflects pi 0.70.x:
|
|
7
|
+
* pi awaits extension handlers inside _emitExtensionEvent, so the microtask
|
|
8
|
+
* resolves *inside* the awaited dispatcher, before persistence. The fix
|
|
9
|
+
* (see change: fix-per-message-fork) is `setTimeout(0)` (a macrotask)
|
|
10
|
+
* combined with reading `event.message.id` (which pi mutates in place
|
|
11
|
+
* during appendMessage) or a WeakMap populated by the wrapped appendMessage.
|
|
7
12
|
*
|
|
8
|
-
* The
|
|
9
|
-
*
|
|
10
|
-
* The
|
|
11
|
-
*
|
|
13
|
+
* The previous test "message_start should still capture entryId immediately
|
|
14
|
+
* (no deferral)" codified the off-by-one bug as expected behavior — it has
|
|
15
|
+
* been REMOVED. The current test suite below models pi 0.70.x semantics
|
|
16
|
+
* directly. Detailed pi-0.70-specific scenarios live in
|
|
17
|
+
* `bridge-entry-id-pi-070.test.ts`.
|
|
12
18
|
*/
|
|
13
|
-
import { describe, it, expect
|
|
19
|
+
import { describe, it, expect } from "vitest";
|
|
14
20
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
* 1. _emit(event) — bridge handler called (async, not awaited)
|
|
20
|
-
* 2. appendMessage(msg) — updates leafId synchronously
|
|
21
|
-
*
|
|
22
|
-
* The bridge handler (async) should yield via queueMicrotask before reading
|
|
23
|
-
* getLeafId(), so that appendMessage has already run.
|
|
24
|
-
*/
|
|
25
|
-
describe("message_end entryId timing", () => {
|
|
26
|
-
it("deferred getLeafId() captures the post-persist entry ID", async () => {
|
|
27
|
-
// Simulate sessionManager with mutable leafId
|
|
28
|
-
let leafId = "user-entry-100"; // stale leaf before appendMessage
|
|
21
|
+
describe("message_end entryId timing on pi 0.70.x", () => {
|
|
22
|
+
it("setTimeout(0) deferral captures the post-persist entry ID", async () => {
|
|
23
|
+
// Simulate pi 0.70.x: bridge handler runs awaited, THEN appendMessage runs.
|
|
24
|
+
let leafId = "user-entry-100";
|
|
29
25
|
const sessionManager = {
|
|
30
26
|
getLeafId: () => leafId,
|
|
27
|
+
appendMessage: (msg: any) => {
|
|
28
|
+
msg.id = "assistant-entry-101";
|
|
29
|
+
leafId = msg.id;
|
|
30
|
+
return msg.id;
|
|
31
|
+
},
|
|
31
32
|
};
|
|
32
33
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
34
|
+
const event = { type: "message_end", message: { role: "assistant" } as any };
|
|
35
|
+
let captured: string | undefined;
|
|
36
|
+
let sendDone!: () => void;
|
|
37
|
+
const sentP = new Promise<void>((r) => { sendDone = r; });
|
|
38
|
+
|
|
39
|
+
// Bridge handler: schedules a setTimeout(0) and returns synchronously.
|
|
40
|
+
// The awaited dispatcher then unwinds, appendMessage runs, AND finally
|
|
41
|
+
// the timeout fires.
|
|
42
|
+
const bridgeHandler = (ev: any) => {
|
|
43
|
+
setTimeout(() => {
|
|
44
|
+
captured = ev.message.id ?? sessionManager.getLeafId();
|
|
45
|
+
sendDone();
|
|
46
|
+
}, 0);
|
|
40
47
|
};
|
|
41
48
|
|
|
42
|
-
// Simulate
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
leafId = "assistant-entry-101";
|
|
47
|
-
|
|
48
|
-
// Wait for the deferred handler to complete
|
|
49
|
-
await handlerPromise;
|
|
49
|
+
// Simulate the dispatcher: await handler, then call appendMessage.
|
|
50
|
+
await bridgeHandler(event);
|
|
51
|
+
sessionManager.appendMessage(event.message);
|
|
52
|
+
await sentP;
|
|
50
53
|
|
|
51
|
-
expect(
|
|
54
|
+
expect(captured).toBe("assistant-entry-101");
|
|
52
55
|
});
|
|
53
56
|
|
|
54
|
-
it("
|
|
57
|
+
it("queueMicrotask deferral would NOT work on pi 0.70.x (regression demonstration)", async () => {
|
|
58
|
+
// Reproduces why we abandoned queueMicrotask: it resolves inside the
|
|
59
|
+
// awaited dispatcher, before appendMessage runs.
|
|
55
60
|
let leafId = "user-entry-100";
|
|
56
|
-
|
|
57
|
-
getLeafId: () => leafId,
|
|
58
|
-
};
|
|
61
|
+
let captured: string | undefined;
|
|
59
62
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
const buggyBridgeHandler = async () => {
|
|
64
|
-
// No deferral — reads leafId before appendMessage runs
|
|
65
|
-
capturedEntryId = sessionManager.getLeafId();
|
|
63
|
+
const buggyBridge = async () => {
|
|
64
|
+
await new Promise<void>((resolve) => queueMicrotask(resolve));
|
|
65
|
+
captured = leafId;
|
|
66
66
|
};
|
|
67
67
|
|
|
68
|
-
//
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
await handlerPromise;
|
|
68
|
+
// Pi 0.70.x: await the bridge, THEN persist. Mirroring the real ordering:
|
|
69
|
+
const handlerP = buggyBridge();
|
|
70
|
+
await handlerP;
|
|
71
|
+
leafId = "assistant-entry-101"; // appendMessage runs after await
|
|
73
72
|
|
|
74
|
-
|
|
75
|
-
expect(
|
|
73
|
+
expect(captured).toBe("user-entry-100");
|
|
74
|
+
expect(captured).not.toBe("assistant-entry-101");
|
|
76
75
|
});
|
|
77
76
|
|
|
78
|
-
it("
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
// Simulate bridge handler for message_start (immediate, no deferral)
|
|
87
|
-
const messageStartHandler = async () => {
|
|
88
|
-
capturedEntryId = sessionManager.getLeafId();
|
|
89
|
-
};
|
|
90
|
-
|
|
91
|
-
const handlerPromise = messageStartHandler();
|
|
92
|
-
// User entry gets written after message_start
|
|
93
|
-
leafId = "user-entry-100";
|
|
94
|
-
|
|
95
|
-
await handlerPromise;
|
|
77
|
+
it("entry_persisted is the back-fill mechanism for user message_start (where event.message.id is unavailable)", () => {
|
|
78
|
+
// Behavioural assertion (pure data shape): when the bridge sends a
|
|
79
|
+
// user message_start, it stamps a nonce; later when pi persists the
|
|
80
|
+
// user entry, the bridge sends entry_persisted { nonce, entryId }.
|
|
81
|
+
// The reducer pairs them by nonce. See change: fix-per-message-fork.
|
|
82
|
+
const start = { type: "message_start", message: { role: "user" }, nonce: "n-1" };
|
|
83
|
+
const persisted = { type: "entry_persisted", entryId: "user-200", nonce: "n-1" };
|
|
96
84
|
|
|
97
|
-
|
|
98
|
-
expect(
|
|
85
|
+
expect(start.nonce).toBe(persisted.nonce);
|
|
86
|
+
expect(persisted.entryId).toBe("user-200");
|
|
99
87
|
});
|
|
100
88
|
});
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from "vitest";
|
|
2
|
+
import { MultiSelectList } from "../multiselect-list.js";
|
|
3
|
+
|
|
4
|
+
function make(options: string[] = ["A", "B", "C"], title = "Pick", message?: string) {
|
|
5
|
+
const list = new MultiSelectList(title, options, message);
|
|
6
|
+
const onConfirm = vi.fn();
|
|
7
|
+
const onCancel = vi.fn();
|
|
8
|
+
list.onConfirm = onConfirm;
|
|
9
|
+
list.onCancel = onCancel;
|
|
10
|
+
return { list, onConfirm, onCancel };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
describe("MultiSelectList", () => {
|
|
14
|
+
describe("keybindings", () => {
|
|
15
|
+
it("space toggles the checked state of the current item and nothing else", () => {
|
|
16
|
+
const { list, onConfirm } = make();
|
|
17
|
+
list.handleInput(" "); // toggle index 0 → A checked
|
|
18
|
+
list.handleInput("\r");
|
|
19
|
+
expect(onConfirm).toHaveBeenCalledWith(["A"]);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("arrow down moves cursor and space toggles the new current item", () => {
|
|
23
|
+
const { list, onConfirm } = make();
|
|
24
|
+
list.handleInput("\u001b[B"); // cursor → 1 (B)
|
|
25
|
+
list.handleInput(" "); // B checked
|
|
26
|
+
list.handleInput("\r");
|
|
27
|
+
expect(onConfirm).toHaveBeenCalledWith(["B"]);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("j / k navigation works like arrows", () => {
|
|
31
|
+
const { list, onConfirm } = make();
|
|
32
|
+
list.handleInput("j"); // cursor → 1
|
|
33
|
+
list.handleInput("j"); // cursor → 2
|
|
34
|
+
list.handleInput(" "); // C checked
|
|
35
|
+
list.handleInput("k"); // cursor → 1
|
|
36
|
+
list.handleInput(" "); // B checked
|
|
37
|
+
list.handleInput("\r");
|
|
38
|
+
// selected values returned in ORIGINAL order
|
|
39
|
+
expect(onConfirm).toHaveBeenCalledWith(["B", "C"]);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("enter with nothing checked confirms with []", () => {
|
|
43
|
+
const { list, onConfirm } = make();
|
|
44
|
+
list.handleInput("\r");
|
|
45
|
+
expect(onConfirm).toHaveBeenCalledWith([]);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("escape cancels; no confirm is fired", () => {
|
|
49
|
+
const { list, onConfirm, onCancel } = make();
|
|
50
|
+
list.handleInput(" "); // check A
|
|
51
|
+
list.handleInput("\u001b");
|
|
52
|
+
expect(onCancel).toHaveBeenCalledTimes(1);
|
|
53
|
+
expect(onConfirm).not.toHaveBeenCalled();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("pressing 'a' does NOT bulk-toggle (no select-all in TUI)", () => {
|
|
57
|
+
const { list, onConfirm } = make();
|
|
58
|
+
list.handleInput("a");
|
|
59
|
+
list.handleInput("\r");
|
|
60
|
+
expect(onConfirm).toHaveBeenCalledWith([]);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("cursor does not go below 0", () => {
|
|
64
|
+
const { list } = make();
|
|
65
|
+
list.handleInput("k");
|
|
66
|
+
list.handleInput("k");
|
|
67
|
+
expect(list.getCursor()).toBe(0);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("cursor does not go past last item", () => {
|
|
71
|
+
const { list } = make(["A", "B"]);
|
|
72
|
+
list.handleInput("j");
|
|
73
|
+
list.handleInput("j");
|
|
74
|
+
list.handleInput("j");
|
|
75
|
+
expect(list.getCursor()).toBe(1);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("toggling twice returns item to unchecked", () => {
|
|
79
|
+
const { list, onConfirm } = make();
|
|
80
|
+
list.handleInput(" ");
|
|
81
|
+
list.handleInput(" ");
|
|
82
|
+
list.handleInput("\r");
|
|
83
|
+
expect(onConfirm).toHaveBeenCalledWith([]);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("selected order follows original option order, not toggle order", () => {
|
|
87
|
+
const { list, onConfirm } = make(["A", "B", "C", "D"]);
|
|
88
|
+
// toggle D first, then A, then C
|
|
89
|
+
list.handleInput("j");
|
|
90
|
+
list.handleInput("j");
|
|
91
|
+
list.handleInput("j");
|
|
92
|
+
list.handleInput(" "); // D
|
|
93
|
+
list.handleInput("k");
|
|
94
|
+
list.handleInput("k");
|
|
95
|
+
list.handleInput("k");
|
|
96
|
+
list.handleInput(" "); // A
|
|
97
|
+
list.handleInput("j");
|
|
98
|
+
list.handleInput("j");
|
|
99
|
+
list.handleInput(" "); // C
|
|
100
|
+
list.handleInput("\r");
|
|
101
|
+
expect(onConfirm).toHaveBeenCalledWith(["A", "C", "D"]);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe("render", () => {
|
|
106
|
+
it("includes footer hint", () => {
|
|
107
|
+
const { list } = make();
|
|
108
|
+
const lines = list.render(80);
|
|
109
|
+
expect(lines.some((l) => l.includes("space toggle"))).toBe(true);
|
|
110
|
+
expect(lines.some((l) => l.includes("enter confirm"))).toBe(true);
|
|
111
|
+
expect(lines.some((l) => l.includes("esc cancel"))).toBe(true);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("renders [ ] for unchecked and [x] for checked items", () => {
|
|
115
|
+
const { list } = make();
|
|
116
|
+
list.handleInput(" "); // check A
|
|
117
|
+
const lines = list.render(80);
|
|
118
|
+
expect(lines.some((l) => l.includes("[x] A"))).toBe(true);
|
|
119
|
+
expect(lines.some((l) => l.includes("[ ] B"))).toBe(true);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("renders cursor marker on current item", () => {
|
|
123
|
+
const { list } = make();
|
|
124
|
+
list.handleInput("j"); // cursor → 1
|
|
125
|
+
const lines = list.render(80);
|
|
126
|
+
// Cursor line should start with "▸ " somewhere
|
|
127
|
+
expect(lines.some((l) => l.startsWith("▸ ") && l.includes("B"))).toBe(true);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("includes title and message when provided", () => {
|
|
131
|
+
const { list } = make(["A", "B"], "Pick one or more", "Some context");
|
|
132
|
+
const lines = list.render(80);
|
|
133
|
+
expect(lines.some((l) => l.includes("Pick one or more"))).toBe(true);
|
|
134
|
+
expect(lines.some((l) => l.includes("Some context"))).toBe(true);
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
});
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Repo-level invariant: the bridge MUST NOT call pi's session-replacement
|
|
3
|
+
* APIs (`pi.newSession(...)`, `ctx.fork(...)`, `ctx.switchSession(...)`)
|
|
4
|
+
* from any code under `packages/extension/src/`.
|
|
5
|
+
*
|
|
6
|
+
* Rationale: pi 0.69.0+ invalidates captured pre-replacement `pi`/`ctx`/
|
|
7
|
+
* session-bound objects on next access after these calls. The bridge
|
|
8
|
+
* holds long-lived caches (`cachedCtx`, `cachedModelRegistry`,
|
|
9
|
+
* `cachedHasUI` in `bridge.ts`; `modelRegistry` in `provider-register.ts`)
|
|
10
|
+
* that depend on pi being the ONLY originator of session replacement, so
|
|
11
|
+
* we can re-capture inside the resulting `session_start` handler keyed on
|
|
12
|
+
* `event.reason ∈ {"new","fork","resume"}`.
|
|
13
|
+
*
|
|
14
|
+
* If this test fails: do NOT add the call. Either drive the user-facing
|
|
15
|
+
* action through the dashboard server (which prompts the user, who
|
|
16
|
+
* triggers replacement via pi's UI), or wrap your post-switch work in
|
|
17
|
+
* the `withSession` callback that pi 0.69+ exposes on each replacement
|
|
18
|
+
* API and capture the freshly-emitted ReplacedSessionContext there.
|
|
19
|
+
*
|
|
20
|
+
* See change: pi-zero-seventy-compat.
|
|
21
|
+
*/
|
|
22
|
+
import { describe, it, expect } from "vitest";
|
|
23
|
+
import fs from "node:fs/promises";
|
|
24
|
+
import path from "node:path";
|
|
25
|
+
import url from "node:url";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Each pattern matches `<receiver>.<method>(` allowing for whitespace and
|
|
29
|
+
* tolerating common variations like `await pi.newSession(...)`. Prefixed
|
|
30
|
+
* with a non-word boundary so we don't flag method names embedded in
|
|
31
|
+
* longer identifiers.
|
|
32
|
+
*/
|
|
33
|
+
const PATTERNS: ReadonlyArray<{ name: string; re: RegExp }> = [
|
|
34
|
+
{ name: "pi.newSession", re: /(?:^|[^.\w])pi\.newSession\s*\(/ },
|
|
35
|
+
{ name: "ctx.fork", re: /(?:^|[^.\w])ctx\.fork\s*\(/ },
|
|
36
|
+
{ name: "ctx.switchSession", re: /(?:^|[^.\w])ctx\.switchSession\s*\(/ },
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Per-line opt-out marker. Use only for documented exceptions (e.g. a
|
|
41
|
+
* future migration cell that intentionally drives a replacement and
|
|
42
|
+
* fully re-binds via `withSession`):
|
|
43
|
+
* await pi.newSession({ withSession: ... }); // ban:session-replacement-ok
|
|
44
|
+
*/
|
|
45
|
+
const OPT_OUT_MARKER = "ban:session-replacement-ok";
|
|
46
|
+
|
|
47
|
+
async function* walk(dir: string): AsyncGenerator<string> {
|
|
48
|
+
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
49
|
+
for (const entry of entries) {
|
|
50
|
+
const full = path.join(dir, entry.name);
|
|
51
|
+
if (entry.isDirectory()) {
|
|
52
|
+
if (entry.name === "node_modules" || entry.name === "dist" || entry.name === "__tests__") continue;
|
|
53
|
+
yield* walk(full);
|
|
54
|
+
} else if (entry.isFile() && /\.(ts|tsx|mts|cts)$/.test(entry.name)) {
|
|
55
|
+
yield full;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
describe("no session-replacement API calls in packages/extension/src/", () => {
|
|
61
|
+
it("bridge code never invokes pi.newSession / ctx.fork / ctx.switchSession", async () => {
|
|
62
|
+
const here = path.dirname(url.fileURLToPath(import.meta.url));
|
|
63
|
+
const srcDir = path.resolve(here, "..");
|
|
64
|
+
const repoRoot = path.resolve(here, "..", "..", "..", "..");
|
|
65
|
+
|
|
66
|
+
const violations: Array<{ file: string; line: number; pattern: string; text: string }> = [];
|
|
67
|
+
|
|
68
|
+
for await (const file of walk(srcDir)) {
|
|
69
|
+
const content = await fs.readFile(file, "utf-8");
|
|
70
|
+
const lines = content.split(/\r?\n/);
|
|
71
|
+
lines.forEach((line, idx) => {
|
|
72
|
+
if (line.includes(OPT_OUT_MARKER)) return;
|
|
73
|
+
for (const { name, re } of PATTERNS) {
|
|
74
|
+
if (re.test(line)) {
|
|
75
|
+
violations.push({
|
|
76
|
+
file: path.relative(repoRoot, file),
|
|
77
|
+
line: idx + 1,
|
|
78
|
+
pattern: name,
|
|
79
|
+
text: line.trim(),
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (violations.length > 0) {
|
|
87
|
+
const msg =
|
|
88
|
+
`Bridge code MUST NOT call pi session-replacement APIs.\n` +
|
|
89
|
+
`pi 0.69.0+ invalidates captured pre-replacement pi/ctx after these calls;\n` +
|
|
90
|
+
`the bridge relies on pi being the sole originator of replacement so it can\n` +
|
|
91
|
+
`re-capture state inside the resulting session_start handler.\n\n` +
|
|
92
|
+
`Offenders (${violations.length}):\n` +
|
|
93
|
+
violations
|
|
94
|
+
.map((v) => ` ${v.file}:${v.line} [${v.pattern}] ${v.text}`)
|
|
95
|
+
.join("\n");
|
|
96
|
+
expect(violations, msg).toEqual([]);
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
});
|
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
* register ask_user. Runtime registration bypasses detectExtensionConflicts.
|
|
7
7
|
*/
|
|
8
8
|
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
9
|
-
import { Type } from "
|
|
9
|
+
import { Type } from "typebox";
|
|
10
|
+
import { polyfillMultiselect } from "./multiselect-polyfill.js";
|
|
10
11
|
|
|
11
12
|
// ──────────────────────────────────────────────────────────────────────────
|
|
12
13
|
// Single-question schema arms (reused inside the batch arm's questions array)
|
|
@@ -121,7 +122,7 @@ export function registerAskUserTool(pi: ExtensionAPI): void {
|
|
|
121
122
|
name: "ask_user",
|
|
122
123
|
label: "Ask User",
|
|
123
124
|
description:
|
|
124
|
-
"Ask the user a question interactively. Use this when you need clarification, confirmation, or a choice from the user before proceeding.",
|
|
125
|
+
"Ask the user a question interactively. Use this when you need clarification, confirmation, or a choice from the user before proceeding. UI provides a Select all toggle; do not add one.",
|
|
125
126
|
promptSnippet:
|
|
126
127
|
"Ask the user interactive questions (confirm, select, multiselect, input, or batch — multiple related questions at once)",
|
|
127
128
|
promptGuidelines: [
|
|
@@ -254,7 +255,7 @@ export function registerAskUserTool(pi: ExtensionAPI): void {
|
|
|
254
255
|
`ask_user batch: sub-question method "multiselect" requires a non-empty "options" array.`,
|
|
255
256
|
);
|
|
256
257
|
}
|
|
257
|
-
answer = await (ctx
|
|
258
|
+
answer = await polyfillMultiselect(ctx, subTitle, opts, subMsg);
|
|
258
259
|
break;
|
|
259
260
|
}
|
|
260
261
|
case "input":
|
|
@@ -336,7 +337,7 @@ export function registerAskUserTool(pi: ExtensionAPI): void {
|
|
|
336
337
|
result = await ctx.ui.select(title, options, msgOpts);
|
|
337
338
|
break;
|
|
338
339
|
case "multiselect":
|
|
339
|
-
result = await (ctx
|
|
340
|
+
result = await polyfillMultiselect(ctx, title, options, msgOpts);
|
|
340
341
|
break;
|
|
341
342
|
case "input":
|
|
342
343
|
result = await ctx.ui.input(title, params.placeholder, msgOpts);
|
|
@@ -186,6 +186,61 @@ function initBridge(pi: ExtensionAPI) {
|
|
|
186
186
|
let lastThinkingLevel: string | undefined;
|
|
187
187
|
let promptBus: PromptBus | undefined;
|
|
188
188
|
|
|
189
|
+
// ── Per-message entry id tracking (for fix-per-message-fork) ──
|
|
190
|
+
// Pi 0.69+ awaits extension handlers BEFORE sessionManager.appendMessage runs,
|
|
191
|
+
// which means getLeafId() at emit time returns the previous leaf, not the
|
|
192
|
+
// entry id of the message currently being emitted. We solve this by:
|
|
193
|
+
// 1. Wrapping ctx.sessionManager.appendMessage at session_start to stamp
|
|
194
|
+
// the just-generated entry id onto the message object reference.
|
|
195
|
+
// 2. Deferring the message_end enrichment-and-send via setTimeout(0) so
|
|
196
|
+
// the awaited dispatcher unwinds and appendMessage runs in between.
|
|
197
|
+
// 3. Stamping a nonce on message_start/message_end events; emitting an
|
|
198
|
+
// entry_persisted event after appendMessage so the client reducer can
|
|
199
|
+
// back-fill user-message ChatMessage.entryId.
|
|
200
|
+
// See change: fix-per-message-fork.
|
|
201
|
+
const idByMessage = new WeakMap<object, string>();
|
|
202
|
+
const pendingNonces = new WeakMap<object, string>();
|
|
203
|
+
let nonceCounter = 0;
|
|
204
|
+
const nextNonce = (): string => `n-${++nonceCounter}-${Date.now()}`;
|
|
205
|
+
let appendMessageWrapped = false;
|
|
206
|
+
let lastWrappedSm: any = null;
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Wrap ctx.sessionManager.appendMessage once per session so that when pi
|
|
210
|
+
* generates an entry id we capture it in the WeakMap and emit
|
|
211
|
+
* entry_persisted to the server.
|
|
212
|
+
*/
|
|
213
|
+
function wrapAppendMessageForCtx(ctx: any): void {
|
|
214
|
+
const sm = ctx?.sessionManager;
|
|
215
|
+
if (!sm || typeof sm.appendMessage !== "function") return;
|
|
216
|
+
// Re-wrap when sessionManager identity changes (session replacement).
|
|
217
|
+
if (sm === lastWrappedSm && appendMessageWrapped) return;
|
|
218
|
+
const original = sm.appendMessage.bind(sm);
|
|
219
|
+
sm.appendMessage = (msg: any, ...rest: any[]) => {
|
|
220
|
+
const result = original(msg, ...rest);
|
|
221
|
+
try {
|
|
222
|
+
if (msg && typeof msg === "object" && typeof msg.id === "string") {
|
|
223
|
+
idByMessage.set(msg as object, msg.id);
|
|
224
|
+
const nonce = pendingNonces.get(msg as object);
|
|
225
|
+
if (nonce && sessionReady && isActive()) {
|
|
226
|
+
const ev = {
|
|
227
|
+
type: "entry_persisted",
|
|
228
|
+
entryId: msg.id,
|
|
229
|
+
nonce,
|
|
230
|
+
};
|
|
231
|
+
connection.send(mapEventToProtocol(sessionId, ev));
|
|
232
|
+
pendingNonces.delete(msg as object);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
} catch (err) {
|
|
236
|
+
console.error("[dashboard] entry_persisted emit failed:", err);
|
|
237
|
+
}
|
|
238
|
+
return result;
|
|
239
|
+
};
|
|
240
|
+
lastWrappedSm = sm;
|
|
241
|
+
appendMessageWrapped = true;
|
|
242
|
+
}
|
|
243
|
+
|
|
189
244
|
/** Wrap a callback so errors log instead of crashing the host pi agent. */
|
|
190
245
|
function safe<T extends (...args: any[]) => any>(fn: T): T {
|
|
191
246
|
return ((...args: any[]) => {
|
|
@@ -612,30 +667,53 @@ function initBridge(pi: ExtensionAPI) {
|
|
|
612
667
|
}
|
|
613
668
|
}
|
|
614
669
|
|
|
615
|
-
// For message_start
|
|
670
|
+
// For message_start: stamp a nonce on the event so the client reducer
|
|
671
|
+
// can correlate a later entry_persisted back-fill with this bubble.
|
|
672
|
+
// We do NOT attach entryId here — the message has no id yet on pi
|
|
673
|
+
// 0.69+ (persistence is deferred to message_end). See change:
|
|
674
|
+
// fix-per-message-fork.
|
|
616
675
|
if (eventType === "message_start") {
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
676
|
+
wrapAppendMessageForCtx(ctx);
|
|
677
|
+
const messageRef = (event as any).message;
|
|
678
|
+
if (messageRef && typeof messageRef === "object") {
|
|
679
|
+
const nonce = nextNonce();
|
|
680
|
+
pendingNonces.set(messageRef as object, nonce);
|
|
681
|
+
const enriched = { ...event, nonce };
|
|
620
682
|
const msg = mapEventToProtocol(sessionId, enriched);
|
|
621
683
|
connection.send(msg);
|
|
622
684
|
return;
|
|
623
685
|
}
|
|
624
686
|
}
|
|
625
687
|
|
|
626
|
-
// For message_end
|
|
627
|
-
//
|
|
628
|
-
//
|
|
629
|
-
//
|
|
688
|
+
// For message_end: defer the SEND via setTimeout(0). Pi 0.69+ runs
|
|
689
|
+
// sessionManager.appendMessage AFTER the awaited extension dispatcher
|
|
690
|
+
// returns, so a queueMicrotask deferral is no longer enough. By the
|
|
691
|
+
// time the macrotask fires, appendMessage has run, pi has mutated
|
|
692
|
+
// event.message.id in place, and the wrapped appendMessage above has
|
|
693
|
+
// populated idByMessage. We also stamp a nonce so a downstream
|
|
694
|
+
// entry_persisted can correlate (covers user message_end where the
|
|
695
|
+
// earlier message_start nonce is what the reducer is waiting on).
|
|
696
|
+
// See change: fix-per-message-fork.
|
|
630
697
|
if (eventType === "message_end") {
|
|
631
|
-
|
|
632
|
-
const
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
698
|
+
wrapAppendMessageForCtx(ctx);
|
|
699
|
+
const messageRef = (event as any).message;
|
|
700
|
+
const nonce = messageRef && typeof messageRef === "object"
|
|
701
|
+
? (pendingNonces.get(messageRef as object) ?? nextNonce())
|
|
702
|
+
: nextNonce();
|
|
703
|
+
if (messageRef && typeof messageRef === "object" && !pendingNonces.has(messageRef as object)) {
|
|
704
|
+
pendingNonces.set(messageRef as object, nonce);
|
|
638
705
|
}
|
|
706
|
+
setTimeout(() => {
|
|
707
|
+
if (!isActive() || !sessionReady) return;
|
|
708
|
+
const entryId =
|
|
709
|
+
(messageRef && typeof messageRef === "object" && typeof messageRef.id === "string" ? messageRef.id : undefined)
|
|
710
|
+
?? (messageRef ? idByMessage.get(messageRef as object) : undefined)
|
|
711
|
+
?? ctx.sessionManager?.getLeafId?.();
|
|
712
|
+
const enriched = { ...event, entryId, nonce };
|
|
713
|
+
const protoMsg = mapEventToProtocol(sessionId, enriched);
|
|
714
|
+
connection.send(protoMsg);
|
|
715
|
+
}, 0);
|
|
716
|
+
return;
|
|
639
717
|
}
|
|
640
718
|
|
|
641
719
|
const msg = mapEventToProtocol(sessionId, event);
|
|
@@ -694,6 +772,15 @@ function initBridge(pi: ExtensionAPI) {
|
|
|
694
772
|
cachedCtx = ctx;
|
|
695
773
|
sessionId = newSessionId;
|
|
696
774
|
|
|
775
|
+
// Wrap sessionManager.appendMessage so that future message_end events can
|
|
776
|
+
// recover the just-generated entry id, even when their setTimeout(0)
|
|
777
|
+
// fires before pi has finished mutating event.message in place. The
|
|
778
|
+
// helper is idempotent and re-wraps on session replacement.
|
|
779
|
+
// See change: fix-per-message-fork.
|
|
780
|
+
appendMessageWrapped = false;
|
|
781
|
+
lastWrappedSm = null;
|
|
782
|
+
wrapAppendMessageForCtx(ctx);
|
|
783
|
+
|
|
697
784
|
// Register ask_user at runtime (not at load time) to avoid static
|
|
698
785
|
// tool-name conflicts with other extensions like pi-flows.
|
|
699
786
|
registerAskUserTool(pi);
|