@zhuxixi/pi-agent-board 0.4.2 → 0.5.0

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.
@@ -5,6 +5,24 @@ import { truncate } from "./heuristics.mjs";
5
5
  import { withViewLockSync } from "./locks.mjs";
6
6
  import * as P from "./paths.mjs";
7
7
 
8
+ /**
9
+ * Run a queue mutation under the view lock, translating any failure (lock
10
+ * unavailable, fs errors inside the mutation) into {ok:false} so callers on
11
+ * the {ok} convention never see a throw (issue #33).
12
+ * @template T
13
+ * @param {string} root
14
+ * @param {string} viewId
15
+ * @param {() => T} fn
16
+ * @returns {T | { ok: false, error: string }}
17
+ */
18
+ function lockedQueueOp(root, viewId, fn) {
19
+ try {
20
+ return withViewLockSync(root, viewId, "queue", fn);
21
+ } catch (err) {
22
+ return { ok: false, error: `follow-up queue lock unavailable: ${err instanceof Error ? err.message : String(err)}` };
23
+ }
24
+ }
25
+
8
26
  /** @param {string} viewId @param {number} [now] @returns {import("./types.mjs").FollowUpQueue} */
9
27
  export function emptyFollowUpQueue(viewId, now = Date.now()) {
10
28
  return { version: 1, viewId, nextSeq: 1, updatedAt: now, items: [] };
@@ -41,7 +59,7 @@ export function summarizeFollowUpQueue(queue) {
41
59
  export function enqueueFollowUp(root, viewId, text, opts = {}) {
42
60
  const clean = String(text || "").trim();
43
61
  if (!clean) return { ok: false, error: "Empty follow-up" };
44
- return withViewLockSync(root, viewId, "queue", () => {
62
+ return lockedQueueOp(root, viewId, () => {
45
63
  const queue = readFollowUpQueue(root, viewId);
46
64
  const now = Date.now();
47
65
  const item = {
@@ -70,7 +88,7 @@ export function enqueueFollowUp(root, viewId, text, opts = {}) {
70
88
 
71
89
  /** @param {string} root @param {string} viewId @param {{ runId?: string|null }} [opts] */
72
90
  export function claimNextFollowUp(root, viewId, opts = {}) {
73
- return withViewLockSync(root, viewId, "queue", () => {
91
+ return lockedQueueOp(root, viewId, () => {
74
92
  const queue = readFollowUpQueue(root, viewId);
75
93
  const item = queue.items.filter((i) => i.status === "queued").sort((a, b) => a.seq - b.seq)[0];
76
94
  if (!item) return { ok: false, error: "No queued follow-up" };
@@ -114,7 +132,7 @@ export function releaseFollowUp(root, viewId, itemId) {
114
132
 
115
133
  /** @param {string} root @param {string} viewId */
116
134
  export function removeLastFollowUp(root, viewId) {
117
- return withViewLockSync(root, viewId, "queue", () => {
135
+ return lockedQueueOp(root, viewId, () => {
118
136
  const queue = readFollowUpQueue(root, viewId);
119
137
  const queued = queue.items.filter((i) => i.status === "queued").sort((a, b) => b.seq - a.seq);
120
138
  const last = queued[0];
@@ -128,7 +146,7 @@ export function removeLastFollowUp(root, viewId) {
128
146
 
129
147
  /** @param {string} root @param {string} viewId */
130
148
  export function clearQueuedFollowUps(root, viewId) {
131
- return withViewLockSync(root, viewId, "queue", () => {
149
+ return lockedQueueOp(root, viewId, () => {
132
150
  const queue = readFollowUpQueue(root, viewId);
133
151
  let cancelled = 0;
134
152
  for (const item of queue.items) {
@@ -145,7 +163,7 @@ export function clearQueuedFollowUps(root, viewId) {
145
163
 
146
164
  /** @param {string} root @param {string} viewId @param {string} itemId @param {(item: import("./types.mjs").FollowUpItem) => void} mutate */
147
165
  function updateItem(root, viewId, itemId, mutate) {
148
- return withViewLockSync(root, viewId, "queue", () => {
166
+ return lockedQueueOp(root, viewId, () => {
149
167
  const queue = readFollowUpQueue(root, viewId);
150
168
  const item = queue.items.find((i) => i.id === itemId);
151
169
  if (!item) return { ok: false, error: "Unknown follow-up" };
@@ -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
+ }
@@ -1,6 +1,14 @@
1
1
  /**
2
2
  * Tiny dependency-free synchronous file lock helpers for local agent-board artifacts.
3
3
  * Locks use atomic mkdir on a sibling .lock directory and are cleaned up in finally.
4
+ *
5
+ * Failure model (issue #33): acquisition failures are classified.
6
+ * - EEXIST (contention): wait in 20ms ticks until the stale window passes, then
7
+ * force-steal (bounded to MAX_STEAL_ATTEMPTS). This preserves the original
8
+ * wait/steal contract (see test/locks.test.mjs).
9
+ * - Anything else (deleted parent, read-only fs, permissions, disk full, ...):
10
+ * MAX_ENV_ATTEMPTS quick retries — each retry re-runs ensureDir so a parent
11
+ * deleted mid-acquisition self-heals — then throw LOCK_TIMEOUT. Never spin.
4
12
  */
5
13
  import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
6
14
  import * as path from "node:path";
@@ -8,22 +16,33 @@ import { ensureDir } from "./atomic.mjs";
8
16
  import * as P from "./paths.mjs";
9
17
 
10
18
  const DEFAULT_STALE_MS = 30_000;
19
+ /** Minimum contention window before a fresh lock can be force-stolen. */
20
+ const MIN_WINDOW_MS = 250;
21
+ const WAIT_TICK_MS = 20;
22
+ /** Max stale-lock steal attempts before giving up. */
23
+ const MAX_STEAL_ATTEMPTS = 2;
24
+ /** Max quick retries for environmental failures before giving up. */
25
+ const MAX_ENV_ATTEMPTS = 3;
26
+
27
+ export const defaultLocksFs = Object.freeze({ existsSync, mkdirSync, readFileSync, rmSync, writeFileSync });
11
28
 
12
29
  /**
13
30
  * @template T
14
31
  * @param {string} lockPath
15
32
  * @param {() => T} fn
16
- * @param {{ staleMs?: number }} [opts]
33
+ * @param {{ staleMs?: number, fs?: typeof defaultLocksFs }} [opts]
17
34
  * @returns {T}
18
35
  */
19
36
  export function withFileLockSync(lockPath, fn, opts = {}) {
20
- const staleMs = opts.staleMs ?? DEFAULT_STALE_MS;
21
- acquireLock(lockPath, staleMs);
37
+ const fs = opts.fs ?? defaultLocksFs;
38
+ acquireLock(lockPath, opts.staleMs ?? DEFAULT_STALE_MS, fs);
39
+ let result;
22
40
  try {
23
- return fn();
41
+ result = fn();
24
42
  } finally {
25
- releaseLock(lockPath);
43
+ releaseLock(lockPath, fs);
26
44
  }
45
+ return result;
27
46
  }
28
47
 
29
48
  /**
@@ -32,37 +51,74 @@ export function withFileLockSync(lockPath, fn, opts = {}) {
32
51
  * @param {string} viewId
33
52
  * @param {string} name
34
53
  * @param {() => T} fn
35
- * @param {{ staleMs?: number }} [opts]
54
+ * @param {{ staleMs?: number, fs?: typeof defaultLocksFs }} [opts]
36
55
  * @returns {T}
37
56
  */
38
57
  export function withViewLockSync(root, viewId, name, fn, opts = {}) {
39
58
  return withFileLockSync(P.viewLockPath(root, viewId, name), fn, opts);
40
59
  }
41
60
 
42
- /** @param {string} lockPath @param {number} staleMs */
43
- function acquireLock(lockPath, staleMs) {
44
- ensureDir(path.dirname(lockPath));
45
- const started = Date.now();
46
- while (true) {
61
+ /** @param {string} lockPath @param {number} staleMs @param {typeof defaultLocksFs} fs */
62
+ function acquireLock(lockPath, staleMs, fs) {
63
+ const deadline = Date.now() + Math.max(MIN_WINDOW_MS, staleMs);
64
+ let steals = 0;
65
+ let envAttempts = 0;
66
+ for (;;) {
67
+ let created = false;
47
68
  try {
48
- mkdirSync(lockPath);
49
- writeFileSync(path.join(lockPath, "owner.json"), JSON.stringify({ pid: process.pid, at: Date.now() }), "utf8");
69
+ // Re-run every attempt: a parent deleted mid-acquisition self-heals here.
70
+ ensureDir(path.dirname(lockPath));
71
+ fs.mkdirSync(lockPath);
72
+ created = true;
73
+ fs.writeFileSync(
74
+ path.join(lockPath, "owner.json"),
75
+ JSON.stringify({ pid: process.pid, at: Date.now() }),
76
+ "utf8",
77
+ );
50
78
  return;
51
79
  } catch (err) {
52
- if (!isLockStale(lockPath, staleMs) && Date.now() - started < Math.max(250, staleMs)) {
53
- Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 20);
80
+ if (err && err.code === "EEXIST") {
81
+ const expired = Date.now() >= deadline;
82
+ if (isLockStale(lockPath, staleMs, fs) || expired) {
83
+ if (steals >= MAX_STEAL_ATTEMPTS) {
84
+ throw lockError(lockPath, `stale lock could not be stolen after ${steals} attempts`);
85
+ }
86
+ steals += 1;
87
+ releaseLock(lockPath, fs);
88
+ continue;
89
+ }
90
+ sleep(WAIT_TICK_MS);
54
91
  continue;
55
92
  }
56
- releaseLock(lockPath);
93
+ // Environmental failure: bounded quick retries, then fail fast.
94
+ if (created) releaseLock(lockPath, fs);
95
+ envAttempts += 1;
96
+ if (envAttempts >= MAX_ENV_ATTEMPTS) {
97
+ const reason = (err && (err.code || err.message)) || "unknown error";
98
+ throw lockError(lockPath, `lock path unusable (${reason})`);
99
+ }
100
+ sleep(WAIT_TICK_MS);
57
101
  }
58
102
  }
59
103
  }
60
104
 
61
- /** @param {string} lockPath @param {number} staleMs */
62
- function isLockStale(lockPath, staleMs) {
105
+ /** @param {string} lockPath @param {string} reason */
106
+ function lockError(lockPath, reason) {
107
+ const err = new Error(`file lock unavailable: ${lockPath} (${reason})`);
108
+ err.code = "LOCK_TIMEOUT";
109
+ return err;
110
+ }
111
+
112
+ /** @param {number} ms */
113
+ function sleep(ms) {
114
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
115
+ }
116
+
117
+ /** @param {string} lockPath @param {number} staleMs @param {typeof defaultLocksFs} fs */
118
+ function isLockStale(lockPath, staleMs, fs) {
63
119
  try {
64
- if (!existsSync(lockPath)) return false;
65
- const raw = readFileSync(path.join(lockPath, "owner.json"), "utf8");
120
+ if (!fs.existsSync(lockPath)) return false;
121
+ const raw = fs.readFileSync(path.join(lockPath, "owner.json"), "utf8");
66
122
  const owner = JSON.parse(raw);
67
123
  return Date.now() - Number(owner.at ?? 0) > staleMs;
68
124
  } catch {
@@ -70,10 +126,10 @@ function isLockStale(lockPath, staleMs) {
70
126
  }
71
127
  }
72
128
 
73
- /** @param {string} lockPath */
74
- function releaseLock(lockPath) {
129
+ /** @param {string} lockPath @param {typeof defaultLocksFs} fs */
130
+ function releaseLock(lockPath, fs) {
75
131
  try {
76
- rmSync(lockPath, { recursive: true, force: true });
132
+ fs.rmSync(lockPath, { recursive: true, force: true });
77
133
  } catch {
78
134
  /* best effort */
79
135
  }
@@ -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");
@@ -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
+ }
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.
@@ -45,3 +46,55 @@ export function isDirty(repoRoot) {
45
46
  return false;
46
47
  }
47
48
  }
49
+
50
+ /**
51
+ * Cached origin-remote URL per repo root. Failures are cached as null so each
52
+ * repo root is only ever queried once per process.
53
+ * @type {Map<string, string|null>}
54
+ */
55
+ const remoteUrlCache = new Map();
56
+
57
+ /**
58
+ * Raw URL of the `origin` remote of `repoRoot`, or null when the repo has no
59
+ * origin remote or is not a repo. The result (including misses) is cached per
60
+ * repoRoot.
61
+ * @param {string} repoRoot
62
+ * @returns {string|null}
63
+ */
64
+ export function gitRemoteUrl(repoRoot) {
65
+ const cached = remoteUrlCache.get(repoRoot);
66
+ if (cached !== undefined) return cached;
67
+ let url = null;
68
+ try {
69
+ const out = execFileSync("git", ["-C", repoRoot, "remote", "get-url", "origin"], {
70
+ encoding: "utf8",
71
+ stdio: ["ignore", "pipe", "ignore"],
72
+ timeout: 2000,
73
+ });
74
+ url = out.trim() || null;
75
+ } catch {
76
+ // not a repo or no origin remote
77
+ }
78
+ remoteUrlCache.set(repoRoot, url);
79
+ return url;
80
+ }
81
+
82
+ /**
83
+ * Host of the `origin` remote of `repoRoot`, lowercased and without port, or
84
+ * null when the repo has no origin remote or is not a repo. Supports
85
+ * `https://host/owner/repo(.git)` and `git@host:owner/repo(.git)`; parsed from
86
+ * the cached raw remote URL.
87
+ * @param {string} repoRoot
88
+ * @returns {string|null}
89
+ */
90
+ export function gitRemoteHost(repoRoot) {
91
+ return parseRemoteHost(gitRemoteUrl(repoRoot));
92
+ }
93
+
94
+ /**
95
+ * Clear the gitRemoteHost cache. Test-only helper.
96
+ * @returns {void}
97
+ */
98
+ export function clearRemoteHostCacheForTests() {
99
+ remoteUrlCache.clear();
100
+ }
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
  /**
@@ -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", () => {
@@ -1362,8 +1362,9 @@ export class DashboardComponent implements Component {
1362
1362
  const contentWidth = Math.max(20, width - indent);
1363
1363
  const glyph = stateGlyph(rv.state, rv.alive, rv.hostAlive, rv.unread);
1364
1364
  const marker = stageFg(rv.state, selected ? `›${glyph}` : ` ${glyph}`);
1365
- const statusBadges = `${rv.reviewReady ? "✓ " : ""}${rv.diagnosticStalled ? "⏳ " : ""}${rv.evidenceErrorCount || rv.diagnosticErrorCount ? "! " : ""}${rv.followUpCount ? `q${rv.followUpCount} ` : ""}${rv.steeringState && rv.steeringState !== "none" ? "π " : ""}`;
1366
- const badge = `${this.mode === "select" ? `${this.isSelectedForBatch(rv.id) ? "◉" : "○"} ` : ""}${rv.pinned ? "★ " : ""}${rv.worktree ? "⌥ " : ""}${statusBadges}`;
1365
+ const statusBadges = `${rv.reviewReady ? "✓ " : ""}${rv.diagnosticStalled ? "⏳ " : ""}${rv.staleHost ? "⌁ " : ""}${rv.evidenceErrorCount || rv.diagnosticErrorCount ? "! " : ""}${rv.followUpCount ? `q${rv.followUpCount} ` : ""}${rv.steeringState && rv.steeringState !== "none" ? "π " : ""}`;
1366
+ const refsBadge = rv.refsBadge ? (rv.refsLowConfidence ? t.fg("dim", rv.refsBadge) : rv.refsBadge) : "";
1367
+ const badge = `${this.mode === "select" ? `${this.isSelectedForBatch(rv.id) ? "◉" : "○"} ` : ""}${rv.pinned ? "★ " : ""}${rv.worktree ? "⌥ " : ""}${statusBadges}${refsBadge ? `${refsBadge} ` : ""}`;
1367
1368
  const ageRaw = ` ${rv.age}`;
1368
1369
  const nameW = clamp(Math.floor(contentWidth * 0.34), 18, 30);
1369
1370
  const folderW = opts.showFolder !== false ? (contentWidth >= 72 ? clamp(Math.floor(contentWidth * 0.16), 10, 22) : contentWidth >= 56 ? 10 : 0) : 0;
@@ -1396,6 +1397,17 @@ export class DashboardComponent implements Component {
1396
1397
  out.push(t.fg("muted", "Auto-state"));
1397
1398
  out.push(clip(` ${auto.kind} · ${auto.confidence} · ${auto.source}${auto.reason ? ` — ${auto.reason}` : ""}`, width));
1398
1399
  }
1400
+ if (row.codeRefs && row.codeRefs.allRefs.length > 0) {
1401
+ const codeRefs = row.codeRefs;
1402
+ out.push("");
1403
+ out.push(t.fg("muted", "Refs"));
1404
+ if (codeRefs.provider) out.push(clip(` ${codeRefs.provider}`, width));
1405
+ for (const ref of codeRefs.allRefs) {
1406
+ const prefix = ref.kind === "pr" ? (codeRefs.prPrefix ?? "▸#") : (codeRefs.issuePrefix ?? "#");
1407
+ const line = ` ${ref.kind} ${prefix}${ref.number} · ${ref.confidence} · ${ref.source}${ref.url ? ` · ${ref.url}` : ""}`;
1408
+ out.push(clip(line, width));
1409
+ }
1410
+ }
1399
1411
  if (row.state?.question) {
1400
1412
  out.push("");
1401
1413
  out.push(t.fg("warning", "Question / blocker"));