@zhuxixi/pi-agent-board 0.4.3 → 0.5.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 (39) hide show
  1. package/PROGRESS.md +18 -3
  2. package/README.md +295 -72
  3. package/VERIFY.md +3 -3
  4. package/docs/PTY_ATTACH_IMPLEMENTATION_PLAN.md +3 -3
  5. package/docs/superpowers/plans/2026-08-29-code-refs-badges.md +223 -0
  6. package/docs/superpowers/plans/2026-08-30-circular-navigation.md +308 -0
  7. package/docs/superpowers/plans/2026-08-30-post-exit-timing-fix.md +37 -0
  8. package/docs/superpowers/plans/2026-08-30-pty-attach-quality-debt.md +311 -0
  9. package/docs/superpowers/plans/2026-08-30-readme-v2.md +294 -0
  10. package/docs/superpowers/specs/2026-08-21-attach-coldstart-jiggle-rearm-design.md +4 -0
  11. package/docs/superpowers/specs/2026-08-22-jiggle-shrink-and-hold-design.md +4 -0
  12. package/docs/superpowers/specs/2026-08-29-code-refs-badges-design.md +128 -0
  13. package/docs/superpowers/specs/2026-08-30-circular-navigation-design.md +47 -0
  14. package/docs/superpowers/specs/2026-08-30-eprm-atomicwrite-race-design.md +77 -0
  15. package/docs/superpowers/specs/2026-08-30-post-exit-timing-fix-design.md +80 -0
  16. package/docs/superpowers/specs/2026-08-30-pty-attach-quality-debt-design.md +105 -0
  17. package/docs/superpowers/specs/2026-08-30-readme-v2-design.md +115 -0
  18. package/package.json +1 -1
  19. package/runner/job-runner.mjs +29 -3
  20. package/runner/pty-runner.mjs +139 -25
  21. package/runner/state-runner.mjs +7 -2
  22. package/runner/title-runner.mjs +1 -1
  23. package/src/core/atomic.mjs +40 -1
  24. package/src/core/code-refs-store.mjs +315 -0
  25. package/src/core/code-refs.mjs +861 -0
  26. package/src/core/host-crash.mjs +39 -0
  27. package/src/core/launch.mjs +6 -0
  28. package/src/core/paths.mjs +24 -1
  29. package/src/core/pty-attach-jiggle-controller.mjs +71 -17
  30. package/src/core/pty-attach-reconnect.mjs +43 -0
  31. package/src/core/pty-scroll.mjs +4 -3
  32. package/src/core/repo.mjs +56 -0
  33. package/src/core/rows.mjs +50 -0
  34. package/src/core/store.mjs +4 -1
  35. package/src/core/types.mjs +12 -0
  36. package/src/core/worktree.mjs +1 -0
  37. package/src/runtime/service.mjs +7 -1
  38. package/src/ui/dashboard.ts +20 -4
  39. package/src/ui/pty-attach.ts +124 -27
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Crash-time finalization for PTY hosts (used by pty-runner's uncaughtException
3
+ * handler). Pure enough to unit-test: writes a failed host.json + a diagnostic.
4
+ * Never throws — this runs on the crash path.
5
+ */
6
+ import { appendDiagnostic } from "./diagnostics.mjs";
7
+ import { writeHost } from "./store.mjs";
8
+
9
+ /**
10
+ * @param {string} root
11
+ * @param {string} viewId
12
+ * @param {object|null} host
13
+ * @param {unknown} error
14
+ * @returns {object}
15
+ */
16
+ export function finalizeHostCrash(root, viewId, host, error) {
17
+ const message = error instanceof Error ? error.message : String(error);
18
+ const failed = {
19
+ ...(host ?? { version: 1, viewId, mode: "pty", socketPath: null, startedAt: Date.now() }),
20
+ state: "failed",
21
+ endedAt: Date.now(),
22
+ lastSeenAt: Date.now(),
23
+ exitCode: 1,
24
+ error: message,
25
+ };
26
+ try {
27
+ writeHost(root, failed);
28
+ } catch { /* best effort */ }
29
+ try {
30
+ appendDiagnostic(root, viewId, {
31
+ source: "runner",
32
+ level: "error",
33
+ code: "host_crashed",
34
+ message: `PTY host crashed: ${message}`,
35
+ details: { error: message },
36
+ });
37
+ } catch { /* best effort */ }
38
+ return failed;
39
+ }
@@ -33,6 +33,9 @@ export function launchRun(root, config, opts) {
33
33
  detached: true,
34
34
  stdio: "ignore",
35
35
  env: process.env,
36
+ // Windows: detached children get their own console window unless
37
+ // suppressed (CREATE_NO_WINDOW; no-op on POSIX) — issue #49.
38
+ windowsHide: true,
36
39
  });
