@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,282 @@
1
+ /**
2
+ * Durable command journal for the View State Coordinator (issue #91, spec D3).
3
+ *
4
+ * Write order contract: the coordinator appends a command record here and
5
+ * fsyncs BEFORE materializing state.json/status.json, so a crash between the
6
+ * two can be repaired by replaying the journal on restart. The checkpoint
7
+ * records how much of the journal (`journalBytes`) is already reflected in
8
+ * materialized state; GC may only drop that prefix after the checkpoint write
9
+ * itself succeeded. Full-coverage GC truncates the journal to empty: the
10
+ * covered commands leave the journal, so post-GC idempotency dedupe is owned
11
+ * by the coordinator's in-memory ring of recent commandIds (older commandIds
12
+ * fall back to current-state re-decision, whose apply paths are idempotent).
13
+ * `screen.log`'s fs-injection and temp+rename patterns are
14
+ * reused so every fs call is injectable in tests.
15
+ */
16
+ import {
17
+ closeSync,
18
+ existsSync,
19
+ fstatSync,
20
+ fsyncSync,
21
+ mkdirSync,
22
+ openSync,
23
+ readSync,
24
+ renameSync,
25
+ statSync,
26
+ unlinkSync,
27
+ writeSync,
28
+ } from "node:fs";
29
+ import { dirname, join } from "node:path";
30
+
31
+ export const JOURNAL_FILE_NAME = "state-journal.jsonl";
32
+ export const CHECKPOINT_FILE_NAME = "state-journal.checkpoint.json";
33
+
34
+ export const defaultJournalFs = Object.freeze({
35
+ closeSync,
36
+ existsSync,
37
+ fstatSync,
38
+ fsyncSync,
39
+ mkdirSync,
40
+ openSync,
41
+ readSync,
42
+ renameSync,
43
+ statSync,
44
+ unlinkSync,
45
+ writeSync,
46
+ });
47
+
48
+ /** @param {string} root @returns {string} */
49
+ export function journalPath(root) {
50
+ return join(root, JOURNAL_FILE_NAME);
51
+ }
52
+
53
+ /** @param {string} root @returns {string} */
54
+ export function checkpointPath(root) {
55
+ return join(root, CHECKPOINT_FILE_NAME);
56
+ }
57
+
58
+ /**
59
+ * Append one processed-command record and fsync it before returning.
60
+ * @param {string} root
61
+ * @param {{ command: object, result: { status: string, reason: string|null }, materializedRevision: number, at: number }} record
62
+ * @param {typeof defaultJournalFs} [fs]
63
+ * @returns {number} journal size in bytes after the append (usable as the next
64
+ * checkpoint's `journalBytes`).
65
+ */
66
+ export function appendCommand(root, record, fs = defaultJournalFs) {
67
+ const file = journalPath(root);
68
+ fs.mkdirSync(dirname(file), { recursive: true });
69
+ const payload = Buffer.from(`${JSON.stringify(record)}\n`, "utf8");
70
+ let fd;
71
+ try {
72
+ fd = fs.openSync(file, "a");
73
+ let offset = 0;
74
+ while (offset < payload.length) offset += fs.writeSync(fd, payload, offset, payload.length - offset);
75
+ fs.fsyncSync(fd);
76
+ } finally {
77
+ if (fd !== undefined) {
78
+ try { fs.closeSync(fd); } catch { /* already closed */ }
79
+ }
80
+ }
81
+ return fileSize(file, 0, fs);
82
+ }
83
+
84
+ /**
85
+ * Read every parseable journal record. A corrupt line (crash mid-append) is
86
+ * skipped — same semantics as atomic.mjs readJsonl, but with injectable fs.
87
+ * @param {string} root
88
+ * @param {typeof defaultJournalFs} [fs]
89
+ * @returns {Array<{ command: object, result: { status: string, reason: string|null }, materializedRevision: number, at: number }>}
90
+ */
91
+ export function readJournal(root, fs = defaultJournalFs) {
92
+ const data = readAllBytes(journalPath(root), fs);
93
+ const entries = [];
94
+ for (const line of data.toString("utf8").split("\n")) {
95
+ const trimmed = line.trim();
96
+ if (!trimmed) continue;
97
+ try {
98
+ entries.push(JSON.parse(trimmed));
99
+ } catch {
100
+ /* skip corrupt line */
101
+ }
102
+ }
103
+ return entries;
104
+ }
105
+
106
+ /**
107
+ * Idempotency lookup: the recorded result of an already-processed commandId,
108
+ * or null. On the (never-intended) duplicate, the FIRST entry is the original.
109
+ * @param {string} root
110
+ * @param {string} commandId
111
+ * @param {typeof defaultJournalFs} [fs]
112
+ * @returns {{ status: string, reason: string|null } | null}
113
+ */
114
+ export function findProcessedCommand(root, commandId, fs = defaultJournalFs) {
115
+ for (const entry of readJournal(root, fs)) {
116
+ if (entry?.command?.commandId === commandId) return entry.result ?? null;
117
+ }
118
+ return null;
119
+ }
120
+
121
+ /**
122
+ * @param {string} root
123
+ * @param {typeof defaultJournalFs} [fs]
124
+ * @returns {{ materializedRevision: number, journalBytes: number } | null}
125
+ */
126
+ export function readCheckpoint(root, fs = defaultJournalFs) {
127
+ const file = checkpointPath(root);
128
+ if (!fs.existsSync(file)) return null;
129
+ let raw;
130
+ try {
131
+ raw = readAllBytes(file, fs).toString("utf8");
132
+ } catch {
133
+ return null;
134
+ }
135
+ if (!raw.trim()) return null;
136
+ try {
137
+ return JSON.parse(raw);
138
+ } catch {
139
+ return null;
140
+ }
141
+ }
142
+
143
+ /**
144
+ * Atomically replace the checkpoint (temp file + fsync + rename, screen-log
145
+ * pattern). Callers may only run gcJournal after this returns true.
146
+ * @param {string} root
147
+ * @param {{ materializedRevision: number, journalBytes: number }} checkpoint
148
+ * @param {typeof defaultJournalFs} [fs]
149
+ * @returns {boolean} whether the checkpoint is durably on disk
150
+ */
151
+ export function writeCheckpoint(root, checkpoint, fs = defaultJournalFs) {
152
+ const file = checkpointPath(root);
153
+ fs.mkdirSync(dirname(file), { recursive: true });
154
+ return replaceFile(file, Buffer.from(`${JSON.stringify(checkpoint, null, 2)}\n`, "utf8"), fs);
155
+ }
156
+
157
+ /**
158
+ * Drop the journal prefix already covered by a successful checkpoint. Without
159
+ * a checkpoint this is a no-op (nothing proves the prefix is materialized).
160
+ * A checkpoint covering the whole journal truncates the file to empty — every
161
+ * covered record is materialized, and keeping it would grow the journal (and
162
+ * every findProcessedCommand/boot-replay scan) without bound.
163
+ * @param {string} root
164
+ * @param {typeof defaultJournalFs} [fs]
165
+ * @returns {number} journal size in bytes after the call
166
+ */
167
+ export function gcJournal(root, fs = defaultJournalFs) {
168
+ const file = journalPath(root);
169
+ const size = fileSize(file, 0, fs);
170
+ if (size === 0) return 0;
171
+ const checkpoint = readCheckpoint(root, fs);
172
+ if (!checkpoint || typeof checkpoint.journalBytes !== "number") return size;
173
+ const journalBytes = Math.floor(checkpoint.journalBytes);
174
+ if (journalBytes <= 0) return size;
175
+ if (journalBytes >= size) {
176
+ // Full coverage: everything in the file is materialized per the
177
+ // checkpoint. Truncate to empty; dedupe for covered commandIds is the
178
+ // coordinator's in-memory ring's job now.
179
+ if (!replaceFile(file, Buffer.alloc(0), fs)) return fileSize(file, size, fs);
180
+ return 0;
181
+ }
182
+ // journalBytes < size means the file exists with an un-checkpointed tail.
183
+ const tail = readAllBytes(file, fs, journalBytes);
184
+ if (!replaceFile(file, tail, fs)) return fileSize(file, size, fs);
185
+ return fileSize(file, tail.length, fs);
186
+ }
187
+
188
+ /**
189
+ * Truncate a crash-torn journal tail so the next append stays parseable.
190
+ *
191
+ * `appendCommand` writes `JSON+\n` in a partial-write loop; a process killed
192
+ * mid-append leaves bytes without a trailing newline. Without repair, the next
193
+ * append concatenates onto that torn line and the merged line is unparseable — the
194
+ * freshly fsynced+acked record becomes invisible to every readJournal /
195
+ * boot-replay / revision scan. The repair is byte-exact and cheap: scan
196
+ * backwards for the last newline and drop everything after it (a journal with
197
+ * no newline at all is torn from byte 0 and truncates to empty). Complete but
198
+ * corrupt lines are kept — readJournal already skips them.
199
+ * @param {string} root
200
+ * @param {typeof defaultJournalFs} [fs]
201
+ * @returns {number} journal size in bytes after the call
202
+ */
203
+ export function repairJournalTail(root, fs = defaultJournalFs) {
204
+ const file = journalPath(root);
205
+ const size = fileSize(file, 0, fs);
206
+ if (size === 0) return 0;
207
+ const data = readAllBytes(file, fs);
208
+ const lastNewline = data.lastIndexOf("\n");
209
+ if (lastNewline === data.length - 1) return size;
210
+ const keep = lastNewline + 1;
211
+ if (!replaceFile(file, data.subarray(0, keep), fs)) return fileSize(file, size, fs);
212
+ return keep;
213
+ }
214
+
215
+ /**
216
+ * Read the file from `position` (default 0) to EOF via injectable readSync.
217
+ * @param {string} file
218
+ * @param {typeof defaultJournalFs} fs
219
+ * @param {number} [position]
220
+ * @returns {Buffer}
221
+ */
222
+ function readAllBytes(file, fs, position = 0) {
223
+ if (!fs.existsSync(file)) return Buffer.alloc(0);
224
+ let fd;
225
+ try {
226
+ fd = fs.openSync(file, "r");
227
+ const size = Math.max(0, fs.fstatSync(fd).size - position);
228
+ if (size === 0) return Buffer.alloc(0);
229
+ const output = Buffer.allocUnsafe(size);
230
+ let offset = 0;
231
+ while (offset < size) {
232
+ const read = fs.readSync(fd, output, offset, size - offset, position + offset);
233
+ if (read === 0) break;
234
+ offset += read;
235
+ }
236
+ return offset === size ? output : output.subarray(0, offset);
237
+ } finally {
238
+ if (fd !== undefined) {
239
+ try { fs.closeSync(fd); } catch { /* already closed */ }
240
+ }
241
+ }
242
+ }
243
+
244
+ /**
245
+ * Temp file + fsync + rename replacement (screen-log replaceScreenLog pattern).
246
+ * @param {string} file
247
+ * @param {Buffer} data
248
+ * @param {typeof defaultJournalFs} fs
249
+ * @returns {boolean}
250
+ */
251
+ function replaceFile(file, data, fs) {
252
+ const temp = `${file}.${process.pid}.${tempSequence++}.tmp`;
253
+ let fd;
254
+ try {
255
+ fs.mkdirSync(dirname(file), { recursive: true });
256
+ fd = fs.openSync(temp, "w");
257
+ let offset = 0;
258
+ while (offset < data.length) offset += fs.writeSync(fd, data, offset, data.length - offset, offset);
259
+ fs.fsyncSync(fd);
260
+ fs.closeSync(fd);
261
+ fd = undefined;
262
+ fs.renameSync(temp, file);
263
+ return true;
264
+ } catch {
265
+ if (fd !== undefined) {
266
+ try { fs.closeSync(fd); } catch { /* already closed */ }
267
+ }
268
+ try { fs.unlinkSync(temp); } catch { /* best effort */ }
269
+ return false;
270
+ }
271
+ }
272
+
273
+ /** @param {string} file @param {number} fallback @param {typeof defaultJournalFs} fs */
274
+ function fileSize(file, fallback, fs) {
275
+ try {
276
+ return fs.statSync(file).size;
277
+ } catch {
278
+ return fallback;
279
+ }
280
+ }
281
+
282
+ let tempSequence = 0;
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Protocol version of the View State Coordinator control socket (issue #108).
3
+ *
4
+ * Bumped whenever a coordinator release adds command kinds or changes the
5
+ * envelope contract. A client built against version N refuses to talk to a
6
+ * coordinator reporting < N — a pong WITHOUT the field counts as version 1
7
+ * (the pre-#107 baseline) — and replaces the stale instance with a fresh one
8
+ * (SIGTERM via the coordinator lease pid, then respawn) instead of feeding
9
+ * it commands it cannot understand (`sync_foreground rejected (unknown_kind)`
10
+ * used to strand every foreground state write on extension updates).
11
+ *
12
+ * Version 3 adds mark_holding / clear_holding (issue #145).
13
+ */
14
+ export const COORDINATOR_PROTOCOL_VERSION = 3;
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Persistent cwd usage stats for the launch dialog's directory favorites.
3
+ *
4
+ * Counts every successfully dispatched session per cwd, independent of the
5
+ * view lifecycle (deleting a view does not decrement). The dashboard reads
6
+ * the ranked list for the cwd picker's favorites mode. Pure node, no Pi imports.
7
+ */
8
+ import { existsSync } from "node:fs";
9
+ import * as os from "node:os";
10
+ import { atomicWriteJson, readJson } from "./atomic.mjs";
11
+ import * as P from "./paths.mjs";
12
+ import { readMeta, readRoster } from "./store.mjs";
13
+
14
+ /**
15
+ * @typedef {Object} CwdStatsEntry
16
+ * @property {number} count
17
+ * @property {number} lastUsed epoch ms, like meta.updatedAt
18
+ */
19
+
20
+ /** @returns {{version: number, entries: Record<string, CwdStatsEntry>}} */
21
+ function emptyStats() {
22
+ return { version: 1, entries: {} };
23
+ }
24
+
25
+ /** @param {string} root @returns {{version: number, entries: Record<string, CwdStatsEntry>}} */
26
+ export function readCwdStats(root) {
27
+ const raw = readJson(P.cwdStatsPath(root), null);
28
+ if (!raw || typeof raw !== "object" || typeof raw.entries !== "object" || raw.entries === null) return emptyStats();
29
+ /** @type {Record<string, CwdStatsEntry>} */
30
+ const entries = {};
31
+ for (const [dir, entry] of Object.entries(raw.entries)) {
32
+ if (!entry || typeof entry.count !== "number") continue;
33
+ entries[dir] = {
34
+ count: Math.max(0, Math.floor(entry.count)),
35
+ lastUsed: typeof entry.lastUsed === "number" ? entry.lastUsed : 0,
36
+ };
37
+ }
38
+ return { version: 1, entries };
39
+ }
40
+
41
+ /**
42
+ * One-time seed: aggregate cwd counts from every roster view's meta.json.
43
+ * No-op when cwd-stats.json already exists.
44
+ * @param {string} root
45
+ */
46
+ export function seedCwdStatsFromViews(root) {
47
+ if (existsSync(P.cwdStatsPath(root))) return;
48
+ /** @type {Record<string, CwdStatsEntry>} */
49
+ const entries = {};
50
+ for (const viewId of readRoster(root).views ?? []) {
51
+ const meta = readMeta(root, viewId);
52
+ const cwd = meta?.cwd;
53
+ if (!cwd) continue;
54
+ const lastUsed = typeof meta.updatedAt === "number" ? meta.updatedAt : Date.now();
55
+ const existing = entries[cwd];
56
+ if (existing) {
57
+ existing.count += 1;
58
+ existing.lastUsed = Math.max(existing.lastUsed, lastUsed);
59
+ } else {
60
+ entries[cwd] = { count: 1, lastUsed };
61
+ }
62
+ }
63
+ atomicWriteJson(P.cwdStatsPath(root), { version: 1, entries });
64
+ }
65
+
66
+ /**
67
+ * Seed when the stats file is missing; tolerate every failure (dashboard UX
68
+ * must never break because of stats bookkeeping).
69
+ * @param {string} root
70
+ */
71
+ export function ensureCwdStatsSeeded(root) {
72
+ if (existsSync(P.cwdStatsPath(root))) return;
73
+ try {
74
+ seedCwdStatsFromViews(root);
75
+ } catch {
76
+ /* best effort */
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Record one successful dispatch for `cwd`. Invalid dirs are ignored.
82
+ * @param {string} root @param {string} cwd
83
+ */
84
+ export function recordCwdLaunch(root, cwd) {
85
+ if (!cwd || !existsSync(cwd)) return;
86
+ const stats = readCwdStats(root);
87
+ const existing = stats.entries[cwd] ?? { count: 0, lastUsed: 0 };
88
+ stats.entries[cwd] = { count: existing.count + 1, lastUsed: Date.now() };
89
+ atomicWriteJson(P.cwdStatsPath(root), stats);
90
+ }
91
+
92
+ /**
93
+ * Ranked candidates: count desc, then lastUsed desc; home appended at the
94
+ * end when absent so the user's home dir is always one keystroke away.
95
+ * `limit` is an optional cap for callers that explicitly want one; the
96
+ * default returns the whole history so the launch picker can search it.
97
+ * @param {string} root @param {number=} limit
98
+ * @returns {Array<{path: string, count: number}>}
99
+ */
100
+ export function rankedCwdCandidates(root, limit = Number.POSITIVE_INFINITY) {
101
+ const stats = readCwdStats(root);
102
+ const rows = Object.entries(stats.entries).map(([dir, entry]) => ({
103
+ path: dir,
104
+ count: entry.count,
105
+ lastUsed: entry.lastUsed,
106
+ }));
107
+ rows.sort((a, b) => b.count - a.count || b.lastUsed - a.lastUsed);
108
+ const out = rows.slice(0, Math.max(1, limit)).map(({ path, count }) => ({ path, count }));
109
+ const home = os.homedir();
110
+ if (out.some((entry) => entry.path === home)) return out;
111
+ const homeRow = rows.find((entry) => entry.path === home);
112
+ out.push(homeRow ? { path: home, count: homeRow.count } : { path: home, count: 0 });
113
+ return out;
114
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Request a differential dashboard repaint.
3
+ *
4
+ * Full-screen custom overlays still participate in Pi TUI's line diffing. Passing
5
+ * `true` here discards that state and clears the terminal before every repaint,
6
+ * which is visible as flicker on terminals without synchronized-output support.
7
+ */
8
+ export function requestDashboardRender(tui) {
9
+ tui.requestRender();
10
+ }
@@ -0,0 +1,117 @@
1
+ /**
2
+ * State-machine + summary derivation. Pure functions over a RunStatus.
3
+ *
4
+ * Semantic-state rules (plan §9.1):
5
+ * while alive: queued → working (once assistant/tool activity begins)
6
+ * clean exit: needs_input (asked a question) | idle (user can mark completed)
7
+ * bad exit: failed
8
+ * user stop: stopped
9
+ * Summary priority (plan §9.3): model → active tool → blocker → first sentence → error → fallback.
10
+ */
11
+ import { firstSentence, truncate } from "./heuristics.mjs";
12
+
13
+ /** @typedef {import("./types.mjs").RunStatus} RunStatus */
14
+ /** @typedef {import("./types.mjs").SemanticState} SemanticState */
15
+
16
+ const GENERIC_STATUS_TEXT = {
17
+ queued: new Set(["Queued"]),
18
+ working: new Set(["Working", "Working…", "Running", "Running…"]),
19
+ needs_input: new Set(["Needs input", "Needs answer"]),
20
+ idle: new Set(["Idle", "In Progress", "Needs instructions"]),
21
+ holding: new Set(["On hold"]),
22
+ completed: new Set(["Completed", "Done"]),
23
+ failed: new Set(["Failed"]),
24
+ stopped: new Set(["Stopped"]),
25
+ };
26
+
27
+ const ALL_GENERIC_STATUS_TEXT = new Set(Object.values(GENERIC_STATUS_TEXT).flatMap((labels) => [...labels]));
28
+
29
+ /**
30
+ * Compute the terminal semantic state for a finished run.
31
+ * @param {{ exitCode:number|null, stopReason:string|null, stoppedByUser:boolean, needsInput:boolean }} p
32
+ * @returns {SemanticState}
33
+ */
34
+ export function finalizeSemanticState({ exitCode, stopReason, stoppedByUser, needsInput }) {
35
+ if (stoppedByUser) return "stopped";
36
+ const errored = (exitCode != null && exitCode !== 0) || stopReason === "error" || stopReason === "aborted";
37
+ if (errored) return "failed";
38
+ if (needsInput) return "needs_input";
39
+ return "idle";
40
+ }
41
+
42
+ /**
43
+ * Fallback status text when no richer summary is available.
44
+ * @param {SemanticState} state
45
+ * @returns {string}
46
+ */
47
+ export function fallbackStatusText(state) {
48
+ switch (state) {
49
+ case "queued":
50
+ return "Queued";
51
+ case "working":
52
+ return "Running…";
53
+ case "needs_input":
54
+ return "Needs answer";
55
+ case "idle":
56
+ return "Needs instructions";
57
+ case "holding":
58
+ return "On hold";
59
+ case "completed":
60
+ return "Done";
61
+ case "failed":
62
+ return "Failed";
63
+ case "stopped":
64
+ return "Stopped";
65
+ default:
66
+ return "Unknown";
67
+ }
68
+ }
69
+
70
+ /** @param {string|null|undefined} text */
71
+ export function isGenericStatusText(text) {
72
+ const trimmed = String(text || "").trim();
73
+ return !trimmed || ALL_GENERIC_STATUS_TEXT.has(trimmed);
74
+ }
75
+
76
+ /**
77
+ * Normalize generic fallback summaries to the current display label for a state.
78
+ * @param {SemanticState} state
79
+ * @param {string|null|undefined} text
80
+ */
81
+ export function normalizeGenericStatusText(state, text) {
82
+ const trimmed = String(text || "").trim();
83
+ if (!trimmed || GENERIC_STATUS_TEXT[state]?.has(trimmed)) return fallbackStatusText(state);
84
+ return trimmed;
85
+ }
86
+
87
+ /**
88
+ * Heuristic summary for a run (no model). Honors the documented priority order.
89
+ * @param {RunStatus} status
90
+ * @param {number} [max] max characters
91
+ * @returns {string}
92
+ */
93
+ export function deriveSummary(status, max = 80) {
94
+ const s = status;
95
+ // 2. active tool (only while still working)
96
+ if (s.processState === "alive" && s.currentTool?.name) {
97
+ // currentTool.summary is precomputed by the reducer; fall back to name.
98
+ const ct = /** @type {any} */ (s.currentTool);
99
+ if (typeof ct.summary === "string" && ct.summary) return truncate(ct.summary, max);
100
+ return truncate(ct.name, max);
101
+ }
102
+ // 3. explicit blocker / question
103
+ if (s.semanticState === "needs_input" && s.question) return truncate(s.question, max);
104
+ // 5. error (placed above generic preview for failed runs)
105
+ if (s.semanticState === "failed" && s.error) return truncate(s.error, max);
106
+ // 4. first sentence of latest assistant output (fall back to full text if the first
107
+ // sentence is uninformatively short, e.g. "Done.")
108
+ if (s.latestAssistantPreview) {
109
+ const fs = firstSentence(s.latestAssistantPreview);
110
+ const pick = fs.length >= 12 ? fs : s.latestAssistantPreview;
111
+ return truncate(pick, max);
112
+ }
113
+ if (s.question) return truncate(s.question, max);
114
+ if (s.error) return truncate(s.error, max);
115
+ // 6. fallback status text
116
+ return fallbackStatusText(s.semanticState);
117
+ }
@@ -0,0 +1,133 @@
1
+ /** Diagnostics JSONL helpers for Agent Board rows. */
2
+ import { atomicWrite, appendJsonl, readJsonl, readJsonlTail } from "./atomic.mjs";
3
+ import * as P from "./paths.mjs";
4
+
5
+ const SECRET_KEY_RE = /token|secret|password|authorization|api[_-]?key/i;
6
+
7
+ /**
8
+ * @param {string} root
9
+ * @param {string} viewId
10
+ * @param {Partial<import("./types.mjs").DiagnosticEvent>} patch
11
+ * @returns {import("./types.mjs").DiagnosticEvent}
12
+ */
13
+ export function appendDiagnostic(root, viewId, patch = {}) {
14
+ const event = normalizeDiagnostic(viewId, patch);
15
+ appendJsonl(P.diagnosticsPath(root, viewId), event);
16
+ return event;
17
+ }
18
+
19
+ /**
20
+ * Append a diagnostic unless a recent event already carries the same code and the
21
+ * same `opts.match` detail values (issue #217 D4): repeated attach clicks on one
22
+ * unverifiable host must leave one trail, not a flood.
23
+ * @param {string} root
24
+ * @param {string} viewId
25
+ * @param {Partial<import("./types.mjs").DiagnosticEvent>} patch
26
+ * @param {{ match?: string[], limit?: number }} [opts] `match: []` never dedupes.
27
+ * @returns {import("./types.mjs").DiagnosticEvent|null} the appended event, or null when skipped
28
+ */
29
+ export function appendDiagnosticOnce(root, viewId, patch = {}, opts = {}) {
30
+ const event = normalizeDiagnostic(viewId, patch);
31
+ const match = Array.isArray(opts.match) ? opts.match : [];
32
+ if (match.length > 0) {
33
+ const limit = opts.limit ?? 20;
34
+ const duplicate = readJsonlTail(P.diagnosticsPath(root, viewId), limit)
35
+ .map((e) => normalizeDiagnostic(viewId, e))
36
+ .some((e) => e.code === event.code && match.every((key) => e.details?.[key] === event.details?.[key]));
37
+ if (duplicate) return null;
38
+ }
39
+ appendJsonl(P.diagnosticsPath(root, viewId), event);
40
+ return event;
41
+ }
42
+
43
+ /** @param {string} root @param {string} viewId @returns {import("./types.mjs").DiagnosticEvent[]} */
44
+ export function readDiagnostics(root, viewId) {
45
+ return readJsonl(P.diagnosticsPath(root, viewId)).map((e) => normalizeDiagnostic(viewId, e));
46
+ }
47
+
48
+ /**
49
+ * @param {string} root
50
+ * @param {string} viewId
51
+ * @param {{ limit?: number }} [opts]
52
+ * @returns {import("./types.mjs").DiagnosticEvent[]}
53
+ */
54
+ export function tailDiagnostics(root, viewId, opts = {}) {
55
+ return readJsonlTail(P.diagnosticsPath(root, viewId), opts.limit ?? 50).map((e) => normalizeDiagnostic(viewId, e));
56
+ }
57
+
58
+ /** @param {string} root @param {string} viewId */
59
+ export function clearDiagnostics(root, viewId) {
60
+ atomicWrite(P.diagnosticsPath(root, viewId), "");
61
+ return { ok: true };
62
+ }
63
+
64
+ /** @returns {import("./types.mjs").DiagnosticSummary} */
65
+ export function emptyDiagnosticSummary() {
66
+ return {
67
+ count: 0,
68
+ warningCount: 0,
69
+ errorCount: 0,
70
+ lastAt: null,
71
+ lastLevel: null,
72
+ lastCode: null,
73
+ lastMessage: null,
74
+ stalled: false,
75
+ stallReason: null,
76
+ };
77
+ }
78
+
79
+ /** @param {import("./types.mjs").DiagnosticEvent[]} events @returns {import("./types.mjs").DiagnosticSummary} */
80
+ export function summarizeDiagnostics(events) {
81
+ const summary = emptyDiagnosticSummary();
82
+ for (const event of events ?? []) {
83
+ summary.count += 1;
84
+ if (event.level === "warn") summary.warningCount += 1;
85
+ if (event.level === "error") summary.errorCount += 1;
86
+ summary.lastAt = event.at ?? summary.lastAt;
87
+ summary.lastLevel = event.level ?? summary.lastLevel;
88
+ summary.lastCode = event.code ?? summary.lastCode;
89
+ summary.lastMessage = event.message ?? summary.lastMessage;
90
+ if (event.code === "provider_stall" || event.code === "stalled") {
91
+ summary.stalled = true;
92
+ summary.stallReason = String(event.details?.reason ?? event.message ?? "stalled");
93
+ }
94
+ if (event.code === "provider_stall_resolved" || event.code === "stall_resolved") {
95
+ summary.stalled = false;
96
+ summary.stallReason = null;
97
+ }
98
+ }
99
+ return summary;
100
+ }
101
+
102
+ /** @param {string} root @param {string} viewId */
103
+ export function readDiagnosticSummary(root, viewId) {
104
+ return summarizeDiagnostics(readDiagnostics(root, viewId));
105
+ }
106
+
107
+ /** @param {string} viewId @param {any} patch @returns {import("./types.mjs").DiagnosticEvent} */
108
+ export function normalizeDiagnostic(viewId, patch = {}) {
109
+ const level = ["info", "warn", "error"].includes(patch.level) ? patch.level : "info";
110
+ const code = typeof patch.code === "string" && patch.code ? patch.code : "event";
111
+ return {
112
+ version: 1,
113
+ at: Number.isFinite(patch.at) ? patch.at : Date.now(),
114
+ viewId,
115
+ runId: typeof patch.runId === "string" ? patch.runId : null,
116
+ source: typeof patch.source === "string" && patch.source ? patch.source : "service",
117
+ level,
118
+ code,
119
+ message: typeof patch.message === "string" && patch.message ? patch.message : code,
120
+ details: redactDiagnosticDetails(patch.details ?? {}),
121
+ };
122
+ }
123
+
124
+ /** @param {any} value @returns {any} */
125
+ export function redactDiagnosticDetails(value) {
126
+ if (Array.isArray(value)) return value.map((v) => redactDiagnosticDetails(v));
127
+ if (!value || typeof value !== "object") return value;
128
+ const out = {};
129
+ for (const [key, val] of Object.entries(value)) {
130
+ out[key] = SECRET_KEY_RE.test(key) ? "[redacted]" : redactDiagnosticDetails(val);
131
+ }
132
+ return out;
133
+ }
@@ -0,0 +1,26 @@
1
+ /** How close two presses must be to count as one double press. */
2
+ export const DOUBLE_PRESS_MS = 400;
3
+
4
+ /** Longer than Pi's own 500ms, or a session could take the second Ctrl+C and quit by itself. */
5
+ export const QUIT_PRESS_MS = 1000;
6
+
7
+ /**
8
+ * Tells a second press apart from a first. Anything else in between starts over.
9
+ * @param {() => number} [now]
10
+ * @param {number} [windowMs]
11
+ * @returns {{ press(): boolean, reset(): void }} press() is true on the second press.
12
+ */
13
+ export function createDoublePress(now = Date.now, windowMs = DOUBLE_PRESS_MS) {
14
+ let last = null;
15
+ return {
16
+ press() {
17
+ const at = now();
18
+ const second = last !== null && at - last <= windowMs;
19
+ last = second ? null : at;
20
+ return second;
21
+ },
22
+ reset() {
23
+ last = null;
24
+ },
25
+ };
26
+ }