@zhuxixi/pi-agent-board 0.6.2 → 0.8.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.
Files changed (67) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/README.md +6 -3
  3. package/VERIFY.md +2 -1
  4. package/docs/PTY_ATTACH_IMPLEMENTATION_PLAN.md +3 -1
  5. package/docs/superpowers/plans/2026-09-08-issue-11-attach-runtime-desync-heal.md +917 -0
  6. package/docs/superpowers/plans/2026-09-09-harden-runner-architecture.md +603 -0
  7. package/docs/superpowers/plans/2026-09-09-single-writer-completion.md +252 -0
  8. package/docs/superpowers/plans/2026-09-10-reader-consistency.md +115 -0
  9. package/docs/superpowers/plans/2026-09-14-attach-cursor-dectcem-gate.md +469 -0
  10. package/docs/superpowers/plans/2026-09-14-attach-snapshot.md +92 -0
  11. package/docs/superpowers/plans/2026-09-14-host-meta-orphan-lock.md +771 -0
  12. package/docs/superpowers/plans/2026-09-14-issue-106-terminal-frame-cognition.md +299 -0
  13. package/docs/superpowers/plans/2026-09-14-issue-113-foreground-preview-race.md +609 -0
  14. package/docs/superpowers/plans/2026-09-14-terminal-model.md +145 -0
  15. package/docs/superpowers/plans/2026-09-15-coordinator-pipe-root-normalize.md +224 -0
  16. package/docs/superpowers/plans/2026-09-15-lease-publish-eprem-reclaim.md +341 -0
  17. package/docs/superpowers/plans/2026-09-18-detach-anchor-reporter-endpoint.md +875 -0
  18. package/docs/superpowers/plans/2026-09-20-control-lifecycle.md +116 -0
  19. package/docs/superpowers/plans/2026-09-20-issue-121-perf-gate-out-of-coverage.md +517 -0
  20. package/docs/superpowers/specs/2026-09-07-issue-11-attach-runtime-desync-heal-design.md +130 -0
  21. package/docs/superpowers/specs/2026-09-09-harden-runner-architecture-design.md +298 -0
  22. package/docs/superpowers/specs/2026-09-14-attach-cursor-dectcem-gate-design.md +114 -0
  23. package/docs/superpowers/specs/2026-09-14-host-meta-orphan-lock-design.md +120 -0
  24. package/docs/superpowers/specs/2026-09-14-issue-106-terminal-frame-cognition-design.md +146 -0
  25. package/docs/superpowers/specs/2026-09-14-issue-113-foreground-preview-race-design.md +116 -0
  26. package/docs/superpowers/specs/2026-09-15-coordinator-pipe-root-normalize-design.md +84 -0
  27. package/docs/superpowers/specs/2026-09-15-lease-publish-eprem-reclaim-design.md +92 -0
  28. package/docs/superpowers/specs/2026-09-18-detach-anchor-reporter-endpoint-design.md +123 -0
  29. package/docs/superpowers/specs/2026-09-20-issue-121-perf-gate-out-of-coverage-design.md +204 -0
  30. package/package.json +3 -2
  31. package/runner/job-runner-legacy.mjs +68 -0
  32. package/runner/job-runner.mjs +371 -67
  33. package/runner/pty-runner-legacy.mjs +50 -0
  34. package/runner/pty-runner.mjs +685 -58
  35. package/runner/state-coordinator.mjs +429 -0
  36. package/runner/state-runner.mjs +90 -15
  37. package/scripts/run-perf-gate.mjs +40 -0
  38. package/src/commands/agent-board.ts +8 -8
  39. package/src/commands/attach-flow.ts +5 -5
  40. package/src/commands/bg.ts +2 -1
  41. package/src/core/control-protocol.mjs +482 -0
  42. package/src/core/coordinator-client.mjs +313 -0
  43. package/src/core/coordinator-journal.mjs +282 -0
  44. package/src/core/coordinator-protocol.mjs +12 -0
  45. package/src/core/editor-state-reporter.mjs +11 -1
  46. package/src/core/foreground-preview-cache.mjs +117 -0
  47. package/src/core/host-protocol.mjs +24 -0
  48. package/src/core/launch.mjs +15 -0
  49. package/src/core/locks.mjs +68 -14
  50. package/src/core/paths.mjs +48 -0
  51. package/src/core/pid.mjs +32 -1
  52. package/src/core/pty-attach-jiggle-controller.mjs +83 -6
  53. package/src/core/pty-attach-reconnect.mjs +13 -6
  54. package/src/core/pty-attach-render.mjs +50 -0
  55. package/src/core/state-commands.mjs +699 -0
  56. package/src/core/status-consistency.mjs +98 -0
  57. package/src/core/store.mjs +59 -13
  58. package/src/core/terminal-attach-client.mjs +803 -0
  59. package/src/core/terminal-attach-protocol.mjs +252 -0
  60. package/src/core/terminal-model.mjs +222 -0
  61. package/src/core/terminal-snapshot.mjs +440 -0
  62. package/src/core/types.mjs +2 -0
  63. package/src/index.ts +12 -4
  64. package/src/runtime/service.mjs +694 -121
  65. package/src/ui/dashboard.ts +67 -92
  66. package/src/ui/pty-attach.ts +298 -72
  67. package/src/core/pty-input.mjs +0 -47
