@zhuxixi/pi-agent-board 0.4.3 → 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.
- package/README.md +1 -0
- package/docs/superpowers/plans/2026-08-29-code-refs-badges.md +223 -0
- package/docs/superpowers/plans/2026-08-30-post-exit-timing-fix.md +37 -0
- package/docs/superpowers/specs/2026-08-29-code-refs-badges-design.md +128 -0
- package/docs/superpowers/specs/2026-08-30-eprm-atomicwrite-race-design.md +77 -0
- package/docs/superpowers/specs/2026-08-30-post-exit-timing-fix-design.md +80 -0
- package/package.json +1 -1
- package/runner/job-runner.mjs +29 -3
- package/runner/pty-runner.mjs +89 -12
- package/runner/state-runner.mjs +7 -2
- package/runner/title-runner.mjs +1 -1
- package/src/core/atomic.mjs +40 -1
- package/src/core/code-refs-store.mjs +312 -0
- package/src/core/code-refs.mjs +861 -0
- package/src/core/host-crash.mjs +39 -0
- package/src/core/paths.mjs +24 -1
- package/src/core/pty-attach-reconnect.mjs +43 -0
- package/src/core/repo.mjs +53 -0
- package/src/core/rows.mjs +50 -0
- package/src/core/store.mjs +4 -1
- package/src/core/types.mjs +12 -0
- package/src/runtime/service.mjs +7 -1
- package/src/ui/dashboard.ts +14 -2
- package/src/ui/pty-attach.ts +32 -3
|
@@ -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
|
+
}
|
package/src/core/paths.mjs
CHANGED
|
@@ -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) =>
|
|
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() || "—";
|
package/src/core/store.mjs
CHANGED
|
@@ -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
|
-
|
|
216
|
+
const codeRefs = summarizeCodeRefs(readCodeRefs(root, viewId));
|
|
217
|
+
return { review, diagnostics, followUps, steering, codeRefs };
|
|
215
218
|
}
|
|
216
219
|
|
|
217
220
|
/**
|
package/src/core/types.mjs
CHANGED
|
@@ -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
|
/**
|
package/src/runtime/service.mjs
CHANGED
|
@@ -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
|
-
|
|
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", () => {
|
package/src/ui/dashboard.ts
CHANGED
|
@@ -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
|
|
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"));
|
package/src/ui/pty-attach.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { CURSOR_MARKER, Key, matchesKey, truncateToWidth, visibleWidth } from "@
|
|
|
8
8
|
import { isProbablyEmptyPiInputLine } from "../core/pty-input.mjs";
|
|
9
9
|
import { findHttpUrlAtCells, findWordRangeAtCells } from "../core/pty-links.mjs";
|
|
10
10
|
import { createAttachOutputRenderScheduler, nextAttachRender, projectPtyCursor, shouldScheduleAttachRenderForMessage } from "../core/pty-attach-render.mjs";
|
|
11
|
+
import { evaluateAttachReconnect, shouldEscapeAttach } from "../core/pty-attach-reconnect.mjs";
|
|
11
12
|
import { installImeCursorCoalesce } from "../core/ime-cursor-coalesce.mjs";
|
|
12
13
|
import { createJiggleRetryController } from "../core/pty-attach-jiggle-controller.mjs";
|
|
13
14
|
import { clampInt, parseMouseInputChunk, resolveWheelLines, scrollViewportTop, selectionDragScrollLines } from "../core/pty-scroll.mjs";
|
|
@@ -133,6 +134,13 @@ export class PtyAttachComponent implements Component {
|
|
|
133
134
|
private osc52Carry = "";
|
|
134
135
|
private passthroughCarry = "";
|
|
135
136
|
private readonly connectStartedAt = Date.now();
|
|
137
|
+
// Set once the first socket connect succeeds; drives the reconnect give-up
|
|
138
|
+
// policy (short window after a live session dies, long window during cold
|
|
139
|
+
// host start — see pty-attach-reconnect.mjs).
|
|
140
|
+
private everConnected = false;
|
|
141
|
+
// Timestamp of the first disconnect that started the current reconnect
|
|
142
|
+
// cycle; reset on every successful (re)connect.
|
|
143
|
+
private disconnectedAt: number | null = null;
|
|
136
144
|
// Lines scrolled per mouse-wheel event; configurable via $AGENT_BOARD_WHEEL_LINES.
|
|
137
145
|
private readonly mouseWheelLines = resolveWheelLines();
|
|
138
146
|
private readonly term: XtermLike;
|
|
@@ -235,7 +243,10 @@ export class PtyAttachComponent implements Component {
|
|
|
235
243
|
matchesKey(data, Key.left) ||
|
|
236
244
|
matchesKey(data, Key.ctrl("]"))
|
|
237
245
|
) {
|
|
238
|
-
|
|
246
|
+
// While the socket is down the key can never reach the child, so
|
|
247
|
+
// escape unconditionally — the view must always be exitable, even
|
|
248
|
+
// after the host crashes mid-output (issue #48).
|
|
249
|
+
if (shouldEscapeAttach(this.connected, this.childInputLooksEmpty())) {
|
|
239
250
|
this.detach();
|
|
240
251
|
return;
|
|
241
252
|
}
|
|
@@ -288,7 +299,7 @@ export class PtyAttachComponent implements Component {
|
|
|
288
299
|
|
|
289
300
|
private loadingDetail(elapsedSeconds: number): string {
|
|
290
301
|
if (this.status === "attached") return "Attached · waiting for the session to render…";
|
|
291
|
-
if (this.status.startsWith("error") || this.status === "host exited") return this.status;
|
|
302
|
+
if (this.status.startsWith("error") || this.status === "host exited" || this.status === "host not reachable") return this.status;
|
|
292
303
|
if (this.status === "disconnected") return `Reconnecting to the session host… ${elapsedSeconds}s`;
|
|
293
304
|
return `Starting the session host… ${elapsedSeconds}s`;
|
|
294
305
|
}
|
|
@@ -315,7 +326,10 @@ export class PtyAttachComponent implements Component {
|
|
|
315
326
|
|
|
316
327
|
private connect(): void {
|
|
317
328
|
if (this.closed || this.connected || this.socket) return;
|
|
318
|
-
|
|
329
|
+
// On Windows the control socket is a named pipe — it never exists as a
|
|
330
|
+
// filesystem entry, so existsSync can't gate the connect; a missing pipe
|
|
331
|
+
// surfaces as an error event on the socket and retries via scheduleReconnect.
|
|
332
|
+
if (process.platform !== "win32" && !existsSync(this.opts.socketPath)) {
|
|
319
333
|
this.status = `starting host… ${Math.ceil((Date.now() - this.connectStartedAt) / 1000)}s`;
|
|
320
334
|
this.scheduleReconnect();
|
|
321
335
|
return;
|
|
@@ -325,6 +339,8 @@ export class PtyAttachComponent implements Component {
|
|
|
325
339
|
socket.on("connect", () => {
|
|
326
340
|
this.clearRetry();
|
|
327
341
|
this.connected = true;
|
|
342
|
+
this.everConnected = true;
|
|
343
|
+
this.disconnectedAt = null;
|
|
328
344
|
this.status = "attached";
|
|
329
345
|
this.send({ type: "hello", clientId: `ui-${Date.now()}`, wantOutput: true });
|
|
330
346
|
this.jiggleRetry.start(this.cols, this.rows);
|
|
@@ -336,6 +352,7 @@ export class PtyAttachComponent implements Component {
|
|
|
336
352
|
socket.on("close", () => {
|
|
337
353
|
this.socket = null;
|
|
338
354
|
this.connected = false;
|
|
355
|
+
this.disconnectedAt ??= Date.now();
|
|
339
356
|
if (!this.closed && this.status !== "host exited") {
|
|
340
357
|
this.status = "disconnected";
|
|
341
358
|
this.scheduleReconnect();
|
|
@@ -345,6 +362,7 @@ export class PtyAttachComponent implements Component {
|
|
|
345
362
|
socket.on("error", (err) => {
|
|
346
363
|
this.socket = null;
|
|
347
364
|
this.connected = false;
|
|
365
|
+
this.disconnectedAt ??= Date.now();
|
|
348
366
|
if (this.closed) return;
|
|
349
367
|
this.status = `waiting for host… ${err.message}`;
|
|
350
368
|
this.scheduleReconnect();
|
|
@@ -365,6 +383,17 @@ export class PtyAttachComponent implements Component {
|
|
|
365
383
|
|
|
366
384
|
private scheduleReconnect(): void {
|
|
367
385
|
if (this.closed || this.retryTimer) return;
|
|
386
|
+
const verdict = evaluateAttachReconnect({
|
|
387
|
+
everConnected: this.everConnected,
|
|
388
|
+
disconnectedAt: this.disconnectedAt,
|
|
389
|
+
connectStartedAt: this.connectStartedAt,
|
|
390
|
+
now: Date.now(),
|
|
391
|
+
});
|
|
392
|
+
if (verdict.giveUp) {
|
|
393
|
+
this.status = verdict.status ?? "host exited";
|
|
394
|
+
this.scheduleRender();
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
368
397
|
this.retryTimer = setTimeout(() => {
|
|
369
398
|
this.retryTimer = null;
|
|
370
399
|
this.connect();
|