@crouton-kit/crouter 0.3.27 → 0.3.29

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 (343) hide show
  1. package/dist/build-root.js +4 -2
  2. package/dist/builtin-memory/crouter-development/marketplaces.md +7 -6
  3. package/dist/builtin-memory/crouter-development/personas/base-prompt.md +1 -1
  4. package/dist/builtin-memory/crouter-development/personas/orchestrator-prompt.md +1 -1
  5. package/dist/builtin-memory/crouter-development/personas.md +1 -1
  6. package/dist/builtin-memory/crouter-development/plugins.md +20 -18
  7. package/dist/builtin-memory/design.md +1 -1
  8. package/dist/builtin-memory/development.md +1 -1
  9. package/dist/builtin-memory/internal/INDEX.md +1 -1
  10. package/dist/builtin-memory/internal/examples/INDEX.md +1 -1
  11. package/dist/builtin-memory/internal/examples/imessage-assistant.md +2 -2
  12. package/dist/builtin-memory/internal/nodes-and-canvas.md +1 -1
  13. package/dist/builtin-memory/internal/storage-tiers.md +1 -1
  14. package/dist/builtin-memory/planning.md +1 -1
  15. package/dist/builtin-memory/spec.md +6 -4
  16. package/dist/builtin-personas/orchestration-kernel.md +4 -5
  17. package/dist/builtin-personas/runtime-base.md +8 -4
  18. package/dist/builtin-personas/spec/PERSONA.md +8 -4
  19. package/dist/builtin-personas/spec/orchestrator.md +5 -3
  20. package/dist/builtin-personas/spec/requirements/PERSONA.md +10 -0
  21. package/dist/builtin-views/canvas/core.mjs +586 -0
  22. package/dist/builtin-views/canvas/text.mjs +58 -0
  23. package/dist/builtin-views/canvas/tui.mjs +165 -0
  24. package/dist/builtin-views/canvas/web.jsx +120 -0
  25. package/dist/builtin-views/git-pr/core.mjs +673 -0
  26. package/dist/builtin-views/git-pr/text.mjs +84 -0
  27. package/dist/builtin-views/git-pr/tui.mjs +302 -0
  28. package/dist/builtin-views/git-pr/web.jsx +216 -0
  29. package/dist/builtin-views/inbox/_lib/render.mjs +1 -3
  30. package/dist/builtin-views/inbox/core.mjs +1273 -0
  31. package/dist/builtin-views/inbox/text.mjs +73 -0
  32. package/dist/builtin-views/inbox/tui.mjs +312 -0
  33. package/dist/builtin-views/inbox/web.jsx +188 -0
  34. package/dist/builtin-views/linkedin/core.mjs +906 -0
  35. package/dist/builtin-views/linkedin/text.mjs +69 -0
  36. package/dist/builtin-views/linkedin/tui.mjs +427 -0
  37. package/dist/builtin-views/linkedin/web.jsx +206 -0
  38. package/dist/builtin-views/workspace-sidebar/core.mjs +621 -0
  39. package/dist/builtin-views/workspace-sidebar/text.mjs +53 -0
  40. package/dist/builtin-views/workspace-sidebar/tui.mjs +142 -0
  41. package/dist/builtin-views/workspace-sidebar/web.jsx +109 -0
  42. package/dist/cli.js +3 -3
  43. package/dist/clients/attach/__tests__/action-parity.test.js +48 -0
  44. package/dist/clients/attach/__tests__/clipboard-image.test.js +76 -0
  45. package/dist/clients/attach/__tests__/editor-newline.test.js +57 -0
  46. package/dist/clients/attach/__tests__/git-info.test.js +31 -0
  47. package/dist/clients/attach/__tests__/onrequest-wired.test.js +20 -0
  48. package/dist/clients/attach/__tests__/reconnect-giveup.test.js +30 -0
  49. package/dist/clients/attach/__tests__/slash-quit-copy.test.js +42 -0
  50. package/dist/clients/attach/__tests__/titled-editor.test.d.ts +1 -0
  51. package/dist/clients/attach/__tests__/titled-editor.test.js +39 -0
  52. package/dist/clients/attach/attach-cmd.js +445 -41
  53. package/dist/clients/attach/auth-pickers.d.ts +9 -0
  54. package/dist/clients/attach/auth-pickers.js +194 -0
  55. package/dist/clients/attach/canvas-panels.d.ts +12 -0
  56. package/dist/clients/attach/canvas-panels.js +78 -0
  57. package/dist/clients/attach/chat-view.d.ts +27 -2
  58. package/dist/clients/attach/chat-view.js +98 -9
  59. package/dist/clients/attach/clipboard-image.d.ts +47 -8
  60. package/dist/clients/attach/clipboard-image.js +150 -26
  61. package/dist/clients/attach/clipboard-text.d.ts +3 -0
  62. package/dist/clients/attach/clipboard-text.js +39 -0
  63. package/dist/clients/attach/config-load.d.ts +54 -7
  64. package/dist/clients/attach/config-load.js +99 -3
  65. package/dist/clients/attach/context-message.d.ts +12 -0
  66. package/dist/clients/attach/context-message.js +72 -0
  67. package/dist/clients/attach/git-info.d.ts +20 -0
  68. package/dist/clients/attach/git-info.js +44 -0
  69. package/dist/clients/attach/graph-overlay.d.ts +43 -0
  70. package/dist/clients/attach/graph-overlay.js +306 -0
  71. package/dist/clients/attach/input-controller.d.ts +81 -13
  72. package/dist/clients/attach/input-controller.js +307 -67
  73. package/dist/clients/attach/pickers.d.ts +58 -0
  74. package/dist/clients/attach/pickers.js +171 -0
  75. package/dist/clients/attach/slash-commands.d.ts +44 -1
  76. package/dist/clients/attach/slash-commands.js +250 -27
  77. package/dist/clients/attach/titled-editor.d.ts +33 -0
  78. package/dist/clients/attach/titled-editor.js +84 -0
  79. package/dist/clients/attach/view-socket.d.ts +37 -2
  80. package/dist/clients/attach/view-socket.js +131 -7
  81. package/dist/clients/web/dev-server.d.ts +7 -0
  82. package/dist/clients/web/dev-server.js +59 -0
  83. package/dist/clients/web/events.d.ts +14 -0
  84. package/dist/clients/web/events.js +151 -0
  85. package/dist/clients/web/server.d.ts +18 -0
  86. package/dist/clients/web/server.js +450 -0
  87. package/dist/clients/web/web-cmd.d.ts +2 -0
  88. package/dist/clients/web/web-cmd.js +120 -0
  89. package/dist/commands/canvas.js +1 -2
  90. package/dist/commands/chord.js +1 -1
  91. package/dist/commands/dashboard.js +5 -1
  92. package/dist/commands/human/prompts.js +3 -3
  93. package/dist/commands/human/queue.d.ts +17 -0
  94. package/dist/commands/human/queue.js +111 -4
  95. package/dist/commands/memory/__tests__/lint-schema.test.js +27 -4
  96. package/dist/commands/memory/lint.d.ts +5 -4
  97. package/dist/commands/memory/lint.js +23 -7
  98. package/dist/commands/memory/read.js +2 -2
  99. package/dist/commands/memory/shared.d.ts +7 -1
  100. package/dist/commands/memory/shared.js +18 -3
  101. package/dist/commands/memory/write.js +20 -7
  102. package/dist/commands/memory.js +4 -4
  103. package/dist/commands/node.d.ts +1 -1
  104. package/dist/commands/node.js +64 -65
  105. package/dist/commands/pkg.js +1 -2
  106. package/dist/commands/revive.js +11 -3
  107. package/dist/commands/sys/feedback.d.ts +1 -0
  108. package/dist/commands/sys/feedback.js +163 -0
  109. package/dist/commands/sys/sync.d.ts +1 -0
  110. package/dist/commands/sys/sync.js +187 -0
  111. package/dist/commands/sys.js +4 -2
  112. package/dist/commands/view-cycle.js +2 -2
  113. package/dist/commands/view-list.js +8 -8
  114. package/dist/commands/view-new.js +21 -17
  115. package/dist/commands/view-pick.js +1 -1
  116. package/dist/commands/view-run.js +35 -14
  117. package/dist/commands/view.js +8 -6
  118. package/dist/commands/workspace.d.ts +2 -0
  119. package/dist/commands/workspace.js +161 -0
  120. package/dist/core/__tests__/broker-double-spawn.test.d.ts +1 -0
  121. package/dist/core/__tests__/broker-double-spawn.test.js +143 -0
  122. package/dist/core/__tests__/broker-fork-seam.test.d.ts +1 -0
  123. package/dist/core/__tests__/broker-fork-seam.test.js +102 -0
  124. package/dist/core/__tests__/broker-sdk-wiring.test.js +2 -0
  125. package/dist/core/__tests__/broker-snapshot-history.test.d.ts +1 -0
  126. package/dist/core/__tests__/broker-snapshot-history.test.js +105 -0
  127. package/dist/core/__tests__/child-death-wake.test.js +56 -111
  128. package/dist/core/__tests__/close.test.js +14 -20
  129. package/dist/core/__tests__/context-intro.test.js +19 -19
  130. package/dist/core/__tests__/daemon-boot.test.js +30 -23
  131. package/dist/core/__tests__/draw-style.test.js +1 -1
  132. package/dist/core/__tests__/error-stall-recycle.test.d.ts +1 -0
  133. package/dist/core/__tests__/error-stall-recycle.test.js +141 -0
  134. package/dist/core/__tests__/fixtures/fake-engine.d.ts +24 -1
  135. package/dist/core/__tests__/fixtures/fake-engine.js +32 -2
  136. package/dist/core/__tests__/fixtures/fake-pi-host.js +2 -2
  137. package/dist/core/__tests__/focuses.test.js +25 -13
  138. package/dist/core/__tests__/fork.test.js +22 -5
  139. package/dist/core/__tests__/full/broker-control-preempt.test.d.ts +1 -0
  140. package/dist/core/__tests__/full/broker-control-preempt.test.js +61 -0
  141. package/dist/core/__tests__/full/broker-model-changed-broadcast.test.d.ts +1 -0
  142. package/dist/core/__tests__/full/broker-model-changed-broadcast.test.js +57 -0
  143. package/dist/core/__tests__/full/broker-navigate-tree-rewelcome.test.d.ts +1 -0
  144. package/dist/core/__tests__/full/broker-navigate-tree-rewelcome.test.js +80 -0
  145. package/dist/core/__tests__/full/broker-pane-resolution.test.js +90 -0
  146. package/dist/core/__tests__/full/cascade-close.test.js +22 -8
  147. package/dist/core/__tests__/full/detach-focus.test.js +66 -169
  148. package/dist/core/__tests__/helpers/harness.d.ts +6 -9
  149. package/dist/core/__tests__/helpers/harness.js +19 -36
  150. package/dist/core/__tests__/human-node-not-supervised.test.d.ts +1 -0
  151. package/dist/core/__tests__/human-node-not-supervised.test.js +83 -0
  152. package/dist/core/__tests__/human-stranded-deliver.test.d.ts +1 -0
  153. package/dist/core/__tests__/human-stranded-deliver.test.js +108 -0
  154. package/dist/core/__tests__/lifecycle.test.js +1 -19
  155. package/dist/core/__tests__/live-mutation-verbs.test.js +5 -5
  156. package/dist/core/__tests__/memory-resolver.test.js +1 -1
  157. package/dist/core/__tests__/on-read-dedup-resume.test.d.ts +1 -0
  158. package/dist/core/__tests__/on-read-dedup-resume.test.js +81 -0
  159. package/dist/core/__tests__/refresh-stall-recycle.test.d.ts +1 -0
  160. package/dist/core/__tests__/refresh-stall-recycle.test.js +88 -0
  161. package/dist/core/__tests__/relaunch-root.test.d.ts +1 -0
  162. package/dist/core/__tests__/relaunch-root.test.js +114 -0
  163. package/dist/core/__tests__/reset.test.js +19 -79
  164. package/dist/core/__tests__/steer-note.test.js +9 -7
  165. package/dist/core/__tests__/tmux-surface.test.js +13 -7
  166. package/dist/core/bootstrap.d.ts +0 -3
  167. package/dist/core/bootstrap.js +1 -143
  168. package/dist/core/canvas/browse/__tests__/model.test.js +86 -1
  169. package/dist/core/canvas/browse/__tests__/render.test.js +2 -1
  170. package/dist/core/canvas/browse/app.js +131 -8
  171. package/dist/core/canvas/browse/model.d.ts +23 -5
  172. package/dist/core/canvas/browse/model.js +68 -13
  173. package/dist/core/canvas/browse/render.d.ts +7 -1
  174. package/dist/core/canvas/browse/render.js +178 -47
  175. package/dist/core/canvas/canvas.js +1 -1
  176. package/dist/core/canvas/focuses.d.ts +4 -4
  177. package/dist/core/canvas/focuses.js +7 -7
  178. package/dist/core/canvas/nav-model.d.ts +162 -0
  179. package/dist/core/canvas/nav-model.js +487 -0
  180. package/dist/core/canvas/paths.d.ts +7 -0
  181. package/dist/core/canvas/paths.js +9 -0
  182. package/dist/core/canvas/render.d.ts +68 -9
  183. package/dist/core/canvas/render.js +195 -61
  184. package/dist/core/canvas/types.d.ts +16 -23
  185. package/dist/core/config.js +1 -4
  186. package/dist/core/memory-resolver.js +43 -5
  187. package/dist/core/runtime/bearings.d.ts +1 -1
  188. package/dist/core/runtime/bearings.js +8 -8
  189. package/dist/core/runtime/branded-host.d.ts +16 -0
  190. package/dist/core/runtime/branded-host.js +127 -0
  191. package/dist/core/runtime/broker-protocol.d.ts +212 -6
  192. package/dist/core/runtime/broker-sdk.d.ts +0 -12
  193. package/dist/core/runtime/broker-sdk.js +77 -6
  194. package/dist/core/runtime/broker.d.ts +5 -1
  195. package/dist/core/runtime/broker.js +520 -59
  196. package/dist/core/runtime/close.d.ts +12 -2
  197. package/dist/core/runtime/close.js +37 -16
  198. package/dist/core/runtime/front-door.js +14 -7
  199. package/dist/core/runtime/host.d.ts +14 -34
  200. package/dist/core/runtime/host.js +14 -50
  201. package/dist/core/runtime/launch.d.ts +12 -9
  202. package/dist/core/runtime/launch.js +27 -15
  203. package/dist/core/runtime/lifecycle.d.ts +1 -1
  204. package/dist/core/runtime/lifecycle.js +15 -19
  205. package/dist/core/runtime/nodes.d.ts +0 -55
  206. package/dist/core/runtime/nodes.js +6 -74
  207. package/dist/core/runtime/placement.d.ts +91 -334
  208. package/dist/core/runtime/placement.js +262 -828
  209. package/dist/core/runtime/promote.d.ts +2 -0
  210. package/dist/core/runtime/promote.js +24 -6
  211. package/dist/core/runtime/recap.d.ts +8 -0
  212. package/dist/core/runtime/recap.js +107 -0
  213. package/dist/core/runtime/recycle.js +25 -61
  214. package/dist/core/runtime/reset.d.ts +43 -37
  215. package/dist/core/runtime/reset.js +131 -218
  216. package/dist/core/runtime/revive.d.ts +9 -29
  217. package/dist/core/runtime/revive.js +45 -135
  218. package/dist/core/runtime/spawn.d.ts +15 -11
  219. package/dist/core/runtime/spawn.js +168 -136
  220. package/dist/core/runtime/surface-bg.d.ts +11 -0
  221. package/dist/core/runtime/surface-bg.js +68 -0
  222. package/dist/core/runtime/tmux.d.ts +15 -43
  223. package/dist/core/runtime/tmux.js +38 -80
  224. package/dist/core/scope.d.ts +1 -2
  225. package/dist/core/scope.js +1 -5
  226. package/dist/core/skill-sync/__tests__/dry-run-wrote-count.test.d.ts +1 -0
  227. package/dist/core/skill-sync/__tests__/dry-run-wrote-count.test.js +57 -0
  228. package/dist/core/skill-sync/builtins.d.ts +42 -0
  229. package/dist/core/skill-sync/builtins.js +112 -0
  230. package/dist/core/skill-sync/claude-plugins.d.ts +23 -0
  231. package/dist/core/skill-sync/claude-plugins.js +71 -0
  232. package/dist/core/skill-sync/engine.d.ts +42 -0
  233. package/dist/core/skill-sync/engine.js +633 -0
  234. package/dist/core/skill-sync/export.d.ts +23 -0
  235. package/dist/core/skill-sync/export.js +86 -0
  236. package/dist/core/skill-sync/manifest.d.ts +64 -0
  237. package/dist/core/skill-sync/manifest.js +181 -0
  238. package/dist/core/skill-sync/profile.d.ts +76 -0
  239. package/dist/core/skill-sync/profile.js +173 -0
  240. package/dist/core/skill-sync/snapshot.d.ts +57 -0
  241. package/dist/core/skill-sync/snapshot.js +120 -0
  242. package/dist/core/spawn.d.ts +14 -0
  243. package/dist/core/spawn.js +29 -9
  244. package/dist/core/substrate/index.d.ts +2 -2
  245. package/dist/core/substrate/index.js +7 -7
  246. package/dist/core/substrate/injected-store.d.ts +10 -0
  247. package/dist/core/substrate/injected-store.js +55 -0
  248. package/dist/core/substrate/on-read.js +68 -6
  249. package/dist/core/substrate/render.d.ts +8 -11
  250. package/dist/core/substrate/render.js +29 -43
  251. package/dist/core/substrate/schema.d.ts +16 -11
  252. package/dist/core/substrate/schema.js +32 -31
  253. package/dist/core/tui/host.d.ts +12 -4
  254. package/dist/core/tui/host.js +280 -149
  255. package/dist/core/view/bridge.d.ts +10 -0
  256. package/dist/core/view/bridge.js +31 -0
  257. package/dist/core/view/chrome.d.ts +9 -0
  258. package/dist/core/view/chrome.js +22 -0
  259. package/dist/core/view/contract.d.ts +171 -0
  260. package/dist/core/view/contract.js +23 -0
  261. package/dist/core/view/loader.d.ts +31 -0
  262. package/dist/core/view/loader.js +188 -0
  263. package/dist/core/view/transport-local.d.ts +7 -0
  264. package/dist/core/view/transport-local.js +70 -0
  265. package/dist/core/view/transport.d.ts +4 -0
  266. package/dist/core/view/transport.js +15 -0
  267. package/dist/daemon/crtrd.d.ts +38 -27
  268. package/dist/daemon/crtrd.js +303 -376
  269. package/dist/daemon/manage.js +6 -1
  270. package/dist/index.js +1 -1
  271. package/dist/pi-extensions/__tests__/canvas-stophook-agentend.test.js +28 -20
  272. package/dist/pi-extensions/canvas-context-intro.js +4 -4
  273. package/dist/pi-extensions/canvas-doc-substrate.js +29 -20
  274. package/dist/pi-extensions/canvas-nav.js +35 -385
  275. package/dist/pi-extensions/canvas-recap.d.ts +37 -0
  276. package/dist/pi-extensions/canvas-recap.js +236 -0
  277. package/dist/pi-extensions/canvas-resume.js +2 -1
  278. package/dist/pi-extensions/canvas-stophook.d.ts +15 -13
  279. package/dist/pi-extensions/canvas-stophook.js +119 -103
  280. package/dist/pi-extensions/canvas-view.js +2 -1
  281. package/dist/pi-extensions/widget-order-bus.d.ts +6 -0
  282. package/dist/pi-extensions/widget-order-bus.js +34 -0
  283. package/dist/prompts/view.d.ts +2 -2
  284. package/dist/prompts/view.js +145 -73
  285. package/dist/types.d.ts +1 -6
  286. package/dist/types.js +1 -3
  287. package/dist/web/ViewChrome.d.ts +7 -0
  288. package/dist/web/ViewChrome.js +28 -0
  289. package/dist/web/ViewPane.d.ts +39 -0
  290. package/dist/web/ViewPane.js +48 -0
  291. package/dist/web/index.d.ts +6 -0
  292. package/dist/web/index.js +16 -0
  293. package/dist/web/runtime.d.ts +39 -0
  294. package/dist/web/runtime.js +133 -0
  295. package/dist/web/states.d.ts +24 -0
  296. package/dist/web/states.js +24 -0
  297. package/dist/web/transport-http.d.ts +5 -0
  298. package/dist/web/transport-http.js +28 -0
  299. package/dist/web-client/assets/index-BUvQb4hR.css +2 -0
  300. package/dist/web-client/assets/index-ClLQXYAE.js +10 -0
  301. package/dist/web-client/index.html +13 -0
  302. package/package.json +20 -6
  303. package/dist/builtin-views/canvas/client.mjs +0 -303
  304. package/dist/builtin-views/canvas/view.mjs +0 -576
  305. package/dist/builtin-views/git-pr/client.mjs +0 -440
  306. package/dist/builtin-views/git-pr/view.mjs +0 -675
  307. package/dist/builtin-views/inbox/sources/gmail.mjs +0 -965
  308. package/dist/builtin-views/inbox/sources/linkedin.mjs +0 -427
  309. package/dist/builtin-views/inbox/view.mjs +0 -889
  310. package/dist/builtin-views/linkedin/client.mjs +0 -610
  311. package/dist/builtin-views/linkedin/view.mjs +0 -1171
  312. package/dist/commands/pkg/bridge.d.ts +0 -1
  313. package/dist/commands/pkg/bridge.js +0 -137
  314. package/dist/commands/skill/author.d.ts +0 -3
  315. package/dist/commands/skill/author.js +0 -140
  316. package/dist/commands/skill/shared.d.ts +0 -3
  317. package/dist/commands/skill/shared.js +0 -19
  318. package/dist/commands/skill.d.ts +0 -2
  319. package/dist/commands/skill.js +0 -21
  320. package/dist/commands/tmux-spread.d.ts +0 -2
  321. package/dist/commands/tmux-spread.js +0 -144
  322. package/dist/core/__tests__/full/daemon-liveness-pane.full.test.js +0 -292
  323. package/dist/core/__tests__/full/placement-focus.test.js +0 -309
  324. package/dist/core/__tests__/full/placement-reconcile.test.js +0 -212
  325. package/dist/core/__tests__/full/placement-revive.test.js +0 -238
  326. package/dist/core/__tests__/full/placement-teardown.test.js +0 -221
  327. package/dist/core/__tests__/home-session.test.js +0 -186
  328. package/dist/core/__tests__/relaunch.test.js +0 -335
  329. package/dist/core/bridge-map.d.ts +0 -19
  330. package/dist/core/bridge-map.js +0 -73
  331. package/dist/core/tui/contract.d.ts +0 -83
  332. package/dist/core/tui/contract.js +0 -8
  333. package/dist/core/tui/loader.d.ts +0 -16
  334. package/dist/core/tui/loader.js +0 -94
  335. package/dist/prompts/skill.d.ts +0 -2
  336. package/dist/prompts/skill.js +0 -650
  337. /package/dist/{core/__tests__/full/daemon-liveness-pane.full.test.d.ts → clients/attach/__tests__/action-parity.test.d.ts} +0 -0
  338. /package/dist/{core/__tests__/full/placement-focus.test.d.ts → clients/attach/__tests__/clipboard-image.test.d.ts} +0 -0
  339. /package/dist/{core/__tests__/full/placement-reconcile.test.d.ts → clients/attach/__tests__/editor-newline.test.d.ts} +0 -0
  340. /package/dist/{core/__tests__/full/placement-revive.test.d.ts → clients/attach/__tests__/git-info.test.d.ts} +0 -0
  341. /package/dist/{core/__tests__/full/placement-teardown.test.d.ts → clients/attach/__tests__/onrequest-wired.test.d.ts} +0 -0
  342. /package/dist/{core/__tests__/home-session.test.d.ts → clients/attach/__tests__/reconnect-giveup.test.d.ts} +0 -0
  343. /package/dist/{core/__tests__/relaunch.test.d.ts → clients/attach/__tests__/slash-quit-copy.test.d.ts} +0 -0
