pi-mega-compact 0.8.0 → 0.8.2

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.
@@ -17,10 +17,16 @@ export async function launchDashboardServer(stateDir) {
17
17
  // upgrade instead of reuse it.
18
18
  let SERVER_VERSION = "0.0.0";
19
19
  try {
20
- // dashboard-server.js lives at <pkg>/dist/extensions/, so package.json is
21
- // two levels up. Guard each candidate so a dev-checkout layout still works.
20
+ // Since v0.7.9 (8821ef3) dashboard-server.js lives at
21
+ // <pkg>/dist/extensions/dashboard-server/, so package.json is THREE levels
22
+ // up. Keep the two- and one-level-up candidates as fallbacks for flatter
23
+ // dev-checkout layouts. Guard each candidate so a missing file is skipped.
22
24
  const here = dirname(fileURLToPath(import.meta.url));
23
- const candidates = [join(here, "..", "..", "package.json"), join(here, "..", "package.json")];
25
+ const candidates = [
26
+ join(here, "..", "..", "..", "package.json"),
27
+ join(here, "..", "..", "package.json"),
28
+ join(here, "..", "package.json"),
29
+ ];
24
30
  for (const p of candidates) {
25
31
  if (!existsSync(p))
26
32
  continue;
@@ -490,6 +496,18 @@ export async function launchDashboardServer(stateDir) {
490
496
  log("server running", { url });
491
497
  // eslint-disable-next-line no-console
492
498
  console.log(`[mega-compact] dashboard server running: ${url}`);
499
+ // v0.8.2: also bind the IPv6 loopback (::1). On many systems `localhost`
500
+ // resolves to ::1 first (see /etc/hosts), so an IPv4-only bind makes the
501
+ // browser hit ::1:port and get connection refused. PREVENT-PI-004
502
+ // (loopback-only) means BOTH 127.0.0.1 and ::1. Non-fatal: IPv4-only
503
+ // hosts or a ::1 already in use just skip the mirror.
504
+ let v6;
505
+ const v4Handler = server.listeners("request")[0];
506
+ if (v4Handler) {
507
+ v6 = createServer((r, s) => v4Handler.call(server, r, s));
508
+ v6.on("error", (e) => log("ipv6 loopback bind skipped", { port, code: e.code, message: e.message }));
509
+ v6.listen(port, "::1", () => log("ipv6 loopback bound", { port })); // guardrails-allow PREVENT-PI-004: IPv6 loopback (::1) mirror of the localhost dashboard server
510
+ }
493
511
  // Write port.pid
494
512
  try {
495
513
  writeFileSync(portFile, JSON.stringify({ port, pid: process.pid }));
@@ -504,6 +522,10 @@ export async function launchDashboardServer(stateDir) {
504
522
  }
505
523
  catch { /* already gone */ }
506
524
  server.close();
525
+ try {
526
+ v6?.close();
527
+ }
528
+ catch { /* not bound */ }
507
529
  process.exit(0);
508
530
  };
509
531
  process.on("SIGTERM", cleanup);
@@ -18,7 +18,7 @@
18
18
  * - mega-runtime.ts shared live state (MegaRuntime) + widget + model capture
19
19
  * - mega-pipeline.ts runCompact (Trident+persist) + doRecall (Layer 5)
20
20
  * - mega-commands.ts data/inspection slash commands
21
- * - mega-game-cmds.ts /mega-game toggle + theme + TUI display mode
21
+ * - mega-game-cmds.ts /mega-compact-settings (+ /mega-game alias) toggle + theme + TUI display mode
22
22
  * - mega-dashboard-cmds.ts localhost dashboard server lifecycle commands
23
23
  * - mega-events.ts pi lifecycle event handlers
24
24
  *
@@ -50,15 +50,17 @@ export function registerContextHandler(pi, runtime, config) {
50
50
  // Legacy: MEGACOMPACT_LEGACY_DURABLE_TRIM=true restores the v0.4.28 ctx.compact
51
51
  // path (kept one release as rollback).
52
52
  pi.on("context", async (event, ctx) => {
53
- if (!config.auto)
54
- return;
55
53
  const usage = ctx.getContextUsage();
56
54
  const pct = usage?.percent;
57
- // Always track context for the dashboard, even if we return early below.
55
+ // Always track context for the dashboard/widget, even when auto is off.
56
+ // (v0.8 regression: !config.auto gate sat above this, leaving ctx stats
57
+ // null -> widget '?% / ?/?' when auto disabled. Track first, THEN gate.)
58
58
  runtime.lastCtxTokens = usage?.tokens ?? null;
59
59
  runtime.lastCtxPercent = pct ?? null;
60
60
  runtime.lastCtxWindow = usage?.contextWindow ?? 0;
61
61
  runtime.snapshot(ctx);
62
+ if (!config.auto)
63
+ return;
62
64
  const messages = event.messages;
63
65
  const view = runtime.engineView(messages);
64
66
  const currentTokens = usage?.tokens ??
@@ -1,5 +1,5 @@
1
1
  /**
2
- * mega-game-cmds.ts — /mega-game slash command (S30).
2
+ * mega-game-cmds.ts — /mega-compact-settings slash command (S30; renamed in v0.8.x).
3
3
  *
4
4
  * Backs the game-mode toggle + theme picker + TUI display mode. All state is
5
5
  * the global `game_state` SQLite row (src/store/sqlite/game-state.ts) — local
@@ -7,100 +7,216 @@
7
7
  * command touches no fetch/http). All SQL is parameterized (PREVENT-002) and
8
8
  * lives in the src/ submodule, not here.
9
9
  *
10
+ * The primary command is /mega-compact-settings. /mega-game is retained as a
11
+ * backward-compat alias (same handler) so existing muscle memory + docs keep
12
+ * working.
13
+ *
10
14
  * Usage:
11
- * /mega-game print current state
12
- * /mega-game on enable game mode (scoring + level-up + MEGA CACHE)
13
- * /mega-game off disable game mode
14
- * /mega-game theme list available themes
15
- * /mega-game theme <id> set theme by id
16
- * /mega-game theme next cycle to next theme
17
- * /mega-game tui full full TUI widget (bars, stats, flair)
18
- * /mega-game tui minimal one-line TUI widget (level + cache %)
19
- * /mega-game achievements list unlocked achievements
15
+ * /mega-compact-settings print current state
16
+ * /mega-compact-settings on enable game mode (scoring + level-up + MEGA CACHE)
17
+ * /mega-compact-settings off disable game mode
18
+ * /mega-compact-settings theme list available themes
19
+ * /mega-compact-settings theme <id> set theme by id
20
+ * /mega-compact-settings theme next cycle to next theme
21
+ * /mega-compact-settings tui full full TUI widget (bars, stats, flair)
22
+ * /mega-compact-settings tui minimal one-line TUI widget (level + cache %)
23
+ * /mega-compact-settings achievements list unlocked achievements
20
24
  */
21
25
  import { getGameState, setGameState, } from "../src/store/sqlite.js";
22
26
  import { listAchievements } from "../src/store/sqlite/game-achievements.js";
23
27
  import { THEMES, THEME_IDS, getTheme, isValidTheme, nextTheme, DEFAULT_THEME } from "../src/config/themes.js";
28
+ /** Notify/usage tag + command name. Primary surface is /mega-compact-settings. */
29
+ const TAG = "mega-compact-settings";
24
30
  /** Format the current state as a human-readable status line set. */
25
31
  function fmtState(s) {
26
32
  return [
27
- `[mega-game] game mode: ${s.game_mode_on ? "ON" : "off"}`,
33
+ `[${TAG}] game mode: ${s.game_mode_on ? "ON" : "off"}`,
28
34
  ` theme: ${s.theme}${s.theme === DEFAULT_THEME ? " (default)" : ""}`,
29
35
  ` tui: ${s.tui_display_mode}`,
30
36
  ];
31
37
  }
32
- /** Register the /mega-game command. */
33
- export function registerGameCommands(pi, runtime) {
34
- pi.registerCommand("mega-game", {
35
- description: "Game mode toggle + theme picker + TUI display mode. Usage: /mega-game [on|off|theme [id|next]|tui [full|minimal]|achievements]",
36
- handler: async (args, ctx) => {
37
- runtime.bindRepo(ctx.cwd);
38
- const stateDir = runtime.currentStateDir;
39
- const parts = args.trim().split(/\s+/).filter(Boolean);
40
- // /mega-game → print current state.
41
- if (parts.length === 0) {
42
- const s = getGameState(stateDir);
43
- for (const line of fmtState(s))
44
- ctx.ui.notify(line);
38
+ /** Shared handler for /mega-compact-settings (primary) + /mega-game (alias). */
39
+ async function handleSettings(args, ctx, runtime) {
40
+ runtime.bindRepo(ctx.cwd);
41
+ const stateDir = runtime.currentStateDir;
42
+ const parts = args.trim().split(/\s+/).filter(Boolean);
43
+ // bare → interactive in-app menu (ctx.ui.select picker). Falls back to a
44
+ // static status print when there's no interactive UI (RPC/print mode, or a
45
+ // test harness stubbing only notify). CLI subcommands below still work for
46
+ // power users + scripts.
47
+ if (parts.length === 0) {
48
+ if (typeof ctx.ui.select === "function") {
49
+ try {
50
+ await runInteractiveMenu(ctx, runtime, stateDir);
45
51
  return;
46
52
  }
47
- const sub = parts[0];
48
- // /mega-game achievements terse list of unlocked (hidden only once unlocked).
49
- if (sub === "achievements") {
50
- const rows = listAchievements(stateDir).filter((r) => r.unlocked_at != null);
51
- ctx.ui.notify(`[mega-game] achievements unlocked (${rows.length}/9):`);
52
- for (const r of rows) {
53
- ctx.ui.notify(` ${r.icon ?? ""} ${r.title}`);
54
- }
55
- return;
53
+ catch {
54
+ // select threw (non-interactive impl) fall through to status print
56
55
  }
57
- // /mega-game on|off
58
- if (sub === "on" || sub === "off") {
59
- const s = setGameState({ game_mode_on: sub === "on" }, stateDir);
60
- runtime.bumpGameState();
61
- ctx.ui.notify(`[mega-game] game mode ${s.game_mode_on ? "ON" : "off"}`);
62
- return;
63
- }
64
- // /mega-game theme [id|next]
65
- if (sub === "theme") {
66
- if (parts.length === 1) {
67
- // list themes
68
- ctx.ui.notify("[mega-game] themes:");
69
- for (const t of THEMES) {
70
- ctx.ui.notify(` ${t.id.padEnd(14)} ${t.label}`);
71
- }
72
- return;
73
- }
74
- const arg = parts[1];
75
- let id;
76
- if (arg === "next") {
77
- id = nextTheme(getGameState(stateDir).theme);
78
- }
79
- else if (isValidTheme(arg)) {
80
- id = arg;
81
- }
82
- else {
83
- ctx.ui.notify(`[mega-game] unknown theme "${arg}". Valid: ${THEME_IDS.join(", ")}`);
84
- return;
85
- }
86
- const s = setGameState({ theme: id }, stateDir);
87
- runtime.bumpGameState();
88
- ctx.ui.notify(`[mega-game] theme → ${s.theme} (${getTheme(s.theme)?.label ?? ""})`);
89
- return;
90
- }
91
- // /mega-game tui full|minimal
92
- if (sub === "tui") {
93
- const arg = parts[1];
94
- if (arg !== "full" && arg !== "minimal") {
95
- ctx.ui.notify(`[mega-game] usage: /mega-game tui full|minimal`);
96
- return;
97
- }
98
- const s = setGameState({ tui_display_mode: arg }, stateDir);
99
- runtime.bumpGameState();
100
- ctx.ui.notify(`[mega-game] tui → ${s.tui_display_mode}`);
101
- return;
56
+ }
57
+ const s = getGameState(stateDir);
58
+ for (const line of fmtState(s))
59
+ ctx.ui.notify(line);
60
+ return;
61
+ }
62
+ const sub = parts[0];
63
+ // achievements terse list of unlocked (hidden only once unlocked).
64
+ if (sub === "achievements") {
65
+ const rows = listAchievements(stateDir).filter((r) => r.unlocked_at != null);
66
+ ctx.ui.notify(`[${TAG}] achievements unlocked (${rows.length}/9):`);
67
+ for (const r of rows) {
68
+ ctx.ui.notify(` ${r.icon ?? ""} ${r.title}`);
69
+ }
70
+ return;
71
+ }
72
+ // on|off
73
+ if (sub === "on" || sub === "off") {
74
+ const s = setGameState({ game_mode_on: sub === "on" }, stateDir);
75
+ runtime.bumpGameState();
76
+ ctx.ui.notify(`[${TAG}] game mode ${s.game_mode_on ? "ON" : "off"}`);
77
+ return;
78
+ }
79
+ // theme [id|next]
80
+ if (sub === "theme") {
81
+ if (parts.length === 1) {
82
+ // list themes
83
+ ctx.ui.notify(`[${TAG}] themes:`);
84
+ for (const t of THEMES) {
85
+ ctx.ui.notify(` ${t.id.padEnd(14)} ${t.label}`);
102
86
  }
103
- ctx.ui.notify(`[mega-game] usage: /mega-game [on|off|theme [id|next]|tui [full|minimal]|achievements]`);
104
- },
87
+ return;
88
+ }
89
+ const arg = parts[1];
90
+ let id;
91
+ if (arg === "next") {
92
+ id = nextTheme(getGameState(stateDir).theme);
93
+ }
94
+ else if (isValidTheme(arg)) {
95
+ id = arg;
96
+ }
97
+ else {
98
+ ctx.ui.notify(`[${TAG}] unknown theme "${arg}". Valid: ${THEME_IDS.join(", ")}`);
99
+ return;
100
+ }
101
+ const s = setGameState({ theme: id }, stateDir);
102
+ runtime.bumpGameState();
103
+ ctx.ui.notify(`[${TAG}] theme → ${s.theme} (${getTheme(s.theme)?.label ?? ""})`);
104
+ return;
105
+ }
106
+ // tui full|minimal
107
+ if (sub === "tui") {
108
+ const arg = parts[1];
109
+ if (arg !== "full" && arg !== "minimal") {
110
+ ctx.ui.notify(`[${TAG}] usage: /${TAG} tui full|minimal`);
111
+ return;
112
+ }
113
+ const s = setGameState({ tui_display_mode: arg }, stateDir);
114
+ runtime.bumpGameState();
115
+ ctx.ui.notify(`[${TAG}] tui → ${s.tui_display_mode}`);
116
+ return;
117
+ }
118
+ ctx.ui.notify(`[${TAG}] usage: /${TAG} [on|off|theme [id|next]|tui [full|minimal]|achievements]`);
119
+ }
120
+ /** Interactive in-app menu for bare `/mega-compact-settings`. Uses ctx.ui.select
121
+ * (a real TUI picker in interactive mode). Loops until the user cancels (or
122
+ * picks "Done"). Each action mutates the global game_state row + bumps the
123
+ * runtime cache so the widget/dashboard reflect it immediately.
124
+ *
125
+ * Guarded by the caller via `typeof ctx.ui.select === "function"`; if select is
126
+ * unavailable we fall back to the static fmtState notify print. */
127
+ async function runInteractiveMenu(ctx, runtime, stateDir) {
128
+ for (;;) {
129
+ const s = getGameState(stateDir);
130
+ const toggleLabel = s.game_mode_on ? "Turn game mode OFF" : "Turn game mode ON";
131
+ const choice = await ctx.ui.select(`[${TAG}] settings · game mode: ${s.game_mode_on ? "ON" : "off"} · theme: ${s.theme} · tui: ${s.tui_display_mode}`, [toggleLabel, "Theme…", "TUI display mode…", "Achievements…", "Done"]);
132
+ if (choice === undefined || choice === "Done")
133
+ return;
134
+ if (choice === toggleLabel) {
135
+ const next = !s.game_mode_on;
136
+ setGameState({ game_mode_on: next }, stateDir);
137
+ runtime.bumpGameState();
138
+ ctx.ui.notify(`[${TAG}] game mode ${next ? "ON" : "off"}`, "info");
139
+ continue;
140
+ }
141
+ if (choice === "Theme…") {
142
+ await themeSubmenu(ctx, runtime, stateDir);
143
+ continue;
144
+ }
145
+ if (choice === "TUI display mode…") {
146
+ await tuiSubmenu(ctx, runtime, stateDir);
147
+ continue;
148
+ }
149
+ if (choice === "Achievements…") {
150
+ await achievementsView(ctx, stateDir);
151
+ continue;
152
+ }
153
+ }
154
+ }
155
+ /** Theme picker submenu — lists all themes (current marked ✓) + a cycle option. */
156
+ async function themeSubmenu(ctx, runtime, stateDir) {
157
+ const s = getGameState(stateDir);
158
+ const opts = THEMES.map((t) => {
159
+ const mark = t.id === s.theme ? " ✓" : "";
160
+ return `${t.id}${mark} ${t.label}`;
161
+ });
162
+ opts.push("next (cycle to next theme)");
163
+ opts.push("Back");
164
+ const choice = await ctx.ui.select(`[${TAG}] theme (current: ${s.theme})`, opts);
165
+ if (choice === undefined || choice === "Back")
166
+ return;
167
+ const first = choice.split(/\s+/)[0];
168
+ let id;
169
+ if (first === "next") {
170
+ id = nextTheme(s.theme);
171
+ }
172
+ else if (isValidTheme(first)) {
173
+ id = first;
174
+ }
175
+ if (id && id !== s.theme) {
176
+ setGameState({ theme: id }, stateDir);
177
+ runtime.bumpGameState();
178
+ ctx.ui.notify(`[${TAG}] theme → ${id} (${getTheme(id)?.label ?? ""})`, "info");
179
+ }
180
+ }
181
+ /** TUI display-mode submenu — full vs minimal (current marked ✓). */
182
+ async function tuiSubmenu(ctx, runtime, stateDir) {
183
+ const s = getGameState(stateDir);
184
+ const mark = (m) => (s.tui_display_mode === m ? " ✓" : "");
185
+ const choice = await ctx.ui.select(`[${TAG}] TUI display mode (current: ${s.tui_display_mode})`, [
186
+ `full${mark("full")} — bars, stats, flair`,
187
+ `minimal${mark("minimal")} — one-line level + cache %`,
188
+ "Back",
189
+ ]);
190
+ if (choice === undefined || choice === "Back")
191
+ return;
192
+ const mode = choice.split(/\s+/)[0];
193
+ if (mode === "full" || mode === "minimal") {
194
+ setGameState({ tui_display_mode: mode }, stateDir);
195
+ runtime.bumpGameState();
196
+ ctx.ui.notify(`[${TAG}] tui → ${mode}`, "info");
197
+ }
198
+ }
199
+ /** Achievements view — terse notify list + a read-only select viewer. */
200
+ async function achievementsView(ctx, stateDir) {
201
+ const rows = listAchievements(stateDir).filter((r) => r.unlocked_at != null);
202
+ ctx.ui.notify(`[${TAG}] achievements unlocked (${rows.length}/9):`, "info");
203
+ for (const r of rows)
204
+ ctx.ui.notify(` ${r.icon ?? ""} ${r.title}`, "info");
205
+ const lines = rows.length
206
+ ? rows.map((r) => `${r.icon ?? ""} ${r.title}`)
207
+ : ["(none unlocked yet — keep compacting!)"];
208
+ // select() as a read-only viewer; any selection / cancel returns to the menu.
209
+ await ctx.ui.select(`[${TAG}] achievements (${rows.length}/9 unlocked)`, lines);
210
+ }
211
+ /** Register /mega-compact-settings (primary) + /mega-game (backward-compat alias). */
212
+ export function registerGameCommands(pi, runtime) {
213
+ const description = "Game mode toggle + theme picker + TUI display mode. Usage: /mega-compact-settings [on|off|theme [id|next]|tui [full|minimal]|achievements]";
214
+ const handler = (args, ctx) => handleSettings(args, ctx, runtime);
215
+ // Primary command (renamed in v0.8.x from /mega-game).
216
+ pi.registerCommand("mega-compact-settings", { description, handler });
217
+ // Backward-compat alias: /mega-game still resolves to the same settings UI.
218
+ pi.registerCommand("mega-game", {
219
+ description: "(alias for /mega-compact-settings) " + description,
220
+ handler,
105
221
  });
106
222
  }
@@ -16,13 +16,13 @@ import { THEME_IDS } from "../src/config/themes.js";
16
16
  // that wires the command against a fake pi without binding to the real pi
17
17
  // module at load time.
18
18
  const require = createRequire(import.meta.url);
19
- function makeHarness(stateDir) {
19
+ function makeHarness(stateDir, select) {
20
20
  const commands = {};
21
21
  const notifies = [];
22
22
  const runtime = { bindRepo: () => { }, currentStateDir: stateDir, bumpGameState: () => { } };
23
23
  const ctx = {
24
24
  cwd: stateDir,
25
- ui: { notify: (s) => notifies.push(s) },
25
+ ui: { notify: (s) => notifies.push(s), ...(select ? { select } : {}) },
26
26
  };
27
27
  const fakePi = {
28
28
  registerCommand: (name, opts) => {
@@ -35,7 +35,7 @@ function makeHarness(stateDir) {
35
35
  mod.registerGameCommands(fakePi, runtime);
36
36
  return { commands, notifies, ctx };
37
37
  }
38
- describe("/mega-game (S30)", () => {
38
+ describe("/mega-compact-settings (S30; /mega-game alias)", () => {
39
39
  let dir;
40
40
  before(() => {
41
41
  dir = mkdtempSync(join(tmpdir(), "mc-megagame-"));
@@ -52,12 +52,46 @@ describe("/mega-game (S30)", () => {
52
52
  await h.commands["mega-game"].handler(args, h.ctx);
53
53
  return h.notifies;
54
54
  }
55
+ it("registers /mega-compact-settings as primary + /mega-game as alias", async () => {
56
+ const h = makeHarness(dir);
57
+ assert.ok(h.commands["mega-compact-settings"], "primary registered");
58
+ assert.ok(h.commands["mega-game"], "alias registered");
59
+ });
55
60
  it("bare command prints current (default) state", async () => {
56
61
  const lines = await run("");
57
62
  assert.ok(lines.some((l) => l.includes("game mode: off")));
58
63
  assert.ok(lines.some((l) => l.includes("transparent")));
59
64
  assert.ok(lines.some((l) => l.includes("tui:")));
60
65
  });
66
+ it("bare command opens interactive menu (select) and toggles game mode", async () => {
67
+ const seq = ["Turn game mode ON", "Done"];
68
+ let i = 0;
69
+ const h = makeHarness(dir, () => Promise.resolve(seq[i++] ?? undefined));
70
+ h.notifies.length = 0;
71
+ await h.commands["mega-compact-settings"].handler("", h.ctx);
72
+ assert.equal(getGameState().game_mode_on, true);
73
+ assert.ok(h.notifies.some((l) => l.includes("game mode ON")));
74
+ // toggle back off via the menu
75
+ const seq2 = ["Turn game mode OFF", "Done"];
76
+ let j = 0;
77
+ const h2 = makeHarness(dir, () => Promise.resolve(seq2[j++] ?? undefined));
78
+ await h2.commands["mega-compact-settings"].handler("", h2.ctx);
79
+ assert.equal(getGameState().game_mode_on, false);
80
+ });
81
+ it("bare command falls back to status print when select is unavailable", async () => {
82
+ // default harness has no select — mimics RPC/print mode
83
+ const lines = await run("");
84
+ assert.ok(lines.some((l) => l.includes("game mode: off")));
85
+ });
86
+ it("menu Theme… → picks a theme and persists", async () => {
87
+ const seq = ["Theme…", "retro Retro Terminal", "Done"];
88
+ let i = 0;
89
+ const h = makeHarness(dir, () => Promise.resolve(seq[i++] ?? undefined));
90
+ h.notifies.length = 0;
91
+ await h.commands["mega-compact-settings"].handler("", h.ctx);
92
+ assert.equal(getGameState().theme, "retro");
93
+ assert.ok(h.notifies.some((l) => l.includes("theme → retro")));
94
+ });
61
95
  it("on enables game mode and persists", async () => {
62
96
  await run("on");
63
97
  assert.equal(getGameState().game_mode_on, true);
@@ -767,11 +767,25 @@ export class MegaRuntime {
767
767
  this.gameStateWatchDir = undefined;
768
768
  }
769
769
  try {
770
- this.gameStateWatcher = watch(join(this.currentStateDir, "sqlite.db"), () => { this.cachedGameState = undefined; });
770
+ // Watch the state DIR (not just sqlite.db) and filter by filename.
771
+ // Why: the store is WAL-mode (openStore sets PRAGMA journal_mode=WAL).
772
+ // Cross-process writes (dashboard server child) append to sqlite.db-wal
773
+ // and do NOT modify sqlite.db until a checkpoint — and a long-lived
774
+ // parent connection (VectorStore + dashboard readers) keeps the WAL
775
+ // uncheckpointed, so a watcher on sqlite.db alone never fires and
776
+ // cachedGameState stays stale (theme stuck after a dashboard edit).
777
+ // Watching the dir + matching sqlite.db* catches the main db, the -wal
778
+ // sidecar, and -shm, so the memo evicts on any cross-process write. The
779
+ // filter also excludes events.log / *.log noise in the same dir.
780
+ this.gameStateWatcher = watch(this.currentStateDir, (_eventType, filename) => {
781
+ if (typeof filename === "string" && filename.startsWith("sqlite.db")) {
782
+ this.cachedGameState = undefined;
783
+ }
784
+ });
771
785
  this.gameStateWatchDir = this.currentStateDir;
772
786
  }
773
787
  catch {
774
- /* non-fatal: missing file / platform issue — next snapshot re-queries */
788
+ /* non-fatal: missing dir / platform issue — next snapshot re-queries */
775
789
  }
776
790
  }
777
791
  /** S32: release the fs.watch game-state watcher. Called when the runtime is
@@ -22,10 +22,16 @@ export async function launchDashboardServer(stateDir: string): Promise<{ port: n
22
22
  // upgrade instead of reuse it.
23
23
  let SERVER_VERSION = "0.0.0";
24
24
  try {
25
- // dashboard-server.js lives at <pkg>/dist/extensions/, so package.json is
26
- // two levels up. Guard each candidate so a dev-checkout layout still works.
25
+ // Since v0.7.9 (8821ef3) dashboard-server.js lives at
26
+ // <pkg>/dist/extensions/dashboard-server/, so package.json is THREE levels
27
+ // up. Keep the two- and one-level-up candidates as fallbacks for flatter
28
+ // dev-checkout layouts. Guard each candidate so a missing file is skipped.
27
29
  const here = dirname(fileURLToPath(import.meta.url));
28
- const candidates = [join(here, "..", "..", "package.json"), join(here, "..", "package.json")];
30
+ const candidates = [
31
+ join(here, "..", "..", "..", "package.json"),
32
+ join(here, "..", "..", "package.json"),
33
+ join(here, "..", "package.json"),
34
+ ];
29
35
  for (const p of candidates) {
30
36
  if (!existsSync(p)) continue;
31
37
  const pkg = JSON.parse(readFileSync(p, "utf-8"));
@@ -464,6 +470,21 @@ export async function launchDashboardServer(stateDir: string): Promise<{ port: n
464
470
  // eslint-disable-next-line no-console
465
471
  console.log(`[mega-compact] dashboard server running: ${url}`);
466
472
 
473
+ // v0.8.2: also bind the IPv6 loopback (::1). On many systems `localhost`
474
+ // resolves to ::1 first (see /etc/hosts), so an IPv4-only bind makes the
475
+ // browser hit ::1:port and get connection refused. PREVENT-PI-004
476
+ // (loopback-only) means BOTH 127.0.0.1 and ::1. Non-fatal: IPv4-only
477
+ // hosts or a ::1 already in use just skip the mirror.
478
+ let v6: ReturnType<typeof createServer> | undefined;
479
+ const v4Handler = server.listeners("request")[0];
480
+ if (v4Handler) {
481
+ v6 = createServer((r, s) => (v4Handler as (a: IncomingMessage, b: ServerResponse) => void).call(server, r, s));
482
+ v6.on("error", (e: NodeJS.ErrnoException) =>
483
+ log("ipv6 loopback bind skipped", { port, code: e.code, message: e.message }),
484
+ );
485
+ v6.listen(port, "::1", () => log("ipv6 loopback bound", { port })); // guardrails-allow PREVENT-PI-004: IPv6 loopback (::1) mirror of the localhost dashboard server
486
+ }
487
+
467
488
  // Write port.pid
468
489
  try {
469
490
  writeFileSync(portFile, JSON.stringify({ port, pid: process.pid }));
@@ -475,6 +496,7 @@ export async function launchDashboardServer(stateDir: string): Promise<{ port: n
475
496
  const cleanup = () => {
476
497
  try { unlinkSync(portFile); } catch { /* already gone */ }
477
498
  server.close();
499
+ try { v6?.close(); } catch { /* not bound */ }
478
500
  process.exit(0);
479
501
  };
480
502
  process.on("SIGTERM", cleanup);
@@ -18,7 +18,7 @@
18
18
  * - mega-runtime.ts shared live state (MegaRuntime) + widget + model capture
19
19
  * - mega-pipeline.ts runCompact (Trident+persist) + doRecall (Layer 5)
20
20
  * - mega-commands.ts data/inspection slash commands
21
- * - mega-game-cmds.ts /mega-game toggle + theme + TUI display mode
21
+ * - mega-game-cmds.ts /mega-compact-settings (+ /mega-game alias) toggle + theme + TUI display mode
22
22
  * - mega-dashboard-cmds.ts localhost dashboard server lifecycle commands
23
23
  * - mega-events.ts pi lifecycle event handlers
24
24
  *
@@ -83,14 +83,16 @@ export function registerContextHandler(
83
83
  // Legacy: MEGACOMPACT_LEGACY_DURABLE_TRIM=true restores the v0.4.28 ctx.compact
84
84
  // path (kept one release as rollback).
85
85
  pi.on("context", async (event: ContextEvent, ctx: ExtensionContext) => {
86
- if (!config.auto) return;
87
86
  const usage = ctx.getContextUsage();
88
87
  const pct = usage?.percent;
89
- // Always track context for the dashboard, even if we return early below.
88
+ // Always track context for the dashboard/widget, even when auto is off.
89
+ // (v0.8 regression: !config.auto gate sat above this, leaving ctx stats
90
+ // null -> widget '?% / ?/?' when auto disabled. Track first, THEN gate.)
90
91
  runtime.lastCtxTokens = usage?.tokens ?? null;
91
92
  runtime.lastCtxPercent = pct ?? null;
92
93
  runtime.lastCtxWindow = usage?.contextWindow ?? 0;
93
94
  runtime.snapshot(ctx);
95
+ if (!config.auto) return;
94
96
 
95
97
  const messages = event.messages;
96
98
  const view = runtime.engineView(messages);
@@ -26,13 +26,16 @@ type Harness = {
26
26
  ctx: any;
27
27
  };
28
28
 
29
- function makeHarness(stateDir: string): Harness {
29
+ function makeHarness(
30
+ stateDir: string,
31
+ select?: (title: string, options: string[]) => Promise<string | undefined>,
32
+ ): Harness {
30
33
  const commands: Record<string, Cmd> = {};
31
34
  const notifies: string[] = [];
32
35
  const runtime = { bindRepo: () => {}, currentStateDir: stateDir, bumpGameState: () => {} };
33
36
  const ctx = {
34
37
  cwd: stateDir,
35
- ui: { notify: (s: string) => notifies.push(s) },
38
+ ui: { notify: (s: string) => notifies.push(s), ...(select ? { select } : {}) },
36
39
  };
37
40
  const fakePi = {
38
41
  registerCommand: (name: string, opts: Cmd) => {
@@ -48,7 +51,7 @@ function makeHarness(stateDir: string): Harness {
48
51
  return { commands, notifies, ctx };
49
52
  }
50
53
 
51
- describe("/mega-game (S30)", () => {
54
+ describe("/mega-compact-settings (S30; /mega-game alias)", () => {
52
55
  let dir: string;
53
56
  before(() => {
54
57
  dir = mkdtempSync(join(tmpdir(), "mc-megagame-"));
@@ -67,6 +70,12 @@ describe("/mega-game (S30)", () => {
67
70
  return h.notifies;
68
71
  }
69
72
 
73
+ it("registers /mega-compact-settings as primary + /mega-game as alias", async () => {
74
+ const h = makeHarness(dir);
75
+ assert.ok(h.commands["mega-compact-settings"], "primary registered");
76
+ assert.ok(h.commands["mega-game"], "alias registered");
77
+ });
78
+
70
79
  it("bare command prints current (default) state", async () => {
71
80
  const lines = await run("");
72
81
  assert.ok(lines.some((l) => l.includes("game mode: off")));
@@ -74,6 +83,38 @@ describe("/mega-game (S30)", () => {
74
83
  assert.ok(lines.some((l) => l.includes("tui:")));
75
84
  });
76
85
 
86
+ it("bare command opens interactive menu (select) and toggles game mode", async () => {
87
+ const seq = ["Turn game mode ON", "Done"];
88
+ let i = 0;
89
+ const h = makeHarness(dir, () => Promise.resolve(seq[i++] ?? undefined));
90
+ h.notifies.length = 0;
91
+ await h.commands["mega-compact-settings"].handler("", h.ctx);
92
+ assert.equal(getGameState().game_mode_on, true);
93
+ assert.ok(h.notifies.some((l) => l.includes("game mode ON")));
94
+ // toggle back off via the menu
95
+ const seq2 = ["Turn game mode OFF", "Done"];
96
+ let j = 0;
97
+ const h2 = makeHarness(dir, () => Promise.resolve(seq2[j++] ?? undefined));
98
+ await h2.commands["mega-compact-settings"].handler("", h2.ctx);
99
+ assert.equal(getGameState().game_mode_on, false);
100
+ });
101
+
102
+ it("bare command falls back to status print when select is unavailable", async () => {
103
+ // default harness has no select — mimics RPC/print mode
104
+ const lines = await run("");
105
+ assert.ok(lines.some((l) => l.includes("game mode: off")));
106
+ });
107
+
108
+ it("menu Theme… → picks a theme and persists", async () => {
109
+ const seq = ["Theme…", "retro Retro Terminal", "Done"];
110
+ let i = 0;
111
+ const h = makeHarness(dir, () => Promise.resolve(seq[i++] ?? undefined));
112
+ h.notifies.length = 0;
113
+ await h.commands["mega-compact-settings"].handler("", h.ctx);
114
+ assert.equal(getGameState().theme, "retro");
115
+ assert.ok(h.notifies.some((l) => l.includes("theme → retro")));
116
+ });
117
+
77
118
  it("on enables game mode and persists", async () => {
78
119
  await run("on");
79
120
  assert.equal(getGameState().game_mode_on, true);
@@ -1,5 +1,5 @@
1
1
  /**
2
- * mega-game-cmds.ts — /mega-game slash command (S30).
2
+ * mega-game-cmds.ts — /mega-compact-settings slash command (S30; renamed in v0.8.x).
3
3
  *
4
4
  * Backs the game-mode toggle + theme picker + TUI display mode. All state is
5
5
  * the global `game_state` SQLite row (src/store/sqlite/game-state.ts) — local
@@ -7,16 +7,20 @@
7
7
  * command touches no fetch/http). All SQL is parameterized (PREVENT-002) and
8
8
  * lives in the src/ submodule, not here.
9
9
  *
10
+ * The primary command is /mega-compact-settings. /mega-game is retained as a
11
+ * backward-compat alias (same handler) so existing muscle memory + docs keep
12
+ * working.
13
+ *
10
14
  * Usage:
11
- * /mega-game print current state
12
- * /mega-game on enable game mode (scoring + level-up + MEGA CACHE)
13
- * /mega-game off disable game mode
14
- * /mega-game theme list available themes
15
- * /mega-game theme <id> set theme by id
16
- * /mega-game theme next cycle to next theme
17
- * /mega-game tui full full TUI widget (bars, stats, flair)
18
- * /mega-game tui minimal one-line TUI widget (level + cache %)
19
- * /mega-game achievements list unlocked achievements
15
+ * /mega-compact-settings print current state
16
+ * /mega-compact-settings on enable game mode (scoring + level-up + MEGA CACHE)
17
+ * /mega-compact-settings off disable game mode
18
+ * /mega-compact-settings theme list available themes
19
+ * /mega-compact-settings theme <id> set theme by id
20
+ * /mega-compact-settings theme next cycle to next theme
21
+ * /mega-compact-settings tui full full TUI widget (bars, stats, flair)
22
+ * /mega-compact-settings tui minimal one-line TUI widget (level + cache %)
23
+ * /mega-compact-settings achievements list unlocked achievements
20
24
  */
21
25
 
22
26
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
@@ -29,94 +33,226 @@ import {
29
33
  import { listAchievements } from "../src/store/sqlite/game-achievements.js";
30
34
  import { THEMES, THEME_IDS, getTheme, isValidTheme, nextTheme, DEFAULT_THEME } from "../src/config/themes.js";
31
35
 
36
+ /** Notify/usage tag + command name. Primary surface is /mega-compact-settings. */
37
+ const TAG = "mega-compact-settings";
38
+
32
39
  /** Format the current state as a human-readable status line set. */
33
40
  function fmtState(s: GameState): string[] {
34
41
  return [
35
- `[mega-game] game mode: ${s.game_mode_on ? "ON" : "off"}`,
42
+ `[${TAG}] game mode: ${s.game_mode_on ? "ON" : "off"}`,
36
43
  ` theme: ${s.theme}${s.theme === DEFAULT_THEME ? " (default)" : ""}`,
37
44
  ` tui: ${s.tui_display_mode}`,
38
45
  ];
39
46
  }
40
47
 
41
- /** Register the /mega-game command. */
42
- export function registerGameCommands(pi: ExtensionAPI, runtime: MegaRuntime): void {
43
- pi.registerCommand("mega-game", {
44
- description:
45
- "Game mode toggle + theme picker + TUI display mode. Usage: /mega-game [on|off|theme [id|next]|tui [full|minimal]|achievements]",
46
- handler: async (args: string, ctx: ExtensionContext) => {
47
- runtime.bindRepo(ctx.cwd);
48
- const stateDir = runtime.currentStateDir;
49
- const parts = args.trim().split(/\s+/).filter(Boolean);
50
-
51
- // /mega-gameprint current state.
52
- if (parts.length === 0) {
53
- const s = getGameState(stateDir);
54
- for (const line of fmtState(s)) ctx.ui.notify(line);
48
+ /** Shared handler for /mega-compact-settings (primary) + /mega-game (alias). */
49
+ async function handleSettings(
50
+ args: string,
51
+ ctx: ExtensionContext,
52
+ runtime: MegaRuntime,
53
+ ): Promise<void> {
54
+ runtime.bindRepo(ctx.cwd);
55
+ const stateDir = runtime.currentStateDir;
56
+ const parts = args.trim().split(/\s+/).filter(Boolean);
57
+
58
+ // bareinteractive in-app menu (ctx.ui.select picker). Falls back to a
59
+ // static status print when there's no interactive UI (RPC/print mode, or a
60
+ // test harness stubbing only notify). CLI subcommands below still work for
61
+ // power users + scripts.
62
+ if (parts.length === 0) {
63
+ if (typeof ctx.ui.select === "function") {
64
+ try {
65
+ await runInteractiveMenu(ctx, runtime, stateDir);
55
66
  return;
67
+ } catch {
68
+ // select threw (non-interactive impl) → fall through to status print
56
69
  }
70
+ }
71
+ const s = getGameState(stateDir);
72
+ for (const line of fmtState(s)) ctx.ui.notify(line);
73
+ return;
74
+ }
57
75
 
58
- const sub = parts[0]!;
76
+ const sub = parts[0]!;
59
77
 
60
- // /mega-game achievements — terse list of unlocked (hidden only once unlocked).
61
- if (sub === "achievements") {
62
- const rows = listAchievements(stateDir).filter((r) => r.unlocked_at != null);
63
- ctx.ui.notify(`[mega-game] achievements unlocked (${rows.length}/9):`);
64
- for (const r of rows) {
65
- ctx.ui.notify(` ${r.icon ?? ""} ${r.title}`);
66
- }
67
- return;
68
- }
78
+ // achievements — terse list of unlocked (hidden only once unlocked).
79
+ if (sub === "achievements") {
80
+ const rows = listAchievements(stateDir).filter((r) => r.unlocked_at != null);
81
+ ctx.ui.notify(`[${TAG}] achievements unlocked (${rows.length}/9):`);
82
+ for (const r of rows) {
83
+ ctx.ui.notify(` ${r.icon ?? ""} ${r.title}`);
84
+ }
85
+ return;
86
+ }
69
87
 
70
- // /mega-game on|off
71
- if (sub === "on" || sub === "off") {
72
- const s = setGameState({ game_mode_on: sub === "on" }, stateDir);
73
- runtime.bumpGameState();
74
- ctx.ui.notify(`[mega-game] game mode ${s.game_mode_on ? "ON" : "off"}`);
75
- return;
76
- }
88
+ // on|off
89
+ if (sub === "on" || sub === "off") {
90
+ const s = setGameState({ game_mode_on: sub === "on" }, stateDir);
91
+ runtime.bumpGameState();
92
+ ctx.ui.notify(`[${TAG}] game mode ${s.game_mode_on ? "ON" : "off"}`);
93
+ return;
94
+ }
77
95
 
78
- // /mega-game theme [id|next]
79
- if (sub === "theme") {
80
- if (parts.length === 1) {
81
- // list themes
82
- ctx.ui.notify("[mega-game] themes:");
83
- for (const t of THEMES) {
84
- ctx.ui.notify(` ${t.id.padEnd(14)} ${t.label}`);
85
- }
86
- return;
87
- }
88
- const arg = parts[1]!;
89
- let id: string;
90
- if (arg === "next") {
91
- id = nextTheme(getGameState(stateDir).theme);
92
- } else if (isValidTheme(arg)) {
93
- id = arg;
94
- } else {
95
- ctx.ui.notify(`[mega-game] unknown theme "${arg}". Valid: ${THEME_IDS.join(", ")}`);
96
- return;
97
- }
98
- const s = setGameState({ theme: id }, stateDir);
99
- runtime.bumpGameState();
100
- ctx.ui.notify(`[mega-game] theme → ${s.theme} (${getTheme(s.theme)?.label ?? ""})`);
101
- return;
96
+ // theme [id|next]
97
+ if (sub === "theme") {
98
+ if (parts.length === 1) {
99
+ // list themes
100
+ ctx.ui.notify(`[${TAG}] themes:`);
101
+ for (const t of THEMES) {
102
+ ctx.ui.notify(` ${t.id.padEnd(14)} ${t.label}`);
102
103
  }
104
+ return;
105
+ }
106
+ const arg = parts[1]!;
107
+ let id: string;
108
+ if (arg === "next") {
109
+ id = nextTheme(getGameState(stateDir).theme);
110
+ } else if (isValidTheme(arg)) {
111
+ id = arg;
112
+ } else {
113
+ ctx.ui.notify(`[${TAG}] unknown theme "${arg}". Valid: ${THEME_IDS.join(", ")}`);
114
+ return;
115
+ }
116
+ const s = setGameState({ theme: id }, stateDir);
117
+ runtime.bumpGameState();
118
+ ctx.ui.notify(`[${TAG}] theme → ${s.theme} (${getTheme(s.theme)?.label ?? ""})`);
119
+ return;
120
+ }
103
121
 
104
- // /mega-game tui full|minimal
105
- if (sub === "tui") {
106
- const arg = parts[1];
107
- if (arg !== "full" && arg !== "minimal") {
108
- ctx.ui.notify(`[mega-game] usage: /mega-game tui full|minimal`);
109
- return;
110
- }
111
- const s = setGameState({ tui_display_mode: arg }, stateDir);
112
- runtime.bumpGameState();
113
- ctx.ui.notify(`[mega-game] tui → ${s.tui_display_mode}`);
114
- return;
115
- }
122
+ // tui full|minimal
123
+ if (sub === "tui") {
124
+ const arg = parts[1];
125
+ if (arg !== "full" && arg !== "minimal") {
126
+ ctx.ui.notify(`[${TAG}] usage: /${TAG} tui full|minimal`);
127
+ return;
128
+ }
129
+ const s = setGameState({ tui_display_mode: arg }, stateDir);
130
+ runtime.bumpGameState();
131
+ ctx.ui.notify(`[${TAG}] tui → ${s.tui_display_mode}`);
132
+ return;
133
+ }
134
+
135
+ ctx.ui.notify(
136
+ `[${TAG}] usage: /${TAG} [on|off|theme [id|next]|tui [full|minimal]|achievements]`,
137
+ );
138
+ }
116
139
 
117
- ctx.ui.notify(
118
- `[mega-game] usage: /mega-game [on|off|theme [id|next]|tui [full|minimal]|achievements]`,
119
- );
120
- },
140
+ /** Interactive in-app menu for bare `/mega-compact-settings`. Uses ctx.ui.select
141
+ * (a real TUI picker in interactive mode). Loops until the user cancels (or
142
+ * picks "Done"). Each action mutates the global game_state row + bumps the
143
+ * runtime cache so the widget/dashboard reflect it immediately.
144
+ *
145
+ * Guarded by the caller via `typeof ctx.ui.select === "function"`; if select is
146
+ * unavailable we fall back to the static fmtState notify print. */
147
+ async function runInteractiveMenu(
148
+ ctx: ExtensionContext,
149
+ runtime: MegaRuntime,
150
+ stateDir: string,
151
+ ): Promise<void> {
152
+ for (;;) {
153
+ const s = getGameState(stateDir);
154
+ const toggleLabel = s.game_mode_on ? "Turn game mode OFF" : "Turn game mode ON";
155
+ const choice = await ctx.ui.select(
156
+ `[${TAG}] settings · game mode: ${s.game_mode_on ? "ON" : "off"} · theme: ${s.theme} · tui: ${s.tui_display_mode}`,
157
+ [toggleLabel, "Theme…", "TUI display mode…", "Achievements…", "Done"],
158
+ );
159
+ if (choice === undefined || choice === "Done") return;
160
+ if (choice === toggleLabel) {
161
+ const next = !s.game_mode_on;
162
+ setGameState({ game_mode_on: next }, stateDir);
163
+ runtime.bumpGameState();
164
+ ctx.ui.notify(`[${TAG}] game mode ${next ? "ON" : "off"}`, "info");
165
+ continue;
166
+ }
167
+ if (choice === "Theme…") {
168
+ await themeSubmenu(ctx, runtime, stateDir);
169
+ continue;
170
+ }
171
+ if (choice === "TUI display mode…") {
172
+ await tuiSubmenu(ctx, runtime, stateDir);
173
+ continue;
174
+ }
175
+ if (choice === "Achievements…") {
176
+ await achievementsView(ctx, stateDir);
177
+ continue;
178
+ }
179
+ }
180
+ }
181
+
182
+ /** Theme picker submenu — lists all themes (current marked ✓) + a cycle option. */
183
+ async function themeSubmenu(
184
+ ctx: ExtensionContext,
185
+ runtime: MegaRuntime,
186
+ stateDir: string,
187
+ ): Promise<void> {
188
+ const s = getGameState(stateDir);
189
+ const opts = THEMES.map((t) => {
190
+ const mark = t.id === s.theme ? " ✓" : "";
191
+ return `${t.id}${mark} ${t.label}`;
192
+ });
193
+ opts.push("next (cycle to next theme)");
194
+ opts.push("Back");
195
+ const choice = await ctx.ui.select(`[${TAG}] theme (current: ${s.theme})`, opts);
196
+ if (choice === undefined || choice === "Back") return;
197
+ const first = choice.split(/\s+/)[0]!;
198
+ let id: string | undefined;
199
+ if (first === "next") {
200
+ id = nextTheme(s.theme);
201
+ } else if (isValidTheme(first)) {
202
+ id = first;
203
+ }
204
+ if (id && id !== s.theme) {
205
+ setGameState({ theme: id }, stateDir);
206
+ runtime.bumpGameState();
207
+ ctx.ui.notify(`[${TAG}] theme → ${id} (${getTheme(id)?.label ?? ""})`, "info");
208
+ }
209
+ }
210
+
211
+ /** TUI display-mode submenu — full vs minimal (current marked ✓). */
212
+ async function tuiSubmenu(
213
+ ctx: ExtensionContext,
214
+ runtime: MegaRuntime,
215
+ stateDir: string,
216
+ ): Promise<void> {
217
+ const s = getGameState(stateDir);
218
+ const mark = (m: string) => (s.tui_display_mode === m ? " ✓" : "");
219
+ const choice = await ctx.ui.select(`[${TAG}] TUI display mode (current: ${s.tui_display_mode})`, [
220
+ `full${mark("full")} — bars, stats, flair`,
221
+ `minimal${mark("minimal")} — one-line level + cache %`,
222
+ "Back",
223
+ ]);
224
+ if (choice === undefined || choice === "Back") return;
225
+ const mode = choice.split(/\s+/)[0];
226
+ if (mode === "full" || mode === "minimal") {
227
+ setGameState({ tui_display_mode: mode }, stateDir);
228
+ runtime.bumpGameState();
229
+ ctx.ui.notify(`[${TAG}] tui → ${mode}`, "info");
230
+ }
231
+ }
232
+
233
+ /** Achievements view — terse notify list + a read-only select viewer. */
234
+ async function achievementsView(ctx: ExtensionContext, stateDir: string): Promise<void> {
235
+ const rows = listAchievements(stateDir).filter((r) => r.unlocked_at != null);
236
+ ctx.ui.notify(`[${TAG}] achievements unlocked (${rows.length}/9):`, "info");
237
+ for (const r of rows) ctx.ui.notify(` ${r.icon ?? ""} ${r.title}`, "info");
238
+ const lines = rows.length
239
+ ? rows.map((r) => `${r.icon ?? ""} ${r.title}`)
240
+ : ["(none unlocked yet — keep compacting!)"];
241
+ // select() as a read-only viewer; any selection / cancel returns to the menu.
242
+ await ctx.ui.select(`[${TAG}] achievements (${rows.length}/9 unlocked)`, lines);
243
+ }
244
+
245
+ /** Register /mega-compact-settings (primary) + /mega-game (backward-compat alias). */
246
+ export function registerGameCommands(pi: ExtensionAPI, runtime: MegaRuntime): void {
247
+ const description =
248
+ "Game mode toggle + theme picker + TUI display mode. Usage: /mega-compact-settings [on|off|theme [id|next]|tui [full|minimal]|achievements]";
249
+ const handler = (args: string, ctx: ExtensionContext) => handleSettings(args, ctx, runtime);
250
+
251
+ // Primary command (renamed in v0.8.x from /mega-game).
252
+ pi.registerCommand("mega-compact-settings", { description, handler });
253
+ // Backward-compat alias: /mega-game still resolves to the same settings UI.
254
+ pi.registerCommand("mega-game", {
255
+ description: "(alias for /mega-compact-settings) " + description,
256
+ handler,
121
257
  });
122
258
  }
@@ -846,13 +846,27 @@ export class MegaRuntime {
846
846
  this.gameStateWatchDir = undefined;
847
847
  }
848
848
  try {
849
+ // Watch the state DIR (not just sqlite.db) and filter by filename.
850
+ // Why: the store is WAL-mode (openStore sets PRAGMA journal_mode=WAL).
851
+ // Cross-process writes (dashboard server child) append to sqlite.db-wal
852
+ // and do NOT modify sqlite.db until a checkpoint — and a long-lived
853
+ // parent connection (VectorStore + dashboard readers) keeps the WAL
854
+ // uncheckpointed, so a watcher on sqlite.db alone never fires and
855
+ // cachedGameState stays stale (theme stuck after a dashboard edit).
856
+ // Watching the dir + matching sqlite.db* catches the main db, the -wal
857
+ // sidecar, and -shm, so the memo evicts on any cross-process write. The
858
+ // filter also excludes events.log / *.log noise in the same dir.
849
859
  this.gameStateWatcher = watch(
850
- join(this.currentStateDir, "sqlite.db"),
851
- () => { this.cachedGameState = undefined; },
860
+ this.currentStateDir,
861
+ (_eventType, filename) => {
862
+ if (typeof filename === "string" && filename.startsWith("sqlite.db")) {
863
+ this.cachedGameState = undefined;
864
+ }
865
+ },
852
866
  );
853
867
  this.gameStateWatchDir = this.currentStateDir;
854
868
  } catch {
855
- /* non-fatal: missing file / platform issue — next snapshot re-queries */
869
+ /* non-fatal: missing dir / platform issue — next snapshot re-queries */
856
870
  }
857
871
  }
858
872
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-mega-compact",
3
- "version": "0.8.0",
3
+ "version": "0.8.2",
4
4
  "description": "Layered, local, vector-backed context compressor for pi — supersede/collapse/cluster compaction with deduped inline recall.",
5
5
  "type": "module",
6
6
  "license": "BSD-2-Clause",