@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.
Files changed (35) hide show
  1. package/AGENTS.md +30 -8
  2. package/README.md +386 -494
  3. package/docs/architecture.md +63 -9
  4. package/package.json +8 -5
  5. package/packages/extension/package.json +6 -4
  6. package/packages/extension/src/__tests__/ask-user-tool.test.ts +40 -8
  7. package/packages/extension/src/__tests__/bridge-entry-id-pi-070.test.ts +174 -0
  8. package/packages/extension/src/__tests__/event-forwarder.test.ts +30 -0
  9. package/packages/extension/src/__tests__/fork-entryid-timing.test.ts +64 -76
  10. package/packages/extension/src/__tests__/multiselect-list.test.ts +137 -0
  11. package/packages/extension/src/__tests__/no-session-replacement-calls.test.ts +99 -0
  12. package/packages/extension/src/ask-user-tool.ts +5 -4
  13. package/packages/extension/src/bridge.ts +102 -15
  14. package/packages/extension/src/multiselect-list.ts +146 -0
  15. package/packages/extension/src/multiselect-polyfill.ts +43 -0
  16. package/packages/extension/src/server-launcher.ts +15 -3
  17. package/packages/server/package.json +5 -5
  18. package/packages/server/src/__tests__/fixtures/fork-jsonl-roundtrip.jsonl +8 -0
  19. package/packages/server/src/__tests__/fork-jsonl-roundtrip.test.ts +49 -0
  20. package/packages/server/src/__tests__/pi-version-skew.test.ts +72 -0
  21. package/packages/server/src/__tests__/restart-helper.test.ts +34 -6
  22. package/packages/server/src/cli.ts +56 -9
  23. package/packages/server/src/pi-version-skew.ts +12 -1
  24. package/packages/server/src/restart-helper.ts +13 -2
  25. package/packages/shared/package.json +1 -1
  26. package/packages/shared/src/__tests__/no-hardcoded-node-modules-paths.test.ts +176 -0
  27. package/packages/shared/src/__tests__/no-raw-node-import.test.ts +146 -0
  28. package/packages/shared/src/__tests__/node-spawn.test.ts +210 -0
  29. package/packages/shared/src/__tests__/resolve-tool-cli.test.ts +105 -0
  30. package/packages/shared/src/__tests__/state-replay-entry-id.test.ts +69 -0
  31. package/packages/shared/src/platform/index.ts +1 -0
  32. package/packages/shared/src/platform/node-spawn.ts +154 -0
  33. package/packages/shared/src/protocol.ts +23 -0
  34. package/packages/shared/src/state-replay.ts +9 -0
  35. package/packages/shared/src/tool-registry/definitions.ts +92 -0
@@ -1,100 +1,88 @@
1
1
  /**
2
- * Tests for fork entryId timing fix.
2
+ * Tests for bridge entryId stamping on message_end events.
3
3
  *
4
- * Verifies that the bridge's message_end entryId enrichment captures the
5
- * correct leaf ID (after pi core persists the entry), not the stale one
6
- * (before appendMessage runs).
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 bug: pi core emits message_end via _emit() BEFORE calling
9
- * sessionManager.appendMessage(), so getLeafId() returns the previous leaf.
10
- * The fix: the bridge defers getLeafId() for message_end using queueMicrotask,
11
- * allowing appendMessage to run first.
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, vi } from "vitest";
19
+ import { describe, it, expect } from "vitest";
14
20
 
15
- /**
16
- * Simulates the pi core + bridge interaction for entryId enrichment.
17
- *
18
- * Pi core's _processAgentEvent does:
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
- let capturedEntryId: string | undefined;
34
-
35
- // Simulate the bridge handler (with the fix: defers via queueMicrotask)
36
- const bridgeHandler = async () => {
37
- // This is what the fixed bridge does for message_end:
38
- await new Promise<void>(resolve => queueMicrotask(resolve));
39
- capturedEntryId = sessionManager.getLeafId();
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 pi core's _processAgentEvent:
43
- // 1. _emit calls handler (async, NOT awaited)
44
- const handlerPromise = bridgeHandler();
45
- // 2. appendMessage runs synchronously, updating leafId
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(capturedEntryId).toBe("assistant-entry-101");
54
+ expect(captured).toBe("assistant-entry-101");
52
55
  });
53
56
 
54
- it("immediate getLeafId() would capture the stale entry ID (demonstrates the bug)", async () => {
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
- const sessionManager = {
57
- getLeafId: () => leafId,
58
- };
61
+ let captured: string | undefined;
59
62
 
60
- let capturedEntryId: string | undefined;
61
-
62
- // Simulate the OLD (buggy) bridge handler: reads getLeafId() immediately
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
- // Simulate pi core's _processAgentEvent:
69
- const handlerPromise = buggyBridgeHandler();
70
- leafId = "assistant-entry-101"; // too late — handler already read it
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
- // Bug: captures the stale leaf, not the assistant's entry
75
- expect(capturedEntryId).toBe("user-entry-100");
73
+ expect(captured).toBe("user-entry-100");
74
+ expect(captured).not.toBe("assistant-entry-101");
76
75
  });
77
76
 
78
- it("message_start should still capture entryId immediately (no deferral)", async () => {
79
- let leafId = "previous-assistant-entry-99";
80
- const sessionManager = {
81
- getLeafId: () => leafId,
82
- };
83
-
84
- let capturedEntryId: string | undefined;
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
- // message_start should capture the leaf BEFORE the user entry is written
98
- expect(capturedEntryId).toBe("previous-assistant-entry-99");
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 "@sinclair/typebox";
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.ui as any).multiselect(subTitle, opts, subMsg);
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.ui as any).multiselect(title, options, msgOpts);
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, enrich with entryId immediately (current leaf)
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
- const entryId = ctx.sessionManager?.getLeafId?.();
618
- if (entryId) {
619
- const enriched = { ...event, entryId };
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, defer getLeafId() so it runs after pi core persists the entry.
627
- // Pi core calls _emit (which invokes this handler) BEFORE appendMessage (which updates leafId).
628
- // Since _emit doesn't await async handlers, yielding via queueMicrotask lets appendMessage
629
- // run first, so getLeafId() returns the correct entry ID for the just-persisted message.
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
- await new Promise<void>(resolve => queueMicrotask(resolve));
632
- const entryId = ctx.sessionManager?.getLeafId?.();
633
- if (entryId) {
634
- const enriched = { ...event, entryId };
635
- const msg = mapEventToProtocol(sessionId, enriched);
636
- connection.send(msg);
637
- return;
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);