@@ -0,0 +1,906 @@
1
+ // @ts-check
2
+ /**
3
+ * LinkedIn Messages — the PORTABLE CORE of the crtr `linkedin` view (manifest ·
4
+ * init · sources · commands · intents). One core renders in BOTH targets: the
5
+ * tmux TUI (`crtr view run linkedin`, via `tui.mjs`) and the React+Tailwind web
6
+ * page (`crtr view serve linkedin`, via `web.jsx`).
7
+ *
8
+ * Runs in BOTH Node and the browser, so it imports NOTHING — no `node:*`, no
9
+ * crtr. The data layer is expressed as transport-agnostic `Source`
10
+ * (reads) and `Command` (writes) descriptors: the core describes WHAT to run
11
+ * (`request()` → a SourceRequest hitting the `capture` CLI), the host's
12
+ * Transport runs it (local `execFile` for the TUI, the HTTP bridge for web), and
13
+ * the pure `parse()` turns bytes → typed data | a typed `SourceError`.
14
+ *
15
+ * THE RECOVERY MACHINE LIVES HERE, ONCE. The discover→auth→settle state machine
16
+ * is a single implementation in the `refresh` intent, shared by both the
17
+ * linkedin view and the inbox view (which imports these descriptors). Every
18
+ * client failure is classified to a typed `SourceError` whose `display` payload
19
+ * BOTH presenters render VERBATIM — they map only `display.level` → glyph/hue,
20
+ * never branch on `kind`. The view never re-derives error copy.
21
+ *
22
+ * NOTHING throws. Sources/commands return a `Result<T>`; the recovery machine
23
+ * maps a blocking error to a guided takeover panel and degrades gracefully.
24
+ *
25
+ * @module linkedin/core
26
+ */
27
+
28
+ /**
29
+ * @typedef {import('../../core/view/contract.js').SourceError} SourceError
30
+ * @typedef {import('../../core/view/contract.js').RawResponse} RawResponse
31
+ * @typedef {import('../../core/view/contract.js').IntentCtx<LiState>} Ctx
32
+ */
33
+
34
+ /**
35
+ * Auth context read once from the page session via the context source.
36
+ * @typedef {Object} LiContext
37
+ * @property {string} csrf CSRF token (`"ajax:<digits>"`).
38
+ * @property {string} memberId Current user member ID (`ACo…`).
39
+ */
40
+ /**
41
+ * One inbox conversation.
42
+ * @typedef {Object} Conversation
43
+ * @property {string} urn
44
+ * @property {string} name
45
+ * @property {string} lastMessage
46
+ * @property {boolean} unread
47
+ * @property {number} ts epoch ms (0 if unknown)
48
+ * @property {string} recipientId
49
+ */
50
+ /**
51
+ * One message in a thread.
52
+ * @typedef {Object} Message
53
+ * @property {string} urn
54
+ * @property {string} sender
55
+ * @property {string} text
56
+ * @property {number} ts
57
+ * @property {boolean} fromMe
58
+ */
59
+ /**
60
+ * The active guided-recovery panel (full-content takeover). `display` is the
61
+ * typed SourceError display rendered VERBATIM by both presenters; `spinner`
62
+ * marks an auto-progress branch (animated glyph + elapsed counter).
63
+ * @typedef {Object} Recovery
64
+ * @property {SourceError['display']} display
65
+ * @property {boolean} spinner
66
+ * @property {number} [startedAt] Epoch ms the auto-progress began.
67
+ */
68
+ /**
69
+ * The view's immutable state (the core owns it; intents replace it via ctx.set).
70
+ * @typedef {Object} LiState
71
+ * @property {LiContext|null} auth Cached after the first context read.
72
+ * @property {string|null} target Discovered/opened CDP tab id (or options.target).
73
+ * @property {string|undefined} port options.port passthrough.
74
+ * @property {Conversation[]} convos Inbox, sorted unread-first then newest.
75
+ * @property {number} convCursor
76
+ * @property {number} convScroll
77
+ * @property {string|null} openUrn URN of the open conversation.
78
+ * @property {Message[]} thread
79
+ * @property {number} threadScroll
80
+ * @property {'list'|'reply'|'react'} mode
81
+ * @property {string} draft
82
+ * @property {number} reactCursor
83
+ * @property {number} lastFetch
84
+ * @property {Recovery|null} recovery
85
+ * @property {boolean} loginTabOpened Once-per-episode login-tab gate; reset on ready.
86
+ */
87
+
88
+ // ── Constants ─────────────────────────────────────────────────────────────────
89
+
90
+ /** Fixed emoji set for the react picker. */
91
+ export const EMOJIS = ['👍', '❤️', '😂', '🔥', '👏', '😮'];
92
+
93
+ /** How many conversations to request per refresh. */
94
+ const CONVO_COUNT = 25;
95
+
96
+ /** Settle-poll bounds: retry readiness up to N times, spaced ~MS, after an auto
97
+ * open/navigate — a hard ceiling so the flow never spins forever. */
98
+ const SETTLE_MAX = 5;
99
+ const SETTLE_INTERVAL_MS = 1200;
100
+
101
+ /** ClientError kinds that waiting can't fix — stop the settle-poll and guide. */
102
+ const HARD_STOP = new Set(['no-cdp', 'not-logged-in', 'capture-not-dev', 'capture-missing']);
103
+
104
+ const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
105
+
106
+ /** The LinkedIn inbox URL the recovery flow opens / navigates to. */
107
+ const MESSAGING_URL = 'https://www.linkedin.com/messaging/';
108
+
109
+ /** @param {number} ms @returns {Promise<void>} */
110
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
111
+
112
+ // ── Result helpers (inlined — the core imports nothing) ───────────────────────
113
+
114
+ /** @template T @param {T} data @returns {{ok:true, data:T}} */
115
+ function ok(data) {
116
+ return { ok: true, data };
117
+ }
118
+ /** @param {SourceError} error @returns {{ok:false, error:SourceError}} */
119
+ function fail(error) {
120
+ return { ok: false, error };
121
+ }
122
+
123
+ // ── Pure shared utilities (imported by tui.mjs / text.mjs) ────────────────────
124
+
125
+ /** @param {string} s @param {number} n @returns {string} */
126
+ export function truncate(s, n) {
127
+ const str = String(s == null ? '' : s);
128
+ return str.length > n ? str.slice(0, Math.max(0, n - 1)) + '…' : str;
129
+ }
130
+
131
+ /** @param {string} s @param {number} n @returns {string} */
132
+ export function padEnd(s, n) {
133
+ const str = String(s == null ? '' : s);
134
+ return str.length >= n ? str.slice(0, n) : str + ' '.repeat(n - str.length);
135
+ }
136
+
137
+ /**
138
+ * Relative-timestamp ladder, max ~5 cols: now / {m}m / {h}h / {d}d / `Mon D`
139
+ * (this year) / `Mon ʼYY` (prior year).
140
+ * @param {number} ts epoch ms (0 ⇒ '')
141
+ * @param {number} [now]
142
+ * @returns {string}
143
+ */
144
+ export function relTimestamp(ts, now = Date.now()) {
145
+ if (!ts) return '';
146
+ const s = Math.floor((now - ts) / 1000);
147
+ if (s < 60) return 'now';
148
+ const m = Math.floor(s / 60);
149
+ if (m < 60) return `${m}m`;
150
+ const h = Math.floor(m / 60);
151
+ if (h < 24) return `${h}h`;
152
+ const d = Math.floor(h / 24);
153
+ if (d < 7) return `${d}d`;
154
+ const dt = new Date(ts);
155
+ const mon = MONTHS[dt.getMonth()] || '';
156
+ const cur = new Date(now);
157
+ if (dt.getFullYear() === cur.getFullYear()) return `${mon} ${dt.getDate()}`;
158
+ return `${mon} ʼ${String(dt.getFullYear()).slice(-2)}`;
159
+ }
160
+
161
+ /**
162
+ * Sort conversations unread-first, then newest (ts desc). Non-mutating.
163
+ * @param {Conversation[]} convos @returns {Conversation[]}
164
+ */
165
+ export function sortConvos(convos) {
166
+ return convos.slice().sort((a, b) => {
167
+ if (!!a.unread !== !!b.unread) return a.unread ? -1 : 1;
168
+ return (b.ts || 0) - (a.ts || 0);
169
+ });
170
+ }
171
+
172
+ /** The open conversation for the current state, or null. @param {LiState} state */
173
+ export function openConvo(state) {
174
+ return state.openUrn ? state.convos.find((c) => c.urn === state.openUrn) || null : null;
175
+ }
176
+
177
+ // ── capture command construction (pure; runs anywhere) ────────────────────────
178
+
179
+ /**
180
+ * Serialize a JS arg object as a literal safe to splice into the exec code
181
+ * string. JSON is a near-subset of JS object-literal syntax; additionally escape
182
+ * U+2028/U+2029 so a pasted reply can't break the splice.
183
+ * @param {Record<string, unknown>} obj @returns {string}
184
+ */
185
+ function jsLiteral(obj) {
186
+ return JSON.stringify(obj).replace(/\u2028/g, '\\u2028').replace(/\u2029/g, '\\u2029');
187
+ }
188
+
189
+ /**
190
+ * Build a `capture exec` SourceRequest for a vault-lib call. `libArgs === null`
191
+ * emits a no-arg call (getContext).
192
+ * @param {string} fnName
193
+ * @param {Record<string, unknown> | null} libArgs
194
+ * @param {{target?:string, port?:string}} [opts]
195
+ * @returns {import('../../core/view/contract.js').SourceRequest}
196
+ */
197
+ function execReq(fnName, libArgs, opts = {}) {
198
+ const call = libArgs === null ? `${fnName}()` : `${fnName}(${jsLiteral(libArgs)})`;
199
+ const code = `import {${fnName}} from 'libs/linkedin'; return await ${call}`;
200
+ const args = ['exec', code];
201
+ if (opts.target) args.push('--target', String(opts.target));
202
+ if (opts.port != null && opts.port !== '') args.push('--port', String(opts.port));
203
+ return { kind: 'exec', bin: 'capture', args };
204
+ }
205
+
206
+ /** `capture list` (tab discovery; no CDP target). @param {{port?:string}} [opts] */
207
+ function listReq(opts = {}) {
208
+ const args = ['list'];
209
+ if (opts.port != null && opts.port !== '') args.push('--port', String(opts.port));
210
+ return /** @type {import('../../core/view/contract.js').SourceRequest} */ ({ kind: 'exec', bin: 'capture', args });
211
+ }
212
+
213
+ /** `capture open <url>` (open/reuse a tab). @param {{port?:string}} [opts] */
214
+ function openReq(opts = {}) {
215
+ const args = ['open', MESSAGING_URL];
216
+ if (opts.port != null && opts.port !== '') args.push('--port', String(opts.port));
217
+ return /** @type {import('../../core/view/contract.js').SourceRequest} */ ({ kind: 'exec', bin: 'capture', args });
218
+ }
219
+
220
+ /** `capture navigate <url> --target <id>` (drive an existing tab). */
221
+ function navReq(opts = {}) {
222
+ const args = ['navigate', MESSAGING_URL];
223
+ if (opts.target) args.push('--target', String(opts.target));
224
+ if (opts.port != null && opts.port !== '') args.push('--port', String(opts.port));
225
+ return /** @type {import('../../core/view/contract.js').SourceRequest} */ ({ kind: 'exec', bin: 'capture', args });
226
+ }
227
+
228
+ // ── Typed SourceError displays (the `display`/`kind` split) ────────────────────
229
+ //
230
+ // The canonical panel copy per error kind. BOTH presenters render `display`
231
+ // VERBATIM and map only `display.level` → glyph/hue. The recovery machine reads
232
+ // the parallel `recoveryPlan` (below) for BEHAVIOR (auto-fix / keep-content /
233
+ // banner) — that is core-internal and never reaches a presenter.
234
+
235
+ /** @type {Record<string, SourceError['display']>} */
236
+ const DISPLAY = {
237
+ 'no-cdp': {
238
+ headline: 'No debuggable browser',
239
+ explanation: 'crtr drives a browser over CDP and none is running.',
240
+ nextStep: 'Launch Arc, or Chrome with --remote-debugging-port=9222, then press g',
241
+ level: 'error', blocking: true,
242
+ },
243
+ 'capture-missing': {
244
+ headline: 'capture not found',
245
+ explanation: 'crtr drives the browser through the capture CLI, which is not on PATH.',
246
+ nextStep: 'Install capture (or add it to PATH), then press g',
247
+ level: 'error', blocking: true,
248
+ },
249
+ 'capture-not-dev': {
250
+ headline: 'Browser bridge unavailable',
251
+ explanation: 'This view needs a capture dev checkout (vault/ + esbuild).',
252
+ nextStep: '', level: 'error', blocking: true,
253
+ },
254
+ 'not-logged-in': {
255
+ headline: 'Log in to continue',
256
+ explanation: 'LinkedIn needs a sign-in in the browser.',
257
+ nextStep: 'Log in in the opened tab, then press g',
258
+ level: 'action', blocking: true,
259
+ },
260
+ 'no-tab': {
261
+ headline: 'Opening LinkedIn…',
262
+ explanation: 'No messaging tab was open — opening one and waiting for it to load.',
263
+ nextStep: '', level: 'action', blocking: true,
264
+ },
265
+ 'not-messaging': {
266
+ headline: 'Opening your inbox…',
267
+ explanation: 'Found LinkedIn on another page — switching it to Messages.',
268
+ nextStep: '', level: 'action', blocking: true,
269
+ },
270
+ 'rate-limited': {
271
+ headline: 'LinkedIn is throttling',
272
+ explanation: 'Too many requests — waiting before trying again.',
273
+ nextStep: 'Press g to retry',
274
+ level: 'info', blocking: false,
275
+ },
276
+ 'not-connection': {
277
+ headline: 'Not a 1st-degree connection',
278
+ explanation: 'LinkedIn only allows messaging 1st-degree connections.',
279
+ nextStep: '', level: 'error', blocking: false,
280
+ },
281
+ };
282
+
283
+ /** @param {string} message @returns {SourceError['display']} */
284
+ function genericDisplay(message) {
285
+ return {
286
+ headline: 'Something went wrong',
287
+ explanation: message || 'Unknown error.',
288
+ nextStep: 'Press g to retry.',
289
+ level: 'error', blocking: true,
290
+ };
291
+ }
292
+
293
+ /**
294
+ * The recovery BEHAVIOR plan per error kind (core-internal, never a presenter
295
+ * concern): which auto-fix to drive, whether to keep last-known content, and the
296
+ * short banner text + level the chrome should raise alongside the panel.
297
+ * @param {string} kind
298
+ * @returns {{auto?:'open'|'navigate', keepContent?:boolean, inline?:boolean, banner?:string, bannerLevel?:import('../../core/view/contract.js').BannerLevel}}
299
+ */
300
+ function recoveryPlan(kind) {
301
+ switch (kind) {
302
+ case 'no-tab': return { auto: 'open' };
303
+ case 'not-messaging': return { auto: 'navigate' };
304
+ case 'not-logged-in': return { banner: 'Log in in the opened tab, then press g', bannerLevel: 'action' };
305
+ case 'no-cdp': return { banner: 'No debuggable browser — launch one, then press g', bannerLevel: 'error' };
306
+ case 'capture-missing': return { banner: 'capture not found — install it, then press g', bannerLevel: 'error' };
307
+ case 'capture-not-dev': return { banner: 'Browser bridge unavailable — capture dev checkout required', bannerLevel: 'error' };
308
+ case 'rate-limited': return { keepContent: true, banner: 'LinkedIn is throttling — waiting, then retry with g', bannerLevel: 'info' };
309
+ case 'not-connection': return { inline: true, banner: 'Can only message 1st-degree connections', bannerLevel: 'error' };
310
+ default: return {};
311
+ }
312
+ }
313
+
314
+ // ── capture stderr → typed SourceError (the single classify) ──────────────────
315
+
316
+ /** Pull a human message out of capture stderr. @param {string} stderr @returns {string} */
317
+ function extractMessage(stderr) {
318
+ const lines = String(stderr || '').split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
319
+ for (let i = lines.length - 1; i >= 0; i--) {
320
+ if (/^ERROR:/i.test(lines[i])) return lines[i].replace(/^ERROR:\s*/i, '').trim();
321
+ }
322
+ if (lines.length) return lines[lines.length - 1];
323
+ return 'capture exec failed';
324
+ }
325
+
326
+ /**
327
+ * Map a failed `capture` invocation (transport failure OR non-zero exit) to a
328
+ * typed {@link SourceError}. This is the single error classifier — every source
329
+ * and command parse routes failures through here.
330
+ * @param {RawResponse} raw @returns {SourceError}
331
+ */
332
+ export function classify(raw) {
333
+ // Transport-level failure: the binary could not be spawned.
334
+ if (!raw.ok) {
335
+ const s = String(raw.stderr || '');
336
+ if (/command not found|ENOENT/i.test(s)) return { kind: 'capture-missing', display: DISPLAY['capture-missing'] };
337
+ return { kind: 'error', display: genericDisplay(extractMessage(s)) };
338
+ }
339
+ const s = String(raw.stderr || '');
340
+ if (/No browser with CDP found/i.test(s)) return { kind: 'no-cdp', display: DISPLAY['no-cdp'] };
341
+ // A dead/unreachable debugger port surfaces as a raw connection failure.
342
+ if (/fetch failed/i.test(s) || /failed to fetch/i.test(s) || /ECONNREFUSED/i.test(s)) {
343
+ return { kind: 'no-cdp', display: DISPLAY['no-cdp'] };
344
+ }
345
+ if (/No tab found/i.test(s)) return { kind: 'no-tab', display: DISPLAY['no-tab'] };
346
+ if (/Unauthenticated/i.test(s)) return { kind: 'not-logged-in', display: DISPLAY['not-logged-in'] };
347
+ if (/Messaging queryId not found/i.test(s) || /Navigate to \/messaging\//i.test(s)) {
348
+ return { kind: 'not-messaging', display: DISPLAY['not-messaging'] };
349
+ }
350
+ if (/RateLimited/i.test(s) || /\b429\b/.test(s)) return { kind: 'rate-limited', display: DISPLAY['rate-limited'] };
351
+ if (/must be a 1st-degree connection/i.test(s)) return { kind: 'not-connection', display: DISPLAY['not-connection'] };
352
+ if (/DEV_ONLY_MSG/i.test(s)) return { kind: 'capture-not-dev', display: DISPLAY['capture-not-dev'] };
353
+ return { kind: 'error', display: genericDisplay(extractMessage(s)) };
354
+ }
355
+
356
+ /** Parse capture stdout JSON; returns the value or a typed error. @param {RawResponse} raw */
357
+ function parseJson(raw) {
358
+ const out = String(raw.stdout || '').trim();
359
+ if (out === '') return ok(null);
360
+ try {
361
+ return ok(JSON.parse(out));
362
+ } catch {
363
+ return fail({ kind: 'error', display: genericDisplay(`could not parse capture output as JSON: ${truncate(out, 300)}`) });
364
+ }
365
+ }
366
+
367
+ // ── Field mappers (pure) ──────────────────────────────────────────────────────
368
+
369
+ /** @param {unknown} iso @returns {number} */
370
+ function parseTs(iso) {
371
+ if (typeof iso !== 'string' || iso === '') return 0;
372
+ const t = Date.parse(iso);
373
+ return Number.isNaN(t) ? 0 : t;
374
+ }
375
+
376
+ /** @param {any} c @returns {Conversation} */
377
+ export function toConversation(c) {
378
+ const o = c || {};
379
+ const participants = Array.isArray(o.participants) ? o.participants : [];
380
+ const p0 = participants[0] || {};
381
+ const names = participants.map((/** @type {any} */ p) => p && p.name).filter(Boolean);
382
+ const name = o.title || p0.name || (names.length ? names.join(', ') : 'Unknown');
383
+ return {
384
+ urn: typeof o.conversationUrn === 'string' ? o.conversationUrn : '',
385
+ name,
386
+ lastMessage: typeof o.lastMessage === 'string' ? o.lastMessage : '',
387
+ unread: typeof o.unreadCount === 'number' ? o.unreadCount > 0 : false,
388
+ ts: parseTs(o.lastActivityAt),
389
+ recipientId: typeof p0.memberId === 'string' ? p0.memberId : '',
390
+ };
391
+ }
392
+
393
+ /**
394
+ * Map a raw vault message. `fromMe` cannot be resolved in a pure `parse` (it
395
+ * needs the caller's memberId), so we keep the raw `fromMemberId` and resolve
396
+ * `fromMe` in the intent via {@link applyFromMe}.
397
+ * @param {any} m @returns {Message & {fromMemberId:string}}
398
+ */
399
+ function toMessage(m) {
400
+ const o = m || {};
401
+ return {
402
+ urn: typeof o.messageUrn === 'string' ? o.messageUrn : '',
403
+ sender: typeof o.fromName === 'string' ? o.fromName : '',
404
+ text: typeof o.text === 'string' ? o.text : '',
405
+ ts: parseTs(o.sentAt),
406
+ fromMe: false,
407
+ fromMemberId: typeof o.fromMemberId === 'string' ? o.fromMemberId : '',
408
+ };
409
+ }
410
+
411
+ /**
412
+ * Resolve `fromMe` against the caller's member id (the step `parse` can't do).
413
+ * @param {Array<Message & {fromMemberId?:string}>} msgs @param {string} myMemberId @returns {Message[]}
414
+ */
415
+ export function applyFromMe(msgs, myMemberId) {
416
+ return msgs.map((m) => ({ urn: m.urn, sender: m.sender, text: m.text, ts: m.ts, fromMe: !!myMemberId && m.fromMemberId === myMemberId }));
417
+ }
418
+
419
+ /** Pure message-list parse (fromMe unresolved). @param {RawResponse} raw */
420
+ function parseMessages(raw) {
421
+ if (!raw.ok || raw.exitCode !== 0) return fail(classify(raw));
422
+ const j = parseJson(raw);
423
+ if (!j.ok) return j;
424
+ const arr = j.data && Array.isArray(j.data.messages) ? j.data.messages : [];
425
+ return ok(arr.map(toMessage));
426
+ }
427
+
428
+ /** @param {string} url @returns {boolean} */
429
+ function isLinkedInUrl(url) {
430
+ try {
431
+ const h = new URL(url).hostname.toLowerCase();
432
+ return h === 'linkedin.com' || h.endsWith('.linkedin.com');
433
+ } catch {
434
+ return false;
435
+ }
436
+ }
437
+
438
+ // ── Sources (reads): request descriptor + pure parse ──────────────────────────
439
+
440
+ /** @type {import('../../core/view/contract.js').Source<string, {port?:string}>} */
441
+ export const discoverTabSource = {
442
+ id: 'li-discover-tab',
443
+ request: (a) => listReq(a),
444
+ parse: (raw) => {
445
+ if (!raw.ok) return fail(classify(raw));
446
+ if (raw.exitCode !== 0) return fail(classify(raw));
447
+ let tabs;
448
+ try {
449
+ tabs = JSON.parse(String(raw.stdout || '').trim() || '[]');
450
+ } catch {
451
+ return fail({ kind: 'error', display: genericDisplay(`could not parse capture list output: ${truncate(raw.stdout, 300)}`) });
452
+ }
453
+ if (!Array.isArray(tabs)) return fail({ kind: 'no-tab', display: DISPLAY['no-tab'] });
454
+ const linkedin = tabs.filter((t) => t && typeof t.url === 'string' && isLinkedInUrl(t.url));
455
+ const messaging = linkedin.find((t) => /\/messaging\//i.test(t.url));
456
+ const chosen = messaging || linkedin[0];
457
+ if (chosen && chosen.id) return ok(String(chosen.id));
458
+ return fail({ kind: 'no-tab', display: DISPLAY['no-tab'] });
459
+ },
460
+ };
461
+
462
+ /** @type {import('../../core/view/contract.js').Source<LiContext, {target?:string, port?:string}>} */
463
+ export const contextSource = {
464
+ id: 'li-context',
465
+ request: (a) => execReq('getContext', null, a),
466
+ parse: (raw) => {
467
+ if (!raw.ok || raw.exitCode !== 0) return fail(classify(raw));
468
+ const j = parseJson(raw);
469
+ if (!j.ok) return j;
470
+ const d = j.data || {};
471
+ if (!d.csrf || !d.memberId) return fail({ kind: 'error', display: genericDisplay('getContext() returned no csrf/memberId') });
472
+ return ok({ csrf: String(d.csrf), memberId: String(d.memberId) });
473
+ },
474
+ };
475
+
476
+ /** @type {import('../../core/view/contract.js').Source<Conversation[], {target?:string, port?:string, csrf:string, memberId:string, count:number}>} */
477
+ export const conversationsSource = {
478
+ id: 'li-conversations',
479
+ request: (a) => execReq('listConversations', { count: a.count, csrf: a.csrf, memberId: a.memberId }, a),
480
+ parse: (raw) => {
481
+ if (!raw.ok || raw.exitCode !== 0) return fail(classify(raw));
482
+ const j = parseJson(raw);
483
+ if (!j.ok) return j;
484
+ const arr = j.data && Array.isArray(j.data.conversations) ? j.data.conversations : [];
485
+ return ok(arr.map(toConversation));
486
+ },
487
+ };
488
+
489
+ // ── Commands (writes): same {request, parse}, intent-invoked ──────────────────
490
+
491
+ /** @type {import('../../core/view/contract.js').Command<string, {port?:string}>} */
492
+ export const openTabCommand = {
493
+ id: 'li-open-tab',
494
+ request: (a) => openReq(a),
495
+ parse: (raw) => {
496
+ if (!raw.ok || raw.exitCode !== 0) return fail(classify(raw));
497
+ const j = parseJson(raw);
498
+ if (!j.ok) return j;
499
+ if (j.data && j.data.id) return ok(String(j.data.id));
500
+ return fail({ kind: 'error', display: genericDisplay('capture open returned no tab id') });
501
+ },
502
+ };
503
+
504
+ /** @type {import('../../core/view/contract.js').Command<void, {target?:string, port?:string}>} */
505
+ export const navigateCommand = {
506
+ id: 'li-navigate',
507
+ request: (a) => navReq(a),
508
+ parse: (raw) => (raw.ok && raw.exitCode === 0 ? ok(undefined) : fail(classify(raw))),
509
+ };
510
+
511
+ /** @type {import('../../core/view/contract.js').Command<Array<Message & {fromMemberId:string}>, {target?:string, port?:string, csrf:string, conversationUrn:string}>} */
512
+ export const viewThreadCommand = {
513
+ id: 'li-view-thread',
514
+ request: (a) => execReq('viewConversation', { csrf: a.csrf, conversationUrn: a.conversationUrn }, a),
515
+ parse: parseMessages,
516
+ };
517
+
518
+ /** @type {import('../../core/view/contract.js').Command<void, {target?:string, port?:string, csrf:string, conversationUrn:string}>} */
519
+ const markReadCommand = {
520
+ id: 'li-mark-read',
521
+ request: (a) => execReq('markConversationAsRead', { csrf: a.csrf, conversationUrn: a.conversationUrn }, a),
522
+ parse: (raw) => (raw.ok && raw.exitCode === 0 ? ok(undefined) : fail(classify(raw))),
523
+ };
524
+
525
+ /** @type {import('../../core/view/contract.js').Command<void, {target?:string, port?:string, csrf:string, myMemberId:string, recipient:string, text:string, conversationUrn?:string}>} */
526
+ export const sendMessageCommand = {
527
+ id: 'li-send',
528
+ request: (a) => {
529
+ /** @type {Record<string, unknown>} */
530
+ const libArgs = { csrf: a.csrf, myMemberId: a.myMemberId, recipient: a.recipient, text: a.text };
531
+ if (a.conversationUrn) libArgs.conversationUrn = a.conversationUrn;
532
+ return execReq('sendMessage', libArgs, a);
533
+ },
534
+ parse: (raw) => {
535
+ if (!raw.ok || raw.exitCode !== 0) return fail(classify(raw)); // incl. 422 → not-connection
536
+ const j = parseJson(raw);
537
+ if (!j.ok) return j;
538
+ const body = j.data || {};
539
+ if (body.success === false) {
540
+ const msg = typeof body.error === 'string' ? body.error : 'send failed';
541
+ if (/1st-degree connection/i.test(msg)) return fail({ kind: 'not-connection', display: DISPLAY['not-connection'] });
542
+ return fail({ kind: 'error', display: genericDisplay(msg) });
543
+ }
544
+ return ok(undefined);
545
+ },
546
+ };
547
+
548
+ /** @type {import('../../core/view/contract.js').Command<void, {target?:string, port?:string, csrf:string, messageUrn:string, emoji:string}>} */
549
+ export const reactCommand = {
550
+ id: 'li-react',
551
+ request: (a) => execReq('reactToMessage', { csrf: a.csrf, messageUrn: a.messageUrn, emoji: a.emoji }, a),
552
+ parse: (raw) => {
553
+ if (!raw.ok || raw.exitCode !== 0) return fail(classify(raw));
554
+ const j = parseJson(raw);
555
+ if (!j.ok) return j;
556
+ if (j.data && j.data.success === false) return fail({ kind: 'error', display: genericDisplay('LinkedIn rejected the reaction') });
557
+ return ok(undefined);
558
+ },
559
+ };
560
+
561
+ // ── The recovery machine (ONE implementation) ─────────────────────────────────
562
+
563
+ /** @param {LiState} s @returns {{target?:string, port?:string}} */
564
+ function baseOpts(s) {
565
+ return { target: s.target || undefined, port: s.port };
566
+ }
567
+
568
+ /**
569
+ * One readiness attempt: discover the tab (unless known), read auth context
570
+ * once, list conversations, reload the open thread if any. Returns the gathered
571
+ * data or the first SourceError — never throws.
572
+ * @param {Ctx} ctx
573
+ * @returns {Promise<{ok:true, data:{target:string, auth:LiContext, convos:Conversation[], thread:Message[]}} | {ok:false, error:SourceError}>}
574
+ */
575
+ async function attemptLoad(ctx) {
576
+ const s = ctx.state;
577
+ let target = s.target || '';
578
+ let auth = s.auth;
579
+ const port = s.port;
580
+
581
+ if (!target) {
582
+ const r = await ctx.resolve(discoverTabSource, { port });
583
+ if (!r.ok) return r;
584
+ target = r.data;
585
+ }
586
+ if (!auth) {
587
+ const r = await ctx.resolve(contextSource, { target, port });
588
+ if (!r.ok) return r;
589
+ auth = r.data;
590
+ }
591
+ const lc = await ctx.resolve(conversationsSource, { target, port, csrf: auth.csrf, memberId: auth.memberId, count: CONVO_COUNT });
592
+ if (!lc.ok) return lc;
593
+ const convos = sortConvos(lc.data);
594
+
595
+ let thread = s.thread;
596
+ if (s.openUrn) {
597
+ const vc = await ctx.execute(viewThreadCommand, { target, port, csrf: auth.csrf, conversationUrn: s.openUrn });
598
+ if (vc.ok) thread = applyFromMe(vc.data, auth.memberId); // a thread-only failure is non-fatal
599
+ }
600
+ return { ok: true, data: { target, auth, convos, thread } };
601
+ }
602
+
603
+ /**
604
+ * Mark the view ready: persist the loaded data, clear recovery + banner, refresh
605
+ * the unread subtitle, stamp lastFetch, reset the login-tab gate.
606
+ * @param {Ctx} ctx @param {{target:string, auth:LiContext, convos:Conversation[], thread:Message[]}} data
607
+ */
608
+ function onReady(ctx, data) {
609
+ ctx.set((s) => {
610
+ const convCursor = s.convCursor >= data.convos.length ? Math.max(0, data.convos.length - 1) : s.convCursor;
611
+ return { ...s, target: data.target, auth: data.auth, convos: data.convos, thread: data.thread, convCursor, recovery: null, loginTabOpened: false, lastFetch: Date.now() };
612
+ });
613
+ ctx.signal.setStatus(null);
614
+ ctx.signal.clearBanner();
615
+ if (ctx.state.mode === 'list') ctx.signal.setMode(null);
616
+ updateUnread(ctx);
617
+ }
618
+
619
+ /** Drive the live "N unread" title subtitle. @param {Ctx} ctx */
620
+ function updateUnread(ctx) {
621
+ let n = 0;
622
+ for (const c of ctx.state.convos) if (c.unread) n++;
623
+ ctx.signal.setSubtitle(n > 0 ? `${n} unread` : null);
624
+ }
625
+
626
+ /**
627
+ * Apply a guided (non-auto) recovery: set (or clear) the panel + the banner. An
628
+ * `inline` error and a `keepContent` error with a populated inbox keep the last-
629
+ * known content instead of taking over.
630
+ * @param {Ctx} ctx @param {SourceError} error
631
+ */
632
+ function applyGuided(ctx, error) {
633
+ ctx.signal.setStatus(null);
634
+ ctx.signal.setMode(null);
635
+ const plan = recoveryPlan(error.kind);
636
+ if (plan.inline || (plan.keepContent && ctx.state.convos.length)) {
637
+ ctx.set((s) => ({ ...s, recovery: null }));
638
+ } else {
639
+ ctx.set((s) => ({ ...s, recovery: { display: error.display, spinner: false } }));
640
+ }
641
+ if (plan.banner) ctx.signal.setBanner(plan.banner, plan.bannerLevel || 'error');
642
+ else ctx.signal.clearBanner();
643
+ }
644
+
645
+ /**
646
+ * Bounded settle-poll after an auto open/navigate: retry readiness up to
647
+ * SETTLE_MAX times spaced SETTLE_INTERVAL_MS, narrating elapsed seconds. Ready →
648
+ * onReady. Hard-stop error → guided. Exhaustion → an action banner over the panel
649
+ * (no infinite spin).
650
+ * @param {Ctx} ctx
651
+ */
652
+ async function settlePoll(ctx) {
653
+ for (let i = 1; i <= SETTLE_MAX; i++) {
654
+ await sleep(SETTLE_INTERVAL_MS);
655
+ const rec = ctx.state.recovery;
656
+ const secs = rec && rec.startedAt ? Math.max(0, Math.floor((Date.now() - rec.startedAt) / 1000)) : i;
657
+ ctx.signal.setStatus(`Loading messages… (${secs}s)`);
658
+ const r = await attemptLoad(ctx);
659
+ if (r.ok) {
660
+ onReady(ctx, r.data);
661
+ return;
662
+ }
663
+ if (HARD_STOP.has(r.error.kind)) {
664
+ applyGuided(ctx, r.error);
665
+ return;
666
+ }
667
+ // transient (no-tab / not-messaging / rate-limited / error) → keep polling
668
+ }
669
+ ctx.set((s) => ({ ...s, recovery: s.recovery ? { ...s.recovery, spinner: false } : null }));
670
+ ctx.signal.setStatus(null);
671
+ ctx.signal.setBanner('Still loading — press g to retry', 'action');
672
+ }
673
+
674
+ /**
675
+ * The recovery state machine entry: auto-fix branches drive the browser
676
+ * (open/navigate) then settle-poll; the rest fall straight to a guided panel.
677
+ * @param {Ctx} ctx @param {SourceError} error
678
+ */
679
+ async function recover(ctx, error) {
680
+ const plan = recoveryPlan(error.kind);
681
+ const port = ctx.state.port;
682
+ if (plan.auto) {
683
+ ctx.signal.setMode(null);
684
+ ctx.signal.clearBanner();
685
+ ctx.set((s) => ({ ...s, recovery: { display: error.display, spinner: true, startedAt: Date.now() } }));
686
+ ctx.signal.setStatus(error.display.headline);
687
+ const fix = plan.auto === 'open'
688
+ ? await ctx.execute(openTabCommand, { port })
689
+ : await ctx.execute(navigateCommand, { target: ctx.state.target || undefined, port });
690
+ if (!fix.ok) {
691
+ applyGuided(ctx, fix.error);
692
+ return;
693
+ }
694
+ ctx.set((s) => ({ ...s, target: plan.auto === 'open' && fix.data ? fix.data : s.target, auth: null }));
695
+ await settlePoll(ctx);
696
+ return;
697
+ }
698
+ // not-logged-in: open the messaging tab ONCE per episode so the login page is
699
+ // visible, then STOP (logged out, /messaging/ redirects to /login, so a new
700
+ // tab would spawn on every 30s poll). onReady resets loginTabOpened.
701
+ if (error.kind === 'not-logged-in' && !ctx.state.loginTabOpened) {
702
+ const o = await ctx.execute(openTabCommand, { port });
703
+ ctx.set((s) => ({ ...s, target: o.ok && o.data ? o.data : s.target, loginTabOpened: true }));
704
+ }
705
+ applyGuided(ctx, error);
706
+ }
707
+
708
+ /** Set the per-action error banner from a typed SourceError (open/send/react). */
709
+ function bannerError(ctx, error) {
710
+ const plan = recoveryPlan(error.kind);
711
+ if (plan.banner) ctx.signal.setBanner(plan.banner, plan.bannerLevel || 'error');
712
+ else ctx.signal.setBanner(error.display.explanation || error.display.headline, error.display.level);
713
+ }
714
+
715
+ // ── The portable core ──────────────────────────────────────────────────────────
716
+
717
+ /** @type {import('../../core/view/contract.js').ViewCore<LiState>} */
718
+ const core = {
719
+ manifest: {
720
+ id: 'linkedin',
721
+ title: 'LinkedIn Messages',
722
+ description: 'Inbox — read, reply, react',
723
+ refreshMs: 30000,
724
+ },
725
+
726
+ /** Cheap + synchronous initial state — NO fetch. @returns {LiState} */
727
+ init(opts) {
728
+ const o = opts || {};
729
+ return {
730
+ auth: null,
731
+ target: o.target || null,
732
+ port: o.port || undefined,
733
+ convos: [],
734
+ convCursor: 0,
735
+ convScroll: 0,
736
+ openUrn: null,
737
+ thread: [],
738
+ threadScroll: 0,
739
+ mode: 'list',
740
+ draft: '',
741
+ reactCursor: 0,
742
+ lastFetch: 0,
743
+ recovery: null,
744
+ loginTabOpened: false,
745
+ };
746
+ },
747
+
748
+ sources: { discoverTabSource, contextSource, conversationsSource },
749
+ commands: { openTabCommand, navigateCommand, viewThreadCommand, markReadCommand, sendMessageCommand, reactCommand },
750
+
751
+ intents: {
752
+ /**
753
+ * Fetch the inbox (and the open thread). Runs in the host's single-flight
754
+ * lane. On failure, hands off to the ONE recovery state machine. Skips
755
+ * auto-polls while composing/reacting so a poll can't disrupt input.
756
+ * @param {Ctx} ctx
757
+ */
758
+ async refresh(ctx) {
759
+ if (ctx.state.mode !== 'list') return;
760
+ ctx.signal.setStatus('Loading…');
761
+ const r = await attemptLoad(ctx);
762
+ if (r.ok) {
763
+ onReady(ctx, r.data);
764
+ return;
765
+ }
766
+ await recover(ctx, r.error);
767
+ },
768
+
769
+ /** @param {Ctx} ctx */
770
+ cursorDown: (ctx) => ctx.set((s) => ({ ...s, convCursor: s.convos.length ? Math.min(s.convos.length - 1, s.convCursor + 1) : 0 })),
771
+ /** @param {Ctx} ctx */
772
+ cursorUp: (ctx) => ctx.set((s) => ({ ...s, convCursor: Math.max(0, s.convCursor - 1) })),
773
+
774
+ /**
775
+ * Open the conversation under the cursor (or at payload index): view it, then
776
+ * auto-mark it read.
777
+ * @param {Ctx} ctx @param {number} [i]
778
+ */
779
+ async openThread(ctx, i) {
780
+ const idx = typeof i === 'number' ? i : ctx.state.convCursor;
781
+ const convo = ctx.state.convos[idx];
782
+ if (!convo) return;
783
+ if (!ctx.state.auth) {
784
+ ctx.signal.setBanner('Not ready yet — press g to refresh', 'action');
785
+ return;
786
+ }
787
+ const auth = ctx.state.auth;
788
+ ctx.set((s) => ({ ...s, convCursor: idx, openUrn: convo.urn, thread: [], threadScroll: 0 }));
789
+ ctx.signal.setStatus('Loading thread…');
790
+ const vc = await ctx.execute(viewThreadCommand, { ...baseOpts(ctx.state), csrf: auth.csrf, conversationUrn: convo.urn });
791
+ if (!vc.ok) {
792
+ ctx.signal.setStatus(null);
793
+ bannerError(ctx, vc.error);
794
+ return;
795
+ }
796
+ ctx.set((s) => ({ ...s, thread: applyFromMe(vc.data, auth.memberId) }));
797
+ await ctx.execute(markReadCommand, { ...baseOpts(ctx.state), csrf: auth.csrf, conversationUrn: convo.urn });
798
+ ctx.set((s) => ({ ...s, convos: s.convos.map((c) => (c.urn === convo.urn ? { ...c, unread: false } : c)) }));
799
+ ctx.signal.setStatus(null);
800
+ ctx.signal.clearBanner();
801
+ updateUnread(ctx);
802
+ },
803
+
804
+ /** Enter reply (compose) mode. @param {Ctx} ctx */
805
+ startReply: (ctx) => {
806
+ if (!ctx.state.openUrn) {
807
+ ctx.signal.setBanner('Open a conversation first', 'action');
808
+ return;
809
+ }
810
+ ctx.set((s) => ({ ...s, mode: 'reply', draft: '' }));
811
+ ctx.signal.setMode('compose');
812
+ ctx.signal.clearBanner();
813
+ },
814
+ /** The host line-editor draft (capture binding). @param {Ctx} ctx @param {string} [draft] */
815
+ setDraft: (ctx, draft) => ctx.set((s) => ({ ...s, draft: typeof draft === 'string' ? draft : '' })),
816
+ /** Leave reply/react mode without acting. @param {Ctx} ctx */
817
+ cancelCompose: (ctx) => {
818
+ ctx.set((s) => ({ ...s, mode: 'list', draft: '' }));
819
+ ctx.signal.setMode(null);
820
+ },
821
+
822
+ /**
823
+ * Send the current draft to the open conversation's recipient.
824
+ * @param {Ctx} ctx
825
+ */
826
+ async submitReply(ctx) {
827
+ const text = ctx.state.draft.trim();
828
+ if (!text) {
829
+ ctx.set((s) => ({ ...s, mode: 'list', draft: '' }));
830
+ ctx.signal.setMode(null);
831
+ return;
832
+ }
833
+ const convo = openConvo(ctx.state);
834
+ const auth = ctx.state.auth;
835
+ if (!convo || !auth) {
836
+ ctx.signal.setBanner('No open conversation to reply to.', 'error');
837
+ ctx.set((s) => ({ ...s, mode: 'list' }));
838
+ ctx.signal.setMode(null);
839
+ return;
840
+ }
841
+ const openUrn = ctx.state.openUrn;
842
+ ctx.set((s) => ({ ...s, mode: 'list' }));
843
+ ctx.signal.setMode(null);
844
+ ctx.signal.setStatus('Sending…');
845
+ const r = await ctx.execute(sendMessageCommand, { ...baseOpts(ctx.state), csrf: auth.csrf, myMemberId: auth.memberId, recipient: convo.recipientId, text, conversationUrn: openUrn || undefined });
846
+ if (!r.ok) {
847
+ ctx.signal.setStatus(null);
848
+ bannerError(ctx, r.error); // not-connection → inline error banner over the open thread
849
+ return;
850
+ }
851
+ // Optimistic append, then reconcile by re-viewing the thread.
852
+ ctx.set((s) => ({ ...s, thread: [...s.thread, { urn: '', sender: 'You', text, ts: Date.now(), fromMe: true }], draft: '' }));
853
+ const vc = await ctx.execute(viewThreadCommand, { ...baseOpts(ctx.state), csrf: auth.csrf, conversationUrn: openUrn || '' });
854
+ if (vc.ok) ctx.set((s) => ({ ...s, thread: applyFromMe(vc.data, auth.memberId) }));
855
+ ctx.signal.clearBanner();
856
+ ctx.signal.setStatus('Sent');
857
+ },
858
+
859
+ /** Enter react mode. @param {Ctx} ctx */
860
+ startReact: (ctx) => {
861
+ if (!ctx.state.openUrn || ctx.state.thread.length === 0) {
862
+ ctx.signal.setBanner('Open a conversation first', 'action');
863
+ return;
864
+ }
865
+ ctx.set((s) => ({ ...s, mode: 'react', reactCursor: 0 }));
866
+ ctx.signal.setMode('react');
867
+ ctx.signal.clearBanner();
868
+ },
869
+ /** @param {Ctx} ctx */
870
+ reactPrev: (ctx) => ctx.set((s) => ({ ...s, reactCursor: Math.max(0, s.reactCursor - 1) })),
871
+ /** @param {Ctx} ctx */
872
+ reactNext: (ctx) => ctx.set((s) => ({ ...s, reactCursor: Math.min(EMOJIS.length - 1, s.reactCursor + 1) })),
873
+ /** @param {Ctx} ctx @param {number} [i] */
874
+ reactPick: (ctx, i) => ctx.set((s) => ({ ...s, reactCursor: typeof i === 'number' ? Math.max(0, Math.min(EMOJIS.length - 1, i)) : s.reactCursor })),
875
+
876
+ /**
877
+ * React to the most recent message in the open thread with the selected emoji.
878
+ * @param {Ctx} ctx
879
+ */
880
+ async submitReact(ctx) {
881
+ const target = ctx.state.thread.length ? ctx.state.thread[ctx.state.thread.length - 1] : null;
882
+ const auth = ctx.state.auth;
883
+ ctx.set((s) => ({ ...s, mode: 'list' }));
884
+ ctx.signal.setMode(null);
885
+ if (!target || !target.urn || !auth) {
886
+ ctx.signal.setBanner('No message to react to.', 'error');
887
+ return;
888
+ }
889
+ const emoji = EMOJIS[ctx.state.reactCursor] || EMOJIS[0];
890
+ ctx.signal.setStatus('Reacting…');
891
+ const r = await ctx.execute(reactCommand, { ...baseOpts(ctx.state), csrf: auth.csrf, messageUrn: target.urn, emoji });
892
+ if (!r.ok) {
893
+ ctx.signal.setStatus(null);
894
+ bannerError(ctx, r.error);
895
+ return;
896
+ }
897
+ ctx.signal.clearBanner();
898
+ ctx.signal.setStatus('Reacted ' + emoji);
899
+ },
900
+
901
+ /** @param {Ctx} ctx */
902
+ quit: (ctx) => ctx.signal.quit(),
903
+ },
904
+ };
905
+
906
+ export default core;