@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,203 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Detached auto-state classifier.
4
+ *
5
+ * Reads the latest assistant turn from evidence/state, asks a cheap model to classify
6
+ * the terminal bucket, and updates state.json (and status.json when a run id exists).
7
+ * Safe best-effort: on model failure it falls back to the same heuristic classifier.
8
+ */
9
+ import { spawn } from "node:child_process";
10
+ import { readJson } from "../src/core/atomic.mjs";
11
+ import { appendDiagnostic } from "../src/core/diagnostics.mjs";
12
+ import { applyAutoStateToStatus, applyAutoStateToViewState, autoStateEnabled, autoStateFromModelOrHeuristic, autoStateModel, buildAutoStatePrompt, heuristicAutoState, isManualVerdict } from "../src/core/auto-state.mjs";
13
+ import { finalizeEvidence, readEvidence, summarizeEvidence, writeEvidence } from "../src/core/evidence.mjs";
14
+ import { updateCodeRefsFromEvidence } from "../src/core/code-refs-store.mjs";
15
+ import { readState, readStatus, readMeta, writeState, writeStatus } from "../src/core/store.mjs";
16
+ import { sendStateCommand } from "../src/core/coordinator-client.mjs";
17
+ import { commandRejectDiagnostic } from "../src/core/state-commands.mjs";
18
+
19
+ async function main() {
20
+ const configPath = process.argv[2];
21
+ if (!configPath) process.exit(2);
22
+ /** @type {import("../src/core/types.mjs").AutoStateConfig|null} */
23
+ const config = readJson(configPath, null);
24
+ if (!config || !autoStateEnabled()) process.exit(0);
25
+
26
+ // Read the view meta once so code-ref extraction reuses it on the evidence write.
27
+ const meta = readMeta(config.root, config.viewId);
28
+
29
+ const state = readState(config.root, config.viewId);
30
+ if (!state || state.processState === "alive" || state.semanticState === "failed" || state.semanticState === "stopped") process.exit(0);
31
+ // Cheap pre-check (optimization only): skip a pointless command when the
32
+ // manual verdict is already materialized. The coordinator's manual_fence
33
+ // stays the authoritative guard for races after this read.
34
+ if (isManualVerdict(state)) process.exit(0);
35
+
36
+ const evidence = readEvidence(config.root, config.viewId);
37
+ const latest = latestEvidenceText(evidence) || state.latestAssistantPreview || state.summary || "";
38
+ if (!latest.trim()) process.exit(0);
39
+
40
+ if (state.autoState?.source === "model" && state.autoState.textHash === heuristicAutoState(latest).textHash) process.exit(0);
41
+
42
+ const model = autoStateModel();
43
+ let classification = heuristicAutoState(latest, { lastAgentActivityAt: state.lastAgentActivityAt ?? null });
44
+ if (model) {
45
+ const prompt = buildAutoStatePrompt(latest);
46
+ const out = await runOneShot(
47
+ config.piCommand,
48
+ [...config.piArgsPrefix, "--mode", "json", "-p", "--no-session", "--model", model, prompt],
49
+ { timeoutMs: 15000, cwd: config.cwd, env: sanitizedEnv() },
50
+ );
51
+ classification = autoStateFromModelOrHeuristic(out, latest, { lastAgentActivityAt: state.lastAgentActivityAt ?? null });
52
+ }
53
+
54
+ // Issue #91 (A8 path 2): the classification lands through the View State
55
+ // Coordinator — the single writer of state.json/status.json. A manual
56
+ // completion is fenced by the coordinator (manual_fence), so this late pass
57
+ // can no longer clobber the user's verdict (#46 class). Ambiguous transport
58
+ // outcomes (timeout / connection_reset) NEVER fall back to a direct write:
59
+ // the command may already be journaled, and the coordinator's boot replay
60
+ // is the recovery path.
61
+ const result = await sendStateCommand(config.root, {
62
+ type: "state_command",
63
+ viewId: config.viewId,
64
+ runId: config.runId ?? null,
65
+ source: "state-runner",
66
+ kind: "auto_state_classified",
67
+ expectedRevision: null,
68
+ payload: { classification },
69
+ });
70
+
71
+ if (result.status === "applied") {
72
+ appendDiagnostic(config.root, config.viewId, { source: "service", runId: config.runId, code: "auto_state_classified", message: "Auto-state classifier updated row state", details: { kind: classification.kind, confidence: classification.confidence, source: classification.source, reason: classification.reason } });
73
+ } else if (result.reason !== "coordinator_disabled") {
74
+ // Unified decided/ambiguous classification (CR r2 issue-3): decided
75
+ // rejects (manual_fence/stale_run/…) log as info *_skipped — the
76
+ // coordinator's verdict is authoritative; only genuinely ambiguous
77
+ // outcomes (timeout/connection_reset) keep the recovery-path warn.
78
+ appendDiagnostic(config.root, config.viewId, { source: "service", runId: config.runId, ...commandRejectDiagnostic("auto_state", "Auto-state classification", result.reason, "otherwise the next classification pass will converge the row"), details: { reason: result.reason } });
79
+ }
80
+
81
+ if (result.reason === "coordinator_disabled") {
82
+ // Legacy escape hatch (AGENT_BOARD_COORDINATOR=off): pre-coordinator
83
+ // direct-write behavior, unchanged.
84
+ let changed = false;
85
+ if (config.runId) {
86
+ const status = readStatus(config.root, config.viewId, config.runId);
87
+ if (status) {
88
+ changed = applyAutoStateToStatus(status, classification, Date.now()) || changed;
89
+ status.evidenceSummary = summarizeEvidence(finalizeEvidence(evidence, status, Date.now()));
90
+ writeStatus(config.root, status);
91
+ }
92
+ }
93
+ const latestState = readState(config.root, config.viewId) ?? state;
94
+ changed = applyAutoStateToViewState(latestState, classification, Date.now()) || changed;
95
+ finalizeEvidence(evidence, { semanticState: latestState.semanticState, usage: null }, Date.now());
96
+ latestState.review = summarizeEvidence(evidence);
97
+ writeEvidence(config.root, evidence);
98
+ updateCodeRefsFromEvidence(config.root, config.viewId, evidence, meta);
99
+ writeState(config.root, latestState);
100
+ if (changed) {
101
+ appendDiagnostic(config.root, config.viewId, { source: "service", runId: config.runId, code: "auto_state_classified", message: "Auto-state classifier updated row state", details: { kind: classification.kind, confidence: classification.confidence, source: classification.source, reason: classification.reason } });
102
+ }
103
+ process.exit(0);
104
+ }
105
+
106
+ // Evidence pipeline (coordinator-independent) stays direct: finalize the
107
+ // evidence snapshot from a FRESH state read (reads are not writes — the
108
+ // coordinator owns state.json/status.json writes, not reads), then persist
109
+ // the evidence artifacts and code-refs.
110
+ const postState = readState(config.root, config.viewId);
111
+ finalizeEvidence(evidence, { semanticState: postState?.semanticState ?? state.semanticState, usage: null }, Date.now());
112
+ writeEvidence(config.root, evidence);
113
+ updateCodeRefsFromEvidence(config.root, config.viewId, evidence, meta);
114
+
115
+ // Issue #91 PR #2 (Task 4): the evidence mirrors move behind the coordinator
116
+ // too — one patch_fields command materializes review/evidenceSummary on both
117
+ // files under a shared materializedRevision (previously two separately
118
+ // fenced direct writes). The coordinator's generic manual_fence / stale_run
119
+ // guards are authoritative; ambiguous outcomes (timeout / connection_reset)
120
+ // NEVER fall back to a direct write — if the command was journaled, boot
121
+ // replay recovers it, and the next classification pass re-derives mirrors
122
+ // from the evidence files either way.
123
+ const mirrorSummary = summarizeEvidence(evidence);
124
+ const patch = await sendStateCommand(config.root, {
125
+ type: "state_command",
126
+ viewId: config.viewId,
127
+ runId: config.runId ?? null,
128
+ source: "state-runner",
129
+ kind: "patch_fields",
130
+ expectedRevision: null,
131
+ payload: { state: { review: mirrorSummary }, status: { evidenceSummary: mirrorSummary } },
132
+ });
133
+ if (patch.status === "applied") {
134
+ // Quiet success — mirrors materialized by the coordinator.
135
+ } else if (patch.reason === "manual_fence" || patch.reason === "stale_run" || patch.reason === "no_change") {
136
+ // Designed fences — informational: a manual verdict or a newer run owns
137
+ // the row, or the mirrors already match.
138
+ appendDiagnostic(config.root, config.viewId, { source: "service", runId: config.runId, code: "evidence_mirror_patch_skipped", message: `Evidence mirror patch not applied (${patch.reason})`, details: { reason: patch.reason } });
139
+ } else {
140
+ appendDiagnostic(config.root, config.viewId, { source: "service", runId: config.runId, level: "warn", code: "evidence_mirror_patch_ambiguous", message: `Evidence mirror patch outcome unknown (${patch.reason}); if the command was journaled, coordinator replay will recover it; the next classification pass re-derives the mirrors from the evidence files`, details: { reason: patch.reason } });
141
+ }
142
+ }
143
+
144
+ /** @param {import("../src/core/types.mjs").EvidenceSnapshot} evidence */
145
+ function latestEvidenceText(evidence) {
146
+ return evidence.assistantEvidence?.[evidence.assistantEvidence.length - 1]?.text ?? "";
147
+ }
148
+
149
+ function sanitizedEnv() {
150
+ const env = { ...process.env };
151
+ delete env.AGENT_BOARD_CHILD;
152
+ delete env.AGENT_VIEW_CHILD;
153
+ delete env.AGENT_BOARD_VIEW_ID;
154
+ delete env.AGENT_VIEW_VIEW_ID;
155
+ delete env.AGENT_BOARD_HOSTED;
156
+ delete env.AGENT_VIEW_HOSTED;
157
+ // Board-spawned classifier workers must not arm the warm-host sweeper
158
+ // (they share the board root with real hosts).
159
+ env.AGENT_BOARD_NO_SWEEP = "1";
160
+ return env;
161
+ }
162
+
163
+ /**
164
+ * @param {string} command
165
+ * @param {string[]} args
166
+ * @param {{ timeoutMs?: number, cwd?: string, env?: NodeJS.ProcessEnv }} [opts]
167
+ */
168
+ function runOneShot(command, args, opts = {}) {
169
+ return new Promise((resolve) => {
170
+ let out = "";
171
+ let settled = false;
172
+ const child = spawn(command, args, { cwd: opts.cwd, env: opts.env ?? process.env, stdio: ["ignore", "pipe", "ignore"], windowsHide: true });
173
+ let buf = "";
174
+ const finish = () => {
175
+ if (settled) return;
176
+ settled = true;
177
+ resolve(out);
178
+ };
179
+ child.stdout.on("data", (c) => {
180
+ buf += c.toString();
181
+ const lines = buf.split("\n");
182
+ buf = lines.pop() ?? "";
183
+ for (const line of lines) {
184
+ try {
185
+ const e = JSON.parse(line);
186
+ if (e?.type === "message_end" && e.message?.role === "assistant") {
187
+ for (const b of e.message.content ?? []) if (b.type === "text") out += b.text;
188
+ }
189
+ } catch {
190
+ /* ignore */
191
+ }
192
+ }
193
+ });
194
+ child.on("close", finish);
195
+ child.on("error", finish);
196
+ setTimeout(() => {
197
+ try { child.kill("SIGKILL"); } catch {}
198
+ finish();
199
+ }, opts.timeoutMs ?? 20000).unref?.();
200
+ });
201
+ }
202
+
203
+ main().catch(() => process.exit(0));
@@ -0,0 +1,80 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Detached title-runner shim.
4
+ *
5
+ * Best-effort only: generates a short GPT-4o title from the initial task prompt and updates
6
+ * the row's meta name if the user has not already renamed it.
7
+ */
8
+ import { spawn } from "node:child_process";
9
+ import { readJson } from "../src/core/atomic.mjs";
10
+ import { readMeta, writeMeta } from "../src/core/store.mjs";
11
+ import { DEFAULT_TITLE_MODEL, DEFAULT_TITLE_THINKING_LEVEL, normalizeGeneratedTitle, titlePrompt } from "../src/core/title.mjs";
12
+
13
+ function main() {
14
+ const configPath = process.argv[2];
15
+ if (!configPath) process.exit(2);
16
+ /** @type {import("../src/core/types.mjs").TitleConfig|null} */
17
+ const config = readJson(configPath, null);
18
+ if (!config?.viewId || !config.prompt) process.exit(2);
19
+
20
+ maybeGenerateTitle(config)
21
+ .catch(() => {})
22
+ .finally(() => process.exit(0));
23
+ }
24
+
25
+ async function maybeGenerateTitle(config) {
26
+ const configured = process.env.AGENT_BOARD_TITLE_MODEL ?? process.env.AGENT_VIEW_TITLE_MODEL;
27
+ if (configured === "off") return;
28
+ const model = configured ?? config.model ?? DEFAULT_TITLE_MODEL;
29
+ const thinking = process.env.AGENT_BOARD_TITLE_THINKING_LEVEL ?? process.env.AGENT_VIEW_TITLE_THINKING_LEVEL ?? DEFAULT_TITLE_THINKING_LEVEL;
30
+ const prompt = titlePrompt(config.prompt);
31
+ const args = [...config.piArgsPrefix, "--mode", "json", "-p", "--no-session", "--model", model];
32
+ if (thinking && thinking !== "off") args.push("--thinking", thinking);
33
+ args.push(prompt);
34
+ const out = await runOneShot(config.piCommand, args, 15000);
35
+ const title = normalizeGeneratedTitle(out.trim().split("\n").slice(-1)[0]?.trim(), config.fallbackName);
36
+ if (!title || title === config.fallbackName) return;
37
+
38
+ const meta = readMeta(config.root, config.viewId);
39
+ if (!meta) return;
40
+ if (meta.name !== config.fallbackName) return;
41
+ meta.name = title;
42
+ writeMeta(config.root, meta);
43
+ }
44
+
45
+ function runOneShot(command, args, timeoutMs = 20000) {
46
+ return new Promise((resolve) => {
47
+ let out = "";
48
+ const child = spawn(command, args, { stdio: ["ignore", "pipe", "ignore"], windowsHide: true, env: { ...process.env, AGENT_BOARD_NO_SWEEP: "1" } });
49
+ let buf = "";
50
+ child.stdout.on("data", (c) => {
51
+ buf += c.toString();
52
+ const lines = buf.split("\n");
53
+ buf = lines.pop() ?? "";
54
+ for (const line of lines) {
55
+ try {
56
+ const e = JSON.parse(line);
57
+ if (e?.type === "message_end" && e.message?.role === "assistant") {
58
+ for (const b of e.message.content ?? []) {
59
+ if (b.type === "text") out += b.text;
60
+ }
61
+ }
62
+ } catch {
63
+ /* ignore */
64
+ }
65
+ }
66
+ });
67
+ child.on("close", () => resolve(out));
68
+ child.on("error", () => resolve(""));
69
+ setTimeout(() => {
70
+ try {
71
+ child.kill("SIGKILL");
72
+ } catch {
73
+ /* ignore */
74
+ }
75
+ resolve(out);
76
+ }, timeoutMs).unref?.();
77
+ });
78
+ }
79
+
80
+ main();
@@ -0,0 +1,394 @@
1
+ /**
2
+ * `/agent-board` command: opens the dashboard, runs its action loop, and attaches through
3
+ * agent-board PTY hosts for fast switching. `ctx.switchSession` is only a no-PTY fallback.
4
+ * Also wires dispatch+attach and stale-row recovery on open.
5
+ */
6
+ import { holdInputListeners } from "../core/hold-input.mjs";
7
+ import { restoreChrome } from "../ui/chrome.ts";
8
+ import { existsSync } from "node:fs";
9
+ import { resolve } from "node:path";
10
+ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
11
+ import { Key, matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
12
+ import { requestDashboardRender } from "../core/dashboard-render.mjs";
13
+ import { nextVisibleIdAfterRemoval } from "../core/rows.mjs";
14
+ import { createService } from "../runtime/service.mjs";
15
+ import { boardCommands } from "../core/slash-commands.mjs";
16
+ import { planAttachPrelude, planAttachResolved } from "./attach-decision.mjs";
17
+ import { screenLogPath } from "../core/paths.mjs";
18
+ import { appendDiagnostic } from "../core/diagnostics.mjs";
19
+ import { DashboardComponent, type DashboardResult } from "../ui/dashboard.ts";
20
+ import { PtyAttachComponent, type PtyAttachResult } from "../ui/pty-attach.ts";
21
+
22
+ const POLL_MS = 700;
23
+
24
+ export interface AgentBoardCommandOptions {
25
+ root: string;
26
+ runnerScript: string;
27
+ ptyRunnerScript?: string;
28
+ titleRunnerScript?: string;
29
+ piCommand: string;
30
+ piArgsPrefix: string[];
31
+ getThinkingLevel: () => "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
32
+ }
33
+
34
+ const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
35
+
36
+ let addedCommands: () => { name: string; description?: string; source: string }[] = () => [];
37
+
38
+ export function registerAgentBoardCommand(pi: ExtensionAPI, opts: AgentBoardCommandOptions): void {
39
+ addedCommands = () => pi.getCommands();
40
+ pi.registerCommand("agent-board", {
41
+ description: "Open the background agent-board dashboard",
42
+ handler: async (args, ctx) => {
43
+ const attachMatch = /(?:^|\s)--attach\s+(\S+)/.exec(args);
44
+ const stopFirst = /(^|\s)--stop(\s|$)/.test(args);
45
+ const service = createService({
46
+ root: opts.root,
47
+ runnerScript: opts.runnerScript,
48
+ ptyRunnerScript: opts.ptyRunnerScript,
49
+ titleRunnerScript: opts.titleRunnerScript,
50
+ piCommand: opts.piCommand,
51
+ piArgsPrefix: opts.piArgsPrefix,
52
+ defaultCwd: ctx.cwd,
53
+ // Stale-defaultModel launch guard (issue #90): live list, undefined
54
+ // when the registry is unavailable so validation conservatively skips.
55
+ availableModels: () => {
56
+ try {
57
+ return ctx.modelRegistry.getAvailable();
58
+ } catch {
59
+ return undefined;
60
+ }
61
+ },
62
+ });
63
+
64
+ if (!ctx.hasUI) {
65
+ ctx.ui.notify("The agent-board dashboard requires interactive mode.", "warning");
66
+ return;
67
+ }
68
+
69
+ if (attachMatch) {
70
+ const outcome = await attach(ctx, service, opts.root, attachMatch[1], stopFirst);
71
+ if (outcome.action !== "switched") await dashboardAttachLoop(ctx, service, opts.root, attachMatch[1], opts.getThinkingLevel);
72
+ return;
73
+ }
74
+
75
+ await dashboardAttachLoop(ctx, service, opts.root, null, opts.getThinkingLevel);
76
+ },
77
+ });
78
+ }
79
+
80
+ export async function openDashboard(
81
+ ctx: Pick<ExtensionCommandContext, "ui" | "cwd" | "modelRegistry" | "model" | "sessionManager" | "shutdown">,
82
+ service: ReturnType<typeof createService>,
83
+ options: {
84
+ initialSelectedId?: string | null;
85
+ initialNotice?: string;
86
+ currentThinkingLevel?: "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
87
+ } = {},
88
+ ): Promise<DashboardResult> {
89
+ ctx.ui.setWorkingVisible(false);
90
+ ctx.ui.setHeader(() => ({ render: () => [], invalidate() {} }));
91
+ ctx.ui.setFooter(() => ({ render: () => [], invalidate() {} }));
92
+ ctx.ui.setTitle("agent-board");
93
+ let board: unknown = null;
94
+ let command: string | null = null;
95
+ let availableModels: any[] = [];
96
+ try {
97
+ availableModels = ctx.modelRegistry.getAvailable();
98
+ } catch {
99
+ availableModels = [];
100
+ }
101
+ try {
102
+ const result = await ctx.ui.custom<DashboardResult>(
103
+ (tui, theme, keybindings, done) => {
104
+ board = tui;
105
+ let interval: ReturnType<typeof setInterval> | null = null;
106
+ const releaseInput = holdInputListeners(tui);
107
+ const wrappedDone = (result: DashboardResult) => {
108
+ releaseInput();
109
+ if (interval) clearInterval(interval);
110
+ interval = null;
111
+ done(result);
112
+ };
113
+ const comp = new DashboardComponent(tui, theme as never, keybindings, wrappedDone, {
114
+ service,
115
+ root: service.getRoot(),
116
+ defaultCwd: ctx.cwd,
117
+ currentSessionFile: ctx.sessionManager?.getSessionFile?.() ?? null,
118
+ initialSelectedId: options.initialSelectedId,
119
+ initialNotice: options.initialNotice,
120
+ availableModels,
121
+ currentModel: ctx.model ?? null,
122
+ currentThinkingLevel: options.currentThinkingLevel ?? "off",
123
+ commands: boardCommands(addedCommands()),
124
+ });
125
+ interval = setInterval(() => {
126
+ void service.reconcile().catch(() => {});
127
+ comp.refresh();
128
+ requestDashboardRender(tui);
129
+ }, POLL_MS);
130
+ const withDispose = comp as DashboardComponent & { dispose: () => void };
131
+ // Chain the component's own dispose so its cleanup (prewarm scheduler
132
+ // cancel) still runs; a plain assignment would shadow the prototype method.
133
+ const origDispose = comp.dispose.bind(comp);
134
+ withDispose.dispose = () => {
135
+ origDispose();
136
+ releaseInput();
137
+ if (interval) clearInterval(interval);
138
+ interval = null;
139
+ };
140
+ return comp;
141
+ },
142
+ {
143
+ overlay: true,
144
+ overlayOptions: { anchor: "top-left", width: "100%", maxHeight: "100%", margin: 0 },
145
+ },
146
+ );
147
+ if (result.action === "quit") ctx.shutdown();
148
+ if (result.action === "command") command = result.text;
149
+ return result;
150
+ } finally {
151
+ restoreChrome(ctx);
152
+ // A command runs with more than an extension is given, so this goes the way a typed one does.
153
+ if (command) {
154
+ ctx.ui.setEditorText(command);
155
+ setTimeout(() => {
156
+ const focused = (board as { getFocusedComponent?: () => { handleInput?: (data: string) => void; getExpandedText?: () => string } | null } | null)?.getFocusedComponent?.();
157
+ // Only if it's the prompt holding the command. Otherwise it's left there to send by hand.
158
+ if (focused?.getExpandedText?.() === command) focused.handleInput?.("\r");
159
+ }, 0);
160
+ }
161
+ }
162
+ }
163
+
164
+ type AttachOutcome = { action: "detached" | "closed" | "switched" | "none"; afterDelete?: { nextSelectedId: string | null; notice: string } };
165
+
166
+ export async function dashboardAttachLoop(
167
+ ctx: ExtensionCommandContext,
168
+ service: ReturnType<typeof createService>,
169
+ root: string,
170
+ initialSelectedId: string | null,
171
+ getThinkingLevel?: () => "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max",
172
+ ): Promise<void> {
173
+ let selectedId = initialSelectedId;
174
+ let again = true;
175
+ let pendingNotice: string | undefined;
176
+ while (again) {
177
+ const currentId = currentViewId(ctx, service);
178
+ if (currentId) void service.markVisited?.(currentId)?.catch(() => {});
179
+ void service.reconcile().catch(() => {});
180
+ const result = await openDashboard(ctx, service, {
181
+ initialSelectedId: selectedId,
182
+ currentThinkingLevel: getThinkingLevel?.(),
183
+ initialNotice: pendingNotice,
184
+ });
185
+ pendingNotice = undefined;
186
+ if (result.action !== "attach") return;
187
+ // That session is the one on screen behind the board: going back to it is just closing.
188
+ if (result.viewId === currentId) return;
189
+ selectedId = result.viewId;
190
+ const outcome = await attach(ctx, service, root, result.viewId, result.stopFirst);
191
+ if (outcome.afterDelete) {
192
+ // Issue #147: land on the neighbor (null -> deleted id -> existing
193
+ // orderedIds[0] clamp) and show the Deleted notice once.
194
+ selectedId = outcome.afterDelete.nextSelectedId ?? selectedId;
195
+ pendingNotice = outcome.afterDelete.notice;
196
+ }
197
+ again = outcome.action === "detached" || outcome.action === "closed" || outcome.action === "none";
198
+ }
199
+ }
200
+
201
+ async function attach(
202
+ ctx: ExtensionCommandContext,
203
+ service: ReturnType<typeof createService>,
204
+ root: string,
205
+ viewId: string,
206
+ stopFirst: boolean,
207
+ ): Promise<AttachOutcome> {
208
+ const row = service.row(viewId);
209
+ if (!row) {
210
+ ctx.ui.notify("Session no longer exists.", "warning");
211
+ return { action: "none" };
212
+ }
213
+ const prelude = planAttachPrelude({ rowAlive: row.alive, rowHostActive: row.hostActive, stopFirst });
214
+ if (prelude.plan === "warn-running") {
215
+ ctx.ui.notify("Session is still running. Stop it before attaching, or confirm from the dashboard.", "warning");
216
+ return { action: "none" };
217
+ }
218
+ if (prelude.plan === "stop-first") {
219
+ service.stop(viewId);
220
+ // Give the runner a moment to terminate the worker and release the session file.
221
+ await sleep(500);
222
+ }
223
+
224
+ // Single async resolver entry: real probe + bounded recovery decide readiness (issue #70).
225
+ const plan = planAttachResolved(await service.resolveAttachTarget(viewId));
226
+ if (plan.plan === "open-pty") {
227
+ void service.markVisited?.(viewId)?.catch(() => {});
228
+ const result = await openPtyAttach(ctx, root, row.meta.id, row.meta.name, plan.socketPath);
229
+ void service.markVisited?.(viewId)?.catch(() => {});
230
+ if (result.action === "quit") {
231
+ ctx.shutdown();
232
+ return { action: "switched" }; // Nothing to come back to.
233
+ }
234
+ if (result.action === "delete") {
235
+ // Issue #147: the component already tore down and exited — archive
236
+ // here so the host's death is never rendered inside the attach view.
237
+ // Neighbor and name are computed BEFORE archiving (rows change).
238
+ const latest = service.row(viewId) ?? row;
239
+ const name = latest.meta.name;
240
+ const nextSelectedId = nextVisibleIdAfterRemoval(service.rows(), viewId, Date.now());
241
+ const res = await service.archive(viewId);
242
+ if (!res.ok) ctx.ui.notify(res.error ?? "Delete failed", "error");
243
+ else return { action: "detached", afterDelete: { nextSelectedId, notice: `Deleted "${name}"` } };
244
+ return { action: "detached" };
245
+ }
246
+ return { action: result.action === "closed" ? "closed" : "detached" };
247
+ }
248
+ if (plan.plan === "session-switch") {
249
+ const latest = service.row(viewId) ?? row;
250
+ if (!existsSync(latest.meta.sessionFile)) {
251
+ ctx.ui.notify("Session file isn't ready yet — try again once the run has started.", "warning");
252
+ return { action: "none" };
253
+ }
254
+ const name = latest.meta.name;
255
+ void service.markVisited?.(viewId)?.catch(() => {});
256
+ const switchingOverlay = await showSwitchingOverlay(ctx, name, "PTY unavailable");
257
+ const result = await ctx.switchSession(latest.meta.sessionFile, {
258
+ withSession: async (replaced) => {
259
+ replaced.ui.notify(`Attached to "${name}". Press ← on empty input to return to agent board.`, "info");
260
+ installBackToDashboard(replaced, service);
261
+ },
262
+ }).finally(() => {
263
+ try {
264
+ switchingOverlay?.hide();
265
+ } catch {}
266
+ });
267
+ if (result.cancelled) {
268
+ ctx.ui.notify("Attach cancelled.", "warning");
269
+ return { action: "none" };
270
+ }
271
+ return { action: "switched" };
272
+ }
273
+ if (plan.plan === "notify-pending") {
274
+ ctx.ui.notify(plan.reason, "info");
275
+ return { action: "none" };
276
+ }
277
+ ctx.ui.notify("Session no longer exists.", "warning");
278
+ return { action: "none" };
279
+ }
280
+
281
+ async function openPtyAttach(
282
+ ctx: ExtensionCommandContext,
283
+ root: string,
284
+ viewId: string,
285
+ name: string,
286
+ socketPath: string,
287
+ ): Promise<PtyAttachResult> {
288
+ ctx.ui.setWorkingVisible(false);
289
+ ctx.ui.setHeader(() => ({ render: () => [], invalidate() {} }));
290
+ ctx.ui.setFooter(() => ({ render: () => [], invalidate() {} }));
291
+ ctx.ui.setTitle(`agent-board: ${name}`);
292
+ try {
293
+ return await ctx.ui.custom<PtyAttachResult>(
294
+ (tui, theme, keybindings, done) =>
295
+ new PtyAttachComponent(tui, theme as never, keybindings, done, {
296
+ socketPath,
297
+ screenLogPath: root ? screenLogPath(root, viewId) : undefined,
298
+ title: name,
299
+ onDiagnostic: root
300
+ ? (event) => {
301
+ // Best effort, like every other diagnostics writer.
302
+ try {
303
+ appendDiagnostic(root, viewId, event as never);
304
+ } catch {
305
+ /* diagnostics must never break attach */
306
+ }
307
+ }
308
+ : undefined,
309
+ }),
310
+ {
311
+ overlay: true,
312
+ overlayOptions: { anchor: "top-left", width: "100%", maxHeight: "100%", margin: 0 },
313
+ },
314
+ );
315
+ } finally {
316
+ restoreChrome(ctx);
317
+ }
318
+ }
319
+
320
+ async function showSwitchingOverlay(ctx: ExtensionCommandContext, name: string, reason: string): Promise<{ hide(): void } | null> {
321
+ let handle: { hide(): void } | null = null;
322
+ void ctx.ui.custom<null>(
323
+ (tui, theme) => ({
324
+ render(width: number): string[] {
325
+ const height = tui.terminal?.rows ?? 24;
326
+ const out = Array.from({ length: Math.max(0, Math.floor(height / 2) - 2) }, () => "");
327
+ out.push(clipLine(theme.fg("accent", theme.bold(`Switching to "${name}"…`)), width));
328
+ out.push(clipLine(theme.fg("dim", `Starting fallback session switch (${reason})`), width));
329
+ while (out.length < height) out.push("");
330
+ return out;
331
+ },
332
+ invalidate() {},
333
+ }),
334
+ {
335
+ overlay: true,
336
+ overlayOptions: { anchor: "top-left", width: "100%", maxHeight: "100%", margin: 0 },
337
+ onHandle: (h) => {
338
+ handle = h;
339
+ },
340
+ },
341
+ );
342
+ await sleep(50);
343
+ return handle;
344
+ }
345
+
346
+ function clipLine(text: string, width: number): string {
347
+ return truncateToWidth(text, width);
348
+ }
349
+
350
+ function installBackToDashboard(ctx: ExtensionCommandContext, service: ReturnType<typeof createService>): void {
351
+ ctx.ui.setStatus("agent-board.back", ctx.ui.theme.fg("muted", "← board"));
352
+ let opening = false;
353
+ ctx.ui.onTerminalInput((data: string) => {
354
+ if (opening || !matchesKey(data, Key.left)) return undefined;
355
+ // Do not steal normal cursor-left while the user is composing a message.
356
+ if (ctx.ui.getEditorText().length > 0) return undefined;
357
+ opening = true;
358
+ void (async () => {
359
+ try {
360
+ let selectedId = currentViewId(ctx, service);
361
+ while (true) {
362
+ if (selectedId) void service.markVisited?.(selectedId)?.catch(() => {});
363
+ void service.reconcile().catch(() => {});
364
+ const result = await openDashboard(ctx, service, { initialSelectedId: selectedId });
365
+ if (result.action !== "attach") return;
366
+ selectedId = result.viewId;
367
+ const target = service.row(result.viewId);
368
+ const currentSessionFile = ctx.sessionManager.getSessionFile();
369
+ if (target && currentSessionFile && samePath(target.meta.sessionFile, currentSessionFile)) {
370
+ ctx.ui.notify("Already attached to this session.", "info");
371
+ continue;
372
+ }
373
+ const outcome = await attach(ctx, service, service.root, result.viewId, result.stopFirst);
374
+ if (outcome.action === "switched") return;
375
+ }
376
+ } catch (err) {
377
+ ctx.ui.notify(`Couldn't open agent board: ${err instanceof Error ? err.message : String(err)}`, "error");
378
+ } finally {
379
+ opening = false;
380
+ }
381
+ })();
382
+ return { consume: true };
383
+ });
384
+ }
385
+
386
+ function currentViewId(ctx: ExtensionCommandContext, service: ReturnType<typeof createService>): string | null {
387
+ const currentSessionFile = ctx.sessionManager.getSessionFile();
388
+ if (!currentSessionFile) return null;
389
+ return service.rows().find((r) => samePath(r.meta.sessionFile, currentSessionFile))?.meta.id ?? null;
390
+ }
391
+
392
+ function samePath(a: string, b: string): boolean {
393
+ return resolve(a) === resolve(b);
394
+ }