@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,313 @@
1
+ /**
2
+ * Pure text heuristics: extracting assistant text, detecting questions/blockers,
3
+ * summarizing tool calls, and formatting relative times. No I/O, fully unit-tested.
4
+ */
5
+
6
+ /**
7
+ * Join the text content blocks of an assistant message into a single string.
8
+ * Accepts the message object as emitted in JSON mode (`message.content` is an array
9
+ * of `{type,...}` blocks, or, defensively, a plain string).
10
+ * @param {any} message
11
+ * @returns {string}
12
+ */
13
+ export function assistantText(message) {
14
+ if (!message) return "";
15
+ const content = message.content;
16
+ if (typeof content === "string") return content;
17
+ if (!Array.isArray(content)) return "";
18
+ return content
19
+ .filter((b) => b && b.type === "text" && typeof b.text === "string")
20
+ .map((b) => b.text)
21
+ .join("\n")
22
+ .trim();
23
+ }
24
+
25
+ /**
26
+ * Extract text from a pi AgentToolResult object
27
+ * ({ content: [{ type: "text", text }, ...], details, ... }), or, defensively,
28
+ * a plain string. Unknown-shaped objects yield "" — never "[object Object]"
29
+ * (issue #41; mirrors pi's own convertToolResultOutput join("\n") semantics).
30
+ * @param {any} result
31
+ * @returns {string}
32
+ */
33
+ export function toolResultText(result) {
34
+ if (result == null) return "";
35
+ if (typeof result === "string") return result;
36
+ if (typeof result !== "object") return String(result);
37
+ const content = result.content;
38
+ if (!Array.isArray(content)) return "";
39
+ return content
40
+ .filter((b) => b && b.type === "text" && typeof b.text === "string")
41
+ .map((b) => b.text)
42
+ .join("\n")
43
+ .trim();
44
+ }
45
+
46
+ /**
47
+ * Collect tool-call blocks from an assistant message.
48
+ * @param {any} message
49
+ * @returns {Array<{name:string, arguments:Record<string,any>}>}
50
+ */
51
+ export function toolCalls(message) {
52
+ const content = message?.content;
53
+ if (!Array.isArray(content)) return [];
54
+ return content
55
+ .filter((b) => b && b.type === "toolCall")
56
+ .map((b) => ({ name: b.name, arguments: b.arguments ?? {} }));
57
+ }
58
+
59
+ const QUESTION_PHRASES = [
60
+ "need your input",
61
+ "need some input",
62
+ "which option",
63
+ "should i",
64
+ "shall i",
65
+ "please confirm",
66
+ "could you confirm",
67
+ "can you confirm",
68
+ "let me know",
69
+ "do you want",
70
+ "would you like",
71
+ "how would you like",
72
+ "what would you like",
73
+ "which one",
74
+ "please clarify",
75
+ "can you clarify",
76
+ "waiting for your",
77
+ ];
78
+
79
+ /**
80
+ * Decide whether the latest assistant text is asking the user a question / is blocked,
81
+ * and extract the question sentence if so.
82
+ * @param {string} text
83
+ * @returns {{ needsInput: boolean, question: string|null }}
84
+ */
85
+ export function detectNeedsInput(text) {
86
+ const trimmed = (text || "").trim();
87
+ if (!trimmed) return { needsInput: false, question: null };
88
+
89
+ const lower = trimmed.toLowerCase();
90
+ const sentences = splitSentences(trimmed);
91
+ const last = sentences[sentences.length - 1] ?? trimmed;
92
+
93
+ // Strongest signal: the message ends on a question mark.
94
+ if (/\?\s*$/.test(trimmed)) {
95
+ const q = [...sentences].reverse().find((s) => s.includes("?")) ?? last;
96
+ return { needsInput: true, question: q.trim() };
97
+ }
98
+
99
+ for (const phrase of QUESTION_PHRASES) {
100
+ if (lower.includes(phrase)) {
101
+ const hit = sentences.find((s) => s.toLowerCase().includes(phrase)) ?? last;
102
+ return { needsInput: true, question: hit.trim() };
103
+ }
104
+ }
105
+
106
+ return { needsInput: false, question: null };
107
+ }
108
+
109
+ /**
110
+ * Split text into sentences (rough; good enough for previews/blocker extraction).
111
+ * @param {string} text
112
+ * @returns {string[]}
113
+ */
114
+ export function splitSentences(text) {
115
+ return (text || "")
116
+ .replace(/\s+/g, " ")
117
+ .trim()
118
+ .split(/(?<=[.!?])\s+/)
119
+ .map((s) => s.trim())
120
+ .filter(Boolean);
121
+ }
122
+
123
+ /**
124
+ * First sentence (or first line) of an assistant message, for compact previews.
125
+ * @param {string} text
126
+ * @returns {string}
127
+ */
128
+ export function firstSentence(text) {
129
+ const sentences = splitSentences(text);
130
+ return sentences[0] ?? "";
131
+ }
132
+
133
+ const PATH_KEYS = ["file_path", "path", "filePath", "file", "filename"];
134
+
135
+ /**
136
+ * Best-effort target path from a tool call's arguments.
137
+ * @param {Record<string,any>} args
138
+ * @returns {string|null}
139
+ */
140
+ export function toolPath(args) {
141
+ if (!args || typeof args !== "object") return null;
142
+ for (const k of PATH_KEYS) {
143
+ if (typeof args[k] === "string" && args[k]) return args[k];
144
+ }
145
+ if (typeof args.pattern === "string" && args.pattern) return args.pattern;
146
+ return null;
147
+ }
148
+
149
+ /**
150
+ * Human one-liner for an in-progress tool call, e.g. "Editing src/auth.ts".
151
+ * @param {string} name
152
+ * @param {Record<string,any>} args
153
+ * @returns {string}
154
+ */
155
+ export function toolSummary(name, args) {
156
+ const p = toolPath(args);
157
+ const base = p ? baseName(p) : null;
158
+ switch (name) {
159
+ case "edit":
160
+ return base ? `Editing ${base}` : "Editing files";
161
+ case "write":
162
+ return base ? `Writing ${base}` : "Writing files";
163
+ case "read":
164
+ return base ? `Reading ${base}` : "Reading files";
165
+ case "ls":
166
+ return base ? `Listing ${base}` : "Listing files";
167
+ case "find":
168
+ return p ? `Finding ${p}` : "Finding files";
169
+ case "grep":
170
+ return p ? `Searching /${p}/` : "Searching";
171
+ case "bash": {
172
+ const cmd = typeof args?.command === "string" ? args.command.trim() : "";
173
+ if (!cmd) return "Running command";
174
+ const kind = classifyCommand(cmd);
175
+ if (kind === "test") return "Running tests";
176
+ if (kind === "build") return "Building";
177
+ if (kind === "lint") return "Linting";
178
+ if (kind === "git") return `git ${cmd.replace(/^git\s+/, "").split(/\s+/)[0] ?? ""}`.trim();
179
+ return `Running ${truncate(cmd, 32)}`;
180
+ }
181
+ default:
182
+ return base ? `${capitalize(name)} ${base}` : capitalize(name);
183
+ }
184
+ }
185
+
186
+ /**
187
+ * Classify a shell command for review evidence.
188
+ * @param {string} command
189
+ * @returns {"test"|"build"|"lint"|"git"|"install"|"other"}
190
+ */
191
+ export function classifyCommand(command) {
192
+ const cmd = String(command || "").trim();
193
+ if (!cmd) return "other";
194
+ if (/\b(test|vitest|jest|pytest|go test|cargo test|npm test|pnpm test|yarn test)\b/.test(cmd)) return "test";
195
+ if (/\b(build|tsc|webpack|vite build|make|cargo build|go build)\b/.test(cmd)) return "build";
196
+ if (/\b(lint|eslint|biome|ruff|clippy)\b/.test(cmd)) return "lint";
197
+ if (/^(git|gh)\b/.test(cmd)) return "git";
198
+ if (/\b(npm install|npm ci|pnpm install|yarn install|bun install)\b/.test(cmd)) return "install";
199
+ return "other";
200
+ }
201
+
202
+ /** @param {string} command @param {number} [n] */
203
+ export function commandPreview(command, n = 120) {
204
+ return truncate(String(command || "").replace(/\s+/g, " ").trim(), n);
205
+ }
206
+
207
+ /**
208
+ * Detect file mutation operations from tool name/args.
209
+ * @param {string} name
210
+ * @param {Record<string,any>} args
211
+ * @returns {{ path:string, action:"edited"|"written"|"deleted"|"unknown" }|null}
212
+ */
213
+ export function toolFileOperation(name, args) {
214
+ const p = toolPath(args);
215
+ if (!p) return null;
216
+ if (["edit", "multi_edit", "apply_patch"].includes(name)) return { path: p, action: "edited" };
217
+ if (["write", "create_file"].includes(name)) return { path: p, action: "written" };
218
+ if (["delete", "rm", "remove_file"].includes(name)) return { path: p, action: "deleted" };
219
+ return null;
220
+ }
221
+
222
+ /** @param {string} p */
223
+ export function baseName(p) {
224
+ const parts = String(p).split(/[\\/]/).filter(Boolean);
225
+ return parts[parts.length - 1] ?? p;
226
+ }
227
+
228
+ /** @param {string} s */
229
+ function capitalize(s) {
230
+ return s ? s.charAt(0).toUpperCase() + s.slice(1) : s;
231
+ }
232
+
233
+ const LONE_HIGH_SURROGATE_RE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])/g;
234
+ const LONE_LOW_SURROGATE_RE = /(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g;
235
+
236
+ /** @param {string} s */
237
+ function stripLoneSurrogates(s) {
238
+ if (!s) return s;
239
+ return s.replace(LONE_HIGH_SURROGATE_RE, "").replace(LONE_LOW_SURROGATE_RE, "");
240
+ }
241
+
242
+ /**
243
+ * Truncate to `n` chars with an ellipsis (counts characters, not display width).
244
+ * The budget `n` is in UTF-16 units. The cut never splits a surrogate pair
245
+ * (it backs off one unit when it would), and lone surrogates in the input are
246
+ * stripped from the result — a lone surrogate renders as U+FFFD, whose
247
+ * terminal width can disagree with the computed width and misalign rows.
248
+ * @param {string} s
249
+ * @param {number} n
250
+ * @returns {string}
251
+ */
252
+ export function truncate(s, n) {
253
+ const str = String(s ?? "");
254
+ if (str.length <= n) return stripLoneSurrogates(str);
255
+ let end = Math.max(0, n - 1);
256
+ const lastUnit = str.charCodeAt(end - 1);
257
+ const nextUnit = str.charCodeAt(end);
258
+ if (lastUnit >= 0xd800 && lastUnit <= 0xdbff && nextUnit >= 0xdc00 && nextUnit <= 0xdfff) end -= 1;
259
+ return `${stripLoneSurrogates(str.slice(0, end))}…`;
260
+ }
261
+
262
+ const OSC_SEQUENCE_RE = /\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g;
263
+ const CSI_SEQUENCE_RE = /\x1b\[[0-9;?]*[ -/]*[@-~]/g;
264
+ const OTHER_ESCAPE_RE = /\x1b./g;
265
+
266
+ /**
267
+ * Last non-empty visible line of a raw terminal log chunk: strips OSC/CSI/ESC
268
+ * escape sequences, resolves per-line carriage-return overwrites (progress
269
+ * bars / boot spinners), and returns the final remaining line truncated to
270
+ * `maxLen`. Used to attribute a failed child's exit reason from screen.log
271
+ * into host.json (issue #90). Returns null for empty/all-invisible input.
272
+ * @param {string|null|undefined} text
273
+ * @param {number} [maxLen]
274
+ * @returns {string|null}
275
+ */
276
+ export function lastVisibleLogLine(text, maxLen = 200) {
277
+ if (!text) return null;
278
+ const visible = String(text)
279
+ .replace(OSC_SEQUENCE_RE, "")
280
+ .replace(CSI_SEQUENCE_RE, "")
281
+ .replace(OTHER_ESCAPE_RE, "")
282
+ // A \r directly before \n is a line terminator (CRLF), not an overwrite
283
+ // marker — normalize it away so the line content survives.
284
+ .replace(/\r\n/g, "\n");
285
+ const lines = visible.split("\n");
286
+ for (let i = lines.length - 1; i >= 0; i--) {
287
+ // Trailing \r chars are line-terminator junk (PTY ONLCR + the program's own
288
+ // CRLF stack up as \r\r\n) — drop them, then resolve interior \r overwrites.
289
+ const cleaned = lines[i].replace(/\r+$/, "");
290
+ const cr = cleaned.lastIndexOf("\r");
291
+ const line = (cr >= 0 ? cleaned.slice(cr + 1) : cleaned).trim();
292
+ if (line) return truncate(line, maxLen);
293
+ }
294
+ return null;
295
+ }
296
+
297
+ /**
298
+ * Compact relative age, e.g. "10s", "2m", "3h", "4d".
299
+ * @param {number} fromMs
300
+ * @param {number} [nowMs]
301
+ * @returns {string}
302
+ */
303
+ export function relativeTime(fromMs, nowMs = Date.now()) {
304
+ const diff = Math.max(0, nowMs - fromMs);
305
+ const s = Math.floor(diff / 1000);
306
+ if (s < 60) return `${s}s`;
307
+ const m = Math.floor(s / 60);
308
+ if (m < 60) return `${m}m`;
309
+ const h = Math.floor(m / 60);
310
+ if (h < 24) return `${h}h`;
311
+ const d = Math.floor(h / 24);
312
+ return `${d}d`;
313
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * While the board or an attached session is on screen, it owns the keyboard. Pi offers extensions'
3
+ * key listeners every key first, so without this a listener in the Pi behind the overlay can take a
4
+ * key: pi-modes' Shift+Tab, for one, changed that Pi's permission mode and never reached the session.
5
+ *
6
+ * Fullscreen Pi's mouse handling is a listener too, and has to keep running. It's added when the TUI
7
+ * is built, so it's first in the set. Listeners added or removed meanwhile, as a session change does,
8
+ * still apply once released. Does nothing if pi-tui changes shape, which leaves today's behavior.
9
+ *
10
+ * @param {any} tui
11
+ * @returns {() => void} gives the keys back
12
+ */
13
+ export function holdInputListeners(tui) {
14
+ const listeners = tui?.inputListeners;
15
+ if (!(listeners instanceof Set)) return () => {};
16
+ const kept = typeof tui.handleViewportInput === "function" ? [...listeners].slice(0, 1) : [];
17
+ const held = {
18
+ size: kept.length,
19
+ add: (listener) => listeners.add(listener),
20
+ delete: (listener) => listeners.delete(listener),
21
+ [Symbol.iterator]: () => kept[Symbol.iterator](),
22
+ };
23
+ tui.inputListeners = held;
24
+ return () => {
25
+ if (tui.inputListeners === held) tui.inputListeners = listeners;
26
+ };
27
+ }
@@ -0,0 +1,300 @@
1
+ /**
2
+ * Pure host-lifecycle decision functions (issue #70).
3
+ *
4
+ * Every function here takes plain snapshots (HostStatus-shaped objects, probe
5
+ * results, process observations) and returns a decision — no fs/net/process
6
+ * I/O. Callers (locks, store, service, runner) own all side effects; this
7
+ * module is the single source of truth for the coordination rules so they can
8
+ * be unit-tested exhaustively.
9
+ *
10
+ * @typedef {import("./types.mjs").HostStatus} HostStatus
11
+ */
12
+
13
+ /**
14
+ * Whether `host` is owned by the given fencing token. A null/missing
15
+ * instanceId never matches — legacy hosts have no owner token.
16
+ * @param {HostStatus|null|undefined} host
17
+ * @param {string|null|undefined} expectedInstanceId
18
+ * @returns {boolean}
19
+ */
20
+ export function sameHostOwner(host, expectedInstanceId) {
21
+ if (!host || !expectedInstanceId) return false;
22
+ return host.instanceId === expectedInstanceId;
23
+ }
24
+
25
+ /**
26
+ * Whether a `starting` claim is still inside the launch grace window.
27
+ * A missing runnerPid or socket does NOT make a fresh claim stale (issue #70:
28
+ * provisional claims are normal while the runner boots).
29
+ * @param {HostStatus|null|undefined} host
30
+ * @param {number} now Epoch ms.
31
+ * @param {number} graceMs
32
+ * @returns {boolean}
33
+ */
34
+ export function isStartingWithinGrace(host, now, graceMs) {
35
+ if (!host || host.state !== "starting" || host.claimAt == null) return false;
36
+ return now - host.claimAt < graceMs;
37
+ }
38
+
39
+ /**
40
+ * Classify a recorded process identity against a live observation.
41
+ * Five states — `not_started` (never spawned) must never be conflated with
42
+ * `dead`, and a missing stable token must never be guessed as alive-owned:
43
+ * it degrades to `unknown` so recovery can only refuse, never misfire.
44
+ *
45
+ * `not_started` requires BOTH the identity and `spawnedAt` to be absent: a
46
+ * recorded identity is proof the spawn happened, so such a record is judged by
47
+ * the observation. Calling it `not_started` (a releasable state) on a
48
+ * spawned-but-incomplete record could release a still-live host and let a new
49
+ * host double-write the session file.
50
+ * @param {{pid:number,startToken:string|null}|null|undefined} identity The recorded identity.
51
+ * @param {{alive:boolean,startToken:string|null}|null|undefined} observed The observation.
52
+ * @param {number|null|undefined} spawnedAt Non-null only after spawn was confirmed.
53
+ * @returns {"not_started"|"dead"|"owned"|"foreign"|"unknown"}
54
+ */
55
+ export function processIdentityState(identity, observed, spawnedAt) {
56
+ if (spawnedAt == null && identity == null) return "not_started";
57
+ if (observed?.alive === false) return "dead";
58
+ if (identity?.startToken == null || observed?.startToken == null) return "unknown";
59
+ return identity.startToken === observed.startToken ? "owned" : "foreign";
60
+ }
61
+
62
+ /** Characters that may abut a path fragment in a command line without being part of it. */
63
+ const COMMAND_LINE_BOUNDARIES = /[\s"']/;
64
+
65
+ /** Windows path semantics: separators and case are not significant. */
66
+ function normalizeCommandLine(text) {
67
+ return text.replace(/\\/g, "/").toLowerCase();
68
+ }
69
+
70
+ function isCommandLineBoundary(text, index, length) {
71
+ const before = index === 0 ? null : text[index - 1];
72
+ const after = index + length >= text.length ? null : text[index + length];
73
+ return (before == null || COMMAND_LINE_BOUNDARIES.test(before)) && (after == null || COMMAND_LINE_BOUNDARIES.test(after));
74
+ }
75
+
76
+ /**
77
+ * Whether a process command line mentions `fragment` as a whole path, not as a
78
+ * prefix of a longer one (`view_abc` must not match `view_abcd`, nor
79
+ * `session.jsonl` match `session-2.jsonl`). Comparison is slash- and
80
+ * case-insensitive because Windows reports `C:\x\y` inside quotes while the
81
+ * recorded fragment is a plain path (issue #217). Empty/missing input is never
82
+ * a match: a blank fragment would otherwise corroborate any process.
83
+ * @param {string|null|undefined} commandLine
84
+ * @param {string|null|undefined} fragment
85
+ * @returns {boolean}
86
+ */
87
+ export function commandLineMentions(commandLine, fragment) {
88
+ if (!commandLine || !fragment) return false;
89
+ const haystack = normalizeCommandLine(commandLine);
90
+ const needle = normalizeCommandLine(fragment);
91
+ let index = haystack.indexOf(needle);
92
+ while (index !== -1) {
93
+ if (isCommandLineBoundary(haystack, index, needle.length)) return true;
94
+ index = haystack.indexOf(needle, index + 1);
95
+ }
96
+ return false;
97
+ }
98
+
99
+ /**
100
+ * Refine a host-role observation that the token path could only call `unknown`
101
+ * (platforms without a start token) using deterministic probe evidence
102
+ * (issue #217). The command line is authoritative because every host role we
103
+ * spawn carries its own unique path argument; process creation time only
104
+ * corroborates when the command line is unreadable, and the tolerance absorbs
105
+ * spawn→record skew. A missing probe stays `unknown` — the #70 contract: an
106
+ * unverifiable process is never guessed as foreign.
107
+ * @param {{
108
+ * spawnedAt: number|null,
109
+ * probe: {creationTimeMs: number|null, commandLine: string|null}|null,
110
+ * expectedCommandFragment: string|null,
111
+ * toleranceMs: number,
112
+ * }} input
113
+ * @returns {"owned"|"foreign"|"unknown"}
114
+ */
115
+ export function classifyUnverifiableProcess({ spawnedAt, probe, expectedCommandFragment, toleranceMs }) {
116
+ if (!probe) return "unknown";
117
+ if (probe.commandLine && expectedCommandFragment) {
118
+ return commandLineMentions(probe.commandLine, expectedCommandFragment) ? "owned" : "foreign";
119
+ }
120
+ if (spawnedAt == null || probe.creationTimeMs == null) return "unknown";
121
+ return Math.abs(probe.creationTimeMs - spawnedAt) <= toleranceMs ? "owned" : "foreign";
122
+ }
123
+
124
+ /** Role observations that mean "no live process can belong to the old instance". */
125
+ const SAFE_TO_RELEASE = new Set(["not_started", "dead", "foreign"]);
126
+
127
+ /**
128
+ * Whether an exited/failed host can be replaced by a new claim. The runner and
129
+ * child roles must be provably gone; any `unknown` observation or an active
130
+ * launch lease blocks replacement. The claim role does NOT participate: claim
131
+ * protection (a launcher mid-transaction between claim and spawn) only matters
132
+ * while the host is `starting`, and this gate only ever sees terminal hosts —
133
+ * a terminal host cannot still be being launched (issue #99: a live claimPid —
134
+ * the dashboard process that wrote the claim — must not block re-attach).
135
+ * @param {{
136
+ * host: HostStatus|null|undefined,
137
+ * runnerObservation: string,
138
+ * childObservation: string,
139
+ * launchLeaseActive: boolean,
140
+ * }} input
141
+ * @returns {boolean}
142
+ */
143
+ export function canReplaceHost({ host, runnerObservation, childObservation, launchLeaseActive }) {
144
+ if (!host || (host.state !== "exited" && host.state !== "failed")) return false;
145
+ if (launchLeaseActive) return false;
146
+ return (
147
+ SAFE_TO_RELEASE.has(runnerObservation) &&
148
+ SAFE_TO_RELEASE.has(childObservation)
149
+ );
150
+ }
151
+
152
+ /**
153
+ * Whether the endpoint file at `current` is the one this instance bound.
154
+ * Compares dev+ino so a replaced (rebound) socket path is never unlinked by
155
+ * its former owner.
156
+ * @param {{dev:number,ino:number}|null|undefined} bound Identity recorded at bind time.
157
+ * @param {{dev:number,ino:number}|null|undefined} current Identity observed at cleanup time.
158
+ * @returns {boolean}
159
+ */
160
+ export function ownsEndpoint(bound, current) {
161
+ return Boolean(bound && current && bound.dev === current.dev && bound.ino === current.ino);
162
+ }
163
+
164
+ /**
165
+ * Whether this instance may unlink the endpoint path now.
166
+ * Restores the #70 cleanup contract (docs/superpowers/specs/
167
+ * 2026-09-05-pty-host-stacking-sock-race-design.md:418): the path must still be
168
+ * the inode this instance bound AND this instance must still own the lease.
169
+ * `compromised` lets the supervised tick veto its own cleanup after detecting
170
+ * that the endpoint is gone or replaced (issue #255).
171
+ * @param {{
172
+ * stillOwner: boolean,
173
+ * compromised: boolean,
174
+ * bound: {dev:number,ino:number}|null|undefined,
175
+ * current: {dev:number,ino:number}|null|undefined,
176
+ * }} input
177
+ * @returns {boolean}
178
+ */
179
+ export function shouldUnlinkEndpoint({ stillOwner, compromised, bound, current }) {
180
+ if (!stillOwner || compromised) return false;
181
+ return ownsEndpoint(bound, current);
182
+ }
183
+
184
+ /**
185
+ * Map one supervised stat attempt to the endpoint observation discriminator.
186
+ * A transient stat failure (EACCES/EBUSY/...) must never be read as "replaced":
187
+ * only ENOENT means the endpoint is actually gone (issue #255, mirrors the
188
+ * rootVanishedOrReplaced rule of issue #241).
189
+ * @param {{dev:number,ino:number}|null} stat The successful stat snapshot, or null.
190
+ * @param {string|null} errorCode The stat error code, or null on success.
191
+ * @returns {{kind:"ok",dev:number,ino:number}|{kind:"missing"}|{kind:"unknown"}}
192
+ */
193
+ export function socketIdentityObservation(stat, errorCode) {
194
+ if (stat) return { kind: "ok", dev: stat.dev, ino: stat.ino };
195
+ return errorCode === "ENOENT" ? { kind: "missing" } : { kind: "unknown" };
196
+ }
197
+
198
+ /**
199
+ * Classify the supervised endpoint against the identity bound at boot.
200
+ * `unknown` never steps a holder down: without a bound identity (win32, or a
201
+ * failed bind-time stat) the supervisor degrades to a no-op.
202
+ * @param {{dev:number,ino:number}|null|undefined} bound
203
+ * @param {{kind:"ok",dev:number,ino:number}|{kind:"missing"}|{kind:"unknown"}|null|undefined} observed
204
+ * @returns {"ok"|"missing"|"replaced"|"unknown"}
205
+ */
206
+ export function classifyEndpointHealth(bound, observed) {
207
+ if (!bound || !observed || observed.kind === "unknown") return "unknown";
208
+ if (observed.kind === "missing") return "missing";
209
+ return ownsEndpoint(bound, observed) ? "ok" : "replaced";
210
+ }
211
+
212
+ /**
213
+ * Classify a connect+hello probe snapshot into the coordination enum.
214
+ * Order: connection errors first (they carry the errorCode), then protocol
215
+ * validity, then ownership mismatch (`occupied` — never touch), then
216
+ * readiness. A valid matching host that is not ready yet is `starting`, which
217
+ * callers treat as "wait", never as stale.
218
+ * @param {{
219
+ * connected?: boolean,
220
+ * protocolValid?: boolean,
221
+ * viewMatch?: boolean,
222
+ * instanceMatch?: boolean,
223
+ * state?: string|null,
224
+ * readyAt?: number|null,
225
+ * errorCode?: string|null,
226
+ * isSocket?: boolean,
227
+ * }} result Probe snapshot from host-probe.
228
+ * @returns {"ready"|"starting"|"stale"|"occupied"|"missing"|"unknown"}
229
+ */
230
+ export function classifyProbeResult(result) {
231
+ if (!result) return "unknown";
232
+ if (!result.connected) {
233
+ if (result.errorCode === "ENOENT") return "missing";
234
+ if (result.errorCode === "ECONNREFUSED" && result.isSocket) return "stale";
235
+ return "unknown";
236
+ }
237
+ if (!result.protocolValid) return "unknown";
238
+ if (result.viewMatch === false || result.instanceMatch === false) return "occupied";
239
+ if (result.state === "alive" && result.readyAt != null) return "ready";
240
+ return "starting";
241
+ }
242
+
243
+ /**
244
+ * Whether a legacy (pre-instanceId) host record can be safely finalized as
245
+ * `exited` so the next attach claims a fresh host. Spec §10.1 keeps legacy
246
+ * hosts unrecovered because their identity is unverifiable — but a provably
247
+ * dead runner pid IS a certain identity (a reused pid reads alive, which
248
+ * conservatively lands here as false), and an unreachable endpoint (missing
249
+ * socket file / refused socket) rules out a merely slow host. All four
250
+ * conditions must hold at once (issue #87).
251
+ * @param {{
252
+ * host: HostStatus|null|undefined,
253
+ * hostPid: number|null|undefined,
254
+ * hostPidAlive: boolean,
255
+ * probeClassification: string,
256
+ * }} input
257
+ * @returns {boolean}
258
+ */
259
+ export function canFinalizeLegacyHost({ host, hostPid, hostPidAlive, probeClassification }) {
260
+ if (!host || host.instanceId != null) return false;
261
+ if (host.state !== "starting" && host.state !== "alive") return false;
262
+ if (hostPid == null || hostPidAlive) return false;
263
+ return probeClassification === "missing" || probeClassification === "stale";
264
+ }
265
+
266
+ /** Host states in which a claim exists and must not be duplicated. */
267
+ const ACTIVE_STATES = new Set(["starting", "alive", "stopping"]);
268
+
269
+ /**
270
+ * Whether a runner starting for `instanceId` must yield because the host
271
+ * record belongs to another still-active instance.
272
+ * @param {{host: HostStatus|null|undefined, instanceId: string|null|undefined}} input
273
+ * @returns {boolean}
274
+ */
275
+ export function shouldYieldRunner({ host, instanceId }) {
276
+ if (!host || host.instanceId === instanceId) return false;
277
+ return ACTIVE_STATES.has(host.state);
278
+ }
279
+
280
+ /**
281
+ * Whether a host is ready to accept service-generated input: alive, ready,
282
+ * and not revoked.
283
+ * @param {HostStatus|null|undefined} host
284
+ * @returns {boolean}
285
+ */
286
+ export function shouldAcceptInput(host) {
287
+ if (!host || host.state !== "alive" || host.readyAt == null) return false;
288
+ return host.stopRequestedAt == null;
289
+ }
290
+
291
+ /**
292
+ * Whether a failed `listen` with this error may be retried (only EADDRINUSE,
293
+ * only the single configured retry).
294
+ * @param {string|null|undefined} errorCode
295
+ * @param {number} attempt Zero-based retry attempt already made.
296
+ * @returns {boolean}
297
+ */
298
+ export function shouldRetryBind(errorCode, attempt) {
299
+ return errorCode === "EADDRINUSE" && attempt < 1;
300
+ }