@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.
- package/PROGRESS.md +18 -3
- package/README.md +295 -72
- package/VERIFY.md +3 -3
- package/docs/PTY_ATTACH_IMPLEMENTATION_PLAN.md +3 -3
- package/docs/superpowers/plans/2026-08-29-code-refs-badges.md +223 -0
- package/docs/superpowers/plans/2026-08-30-circular-navigation.md +308 -0
- package/docs/superpowers/plans/2026-08-30-post-exit-timing-fix.md +37 -0
- package/docs/superpowers/plans/2026-08-30-pty-attach-quality-debt.md +311 -0
- package/docs/superpowers/plans/2026-08-30-readme-v2.md +294 -0
- package/docs/superpowers/specs/2026-08-21-attach-coldstart-jiggle-rearm-design.md +4 -0
- package/docs/superpowers/specs/2026-08-22-jiggle-shrink-and-hold-design.md +4 -0
- package/docs/superpowers/specs/2026-08-29-code-refs-badges-design.md +128 -0
- package/docs/superpowers/specs/2026-08-30-circular-navigation-design.md +47 -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/docs/superpowers/specs/2026-08-30-pty-attach-quality-debt-design.md +105 -0
- package/docs/superpowers/specs/2026-08-30-readme-v2-design.md +115 -0
- package/package.json +1 -1
- package/runner/job-runner.mjs +29 -3
- package/runner/pty-runner.mjs +139 -25
- 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 +315 -0
- package/src/core/code-refs.mjs +861 -0
- package/src/core/host-crash.mjs +39 -0
- package/src/core/launch.mjs +6 -0
- package/src/core/paths.mjs +24 -1
- package/src/core/pty-attach-jiggle-controller.mjs +71 -17
- package/src/core/pty-attach-reconnect.mjs +43 -0
- package/src/core/pty-scroll.mjs +4 -3
- package/src/core/repo.mjs +56 -0
- package/src/core/rows.mjs +50 -0
- package/src/core/store.mjs +4 -1
- package/src/core/types.mjs +12 -0
- package/src/core/worktree.mjs +1 -0
- package/src/runtime/service.mjs +7 -1
- package/src/ui/dashboard.ts +20 -4
- 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
|
+
}
|
package/src/core/launch.mjs
CHANGED
|
@@ -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
|
|
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");
|
|
@@ -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
|
|
6
|
-
*
|
|
7
|
-
* observed the
|
|
8
|
-
* fullRender(true) whenever widthChanged fires).
|
|
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
|
-
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
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
|
-
|
|
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(
|
|
186
|
-
held =
|
|
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
|
+
}
|
package/src/core/pty-scroll.mjs
CHANGED
|
@@ -141,9 +141,10 @@ export function selectionDragScrollLines(row, bodyHeight) {
|
|
|
141
141
|
}
|
|
142
142
|
|
|
143
143
|
/**
|
|
144
|
-
* Return a
|
|
145
|
-
*
|
|
146
|
-
*
|
|
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() || "—";
|
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/core/worktree.mjs
CHANGED
|
@@ -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) {
|
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", () => {
|