@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,523 @@
1
+ /**
2
+ * High-level store operations over the agent-board layout (see paths.mjs).
3
+ * Roster/meta/state/status read+write, row listing, and view creation/recovery.
4
+ * Used by the extension, the runner, and tests. Pure node, no Pi imports.
5
+ */
6
+ import { existsSync, readdirSync, statSync } from "node:fs";
7
+ import { atomicWriteJson, ensureDir, readJson } from "./atomic.mjs";
8
+ import { sameHostOwner } from "./host-coordination.mjs";
9
+ import { tryAcquireOwnedViewLock } from "./locks.mjs";
10
+ import * as P from "./paths.mjs";
11
+ import { currentProcessIdentity, isAlive } from "./pid.mjs";
12
+ import { readCodeRefs, summarizeCodeRefs } from "./code-refs-store.mjs";
13
+ import { appendDiagnostic, readDiagnosticSummary } from "./diagnostics.mjs";
14
+ import { readEvidence, summarizeEvidence } from "./evidence.mjs";
15
+ import { readFollowUpQueue, summarizeFollowUpQueue } from "./follow-up-queue.mjs";
16
+ import { readSteering, summarizeSteering } from "./steering.mjs";
17
+
18
+ /** @typedef {import("./types.mjs").Roster} Roster */
19
+ /** @typedef {import("./types.mjs").ViewMeta} ViewMeta */
20
+ /** @typedef {import("./types.mjs").ViewState} ViewState */
21
+ /** @typedef {import("./types.mjs").RunStatus} RunStatus */
22
+ /** @typedef {import("./types.mjs").HostStatus} HostStatus */
23
+ /** @typedef {import("./types.mjs").LaunchPrefs} LaunchPrefs */
24
+
25
+ const META_VERSION = 1;
26
+
27
+ // ---- roster ---------------------------------------------------------------
28
+
29
+ /** @param {string} root @returns {Roster} */
30
+ export function readRoster(root) {
31
+ return readJson(P.rosterPath(root), { version: 1, views: [] });
32
+ }
33
+
34
+ /** @param {string} root @param {Roster} roster */
35
+ export function writeRoster(root, roster) {
36
+ atomicWriteJson(P.rosterPath(root), roster);
37
+ }
38
+
39
+ /** @param {string} root @param {string} viewId */
40
+ export function addToRoster(root, viewId) {
41
+ const roster = readRoster(root);
42
+ if (!roster.views.includes(viewId)) {
43
+ roster.views.push(viewId);
44
+ writeRoster(root, roster);
45
+ }
46
+ }
47
+
48
+ /** @param {string} root @param {string} viewId */
49
+ export function removeFromRoster(root, viewId) {
50
+ const roster = readRoster(root);
51
+ const next = roster.views.filter((v) => v !== viewId);
52
+ if (next.length !== roster.views.length) {
53
+ roster.version = roster.version ?? 1;
54
+ writeRoster(root, { version: roster.version, views: next });
55
+ }
56
+ }
57
+
58
+ /** @param {string} root @returns {LaunchPrefs} */
59
+ export function readLaunchPrefs(root) {
60
+ return readJson(P.launchPrefsPath(root), {
61
+ version: 1,
62
+ cwd: null,
63
+ model: null,
64
+ thinkingLevel: null,
65
+ screenLogRetentionDays: null,
66
+ screenLogMaxSize: null,
67
+ });
68
+ }
69
+
70
+ /** @param {string} root @param {Partial<LaunchPrefs>} prefs */
71
+ export function writeLaunchPrefs(root, prefs) {
72
+ atomicWriteJson(P.launchPrefsPath(root), {
73
+ version: 1,
74
+ cwd: prefs.cwd ?? null,
75
+ model: prefs.model ?? null,
76
+ thinkingLevel: prefs.thinkingLevel ?? null,
77
+ screenLogRetentionDays: prefs.screenLogRetentionDays ?? null,
78
+ screenLogMaxSize: prefs.screenLogMaxSize ?? null,
79
+ });
80
+ }
81
+
82
+ // ---- meta / state / status ------------------------------------------------
83
+
84
+ /** @param {string} root @param {string} viewId @returns {ViewMeta|null} */
85
+ export function readMeta(root, viewId) {
86
+ return readJson(P.metaPath(root, viewId), /** @type {ViewMeta|null} */ (null));
87
+ }
88
+
89
+ /** @param {string} root @param {ViewMeta} meta */
90
+ export function writeMeta(root, meta) {
91
+ meta.updatedAt = Date.now();
92
+ atomicWriteJson(P.metaPath(root, meta.id), meta);
93
+ }
94
+
95
+ /** @param {string} root @param {string} viewId @returns {ViewState|null} */
96
+ export function readState(root, viewId) {
97
+ return readJson(P.statePath(root, viewId), /** @type {ViewState|null} */ (null));
98
+ }
99
+
100
+ /** @param {string} root @param {ViewState} state */
101
+ export function writeState(root, state) {
102
+ atomicWriteJson(P.statePath(root, state.viewId), state);
103
+ }
104
+
105
+ /** @param {string} root @param {string} viewId @param {string} runId @returns {RunStatus|null} */
106
+ export function readStatus(root, viewId, runId) {
107
+ return readJson(P.statusPath(root, viewId, runId), /** @type {RunStatus|null} */ (null));
108
+ }
109
+
110
+ /** @param {string} root @param {RunStatus} status */
111
+ export function writeStatus(root, status) {
112
+ atomicWriteJson(P.statusPath(root, status.viewId, status.runId), status);
113
+ }
114
+
115
+ /** @param {string} root @param {string} viewId @returns {HostStatus|null} */
116
+ export function readHost(root, viewId) {
117
+ return readJson(P.hostPath(root, viewId), /** @type {HostStatus|null} */ (null));
118
+ }
119
+
120
+ /** @param {string} root @param {HostStatus} host */
121
+ export function writeHost(root, host) {
122
+ atomicWriteJson(P.hostPath(root, host.viewId), host);
123
+ }
124
+
125
+ /**
126
+ * @param {string} root
127
+ * @param {string} viewId
128
+ * @param {number|null} pid
129
+ * @param {{ instanceId?: string, identity?: object|null }} [extra] merged into the mirror record
130
+ */
131
+ export function writeHostPid(root, viewId, pid, extra) {
132
+ atomicWriteJson(P.hostPidPath(root, viewId), { pid, at: Date.now(), ...(extra ?? {}) });
133
+ }
134
+
135
+ /**
136
+ * Host states that represent an unreplaced claim: no replacement may be
137
+ * started while a host record is in one of these states (issue #70).
138
+ * @param {HostStatus|null} host
139
+ */
140
+ function hostClaimActive(host) {
141
+ return Boolean(host && (host.state === "starting" || host.state === "alive" || host.state === "stopping"));
142
+ }
143
+
144
+ /**
145
+ * Views with an unrecovered host-meta contention report on record. The
146
+ * heartbeat path calls updateOwnedHost once per second, so without this a
147
+ * sustained contention episode would flood diagnostics.jsonl (issue #112).
148
+ */
149
+ const hostMetaContentionReported = new Set();
150
+
151
+ /** Test hook: clear the per-process contention report throttle. */
152
+ export function clearHostMetaThrottleForTests() {
153
+ hostMetaContentionReported.clear();
154
+ }
155
+
156
+ /**
157
+ * Best-effort single warn per view per contention episode; diagnostics must
158
+ * never break the caller (appendDiagnostic throws on fs failure).
159
+ */
160
+ function reportHostMetaContention(root, viewId, code, message, details) {
161
+ if (hostMetaContentionReported.has(viewId)) return;
162
+ hostMetaContentionReported.add(viewId);
163
+ try {
164
+ appendDiagnostic(root, viewId, { source: "store", level: "warn", code, message, details });
165
+ } catch { /* best effort */ }
166
+ }
167
+
168
+ /**
169
+ * Identity stamped on host-meta acquisitions so a holder that dies mid-hold
170
+ * leaves a reclaimable record (issue #112).
171
+ * @returns {{pid: number, startToken: string|null}}
172
+ */
173
+ function hostMetaIdentity() {
174
+ return currentProcessIdentity();
175
+ }
176
+
177
+ /**
178
+ * Atomically create the provisional `starting` host record for a new instance.
179
+ * The ONLY entry point allowed to move "no claim / reclaimable terminal state"
180
+ * into `starting` (issue #70). Refuses when an active claim exists or the
181
+ * host-meta lease is contended; the fresh record always nulls runner/child/
182
+ * ready/stop fields so no stale owner data survives the handover.
183
+ * @param {string} root
184
+ * @param {Partial<HostStatus> & { viewId: string, instanceId: string }} provisionalHost
185
+ * @param {{ heldStartLease?: unknown, lockImpl?: typeof tryAcquireOwnedViewLock }} [opts]
186
+ * heldStartLease is reserved for host-start lease nesting; lockImpl injects
187
+ * the host-meta acquisition for deterministic contention/identity tests.
188
+ * @returns {{ claimed: boolean, host: HostStatus|null }}
189
+ */
190
+ export function claimHost(root, provisionalHost, opts = {}) {
191
+ const acquireHostMeta = opts.lockImpl ?? tryAcquireOwnedViewLock;
192
+ const lock = acquireHostMeta(root, provisionalHost.viewId, "host-meta", { identity: hostMetaIdentity() });
193
+ if (!lock.acquired) {
194
+ // busy is ordinary millisecond-scale contention; blocked (identity-less
195
+ // holder) is the orphan-lock signature worth a diagnostic (issue #112).
196
+ if (lock.reason === "blocked") {
197
+ reportHostMetaContention(root, provisionalHost.viewId, "host_meta_claim_contended", "host-meta lease blocked; host claim not established", { reason: lock.reason });
198
+ }
199
+ return { claimed: false, host: null };
200
+ }
201
+ hostMetaContentionReported.delete(provisionalHost.viewId);
202
+ try {
203
+ const existing = readHost(root, provisionalHost.viewId);
204
+ if (hostClaimActive(existing)) return { claimed: false, host: existing };
205
+ const now = Date.now();
206
+ /** @type {HostStatus} */
207
+ const record = {
208
+ version: 1,
209
+ viewId: provisionalHost.viewId,
210
+ mode: "pty",
211
+ instanceId: provisionalHost.instanceId,
212
+ configPath: provisionalHost.configPath ?? null,
213
+ socketPath: provisionalHost.socketPath ?? null,
214
+ claimAt: provisionalHost.claimAt ?? now,
215
+ claimPid: provisionalHost.claimPid ?? null,
216
+ claimIdentity: provisionalHost.claimIdentity ?? null,
217
+ // A fresh claim owns nothing yet: force-clear every field a superseded
218
+ // instance might have left behind.
219
+ runnerPid: null,
220
+ runnerIdentity: null,
221
+ runnerSpawnedAt: null,
222
+ childPid: null,
223
+ childIdentity: null,
224
+ childSpawnedAt: null,
225
+ readyAt: null,
226
+ stopRequestedAt: null,
227
+ revokeToken: null,
228
+ stopReason: null,
229
+ state: "starting",
230
+ startedAt: provisionalHost.claimAt ?? now,
231
+ lastSeenAt: now,
232
+ endedAt: null,
233
+ exitCode: null,
234
+ error: null,
235
+ cols: provisionalHost.cols ?? 120,
236
+ rows: provisionalHost.rows ?? 36,
237
+ attachedClients: 0,
238
+ };
239
+ writeHost(root, record);
240
+ // Mirror is best-effort; host.json is the authority for the new protocol.
241
+ try {
242
+ writeHostPid(root, provisionalHost.viewId, record.claimPid, {
243
+ instanceId: record.instanceId,
244
+ identity: record.claimIdentity,
245
+ });
246
+ } catch { /* best effort */ }
247
+ return { claimed: true, host: record };
248
+ } finally {
249
+ lock.lease.release();
250
+ }
251
+ }
252
+
253
+ /**
254
+ * Bounded contention retry for owner-fenced host writes (issue #70, PR #84 CI
255
+ * wave 2). Heartbeat/client-merge writes hold the host-meta lease for only a
256
+ * few milliseconds, but a one-shot acquire can land inside that window and
257
+ * return busy — a revoke or recovery write that silently no-ops is a real
258
+ * reliability bug, not just a test race. Both `busy` (live owner) and
259
+ * `blocked` (identity-less holder) are transient here: retry a few times with
260
+ * a short synchronous sleep before giving up, and record a warn diagnostic on
261
+ * a sustained episode (issue #112). Acquisitions stamp a full process identity
262
+ * (issue #112), so a holder that dies mid-hold leaves a reclaimable record;
263
+ * legacy identity-less residue is reclaimed past the orphan age gate.
264
+ */
265
+ const UPDATE_LOCK_BUSY_ATTEMPTS = 3;
266
+ const UPDATE_LOCK_BUSY_SLEEP_MS = 20;
267
+
268
+ /**
269
+ * Owner-fenced compare-and-write for host.json. Re-reads the view's record
270
+ * under the host-meta lease and only applies `mutate` while it still belongs
271
+ * to `expectedInstanceId`; a superseded instance's late heartbeat/crash/exit
272
+ * writes are rejected with `ownerChanged: true` and the current host (issue
273
+ * #70). Lease contention that survives the bounded retry returns not-updated
274
+ * with `ownerChanged: false` — callers treat that as retryable (heartbeats
275
+ * simply retry next tick; revokes/recovery re-attempt).
276
+ * @param {string} root
277
+ * @param {string} viewId
278
+ * @param {string} expectedInstanceId
279
+ * @param {(host: HostStatus) => HostStatus} mutate returns a new record
280
+ * @param {{ heldStartLease?: unknown, lockImpl?: typeof tryAcquireOwnedViewLock }} [opts]
281
+ * heldStartLease is reserved for host-start lease nesting; lockImpl injects
282
+ * the host-meta acquisition for deterministic contention tests.
283
+ * @returns {{ updated: boolean, ownerChanged: boolean, host: HostStatus|null }}
284
+ */
285
+ export function updateOwnedHost(root, viewId, expectedInstanceId, mutate, opts = {}) {
286
+ const acquireHostMeta = opts.lockImpl ?? tryAcquireOwnedViewLock;
287
+ let lock;
288
+ let lastReason = null;
289
+ for (let attempt = 0; ; attempt++) {
290
+ lock = acquireHostMeta(root, viewId, "host-meta", { identity: hostMetaIdentity() });
291
+ if (lock.acquired) break;
292
+ lastReason = lock.reason;
293
+ // busy and blocked are both millisecond-scale holds for host-meta;
294
+ // neither is ownership information — only the fenced read below is.
295
+ if (attempt >= UPDATE_LOCK_BUSY_ATTEMPTS - 1) {
296
+ reportHostMetaContention(root, viewId, "host_meta_lease_contended", "host-meta lease contended; fenced write not applied", { attempts: UPDATE_LOCK_BUSY_ATTEMPTS, lastReason });
297
+ return { updated: false, ownerChanged: false, host: null };
298
+ }
299
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, UPDATE_LOCK_BUSY_SLEEP_MS);
300
+ }
301
+ try {
302
+ hostMetaContentionReported.delete(viewId);
303
+ const host = readHost(root, viewId);
304
+ if (!host || !sameHostOwner(host, expectedInstanceId)) {
305
+ return { updated: false, ownerChanged: true, host };
306
+ }
307
+ const next = mutate(host);
308
+ // The mutate result must still belong to the expected instance: a buggy or
309
+ // hijacked callback that swaps the owner token is never written (issue #70).
310
+ if (!sameHostOwner(next, expectedInstanceId)) {
311
+ return { updated: false, ownerChanged: true, host };
312
+ }
313
+ writeHost(root, next);
314
+ try {
315
+ writeHostPid(root, viewId, next.runnerPid ?? null, {
316
+ instanceId: next.instanceId,
317
+ identity: next.runnerIdentity ?? null,
318
+ });
319
+ } catch { /* best effort */ }
320
+ return { updated: true, ownerChanged: false, host: next };
321
+ } finally {
322
+ lock.lease.release();
323
+ }
324
+ }
325
+
326
+ /** @param {string} root @param {string} viewId @returns {number|null} */
327
+ export function readHostPid(root, viewId) {
328
+ return readJson(P.hostPidPath(root, viewId), { pid: null }).pid ?? null;
329
+ }
330
+
331
+ /** @param {string} root @param {string} viewId @param {string} runId @param {number|null} pid */
332
+ export function writePid(root, viewId, runId, pid) {
333
+ atomicWriteJson(P.pidPath(root, viewId, runId), { pid, at: Date.now() });
334
+ }
335
+
336
+ /** @param {string} root @param {string} viewId @param {string} runId @returns {number|null} */
337
+ export function readPid(root, viewId, runId) {
338
+ return readJson(P.pidPath(root, viewId, runId), { pid: null }).pid ?? null;
339
+ }
340
+
341
+ // ---- listing / loading ----------------------------------------------------
342
+
343
+ /** @param {string} root @param {string} viewId @returns {string[]} run ids, newest first by mtime. */
344
+ export function listRunIds(root, viewId) {
345
+ const dir = P.runsDir(root, viewId);
346
+ if (!existsSync(dir)) return [];
347
+ try {
348
+ return readdirSync(dir)
349
+ .filter((name) => {
350
+ try {
351
+ return statSync(P.runDir(root, viewId, name)).isDirectory();
352
+ } catch {
353
+ return false;
354
+ }
355
+ })
356
+ .sort((a, b) => mtime(P.runDir(root, viewId, b)) - mtime(P.runDir(root, viewId, a)));
357
+ } catch {
358
+ return [];
359
+ }
360
+ }
361
+
362
+ /** @param {string} dir */
363
+ function mtime(dir) {
364
+ try {
365
+ return statSync(dir).mtimeMs;
366
+ } catch {
367
+ return 0;
368
+ }
369
+ }
370
+
371
+ /**
372
+ * A merged dashboard row: meta + derived state. `state` may be null for a never-run row.
373
+ * @typedef {Object} Row
374
+ * @property {ViewMeta} meta
375
+ * @property {ViewState|null} state
376
+ * @property {boolean} alive Whether the row's current run pid/foreground activity is alive.
377
+ * @property {boolean} hostAlive Whether a PTY host/socket is alive and attachable.
378
+ * @property {boolean} hostActive Whether an unreplaced host claim exists (starting/alive/stopping).
379
+ * @property {boolean} hostReady Whether the host claims readiness (alive + readyAt); metadata hint only.
380
+ * @property {HostStatus|null} host
381
+ * @property {import("./types.mjs").ReviewSummary} [review]
382
+ * @property {import("./types.mjs").DiagnosticSummary} [diagnostics]
383
+ * @property {import("./types.mjs").FollowUpSummary} [followUps]
384
+ * @property {import("./types.mjs").SteeringSummary} [steering]
385
+ * @property {import("./types.mjs").CodeRefsSummary} [codeRefs]
386
+ */
387
+
388
+ /** @param {string} root @param {string} viewId @returns {Row|null} */
389
+ export function loadRow(root, viewId) {
390
+ const meta = readMeta(root, viewId);
391
+ if (!meta) return null;
392
+ const state = readState(root, viewId);
393
+ const summaries = readViewArtifactSummaries(root, viewId);
394
+ const enrichedState = state ? { ...state, ...summaries } : state;
395
+ let alive = false;
396
+ // True when a detached runner pid record exists for the current run. The
397
+ // processState mirror below exists for the foreground path, which has no
398
+ // runner pid to poll — it must never override a RECORDED pid: an
399
+ // un-finalized dead runner is the row's truth, and trusting the mirror
400
+ // here makes reconcile() skip the row forever (issue #153, A10).
401
+ let runnerPidRecorded = false;
402
+ if (enrichedState?.currentRunId) {
403
+ const pid = readPid(root, viewId, enrichedState.currentRunId);
404
+ runnerPidRecorded = pid != null;
405
+ alive = isAlive(pid);
406
+ }
407
+ // A managed session can also be active in the foreground after the user attaches
408
+ // and types a follow-up. In that path there is no detached runner pid for us to
409
+ // poll, but foreground extension events mirror processState into state.json.
410
+ if (!alive && !runnerPidRecorded && enrichedState?.processState === "alive") alive = true;
411
+ const host = readHost(root, viewId);
412
+ // New-protocol records carry an explicit runnerPid (null until spawn
413
+ // confirms); only legacy records without the property fall back to the
414
+ // host-pid mirror (issue #70).
415
+ const hostPid = host && Object.hasOwn(host, "runnerPid") ? host.runnerPid : readHostPid(root, viewId);
416
+ const hostAlive = Boolean(host && (host.state === "alive" || host.state === "starting") && isAlive(hostPid));
417
+ const hostActive = Boolean(host && (host.state === "starting" || host.state === "alive" || host.state === "stopping"));
418
+ const hostReady = Boolean(hostActive && host.state === "alive" && host.readyAt != null);
419
+ return { meta, state: enrichedState, alive, hostAlive, hostActive, hostReady, host, ...summaries };
420
+ }
421
+
422
+ /** @param {string} root @param {string} viewId */
423
+ export function readViewArtifactSummaries(root, viewId) {
424
+ const review = summarizeEvidence(readEvidence(root, viewId));
425
+ const diagnostics = readDiagnosticSummary(root, viewId);
426
+ const followUps = summarizeFollowUpQueue(readFollowUpQueue(root, viewId));
427
+ const steering = summarizeSteering(readSteering(root, viewId));
428
+ const codeRefs = summarizeCodeRefs(readCodeRefs(root, viewId));
429
+ return { review, diagnostics, followUps, steering, codeRefs };
430
+ }
431
+
432
+ /**
433
+ * Load every non-archived row referenced by the roster.
434
+ * @param {string} root
435
+ * @param {{ includeArchived?: boolean }} [opts]
436
+ * @returns {Row[]}
437
+ */
438
+ export function listRows(root, opts = {}) {
439
+ const roster = readRoster(root);
440
+ /** @type {Row[]} */
441
+ const rows = [];
442
+ for (const viewId of roster.views) {
443
+ // Archived short-circuit: archived rows are invisible on the dashboard, so
444
+ // never pay for their artifact files (state/evidence/host/diagnostics...).
445
+ // meta.json is the single authoritative source of the archived flag.
446
+ const meta = readMeta(root, viewId);
447
+ if (!meta) continue;
448
+ if (meta.archived && !opts.includeArchived) continue;
449
+ const row = loadRow(root, viewId);
450
+ if (!row) continue;
451
+ if (row.meta.archived && !opts.includeArchived) continue;
452
+ rows.push(row);
453
+ }
454
+ return rows;
455
+ }
456
+
457
+ // ---- creation -------------------------------------------------------------
458
+
459
+ /**
460
+ * Create a new managed view (row) and persist meta + roster + an initial queued state.
461
+ * Does not launch anything — the caller launches a run afterward.
462
+ * @param {string} root
463
+ * @param {{
464
+ * id: string, name: string, cwd: string, repoCwd?: string, repoRoot?: string|null,
465
+ * worktreeMode?: import("./types.mjs").WorktreeMode, worktreePath?: string|null,
466
+ * defaultModel?: string|null,
467
+ * defaultThinking?: "off"|"minimal"|"low"|"medium"|"high"|"xhigh"|null,
468
+ * writeCapable?: boolean,
469
+ * sessionFile?: string,
470
+ * }} opts
471
+ * @returns {ViewMeta}
472
+ */
473
+ export function createView(root, opts) {
474
+ ensureDir(P.sessionsDir(root));
475
+ const now = Date.now();
476
+ /** @type {ViewMeta} */
477
+ const meta = {
478
+ version: META_VERSION,
479
+ id: opts.id,
480
+ name: opts.name,
481
+ cwd: opts.cwd,
482
+ repoCwd: opts.repoCwd ?? opts.cwd,
483
+ repoRoot: opts.repoRoot ?? null,
484
+ sessionFile: opts.sessionFile ?? P.sessionFilePath(root, opts.id),
485
+ createdAt: now,
486
+ updatedAt: now,
487
+ pinned: false,
488
+ kind: "pi-session",
489
+ defaultModel: opts.defaultModel ?? null,
490
+ defaultThinking: opts.defaultThinking ?? null,
491
+ worktreeMode: opts.worktreeMode ?? "off",
492
+ worktreePath: opts.worktreePath ?? null,
493
+ writeCapable: opts.writeCapable ?? true,
494
+ archived: false,
495
+ source: "agent-board",
496
+ };
497
+ ensureDir(P.viewDir(root, meta.id));
498
+ writeMeta(root, meta);
499
+ /** @type {ViewState} */
500
+ const state = {
501
+ version: 1,
502
+ viewId: meta.id,
503
+ currentRunId: null,
504
+ semanticState: "queued",
505
+ processState: "exited",
506
+ summary: "Queued",
507
+ lastActivityAt: now,
508
+ updatedAt: now,
509
+ needsInput: false,
510
+ hasError: false,
511
+ latestAssistantPreview: "",
512
+ latestTool: null,
513
+ question: null,
514
+ pendingQuestions: [],
515
+ error: null,
516
+ lastVisitedAt: null,
517
+ lastAgentActivityAt: null,
518
+ autoState: null,
519
+ };
520
+ writeState(root, state);
521
+ addToRoster(root, meta.id);
522
+ return meta;
523
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Ctrl+Z from the board or an attached session. Pi only handles it at its own prompt. Sent on to a
3
+ * session it would stop that session's screen for good: nothing there can be stopped or resumed,
4
+ * since a session has no shell. Stops only this Pi, as sessions run in their own process groups.
5
+ *
6
+ * The same steps as Pi's own, which an extension can't call. On Windows `app.suspend` has no key,
7
+ * so this isn't reached.
8
+ *
9
+ * @param {{ start(): void, stop(): void, requestRender(force?: boolean): void }} tui
10
+ * @param {{ beforeStop?: () => void, afterResume?: () => void }} [hooks]
11
+ */
12
+ export function suspendToShell(tui, hooks = {}) {
13
+ // Without a handle holding the event loop open, Node could exit on `fg` before SIGCONT arrives.
14
+ const keepAlive = setInterval(() => {}, 2 ** 30);
15
+ // Ctrl+C at the shell mustn't kill what's in the background.
16
+ const ignoreSigint = () => {};
17
+ process.on("SIGINT", ignoreSigint);
18
+ const finish = () => {
19
+ clearInterval(keepAlive);
20
+ process.removeListener("SIGINT", ignoreSigint);
21
+ };
22
+ process.once("SIGCONT", () => {
23
+ finish();
24
+ tui.start();
25
+ hooks.afterResume?.();
26
+ tui.requestRender(true);
27
+ });
28
+ try {
29
+ hooks.beforeStop?.();
30
+ tui.stop();
31
+ process.kill(0, "SIGTSTP");
32
+ } catch (error) {
33
+ finish();
34
+ throw error;
35
+ }
36
+ }