@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,315 @@
1
+ /**
2
+ * Per-view code-refs artifact (`github.json`) plus the evidence→extraction
3
+ * hook helper.
4
+ *
5
+ * Mirrors evidence.mjs's normalize/read/write/summarize shape. Extraction is
6
+ * delegated to the pure engine in code-refs.mjs; this module only composes the
7
+ * engine input from a view's meta + evidence and persists the snapshot
8
+ * atomically. `meta` is passed in by callers (they already hold it) so this
9
+ * module never imports store.mjs — keeping the store ↔ artifact imports free
10
+ * of cycles.
11
+ */
12
+ import { execFileSync } from "node:child_process";
13
+ import { statSync } from "node:fs";
14
+ import { atomicWriteJson, readJson } from "./atomic.mjs";
15
+ import * as P from "./paths.mjs";
16
+ import {
17
+ extractCodeRefs,
18
+ loadProvidersWithErrors,
19
+ matchProvider,
20
+ parseRemoteHost,
21
+ parseRemotePath,
22
+ } from "./code-refs.mjs";
23
+ import { gitRemoteUrl } from "./repo.mjs";
24
+ import { appendDiagnostic } from "./diagnostics.mjs";
25
+
26
+ /**
27
+ * @typedef {Object} CodeRefsSnapshot
28
+ * @property {number} version
29
+ * @property {string} viewId
30
+ * @property {number} updatedAt
31
+ * @property {string|null} provider
32
+ * @property {string} issuePrefix Issue-number prefix resolved from the matched provider (default "#").
33
+ * @property {string} prPrefix PR/MR-number prefix resolved from the matched provider (default "▸#").
34
+ * @property {import("./code-refs.mjs").Ref|null} issue
35
+ * @property {import("./code-refs.mjs").Ref|null} pr
36
+ * @property {import("./code-refs.mjs").Ref[]} allRefs
37
+ */
38
+
39
+ /** @param {{ viewId:string, now?:number }} opts @returns {CodeRefsSnapshot} */
40
+ export function emptyCodeRefsSnapshot(opts) {
41
+ const now = opts.now ?? Date.now();
42
+ return {
43
+ version: 1,
44
+ viewId: opts.viewId,
45
+ updatedAt: now,
46
+ provider: null,
47
+ issuePrefix: "#",
48
+ prPrefix: "▸#",
49
+ issue: null,
50
+ pr: null,
51
+ allRefs: [],
52
+ };
53
+ }
54
+
55
+ /**
56
+ * Defensive shape guard mirroring evidence's normalize: missing/garbage input
57
+ * yields an empty snapshot; valid fields pass through.
58
+ * @param {any} raw
59
+ * @param {{ viewId:string }} fallback
60
+ * @returns {CodeRefsSnapshot}
61
+ */
62
+ export function normalizeCodeRefsSnapshot(raw, fallback) {
63
+ const base = emptyCodeRefsSnapshot({ viewId: fallback.viewId });
64
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return base;
65
+ return {
66
+ ...base,
67
+ ...raw,
68
+ viewId: typeof raw.viewId === "string" ? raw.viewId : base.viewId,
69
+ provider: typeof raw.provider === "string" ? raw.provider : (raw.provider === null ? null : base.provider),
70
+ issuePrefix: typeof raw.issuePrefix === "string" ? raw.issuePrefix : base.issuePrefix,
71
+ prPrefix: typeof raw.prPrefix === "string" ? raw.prPrefix : base.prPrefix,
72
+ issue: isRefObject(raw.issue) ? raw.issue : null,
73
+ pr: isRefObject(raw.pr) ? raw.pr : null,
74
+ allRefs: Array.isArray(raw.allRefs) ? raw.allRefs.filter(isValidRefElement) : [],
75
+ };
76
+ }
77
+
78
+ /** @param {any} value @returns {boolean} */
79
+ function isRefObject(value) {
80
+ return Boolean(value && typeof value === "object" && !Array.isArray(value));
81
+ }
82
+
83
+ /**
84
+ * A valid allRefs element is a ref-shaped object whose kind is issue/pr and
85
+ * whose number is a positive integer.
86
+ * @param {any} value @returns {boolean}
87
+ */
88
+ function isValidRefElement(value) {
89
+ return Boolean(
90
+ isRefObject(value) &&
91
+ (value.kind === "issue" || value.kind === "pr") &&
92
+ Number.isInteger(value.number) &&
93
+ value.number > 0
94
+ );
95
+ }
96
+
97
+ /** @param {string} root @param {string} viewId @returns {CodeRefsSnapshot} */
98
+ export function readCodeRefs(root, viewId) {
99
+ return normalizeCodeRefsSnapshot(readJson(P.codeRefsPath(root, viewId), null), { viewId });
100
+ }
101
+
102
+ /** @param {string} root @param {CodeRefsSnapshot} snapshot @returns {CodeRefsSnapshot} */
103
+ export function writeCodeRefs(root, snapshot) {
104
+ const normalized = normalizeCodeRefsSnapshot(snapshot, { viewId: snapshot.viewId });
105
+ normalized.updatedAt = Date.now();
106
+ atomicWriteJson(P.codeRefsPath(root, normalized.viewId), normalized);
107
+ return normalized;
108
+ }
109
+
110
+ /** @param {any} snapshot @returns {import("./types.mjs").CodeRefsSummary} */
111
+ export function summarizeCodeRefs(snapshot) {
112
+ const s = normalizeCodeRefsSnapshot(snapshot, { viewId: snapshot?.viewId ?? "" });
113
+ return {
114
+ provider: s.provider,
115
+ issuePrefix: s.issuePrefix,
116
+ prPrefix: s.prPrefix,
117
+ issue: s.issue,
118
+ pr: s.pr,
119
+ allRefs: s.allRefs,
120
+ };
121
+ }
122
+
123
+ /**
124
+ * Current branch of a working dir via `git branch --show-current`, cached per
125
+ * cwd for 60s. Best-effort like repo.mjs: off-repo or git failures yield null
126
+ * (also cached), but the TTL means a newly created branch shows up within a
127
+ * minute without needing an explicit cache clear.
128
+ * @type {Map<string, { at:number, branch:string|null }>}
129
+ */
130
+ const branchCache = new Map();
131
+ const BRANCH_CACHE_TTL_MS = 60_000;
132
+
133
+ /** @param {string|null} cwd @returns {string|null} */
134
+ function currentBranch(cwd) {
135
+ if (typeof cwd !== "string" || !cwd) return null;
136
+ const cached = branchCache.get(cwd);
137
+ if (cached && Date.now() - cached.at < BRANCH_CACHE_TTL_MS) return cached.branch;
138
+ let branch = null;
139
+ try {
140
+ const out = execFileSync("git", ["-C", cwd, "branch", "--show-current"], {
141
+ encoding: "utf8",
142
+ stdio: ["ignore", "pipe", "ignore"],
143
+ timeout: 2000,
144
+ // Windows: no console window when spawned from a console-less worker
145
+ // (issue #49 follow-up — these git spawns created visible WT windows).
146
+ windowsHide: true,
147
+ });
148
+ branch = out.trim() || null;
149
+ } catch {
150
+ // not a repo or git unavailable
151
+ }
152
+ branchCache.set(cwd, { at: Date.now(), branch });
153
+ return branch;
154
+ }
155
+
156
+ /**
157
+ * Extract issue/PR refs from view evidence and persist the per-view snapshot.
158
+ * The hook helper for every `writeEvidence` call site: never throws, writes only
159
+ * when the serialized ref content actually changed, and is disabled entirely
160
+ * by `AGENT_BOARD_CODE_REFS=off` (returns false without writing).
161
+ *
162
+ * `meta` is a parameter (callers pass `row.meta` / the runner's readMeta
163
+ * result) so this module never imports store.mjs. repoRoot falls back to
164
+ * meta.cwd when the view has no recorded repo root.
165
+ * @param {string} root
166
+ * @param {string} viewId
167
+ * @param {import("./types.mjs").EvidenceSnapshot|any} evidence
168
+ * @param {{repoRoot?: string|null, cwd?: string|null, worktreePath?: string|null}|null|undefined} meta
169
+ * @returns {boolean} true when extraction ran and the snapshot is current; false when off or failed.
170
+ */
171
+ export function updateCodeRefsFromEvidence(root, viewId, evidence, meta) {
172
+ if (process.env.AGENT_BOARD_CODE_REFS === "off") return false;
173
+ try {
174
+ const hasCommands = Array.isArray(evidence?.commands) && evidence.commands.length > 0;
175
+ const hasAssistantEvidence = Array.isArray(evidence?.assistantEvidence) && evidence.assistantEvidence.length > 0;
176
+ if (!hasCommands && !hasAssistantEvidence) return false;
177
+ const repoRoot = meta?.repoRoot ?? meta?.cwd ?? null;
178
+ const remoteUrl = repoRoot ? gitRemoteUrl(repoRoot) : null;
179
+ const host = remoteUrl ? parseRemoteHost(remoteUrl) : null;
180
+ const repoUrl = remoteUrl ? parseRemotePath(remoteUrl) : null;
181
+ const { providers, errors } = loadProvidersWithErrors(root);
182
+ const provider = matchProvider(providers, host);
183
+ reportConfigErrors(root, viewId, evidence, errors);
184
+ const cwd = typeof meta?.cwd === "string" ? meta.cwd : null;
185
+ const worktreePath = typeof meta?.worktreePath === "string" ? meta.worktreePath : null;
186
+ const branch = currentBranch(cwd);
187
+ const input = buildEngineInput(evidence, { worktreePath, branch, repoUrl, host });
188
+ const result = extractCodeRefs(input, provider);
189
+ const existing = readCodeRefs(root, viewId);
190
+ // Carry forward earned refs per kind: job-runner resets view-level
191
+ // evidence at each run start, so a follow-up run whose events carry no
192
+ // signal for a kind must not null that kind's previously earned ref —
193
+ // absence of signal is not evidence of absence. (CR rounds 1-2)
194
+ const merged = mergeWithExisting(result, existing);
195
+ const next = {
196
+ version: 1,
197
+ viewId,
198
+ updatedAt: Date.now(),
199
+ provider: result.provider,
200
+ issuePrefix: provider.issuePrefix,
201
+ prPrefix: provider.prPrefix,
202
+ issue: merged.issue,
203
+ pr: merged.pr,
204
+ allRefs: merged.allRefs,
205
+ };
206
+ // Avoid churning the artifact (and its mtime) when the refs are unchanged.
207
+ if (contentOf(existing) === contentOf(next)) return true;
208
+ writeCodeRefs(root, next);
209
+ return true;
210
+ } catch (e) {
211
+ try {
212
+ appendDiagnostic(root, viewId, {
213
+ runId: evidence?.runId ?? null,
214
+ source: "evidence",
215
+ level: "error",
216
+ code: "code_refs_extract_failed",
217
+ message: `code-refs extraction failed: ${e instanceof Error ? e.message : String(e)}`,
218
+ });
219
+ } catch {
220
+ // diagnostics must never break the evidence flow either
221
+ }
222
+ return false;
223
+ }
224
+ }
225
+
226
+ /** @param {any} s @returns {string} serialized ref content (updatedAt excluded) */
227
+ function contentOf(s) {
228
+ return JSON.stringify({
229
+ provider: s.provider,
230
+ issuePrefix: s.issuePrefix,
231
+ prPrefix: s.prPrefix,
232
+ issue: s.issue,
233
+ pr: s.pr,
234
+ allRefs: s.allRefs,
235
+ });
236
+ }
237
+
238
+ /**
239
+ * Merge a fresh extraction with the stored snapshot: a kind with no signal in
240
+ * the new extraction inherits the stored ref (per-kind carry-forward), and
241
+ * stored refs missing from the new allRefs are appended (deduped by
242
+ * kind+number, capped at 10).
243
+ * @param {import("./code-refs.mjs").CodeRefsResult} result
244
+ * @param {any} existing normalized snapshot
245
+ * @returns {{issue: any, pr: any, allRefs: any[]}}
246
+ */
247
+ function mergeWithExisting(result, existing) {
248
+ const issue = result.issue ?? existing?.issue ?? null;
249
+ const pr = result.pr ?? existing?.pr ?? null;
250
+ const seen = new Set((result.allRefs ?? []).map((r) => `${r.kind}:${r.number}`));
251
+ const carried = [];
252
+ for (const r of [existing?.issue, existing?.pr, ...(Array.isArray(existing?.allRefs) ? existing.allRefs : [])]) {
253
+ if (!r || seen.has(`${r.kind}:${r.number}`)) continue;
254
+ seen.add(`${r.kind}:${r.number}`);
255
+ carried.push(r);
256
+ }
257
+ return { issue, pr, allRefs: [...(result.allRefs ?? []), ...carried].slice(0, 10) };
258
+ }
259
+
260
+ /** Last providers.json mtime per root for which a code_refs_config diagnostic was emitted. */
261
+ const reportedConfigMtime = new Map();
262
+
263
+ /**
264
+ * Emit ONE `code_refs_config` diagnostic per distinct providers.json mtime so a
265
+ * broken config is surfaced without spamming every evidence write.
266
+ * @param {string} root
267
+ * @param {string} viewId
268
+ * @param {import("./types.mjs").EvidenceSnapshot|any} evidence
269
+ * @param {string[]} errors
270
+ */
271
+ function reportConfigErrors(root, viewId, evidence, errors) {
272
+ if (errors.length === 0) return;
273
+ let mtimeMs = null;
274
+ try {
275
+ mtimeMs = statSync(P.providersPath(root)).mtimeMs;
276
+ } catch {
277
+ // file disappeared — nothing to report against
278
+ }
279
+ if (reportedConfigMtime.get(root) === mtimeMs) return;
280
+ reportedConfigMtime.set(root, mtimeMs);
281
+ try {
282
+ appendDiagnostic(root, viewId, {
283
+ runId: evidence?.runId ?? null,
284
+ source: "code-refs",
285
+ level: "warn",
286
+ code: "code_refs_config",
287
+ message: "providers.json has invalid entries",
288
+ details: { errors },
289
+ });
290
+ } catch {
291
+ // diagnostics must never break the evidence flow either
292
+ }
293
+ }
294
+
295
+ /**
296
+ * Compose the pure engine input from evidence: the last 200 commands (each
297
+ * truncated to 4000 chars) plus the last 20 assistant claim texts.
298
+ * @param {any} evidence
299
+ * @param {{ worktreePath: string|null, branch: string|null, repoUrl: string|null, host: string|null }} ctx
300
+ */
301
+ function buildEngineInput(evidence, ctx) {
302
+ const rawCommands = Array.isArray(evidence?.commands) ? evidence.commands : [];
303
+ const commands = rawCommands.slice(-200).map((cmd) => {
304
+ if (cmd && typeof cmd.command === "string" && cmd.command.length > 4000) {
305
+ return { ...cmd, command: cmd.command.slice(0, 4000) };
306
+ }
307
+ return cmd;
308
+ });
309
+ const claims = Array.isArray(evidence?.assistantEvidence) ? evidence.assistantEvidence : [];
310
+ const assistantTexts = claims
311
+ .slice(-20)
312
+ .map((claim) => (claim && typeof claim.text === "string" ? claim.text : ""))
313
+ .filter((text) => text !== "");
314
+ return { commands, assistantTexts, worktreePath: ctx.worktreePath, branch: ctx.branch, repoUrl: ctx.repoUrl, host: ctx.host };
315
+ }