gsd-pi 2.71.0-dev.06b86c6 → 2.71.0-dev.7a61d89

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 (151) hide show
  1. package/dist/headless-events.d.ts +2 -0
  2. package/dist/headless-events.js +7 -0
  3. package/dist/headless.js +16 -3
  4. package/dist/resource-loader.js +6 -3
  5. package/dist/resources/extensions/claude-code-cli/stream-adapter.js +10 -4
  6. package/dist/resources/extensions/gsd/auto/infra-errors.js +34 -0
  7. package/dist/resources/extensions/gsd/auto/loop.js +32 -1
  8. package/dist/resources/extensions/gsd/auto/session.js +8 -0
  9. package/dist/resources/extensions/gsd/auto-dashboard.js +22 -16
  10. package/dist/resources/extensions/gsd/auto.js +52 -0
  11. package/dist/resources/extensions/gsd/bootstrap/register-shortcuts.js +66 -51
  12. package/dist/resources/extensions/gsd/commands/handlers/auto.js +10 -33
  13. package/dist/resources/extensions/gsd/commands/handlers/core.js +45 -11
  14. package/dist/resources/extensions/gsd/commands/handlers/notifications-handler.js +15 -6
  15. package/dist/resources/extensions/gsd/commands/handlers/workflow.js +4 -10
  16. package/dist/resources/extensions/gsd/dashboard-overlay.js +8 -3
  17. package/dist/resources/extensions/gsd/forensics.js +19 -6
  18. package/dist/resources/extensions/gsd/guided-flow.js +5 -10
  19. package/dist/resources/extensions/gsd/metrics.js +1 -0
  20. package/dist/resources/extensions/gsd/milestone-actions.js +10 -4
  21. package/dist/resources/extensions/gsd/notification-overlay.js +20 -5
  22. package/dist/resources/extensions/gsd/notification-store.js +30 -0
  23. package/dist/resources/extensions/gsd/notification-widget.js +5 -13
  24. package/dist/resources/extensions/gsd/parallel-monitor-overlay.js +8 -3
  25. package/dist/resources/extensions/gsd/shortcut-defs.js +34 -0
  26. package/dist/web/standalone/.next/BUILD_ID +1 -1
  27. package/dist/web/standalone/.next/app-path-routes-manifest.json +11 -11
  28. package/dist/web/standalone/.next/build-manifest.json +2 -2
  29. package/dist/web/standalone/.next/prerender-manifest.json +3 -3
  30. package/dist/web/standalone/.next/server/app/_global-error.html +1 -1
  31. package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
  32. package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  33. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  34. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
  35. package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  36. package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  37. package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  38. package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
  39. package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
  40. package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
  41. package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  42. package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
  43. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  44. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  45. package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  46. package/dist/web/standalone/.next/server/app/index.html +1 -1
  47. package/dist/web/standalone/.next/server/app/index.rsc +1 -1
  48. package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
  49. package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
  50. package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
  51. package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
  52. package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  53. package/dist/web/standalone/.next/server/app-paths-manifest.json +11 -11
  54. package/dist/web/standalone/.next/server/middleware-build-manifest.js +1 -1
  55. package/dist/web/standalone/.next/server/pages/404.html +1 -1
  56. package/dist/web/standalone/.next/server/pages/500.html +1 -1
  57. package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
  58. package/package.json +1 -1
  59. package/packages/pi-ai/dist/providers/anthropic-auth.test.d.ts +2 -0
  60. package/packages/pi-ai/dist/providers/anthropic-auth.test.d.ts.map +1 -0
  61. package/packages/pi-ai/dist/providers/anthropic-auth.test.js +20 -0
  62. package/packages/pi-ai/dist/providers/anthropic-auth.test.js.map +1 -0
  63. package/packages/pi-ai/dist/providers/anthropic.d.ts +2 -1
  64. package/packages/pi-ai/dist/providers/anthropic.d.ts.map +1 -1
  65. package/packages/pi-ai/dist/providers/anthropic.js +7 -4
  66. package/packages/pi-ai/dist/providers/anthropic.js.map +1 -1
  67. package/packages/pi-ai/src/providers/anthropic-auth.test.ts +32 -0
  68. package/packages/pi-ai/src/providers/anthropic.ts +8 -4
  69. package/packages/pi-coding-agent/dist/core/agent-session-renderable-tools.test.d.ts +2 -0
  70. package/packages/pi-coding-agent/dist/core/agent-session-renderable-tools.test.d.ts.map +1 -0
  71. package/packages/pi-coding-agent/dist/core/agent-session-renderable-tools.test.js +61 -0
  72. package/packages/pi-coding-agent/dist/core/agent-session-renderable-tools.test.js.map +1 -0
  73. package/packages/pi-coding-agent/dist/core/agent-session.d.ts.map +1 -1
  74. package/packages/pi-coding-agent/dist/core/agent-session.js +2 -1
  75. package/packages/pi-coding-agent/dist/core/agent-session.js.map +1 -1
  76. package/packages/pi-coding-agent/dist/core/auth-storage.d.ts +10 -0
  77. package/packages/pi-coding-agent/dist/core/auth-storage.d.ts.map +1 -1
  78. package/packages/pi-coding-agent/dist/core/auth-storage.js +27 -0
  79. package/packages/pi-coding-agent/dist/core/auth-storage.js.map +1 -1
  80. package/packages/pi-coding-agent/dist/core/auth-storage.test.js +85 -0
  81. package/packages/pi-coding-agent/dist/core/auth-storage.test.js.map +1 -1
  82. package/packages/pi-coding-agent/dist/core/sdk.d.ts +11 -0
  83. package/packages/pi-coding-agent/dist/core/sdk.d.ts.map +1 -1
  84. package/packages/pi-coding-agent/dist/core/sdk.js +38 -5
  85. package/packages/pi-coding-agent/dist/core/sdk.js.map +1 -1
  86. package/packages/pi-coding-agent/dist/core/sdk.test.d.ts +2 -0
  87. package/packages/pi-coding-agent/dist/core/sdk.test.d.ts.map +1 -0
  88. package/packages/pi-coding-agent/dist/core/sdk.test.js +71 -0
  89. package/packages/pi-coding-agent/dist/core/sdk.test.js.map +1 -0
  90. package/packages/pi-coding-agent/dist/index.d.ts +1 -1
  91. package/packages/pi-coding-agent/dist/index.d.ts.map +1 -1
  92. package/packages/pi-coding-agent/dist/index.js +1 -1
  93. package/packages/pi-coding-agent/dist/index.js.map +1 -1
  94. package/packages/pi-coding-agent/dist/modes/interactive/components/tool-execution.d.ts +4 -0
  95. package/packages/pi-coding-agent/dist/modes/interactive/components/tool-execution.d.ts.map +1 -1
  96. package/packages/pi-coding-agent/dist/modes/interactive/components/tool-execution.js +43 -0
  97. package/packages/pi-coding-agent/dist/modes/interactive/components/tool-execution.js.map +1 -1
  98. package/packages/pi-coding-agent/dist/modes/interactive/controllers/chat-controller.d.ts.map +1 -1
  99. package/packages/pi-coding-agent/dist/modes/interactive/controllers/chat-controller.js +7 -2
  100. package/packages/pi-coding-agent/dist/modes/interactive/controllers/chat-controller.js.map +1 -1
  101. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  102. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.js +4 -3
  103. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.js.map +1 -1
  104. package/packages/pi-coding-agent/dist/modes/interactive/slash-command-handlers.js +4 -2
  105. package/packages/pi-coding-agent/dist/modes/interactive/slash-command-handlers.js.map +1 -1
  106. package/packages/pi-coding-agent/src/core/agent-session-renderable-tools.test.ts +70 -0
  107. package/packages/pi-coding-agent/src/core/agent-session.ts +2 -1
  108. package/packages/pi-coding-agent/src/core/auth-storage.test.ts +108 -0
  109. package/packages/pi-coding-agent/src/core/auth-storage.ts +30 -0
  110. package/packages/pi-coding-agent/src/core/sdk.test.ts +89 -0
  111. package/packages/pi-coding-agent/src/core/sdk.ts +45 -9
  112. package/packages/pi-coding-agent/src/index.ts +1 -0
  113. package/packages/pi-coding-agent/src/modes/interactive/components/tool-execution.ts +47 -0
  114. package/packages/pi-coding-agent/src/modes/interactive/controllers/chat-controller.ts +7 -2
  115. package/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts +4 -3
  116. package/packages/pi-coding-agent/src/modes/interactive/slash-command-handlers.ts +4 -2
  117. package/src/resources/extensions/claude-code-cli/stream-adapter.ts +13 -5
  118. package/src/resources/extensions/claude-code-cli/tests/stream-adapter.test.ts +56 -4
  119. package/src/resources/extensions/gsd/auto/infra-errors.ts +38 -0
  120. package/src/resources/extensions/gsd/auto/loop.ts +45 -1
  121. package/src/resources/extensions/gsd/auto/session.ts +8 -0
  122. package/src/resources/extensions/gsd/auto-dashboard.ts +29 -18
  123. package/src/resources/extensions/gsd/auto.ts +68 -0
  124. package/src/resources/extensions/gsd/bootstrap/register-shortcuts.ts +82 -60
  125. package/src/resources/extensions/gsd/commands/handlers/auto.ts +10 -36
  126. package/src/resources/extensions/gsd/commands/handlers/core.ts +46 -11
  127. package/src/resources/extensions/gsd/commands/handlers/notifications-handler.ts +17 -7
  128. package/src/resources/extensions/gsd/commands/handlers/workflow.ts +4 -10
  129. package/src/resources/extensions/gsd/dashboard-overlay.ts +10 -3
  130. package/src/resources/extensions/gsd/forensics.ts +23 -7
  131. package/src/resources/extensions/gsd/guided-flow.ts +5 -10
  132. package/src/resources/extensions/gsd/interrupted-session.ts +1 -0
  133. package/src/resources/extensions/gsd/metrics.ts +12 -1
  134. package/src/resources/extensions/gsd/milestone-actions.ts +10 -3
  135. package/src/resources/extensions/gsd/notification-overlay.ts +24 -7
  136. package/src/resources/extensions/gsd/notification-store.ts +30 -0
  137. package/src/resources/extensions/gsd/notification-widget.ts +5 -14
  138. package/src/resources/extensions/gsd/parallel-monitor-overlay.ts +10 -3
  139. package/src/resources/extensions/gsd/shortcut-defs.ts +49 -0
  140. package/src/resources/extensions/gsd/tests/forensics-stuck-loops.test.ts +62 -0
  141. package/src/resources/extensions/gsd/tests/format-shortcut.test.ts +15 -0
  142. package/src/resources/extensions/gsd/tests/infra-errors-cooldown.test.ts +180 -0
  143. package/src/resources/extensions/gsd/tests/notification-store.test.ts +18 -0
  144. package/src/resources/extensions/gsd/tests/notification-widget.test.ts +3 -2
  145. package/src/resources/extensions/gsd/tests/notifications-handler.test.ts +90 -0
  146. package/src/resources/extensions/gsd/tests/parallel-monitor-overlay.test.ts +1 -0
  147. package/src/resources/extensions/gsd/tests/park-db-sync.test.ts +18 -0
  148. package/src/resources/extensions/gsd/tests/register-shortcuts.test.ts +62 -5
  149. package/src/resources/extensions/gsd/tests/start-auto-detached.test.ts +90 -0
  150. /package/dist/web/standalone/.next/static/{dYVdRaunb2ZSEA8fjkT-V → ug91LJa0m7OdzrTVaz_48}/_buildManifest.js +0 -0
  151. /package/dist/web/standalone/.next/static/{dYVdRaunb2ZSEA8fjkT-V → ug91LJa0m7OdzrTVaz_48}/_ssgManifest.js +0 -0