37
40
  child.unref();
38
41
 
@@ -61,6 +64,7 @@ export function launchHost(root, config, opts) {
61
64
  detached: true,
62
65
  stdio: "ignore",
63
66
  env: process.env,
67
+ windowsHide: true,
64
68
  });
65
69
  child.unref();
66
70
 
@@ -85,6 +89,7 @@ export function launchTitle(root, config, opts) {
85
89
  detached: true,
86
90
  stdio: "ignore",
87
91
  env: process.env,
92
+ windowsHide: true,
88
93
  });
89
94
  child.unref();
90
95
 
@@ -109,6 +114,7 @@ export function launchAutoState(root, config, opts) {
109
114
  detached: true,
110
115
  stdio: "ignore",
111
116
  env: process.env,
117
+ windowsHide: true,
112
118
  });
113
119
  child.unref();
114
120
 
@@ -23,6 +23,8 @@ export const launchPrefsPath = (root) => path.join(root, "launch-prefs.json");
23
23
  export const gcHistoryPath = (root) => path.join(root, "gc-history.jsonl");
24
24
  /** @param {string} root */
25
25
  export const cwdStatsPath = (root) => path.join(root, "cwd-stats.json");
26
+ /** @param {string} root */
27
+ export const providersPath = (root) => path.join(root, "providers.json");
26
28
 
27
29
  /** @param {string} root */
28
30
  export const viewsDir = (root) => path.join(root, "views");
