@huseynsnmz/pi-suite 0.0.0-stage → 0.2.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 (274) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/LICENSE +21 -0
  3. package/README.md +88 -2
  4. package/SECURITY.md +5 -0
  5. package/package.json +112 -4
  6. package/packages/board/CHANGELOG.md +340 -0
  7. package/packages/board/CONTRIBUTING.md +7 -0
  8. package/packages/board/LICENSE +21 -0
  9. package/packages/board/README.md +88 -0
  10. package/packages/board/SECURITY.md +19 -0
  11. package/packages/board/docs/how-it-works.md +31 -0
  12. package/packages/board/docs/reference.md +278 -0
  13. package/packages/board/package.json +81 -0
  14. package/packages/board/runner/job-runner-legacy.mjs +68 -0
  15. package/packages/board/runner/job-runner.mjs +834 -0
  16. package/packages/board/runner/pty-runner-legacy.mjs +50 -0
  17. package/packages/board/runner/pty-runner.mjs +2175 -0
  18. package/packages/board/runner/state-coordinator.mjs +673 -0
  19. package/packages/board/runner/state-runner.mjs +203 -0
  20. package/packages/board/runner/title-runner.mjs +80 -0
  21. package/packages/board/src/commands/agent-board.ts +394 -0
  22. package/packages/board/src/commands/attach-decision.mjs +66 -0
  23. package/packages/board/src/commands/attach-flow.ts +251 -0
  24. package/packages/board/src/commands/bg.ts +103 -0
  25. package/packages/board/src/commands/board-model.ts +21 -0
  26. package/packages/board/src/core/atomic.mjs +184 -0
  27. package/packages/board/src/core/attach-delete.mjs +24 -0
  28. package/packages/board/src/core/attach-frame-proof.mjs +870 -0
  29. package/packages/board/src/core/attach-frame-reporter.mjs +648 -0
  30. package/packages/board/src/core/attach-frame-stream.mjs +266 -0
  31. package/packages/board/src/core/attach-view-proof.mjs +709 -0
  32. package/packages/board/src/core/auto-state.mjs +377 -0
  33. package/packages/board/src/core/background-runs.mjs +273 -0
  34. package/packages/board/src/core/code-refs-store.mjs +315 -0
  35. package/packages/board/src/core/code-refs.mjs +913 -0
  36. package/packages/board/src/core/config.mjs +77 -0
  37. package/packages/board/src/core/control-protocol.mjs +482 -0
  38. package/packages/board/src/core/coordinator-client.mjs +633 -0
  39. package/packages/board/src/core/coordinator-journal.mjs +282 -0
  40. package/packages/board/src/core/coordinator-protocol.mjs +14 -0
  41. package/packages/board/src/core/cwd-stats.mjs +114 -0
  42. package/packages/board/src/core/dashboard-render.mjs +10 -0
  43. package/packages/board/src/core/derive.mjs +117 -0
  44. package/packages/board/src/core/diagnostics.mjs +133 -0
  45. package/packages/board/src/core/double-press.mjs +26 -0
  46. package/packages/board/src/core/editor-state-reporter.mjs +112 -0
  47. package/packages/board/src/core/events.mjs +286 -0
  48. package/packages/board/src/core/evidence.mjs +245 -0
  49. package/packages/board/src/core/follow-up-queue.mjs +211 -0
  50. package/packages/board/src/core/foreground-preview-cache.mjs +117 -0
  51. package/packages/board/src/core/foreground-projection-cache.mjs +99 -0
  52. package/packages/board/src/core/frame-witness-update.mjs +35 -0
  53. package/packages/board/src/core/heuristics.mjs +313 -0
  54. package/packages/board/src/core/hold-input.mjs +27 -0
  55. package/packages/board/src/core/host-coordination.mjs +300 -0
  56. package/packages/board/src/core/host-crash.mjs +79 -0
  57. package/packages/board/src/core/host-probe.mjs +207 -0
  58. package/packages/board/src/core/host-protocol.mjs +73 -0
  59. package/packages/board/src/core/ids.mjs +35 -0
  60. package/packages/board/src/core/ime-cursor-coalesce.mjs +193 -0
  61. package/packages/board/src/core/invocation.mjs +43 -0
  62. package/packages/board/src/core/launch-options.mjs +418 -0
  63. package/packages/board/src/core/launch.mjs +157 -0
  64. package/packages/board/src/core/load-config.mjs +4 -0
  65. package/packages/board/src/core/locks.mjs +530 -0
  66. package/packages/board/src/core/paths.mjs +183 -0
  67. package/packages/board/src/core/pid.mjs +75 -0
  68. package/packages/board/src/core/prewarm-schedule.mjs +41 -0
  69. package/packages/board/src/core/process-identity-probe.mjs +228 -0
  70. package/packages/board/src/core/prompt-transport.mjs +13 -0
  71. package/packages/board/src/core/pty-attach-jiggle-controller.mjs +356 -0
  72. package/packages/board/src/core/pty-attach-jiggle-retry.mjs +109 -0
  73. package/packages/board/src/core/pty-attach-reconnect.mjs +61 -0
  74. package/packages/board/src/core/pty-attach-render.mjs +229 -0
  75. package/packages/board/src/core/pty-links.mjs +81 -0
  76. package/packages/board/src/core/pty-scroll.mjs +522 -0
  77. package/packages/board/src/core/pty-support.mjs +327 -0
  78. package/packages/board/src/core/repo.mjs +103 -0
  79. package/packages/board/src/core/rows.mjs +414 -0
  80. package/packages/board/src/core/screen-log-gc.mjs +198 -0
  81. package/packages/board/src/core/screen-log.mjs +160 -0
  82. package/packages/board/src/core/session-view.mjs +174 -0
  83. package/packages/board/src/core/slash-commands.mjs +52 -0
  84. package/packages/board/src/core/state-commands.mjs +757 -0
  85. package/packages/board/src/core/status-consistency.mjs +98 -0
  86. package/packages/board/src/core/steering-prompts.mjs +34 -0
  87. package/packages/board/src/core/steering.mjs +133 -0
  88. package/packages/board/src/core/stop-latch.mjs +41 -0
  89. package/packages/board/src/core/store.mjs +523 -0
  90. package/packages/board/src/core/suspend.mjs +36 -0
  91. package/packages/board/src/core/terminal-attach-client.mjs +981 -0
  92. package/packages/board/src/core/terminal-attach-protocol.mjs +460 -0
  93. package/packages/board/src/core/terminal-model.mjs +222 -0
  94. package/packages/board/src/core/terminal-query-sequences.mjs +239 -0
  95. package/packages/board/src/core/terminal-reply-guard.mjs +222 -0
  96. package/packages/board/src/core/terminal-snapshot.mjs +440 -0
  97. package/packages/board/src/core/test-knobs.mjs +28 -0
  98. package/packages/board/src/core/title.mjs +43 -0
  99. package/packages/board/src/core/types.mjs +414 -0
  100. package/packages/board/src/core/warm-host-sweeper.mjs +168 -0
  101. package/packages/board/src/core/worktree.mjs +65 -0
  102. package/packages/board/src/index.ts +303 -0
  103. package/packages/board/src/runtime/service.mjs +3222 -0
  104. package/packages/board/src/ui/chrome.ts +20 -0
  105. package/packages/board/src/ui/dashboard-decisions.mjs +55 -0
  106. package/packages/board/src/ui/dashboard-evidence.mjs +85 -0
  107. package/packages/board/src/ui/dashboard.ts +2237 -0
  108. package/packages/board/src/ui/double-left.ts +30 -0
  109. package/packages/board/src/ui/external-editor.ts +25 -0
  110. package/packages/board/src/ui/focus.ts +32 -0
  111. package/packages/board/src/ui/pty-attach.ts +3184 -0
  112. package/packages/board/src/ui/waiting.ts +30 -0
  113. package/packages/commands/LICENSE +21 -0
  114. package/packages/commands/README.md +19 -0
  115. package/packages/commands/package.json +22 -0
  116. package/packages/commands/src/index.ts +43 -0
  117. package/packages/commands/src/security-review.ts +109 -0
  118. package/packages/frame/CHANGELOG.md +113 -0
  119. package/packages/frame/CONTRIBUTING.md +7 -0
  120. package/packages/frame/LICENSE +22 -0
  121. package/packages/frame/README.md +105 -0
  122. package/packages/frame/SECURITY.md +16 -0
  123. package/packages/frame/docs/how-it-works.md +53 -0
  124. package/packages/frame/docs/reference.md +78 -0
  125. package/packages/frame/package.json +72 -0
  126. package/packages/frame/src/data/git.ts +90 -0
  127. package/packages/frame/src/data/github.ts +140 -0
  128. package/packages/frame/src/data/gitlab.ts +63 -0
  129. package/packages/frame/src/data/mode.ts +49 -0
  130. package/packages/frame/src/data/past-prompts.ts +57 -0
  131. package/packages/frame/src/data/runner.ts +32 -0
  132. package/packages/frame/src/data/snapshot.ts +223 -0
  133. package/packages/frame/src/data/start-notes.ts +104 -0
  134. package/packages/frame/src/data/telemetry.ts +337 -0
  135. package/packages/frame/src/data/todos.ts +162 -0
  136. package/packages/frame/src/data/totals.ts +94 -0
  137. package/packages/frame/src/data/visits.ts +45 -0
  138. package/packages/frame/src/index.ts +696 -0
  139. package/packages/frame/src/kit/LICENSE +21 -0
  140. package/packages/frame/src/kit/app/attribution.ts +30 -0
  141. package/packages/frame/src/kit/app/builder.ts +186 -0
  142. package/packages/frame/src/kit/app/claim.ts +27 -0
  143. package/packages/frame/src/kit/app/feature.ts +91 -0
  144. package/packages/frame/src/kit/app/host.ts +214 -0
  145. package/packages/frame/src/kit/app/renderers.ts +62 -0
  146. package/packages/frame/src/kit/app/scope.ts +125 -0
  147. package/packages/frame/src/kit/board/model.ts +337 -0
  148. package/packages/frame/src/kit/contract.ts +79 -0
  149. package/packages/frame/src/kit/contracts/accounts.ts +33 -0
  150. package/packages/frame/src/kit/contracts/ask.ts +120 -0
  151. package/packages/frame/src/kit/contracts/board.ts +6 -0
  152. package/packages/frame/src/kit/contracts/by.ts +22 -0
  153. package/packages/frame/src/kit/contracts/calls.ts +46 -0
  154. package/packages/frame/src/kit/contracts/events.ts +89 -0
  155. package/packages/frame/src/kit/contracts/mode.ts +26 -0
  156. package/packages/frame/src/kit/contracts/names.ts +12 -0
  157. package/packages/frame/src/kit/contracts/peers.ts +77 -0
  158. package/packages/frame/src/kit/contracts/prompts.ts +8 -0
  159. package/packages/frame/src/kit/contracts/screen.ts +75 -0
  160. package/packages/frame/src/kit/contracts/skills.ts +81 -0
  161. package/packages/frame/src/kit/contracts/sources.ts +81 -0
  162. package/packages/frame/src/kit/contracts/views.ts +42 -0
  163. package/packages/frame/src/kit/control.ts +59 -0
  164. package/packages/frame/src/kit/dark-theme.ts +41 -0
  165. package/packages/frame/src/kit/decode.ts +210 -0
  166. package/packages/frame/src/kit/events/log.ts +313 -0
  167. package/packages/frame/src/kit/format.ts +25 -0
  168. package/packages/frame/src/kit/host.ts +4 -0
  169. package/packages/frame/src/kit/index.ts +25 -0
  170. package/packages/frame/src/kit/legacy-tasks.ts +39 -0
  171. package/packages/frame/src/kit/overlay-images.ts +36 -0
  172. package/packages/frame/src/kit/paths.ts +16 -0
  173. package/packages/frame/src/kit/screen/client.ts +74 -0
  174. package/packages/frame/src/kit/screen/feature.ts +64 -0
  175. package/packages/frame/src/kit/screen/model.ts +182 -0
  176. package/packages/frame/src/kit/screen/view.ts +838 -0
  177. package/packages/frame/src/kit/settings/files.ts +90 -0
  178. package/packages/frame/src/kit/settings/setting.ts +57 -0
  179. package/packages/frame/src/kit/settings/store.ts +192 -0
  180. package/packages/frame/src/kit/sources/file.ts +129 -0
  181. package/packages/frame/src/kit/sources/follow.ts +155 -0
  182. package/packages/frame/src/kit/testing.ts +260 -0
  183. package/packages/frame/src/kit/totals.ts +72 -0
  184. package/packages/frame/src/kit/ui/entry.ts +75 -0
  185. package/packages/frame/src/kit/ui/frame.ts +212 -0
  186. package/packages/frame/src/kit/ui/glyphs.ts +132 -0
  187. package/packages/frame/src/kit/ui/scroll.ts +14 -0
  188. package/packages/frame/src/render/diff.ts +157 -0
  189. package/packages/frame/src/render/fit.ts +86 -0
  190. package/packages/frame/src/render/format.ts +108 -0
  191. package/packages/frame/src/render/frame.ts +106 -0
  192. package/packages/frame/src/render/glyphs.ts +189 -0
  193. package/packages/frame/src/render/keys.ts +43 -0
  194. package/packages/frame/src/render/lead.ts +37 -0
  195. package/packages/frame/src/render/paint.ts +135 -0
  196. package/packages/frame/src/render/safe.ts +19 -0
  197. package/packages/frame/src/render/segments.ts +735 -0
  198. package/packages/frame/src/render/streaming.ts +223 -0
  199. package/packages/frame/src/render/timing.ts +106 -0
  200. package/packages/frame/src/render/tools.ts +167 -0
  201. package/packages/frame/src/settings.ts +105 -0
  202. package/packages/frame/src/ui/editor.ts +272 -0
  203. package/packages/frame/src/ui/effort.ts +36 -0
  204. package/packages/frame/src/ui/footer.ts +165 -0
  205. package/packages/frame/src/ui/header.ts +362 -0
  206. package/packages/frame/src/ui/history-search.ts +23 -0
  207. package/packages/frame/src/ui/interrupt.ts +21 -0
  208. package/packages/frame/src/ui/outputs.ts +170 -0
  209. package/packages/frame/src/ui/pi-entries.ts +298 -0
  210. package/packages/frame/src/ui/previews.ts +72 -0
  211. package/packages/frame/src/ui/queue.ts +75 -0
  212. package/packages/frame/src/ui/screen.ts +59 -0
  213. package/packages/frame/src/ui/timings.ts +48 -0
  214. package/packages/frame/src/ui/todos.ts +205 -0
  215. package/packages/frame/src/ui/tool-frame.ts +595 -0
  216. package/packages/frame/src/ui/working.ts +25 -0
  217. package/packages/frame/src/view/state.ts +97 -0
  218. package/packages/frame/src/view/transcript.ts +175 -0
  219. package/packages/frame/src/view/view.ts +162 -0
  220. package/packages/memory/CONTRIBUTING.md +7 -0
  221. package/packages/memory/LICENSE +21 -0
  222. package/packages/memory/README.md +44 -0
  223. package/packages/memory/SECURITY.md +20 -0
  224. package/packages/memory/package.json +55 -0
  225. package/packages/memory/skills/review-memory/SKILL.md +48 -0
  226. package/packages/memory/src/index.ts +163 -0
  227. package/packages/memory/src/prompt.ts +25 -0
  228. package/packages/memory/src/review.ts +150 -0
  229. package/packages/memory/src/secrets.ts +22 -0
  230. package/packages/memory/src/store.ts +173 -0
  231. package/packages/modes/CHANGELOG.md +104 -0
  232. package/packages/modes/CONTRIBUTING.md +7 -0
  233. package/packages/modes/LICENSE +21 -0
  234. package/packages/modes/README.md +174 -0
  235. package/packages/modes/SECURITY.md +17 -0
  236. package/packages/modes/package.json +55 -0
  237. package/packages/modes/skills/review-permissions/SKILL.md +54 -0
  238. package/packages/modes/src/bash.ts +218 -0
  239. package/packages/modes/src/deny-off.ts +62 -0
  240. package/packages/modes/src/git-safety.ts +72 -0
  241. package/packages/modes/src/index.ts +338 -0
  242. package/packages/modes/src/interview.ts +206 -0
  243. package/packages/modes/src/judge-gate.ts +113 -0
  244. package/packages/modes/src/modes.ts +121 -0
  245. package/packages/modes/src/review/apply.mjs +156 -0
  246. package/packages/modes/src/review/collect.mjs +208 -0
  247. package/packages/modes/src/review/tools.ts +71 -0
  248. package/packages/modes/src/sandbox.ts +37 -0
  249. package/packages/router/CHANGELOG.md +8 -0
  250. package/packages/router/LICENSE +21 -0
  251. package/packages/router/README.md +90 -0
  252. package/packages/router/package.json +51 -0
  253. package/packages/router/src/config.ts +65 -0
  254. package/packages/router/src/index.ts +172 -0
  255. package/packages/router/src/judge.ts +51 -0
  256. package/packages/router/src/route.ts +67 -0
  257. package/packages/web/CHANGELOG.md +36 -0
  258. package/packages/web/LICENSE +21 -0
  259. package/packages/web/README.md +87 -0
  260. package/packages/web/SECURITY.md +13 -0
  261. package/packages/web/package.json +59 -0
  262. package/packages/web/src/config.ts +109 -0
  263. package/packages/web/src/dnsfilter.ts +97 -0
  264. package/packages/web/src/extract.ts +65 -0
  265. package/packages/web/src/index.ts +217 -0
  266. package/packages/web/src/markdown.ts +122 -0
  267. package/packages/web/src/net.ts +109 -0
  268. package/packages/web/src/reader.ts +73 -0
  269. package/packages/web/src/sanitize.ts +50 -0
  270. package/packages/web/src/search.ts +76 -0
  271. package/packages/web/src/secrets.ts +22 -0
  272. package/packages/web/src/validate.ts +186 -0
  273. package/src/index.ts +156 -0
  274. package/src/settings.ts +79 -0
