@zhuxixi/pi-agent-board 0.6.2 → 0.7.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/CHANGELOG.md +17 -0
- package/docs/superpowers/plans/2026-09-08-issue-11-attach-runtime-desync-heal.md +917 -0
- package/docs/superpowers/plans/2026-09-09-harden-runner-architecture.md +603 -0
- package/docs/superpowers/plans/2026-09-09-single-writer-completion.md +252 -0
- package/docs/superpowers/specs/2026-09-07-issue-11-attach-runtime-desync-heal-design.md +130 -0
- package/docs/superpowers/specs/2026-09-09-harden-runner-architecture-design.md +298 -0
- package/package.json +1 -1
- package/runner/job-runner-legacy.mjs +68 -0
- package/runner/job-runner.mjs +370 -67
- package/runner/pty-runner-legacy.mjs +50 -0
- package/runner/pty-runner.mjs +69 -31
- package/runner/state-coordinator.mjs +403 -0
- package/runner/state-runner.mjs +89 -15
- package/src/commands/bg.ts +2 -1
- package/src/core/coordinator-client.mjs +313 -0
- package/src/core/coordinator-journal.mjs +282 -0
- package/src/core/coordinator-protocol.mjs +12 -0
- package/src/core/launch.mjs +15 -0
- package/src/core/paths.mjs +16 -0
- package/src/core/pty-attach-jiggle-controller.mjs +57 -4
- package/src/core/pty-attach-render.mjs +30 -0
- package/src/core/state-commands.mjs +617 -0
- package/src/core/types.mjs +2 -0
- package/src/runtime/service.mjs +416 -111
- package/src/ui/dashboard.ts +77 -110
- package/src/ui/pty-attach.ts +63 -1
package/src/ui/dashboard.ts
CHANGED
|
@@ -11,7 +11,7 @@ import { CustomEditor } from "@earendil-works/pi-coding-agent";
|
|
|
11
11
|
import type { Component, EditorTheme, KeybindingsManager, TUI } from "@earendil-works/pi-tui";
|
|
12
12
|
import { Key, matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
13
13
|
import { requestDashboardRender } from "../core/dashboard-render.mjs";
|
|
14
|
-
import {
|
|
14
|
+
import { normalizeGenericStatusText } from "../core/derive.mjs";
|
|
15
15
|
import { firstSentence, truncate } from "../core/heuristics.mjs";
|
|
16
16
|
import {
|
|
17
17
|
canonicalModelRef,
|
|
@@ -31,7 +31,7 @@ import { filterRows, groupRowsByFolder, rowState, stateGlyph } from "../core/row
|
|
|
31
31
|
import { loadSessionView } from "../core/session-view.mjs";
|
|
32
32
|
import { GROUP_LABELS } from "../core/types.mjs";
|
|
33
33
|
import { buildEvidencePanel } from "./dashboard-evidence.mjs";
|
|
34
|
-
import {
|
|
34
|
+
import { type Row } from "../core/store.mjs";
|
|
35
35
|
import type { createService } from "../runtime/service.mjs";
|
|
36
36
|
|
|
37
37
|
type Service = ReturnType<typeof createService>;
|
|
@@ -847,44 +847,49 @@ export class DashboardComponent implements Component {
|
|
|
847
847
|
const launchCwd = launchOpts?.cwd ?? this.deps.defaultCwd;
|
|
848
848
|
const launchModel = launchOpts?.model ?? (this.launch?.model ? canonicalModelRef(this.launch.model) : null);
|
|
849
849
|
const launchThinking = launchOpts?.thinkingLevel ?? this.launch?.thinking ?? this.deps.currentThinkingLevel;
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
this.
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
850
|
+
// dispatch is async (mark_queued routes through the view-state coordinator
|
|
851
|
+
// before the runner spawns, issue #91); notices/attach land when it settles.
|
|
852
|
+
void Promise.resolve(
|
|
853
|
+
this.deps.service.dispatch(text, {
|
|
854
|
+
cwd: launchCwd,
|
|
855
|
+
model: launchModel,
|
|
856
|
+
thinkingLevel: launchThinking,
|
|
857
|
+
}),
|
|
858
|
+
).then((res) => {
|
|
859
|
+
if (!res.ok) this.notice(res.error ?? "Dispatch failed", "error");
|
|
860
|
+
else {
|
|
861
|
+
this.lastLaunchPrefs = { ...this.deps.service.getLaunchPrefs?.(), cwd: launchCwd, model: launchModel, thinkingLevel: launchThinking };
|
|
862
|
+
try {
|
|
863
|
+
recordCwdLaunch(this.deps.root, launchCwd);
|
|
864
|
+
} catch {
|
|
865
|
+
/* best effort: stats must never block dispatch */
|
|
866
|
+
}
|
|
867
|
+
try {
|
|
868
|
+
this.deps.service.saveLaunchPrefs?.(this.lastLaunchPrefs);
|
|
869
|
+
} catch {
|
|
870
|
+
/* best effort */
|
|
871
|
+
}
|
|
872
|
+
this.selectedId = res.viewId ?? this.selectedId;
|
|
873
|
+
if (launchOpts?.attach && res.hostMode === "pty" && res.viewId) {
|
|
874
|
+
this.setInput("");
|
|
875
|
+
this.launch = null;
|
|
876
|
+
this.mode = "list";
|
|
877
|
+
this.inputNotice = null;
|
|
878
|
+
this.done({ action: "attach", viewId: res.viewId, stopFirst: false });
|
|
879
|
+
return;
|
|
880
|
+
}
|
|
881
|
+
if (res.hostMode === "json-runner") {
|
|
882
|
+
this.notice(launchOpts?.attach ? `Start & attach needs PTY; launched in background: ${res.fallbackReason ?? "PTY unavailable"}` : `Dispatched with non-live fallback: ${res.fallbackReason ?? "PTY unavailable"}`, "warn");
|
|
883
|
+
} else {
|
|
884
|
+
this.notice(`Dispatched: ${truncate(text, 40)}`, "info");
|
|
885
|
+
}
|
|
881
886
|
}
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
887
|
+
this.setInput("");
|
|
888
|
+
this.launch = null;
|
|
889
|
+
this.mode = "list";
|
|
890
|
+
this.inputNotice = null;
|
|
891
|
+
this.refresh();
|
|
892
|
+
});
|
|
888
893
|
}
|
|
889
894
|
|
|
890
895
|
private async submitReply(): Promise<void> {
|
|
@@ -947,10 +952,11 @@ export class DashboardComponent implements Component {
|
|
|
947
952
|
this.pending = {
|
|
948
953
|
prompt: `Mark "${row.meta.name}" as done? (y/N)`,
|
|
949
954
|
onYes: () => {
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
955
|
+
void this.markCompleted(row).then((res) => {
|
|
956
|
+
if (!res.ok) this.notice(res.error ?? "Mark done failed", "error");
|
|
957
|
+
else this.notice("Marked done", "info");
|
|
958
|
+
this.refresh();
|
|
959
|
+
});
|
|
954
960
|
},
|
|
955
961
|
};
|
|
956
962
|
this.mode = "confirm";
|
|
@@ -966,57 +972,25 @@ export class DashboardComponent implements Component {
|
|
|
966
972
|
prompt: `Mark ${doneIds.length} selected session${doneIds.length === 1 ? "" : "s"} as done?${skipped ? ` ${skipped} will be skipped.` : ""} (y/N)`,
|
|
967
973
|
returnMode: "select",
|
|
968
974
|
onYes: () => {
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
975
|
+
void Promise.resolve(this.deps.service.markCompletedMany?.(doneIds) ?? { ok: true, completed: 0, skipped: doneIds.length, completedIds: [] }).then((res) => {
|
|
976
|
+
if (!res.ok) this.notice("Mark done failed", "error");
|
|
977
|
+
else {
|
|
978
|
+
if (res.completedIds?.length) this.selectedId = res.completedIds[0];
|
|
979
|
+
this.notice(`Moved ${res.completed} to Done${res.skipped ? ` · skipped ${res.skipped}` : ""}`, "info");
|
|
980
|
+
}
|
|
981
|
+
this.refresh();
|
|
982
|
+
});
|
|
976
983
|
},
|
|
977
984
|
};
|
|
978
985
|
this.mode = "confirm";
|
|
979
986
|
}
|
|
980
987
|
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
const state = readState(service.root, row.meta.id) ?? row.state ?? {
|
|
988
|
-
version: 1,
|
|
989
|
-
viewId: row.meta.id,
|
|
990
|
-
currentRunId: null,
|
|
991
|
-
semanticState: "idle",
|
|
992
|
-
processState: "exited",
|
|
993
|
-
summary: "Needs instructions",
|
|
994
|
-
lastActivityAt: Date.now(),
|
|
995
|
-
updatedAt: Date.now(),
|
|
996
|
-
needsInput: false,
|
|
997
|
-
hasError: false,
|
|
998
|
-
latestAssistantPreview: "",
|
|
999
|
-
latestTool: null,
|
|
1000
|
-
question: null,
|
|
1001
|
-
pendingQuestions: [],
|
|
1002
|
-
error: null,
|
|
1003
|
-
lastVisitedAt: null,
|
|
1004
|
-
lastAgentActivityAt: null,
|
|
1005
|
-
autoState: null,
|
|
1006
|
-
};
|
|
1007
|
-
state.semanticState = "completed";
|
|
1008
|
-
state.processState = "exited";
|
|
1009
|
-
state.needsInput = false;
|
|
1010
|
-
state.hasError = false;
|
|
1011
|
-
state.question = null;
|
|
1012
|
-
state.pendingQuestions = [];
|
|
1013
|
-
state.error = null;
|
|
1014
|
-
state.autoState = null;
|
|
1015
|
-
state.summary = completedSummary(state.summary, state.latestAssistantPreview);
|
|
1016
|
-
state.lastActivityAt = Date.now();
|
|
1017
|
-
state.updatedAt = Date.now();
|
|
1018
|
-
writeState(service.root, state);
|
|
1019
|
-
return { ok: true };
|
|
988
|
+
// Routes through the View State Coordinator (issue #91): the service's
|
|
989
|
+
// markCompleted submits a fenced state command. No compat fallback for stale
|
|
990
|
+
// pre-coordinator service objects — the window is transient and self-heals
|
|
991
|
+
// on the next dashboard reload.
|
|
992
|
+
private markCompleted(row: Row): Promise<{ ok: boolean; error?: string }> {
|
|
993
|
+
return this.deps.service.markCompleted(row.meta.id);
|
|
1020
994
|
}
|
|
1021
995
|
|
|
1022
996
|
private handleDeleteKey(): void {
|
|
@@ -1025,10 +999,13 @@ export class DashboardComponent implements Component {
|
|
|
1025
999
|
const now = Date.now();
|
|
1026
1000
|
if (this.deleteArm !== null && this.deleteArm.id === row.meta.id && now - this.deleteArm.at <= DELETE_DOUBLE_PRESS_MS) {
|
|
1027
1001
|
this.deleteArm = null;
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1002
|
+
// archive is async (routes through the view-state coordinator, issue #91);
|
|
1003
|
+
// the notice + refresh land when the command settles.
|
|
1004
|
+
void Promise.resolve(this.deps.service.archive(row.meta.id)).then((res) => {
|
|
1005
|
+
if (!res.ok) this.notice(res.error ?? "Delete failed", "error");
|
|
1006
|
+
else this.notice(`Deleted "${row.meta.name}"`, "info");
|
|
1007
|
+
this.refresh();
|
|
1008
|
+
});
|
|
1032
1009
|
return;
|
|
1033
1010
|
}
|
|
1034
1011
|
this.deleteArm = { id: row.meta.id, at: now };
|
|
@@ -1063,11 +1040,13 @@ export class DashboardComponent implements Component {
|
|
|
1063
1040
|
prompt: `Delete ${rows.length} done session${rows.length === 1 ? "" : "s"}? Session files are preserved. (y/N)`,
|
|
1064
1041
|
returnMode: "select",
|
|
1065
1042
|
onYes: () => {
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1043
|
+
// archiveMany is async (coordinator-routed, issue #91).
|
|
1044
|
+
void Promise.resolve(this.deps.service.archiveMany?.(rows.map((row) => row.meta.id)) ?? { ok: true, archived: 0, skipped: rows.length }).then((res) => {
|
|
1045
|
+
if (!res.ok) this.notice("Delete failed", "error");
|
|
1046
|
+
else this.notice(`Deleted ${res.archived}${res.skipped ? ` · skipped ${res.skipped}` : ""}`, "info");
|
|
1047
|
+
this.exitSelectionMode(true);
|
|
1048
|
+
this.refresh();
|
|
1049
|
+
});
|
|
1071
1050
|
},
|
|
1072
1051
|
};
|
|
1073
1052
|
this.mode = "confirm";
|
|
@@ -2009,18 +1988,6 @@ function isAgentBusy(row: Row): boolean {
|
|
|
2009
1988
|
return Boolean(row.alive && (st === "queued" || st === "working" || waitingOnTool));
|
|
2010
1989
|
}
|
|
2011
1990
|
|
|
2012
|
-
function completedSummary(summary: string, _latestAssistantPreview: string): string {
|
|
2013
|
-
if (!isGenericStatusText(summary)) return compactCompletedSummary(summary);
|
|
2014
|
-
return "Done";
|
|
2015
|
-
}
|
|
2016
|
-
|
|
2017
|
-
function compactCompletedSummary(text: string): string {
|
|
2018
|
-
const cleaned = String(text || "").replace(/\s+/g, " ").trim();
|
|
2019
|
-
if (!cleaned) return "Done";
|
|
2020
|
-
const first = firstSentence(cleaned);
|
|
2021
|
-
return truncate(first.length >= 12 ? first : cleaned, 80);
|
|
2022
|
-
}
|
|
2023
|
-
|
|
2024
1991
|
function displayPath(path: string): string {
|
|
2025
1992
|
const home = process.env.HOME;
|
|
2026
1993
|
if (home && path.startsWith(home)) return `~${path.slice(home.length)}` || "~";
|
package/src/ui/pty-attach.ts
CHANGED
|
@@ -7,7 +7,7 @@ import type { Component, KeybindingsManager, TUI } from "@earendil-works/pi-tui"
|
|
|
7
7
|
import { CURSOR_MARKER, Key, matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
8
8
|
import { isProbablyEmptyPiInputLine, isProbablyPiInputLine, resolveEditorEmpty } from "../core/pty-input.mjs";
|
|
9
9
|
import { findHttpUrlAtCells, findWordRangeAtCells } from "../core/pty-links.mjs";
|
|
10
|
-
import { createAttachOutputRenderScheduler, nextAttachRender, projectPtyCursor, shouldScheduleAttachRenderForMessage } from "../core/pty-attach-render.mjs";
|
|
10
|
+
import { createAttachOutputRenderScheduler, detectCursorDesync, nextAttachRender, projectPtyCursor, shouldScheduleAttachRenderForMessage } from "../core/pty-attach-render.mjs";
|
|
11
11
|
import { evaluateAttachReconnect, shouldEscapeAttach } from "../core/pty-attach-reconnect.mjs";
|
|
12
12
|
import { installImeCursorCoalesce } from "../core/ime-cursor-coalesce.mjs";
|
|
13
13
|
import { createJiggleRetryController } from "../core/pty-attach-jiggle-controller.mjs";
|
|
@@ -42,6 +42,15 @@ const ATTACH_SETTLE_MS = 250;
|
|
|
42
42
|
const ATTACH_HARD_TIMEOUT_MS = 2500;
|
|
43
43
|
/** Give ordered detach packets time to flush before using destroy as a fallback. */
|
|
44
44
|
const GRACEFUL_SOCKET_CLOSE_MS = 1000;
|
|
45
|
+
/** Desync detection window: how long output must stay silent before a
|
|
46
|
+
* misaligned cursor counts as desync (issue #11). Streaming keeps the cursor
|
|
47
|
+
* on plain output cells legitimately; a healthy idle child re-parks it on the
|
|
48
|
+
* inverse fake-cursor cell on its last rendered frame. */
|
|
49
|
+
const DESYNC_QUIET_MS = 1500;
|
|
50
|
+
/** How often the post-settle probe runs checkDesync() (issue #11). */
|
|
51
|
+
const DESYNC_PROBE_INTERVAL_MS = 2000;
|
|
52
|
+
/** Minimum spacing between two runtime heals (issue #11). */
|
|
53
|
+
const HEAL_RATELIMIT_MS = 10000;
|
|
45
54
|
/** How many tail bytes of the screen log to replay on attach. Read from the file tail
|
|
46
55
|
* (not the whole file) so multi-MB logs don't block startup; ~60KB covers the last
|
|
47
56
|
* handful of screens, which is all a fresh attach needs. */
|
|
@@ -150,6 +159,13 @@ export class PtyAttachComponent implements Component {
|
|
|
150
159
|
private rows = 24;
|
|
151
160
|
// Absolute buffer line shown at the top of the viewport. null means follow bottom.
|
|
152
161
|
private viewportTop: number | null = null;
|
|
162
|
+
// Runtime desync backstop state (issue #11): last socket-output timestamp,
|
|
163
|
+
// last heal timestamp, the probe timer, and an injectable clock for tests.
|
|
164
|
+
private lastOutputAt = 0;
|
|
165
|
+
private lastHealAt = 0;
|
|
166
|
+
private desyncProbeTimer: ReturnType<typeof setInterval> | null = null;
|
|
167
|
+
/** Injectable clock for desync gating (tests override this). */
|
|
168
|
+
private nowFn: () => number = () => Date.now();
|
|
153
169
|
private selection: MouseSelection | null = null;
|
|
154
170
|
private selectionDragging = false;
|
|
155
171
|
private selectionAutoScrollTimer: ReturnType<typeof setInterval> | null = null;
|
|
@@ -541,6 +557,48 @@ export class PtyAttachComponent implements Component {
|
|
|
541
557
|
// Force a full clear so the loading banner is replaced atomically by the settled
|
|
542
558
|
// buffer, instead of diffing banner lines into buffer lines.
|
|
543
559
|
this.scheduleRender(true);
|
|
560
|
+
this.startDesyncProbe();
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
private startDesyncProbe(): void {
|
|
564
|
+
this.stopDesyncProbe();
|
|
565
|
+
// The probe is deliberately NOT hooked into the render path: rendering is
|
|
566
|
+
// event-driven (socket output / keypress / resize) and stops exactly when
|
|
567
|
+
// desync strikes (output goes quiet). A self-contained timer is the only
|
|
568
|
+
// way an idle desynced screen gets its self-heal without user action.
|
|
569
|
+
this.desyncProbeTimer = setInterval(() => this.checkDesync(), DESYNC_PROBE_INTERVAL_MS);
|
|
570
|
+
this.desyncProbeTimer.unref?.();
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
private stopDesyncProbe(): void {
|
|
574
|
+
if (this.desyncProbeTimer) {
|
|
575
|
+
clearInterval(this.desyncProbeTimer);
|
|
576
|
+
this.desyncProbeTimer = null;
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/**
|
|
581
|
+
* Runtime desync backstop (issue #11). Seven gates, cheapest first:
|
|
582
|
+
* settled+connected, child is a TUI (frame seen), chain idle, output quiet,
|
|
583
|
+
* heal rate limit, misaligned cursor. All pass → heal() re-arms
|
|
584
|
+
* shrink-and-hold; the child's fullRender clear then restores the size
|
|
585
|
+
* and repaints a consistent screen.
|
|
586
|
+
*/
|
|
587
|
+
private checkDesync(): void {
|
|
588
|
+
if (this.closed || this.attaching || !this.connected) return; // gates 1, 7
|
|
589
|
+
const chain = this.jiggleRetry.getState();
|
|
590
|
+
if (!chain.tuiFrameSeen) return; // gate 2: shell/vim children never heal
|
|
591
|
+
if (!chain.stopped || chain.held) return; // gate 5: attach/heal chain active
|
|
592
|
+
const now = this.nowFn();
|
|
593
|
+
if (now - this.lastOutputAt <= DESYNC_QUIET_MS) return; // gate 4: streaming
|
|
594
|
+
if (now - this.lastHealAt <= HEAL_RATELIMIT_MS) return; // gate 6: rate limit
|
|
595
|
+
const height = this.bodyHeight();
|
|
596
|
+
this.clampViewportTop(height);
|
|
597
|
+
const start = this.viewportTop ?? this.bottomViewportTop(height);
|
|
598
|
+
const buf = this.term.buffer.active;
|
|
599
|
+
if (detectCursorDesync(buf, projectPtyCursor(buf, start, height)) !== "misaligned") return; // gate 3
|
|
600
|
+
this.lastHealAt = now;
|
|
601
|
+
this.jiggleRetry.heal(this.cols, this.rows);
|
|
544
602
|
}
|
|
545
603
|
|
|
546
604
|
private clearMouseRefreshTimers(): void {
|
|
@@ -1060,6 +1118,9 @@ export class PtyAttachComponent implements Component {
|
|
|
1060
1118
|
|
|
1061
1119
|
private pushOutput(data: string, opts: { forwardProtocols?: boolean } = {}): void {
|
|
1062
1120
|
if (data.length === 0) return;
|
|
1121
|
+
// Recorded synchronously BEFORE term.write (whose callback is async): the
|
|
1122
|
+
// desync quiet-window must measure when output ARRIVED, not when parsing finished.
|
|
1123
|
+
this.lastOutputAt = this.nowFn();
|
|
1063
1124
|
if (opts.forwardProtocols) this.forwardTerminalProtocols(data);
|
|
1064
1125
|
// @xterm/headless parses asynchronously; the buffer is only populated once this
|
|
1065
1126
|
// callback fires. Mark the buffer as ready (so the project path can paint it), but
|
|
@@ -1110,6 +1171,7 @@ export class PtyAttachComponent implements Component {
|
|
|
1110
1171
|
this.clearRetry();
|
|
1111
1172
|
this.stopLoadingTicker();
|
|
1112
1173
|
this.outputRenderScheduler.dispose();
|
|
1174
|
+
this.stopDesyncProbe();
|
|
1113
1175
|
if (this.attachSettleTimer) {
|
|
1114
1176
|
clearTimeout(this.attachSettleTimer);
|
|
1115
1177
|
this.attachSettleTimer = null;
|