@@ -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>;
@@ -440,7 +440,7 @@ export class DashboardComponent implements Component {
440
440
  const page = Math.max(1, termRows - 8);
441
441
  if (matchesKey(data, Key.left) || matchesKey(data, Key.escape) || data === "<") {
442
442
  const row = this.selectedRow();
443
- if (row) this.deps.service.markVisited?.(row.meta.id);
443
+ if (row) void this.deps.service.markVisited?.(row.meta.id)?.catch(() => {});
444
444
  this.mode = "list";
445
445
  return;
446
446
  }
@@ -847,13 +847,28 @@ 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 {
850
+ // Clear synchronously, BEFORE the async dispatch: closes the double-submit
851
+ // window — a second Enter during the coordinator round-trip (mark_queued
852
+ // lands before the runner spawns, issue #91) must not re-dispatch.
853
+ this.setInput("");
854
+ this.launch = null;
855
+ this.mode = "list";
856
+ this.inputNotice = null;
857
+ this.refresh();
858
+ // dispatch is async (mark_queued routes through the view-state coordinator
859
+ // before the runner spawns, issue #91); notices/attach land when it settles.
860
+ void Promise.resolve(
861
+ this.deps.service.dispatch(text, {
862
+ cwd: launchCwd,
863
+ model: launchModel,
864
+ thinkingLevel: launchThinking,
865
+ }),
866
+ ).then((res) => {
867
+ if (!res.ok) {
868
+ this.notice(res.error ?? "Dispatch failed", "error");
869
+ this.refresh();
870
+ return;
871
+ }
857
872
  this.lastLaunchPrefs = { ...this.deps.service.getLaunchPrefs?.(), cwd: launchCwd, model: launchModel, thinkingLevel: launchThinking };
858
873
  try {
859
874
  recordCwdLaunch(this.deps.root, launchCwd);
@@ -867,10 +882,6 @@ export class DashboardComponent implements Component {
867
882
  }
868
883
  this.selectedId = res.viewId ?? this.selectedId;
869
884
  if (launchOpts?.attach && res.hostMode === "pty" && res.viewId) {
870
- this.setInput("");
871
- this.launch = null;
872
- this.mode = "list";
873
- this.inputNotice = null;
874
885
  this.done({ action: "attach", viewId: res.viewId, stopFirst: false });
875
886
  return;
876
887
  }
@@ -879,12 +890,14 @@ export class DashboardComponent implements Component {
879
890
  } else {
880
891
  this.notice(`Dispatched: ${truncate(text, 40)}`, "info");
881
892
  }
882
- }
883
- this.setInput("");
884
- this.launch = null;
885
- this.mode = "list";
886
- this.inputNotice = null;
887
- this.refresh();
893
+ this.refresh();
894
+ }).catch(() => {
895
+ // dispatch() is not expected to reject (its failures surface as
896
+ // {ok:false}); this guards fs-class throws from escaping as unhandled
897
+ // rejections (issue #91 hygiene).
898
+ this.notice("Dispatch failed", "error");
899
+ this.refresh();
900
+ });
888
901
  }
889
902
 
890
903
  private async submitReply(): Promise<void> {
@@ -947,10 +960,11 @@ export class DashboardComponent implements Component {
947
960
  this.pending = {
948
961
  prompt: `Mark "${row.meta.name}" as done? (y/N)`,
949
962
  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();
963
+ void this.markCompleted(row).then((res) => {
964
+ if (!res.ok) this.notice(res.error ?? "Mark done failed", "error");
965
+ else this.notice("Marked done", "info");
966
+ this.refresh();
967
+ });
954
968
  },
955
969
  };
956
970
  this.mode = "confirm";
@@ -966,57 +980,25 @@ export class DashboardComponent implements Component {
966
980
  prompt: `Mark ${doneIds.length} selected session${doneIds.length === 1 ? "" : "s"} as done?${skipped ? ` ${skipped} will be skipped.` : ""} (y/N)`,
967
981
  returnMode: "select",
968
982
  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();
983
+ void Promise.resolve(this.deps.service.markCompletedMany?.(doneIds) ?? { ok: true, completed: 0, skipped: doneIds.length, completedIds: [] }).then((res) => {
984
+ if (!res.ok) this.notice("Mark done failed", "error");
985
+ else {
986
+ if (res.completedIds?.length) this.selectedId = res.completedIds[0];
987
+ this.notice(`Moved ${res.completed} to Done${res.skipped ? ` · skipped ${res.skipped}` : ""}`, "info");
988
+ }
989
+ this.refresh();
990
+ });
976
991
  },
977
992
  };
978
993
  this.mode = "confirm";
979
994
  }