@@ -40,8 +42,27 @@ export const hostConfigPath = (root, viewId) => path.join(viewDir(root, viewId),
40
42
  export const titleConfigPath = (root, viewId) => path.join(viewDir(root, viewId), "title-config.json");
41
43
  /** @param {string} root @param {string} viewId */
42
44
  export const autoStateConfigPath = (root, viewId) => path.join(viewDir(root, viewId), "auto-state-config.json");
45
+ /**
46
+ * Named pipe used for the host control socket on Windows. Named pipes are kernel
47
+ * objects, not filesystem entries, so existsSync/unlink never apply to them; they
48
+ * vanish automatically when the owning process exits. Names must be ≤ 256 chars.
49
+ * @param {string} viewId
50
+ */
51
+ export const controlPipeName = (viewId) => `\\\\.\\pipe\\pi-agent-board-${viewId}`;
52
+ /**
53
+ * Control socket address for a view, platform-aware. On Windows plain filesystem
54
+ * paths cannot be bound by node's net module (EACCES) — only `\\.\pipe\`-style
55
+ * names work (see libuv#458, nodejs/node#55979).
56
+ * @param {"win32"|"linux"|"darwin"} platform
57
+ * @param {string} root
58
+ * @param {string} viewId
59
+ */
60
+ export function controlSocketPathFor(platform, root, viewId) {
61
+ if (platform === "win32") return controlPipeName(viewId);
62
+ return path.join(viewDir(root, viewId), "control.sock");
63
+ }
43
64
  /** @param {string} root @param {string} viewId */
44
- export const controlSocketPath = (root, viewId) => path.join(viewDir(root, viewId), "control.sock");
65
+ export const controlSocketPath = (root, viewId) => controlSocketPathFor(process.platform, root, viewId);
45
66
  /** @param {string} root @param {string} viewId */
46
67
  export const screenLogPath = (root, viewId) => path.join(viewDir(root, viewId), "screen.log");
47
68
  /** @param {string} root @param {string} viewId */
@@ -65,6 +86,8 @@ export const diagnosticsPath = (root, viewId) => path.join(viewDir(root, viewId)
65
86
  /** @param {string} root @param {string} viewId */
66
87
  export const evidencePath = (root, viewId) => path.join(viewDir(root, viewId), "evidence.json");
67
88
  /** @param {string} root @param {string} viewId */
89
+ export const codeRefsPath = (root, viewId) => path.join(viewDir(root, viewId), "github.json");
90
+ /** @param {string} root @param {string} viewId */
68
91
  export const viewEvidencePath = evidencePath;
69
92
  /** @param {string} root @param {string} viewId */
70
93
  export const followUpQueuePath = (root, viewId) => path.join(viewDir(root, viewId), "queue.json");
@@ -2,10 +2,12 @@
2
2
  * Injectable orchestration for the attach shrink-and-hold jiggle protocol.
3
3
  *
4
4
  * Replaces the pulse-pair jiggle (shrink → 200ms → restore) with a
5
- * shrink-and-hold protocol: on connect we resize the child PTY down one
6
- * column/row and KEEP it there until the child's own rendering proves it
7
- * observed the width change (a full clear, \x1b[2J, which pi-tui emits from
8
- * fullRender(true) whenever widthChanged fires). Because there is no
5
+ * shrink-and-hold protocol: on connect we resize the child PTY to a safe
6
+ * alternate size and KEEP it there until the child's own rendering proves it
7
+ * observed the size change (a full clear, \x1b[2J, which pi-tui emits from
8
+ * fullRender(true) whenever widthChanged fires). At normal sizes the alternate
9
+ * size is one column/row smaller; near the minimum it changes only a safe
10
+ * dimension, and at 20x5 no jiggle is attempted. Because there is no
9
11
  * "restore" that can cancel the shrink before a clear is seen, event-loop
10
12
  * coalescing on either side (outer dashboard timers or the child's
11
13
  * SIGWINCH/render throttle) can no longer collapse a jiggle into a
@@ -27,10 +29,19 @@
27
29
  * G4 notifyExternalResize() — a real user resize cancels the hold and
28
30
  * adopts the new size.
29
31
  * G5 start() restores any previous hold before arming a new one (reconnect).
32
+ * G6 post-restore verify (issue #42): the frameStart fast path restores the
33
+ * hold on the FIRST frame, which may predate the shrink — if the restore
34
+ * then collapses both resizes into a net-zero change at the child, the
35
+ * child renders nothing and never clears. While a frame has been seen
36
+ * and no clear followed, each backoff tick re-arms the shrink (which
37
+ * lands alone and cannot collapse), bounded by the same retry budget.
30
38
  *
31
39
  * If pi-tui ever drops the 2026h sequence, re-arm degrades to G1: the PTY
32
- * is restored within 6s — still better than the pre-#10 behavior.
40
+ * is restored within 6s — still better than the pre-#10 behavior. At the
41
+ * minimum supported PTY size (20x5), jiggle is disabled because the runner's
42
+ * clamp leaves no valid alternate size.
33
43
  */
44
+ import { resizeJiggleSize } from "./pty-scroll.mjs";
34
45
  import {
35
46
  advanceRetry,
36
47
  createJiggleRetryState,
@@ -41,6 +52,13 @@ import {
41
52
 
42
53
  /** Restore the held (shrunk) child PTY when no TUI frame arrives this long. */
43
54
  const NO_FRAME_RESTORE_MS = 6000;
55
+ /**
56
+ * Grace window after the frameStart fast-path restore before the chain may
57
+ * re-poke (issue #42). A healthy child proves the restore landed with a
58
+ * fullRender clear within a few hundred ms even on large screens; without the
59
+ * grace a slow-but-healthy clear would trigger a pointless re-shrink flicker.
60
+ */
61
+ const POST_RESTORE_VERIFY_MS = 900;
44
62
 
45
63
  /**
46
64
  * @typedef {Object} JiggleRetryControllerDeps
@@ -64,6 +82,8 @@ export function createJiggleRetryController(deps) {
64
82
  /** @type {[number, number]} */
65
83
  let originalCols = 0;
66
84
  let originalRows = 0;
85
+ /** The safe alternate size used while a jiggle hold is active, or null when unsupported. */
86
+ let holdSize = null;
67
87
  /** @type {unknown | null} */
68
88
  let chainTimer = null;
69
89
  /** @type {unknown | null} */
@@ -98,10 +118,31 @@ export function createJiggleRetryController(deps) {
98
118
  }
99
119
 
100
120
  /**
101
- * Backoff chain is a pure countdown under the hold protocol: firing a
102
- * retry schedules the next backoff (no pulse is sent — the shrink is
103
- * already held). When the budget runs out, G2 restores the size.
121
+ * One chain tick: advance the backoff budget, then re-arm the hold if the
122
+ * child has a renderer (a TUI frame was seen) but no clear ever proved the
123
+ * last size delta landed and the hold is currently inactive (issue #42:
124
+ * the frameStart fast-path restore can race the shrink into a net-zero
125
+ * size change at the child — its throttled renderer reads back the
126
+ * original size and stays silent forever — so a clear-less restore must
127
+ * be followed by a fresh shrink, which lands alone and cannot collapse).
128
+ * Children that never rendered a frame (shells, cold boots) keep the old
129
+ * pure-countdown behavior and are handled by G1/G2 only.
104
130
  */
131
+ function tickChain() {
132
+ chainTimer = null;
133
+ state = advanceRetry(state);
134
+ ensureHold();
135
+ if (state.clearDetected || state.stopped) return;
136
+ scheduleNextRetry();
137
+ }
138
+
139
+ function ensureHold() {
140
+ if (!tuiFrameSeen || held || state.clearDetected || state.stopped || !holdSize) return;
141
+ sendResize(holdSize.cols, holdSize.rows);
142
+ held = true;
143
+ restored = false;
144
+ }
145
+
105
146
  function scheduleNextRetry() {
106
147
  const delay = nextRetryDelay(state);
107
148
  if (delay === null) {
@@ -109,11 +150,7 @@ export function createJiggleRetryController(deps) {
109
150
  restoreIfHeld(); // G2
110
151
  return;
111
152
  }
112
- chainTimer = setTimeoutFn(() => {
113
- chainTimer = null;
114
- state = advanceRetry(state);
115
- scheduleNextRetry();
116
- }, delay);
153
+ chainTimer = setTimeoutFn(tickChain, delay);
117
154
  }
118
155
 
119
156
  function armG1() {
@@ -142,8 +179,18 @@ export function createJiggleRetryController(deps) {
142
179
  tuiFrameSeen = false;
143
180
  originalCols = cols;
144
181
  originalRows = rows;
182
+ holdSize = resizeJiggleSize(cols, rows);
145
183
  sendResize(cols, rows);
146
- sendResize(cols - 1, rows - 1);
184
+ if (!holdSize) {
185
+ // The runner clamps below 20x5, so a smaller request would be a
186
+ // net-zero resize and cannot trigger a redraw. Leave the child at the
187
+ // requested original size and disable this attach's jiggle chain.
188
+ state = stopRetry(state);
189
+ held = false;
190
+ restored = true;
191
+ return;
192
+ }
193
+ sendResize(holdSize.cols, holdSize.rows);
147
194
  held = true;
148
195
  restored = false;
149
196
  armG1();
@@ -176,14 +223,20 @@ export function createJiggleRetryController(deps) {
176
223
  clearG1Timer();
177
224
  if (held) {
178
225
  restoreIfHeld(); // fast path: child is rendering, width delta now lands
226
+ // …unless that frame was in flight BEFORE the shrink and the restore
227
+ // collapses both into a net-zero change at the child (issue #42).
228
+ // Restart the chain on the post-restore grace window: no clear within
229
+ // POST_RESTORE_VERIFY_MS → tickChain re-arms the hold (ensureHold).
230
+ clearChainTimer();
231
+ chainTimer = setTimeoutFn(tickChain, POST_RESTORE_VERIFY_MS);
179
232
  } else {
180
233
  // Slow boot: G1 released the hold before the TUI came up, so the
181
234
  // child baselined at the original size. Re-arm a fresh hold — its
182
235
  // next frame then sees a width delta and fullRenders. Guards: the
183
236
  // clear path (primary) and G2 budget exhaustion (chain still
184
237
  // ticking). G1 is NOT re-armed: frames are now flowing.
185
- sendResize(originalCols - 1, originalRows - 1);
186
- held = true;
238
+ if (holdSize) sendResize(holdSize.cols, holdSize.rows);
239
+ held = holdSize !== null;
187
240
  restored = false;
188
241
  }
189
242
  }
@@ -212,6 +265,7 @@ export function createJiggleRetryController(deps) {
212
265
  restored = false;
213
266
  originalCols = cols;
214
267
  originalRows = rows;
268
+ holdSize = null;
215
269
  state = stopRetry(state);
216
270
  }
217
271
 
@@ -220,6 +274,6 @@ export function createJiggleRetryController(deps) {
220
274
  feed,
221
275
  restoreAndStop,
222
276
  notifyExternalResize,
223
- getState: () => ({ ...state, held, tuiFrameSeen, originalCols, originalRows }),
277
+ getState: () => ({ ...state, held, tuiFrameSeen, originalCols, originalRows, holdSize }),
224
278
  };
225
279
  }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Attach reconnect policy (issue #48). The pty-runner can die without sending
3
+ * an exit message (uncaught crash), which previously left the attach view in a
4
+ * 150 ms reconnect loop forever with no way out. Two regimes:
5
+ * - ever connected: a crashed host is unrecoverable quickly — give up after a
6
+ * short window and report "host exited";
7
+ * - never connected: the host may still be cold-starting (service launches it
8
+ * right before attach) — allow a long window before declaring it unreachable.
9
+ */
10
+
11
+ /** Give-up delay after a previously-established connection drops (ms). */
12
+ export const ATTACH_RECONNECT_TIMEOUT_MS = 15_000;
13
+ /** Give-up delay while waiting for the initial host connection (ms). */
14
+ export const ATTACH_HOST_START_TIMEOUT_MS = 120_000;
15
+
16
+ /**
17
+ * @param {{ everConnected: boolean, disconnectedAt: number|null, connectStartedAt: number, now: number }} input
18
+ * @returns {{ giveUp: boolean, status: string | null }}
19
+ */
20
+ export function evaluateAttachReconnect({ everConnected, disconnectedAt, connectStartedAt, now }) {
21
+ if (everConnected && disconnectedAt !== null) {
22
+ const elapsed = now - disconnectedAt;
23
+ return elapsed >= ATTACH_RECONNECT_TIMEOUT_MS
24
+ ? { giveUp: true, status: "host exited" }
25
+ : { giveUp: false, status: null };
26
+ }
27
+ const elapsed = now - connectStartedAt;
28
+ return elapsed >= ATTACH_HOST_START_TIMEOUT_MS
29
+ ? { giveUp: true, status: "host not reachable" }
30
+ : { giveUp: false, status: null };
31
+ }
32
+
33
+ /**
34
+ * Detach-key policy: while the socket is down a key can never reach the child,
35
+ * so treat ← / ctrl+] as "leave the view" unconditionally; otherwise keep the
36
+ * existing empty-input-line gate (issue #48 comment 1).
37
+ * @param {boolean} connected
38
+ * @param {boolean} childInputLooksEmpty
39
+ * @returns {boolean}
40
+ */
41
+ export function shouldEscapeAttach(connected, childInputLooksEmpty) {
42
+ return !connected || childInputLooksEmpty;
43
+ }
@@ -141,9 +141,10 @@ export function selectionDragScrollLines(row, bodyHeight) {
141
141
  }
142
142
 
143
143
  /**
144
- * Return a one-frame alternate PTY size that will force a hosted TUI to receive a
145
- * SIGWINCH/redraw before restoring the real size. This mirrors the user-visible
146
- * terminal zoom workaround without leaving the child at the wrong dimensions.
144
+ * Return a safe temporary PTY size for an attach jiggle hold. The attach controller
145
+ * keeps the child at this size until a clear or guard restores the original size;
146
+ * near the minimum supported dimensions, only one safe dimension is changed, and
147
+ * at 20x5 there is no valid alternate size.
147
148
  */
148
149
  export function resizeJiggleSize(cols, rows) {
149
150
  const c = Math.max(1, Math.floor(cols));
package/src/core/repo.mjs CHANGED
@@ -1,5 +1,6 @@
1
1
  /** Git repository identity. Pure node (shells out to `git`); returns null off-repo. */
2
2
  import { execFileSync } from "node:child_process";
3
+ import { parseRemoteHost } from "./code-refs.mjs";
3
4
 
4
5
  /**
5
6
  * Resolve the git repo root containing `cwd`, or null if not in a repo.
@@ -11,6 +12,7 @@ export function gitRepoRoot(cwd) {
11
12
  const out = execFileSync("git", ["-C", cwd, "rev-parse", "--show-toplevel"], {
12
13
  encoding: "utf8",
13
14
  stdio: ["ignore", "pipe", "ignore"],
15
+ windowsHide: true,
14
16
  });
15
17
  const root = out.trim();
16
18
  return root || null;
@@ -39,9 +41,63 @@ export function isDirty(repoRoot) {
39
41
  const out = execFileSync("git", ["-C", repoRoot, "status", "--porcelain"], {
40
42
  encoding: "utf8",
41
43
  stdio: ["ignore", "pipe", "ignore"],
44
+ windowsHide: true,
42
45
  });
43
46
  return out.trim().length > 0;
44
47
  } catch {
45
48
  return false;
46
49
  }
47
50
  }
51
+
52
+ /**
53
+ * Cached origin-remote URL per repo root. Failures are cached as null so each
54
+ * repo root is only ever queried once per process.
55
+ * @type {Map<string, string|null>}
56
+ */
57
+ const remoteUrlCache = new Map();
58
+
59
+ /**
60
+ * Raw URL of the `origin` remote of `repoRoot`, or null when the repo has no
61
+ * origin remote or is not a repo. The result (including misses) is cached per
62
+ * repoRoot.
63
+ * @param {string} repoRoot
64
+ * @returns {string|null}
65
+ */
66
+ export function gitRemoteUrl(repoRoot) {
67
+ const cached = remoteUrlCache.get(repoRoot);
68
+ if (cached !== undefined) return cached;
69
+ let url = null;
70
+ try {
71
+ const out = execFileSync("git", ["-C", repoRoot, "remote", "get-url", "origin"], {
72
+ encoding: "utf8",
73
+ stdio: ["ignore", "pipe", "ignore"],
74
+ timeout: 2000,
75
+ windowsHide: true,
76
+ });
77
+ url = out.trim() || null;
78
+ } catch {
79
+ // not a repo or no origin remote
80
+ }
81
+ remoteUrlCache.set(repoRoot, url);
82
+ return url;
83
+ }
84
+
85
+ /**
86
+ * Host of the `origin` remote of `repoRoot`, lowercased and without port, or
87
+ * null when the repo has no origin remote or is not a repo. Supports
88
+ * `https://host/owner/repo(.git)` and `git@host:owner/repo(.git)`; parsed from
89
+ * the cached raw remote URL.
90
+ * @param {string} repoRoot
91
+ * @returns {string|null}
92
+ */
93
+ export function gitRemoteHost(repoRoot) {
94
+ return parseRemoteHost(gitRemoteUrl(repoRoot));
95
+ }
96
+
97
+ /**
98
+ * Clear the gitRemoteHost cache. Test-only helper.
99
+ * @returns {void}
100
+ */
101
+ export function clearRemoteHostCacheForTests() {
102
+ remoteUrlCache.clear();
103
+ }
package/src/core/rows.mjs CHANGED
@@ -10,6 +10,13 @@ import { GROUP_LABELS, GROUP_ORDER, SEMANTIC_STATES } from "./types.mjs";
10
10
  /** @typedef {import("./store.mjs").Row} Row */
11
11
  /** @typedef {import("./types.mjs").SemanticState} SemanticState */
12
12
 
13
+ /**
14
+ * Host heartbeat staleness threshold (ms). The pty-runner writes host.json on a
15
+ * 1 Hz heartbeat; when a crashed runner leaves host.json stuck on "alive", the
16
+ * heartbeat goes stale within seconds (issue #48 L4).
17
+ */
18
+ const STALE_HOST_MS = 10_000;
19
+
13
20
  /**
14
21
  * @typedef {Object} RowView
15
22
  * @property {string} id
@@ -24,6 +31,8 @@ import { GROUP_LABELS, GROUP_ORDER, SEMANTIC_STATES } from "./types.mjs";
24
31
  * @property {SemanticState} state
25
32
  * @property {boolean} alive
26
33
  * @property {boolean} hostAlive
34
+ * @property {boolean} staleHost True when host.json claims alive but the runner
35
+ * process is gone and the heartbeat is stale (display-only crash marker).
27
36
  * @property {boolean} needsInput
28
37
  * @property {boolean} hasError
29
38
  * @property {boolean} worktree
@@ -35,6 +44,9 @@ import { GROUP_LABELS, GROUP_ORDER, SEMANTIC_STATES } from "./types.mjs";
35
44
  * @property {number} followUpCount
36
45
  * @property {string|null} followUpPreview
37
46
  * @property {string} steeringState
47
+ * @property {string} refsBadge Inline issue/PR badge (e.g. "#40 ▸#45"); "" when there are no refs.
48
+ * @property {boolean} refsLowConfidence True when the winning issue or pr confidence is "low".
49
+ * @property {import("./types.mjs").CodeRefsSummary|null} codeRefs Compact ref summary the peek view consumes.
38
50
  * @property {number} lastActivityAt
39
51
  * @property {number} createdAt
40
52
  */
@@ -115,6 +127,19 @@ export function rowView(row, now) {
115
127
  const place = folderName;
116
128
  const lastVisitedAt = row.state?.lastVisitedAt ?? null;
117
129
  const lastAgentActivityAt = row.state?.lastAgentActivityAt ?? null;
130
+ const refs = codeRefsBadge(row.codeRefs);
131
+ // Host-lost display marker (issue #48 L4): host.json still claims "alive"
132
+ // but the runner process is gone and the 1 Hz heartbeat went stale — the
133
+ // crash path leaves exactly this fingerprint (no exit message, no
134
+ // reconcile). Display-only; state.json is deliberately untouched.
135
+ const host = row.host;
136
+ const staleHost = Boolean(
137
+ host &&
138
+ host.state === "alive" &&
139
+ !row.hostAlive &&
140
+ typeof host.lastSeenAt === "number" &&
141
+ now - host.lastSeenAt > STALE_HOST_MS,
142
+ );
118
143
  return {
119
144
  id: row.meta.id,
120
145
  name: row.meta.name,
@@ -128,6 +153,7 @@ export function rowView(row, now) {
128
153
  state,
129
154
  alive: Boolean(row.alive),
130
155
  hostAlive: Boolean(row.hostAlive),
156
+ staleHost,
131
157
  needsInput: state === "needs_input",
132
158
  hasError: state === "failed",
133
159
  worktree,
@@ -139,11 +165,35 @@ export function rowView(row, now) {
139
165
  followUpCount: row.state?.followUps?.queuedCount ?? 0,
140
166
  followUpPreview: row.state?.followUps?.lastQueuedPreview ?? null,
141
167
  steeringState: row.state?.steering?.status ?? "none",
168
+ refsBadge: refs.refsBadge,
169
+ refsLowConfidence: refs.refsLowConfidence,
170
+ codeRefs: row.codeRefs ?? null,
142
171
  lastActivityAt,
143
172
  createdAt: row.meta.createdAt ?? 0,
144
173
  };
145
174
  }
146
175
 
176
+ /**
177
+ * Map a CodeRefsSummary to the inline badge strings shown on dashboard rows:
178
+ * the winning issue and pr joined by a space, each prefixed with the provider
179
+ * prefix stored on the summary; plus the low-confidence flag that makes the
180
+ * dashboard dim the badge. Missing/garbage summaries yield an empty badge.
181
+ * @param {import("./types.mjs").CodeRefsSummary|null|undefined} summary
182
+ * @returns {{ refsBadge: string, refsLowConfidence: boolean }}
183
+ */
184
+ function codeRefsBadge(summary) {
185
+ if (!summary) return { refsBadge: "", refsLowConfidence: false };
186
+ const issue = summary.issue;
187
+ const pr = summary.pr;
188
+ const parts = [];
189
+ if (issue && Number.isFinite(issue.number)) parts.push(`${summary.issuePrefix ?? "#"}${issue.number}`);
190
+ if (pr && Number.isFinite(pr.number)) parts.push(`${summary.prPrefix ?? "▸#"}${pr.number}`);
191
+ return {
192
+ refsBadge: parts.join(" "),
193
+ refsLowConfidence: issue?.confidence === "low" || pr?.confidence === "low",
194
+ };
195
+ }
196
+
147
197
  /** @param {string} text */
148
198
  function oneLine(text) {
149
199
  return String(text || "").replace(/\s+/g, " ").trim() || "—";
@@ -7,6 +7,7 @@ import { existsSync, readdirSync, statSync } from "node:fs";
7
7
  import { atomicWriteJson, ensureDir, readJson } from "./atomic.mjs";
8
8
  import * as P from "./paths.mjs";
9
9
  import { isAlive } from "./pid.mjs";
10
+ import { readCodeRefs, summarizeCodeRefs } from "./code-refs-store.mjs";
10
11
  import { readDiagnosticSummary } from "./diagnostics.mjs";
11
12
  import { readEvidence, summarizeEvidence } from "./evidence.mjs";
12
13
  import { readFollowUpQueue, summarizeFollowUpQueue } from "./follow-up-queue.mjs";
@@ -181,6 +182,7 @@ function mtime(dir) {
181
182
  * @property {import("./types.mjs").DiagnosticSummary} [diagnostics]
182
183
  * @property {import("./types.mjs").FollowUpSummary} [followUps]
183
184
  * @property {import("./types.mjs").SteeringSummary} [steering]
185
+ * @property {import("./types.mjs").CodeRefsSummary} [codeRefs]
184
186
  */
185
187
 
186
188
  /** @param {string} root @param {string} viewId @returns {Row|null} */
@@ -211,7 +213,8 @@ export function readViewArtifactSummaries(root, viewId) {
211
213
  const diagnostics = readDiagnosticSummary(root, viewId);
212
214
  const followUps = summarizeFollowUpQueue(readFollowUpQueue(root, viewId));
213
215
  const steering = summarizeSteering(readSteering(root, viewId));
214
- return { review, diagnostics, followUps, steering };
216
+ const codeRefs = summarizeCodeRefs(readCodeRefs(root, viewId));
217
+ return { review, diagnostics, followUps, steering, codeRefs };
215
218
  }
216
219
 
217
220
  /**
@@ -118,6 +118,7 @@ export const GROUP_LABELS = {
118
118
  * @property {DiagnosticSummary} [diagnostics] Compact diagnostics summary.
119
119
  * @property {FollowUpSummary} [followUps] Compact queued follow-up summary.
120
120
  * @property {SteeringSummary} [steering] Compact plan/approval steering summary.
121
+ * @property {CodeRefsSummary} [codeRefs] Compact issue/PR reference summary.
121
122
  * @property {AutoStateClassification|null} [autoState] Latest automatic terminal-state classification.
122
123
  */
123
124
 
@@ -364,6 +365,17 @@ export const GROUP_LABELS = {
364
365
 
365
366
  /** @typedef {{ status:SteeringModeState, awaitingApproval:boolean, planPreview:string|null, updatedAt:number|null, question:string|null }} SteeringSummary */
366
367
 
368
+ /**
369
+ * Compact issue/PR reference summary for a view (`github.json`).
370
+ * @typedef {Object} CodeRefsSummary
371
+ * @property {string|null} provider Resolved provider name (e.g. "github"); always non-null after a successful extraction (falls back to "generic"), null only before any extraction has run.
372
+ * @property {string} issuePrefix Issue-number prefix resolved from the matched provider at write time (default "#").
373
+ * @property {string} prPrefix PR/MR-number prefix resolved from the matched provider at write time (default "▸#"; "!" for gitlab).
374
+ * @property {import("./code-refs.mjs").Ref|null} issue
375
+ * @property {import("./code-refs.mjs").Ref|null} pr
376
+ * @property {import("./code-refs.mjs").Ref[]} allRefs Distinct refs for the peek view (max 10).
377
+ */
378
+
367
379
  /** Roster index (`roster.json`). @typedef {Object} Roster @property {number} version @property {string[]} views */
368
380
 
369
381
  /**
@@ -15,6 +15,7 @@ function git(repoRoot, args) {
15
15
  const stdout = execFileSync("git", ["-C", repoRoot, ...args], {
16
16
  encoding: "utf8",
17
17
  stdio: ["ignore", "pipe", "pipe"],
18
+ windowsHide: true,
18
19
  });
19
20
  return { ok: true, stdout: stdout.trim(), error: null };
20
21
  } catch (err) {
@@ -13,6 +13,7 @@ import { appendLine } from "../core/atomic.mjs";
13
13
  import { finalizeRun, projectViewState, reduceEvent } from "../core/events.mjs";
14
14
  import { clearDiagnostics, appendDiagnostic, tailDiagnostics } from "../core/diagnostics.mjs";
15
15
  import { emptyEvidenceSnapshot, finalizeEvidence, readEvidence, reduceEvidence, summarizeEvidence, writeEvidence } from "../core/evidence.mjs";
16
+ import { updateCodeRefsFromEvidence } from "../core/code-refs-store.mjs";
16
17
  import { claimNextFollowUp, completeFollowUp, enqueueFollowUp, readFollowUpQueue, releaseFollowUp, summarizeFollowUpQueue, clearQueuedFollowUps, removeLastFollowUp } from "../core/follow-up-queue.mjs";
17
18
  import { approvePlan as approvePlanState, markExecutingApprovedPlan, readSteering, recordPlanReady, requestPlan as requestPlanState, requestPlanChanges as requestPlanChangesState, summarizeSteering } from "../core/steering.mjs";
18
19
  import { buildApprovePlanPrompt, buildPlanChangesPrompt, buildPlanRequestPrompt } from "../core/steering-prompts.mjs";
@@ -517,6 +518,7 @@ export function createService(opts) {
517
518
  try {
518
519
  reduceEvidence(evidence, event, now);
519
520
  writeEvidence(root, evidence);
521
+ updateCodeRefsFromEvidence(root, row.meta.id, evidence, row.meta);
520
522
  } catch (err) {
521
523
  appendDiagnostic(root, row.meta.id, { source: "evidence", level: "warn", code: "evidence_reduce_failed", message: "Could not reduce hosted evidence", details: { error: err instanceof Error ? err.message : String(err) } });
522
524
  }
@@ -548,6 +550,7 @@ export function createService(opts) {
548
550
  }
549
551
  status.evidenceSummary = summarizeEvidence(evidence);
550
552
  writeEvidence(root, evidence);
553
+ updateCodeRefsFromEvidence(root, row.meta.id, evidence, row.meta);
551
554
  writeForegroundState(row, status);
552
555
  pruneWarmHosts({ keepViewId: row.meta.id });
553
556
  drainNextFollowUp(row.meta.id);
@@ -1142,7 +1145,10 @@ function compactSummary(text) {
1142
1145
  function sendHostMessage(row, message) {
1143
1146
  const socketPath = row.host?.socketPath;
1144
1147
  if (!socketPath) return { ok: false, error: "No host socket" };
1145
- if (!existsSync(socketPath)) return { ok: false, error: "Host socket is not ready" };
1148
+ // On Windows the control socket is a named pipe, which never exists as a
1149
+ // filesystem entry, so existsSync can't be used as a readiness probe there.
1150
+ // A missing pipe surfaces as a connection error on the socket below.
1151
+ if (process.platform !== "win32" && !existsSync(socketPath)) return { ok: false, error: "Host socket is not ready" };
1146
1152
  try {
1147
1153
  const socket = createConnection(socketPath);
1148
1154
  socket.on("connect", () => {