@@ -0,0 +1,414 @@
1
+ /**
2
+ * Pure dashboard logic: turn store Rows into display view-models, group them by
3
+ * semantic state, and filter them. No pi-tui / theme coupling (the dashboard component
4
+ * applies colors); everything here is unit-tested.
5
+ */
6
+ import { normalizeGenericStatusText } from "./derive.mjs";
7
+ import { baseName, relativeTime } from "./heuristics.mjs";
8
+ import { GROUP_LABELS, GROUP_ORDER, SEMANTIC_STATES } from "./types.mjs";
9
+
10
+ /** @typedef {import("./store.mjs").Row} Row */
11
+ /** @typedef {import("./types.mjs").SemanticState} SemanticState */
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
+
20
+ /**
21
+ * @typedef {Object} RowView
22
+ * @property {string} id
23
+ * @property {string} name
24
+ * @property {string} summary
25
+ * @property {string} age
26
+ * @property {string} place
27
+ * @property {string} folderKey
28
+ * @property {string} folderName
29
+ * @property {string} folderPath
30
+ * @property {boolean} pinned
31
+ * @property {SemanticState} state
32
+ * @property {boolean} alive
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).
36
+ * @property {boolean} needsInput
37
+ * @property {boolean} hasError
38
+ * @property {boolean} worktree
39
+ * @property {boolean} unread
40
+ * @property {boolean} reviewReady
41
+ * @property {number} evidenceErrorCount
42
+ * @property {boolean} diagnosticStalled
43
+ * @property {number} diagnosticErrorCount
44
+ * @property {number} followUpCount
45
+ * @property {string|null} followUpPreview
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.
50
+ * @property {number} lastActivityAt
51
+ * @property {number} createdAt
52
+ */
53
+
54
+ /** @param {Row} row @returns {SemanticState} */
55
+ export function rowState(row) {
56
+ return row.state?.semanticState ?? "queued";
57
+ }
58
+
59
+ /**
60
+ * State glyph (plain unicode; the dashboard colors it via theme).
61
+ * @param {SemanticState} state
62
+ * @param {boolean} alive
63
+ * @param {boolean} [hostAlive]
64
+ * @param {boolean} [unread]
65
+ * @returns {string}
66
+ */
67
+ export function stateGlyph(state, alive, hostAlive = false, unread = false) {
68
+ switch (state) {
69
+ case "needs_input":
70
+ return unread ? "◆" : "◇";
71
+ case "working":
72
+ return alive ? (unread ? "◉" : "●") : (unread ? "◕" : "◐");
73
+ case "queued":
74
+ return hostAlive ? (unread ? "◍" : "◌") : (unread ? "◎" : "○");
75
+ case "completed":
76
+ return hostAlive ? (unread ? "◍" : "◌") : (unread ? "✔" : "✓");
77
+ case "failed":
78
+ return unread ? "✖" : "✗";
79
+ case "idle":
80
+ return unread ? "●" : "·";
81
+ case "holding":
82
+ return unread ? "◒" : "◑";
83
+ case "stopped":
84
+ return unread ? "■" : "▪";
85
+ default:
86
+ return "?";
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Theme color name appropriate for a state (consumed by the dashboard).
92
+ * @param {SemanticState} state
93
+ * @returns {string}
94
+ */
95
+ export function stateColor(state) {
96
+ switch (state) {
97
+ case "needs_input":
98
+ return "warning";
99
+ case "working":
100
+ return "accent";
101
+ case "queued":
102
+ return "muted";
103
+ case "completed":
104
+ return "success";
105
+ case "failed":
106
+ return "error";
107
+ case "idle":
108
+ return "dim";
109
+ case "holding":
110
+ return "warning";
111
+ case "stopped":
112
+ return "muted";
113
+ default:
114
+ return "text";
115
+ }
116
+ }
117
+
118
+ /**
119
+ * @param {Row} row
120
+ * @param {number} now
121
+ * @returns {RowView}
122
+ */
123
+ export function rowView(row, now) {
124
+ const state = rowState(row);
125
+ const summary = oneLine(normalizeGenericStatusText(state, row.state?.summary));
126
+ const lastActivityAt = row.state?.lastActivityAt ?? row.meta.updatedAt ?? row.meta.createdAt;
127
+ const worktree = row.meta.worktreeMode === "worktree";
128
+ const folderPath = row.meta.repoCwd || row.meta.cwd;
129
+ const folderName = baseName(folderPath) + (worktree ? "⌥" : "");
130
+ const folderKey = normalizeFolderKey(folderPath);
131
+ const place = folderName;
132
+ const lastVisitedAt = row.state?.lastVisitedAt ?? null;
133
+ const lastAgentActivityAt = row.state?.lastAgentActivityAt ?? null;
134
+ const refs = codeRefsBadge(row.codeRefs);
135
+ // Host-lost display marker (issue #48 L4): host.json still claims "alive"
136
+ // but the runner process is gone and the 1 Hz heartbeat went stale — the
137
+ // crash path leaves exactly this fingerprint (no exit message, no
138
+ // reconcile). Display-only; state.json is deliberately untouched.
139
+ const host = row.host;
140
+ const staleHost = Boolean(
141
+ host &&
142
+ host.state === "alive" &&
143
+ !row.hostAlive &&
144
+ typeof host.lastSeenAt === "number" &&
145
+ now - host.lastSeenAt > STALE_HOST_MS,
146
+ );
147
+ return {
148
+ id: row.meta.id,
149
+ name: row.meta.name,
150
+ summary,
151
+ age: relativeTime(lastActivityAt, now),
152
+ place,
153
+ folderKey,
154
+ folderName,
155
+ folderPath,
156
+ pinned: Boolean(row.meta.pinned),
157
+ order: row.meta.order ?? row.meta.createdAt ?? 0,
158
+ state,
159
+ alive: Boolean(row.alive),
160
+ hostAlive: Boolean(row.hostAlive),
161
+ staleHost,
162
+ needsInput: state === "needs_input",
163
+ hasError: state === "failed",
164
+ worktree,
165
+ unread: lastAgentActivityAt !== null && (lastVisitedAt === null || lastAgentActivityAt > lastVisitedAt),
166
+ reviewReady: Boolean(row.state?.review?.ready),
167
+ evidenceErrorCount: row.state?.review?.errorCount ?? 0,
168
+ diagnosticStalled: Boolean(row.state?.diagnostics?.stalled),
169
+ diagnosticErrorCount: row.state?.diagnostics?.errorCount ?? 0,
170
+ followUpCount: row.state?.followUps?.queuedCount ?? 0,
171
+ followUpPreview: row.state?.followUps?.lastQueuedPreview ?? null,
172
+ steeringState: row.state?.steering?.status ?? "none",
173
+ refsBadge: refs.refsBadge,
174
+ refsLowConfidence: refs.refsLowConfidence,
175
+ codeRefs: row.codeRefs ?? null,
176
+ lastActivityAt,
177
+ createdAt: row.meta.createdAt ?? 0,
178
+ };
179
+ }
180
+
181
+ /**
182
+ * Map a CodeRefsSummary to the inline badge strings shown on dashboard rows:
183
+ * the winning issue and pr joined by a space, each prefixed with the provider
184
+ * prefix stored on the summary; plus the low-confidence flag that makes the
185
+ * dashboard dim the badge. Missing/garbage summaries yield an empty badge.
186
+ * @param {import("./types.mjs").CodeRefsSummary|null|undefined} summary
187
+ * @returns {{ refsBadge: string, refsLowConfidence: boolean }}
188
+ */
189
+ function codeRefsBadge(summary) {
190
+ if (!summary) return { refsBadge: "", refsLowConfidence: false };
191
+ const issue = summary.issue;
192
+ const pr = summary.pr;
193
+ const parts = [];
194
+ if (issue && Number.isFinite(issue.number)) parts.push(`${summary.issuePrefix ?? "#"}${issue.number}`);
195
+ if (pr && Number.isFinite(pr.number)) parts.push(`${summary.prPrefix ?? "▸#"}${pr.number}`);
196
+ return {
197
+ refsBadge: parts.join(" "),
198
+ refsLowConfidence: issue?.confidence === "low" || pr?.confidence === "low",
199
+ };
200
+ }
201
+
202
+ /** @param {string} text */
203
+ function oneLine(text) {
204
+ return String(text || "").replace(/\s+/g, " ").trim() || "—";
205
+ }
206
+
207
+ /**
208
+ * Group rows by semantic state in GROUP_ORDER. Within a group: pinned first, then
209
+ * creation order (oldest first) — the order is stable and never reshuffles on activity.
210
+ * Empty groups are omitted.
211
+ * @param {Row[]} rows
212
+ * @param {number} now
213
+ * @returns {Array<{ state: SemanticState, label: string, rows: RowView[] }>}
214
+ */
215
+ export function groupRows(rows, now) {
216
+ const views = rows.map((r) => rowView(r, now));
217
+ /** @type {Array<{state: SemanticState, label: string, rows: RowView[]}>} */
218
+ const groups = [];
219
+ for (const state of GROUP_ORDER) {
220
+ const inGroup = sortRowViews(views.filter((v) => v.state === state));
221
+ if (inGroup.length > 0) groups.push({ state, label: GROUP_LABELS[state], rows: inGroup });
222
+ }
223
+ return groups;
224
+ }
225
+
226
+ /**
227
+ * Group rows first by semantic state, then by repo/folder within that state.
228
+ * `showFolders` is true only when a stage spans more than one folder, so a stage backed by
229
+ * a single folder renders as a flat list (no redundant folder header).
230
+ * @param {Row[]} rows
231
+ * @param {number} now
232
+ * @returns {Array<{ state: SemanticState, label: string, rowCount: number, showFolders: boolean, folders: Array<{ key: string, name: string, path: string, rows: RowView[], createdAt: number, pinned: boolean }> }>}
233
+ */
234
+ export function groupRowsByFolder(rows, now) {
235
+ const views = rows.map((r) => rowView(r, now));
236
+ const groups = [];
237
+ for (const state of GROUP_ORDER) {
238
+ const inState = views.filter((v) => v.state === state);
239
+ if (inState.length === 0) continue;
240
+ const byFolder = new Map();
241
+ for (const view of inState) {
242
+ const key = view.folderKey;
243
+ const existing = byFolder.get(key);
244
+ if (existing) existing.rows.push(view);
245
+ else byFolder.set(key, { key, name: view.folderName, path: view.folderPath, rows: [view], createdAt: view.createdAt, pinned: view.pinned });
246
+ }
247
+ const folders = [...byFolder.values()].map((folder) => {
248
+ folder.rows = sortRowViews(folder.rows);
249
+ // Folder creation time = its first-created row, so folders keep a stable,
250
+ // creation-ordered position and don't jump when any row inside gets busy.
251
+ folder.createdAt = Math.min(...folder.rows.map((r) => r.createdAt));
252
+ folder.pinned = folder.rows.some((r) => r.pinned);
253
+ return folder;
254
+ }).sort((a, b) => Number(b.pinned) - Number(a.pinned) || a.createdAt - b.createdAt || a.name.localeCompare(b.name));
255
+ const showFolders = folders.length > 1;
256
+ groups.push({ state, label: GROUP_LABELS[state], rowCount: inState.length, showFolders, folders });
257
+ }
258
+ return groups;
259
+ }
260
+
261
+ /**
262
+ * The other way to see the list: one group per directory, whatever state its rows are in.
263
+ * @param {Row[]} rows
264
+ * @param {number} now
265
+ * @returns {Array<{ key: string, name: string, path: string, rows: RowView[] }>}
266
+ */
267
+ export function groupRowsByDirectory(rows, now) {
268
+ const byFolder = new Map();
269
+ for (const view of rows.map((r) => rowView(r, now))) {
270
+ const existing = byFolder.get(view.folderKey);
271
+ if (existing) existing.rows.push(view);
272
+ else byFolder.set(view.folderKey, { key: view.folderKey, name: view.folderName, path: view.folderPath, rows: [view] });
273
+ }
274
+ const folders = [...byFolder.values()];
275
+ for (const folder of folders) folder.rows = sortRowViews(folder.rows);
276
+ return folders.sort((a, b) => Math.min(...a.rows.map((r) => r.createdAt)) - Math.min(...b.rows.map((r) => r.createdAt)) || a.name.localeCompare(b.name));
277
+ }
278
+
279
+ /**
280
+ * Issue #147: the row the board should select after `id` is removed — the
281
+ * next visible row in dashboard order, else the previous one, else null
282
+ * (caller falls back to the existing orderedIds[0] clamp). Uses the same
283
+ * groupRowsByFolder ordering as the dashboard's orderedIds.
284
+ * @param {Row[]} rows
285
+ * @param {string} id
286
+ * @param {number} now
287
+ * @returns {string | null}
288
+ */
289
+ export function nextVisibleIdAfterRemoval(rows, id, now) {
290
+ const ordered = groupRowsByFolder(rows, now).flatMap((g) => g.folders.flatMap((f) => f.rows.map((r) => r.id)));
291
+ const groups = new Map(groupRowsByFolder(rows, now).flatMap((g) => g.folders.flatMap((f) => f.rows.map((r) => [r.id, g.state]))));
292
+ return neighborAfterRemoval(ordered, groups, id, (other) => other !== id);
293
+ }
294
+
295
+ /**
296
+ * Where the cursor goes when its row is gone: one up in the same group, or the
297
+ * next one down if it was that group's first. An emptied group falls through to
298
+ * the nearest row above, then below.
299
+ * @param {readonly string[]} ordered
300
+ * @param {ReadonlyMap<string, string>} groups
301
+ * @param {string} id
302
+ * @param {(other: string) => boolean} survives
303
+ * @returns {string | null}
304
+ */
305
+ export function neighborAfterRemoval(ordered, groups, id, survives) {
306
+ const at = ordered.indexOf(id);
307
+ if (at === -1) return null;
308
+ const above = ordered.slice(0, at).reverse().filter(survives);
309
+ const below = ordered.slice(at + 1).filter(survives);
310
+ const sameGroup = (other) => groups.get(other) === groups.get(id);
311
+ return above.find(sameGroup) ?? below.find(sameGroup) ?? above[0] ?? below[0] ?? null;
312
+ }
313
+
314
+ /**
315
+ * Stable row order: pinned first, then creation order (oldest first), then id.
316
+ * Activity time deliberately does not participate, so rows never reshuffle while
317
+ * sessions are running.
318
+ * @param {RowView[]} views
319
+ */
320
+ function sortRowViews(views) {
321
+ return views.sort(
322
+ (a, b) =>
323
+ Number(b.pinned) - Number(a.pinned) ||
324
+ a.order - b.order ||
325
+ a.createdAt - b.createdAt ||
326
+ a.id.localeCompare(b.id),
327
+ );
328
+ }
329
+
330
+ /** @param {string} path */
331
+ function normalizeFolderKey(path) {
332
+ return String(path || "").replace(/\\/g, "/").replace(/\/+$/, "") || "/";
333
+ }
334
+
335
+ /**
336
+ * Parse a filter query into a state filter + free-text terms.
337
+ * Supports `s:<state>` (state prefix) and bare words (AND substring match).
338
+ * @param {string} query
339
+ * @returns {{ states: SemanticState[], terms: string[], reviewReady: boolean, diagStalled: boolean, evidenceError: boolean, queued: boolean, steering: string|null }}
340
+ */
341
+ export function parseFilter(query) {
342
+ /** @type {Set<SemanticState>} */
343
+ const states = new Set();
344
+ /** @type {string[]} */
345
+ const terms = [];
346
+ let reviewReady = false;
347
+ let diagStalled = false;
348
+ let evidenceError = false;
349
+ let queued = false;
350
+ let steering = null;
351
+ for (const tok of String(query || "").trim().split(/\s+/).filter(Boolean)) {
352
+ const m = /^s:(.+)$/i.exec(tok);
353
+ if (m) {
354
+ const want = normalizeStateToken(m[1]);
355
+ for (const s of SEMANTIC_STATES) {
356
+ const aliases = [s, GROUP_LABELS[s]];
357
+ if (aliases.some((alias) => matchesStateToken(alias, want))) states.add(s);
358
+ }
359
+ } else if (/^review:ready$/i.test(tok)) {
360
+ reviewReady = true;
361
+ } else if (/^diag:stalled$/i.test(tok)) {
362
+ diagStalled = true;
363
+ } else if (/^evidence:error$/i.test(tok)) {
364
+ evidenceError = true;
365
+ } else if (/^queued:(true|yes|1)$/i.test(tok)) {
366
+ queued = true;
367
+ } else if (/^steer:/i.test(tok)) {
368
+ steering = normalizeStateToken(tok.replace(/^steer:/i, ""));
369
+ } else {
370
+ terms.push(tok.toLowerCase());
371
+ }
372
+ }
373
+ return { states: [...states], terms, reviewReady, diagStalled, evidenceError, queued, steering };
374
+ }
375
+
376
+ /** @param {string} value */
377
+ function normalizeStateToken(value) {
378
+ return String(value || "").toLowerCase().replace(/[^a-z0-9]+/g, "");
379
+ }
380
+
381
+ /** @param {string} alias @param {string} want */
382
+ function matchesStateToken(alias, want) {
383
+ const normalized = normalizeStateToken(alias);
384
+ return normalized === want || normalized.startsWith(want);
385
+ }
386
+
387
+ /** @param {string} query @returns {boolean} whether the text is a filter expression. */
388
+ export function isFilterQuery(query) {
389
+ return /(^|\s)(s:|review:|diag:|evidence:|queued:|steer:)/i.test(query || "");
390
+ }
391
+
392
+ /**
393
+ * Filter rows by a query string.
394
+ * @param {Row[]} rows
395
+ * @param {string} query
396
+ * @returns {Row[]}
397
+ */
398
+ export function filterRows(rows, query) {
399
+ const { states, terms, reviewReady, diagStalled, evidenceError, queued, steering } = parseFilter(query);
400
+ if (states.length === 0 && terms.length === 0 && !reviewReady && !diagStalled && !evidenceError && !queued && !steering) return rows;
401
+ return rows.filter((row) => {
402
+ if (states.length > 0 && !states.includes(rowState(row))) return false;
403
+ if (reviewReady && !row.state?.review?.ready) return false;
404
+ if (diagStalled && !row.state?.diagnostics?.stalled) return false;
405
+ if (evidenceError && (row.state?.review?.errorCount ?? 0) <= 0) return false;
406
+ if (queued && (row.state?.followUps?.queuedCount ?? 0) <= 0) return false;
407
+ if (steering && normalizeStateToken(row.state?.steering?.status ?? "none") !== steering) return false;
408
+ if (terms.length === 0) return true;
409
+ const hay = [row.meta.name, row.state?.summary ?? "", row.meta.repoCwd, row.meta.cwd]
410
+ .join(" ")
411
+ .toLowerCase();
412
+ return terms.every((t) => hay.includes(t));
413
+ });
414
+ }
@@ -0,0 +1,198 @@
1
+ /**
2
+ * Startup GC for per-view PTY replay logs.
3
+ *
4
+ * screen.log write-path growth is already bounded by screen-log.mjs (cap + tail
5
+ * compaction inside pty-runner). This module reclaims the other half: logs of
6
+ * views whose session ENDED long ago — no runner will ever touch them again,
7
+ * so without a sweep they sit on disk forever.
8
+ *
9
+ * Safety rules:
10
+ * - Only screen.log is removed; meta/state/evidence stay so the dashboard row survives.
11
+ * - Views with a live host (state alive/starting, endedAt null) are never touched:
12
+ * pty-runner holds an in-memory byte counter for its log and external mutation
13
+ * would race with it. Live logs are bounded by the runner's own cap.
14
+ */
15
+ import { existsSync, readdirSync, statSync, unlinkSync } from "node:fs";
16
+ import { readJson } from "./atomic.mjs";
17
+ import * as P from "./paths.mjs";
18
+ import { isAlive } from "./pid.mjs";
19
+
20
+ export const DEFAULT_SCREEN_LOG_RETENTION_DAYS = 7;
21
+ const DAY_MS = 24 * 60 * 60 * 1000;
22
+
23
+ /**
24
+ * A live pty-runner heartbeat-persists host.json every second, and a booting runner
25
+ * persists state "starting" before the service's own writeHost lands. A host.json
26
+ * modified within this window therefore means a runner is live or mid-launch.
27
+ */
28
+ const HOST_FRESH_GRACE_MS = 10_000;
29
+
30
+ /**
31
+ * An "alive"/"starting" claim older than this is not trusted: heartbeat-persisted
32
+ * lastSeenAt goes stale when a runner dies, and a dead pid can later be recycled by
33
+ * an unrelated process — pid liveness alone must not exempt a view forever.
34
+ */
35
+ const ALIVE_CLAIM_MAX_AGE_MS = 3 * DAY_MS;
36
+
37
+ /**
38
+ * Shared input gate for the prefs normalizers: only finite numbers and non-blank
39
+ * numeric strings are meaningful. Anything else (booleans, arrays, "", " ",
40
+ * objects) is a hand-edit accident, not a value.
41
+ * @param {unknown} value
42
+ * @returns {number|null} the coerced finite number, or null when not usable
43
+ */
44
+ function coerceUsableNumber(value) {
45
+ if (typeof value === "number") return Number.isFinite(value) ? value : null;
46
+ if (typeof value === "string" && value.trim() !== "") {
47
+ const n = Number(value);
48
+ return Number.isFinite(n) ? n : null;
49
+ }
50
+ return null;
51
+ }
52
+
53
+ /**
54
+ * Normalize the `screenLogRetentionDays` pref.
55
+ * @param {unknown} value
56
+ * @returns {number|null} days, or null when GC is disabled (pref = 0)
57
+ */
58
+ export function normalizeRetentionDays(value) {
59
+ const n = coerceUsableNumber(value);
60
+ if (n === null || n < 0) return DEFAULT_SCREEN_LOG_RETENTION_DAYS;
61
+ const days = Math.floor(n);
62
+ // Anything flooring to 0 (0, "0", "0.0", fractions in (0,1)) means "disabled".
63
+ // A 0-day retention would otherwise compute cutoff=now and delete EVERY ended log.
64
+ return days === 0 ? null : days;
65
+ }
66
+
67
+ /**
68
+ * Normalize the `screenLogMaxSize` pref.
69
+ * @param {unknown} value
70
+ * @returns {number|null} bytes, or null to keep the runner's built-in default
71
+ */
72
+ export function normalizeScreenLogMaxBytes(value) {
73
+ const n = coerceUsableNumber(value);
74
+ if (n === null) return null;
75
+ const bytes = Math.floor(n);
76
+ return bytes > 0 ? bytes : null;
77
+ }
78
+
79
+ /**
80
+ * Delete screen.log of ended views older than the retention window.
81
+ * Best-effort: a per-file failure is counted and skipped, never thrown.
82
+ * @param {string} root
83
+ * @param {{ retentionDays?: number|null, now?: number }} [opts]
84
+ * @returns {{ scanned: number, removed: number, skippedActive: number, skippedFresh: number, skippedForeign: number, bytesReclaimed: number, errors: number }}
85
+ */
86
+ export function pruneScreenLogs(root, opts = {}) {
87
+ const stats = { scanned: 0, removed: 0, skippedActive: 0, skippedFresh: 0, skippedForeign: 0, bytesReclaimed: 0, errors: 0 };
88
+ const retentionDays = normalizeRetentionDays(opts.retentionDays);
89
+ if (retentionDays === null) return stats;
90
+ const now = Number.isFinite(opts.now) ? opts.now : Date.now();
91
+ const cutoff = now - retentionDays * DAY_MS;
92
+ /** @type {import("node:fs").Dirent[]} */
93
+ let entries;
94
+ try {
95
+ entries = readdirSync(P.viewsDir(root), { withFileTypes: true });
96
+ } catch {
97
+ return stats; // no views dir yet — nothing to do
98
+ }
99
+ for (const entry of entries) {
100
+ if (!entry.isDirectory()) continue;
101
+ if (!isViewDir(root, entry.name)) {
102
+ // Foreign dirs must never lose a file; count the ones holding a screen.log
103
+ // so the skip is visible in stats.
104
+ try {
105
+ if (statSync(P.screenLogPath(root, entry.name)).size > 0) stats.skippedForeign++;
106
+ } catch {}
107
+ continue;
108
+ }
109
+ const logFile = P.screenLogPath(root, entry.name);
110
+ /** @type {number} */
111
+ let size;
112
+ try {
113
+ size = statSync(logFile).size;
114
+ } catch {
115
+ continue; // no screen.log (job-runner views never have one)
116
+ }
117
+ if (size <= 0) continue;
118
+ stats.scanned++;
119
+ const basis = ageBasisMs(root, entry.name, logFile, now);
120
+ if (basis === "active") {
121
+ stats.skippedActive++;
122
+ continue;
123
+ }
124
+ if (basis === null || basis > cutoff) {
125
+ stats.skippedFresh++;
126
+ continue;
127
+ }
128
+ try {
129
+ unlinkSync(logFile);
130
+ stats.removed++;
131
+ stats.bytesReclaimed += size;
132
+ } catch (err) {
133
+ if (classifyUnlinkFailure(err) === "removed") stats.removed++;
134
+ else stats.errors++;
135
+ }
136
+ }
137
+ return stats;
138
+ }
139
+
140
+ /**
141
+ * Age basis for one view's log: host endedAt when known, else the log's mtime.
142
+ * @param {string} root @param {string} viewId @param {string} logFile @param {number} now
143
+ * @returns {number|null|"active"} epoch ms, "active" for live views, null when unknown
144
+ */
145
+ function ageBasisMs(root, viewId, logFile, now) {
146
+ const hostFile = P.hostPath(root, viewId);
147
+ const host = readJson(hostFile, null);
148
+ if (host) {
149
+ // Fresh host.json = a runner is heartbeating or mid-launch. Checking mtime first
150
+ // closes the launch TOCTOU window (runner boots and opens the log before the
151
+ // service's writeHost records "starting"): a stale read can never delete the
152
+ // log of a runner that just started.
153
+ try {
154
+ if (now - statSync(hostFile).mtimeMs < HOST_FRESH_GRACE_MS) return "active";
155
+ } catch {}
156
+ if (host.endedAt == null && (host.state === "alive" || host.state === "starting")) {
157
+ // A runner killed by SIGKILL/OOM leaves state "alive" on disk forever, and its
158
+ // pid may later be recycled by an unrelated long-lived process (isAlive also
159
+ // treats EPERM as alive). Trust the claim only when the heartbeat refreshed
160
+ // lastSeenAt recently AND the pid is actually alive.
161
+ const claimFresh = Number.isFinite(host.lastSeenAt) && now - host.lastSeenAt < ALIVE_CLAIM_MAX_AGE_MS;
162
+ if (claimFresh && Number.isInteger(host.runnerPid) && isAlive(host.runnerPid)) return "active";
163
+ }
164
+ if (Number.isFinite(host.endedAt)) return host.endedAt;
165
+ }
166
+ try {
167
+ return statSync(logFile).mtimeMs;
168
+ } catch {
169
+ return null;
170
+ }
171
+ }
172
+
173
+ /**
174
+ * Classify an unlinkSync failure. ENOENT after a successful stat means a concurrent
175
+ * sweep (a second dashboard sharing the root) won the unlink: the reclaim goal is
176
+ * achieved, so it counts as removed (without the bytes, which the winner accounted
177
+ * for), never as an error. Everything else is a real failure.
178
+ * Extracted as a pure predicate because the stat→unlink race cannot be reproduced
179
+ * deterministically in tests — the predicate is the part that can silently regress.
180
+ * @param {unknown} err
181
+ * @returns {"removed"|"error"}
182
+ */
183
+ export function classifyUnlinkFailure(err) {
184
+ return err && err.code === "ENOENT" ? "removed" : "error";
185
+ }
186
+
187
+ /**
188
+ * A sweep-eligible directory is a real view: it carries meta.json, or — when
189
+ * meta.json was externally deleted — a host.json that is parseable AND names this
190
+ * directory as its view. A stray/garbage host.json in a foreign directory must not
191
+ * make that directory's screen.log a deletion candidate.
192
+ * @param {string} root @param {string} viewId
193
+ */
194
+ function isViewDir(root, viewId) {
195
+ if (existsSync(P.metaPath(root, viewId))) return true;
196
+ const host = readJson(P.hostPath(root, viewId), null);
197
+ return Boolean(host && host.viewId === viewId);
198
+ }