@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.
@@ -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 { isGenericStatusText, normalizeGenericStatusText } from "../core/derive.mjs";
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 { readState, writeState, type Row } from "../core/store.mjs";
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
- const res = this.deps.service.dispatch(text, {
851
- cwd: launchCwd,
852
- model: launchModel,
853
- thinkingLevel: launchThinking,
854
- });
855
- if (!res.ok) this.notice(res.error ?? "Dispatch failed", "error");
856
- else {
857
- this.lastLaunchPrefs = { ...this.deps.service.getLaunchPrefs?.(), cwd: launchCwd, model: launchModel, thinkingLevel: launchThinking };
858
- try {
859
- recordCwdLaunch(this.deps.root, launchCwd);
860
- } catch {
861
- /* best effort: stats must never block dispatch */
862
- }
863
- try {
864
- this.deps.service.saveLaunchPrefs?.(this.lastLaunchPrefs);
865
- } catch {
866
- /* best effort */
867
- }
868
- this.selectedId = res.viewId ?? this.selectedId;
869
- if (launchOpts?.attach && res.hostMode === "pty" && res.viewId) {
870
- this.setInput("");
871
- this.launch = null;
872
- this.mode = "list";
873
- this.inputNotice = null;
874
- this.done({ action: "attach", viewId: res.viewId, stopFirst: false });
875
- return;
876
- }
877
- if (res.hostMode === "json-runner") {
878
- 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");
879
- } else {
880
- this.notice(`Dispatched: ${truncate(text, 40)}`, "info");
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
- this.setInput("");
884
- this.launch = null;
885
- this.mode = "list";
886
- this.inputNotice = null;
887
- this.refresh();
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
- const res = this.markCompleted(row);
951
- if (!res.ok) this.notice(res.error ?? "Mark done failed", "error");
952
- else this.notice("Marked done", "info");
953
- this.refresh();
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
- const res = this.deps.service.markCompletedMany?.(doneIds) ?? { ok: true, completed: 0, skipped: doneIds.length, completedIds: [] };
970
- if (!res.ok) this.notice("Mark done failed", "error");
971
- else {
972
- if (res.completedIds?.length) this.selectedId = res.completedIds[0];
973
- this.notice(`Moved ${res.completed} to Done${res.skipped ? ` · skipped ${res.skipped}` : ""}`, "info");
974
- }
975
- this.refresh();
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
- private markCompleted(row: Row): { ok: boolean; error?: string } {
982
- const service = this.deps.service as Service & { markCompleted?: (viewId: string) => { ok: boolean; error?: string } };
983
- if (typeof service.markCompleted === "function") return service.markCompleted(row.meta.id);
984
-
985
- // Compatibility guard for an already-open dashboard whose service object came
986
- // from an older module instance. The service owns this path normally.
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
- const res = this.deps.service.archive(row.meta.id);
1029
- if (!res.ok) this.notice(res.error ?? "Delete failed", "error");
1030
- else this.notice(`Deleted "${row.meta.name}"`, "info");
1031
- this.refresh();
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
- const res = this.deps.service.archiveMany?.(rows.map((row) => row.meta.id)) ?? { ok: true, archived: 0, skipped: rows.length };
1067
- if (!res.ok) this.notice("Delete failed", "error");
1068
- else this.notice(`Deleted ${res.archived}${res.skipped ? ` · skipped ${res.skipped}` : ""}`, "info");
1069
- this.exitSelectionMode(true);
1070
- this.refresh();
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)}` || "~";
@@ -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;