980
995
 
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 };
996
+ // Routes through the View State Coordinator (issue #91): the service's
997
+ // markCompleted submits a fenced state command. No compat fallback for stale
998
+ // pre-coordinator service objects — the window is transient and self-heals
999
+ // on the next dashboard reload.
1000
+ private markCompleted(row: Row): Promise<{ ok: boolean; error?: string }> {
1001
+ return this.deps.service.markCompleted(row.meta.id);
1020
1002
  }
1021
1003
 
1022
1004
  private handleDeleteKey(): void {
@@ -1025,10 +1007,13 @@ export class DashboardComponent implements Component {
1025
1007
  const now = Date.now();
1026
1008
  if (this.deleteArm !== null && this.deleteArm.id === row.meta.id && now - this.deleteArm.at <= DELETE_DOUBLE_PRESS_MS) {
1027
1009
  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();
1010
+ // archive is async (routes through the view-state coordinator, issue #91);
1011
+ // the notice + refresh land when the command settles.
1012
+ void Promise.resolve(this.deps.service.archive(row.meta.id)).then((res) => {
1013
+ if (!res.ok) this.notice(res.error ?? "Delete failed", "error");
1014
+ else this.notice(`Deleted "${row.meta.name}"`, "info");
1015
+ this.refresh();
1016
+ });
1032
1017
  return;
1033
1018
  }
1034
1019
  this.deleteArm = { id: row.meta.id, at: now };
@@ -1063,11 +1048,13 @@ export class DashboardComponent implements Component {
1063
1048
  prompt: `Delete ${rows.length} done session${rows.length === 1 ? "" : "s"}? Session files are preserved. (y/N)`,
1064
1049
  returnMode: "select",
1065
1050
  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();
1051
+ // archiveMany is async (coordinator-routed, issue #91).
1052
+ void Promise.resolve(this.deps.service.archiveMany?.(rows.map((row) => row.meta.id)) ?? { ok: true, archived: 0, skipped: rows.length }).then((res) => {
1053
+ if (!res.ok) this.notice("Delete failed", "error");
1054
+ else this.notice(`Deleted ${res.archived}${res.skipped ? ` · skipped ${res.skipped}` : ""}`, "info");
1055
+ this.exitSelectionMode(true);
1056
+ this.refresh();
1057
+ });
1071
1058
  },
1072
1059
  };
1073
1060
  this.mode = "confirm";
@@ -1088,7 +1075,7 @@ export class DashboardComponent implements Component {
1088
1075
 
1089
1076
  private openSessionView(): void {
1090
1077
  if (!this.selectedId) return;
1091
- this.deps.service.markVisited?.(this.selectedId);
1078
+ void this.deps.service.markVisited?.(this.selectedId)?.catch(() => {});
1092
1079
  this.sessionScrollTop = 0;
1093
1080
  this.mode = "session";
1094
1081
  }
@@ -2009,18 +1996,6 @@ function isAgentBusy(row: Row): boolean {
2009
1996
  return Boolean(row.alive && (st === "queued" || st === "working" || waitingOnTool));
2010
1997
  }
2011
1998
 
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
1999
  function displayPath(path: string): string {
2025
2000
  const home = process.env.HOME;
2026
2001
  if (home && path.startsWith(home)) return `~${path.slice(home.length)}` || "~";