@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,79 @@
1
+ /**
2
+ * Crash-time finalization for PTY hosts (used by pty-runner's uncaughtException
3
+ * handler). Pure enough to unit-test: writes a failed host.json + a diagnostic.
4
+ * Never throws — this runs on the crash path.
5
+ */
6
+ import { appendDiagnostic } from "./diagnostics.mjs";
7
+ import { updateOwnedHost, writeHost } from "./store.mjs";
8
+
9
+ /**
10
+ * @param {string} root
11
+ * @param {string} viewId
12
+ * @param {object|null} host
13
+ * @param {unknown} error
14
+ * @param {{ expectedInstanceId?: string|null }} [opts] When `expectedInstanceId`
15
+ * is set (new ownership protocol, issue #70), the failed state is written via
16
+ * the owner-fenced `updateOwnedHost` — a superseded instance can never
17
+ * clobber the replacement's live record; the skip is recorded as a
18
+ * `host_crash_owner_changed` diagnostic. Without it the legacy wholesale
19
+ * `writeHost` behavior is preserved exactly.
20
+ * @returns {object}
21
+ */
22
+ export function finalizeHostCrash(root, viewId, host, error, opts = {}) {
23
+ const message = error instanceof Error ? error.message : String(error);
24
+ const failed = {
25
+ ...(host ?? { version: 1, viewId, mode: "pty", socketPath: null, startedAt: Date.now() }),
26
+ state: "failed",
27
+ endedAt: Date.now(),
28
+ lastSeenAt: Date.now(),
29
+ exitCode: 1,
30
+ error: message,
31
+ };
32
+ if (opts.expectedInstanceId != null) {
33
+ try {
34
+ // Build the terminal state from the LIVE record inside the host-meta
35
+ // lock — never from the possibly-stale `host` snapshot.
36
+ const result = updateOwnedHost(root, viewId, opts.expectedInstanceId, (cur) => ({
37
+ ...cur,
38
+ state: "failed",
39
+ endedAt: Date.now(),
40
+ lastSeenAt: Date.now(),
41
+ exitCode: 1,
42
+ error: message,
43
+ }));
44
+ if (result.updated && result.host) {
45
+ appendCrashDiagnostic(root, viewId, message);
46
+ return result.host;
47
+ }
48
+ // ownerChanged, or the meta lock was briefly busy: never write unfenced.
49
+ try {
50
+ appendDiagnostic(root, viewId, {
51
+ source: "runner",
52
+ level: "warn",
53
+ code: "host_crash_owner_changed",
54
+ message: `Skipped crash finalize: host record no longer belongs to this instance (${message})`,
55
+ details: { error: message, ownerChanged: result.ownerChanged },
56
+ });
57
+ } catch { /* best effort */ }
58
+ } catch { /* best effort */ }
59
+ return failed;
60
+ }
61
+ try {
62
+ writeHost(root, failed);
63
+ } catch { /* best effort */ }
64
+ appendCrashDiagnostic(root, viewId, message);
65
+ return failed;
66
+ }
67
+
68
+ /** @param {string} root @param {string} viewId @param {string} message */
69
+ function appendCrashDiagnostic(root, viewId, message) {
70
+ try {
71
+ appendDiagnostic(root, viewId, {
72
+ source: "runner",
73
+ level: "error",
74
+ code: "host_crashed",
75
+ message: `PTY host crashed: ${message}`,
76
+ details: { error: message },
77
+ });
78
+ } catch { /* best effort */ }
79
+ }
@@ -0,0 +1,207 @@
1
+ /**
2
+ * Real control-endpoint probe (issue #70).
3
+ *
4
+ * Connects to a PTY host control socket/pipe, performs the JSONL hello
5
+ * handshake, and classifies the outcome via `classifyProbeResult`. This is
6
+ * the ONLY "is this endpoint actually usable" authority — path existence and
7
+ * host.json state are hints, never proof (spec §7.1).
8
+ *
9
+ * Side-effectful by design (net + fs); all pure decisions live in
10
+ * host-coordination.mjs.
11
+ */
12
+ import { createConnection } from "node:net";
13
+ import { lstatSync } from "node:fs";
14
+ import { classifyProbeResult } from "./host-coordination.mjs";
15
+ import { resolveTestMs } from "./test-knobs.mjs";
16
+
17
+ /** Default probe timeout. */
18
+ export const HOST_PROBE_TIMEOUT_MS = 250;
19
+ /** Poll cadence between attach-resolver probes / retries (issue #70 Task 12). */
20
+ export const HOST_PROBE_RETRY_MS = 150;
21
+
22
+ /**
23
+ * Dynamic reader for HOST_PROBE_TIMEOUT_MS (issue #95 F4): env set after module
24
+ * load must still reach the probe, so read the knob at each use site.
25
+ * @param {NodeJS.ProcessEnv} [env]
26
+ * @returns {number}
27
+ */
28
+ export function resolveHostProbeTimeoutMs(env = process.env) {
29
+ return resolveTestMs(env, "AGENT_BOARD_TEST_HOST_PROBE_TIMEOUT_MS", HOST_PROBE_TIMEOUT_MS);
30
+ }
31
+
32
+ /**
33
+ * @typedef {object} ProbeResult
34
+ * @property {"ready"|"starting"|"stale"|"occupied"|"missing"|"unknown"} classification
35
+ * @property {boolean} connected
36
+ * @property {boolean} protocolValid
37
+ * @property {boolean} ready
38
+ * @property {string|null} viewId
39
+ * @property {string|null} instanceId
40
+ * @property {string|null} state
41
+ * @property {string|null} errorCode
42
+ */
43
+
44
+ /**
45
+ * Probe a host control endpoint via a real connection + JSONL hello.
46
+ *
47
+ * `connect` is injectable for tests; the default accepts anything
48
+ * `net.createConnection` accepts (a socket path / pipe name, or a
49
+ * `{ host, port }` address object) and must return a socket-like object with
50
+ * the usual `'connect' | 'data' | 'error'` events and a `.destroy()`.
51
+ *
52
+ * The probe socket is ALWAYS destroyed before the promise resolves.
53
+ *
54
+ * @param {string|{host:string,port:number}} socketPath
55
+ * @param {{ timeoutMs?: number, expectedViewId?: string|null, expectedInstanceId?: string|null, connect?: (target: any) => any }} [opts]
56
+ * @returns {Promise<ProbeResult>}
57
+ */
58
+ export function probeHost(socketPath, opts = {}) {
59
+ const timeoutMs = opts.timeoutMs ?? resolveHostProbeTimeoutMs();
60
+ const expectedViewId = opts.expectedViewId ?? null;
61
+ const expectedInstanceId = opts.expectedInstanceId ?? null;
62
+ const connect = opts.connect ?? createConnection;
63
+
64
+ return new Promise((resolve) => {
65
+ /** @type {any} */ let socket;
66
+ /** @type {NodeJS.Timeout|null} */ let timer = null;
67
+ let settled = false;
68
+ let buffer = "";
69
+ /** @type {ProbeResult} */ let result;
70
+
71
+ const finish = () => {
72
+ if (settled) return;
73
+ settled = true;
74
+ if (timer) clearTimeout(timer);
75
+ try { socket?.destroy(); } catch { /* best effort */ }
76
+ resolve(result);
77
+ };
78
+
79
+ /** @param {string} errorCode */ const finishError = (errorCode) => {
80
+ const snapshot = {
81
+ connected: false,
82
+ protocolValid: false,
83
+ errorCode,
84
+ isSocket: false,
85
+ };
86
+ // Only a refused Unix-domain socket path can be considered stale, and
87
+ // even then only when the path really is a socket file (a plain file
88
+ // or directory at the same path must stay "unknown" — never unlink it).
89
+ if (errorCode === "ECONNREFUSED" && process.platform !== "win32" && typeof socketPath === "string") {
90
+ try {
91
+ snapshot.isSocket = lstatSync(socketPath).isSocket() === true;
92
+ } catch {
93
+ snapshot.isSocket = false;
94
+ }
95
+ }
96
+ result = buildResult(snapshot, null);
97
+ finish();
98
+ };
99
+
100
+ try {
101
+ socket = connect(socketPath);
102
+ } catch (err) {
103
+ result = buildResult({ connected: false, protocolValid: false, errorCode: errCode(err), isSocket: false }, null);
104
+ finish();
105
+ return;
106
+ }
107
+
108
+ timer = setTimeout(() => finishError("TIMEOUT"), timeoutMs);
109
+
110
+ socket.on("error", (err) => {
111
+ finishError(errCode(err));
112
+ });
113
+
114
+ socket.on("connect", () => {
115
+ try {
116
+ socket.write(JSON.stringify({ type: "hello", clientId: "probe", wantOutput: false }) + "\n");
117
+ } catch (err) {
118
+ finishError(errCode(err));
119
+ }
120
+ });
121
+
122
+ socket.on("data", (chunk) => {
123
+ if (settled) return;
124
+ buffer += chunk.toString("utf8");
125
+ const newline = buffer.indexOf("\n");
126
+ if (newline < 0) return;
127
+ const line = buffer.slice(0, newline).trim();
128
+ const parsed = parseReply(line);
129
+ result = buildResultFromReply(parsed, expectedViewId, expectedInstanceId);
130
+ finish();
131
+ });
132
+
133
+ // A close before any data settles the probe as an error-path unknown.
134
+ socket.on("close", () => {
135
+ if (!settled) finishError("CLOSED");
136
+ });
137
+ });
138
+ }
139
+
140
+ /**
141
+ * @param {string} line
142
+ * @returns {{ ok: boolean, status: any }}
143
+ */
144
+ function parseReply(line) {
145
+ if (!line) return { ok: false, status: null };
146
+ try {
147
+ const msg = JSON.parse(line);
148
+ if (!msg || typeof msg !== "object") return { ok: false, status: null };
149
+ if (msg.type !== "hello" && msg.type !== "status") return { ok: false, status: null };
150
+ if (!msg.status || typeof msg.status !== "object") return { ok: false, status: null };
151
+ return { ok: true, status: msg.status };
152
+ } catch {
153
+ return { ok: false, status: null };
154
+ }
155
+ }
156
+
157
+ /**
158
+ * Assemble the classify snapshot from a parsed reply.
159
+ * @param {{ ok: boolean, status: any }} parsed
160
+ * @param {string|null} expectedViewId
161
+ * @param {string|null} expectedInstanceId
162
+ */
163
+ function buildResultFromReply(parsed, expectedViewId, expectedInstanceId) {
164
+ const status = parsed.ok ? parsed.status : null;
165
+ const viewMatch = expectedViewId == null ? true : status?.viewId === expectedViewId;
166
+ const instanceMatch = expectedInstanceId == null ? true : status?.instanceId === expectedInstanceId;
167
+ // Legacy hosts (pre-instanceId protocol) report alive without a readyAt
168
+ // field; a legacy probe (no expectedInstanceId) must still classify them
169
+ // ready so upgrade-window attach keeps working (spec §10.1 / Task 2 ruling).
170
+ const readyAt = status?.readyAt ?? (expectedInstanceId == null && status?.state === "alive" ? 1 : null);
171
+ return buildResult(
172
+ {
173
+ connected: true,
174
+ protocolValid: parsed.ok,
175
+ viewMatch,
176
+ instanceMatch,
177
+ state: status?.state ?? null,
178
+ readyAt,
179
+ },
180
+ status,
181
+ );
182
+ }
183
+
184
+ /**
185
+ * @param {{ connected: boolean, protocolValid: boolean, viewMatch?: boolean, instanceMatch?: boolean, state?: string|null, readyAt?: number|null, errorCode?: string|null, isSocket?: boolean }} snapshot
186
+ * @param {any} status
187
+ * @returns {ProbeResult}
188
+ */
189
+ function buildResult(snapshot, status) {
190
+ const classification = classifyProbeResult(snapshot);
191
+ return {
192
+ classification,
193
+ connected: snapshot.connected === true,
194
+ protocolValid: snapshot.protocolValid === true,
195
+ ready: classification === "ready",
196
+ viewId: status?.viewId ?? null,
197
+ instanceId: status?.instanceId ?? null,
198
+ state: snapshot.state ?? null,
199
+ errorCode: snapshot.errorCode ?? null,
200
+ };
201
+ }
202
+
203
+ /** @param {unknown} err */
204
+ function errCode(err) {
205
+ if (err && typeof err === "object" && typeof /** @type {any} */ (err).code === "string") return /** @type {any} */ (err).code;
206
+ return "UNKNOWN";
207
+ }
@@ -0,0 +1,73 @@
1
+ /** Control-socket client identity helpers (issue #103).
2
+ *
3
+ * Client ids travel in the `hello` handshake. `helloBookkeeping` turns the
4
+ * classification into the explicit socket policy: probes and the resident
5
+ * editor-state reporter stay out of `attachedClients` / `attachedEver`, and
6
+ * probes additionally never trigger a host.json write — counting or writing
7
+ * either would pin every host against warm-host reclaim (issue #75 / #103 §C /
8
+ * #130).
9
+ */
10
+
11
+ /** Read-only endpoint probe (attach resolver); never counts as attached. */
12
+ export const CLIENT_ID_PROBE = "probe";
13
+ /** Resident editor-state reporter inside a hosted child; never counts as attached. */
14
+ export const CLIENT_ID_EDITOR_REPORTER = "editor-reporter";
15
+ /**
16
+ * Resident frame-witness reporter inside a hosted child (issue #222 FW-03/FW-04).
17
+ * Bookkeeping-only like the editor reporter: it must never keep a host attached or flip
18
+ * attachedEver, or warm-host reclaim never fires.
19
+ */
20
+ export const CLIENT_ID_FRAME_REPORTER = "frame-reporter";
21
+
22
+ /**
23
+ * @param {{ type?: string, clientId?: string } | null | undefined} msg
24
+ * @returns {"probe" | "editor-reporter" | "frame-reporter" | "client"}
25
+ */
26
+ export function classifyClientHello(msg) {
27
+ const clientId = msg && typeof msg.clientId === "string" ? msg.clientId : "";
28
+ if (clientId === CLIENT_ID_PROBE) return "probe";
29
+ if (clientId === CLIENT_ID_EDITOR_REPORTER) return "editor-reporter";
30
+ if (clientId === CLIENT_ID_FRAME_REPORTER) return "frame-reporter";
31
+ return "client";
32
+ }
33
+
34
+ /**
35
+ * Socket bookkeeping implied by a hello classification (issue #103 §C / #130).
36
+ *
37
+ * `probe` and `editor-reporter` are bookkeeping-only: neither may keep
38
+ * `attachedClients` — whose sole source is the runner's `clients` set —
39
+ * non-zero, or warm-host reclaim never fires and hosts leak (issue #75). Both
40
+ * therefore leave `clients` at hello time, while their socket stays writable so
41
+ * probe replies and editor_state keep flowing.
42
+ *
43
+ * `persist: false` for probes is deliberate: the attach resolver probes every
44
+ * HOST_PROBE_RETRY_MS (see host-probe.mjs), so one host.json write per probe
45
+ * would amplify fenced writes — the exact cost the runner's probe-socket write
46
+ * suppression exists to avoid. Dropping the socket from `clients` already makes
47
+ * the in-memory count right, and the next heartbeat (<=1s) persists it.
48
+ *
49
+ * `suppressCloseWrite: true` is the other half of that suppression: close is the
50
+ * wrong moment to flush a record a probe never changed.
51
+ *
52
+ * An unrecognized kind falls back to the real-client row on purpose — counting
53
+ * an unknown connection is the conservative direction (it can only delay
54
+ * reclaim, never risk killing a host with a live client).
55
+ *
56
+ * @param {"probe" | "editor-reporter" | "frame-reporter" | "client"} kind
57
+ * @returns {{ keepInClients: boolean, registerReporter: boolean,
58
+ * flipAttachedEver: boolean, persist: boolean, suppressCloseWrite: boolean }}
59
+ */
60
+ export function helloBookkeeping(kind) {
61
+ switch (kind) {
62
+ case "probe":
63
+ return { keepInClients: false, registerReporter: false, flipAttachedEver: false, persist: false, suppressCloseWrite: true };
64
+ case "editor-reporter":
65
+ return { keepInClients: false, registerReporter: true, flipAttachedEver: false, persist: true, suppressCloseWrite: false };
66
+ case "frame-reporter":
67
+ // Same policy as the editor reporter (issue #222 FW-04): bookkeeping-only, no
68
+ // attachEver flip, but the socket stays writable so enable/intent keep flowing.
69
+ return { keepInClients: false, registerReporter: false, flipAttachedEver: false, persist: true, suppressCloseWrite: false };
70
+ default:
71
+ return { keepInClients: true, registerReporter: false, flipAttachedEver: true, persist: true, suppressCloseWrite: false };
72
+ }
73
+ }
@@ -0,0 +1,35 @@
1
+ /** ID generation for views and runs. */
2
+ import { randomBytes } from "node:crypto";
3
+
4
+ /** @param {string} prefix @returns {string} e.g. "view_8f3a1c2b". */
5
+ export function genId(prefix) {
6
+ return `${prefix}_${randomBytes(5).toString("hex")}`;
7
+ }
8
+
9
+ /** @returns {string} a new view (row) id. */
10
+ export const newViewId = () => genId("view");
11
+
12
+ /** @returns {string} a new run id. */
13
+ export const newRunId = () => genId("run");
14
+
15
+ /** @returns {string} a new follow-up queue item id. */
16
+ export const newFollowUpId = () => genId("follow");
17
+
18
+ /**
19
+ * Derive a short, filesystem-safe slug from a free-text task, for display names.
20
+ * @param {string} text
21
+ * @param {number} [maxWords]
22
+ * @returns {string}
23
+ */
24
+ export function slugifyTask(text, maxWords = 5) {
25
+ const words = String(text)
26
+ .toLowerCase()
27
+ .replace(/[`'"]/g, "")
28
+ .replace(/[^a-z0-9]+/g, " ")
29
+ .trim()
30
+ .split(/\s+/)
31
+ .filter(Boolean)
32
+ .slice(0, maxWords);
33
+ const slug = words.join("-");
34
+ return slug || "task";
35
+ }
@@ -0,0 +1,193 @@
1
+ /**
2
+ * IME cursor-rect flicker fix (issue #28).
3
+ *
4
+ * pi-tui's doRender() emits each frame as separate terminal.write() calls:
5
+ *
6
+ * 1. ESC[?2026h ...content... ESC[?2026l (differential/full frame, sync block)
7
+ * 2. ESC[<n>A/B ESC[<col>G (positionHardwareCursor park write)
8
+ * 3. ESC[?25l (hideCursor - bypasses write())
9
+ *
10
+ * The park sequences sit OUTSIDE the synchronized-output block. Terminals that
11
+ * honor ?2026 (WezTerm et al.) present a frame and report the IME cursor
12
+ * rectangle at each ?2026l boundary, so every frame produces two cursor-rect
13
+ * reports at different positions (diff-write end vs parked input line) and the
14
+ * IME candidate window bounces at frame rate. E2E measured on WezTerm + fcitx5:
15
+ * ~20 position changes / 4s with the split writes, 0 with the park inside the
16
+ * block (see issue #28 for the full experiment).
17
+ *
18
+ * This module wraps a Terminal instance's write/hideCursor/showCursor at runtime
19
+ * and folds the out-of-block park/hide sequences back INSIDE the frame's sync
20
+ * block, re-emitting the frame as a single write. Content is byte-identical up
21
+ * to reordering of the trailing ?2026l; nothing is dropped or added.
22
+ *
23
+ * Safety properties (issue #28):
24
+ * - No match -> passthrough: any write that isn't a pure cursor-park/hide
25
+ * sequence flushes the held frame unchanged first, preserving byte order.
26
+ * - The three writes happen inside one synchronous doRender() stack, so a
27
+ * process.nextTick flush is enough to see them all; the added latency is
28
+ * sub-millisecond.
29
+ * - Uninstall restores the original methods; a WeakMap makes overlapping
30
+ * installs on the same terminal refcounted and idempotent.
31
+ * - Kill switch: AGENT_BOARD_IME_FIX=0 disables installation entirely.
32
+ *
33
+ * If pi-tui ever folds positionHardwareCursor into the sync block upstream,
34
+ * the "pure park sequence following a sync-end write" pattern stops matching
35
+ * and this wrapper degrades to a passthrough (one buffered write per frame,
36
+ * same bytes) - no behavior change.
37
+ */
38
+
39
+ const ESC = String.fromCharCode(27);
40
+ /** ESC[?2026l - end of a synchronized-output block. */
41
+ const SYNC_END = ESC + "[?2026l";
42
+
43
+ /**
44
+ * Cursor sequences positionHardwareCursor() emits after a frame: relative row
45
+ * moves (ESC[<n>A / ESC[<n>B, n optional) and an absolute column set
46
+ * (ESC[<col>G), plus the cursor visibility toggles (ESC[?25l/h). These are
47
+ * the ONLY sequences safe to fold into the block - anything else (line clears,
48
+ * absolute positioning, OSC/DCS queries, content) flushes the held frame.
49
+ */
50
+ const PARK_SEQUENCE = new RegExp(
51
+ "^(?:" + ESC + "\\[\\d*[AB]|" + ESC + "\\[\\d+G|" + ESC + "\\[\\?25[hl])*$",
52
+ );
53
+
54
+ /** True when a write ends a synchronized-output block (pi-tui frame writes do). */
55
+ export function endsWithSyncEnd(data) {
56
+ return typeof data === "string" && data.endsWith(SYNC_END);
57
+ }
58
+
59
+ /** True when data is exclusively cursor park/visibility sequences (see above). */
60
+ export function isPureCursorParking(data) {
61
+ return typeof data === "string" && data.length > 0 && PARK_SEQUENCE.test(data);
62
+ }
63
+
64
+ /** Insert seq just before the trailing SYNC_END of a held frame write. */
65
+ export function mergeIntoSyncBlock(held, seq) {
66
+ return held.slice(0, held.length - SYNC_END.length) + seq + SYNC_END;
67
+ }
68
+
69
+ /** terminal -> active wrapper, so overlapping installs share one wrapper. */
70
+ const activeWrappers = new WeakMap();
71
+
72
+ /**
73
+ * Each wrapTerminalWrites() call returns its own guarded handle: idempotent
74
+ * per handle, refcounted across handles — the patch is torn down only when
75
+ * the LAST caller uninstalls (CR round 1, issue-1).
76
+ */
77
+ function refcountedUninstall(entry) {
78
+ let done = false;
79
+ return () => {
80
+ if (done) return;
81
+ done = true;
82
+ entry.refs -= 1;
83
+ if (entry.refs > 0) return;
84
+ entry.teardown();
85
+ };
86
+ }
87
+
88
+ /**
89
+ * Patch write/hideCursor/showCursor on a terminal instance so each frame's
90
+ * out-of-block park/hide sequences are folded into the frame's sync block and
91
+ * emitted as one write. Returns an uninstall function (idempotent, drops the
92
+ * patch when the last caller uninstalls).
93
+ */
94
+ export function wrapTerminalWrites(terminal) {
95
+ if (!terminal || typeof terminal.write !== "function" || typeof terminal.hideCursor !== "function" || typeof terminal.showCursor !== "function") {
96
+ throw new Error("terminal must expose write/hideCursor/showCursor");
97
+ }
98
+ const existing = activeWrappers.get(terminal);
99
+ if (existing) {
100
+ existing.refs += 1;
101
+ return refcountedUninstall(existing);
102
+ }
103
+
104
+ const original = {
105
+ write: terminal.write.bind(terminal),
106
+ hideCursor: terminal.hideCursor.bind(terminal),
107
+ showCursor: terminal.showCursor.bind(terminal),
108
+ };
109
+ /** Frame write ending in SYNC_END, accumulating park/hide merges. */
110
+ let held = null;
111
+ let flushScheduled = false;
112
+
113
+ const flush = () => {
114
+ if (held === null) return;
115
+ const out = held;
116
+ held = null;
117
+ original.write(out);
118
+ };
119
+
120
+ const scheduleFlush = () => {
121
+ if (flushScheduled) return;
122
+ flushScheduled = true;
123
+ process.nextTick(() => {
124
+ flushScheduled = false;
125
+ flush();
126
+ });
127
+ };
128
+
129
+ /** Fold a park/hide seq into the held frame, or emit it standalone. */
130
+ const mergeOrEmit = (seq, directEmit) => {
131
+ if (held !== null) {
132
+ held = mergeIntoSyncBlock(held, seq);
133
+ return;
134
+ }
135
+ directEmit();
136
+ };
137
+
138
+ terminal.write = (data) => {
139
+ if (typeof data !== "string" || data.length === 0) {
140
+ flush();
141
+ original.write(data);
142
+ return;
143
+ }
144
+ if (held !== null) {
145
+ // Only a pure park/hide burst may join the held frame; anything
146
+ // else means this isn't a doRender park tail - flush unchanged.
147
+ if (isPureCursorParking(data)) {
148
+ held = mergeIntoSyncBlock(held, data);
149
+ return;
150
+ }
151
+ flush();
152
+ }
153
+ if (endsWithSyncEnd(data)) {
154
+ held = data;
155
+ scheduleFlush();
156
+ return;
157
+ }
158
+ original.write(data);
159
+ };
160
+
161
+ terminal.hideCursor = () => mergeOrEmit(ESC + "[?25l", original.hideCursor);
162
+ terminal.showCursor = () => mergeOrEmit(ESC + "[?25h", original.showCursor);
163
+
164
+ const entry = { refs: 1, teardown: null };
165
+ entry.teardown = () => {
166
+ flush();
167
+ terminal.write = original.write;
168
+ terminal.hideCursor = original.hideCursor;
169
+ terminal.showCursor = original.showCursor;
170
+ activeWrappers.delete(terminal);
171
+ };
172
+
173
+ activeWrappers.set(terminal, entry);
174
+ return refcountedUninstall(entry);
175
+ }
176
+
177
+ /**
178
+ * Install the coalescer on a TUI's terminal. Never throws: on any surprise
179
+ * (shape change upstream, kill switch) it returns null and behavior stays
180
+ * exactly as today.
181
+ */
182
+ export function installImeCursorCoalesce(tui) {
183
+ if (process.env.AGENT_BOARD_IME_FIX === "0") return null;
184
+ try {
185
+ const terminal = tui && tui.terminal;
186
+ if (!terminal || typeof terminal.write !== "function" || typeof terminal.hideCursor !== "function" || typeof terminal.showCursor !== "function") {
187
+ return null;
188
+ }
189
+ return wrapTerminalWrites(terminal);
190
+ } catch {
191
+ return null;
192
+ }
193
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Resolve how to invoke the `pi` worker and the `node` runner across install shapes:
3
+ * - pi as `dist/cli.js` under system node → piCommand=node, piArgsPrefix=[cli.js]
4
+ * - pi as a bun-compiled binary → piCommand="pi" (on PATH)
5
+ * - generic node/bun runtime → piCommand=execPath
6
+ * Mirrors the logic in the official subagent example's getPiInvocation().
7
+ */
8
+ import { existsSync } from "node:fs";
9
+ import * as path from "node:path";
10
+
11
+ /** @returns {boolean} true when process.execPath is a generic node/bun runtime (not a compiled app). */
12
+ function isGenericRuntime() {
13
+ const name = path.basename(process.execPath).toLowerCase();
14
+ return /^(node|bun)(\.exe)?$/.test(name);
15
+ }
16
+
17
+ /**
18
+ * Compute `{ piCommand, piArgsPrefix }` for spawning the pi worker.
19
+ * @returns {{ piCommand: string, piArgsPrefix: string[] }}
20
+ */
21
+ export function resolvePiInvocation() {
22
+ const script = process.argv[1];
23
+ const isBunVirtual = typeof script === "string" && script.startsWith("/$bunfs/root/");
24
+ if (script && !isBunVirtual && existsSync(script)) {
25
+ // Running the pi cli script under a runtime — re-run the same script.
26
+ return { piCommand: process.execPath, piArgsPrefix: [script] };
27
+ }
28
+ if (!isGenericRuntime()) {
29
+ // Compiled pi binary invoked directly.
30
+ return { piCommand: process.execPath, piArgsPrefix: [] };
31
+ }
32
+ // Fallback: rely on `pi` being on PATH.
33
+ return { piCommand: "pi", piArgsPrefix: [] };
34
+ }
35
+
36
+ /**
37
+ * Resolve a real `node` executable to run the detached `.mjs` runner.
38
+ * @returns {string}
39
+ */
40
+ export function resolveNode() {
41
+ if (isGenericRuntime()) return process.execPath;
42
+ return "node";
43
+ }