@@ -650,19 +650,33 @@ function getDbCompletionCounts(): DbCompletionCounts | null {
650
650
  * Exported for testability.
651
651
  */
652
652
  export function detectStuckLoops(units: UnitMetrics[], anomalies: ForensicAnomaly[]): void {
653
- // First, collect unique startedAt values per type/id key
654
- const dispatchMap = new Map<string, Set<number>>();
653
+ // First, collect unique startedAt values per type/id key, bucketed by
654
+ // autoSessionKey when available so cross-session recovery does not look
655
+ // like a within-session stuck loop.
656
+ const dispatchMap = new Map<string, Map<string, Set<number>>>();
655
657
  for (const u of units) {
656
658
  const key = `${u.type}/${u.id}`;
657
- let starts = dispatchMap.get(key);
659
+ let sessionBuckets = dispatchMap.get(key);
660
+ if (!sessionBuckets) {
661
+ sessionBuckets = new Map();
662
+ dispatchMap.set(key, sessionBuckets);
663
+ }
664
+
665
+ const sessionKey = u.autoSessionKey ?? "__legacy__";
666
+ let starts = sessionBuckets.get(sessionKey);
658
667
  if (!starts) {
659
668
  starts = new Set();
660
- dispatchMap.set(key, starts);
669
+ sessionBuckets.set(sessionKey, starts);
661
670
  }
662
671
  starts.add(u.startedAt);
663
672
  }
664
- for (const [key, starts] of dispatchMap) {
665
- const count = starts.size;
673
+
674
+ for (const [key, sessionBuckets] of dispatchMap) {
675
+ const hasSessionAwareData = Array.from(sessionBuckets.keys()).some((sessionKey) => sessionKey !== "__legacy__");
676
+ const count = hasSessionAwareData
677
+ ? Math.max(...Array.from(sessionBuckets.values(), (starts) => starts.size))
678
+ : (sessionBuckets.get("__legacy__")?.size ?? 0);
679
+
666
680
  if (count > 1) {
667
681
  const [unitType, ...idParts] = key.split("/");
668
682
  anomalies.push({
@@ -671,7 +685,9 @@ export function detectStuckLoops(units: UnitMetrics[], anomalies: ForensicAnomal
671
685
  unitType,
672
686
  unitId: idParts.join("/"),
673
687
  summary: `Unit ${key} was dispatched ${count} times`,
674
- details: `Repeated dispatch suggests the unit completed but its artifacts weren't verified, or the state machine kept returning it.`,
688
+ details: hasSessionAwareData
689
+ ? `Repeated dispatch within the same auto session suggests the unit completed but its artifacts were not verified, or the state machine kept returning it. Cross-session recovery runs are ignored.`
690
+ : `Repeated dispatch suggests the unit completed but its artifacts weren't verified, or the state machine kept returning it.`,
675
691
  });
676
692
  }
677
693
  }
@@ -15,7 +15,7 @@ import { loadPrompt, inlineTemplate } from "./prompt-loader.js";
15
15
  import { buildSkillActivationBlock } from "./auto-prompts.js";
16
16
  import { deriveState } from "./state.js";
17
17
  import { invalidateAllCaches } from "./cache.js";
18
- import { startAuto } from "./auto.js";
18
+ import { startAutoDetached } from "./auto.js";
19
19
  import { clearLock } from "./crash-recovery.js";
20
20
  import {
21
21
  assessInterruptedSession,
@@ -67,7 +67,6 @@ export {
67
67
  showQueue, handleQueueReorder, showQueueAdd,
68
68
  buildExistingMilestonesContext,
69
69
  } from "./guided-flow-queue.js";
70
- import { getErrorMessage } from "./error-utils.js";
71
70
  import { logWarning } from "./workflow-logger.js";
72
71
 
73
72
  // ─── ID Generation with Reservation ─────────────────────────────────────────
@@ -244,11 +243,7 @@ export function checkAutoStartAfterDiscuss(): boolean {
244
243
 
245
244
  pendingAutoStartMap.delete(basePath);
246
245
  ctx.ui.notify(`Milestone ${milestoneId} ready.`, "info");
247
- startAuto(ctx, pi, basePath, false, { step }).catch((err) => {
248
- ctx.ui.notify(`Auto-start failed: ${getErrorMessage(err)}`, "error");
249
- logWarning("guided", `auto start error: ${getErrorMessage(err)}`);
250
- debugLog("auto-start-failed", { error: getErrorMessage(err) });
251
- });
246
+ startAutoDetached(ctx, pi, basePath, false, { step });
252
247
  return true;
253
248
  }
254
249
 
@@ -1305,7 +1300,7 @@ export async function showSmartEntry(
1305
1300
  ],
1306
1301
  });
1307
1302
  if (resume === "resume") {
1308
- await startAuto(ctx, pi, basePath, false, {
1303
+ startAutoDetached(ctx, pi, basePath, false, {
1309
1304
  interrupted,
1310
1305
  step: interrupted.pausedSession?.stepMode ?? false,
1311
1306
  });
@@ -1647,7 +1642,7 @@ export async function showSmartEntry(
1647
1642
  });
1648
1643
 
1649
1644
  if (choice === "auto") {
1650
- await startAuto(ctx, pi, basePath, false);
1645
+ startAutoDetached(ctx, pi, basePath, false);
1651
1646
  } else if (choice === "status") {
1652
1647
  const { fireStatusViaCommand } = await import("./commands.js");
1653
1648
  await fireStatusViaCommand(ctx);
@@ -1859,7 +1854,7 @@ export async function showSmartEntry(
1859
1854
  });
1860
1855
 
1861
1856
  if (choice === "auto") {
1862
- await startAuto(ctx, pi, basePath, false);
1857
+ startAutoDetached(ctx, pi, basePath, false);
1863
1858
  return;
1864
1859
  }
1865
1860
 
@@ -34,6 +34,7 @@ export interface PausedSessionMetadata {
34
34
  activeEngineId?: string;
35
35
  activeRunDir?: string | null;
36
36
  autoStartTime?: number;
37
+ milestoneLock?: string | null;
37
38
  }
38
39
 
39
40
  export interface InterruptedSessionAssessment {
@@ -41,6 +41,7 @@ export interface UnitMetrics {
41
41
  model: string; // model ID used
42
42
  startedAt: number; // ms timestamp
43
43
  finishedAt: number; // ms timestamp
44
+ autoSessionKey?: string; // identifies one auto-mode run across pause/resume
44
45
  tokens: TokenCounts;
45
46
  cost: number; // total USD cost
46
47
  toolCalls: number;
@@ -133,7 +134,16 @@ export function snapshotUnitMetrics(
133
134
  unitId: string,
134
135
  startedAt: number,
135
136
  model: string,
136
- opts?: { tier?: string; modelDowngraded?: boolean; contextWindowTokens?: number; truncationSections?: number; continueHereFired?: boolean; promptCharCount?: number; baselineCharCount?: number },
137
+ opts?: {
138
+ tier?: string;
139
+ modelDowngraded?: boolean;
140
+ contextWindowTokens?: number;
141
+ truncationSections?: number;
142
+ continueHereFired?: boolean;
143
+ promptCharCount?: number;
144
+ baselineCharCount?: number;
145
+ autoSessionKey?: string;
146
+ },
137
147
  ): UnitMetrics | null {
138
148
  if (!ledger) return null;
139
149
 
@@ -181,6 +191,7 @@ export function snapshotUnitMetrics(
181
191
  model,
182
192
  startedAt,
183
193
  finishedAt: Date.now(),
194
+ ...(opts?.autoSessionKey ? { autoSessionKey: opts.autoSessionKey } : {}),
184
195
  tokens,
185
196
  cost,
186
197
  toolCalls,
@@ -20,7 +20,7 @@ import {
20
20
  } from "./paths.js";
21
21
  import { invalidateAllCaches } from "./cache.js";
22
22
  import { loadQueueOrder, saveQueueOrder } from "./queue-order.js";
23
- import { isDbAvailable, updateMilestoneStatus } from "./gsd-db.js";
23
+ import { getMilestone, isDbAvailable, updateMilestoneStatus } from "./gsd-db.js";
24
24
  import { logWarning } from "./workflow-logger.js";
25
25
 
26
26
  // ─── Park ──────────────────────────────────────────────────────────────────
@@ -77,9 +77,16 @@ export function unparkMilestone(basePath: string, milestoneId: string): boolean
77
77
  if (!mDir || !existsSync(mDir)) return false;
78
78
 
79
79
  const parkedPath = join(mDir, buildMilestoneFileName(milestoneId, "PARKED"));
80
- if (!existsSync(parkedPath)) return false; // not parked
80
+ const hadParkedFile = existsSync(parkedPath);
81
+ const dbThinksParked = isDbAvailable() && getMilestone(milestoneId)?.status === "parked";
81
82
 
82
- unlinkSync(parkedPath);
83
+ // Recover the reverse desync too: DB can still say "parked" even when the
84
+ // PARKED marker was lost on disk, and /gsd unpark should repair that state.
85
+ if (!hadParkedFile && !dbThinksParked) return false;
86
+
87
+ if (hadParkedFile) {
88
+ unlinkSync(parkedPath);
89
+ }
83
90
  // Sync DB status so deriveStateFromDb picks up the unparked milestone (#2694)
84
91
  if (isDbAvailable()) {
85
92
  try {
@@ -1,6 +1,6 @@
1
1
  // GSD Extension — Notification History Overlay
2
2
  // Scrollable panel showing all persisted notifications with severity filtering.
3
- // Toggled with Ctrl+Alt+N (⌃⌥N on macOS) or opened from /gsd notifications.
3
+ // Toggled with Ctrl+Alt+N (⌃⌥N on macOS), Ctrl+Shift+N fallback, or /gsd notifications.
4
4
 
5
5
  import type { Theme } from "@gsd/pi-coding-agent";
6
6
  import { truncateToWidth, visibleWidth, matchesKey, Key } from "@gsd/pi-tui";
@@ -9,11 +9,11 @@ import {
9
9
  readNotifications,
10
10
  markAllRead,
11
11
  clearNotifications,
12
- getUnreadCount,
13
12
  type NotificationEntry,
14
13
  type NotifySeverity,
15
14
  } from "./notification-store.js";
16
- import { padRight, centerLine, joinColumns, formatDuration } from "../shared/mod.js";
15
+ import { formattedShortcutPair } from "./shortcut-defs.js";
16
+ import { padRight, joinColumns } from "../shared/mod.js";
17
17
 
18
18
  type FilterMode = "all" | "error" | "warning" | "info";
19
19
  const FILTER_CYCLE: FilterMode[] = ["all", "error", "warning", "info"];
@@ -63,6 +63,12 @@ function formatTimestamp(ts: string): string {
63
63
  }
64
64
  }
65
65
 
66
+ function notificationSignature(entries: readonly NotificationEntry[]): string {
67
+ return entries
68
+ .map((entry) => `${entry.ts}|${entry.severity}|${entry.read ? 1 : 0}|${entry.message}`)
69
+ .join("\n");
70
+ }
71
+
66
72
  export class GSDNotificationOverlay {
67
73
  private tui: { requestRender: () => void };
68
74
  private theme: Theme;
@@ -72,6 +78,7 @@ export class GSDNotificationOverlay {
72
78
  private scrollOffset = 0;
73
79
  private filterIndex = 0;
74
80
  private entries: NotificationEntry[] = [];
81
+ private entriesSignature = "";
75
82
  private refreshTimer: ReturnType<typeof setInterval>;
76
83
  private disposed = false;
77
84
  private resizeHandler: (() => void) | null = null;
@@ -88,6 +95,7 @@ export class GSDNotificationOverlay {
88
95
  // Mark all as read on open
89
96
  markAllRead();
90
97
  this.entries = readNotifications();
98
+ this.entriesSignature = notificationSignature(this.entries);
91
99
 
92
100
  // Resize handler
93
101
  this.resizeHandler = () => {
@@ -101,9 +109,11 @@ export class GSDNotificationOverlay {
101
109
  this.refreshTimer = setInterval(() => {
102
110
  if (this.disposed) return;
103
111
  const fresh = readNotifications();
104
- if (fresh.length !== this.entries.length) {
105
- this.entries = fresh;
112
+ const signature = notificationSignature(fresh);
113
+ if (signature !== this.entriesSignature) {
106
114
  markAllRead();
115
+ this.entries = readNotifications();
116
+ this.entriesSignature = notificationSignature(this.entries);
107
117
  this.invalidate();
108
118
  this.tui.requestRender();
109
119
  }
@@ -120,7 +130,12 @@ export class GSDNotificationOverlay {
120
130
  }
121
131
 
122
132
  handleInput(data: string): void {
123
- if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c")) || matchesKey(data, Key.ctrlAlt("n"))) {
133
+ if (
134
+ matchesKey(data, Key.escape) ||
135
+ matchesKey(data, Key.ctrl("c")) ||
136
+ matchesKey(data, Key.ctrlAlt("n")) ||
137
+ matchesKey(data, Key.ctrlShift("n"))
138
+ ) {
124
139
  this.dispose();
125
140
  this.onClose();
126
141
  return;
@@ -165,6 +180,7 @@ export class GSDNotificationOverlay {
165
180
  if (data === "c") {
166
181
  clearNotifications();
167
182
  this.entries = [];
183
+ this.entriesSignature = notificationSignature(this.entries);
168
184
  this.scrollOffset = 0;
169
185
  this.invalidate();
170
186
  this.tui.requestRender();
@@ -250,7 +266,8 @@ export class GSDNotificationOverlay {
250
266
  lines.push(hr());
251
267
 
252
268
  // Controls
253
- lines.push(row(th.fg("dim", "↑/↓ scroll f filter c clear Esc close")));
269
+ const closeShortcut = formattedShortcutPair("notifications");
270
+ lines.push(row(th.fg("dim", `↑/↓ scroll f filter c clear Esc close (${closeShortcut})`)));
254
271
  lines.push(blank());
255
272
 
256
273
  // Entries
@@ -35,6 +35,7 @@ let _basePath: string | null = null;
35
35
  let _lineCount = 0; // Hint for rotation — not authoritative for public API
36
36
  let _suppressCount = 0;
37
37
  let _recentMessageTimestamps = new Map<string, number>();
38
+ const _changeListeners = new Set<() => void>();
38
39
 
39
40
  // ─── Public API ─────────────────────────────────────────────────────────
40
41
 
@@ -93,6 +94,7 @@ export function appendNotification(
93
94
  if (_lineCount > MAX_ENTRIES) {
94
95
  _rotate();
95
96
  }
97
+ _emitChange();
96
98
  } catch {
97
99
  // Non-fatal — never let persistence break the caller
98
100
  }
@@ -121,6 +123,7 @@ export function markAllRead(basePath?: string): void {
121
123
  const hasUnread = entries.some((e) => !e.read);
122
124
  if (!hasUnread) return;
123
125
 
126
+ let changed = false;
124
127
  try {
125
128
  _withLock(bp, () => {
126
129
  // Re-read inside lock to get freshest state
@@ -128,10 +131,12 @@ export function markAllRead(basePath?: string): void {
128
131
  if (fresh.length === 0 || !fresh.some((e) => !e.read)) return;
129
132
  const lines = fresh.map((e) => JSON.stringify({ ...e, read: true }));
130
133
  _atomicWrite(bp, lines.join("\n") + "\n");
134
+ changed = true;
131
135
  });
132
136
  } catch {
133
137
  // Non-fatal
134
138
  }
139
+ if (changed) _emitChange();
135
140
  }
136
141
 
137
142
  /**
@@ -145,6 +150,8 @@ export function clearNotifications(basePath?: string): void {
145
150
  _withLock(bp, () => {
146
151
  _atomicWrite(bp, "");
147
152
  });
153
+ _lineCount = 0;
154
+ _emitChange();
148
155
  } catch {
149
156
  // Non-fatal
150
157
  }
@@ -189,6 +196,17 @@ export function unsuppressPersistence(): void {
189
196
  _suppressCount = Math.max(0, _suppressCount - 1);
190
197
  }
191
198
 
199
+ /**
200
+ * Subscribe to notification-store mutations (append, mark-read, clear).
201
+ * Returns an unsubscribe function.
202
+ */
203
+ export function onNotificationStoreChange(listener: () => void): () => void {
204
+ _changeListeners.add(listener);
205
+ return () => {
206
+ _changeListeners.delete(listener);
207
+ };
208
+ }
209
+
192
210
  // ─── Test Helpers ───────────────────────────────────────────────────────
193
211
 
194
212
  /**
@@ -199,6 +217,7 @@ export function _resetNotificationStore(): void {
199
217
  _lineCount = 0;
200
218
  _suppressCount = 0;
201
219
  _recentMessageTimestamps = new Map();
220
+ _changeListeners.clear();
202
221
  }
203
222
 
204
223
  // ─── Internal ───────────────────────────────────────────────────────────
@@ -234,12 +253,23 @@ function _rotate(): void {
234
253
  const trimmed = entries.slice(entries.length - MAX_ENTRIES);
235
254
  const lines = trimmed.map((e) => JSON.stringify(e));
236
255
  _atomicWrite(_basePath!, lines.join("\n") + "\n");
256
+ _lineCount = trimmed.length;
237
257
  });
238
258
  } catch {
239
259
  // Non-fatal
240
260
  }
241
261
  }
242
262
 
263
+ function _emitChange(): void {
264
+ for (const listener of _changeListeners) {
265
+ try {
266
+ listener();
267
+ } catch {
268
+ // Non-fatal
269
+ }
270
+ }
271
+ }
272
+
243
273
  /**
244
274
  * Atomic file rewrite via temp-file + rename. Prevents partial reads
245
275
  * by other processes (web API subprocess, parallel workers).
@@ -5,8 +5,8 @@
5
5
 
6
6
  import type { ExtensionContext } from "@gsd/pi-coding-agent";
7
7
 
8
- import { getUnreadCount, readNotifications } from "./notification-store.js";
9
- import { formatShortcut } from "./files.js";
8
+ import { getUnreadCount, onNotificationStoreChange } from "./notification-store.js";
9
+ import { formattedShortcutPair } from "./shortcut-defs.js";
10
10
 
11
11
  // ─── Pure rendering ──���────────────────────────���─────────────────────────
12
12
 
@@ -14,18 +14,7 @@ export function buildNotificationWidgetLines(): string[] {
14
14
  const unread = getUnreadCount();
15
15
  if (unread === 0) return [];
16
16
 
17
- const entries = readNotifications();
18
- const latest = entries[0]; // newest-first
19
- if (!latest) return [];
20
-
21
- const icon = latest.severity === "error" ? "✗" : latest.severity === "warning" ? "⚠" : "●";
22
- const badge = `${unread} unread`;
23
- const msgMax = 80;
24
- const truncated = latest.message.length > msgMax
25
- ? latest.message.slice(0, msgMax - 1) + "…"
26
- : latest.message;
27
-
28
- return [` ${icon} [${badge}] ${truncated} (${formatShortcut("Ctrl+Alt+N")} or /gsd notifications)`];
17
+ return [` 🔔 Notifications: ${unread} unread (${formattedShortcutPair("notifications")})`];
29
18
  }
30
19
 
31
20
  // ─── Widget init ────────────────────────────────────────────────────────
@@ -51,6 +40,7 @@ export function initNotificationWidget(ctx: ExtensionContext): void {
51
40
  _tui.requestRender();
52
41
  };
53
42
 
43
+ const unsubscribe = onNotificationStoreChange(refresh);
54
44
  const refreshTimer = setInterval(refresh, REFRESH_INTERVAL_MS);
55
45
 
56
46
  return {
@@ -62,6 +52,7 @@ export function initNotificationWidget(ctx: ExtensionContext): void {
62
52
  cachedLines = undefined;
63
53
  },
64
54
  dispose(): void {
55
+ unsubscribe();
65
56
  clearInterval(refreshTimer);
66
57
  },
67
58
  };
@@ -2,7 +2,8 @@
2
2
  * GSD Parallel Monitor Overlay
3
3
  *
4
4
  * Full-screen TUI overlay showing real-time parallel worker progress.
5
- * Opened via `/gsd parallel watch` or Ctrl+Alt+P (⌃⌥P on macOS).
5
+ * Opened via `/gsd parallel watch`, Ctrl+Alt+P (⌃⌥P on macOS),
6
+ * or Ctrl+Shift+P fallback.
6
7
  * Reads the same data sources as `scripts/parallel-monitor.mjs` but
7
8
  * renders as a native pi-tui overlay with theme integration.
8
9
  */
@@ -15,6 +16,7 @@ import type { Theme } from "@gsd/pi-coding-agent";
15
16
  import { truncateToWidth, visibleWidth, matchesKey, Key } from "@gsd/pi-tui";
16
17
 
17
18
  import { formatDuration, STATUS_GLYPH, STATUS_COLOR } from "../shared/mod.js";
19
+ import { formattedShortcutPair } from "./shortcut-defs.js";
18
20
 
19
21
  // ─── Types ────────────────────────────────────────────────────────────────
20
22
 
@@ -347,7 +349,12 @@ export class ParallelMonitorOverlay {
347
349
  }
348
350
 
349
351
  handleInput(data: string): void {
350
- if (matchesKey(data, Key.escape) || data === "q") {
352
+ if (
353
+ matchesKey(data, Key.escape) ||
354
+ matchesKey(data, Key.ctrlAlt("p")) ||
355
+ matchesKey(data, Key.ctrlShift("p")) ||
356
+ data === "q"
357
+ ) {
351
358
  this.dispose();
352
359
  this.onClose();
353
360
  return;
@@ -486,7 +493,7 @@ export class ParallelMonitorOverlay {
486
493
  }
487
494
  lines.push(` ${t.bold("Total: $" + this.workers.reduce((s, wk) => s + wk.cost, 0).toFixed(2))}`);
488
495
  }
489
- lines.push(t.fg("muted", " ESC/q to close │ ↑↓ scroll"));
496
+ lines.push(t.fg("muted", ` ESC/q/${formattedShortcutPair("parallel")} close │ ↑↓ scroll`));
490
497
 
491
498
  // Apply scroll — use terminal rows as height estimate
492
499
  const termHeight = process.stdout.rows || 40;
@@ -0,0 +1,49 @@
1
+ // Canonical GSD shortcut definitions used by registration, help text, and overlays.
2
+
3
+ import { formatShortcut } from "./files.js";
4
+
5
+ export type GSDShortcutId = "dashboard" | "notifications" | "parallel";
6
+
7
+ type GSDShortcutDef = {
8
+ key: "g" | "n" | "p";
9
+ action: string;
10
+ command: string;
11
+ };
12
+
13
+ export const GSD_SHORTCUTS: Record<GSDShortcutId, GSDShortcutDef> = {
14
+ dashboard: {
15
+ key: "g",
16
+ action: "Open GSD dashboard",
17
+ command: "/gsd status",
18
+ },
19
+ notifications: {
20
+ key: "n",
21
+ action: "Open notification history",
22
+ command: "/gsd notifications",
23
+ },
24
+ parallel: {
25
+ key: "p",
26
+ action: "Open parallel worker monitor",
27
+ command: "/gsd parallel watch",
28
+ },
29
+ };
30
+
31
+ function combo(prefix: "Ctrl+Alt+" | "Ctrl+Shift+", key: string): string {
32
+ return `${prefix}${key.toUpperCase()}`;
33
+ }
34
+
35
+ export function primaryShortcutCombo(id: GSDShortcutId): string {
36
+ return combo("Ctrl+Alt+", GSD_SHORTCUTS[id].key);
37
+ }
38
+
39
+ export function fallbackShortcutCombo(id: GSDShortcutId): string {
40
+ return combo("Ctrl+Shift+", GSD_SHORTCUTS[id].key);
41
+ }
42
+
43
+ export function shortcutPair(id: GSDShortcutId, formatter: (combo: string) => string = (combo) => combo): string {
44
+ return `${formatter(primaryShortcutCombo(id))} / ${formatter(fallbackShortcutCombo(id))}`;
45
+ }
46
+
47
+ export function formattedShortcutPair(id: GSDShortcutId): string {
48
+ return shortcutPair(id, formatShortcut);
49
+ }
@@ -101,3 +101,65 @@ test("#1943 detectStuckLoops ignores watchdog duplicates but flags real re-dispa
101
101
  assert.equal(anomalies.length, 1, `expected 1 anomaly (for the 3x dispatched task), got ${anomalies.length}`);
102
102
  assert.ok(anomalies[0].summary.includes("3 times"));
103
103
  });
104
+
105
+ test("#3760 detectStuckLoops ignores cross-session recovery re-dispatches", () => {
106
+ const anomalies: ForensicAnomaly[] = [];
107
+
108
+ const units: UnitMetrics[] = [
109
+ makeUnit({
110
+ type: "plan-slice",
111
+ id: "M001/S02",
112
+ startedAt: 1000,
113
+ finishedAt: 2000,
114
+ autoSessionKey: "session-a",
115
+ }),
116
+ makeUnit({
117
+ type: "plan-slice",
118
+ id: "M001/S02",
119
+ startedAt: 5000,
120
+ finishedAt: 6000,
121
+ autoSessionKey: "session-b",
122
+ }),
123
+ ];
124
+
125
+ detectStuckLoops(units, anomalies);
126
+
127
+ assert.equal(anomalies.length, 0, "cross-session recovery should not be flagged as a stuck loop");
128
+ });
129
+
130
+ test("#3760 detectStuckLoops still flags repeated dispatches within one auto session", () => {
131
+ const anomalies: ForensicAnomaly[] = [];
132
+
133
+ const units: UnitMetrics[] = [
134
+ makeUnit({
135
+ type: "complete-slice",
136
+ id: "M011/S02",
137
+ startedAt: 1000,
138
+ finishedAt: 2000,
139
+ autoSessionKey: "session-a",
140
+ }),
141
+ makeUnit({
142
+ type: "complete-slice",
143
+ id: "M011/S02",
144
+ startedAt: 5000,
145
+ finishedAt: 6000,
146
+ autoSessionKey: "session-a",
147
+ }),
148
+ makeUnit({
149
+ type: "complete-slice",
150
+ id: "M011/S02",
151
+ startedAt: 9000,
152
+ finishedAt: 10000,
153
+ autoSessionKey: "session-b",
154
+ }),
155
+ ];
156
+
157
+ detectStuckLoops(units, anomalies);
158
+
159
+ assert.equal(anomalies.length, 1, "within-session retries should still be flagged");
160
+ assert.ok(anomalies[0].summary.includes("2 times"), `summary should reflect the worst same-session loop: ${anomalies[0].summary}`);
161
+ assert.ok(
162
+ anomalies[0].details.includes("Cross-session recovery runs are ignored"),
163
+ `details should explain the session-aware rule: ${anomalies[0].details}`,
164
+ );
165
+ });
@@ -4,6 +4,7 @@
4
4
  import test from 'node:test';
5
5
  import assert from 'node:assert/strict';
6
6
  import { formatShortcut } from '../files.ts';
7
+ import { formattedShortcutPair, primaryShortcutCombo, fallbackShortcutCombo } from '../shortcut-defs.ts';
7
8
 
8
9
  // ─── formatShortcut renders per-platform shortcuts ──────────────────────
9
10
 
@@ -67,3 +68,17 @@ test('formatShortcut: passes through plain key names', () => {
67
68
  assert.strictEqual(formatShortcut('Escape'), 'Escape');
68
69
  assert.strictEqual(formatShortcut('Enter'), 'Enter');
69
70
  });
71
+
72
+ test("shortcut-defs: exposes canonical dashboard combos", () => {
73
+ assert.equal(primaryShortcutCombo("dashboard"), "Ctrl+Alt+G");
74
+ assert.equal(fallbackShortcutCombo("dashboard"), "Ctrl+Shift+G");
75
+ });
76
+
77
+ test("shortcut-defs: formats shortcut pair using platform symbols", () => {
78
+ const pair = formattedShortcutPair("notifications");
79
+ if (process.platform === "darwin") {
80
+ assert.equal(pair, "⌃⌥N / ⌃⇧N");
81
+ } else {
82
+ assert.equal(pair, "Ctrl+Alt+N / Ctrl+Shift+N");
83
+ }
84
+ });