@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,66 @@
1
+ /**
2
+ * Attach decision tables for the agent-board attach flow (issue #70).
3
+ *
4
+ * Pure mapping functions extracted from the two near-duplicate attach()
5
+ * implementations (src/commands/attach-flow.ts and the private copy in
6
+ * src/commands/agent-board.ts) so the branching contract is node-testable.
7
+ * Both TS files consume these for their prelude and resolver-result
8
+ * branching; they must stay free of I/O, service calls, and UI.
9
+ */
10
+
11
+ /** Default message surfaced when the resolver reports pending without a reason. */
12
+ export const ATTACH_PENDING_DEFAULT_MESSAGE = "Session host is starting — try again shortly.";
13
+
14
+ /**
15
+ * Prelude facts as observed from a dashboard row.
16
+ * @typedef {{ rowAlive: boolean, rowHostActive: boolean, stopFirst: boolean }} AttachPreludeFacts
17
+ */
18
+
19
+ /**
20
+ * Map the attach prelude guards to a plan.
21
+ *
22
+ * The prelude only guards live JSON-runner rows (alive with no host claim):
23
+ * a row with an active host (starting/alive/stopping) passes straight
24
+ * through to the async resolver — attaching must not block or warn while a
25
+ * host is starting (issue #70).
26
+ *
27
+ * @param {AttachPreludeFacts} facts
28
+ * @returns {{ plan: "warn-running" } | { plan: "stop-first" } | { plan: "proceed" }}
29
+ */
30
+ export function planAttachPrelude({ rowAlive, rowHostActive, stopFirst }) {
31
+ if (rowAlive && !rowHostActive) {
32
+ return stopFirst ? { plan: "stop-first" } : { plan: "warn-running" };
33
+ }
34
+ return { plan: "proceed" };
35
+ }
36
+
37
+ /**
38
+ * Map a `resolveAttachTarget()` result to the attach action plan.
39
+ *
40
+ * `socketPath` is typed `string | null` upstream; a pty resolution without a
41
+ * usable path is a transient resolver inconsistency and maps to
42
+ * notify-pending (retry later) rather than a misleading "missing".
43
+ *
44
+ * @param {{ kind: "pty", socketPath: string | null, sessionFile?: string, instanceId?: string | null } |
45
+ * { kind: "session", sessionFile: string } |
46
+ * { kind: "pending", sessionFile?: string, reason?: string } |
47
+ * { kind: "missing" }} resolved
48
+ * @returns {{ plan: "open-pty", socketPath: string } |
49
+ * { plan: "session-switch", sessionFile: string } |
50
+ * { plan: "notify-pending", reason: string } |
51
+ * { plan: "notify-missing" }}
52
+ */
53
+ export function planAttachResolved(resolved) {
54
+ switch (resolved?.kind) {
55
+ case "pty":
56
+ return resolved.socketPath != null
57
+ ? { plan: "open-pty", socketPath: resolved.socketPath }
58
+ : { plan: "notify-pending", reason: ATTACH_PENDING_DEFAULT_MESSAGE };
59
+ case "session":
60
+ return { plan: "session-switch", sessionFile: resolved.sessionFile };
61
+ case "pending":
62
+ return { plan: "notify-pending", reason: resolved.reason ?? ATTACH_PENDING_DEFAULT_MESSAGE };
63
+ default:
64
+ return { plan: "notify-missing" };
65
+ }
66
+ }
@@ -0,0 +1,251 @@
1
+ /**
2
+ * Reusable attach-flow helpers shared by the `agent-board` and `bg` commands.
3
+ *
4
+ * Pure helpers (paths, prompts, current-view lookup) plus the resolver-driven
5
+ * attach flow: since issue #70 the attach path calls the async
6
+ * `resolveAttachTarget()` once and plans via `attach-decision.mjs`, instead of
7
+ * the old attachTarget-hint → ensureHost double path.
8
+ */
9
+ import { existsSync } from "node:fs";
10
+ import { resolve } from "node:path";
11
+ import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
12
+ import { restoreChrome } from "../ui/chrome.ts";
13
+ import { Key, matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
14
+ import { createService } from "../runtime/service.mjs";
15
+ import { planAttachPrelude, planAttachResolved } from "./attach-decision.mjs";
16
+ import { screenLogPath } from "../core/paths.mjs";
17
+ import { appendDiagnostic } from "../core/diagnostics.mjs";
18
+ import { PtyAttachComponent, type PtyAttachResult } from "../ui/pty-attach.js";
19
+ import type { DashboardResult } from "../ui/dashboard.js";
20
+
21
+ const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
22
+
23
+ export type AttachOutcome = { action: "detached" | "closed" | "switched" | "none" };
24
+
25
+ /** Open the PTY-attach UI for a live agent session. */
26
+ export async function openPtyAttach(
27
+ ctx: ExtensionCommandContext,
28
+ root: string,
29
+ viewId: string,
30
+ name: string,
31
+ socketPath: string,
32
+ ): Promise<PtyAttachResult> {
33
+ ctx.ui.setWorkingVisible(false);
34
+ ctx.ui.setHeader(() => ({ render: () => [], invalidate() {} }));
35
+ ctx.ui.setFooter(() => ({ render: () => [], invalidate() {} }));
36
+ ctx.ui.setTitle(`agent-board: ${name}`);
37
+ try {
38
+ return await ctx.ui.custom<PtyAttachResult>(
39
+ (tui, theme, keybindings, done) =>
40
+ new PtyAttachComponent(tui, theme as never, keybindings, done, {
41
+ socketPath,
42
+ screenLogPath: root ? screenLogPath(root, viewId) : undefined,
43
+ title: name,
44
+ onDiagnostic: root
45
+ ? (event) => {
46
+ // Best effort, like every other diagnostics writer.
47
+ try {
48
+ appendDiagnostic(root, viewId, event as never);
49
+ } catch {
50
+ /* diagnostics must never break attach */
51
+ }
52
+ }
53
+ : undefined,
54
+ }),
55
+ {
56
+ overlay: true,
57
+ overlayOptions: { anchor: "top-left", width: "100%", maxHeight: "100%", margin: 0 },
58
+ },
59
+ );
60
+ } finally {
61
+ restoreChrome(ctx);
62
+ }
63
+ }
64
+
65
+ /** Show a brief overlay while the session switch is being initiated. */
66
+ export async function showSwitchingOverlay(
67
+ ctx: ExtensionCommandContext,
68
+ name: string,
69
+ reason: string,
70
+ ): Promise<{ hide(): void } | null> {
71
+ let handle: { hide(): void } | null = null;
72
+ void ctx.ui.custom<null>(
73
+ (tui, theme) => ({
74
+ render(width: number): string[] {
75
+ const height = tui.terminal?.rows ?? 24;
76
+ const out = Array.from({ length: Math.max(0, Math.floor(height / 2) - 2) }, () => "");
77
+ out.push(clipLine(theme.fg("accent", theme.bold(`Switching to "${name}"…`)), width));
78
+ out.push(clipLine(theme.fg("dim", `Starting fallback session switch (${reason})`), width));
79
+ while (out.length < height) out.push("");
80
+ return out;
81
+ },
82
+ invalidate() {},
83
+ }),
84
+ {
85
+ overlay: true,
86
+ overlayOptions: { anchor: "top-left", width: "100%", maxHeight: "100%", margin: 0 },
87
+ onHandle: (h) => {
88
+ handle = h;
89
+ },
90
+ },
91
+ );
92
+ await sleep(50);
93
+ return handle;
94
+ }
95
+
96
+ function clipLine(text: string, width: number): string {
97
+ return truncateToWidth(text, width);
98
+ }
99
+
100
+ /** Return the viewId of whichever agent session matches the current pi session file. */
101
+ export function currentViewId(
102
+ ctx: ExtensionCommandContext,
103
+ service: ReturnType<typeof createService>,
104
+ ): string | null {
105
+ const currentSessionFile = ctx.sessionManager.getSessionFile();
106
+ if (!currentSessionFile) return null;
107
+ return service.rows().find((r) => samePath(r.meta.sessionFile, currentSessionFile))?.meta.id ?? null;
108
+ }
109
+
110
+ export function samePath(a: string, b: string): boolean {
111
+ return resolve(a) === resolve(b);
112
+ }
113
+
114
+ /**
115
+ * Attach to an agent session through the single async resolver entry point.
116
+ * PTY readiness is decided by `resolveAttachTarget()` (real connect + hello
117
+ * probe, with bounded recovery); this function only maps its result to UI
118
+ * actions (issue #70 — no more attachTarget-hint → ensureHost double path).
119
+ */
120
+ export async function attach(
121
+ ctx: ExtensionCommandContext,
122
+ service: ReturnType<typeof createService>,
123
+ root: string,
124
+ viewId: string,
125
+ stopFirst: boolean,
126
+ ): Promise<AttachOutcome> {
127
+ const row = service.row(viewId);
128
+ if (!row) {
129
+ ctx.ui.notify("Session no longer exists.", "warning");
130
+ return { action: "none" };
131
+ }
132
+ const prelude = planAttachPrelude({ rowAlive: row.alive, rowHostActive: row.hostActive, stopFirst });
133
+ if (prelude.plan === "warn-running") {
134
+ ctx.ui.notify("Session is still running. Stop it before attaching, or confirm from the dashboard.", "warning");
135
+ return { action: "none" };
136
+ }
137
+ if (prelude.plan === "stop-first") {
138
+ service.stop(viewId);
139
+ // Give the runner a moment to terminate the worker and release the session file.
140
+ await sleep(500);
141
+ }
142
+
143
+ const plan = planAttachResolved(await service.resolveAttachTarget(viewId));
144
+ if (plan.plan === "open-pty") {
145
+ void service.markVisited?.(viewId)?.catch(() => {});
146
+ const result = await openPtyAttach(ctx, root, row.meta.id, row.meta.name, plan.socketPath);
147
+ void service.markVisited?.(viewId)?.catch(() => {});
148
+ if (result.action === "quit") {
149
+ ctx.shutdown();
150
+ return { action: "switched" }; // Nothing to come back to.
151
+ }
152
+ return { action: result.action === "closed" ? "closed" : "detached" };
153
+ }
154
+ if (plan.plan === "session-switch") {
155
+ const latest = service.row(viewId) ?? row;
156
+ if (!existsSync(latest.meta.sessionFile)) {
157
+ ctx.ui.notify("Session file isn't ready yet — try again once the run has started.", "warning");
158
+ return { action: "none" };
159
+ }
160
+ const name = latest.meta.name;
161
+ void service.markVisited?.(viewId)?.catch(() => {});
162
+ const switchingOverlay = await showSwitchingOverlay(ctx, name, "PTY unavailable");
163
+ const result = await ctx.switchSession(latest.meta.sessionFile, {
164
+ withSession: async (replaced) => {
165
+ replaced.ui.notify(`Attached to "${name}". Press ← on empty input to return to agent board.`, "info");
166
+ installBackToDashboard(replaced, service, openDashboardFn);
167
+ },
168
+ }).finally(() => {
169
+ try {
170
+ switchingOverlay?.hide();
171
+ } catch {}
172
+ });
173
+ if (result.cancelled) {
174
+ ctx.ui.notify("Attach cancelled.", "warning");
175
+ return { action: "none" };
176
+ }
177
+ return { action: "switched" };
178
+ }
179
+ if (plan.plan === "notify-pending") {
180
+ ctx.ui.notify(plan.reason, "info");
181
+ return { action: "none" };
182
+ }
183
+ ctx.ui.notify("Session no longer exists.", "warning");
184
+ return { action: "none" };
185
+ }
186
+
187
+ /**
188
+ * The openDashboard function reference used by installBackToDashboard when called
189
+ * from attach(). This is set at module initialisation time by agent-board.ts via
190
+ * setOpenDashboardFn so that attach-flow.ts does not need to import from agent-board.ts
191
+ * (which would create a circular dependency).
192
+ */
193
+ type OpenDashboardFn = (
194
+ ctx: Pick<ExtensionCommandContext, "ui" | "cwd" | "modelRegistry" | "model">,
195
+ service: ReturnType<typeof createService>,
196
+ options?: { initialSelectedId?: string | null },
197
+ ) => Promise<DashboardResult>;
198
+
199
+ let openDashboardFn: OpenDashboardFn = async () => {
200
+ throw new Error("openDashboardFn not initialised — call setOpenDashboardFn first");
201
+ };
202
+
203
+ /** Called once by agent-board.ts to wire up the openDashboard dependency. */
204
+ export function setOpenDashboardFn(fn: OpenDashboardFn): void {
205
+ openDashboardFn = fn;
206
+ }
207
+
208
+ /**
209
+ * Install the ← keybinding that opens the dashboard from inside an attached session.
210
+ * `openDashboard` is accepted as a parameter to avoid a circular import between
211
+ * agent-board.ts and attach-flow.ts.
212
+ */
213
+ export function installBackToDashboard(
214
+ ctx: ExtensionCommandContext,
215
+ service: ReturnType<typeof createService>,
216
+ openDashboard: OpenDashboardFn,
217
+ ): void {
218
+ ctx.ui.setStatus("agent-board.back", ctx.ui.theme.fg("muted", "← board"));
219
+ let opening = false;
220
+ ctx.ui.onTerminalInput((data: string) => {
221
+ if (opening || !matchesKey(data, Key.left)) return undefined;
222
+ // Do not steal normal cursor-left while the user is composing a message.
223
+ if (ctx.ui.getEditorText().length > 0) return undefined;
224
+ opening = true;
225
+ void (async () => {
226
+ try {
227
+ let selectedId = currentViewId(ctx, service);
228
+ while (true) {
229
+ if (selectedId) void service.markVisited?.(selectedId)?.catch(() => {});
230
+ void service.reconcile().catch(() => {});
231
+ const result = await openDashboard(ctx, service, { initialSelectedId: selectedId });
232
+ if (result.action !== "attach") return;
233
+ selectedId = result.viewId;
234
+ const target = service.row(result.viewId);
235
+ const currentSessionFile = ctx.sessionManager.getSessionFile();
236
+ if (target && currentSessionFile && samePath(target.meta.sessionFile, currentSessionFile)) {
237
+ ctx.ui.notify("Already attached to this session.", "info");
238
+ continue;
239
+ }
240
+ const outcome = await attach(ctx, service, service.root, result.viewId, result.stopFirst);
241
+ if (outcome.action === "switched") return;
242
+ }
243
+ } catch (err) {
244
+ ctx.ui.notify(`Couldn't open agent board: ${err instanceof Error ? err.message : String(err)}`, "error");
245
+ } finally {
246
+ opening = false;
247
+ }
248
+ })();
249
+ return { consume: true };
250
+ });
251
+ }
@@ -0,0 +1,103 @@
1
+ /** `/bg` command: adopt the current interactive Pi session into Agent Board. */
2
+ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
3
+ import { createService } from "../runtime/service.mjs";
4
+ import { dashboardAttachLoop } from "./agent-board.js";
5
+
6
+ const NEW_SESSION = "new session";
7
+
8
+ export interface BgCommandOptions {
9
+ root: string;
10
+ runnerScript: string;
11
+ ptyRunnerScript?: string;
12
+ titleRunnerScript?: string;
13
+ piCommand: string;
14
+ piArgsPrefix: string[];
15
+ getThinkingLevel: () => "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
16
+ }
17
+
18
+ export function registerBgCommand(pi: ExtensionAPI, opts: BgCommandOptions): void {
19
+ pi.registerCommand("bg", {
20
+ description: "Adopt the current session into Agent Board and optionally queue a prompt",
21
+ handler: async (args, ctx) => {
22
+ await handleBgCommand(args, ctx, opts);
23
+ },
24
+ });
25
+ }
26
+
27
+ async function handleBgCommand(args: string, ctx: ExtensionCommandContext, opts: BgCommandOptions): Promise<void> {
28
+ if (!ctx.hasUI) {
29
+ ctx.ui.notify("/bg requires interactive mode.", "warning");
30
+ return;
31
+ }
32
+ const sessionFile = ctx.sessionManager.getSessionFile();
33
+ const service = createService({
34
+ root: opts.root,
35
+ runnerScript: opts.runnerScript,
36
+ ptyRunnerScript: opts.ptyRunnerScript,
37
+ titleRunnerScript: opts.titleRunnerScript,
38
+ piCommand: opts.piCommand,
39
+ piArgsPrefix: opts.piArgsPrefix,
40
+ defaultCwd: ctx.cwd,
41
+ // Stale-defaultModel launch guard (issue #90): live list, undefined
42
+ // when the registry is unavailable so validation conservatively skips.
43
+ availableModels: () => {
44
+ try {
45
+ return ctx.modelRegistry.getAvailable();
46
+ } catch {
47
+ return undefined;
48
+ }
49
+ },
50
+ });
51
+ // A session that isn't saved anywhere has nothing to come back to, but the board still opens.
52
+ if (!sessionFile) {
53
+ await dashboardAttachLoop(ctx, service, opts.root, null, opts.getThinkingLevel);
54
+ return;
55
+ }
56
+ const model = modelRef(ctx.model as any);
57
+ // adoptSession is async (routes through the view-state coordinator, issue #91).
58
+ const adopted = await service.adoptSession({
59
+ sessionFile,
60
+ cwd: ctx.cwd,
61
+ model,
62
+ thinkingLevel: opts.getThinkingLevel(),
63
+ // A row that exists keeps its name, which may be one you gave it.
64
+ name: hasOwnName(service, sessionFile) ? undefined : sessionName(ctx),
65
+ });
66
+ if (!adopted.ok || !adopted.viewId) {
67
+ ctx.ui.notify(adopted.error ?? "Could not background current session.", "warning");
68
+ return;
69
+ }
70
+ const prompt = String(args || "").trim();
71
+ if (prompt) {
72
+ const reply = service.queueFollowUp(adopted.viewId, prompt, { delivery: "queue", source: "bg-command" }) as { ok: boolean; error?: string };
73
+ if (!reply.ok) ctx.ui.notify(reply.error ?? "Could not queue background prompt.", "warning");
74
+ else ctx.ui.notify("Prompt queued for background session.", "info");
75
+ }
76
+ await dashboardAttachLoop(ctx, service, opts.root, adopted.viewId, opts.getThinkingLevel);
77
+ }
78
+
79
+ function hasOwnName(service: ReturnType<typeof createService>, sessionFile: string): boolean {
80
+ const row = service.rows().find((candidate) => candidate.meta.sessionFile === sessionFile);
81
+ return row !== undefined && row.meta.name !== NEW_SESSION;
82
+ }
83
+
84
+ /** Its name if it has one, then what you first asked. A session with neither is new. */
85
+ function sessionName(ctx: ExtensionCommandContext): string {
86
+ const named = ctx.sessionManager.getSessionName?.();
87
+ if (named) return named;
88
+ for (const entry of ctx.sessionManager.getEntries() as any[]) {
89
+ if (entry.type !== "message" || entry.message?.role !== "user") continue;
90
+ const content = entry.message.content;
91
+ const text = typeof content === "string" ? content : (content ?? []).map((part: any) => part.text ?? "").join(" ");
92
+ const line = String(text).trim().split("\n")[0];
93
+ if (line) return line.slice(0, 48);
94
+ }
95
+ return NEW_SESSION;
96
+ }
97
+
98
+ function modelRef(model: any): string | null {
99
+ if (!model || typeof model !== "object") return null;
100
+ if (typeof model.provider === "string" && typeof model.id === "string") return `${model.provider}/${model.id}`;
101
+ if (typeof model.id === "string") return model.id;
102
+ return null;
103
+ }
@@ -0,0 +1,21 @@
1
+ /** `/board-model`: which model writes the board's titles, summaries and states. */
2
+ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
3
+ import { readConfig, setHelperModel } from "../core/config.mjs";
4
+
5
+ const OFF = "Off: use built-in rules, no model calls";
6
+
7
+ export function registerBoardModelCommand(pi: ExtensionAPI): void {
8
+ pi.registerCommand("board-model", {
9
+ description: "Choose the model for the board's titles, summaries and states, or turn it off",
10
+ handler: async (_args, ctx: ExtensionCommandContext) => {
11
+ if (!ctx.hasUI) return;
12
+ const refs = ctx.modelRegistry.getAvailable().map((model) => `${model.provider}/${model.id}`);
13
+ const current = readConfig().model;
14
+ const choice = await ctx.ui.select(`Board helper model (now: ${current ?? "off"})`, [OFF, ...refs]);
15
+ if (choice === undefined) return;
16
+ const model = choice === OFF ? null : choice;
17
+ setHelperModel(model);
18
+ ctx.ui.notify(model ? `The board's helper calls now use ${model}.` : "The board's helper model calls are off.", "info");
19
+ },
20
+ });
21
+ }
@@ -0,0 +1,184 @@
1
+ /**
2
+ * Atomic + crash-tolerant file helpers used across the extension and the runner.
3
+ *
4
+ * Writes go to a temp sibling then `rename()` (atomic on the same filesystem) so a
5
+ * reader never observes a half-written JSON file. Reads tolerate missing/corrupt files.
6
+ */
7
+ import { randomBytes } from "node:crypto";
8
+ import {
9
+ appendFileSync,
10
+ existsSync,
11
+ mkdirSync,
12
+ readFileSync,
13
+ renameSync,
14
+ unlinkSync,
15
+ writeFileSync,
16
+ } from "node:fs";
17
+ import * as path from "node:path";
18
+
19
+ /** Retry backoff (ms) for rename retries; index i is the delay after attempt i. */
20
+ export const RENAME_RETRY_BACKOFF_MS = [10, 50, 250];
21
+ /** Error codes that mean "transient sharing violation" — retryable on Windows. */
22
+ export const RENAME_RETRY_ERROR_CODES = new Set(["EPERM", "EBUSY", "EACCES"]);
23
+
24
+ /** @param {string} dir */
25
+ export function ensureDir(dir) {
26
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
27
+ }
28
+
29
+ /**
30
+ * rename() with retries for Windows sharing-violation races.
31
+ *
32
+ * On Windows, libuv opens files without FILE_SHARE_DELETE, so replacing an
33
+ * existing target while any other process holds it open for reading throws
34
+ * EPERM/EBUSY/EACCES. The reader window is microseconds, so a few retries
35
+ * with a short backoff succeed almost always. Non-whitelisted errors
36
+ * (e.g. EISDIR) are configuration errors and throw immediately.
37
+ * @param {string} tmp
38
+ * @param {string} file
39
+ * @param {{ rename?: (a: string, b: string) => void, delays?: number[], errorCodes?: Set<string> }} [opts]
40
+ */
41
+ export function renameWithRetry(tmp, file, opts = {}) {
42
+ const rename = opts.rename ?? renameSync;
43
+ const delays = opts.delays ?? RENAME_RETRY_BACKOFF_MS;
44
+ const errorCodes = opts.errorCodes ?? RENAME_RETRY_ERROR_CODES;
45
+ for (let attempt = 0; ; attempt++) {
46
+ try {
47
+ rename(tmp, file);
48
+ return;
49
+ } catch (err) {
50
+ const code = /** @type {NodeJS.ErrnoException} */ (err).code;
51
+ if (!errorCodes.has(code) || attempt >= delays.length) {
52
+ // Exhausted or non-transient: leave no .tmp litter behind, then
53
+ // surface the original error so callers can degrade deliberately.
54
+ try { unlinkSync(tmp); } catch { /* best effort */ }
55
+ throw err;
56
+ }
57
+ }
58
+ // Synchronous sleep (Atomics.wait) — atomicWrite is a sync API.
59
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, delays[attempt]);
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Atomically write a string to `file` (creates parent dirs).
65
+ * @param {string} file
66
+ * @param {string} data
67
+ */
68
+ export function atomicWrite(file, data) {
69
+ ensureDir(path.dirname(file));
70
+ const tmp = `${file}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
71
+ writeFileSync(tmp, data, "utf8");
72
+ renameWithRetry(tmp, file);
73
+ }
74
+
75
+ /**
76
+ * Atomically write a value as pretty JSON.
77
+ * @param {string} file
78
+ * @param {unknown} value
79
+ */
80
+ export function atomicWriteJson(file, value) {
81
+ atomicWrite(file, `${JSON.stringify(value, null, 2)}\n`);
82
+ }
83
+
84
+ /**
85
+ * Read and parse JSON, returning `fallback` when the file is missing or unparseable.
86
+ * @template T
87
+ * @param {string} file
88
+ * @param {T} fallback
89
+ * @returns {T}
90
+ */
91
+ export function readJson(file, fallback) {
92
+ try {
93
+ if (!existsSync(file)) return fallback;
94
+ const raw = readFileSync(file, "utf8");
95
+ if (!raw.trim()) return fallback;
96
+ return /** @type {T} */ (JSON.parse(raw));
97
+ } catch {
98
+ return fallback;
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Append one line (a trailing newline is added) to a log/jsonl file.
104
+ * @param {string} file
105
+ * @param {string} line
106
+ */
107
+ export function appendLine(file, line) {
108
+ ensureDir(path.dirname(file));
109
+ appendFileSync(file, line.endsWith("\n") ? line : `${line}\n`, "utf8");
110
+ }
111
+
112
+ /**
113
+ * Append one JSON value as a JSONL record.
114
+ * @param {string} file
115
+ * @param {unknown} value
116
+ */
117
+ export function appendJsonl(file, value) {
118
+ appendLine(file, JSON.stringify(value));
119
+ }
120
+
121
+ /**
122
+ * Read a JSONL file into parsed objects, skipping blank/corrupt lines.
123
+ * @param {string} file
124
+ * @returns {any[]}
125
+ */
126
+ export function readJsonl(file) {
127
+ try {
128
+ if (!existsSync(file)) return [];
129
+ const out = [];
130
+ for (const line of readFileSync(file, "utf8").split("\n")) {
131
+ const t = line.trim();
132
+ if (!t) continue;
133
+ try {
134
+ out.push(JSON.parse(t));
135
+ } catch {
136
+ /* skip corrupt line */
137
+ }
138
+ }
139
+ return out;
140
+ } catch {
141
+ return [];
142
+ }
143
+ }
144
+
145
+ /**
146
+ * Read the newest `limit` JSONL records. Corrupt lines are skipped.
147
+ * @param {string} file
148
+ * @param {number} [limit]
149
+ * @returns {any[]}
150
+ */
151
+ export function readJsonlTail(file, limit = 50) {
152
+ const all = readJsonl(file);
153
+ const n = Math.max(0, Math.floor(Number(limit) || 0));
154
+ return n > 0 ? all.slice(-n) : all;
155
+ }
156
+
157
+ /**
158
+ * Best-effort delete helper used by cleanup actions.
159
+ * @param {string} file
160
+ * @returns {boolean}
161
+ */
162
+ export function removeFile(file) {
163
+ try {
164
+ if (!existsSync(file)) return false;
165
+ unlinkSync(file);
166
+ return true;
167
+ } catch {
168
+ return false;
169
+ }
170
+ }
171
+
172
+ /**
173
+ * Read a file's text, returning `fallback` if missing.
174
+ * @param {string} file
175
+ * @param {string} [fallback]
176
+ * @returns {string}
177
+ */
178
+ export function readText(file, fallback = "") {
179
+ try {
180
+ return existsSync(file) ? readFileSync(file, "utf8") : fallback;
181
+ } catch {
182
+ return fallback;
183
+ }
184
+ }
@@ -0,0 +1,24 @@
1
+ import { Key, matchesKey } from "@earendil-works/pi-tui";
2
+
3
+ /**
4
+ * Issue #147: key resolution for the in-attach delete gesture. Pure — never
5
+ * reads socket/connection/editor state, so the gesture works even while the
6
+ * host is down. Second ctrl+x is swallowed (cancel-swallow, user adjudication
7
+ * 2026-10-10): the chord is a confirm toggle here, and forwarding it would
8
+ * inject one message.copy into the child Pi.
9
+ * @param {{ confirmArmed: boolean }} state
10
+ * @param {string} data raw key chunk
11
+ * @returns {{ action: "arm" | "confirm" | "cancel" | "cancel-swallow" | "forward", confirmArmed: boolean }}
12
+ */
13
+ export function resolveAttachDeleteKey(state, data) {
14
+ if (matchesKey(data, Key.ctrl("x"))) {
15
+ return state.confirmArmed ? { action: "cancel-swallow", confirmArmed: false } : { action: "arm", confirmArmed: true };
16
+ }
17
+ if (state.confirmArmed) {
18
+ if (data === "y" || data === "Y" || matchesKey(data, Key.enter)) {
19
+ return { action: "confirm", confirmArmed: false };
20
+ }
21
+ return { action: "cancel", confirmArmed: false };
22
+ }
23
+ return { action: "forward", confirmArmed: false };
24
+ }