elanous 0.2.7 → 0.2.8

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 (252) hide show
  1. package/apps/pwa/out/404/index.html +1 -1
  2. package/apps/pwa/out/404.html +1 -1
  3. package/apps/pwa/out/_next/static/chunks/4836-3084c58c1725a40b.js +1 -0
  4. package/apps/pwa/out/_next/static/chunks/7032-f4ef698518fba61c.js +1 -0
  5. package/apps/pwa/out/_next/static/chunks/8067-d976b441a104ab74.js +1 -0
  6. package/apps/pwa/out/_next/static/chunks/8519-5632377cce994f86.js +1 -0
  7. package/apps/pwa/out/_next/static/chunks/8894-7317998f6cb97d8c.js +1 -0
  8. package/apps/pwa/out/_next/static/chunks/9740-8f5ff96c522b5f55.js +1 -0
  9. package/apps/pwa/out/_next/static/chunks/app/approvals/page-9aa47b0bc6d570ff.js +1 -0
  10. package/apps/pwa/out/_next/static/chunks/app/autopilot/page-f4d2ecbbae6088d6.js +1 -0
  11. package/apps/pwa/out/_next/static/chunks/app/board/page-2053e49930c82748.js +1 -0
  12. package/apps/pwa/out/_next/static/chunks/app/botlab/{page-624f7a2f89b60c8f.js → page-d4b6df905f185055.js} +1 -1
  13. package/apps/pwa/out/_next/static/chunks/app/bots/{page-5a5fb385a4df0ffe.js → page-a70ba27c33003dd0.js} +1 -1
  14. package/apps/pwa/out/_next/static/chunks/app/chat/page-186fc6952f027acc.js +1 -0
  15. package/apps/pwa/out/_next/static/chunks/app/consult/{page-7457f52683562161.js → page-18166f885d831d3e.js} +1 -1
  16. package/apps/pwa/out/_next/static/chunks/app/control/page-1c9d6cfe68b121c2.js +1 -0
  17. package/apps/pwa/out/_next/static/chunks/app/design-check/page-cbbe03c0da7b081a.js +1 -0
  18. package/apps/pwa/out/_next/static/chunks/app/editor/page-c108ff1fdb665c97.js +1 -0
  19. package/apps/pwa/out/_next/static/chunks/app/exec/{page-b7dcf5d48a854b76.js → page-147a328753d983e2.js} +1 -1
  20. package/apps/pwa/out/_next/static/chunks/app/field/{page-51480757e7d68b92.js → page-799395a9df7d95aa.js} +1 -1
  21. package/apps/pwa/out/_next/static/chunks/app/intake/page-6a8158b71d1fe9d7.js +1 -0
  22. package/apps/pwa/out/_next/static/chunks/app/layout-9567560270857531.js +1 -0
  23. package/apps/pwa/out/_next/static/chunks/app/live/{page-30533117beb53625.js → page-e7ed3d010153d818.js} +1 -1
  24. package/apps/pwa/out/_next/static/chunks/app/market/page-c0c0d19d51342d3e.js +1 -0
  25. package/apps/pwa/out/_next/static/chunks/app/missions/[id]/{page-07267f1391b96aba.js → page-2e002ad8faa43291.js} +1 -1
  26. package/apps/pwa/out/_next/static/chunks/app/morning/{page-55d50e9b0006a91d.js → page-b672e02e4bec8deb.js} +1 -1
  27. package/apps/pwa/out/_next/static/chunks/app/not-found-1ba4a5d857276334.js +1 -0
  28. package/apps/pwa/out/_next/static/chunks/app/observatory/page-9db2226f38c542fb.js +1 -0
  29. package/apps/pwa/out/_next/static/chunks/app/{page-a7e37af7d0399cb8.js → page-70ee65f5f7a151dc.js} +1 -1
  30. package/apps/pwa/out/_next/static/chunks/app/reflection/page-0bb8e43203efb23b.js +1 -0
  31. package/apps/pwa/out/_next/static/chunks/app/scheduler/page-092e1bcf27d751cc.js +1 -0
  32. package/apps/pwa/out/_next/static/chunks/app/sessions/{page-15e5a15db84c56aa.js → page-eb52580c4b75b905.js} +1 -1
  33. package/apps/pwa/out/_next/static/chunks/app/settings/devices/{page-a2e572e4511f2a16.js → page-7464c1987dd76e6d.js} +1 -1
  34. package/apps/pwa/out/_next/static/chunks/app/settings/{page-dd107bf1c7d52a90.js → page-e573437b0a1c728f.js} +1 -1
  35. package/apps/pwa/out/_next/static/chunks/app/setup/done/page-294300625e49e4d3.js +1 -0
  36. package/apps/pwa/out/_next/static/chunks/app/setup/page-085847b088675978.js +1 -0
  37. package/apps/pwa/out/_next/static/chunks/app/share/page-9fe9d38c36999eb4.js +1 -0
  38. package/apps/pwa/out/_next/static/chunks/app/showroom/page-d8ab4d1489223459.js +1 -0
  39. package/apps/pwa/out/_next/static/chunks/app/tasks/page-6141f67f531d8b6a.js +1 -0
  40. package/apps/pwa/out/_next/static/chunks/app/term/page-295a02b1026b8255.js +1 -0
  41. package/apps/pwa/out/_next/static/chunks/app/trace/page-1eb9a0e1e054209d.js +1 -0
  42. package/apps/pwa/out/_next/static/chunks/app/vault/{page-56adbe1a1f1379bd.js → page-82efa177a30f3741.js} +1 -1
  43. package/apps/pwa/out/_next/static/chunks/app/workflows/chat-ui/page-d0be51cec4f62606.js +1 -0
  44. package/apps/pwa/out/_next/static/chunks/app/workflows/page-2e367075ffdbc062.js +1 -0
  45. package/apps/pwa/out/_next/static/chunks/app/workspace/{page-5d422f38c052967e.js → page-647ed2b66c1d49f1.js} +1 -1
  46. package/apps/pwa/out/_next/static/chunks/app/worktrees/page-ba058c56e38f2792.js +1 -0
  47. package/apps/pwa/out/_next/static/css/5ef72a618a8994f3.css +1 -0
  48. package/apps/pwa/out/approvals/index.html +1 -1
  49. package/apps/pwa/out/approvals/index.txt +13 -13
  50. package/apps/pwa/out/autopilot/index.html +1 -1
  51. package/apps/pwa/out/autopilot/index.txt +13 -13
  52. package/apps/pwa/out/board/index.html +1 -1
  53. package/apps/pwa/out/board/index.txt +13 -13
  54. package/apps/pwa/out/botlab/index.html +1 -1
  55. package/apps/pwa/out/botlab/index.txt +13 -13
  56. package/apps/pwa/out/bots/index.html +1 -1
  57. package/apps/pwa/out/bots/index.txt +13 -13
  58. package/apps/pwa/out/chat/index.html +1 -1
  59. package/apps/pwa/out/chat/index.txt +13 -13
  60. package/apps/pwa/out/consult/index.html +1 -1
  61. package/apps/pwa/out/consult/index.txt +13 -13
  62. package/apps/pwa/out/control/index.html +1 -1
  63. package/apps/pwa/out/control/index.txt +13 -13
  64. package/apps/pwa/out/design-check/index.html +1 -1
  65. package/apps/pwa/out/design-check/index.txt +13 -13
  66. package/apps/pwa/out/editor/index.html +1 -1
  67. package/apps/pwa/out/editor/index.txt +13 -13
  68. package/apps/pwa/out/exec/index.html +1 -1
  69. package/apps/pwa/out/exec/index.txt +13 -13
  70. package/apps/pwa/out/field/index.html +1 -1
  71. package/apps/pwa/out/field/index.txt +13 -13
  72. package/apps/pwa/out/healthz +1 -1
  73. package/apps/pwa/out/index.html +1 -1
  74. package/apps/pwa/out/index.txt +13 -13
  75. package/apps/pwa/out/intake/index.html +1 -1
  76. package/apps/pwa/out/intake/index.txt +13 -13
  77. package/apps/pwa/out/live/index.html +1 -1
  78. package/apps/pwa/out/live/index.txt +13 -13
  79. package/apps/pwa/out/market/index.html +1 -1
  80. package/apps/pwa/out/market/index.txt +13 -13
  81. package/apps/pwa/out/missions/placeholder/index.html +1 -1
  82. package/apps/pwa/out/missions/placeholder/index.txt +13 -13
  83. package/apps/pwa/out/morning/index.html +1 -1
  84. package/apps/pwa/out/morning/index.txt +13 -13
  85. package/apps/pwa/out/observatory/index.html +1 -1
  86. package/apps/pwa/out/observatory/index.txt +13 -13
  87. package/apps/pwa/out/reflection/index.html +1 -1
  88. package/apps/pwa/out/reflection/index.txt +14 -14
  89. package/apps/pwa/out/scheduler/index.html +1 -1
  90. package/apps/pwa/out/scheduler/index.txt +13 -13
  91. package/apps/pwa/out/sessions/index.html +1 -1
  92. package/apps/pwa/out/sessions/index.txt +13 -13
  93. package/apps/pwa/out/settings/devices/index.html +1 -1
  94. package/apps/pwa/out/settings/devices/index.txt +13 -13
  95. package/apps/pwa/out/settings/index.html +1 -1
  96. package/apps/pwa/out/settings/index.txt +13 -13
  97. package/apps/pwa/out/setup/done/index.html +1 -1
  98. package/apps/pwa/out/setup/done/index.txt +13 -13
  99. package/apps/pwa/out/setup/index.html +1 -1
  100. package/apps/pwa/out/setup/index.txt +13 -13
  101. package/apps/pwa/out/share/index.html +1 -1
  102. package/apps/pwa/out/share/index.txt +13 -13
  103. package/apps/pwa/out/showroom/index.html +1 -1
  104. package/apps/pwa/out/showroom/index.txt +13 -13
  105. package/apps/pwa/out/tasks/index.html +1 -1
  106. package/apps/pwa/out/tasks/index.txt +13 -13
  107. package/apps/pwa/out/term/index.html +1 -1
  108. package/apps/pwa/out/term/index.txt +13 -13
  109. package/apps/pwa/out/trace/index.html +1 -1
  110. package/apps/pwa/out/trace/index.txt +13 -13
  111. package/apps/pwa/out/vault/index.html +1 -1
  112. package/apps/pwa/out/vault/index.txt +13 -13
  113. package/apps/pwa/out/workflows/chat-ui/index.html +1 -1
  114. package/apps/pwa/out/workflows/chat-ui/index.txt +13 -13
  115. package/apps/pwa/out/workflows/index.html +1 -1
  116. package/apps/pwa/out/workflows/index.txt +13 -13
  117. package/apps/pwa/out/workspace/index.html +1 -1
  118. package/apps/pwa/out/workspace/index.txt +13 -13
  119. package/apps/pwa/out/worktrees/index.html +1 -1
  120. package/apps/pwa/out/worktrees/index.txt +13 -13
  121. package/graphs/test-diet/recipes.yaml +9 -0
  122. package/graphs/test-diet/test-diet.yaml +22 -0
  123. package/package.json +2 -1
  124. package/persona-presets/_index.yaml +10 -0
  125. package/persona-presets/_schema.json +146 -0
  126. package/persona-presets/dana.yaml +26 -0
  127. package/persona-presets/haru.yaml +27 -0
  128. package/persona-presets/kai.yaml +27 -0
  129. package/persona-presets/leo.yaml +27 -0
  130. package/persona-presets/mira.yaml +30 -0
  131. package/persona-presets/nari.yaml +27 -0
  132. package/persona-presets/sora.yaml +27 -0
  133. package/persona-presets/yuna.yaml +27 -0
  134. package/plugins/geo-check/graphs/run-step.ts +34 -10
  135. package/scripts/field-feed/deliver-node.ts +8 -3
  136. package/scripts/field-feed/lib.ts +2 -0
  137. package/scripts/install.sh +20 -11
  138. package/scripts/persona-presets/lint.ts +79 -0
  139. package/scripts/release-loop/checklist-gate-node.ts +2 -2
  140. package/scripts/release-loop/gate-node.ts +27 -3
  141. package/scripts/release-loop/npm-publish-node.ts +10 -4
  142. package/scripts/release-loop/ops-upgrade-node.ts +11 -3
  143. package/scripts/release-loop/publish-node.ts +23 -1
  144. package/scripts/release-loop/shard-plan.ts +17 -0
  145. package/scripts/storyboard/storyboard.ts +167 -0
  146. package/scripts/test-diet/lib.ts +88 -0
  147. package/scripts/test-diet/node.ts +102 -0
  148. package/src/acp/auth-methods.ts +30 -0
  149. package/src/acp/server.ts +5 -5
  150. package/src/channel-import/detect.ts +191 -0
  151. package/src/channel-import/run.ts +111 -0
  152. package/src/cli/config-test-sync.ts +4 -0
  153. package/src/cli/decisions-cli.ts +13 -3
  154. package/src/cli/doctor-bundle.ts +128 -0
  155. package/src/cli/doctor-cli.ts +36 -1
  156. package/src/cli/doctor-local-llm.ts +95 -0
  157. package/src/cli/import-cli.ts +60 -0
  158. package/src/cli/mcp-login.ts +13 -4
  159. package/src/cli/persona-cli.ts +116 -0
  160. package/src/cli/release-cli.ts +8 -7
  161. package/src/cli/skills-cli.ts +173 -0
  162. package/src/cli/start-cli.ts +13 -4
  163. package/src/codex/setup.ts +15 -13
  164. package/src/dashboard/index.ts +17 -1
  165. package/src/dashboard/slash-runtime/dashboard-handlers.ts +2 -2
  166. package/src/dashboard/slash-runtime/help-from-registry.ts +4 -2
  167. package/src/decisions/decision-cards.ts +291 -0
  168. package/src/decisions/decision-ledger.ts +5 -2
  169. package/src/decisions/discord-decision-cards.ts +133 -0
  170. package/src/decisions/telegram-decision-cards.ts +100 -0
  171. package/src/directives/version-at.ts +77 -0
  172. package/src/discord.ts +17 -0
  173. package/src/domains/outbound-alert.ts +44 -1
  174. package/src/domains/repeated-failure.ts +1 -1
  175. package/src/error-report/report.ts +200 -0
  176. package/src/exec-requests/planner.ts +8 -2
  177. package/src/exec-requests/runner.ts +64 -24
  178. package/src/exec-requests/store.ts +2 -0
  179. package/src/field/field-feed-auto.ts +48 -0
  180. package/src/field/field-reel.ts +8 -1
  181. package/src/hooks/error-report.ts +210 -0
  182. package/src/hooks/receiver.ts +41 -1
  183. package/src/import/apply.ts +128 -0
  184. package/src/import/plan.ts +197 -0
  185. package/src/index.ts +82 -7
  186. package/src/input-core/text-entry.ts +13 -5
  187. package/src/intake-plane/seat-answer.ts +96 -0
  188. package/src/intake-plane/seat-doc-route.ts +17 -3
  189. package/src/nexus/api/consult-requests.ts +19 -8
  190. package/src/nexus/api/error-reports.ts +108 -0
  191. package/src/nexus/api/http-server.ts +21 -1
  192. package/src/nexus/api/outbound-report.ts +7 -0
  193. package/src/nexus/api/running-runs-background.ts +68 -0
  194. package/src/nexus/api/running-runs-child.ts +6 -0
  195. package/src/nexus/api/share-captures.ts +91 -0
  196. package/src/nexus/api/skill-problems.ts +56 -0
  197. package/src/nexus/api/terminals.ts +24 -8
  198. package/src/nexus/index.ts +25 -1
  199. package/src/nexus/setup-status.ts +7 -2
  200. package/src/oauth/browser-availability.ts +14 -0
  201. package/src/oauth/codex-quota-policy.ts +16 -0
  202. package/src/onboarding/continue-here.ts +13 -8
  203. package/src/onboarding/web-first.ts +8 -1
  204. package/src/onboarding.ts +66 -12
  205. package/src/persona/presets.ts +105 -0
  206. package/src/release-loop/checklist.ts +28 -5
  207. package/src/self-dev/orchestrate.ts +18 -11
  208. package/src/shell-env-bootstrap.ts +44 -7
  209. package/src/skills/connect-claude-plugin.ts +142 -0
  210. package/src/skills/connect.ts +70 -0
  211. package/src/skills/index.ts +29 -1
  212. package/src/skills/repair.ts +124 -0
  213. package/src/skills/runner.ts +11 -2
  214. package/src/skills/trigger-extract.ts +1 -1
  215. package/src/task-orchestrator/surfaces/pod-command-job.ts +16 -8
  216. package/src/telegram-commands.ts +13 -0
  217. package/src/telegram.ts +34 -0
  218. package/src/user-config.ts +111 -13
  219. package/src/version/packed-revision.json +1 -1
  220. package/apps/pwa/out/_next/static/chunks/4836-2eba6079f31b88d4.js +0 -1
  221. package/apps/pwa/out/_next/static/chunks/8067-f74bf14d67ad98f1.js +0 -1
  222. package/apps/pwa/out/_next/static/chunks/8519-f6b5d8a5833a7603.js +0 -1
  223. package/apps/pwa/out/_next/static/chunks/8894-e92491a164192cc1.js +0 -1
  224. package/apps/pwa/out/_next/static/chunks/9740-67051141115ce775.js +0 -1
  225. package/apps/pwa/out/_next/static/chunks/9804-64c02c3c127d5014.js +0 -1
  226. package/apps/pwa/out/_next/static/chunks/app/approvals/page-d5ebde04844689e6.js +0 -1
  227. package/apps/pwa/out/_next/static/chunks/app/autopilot/page-10ed24f413ed66e9.js +0 -1
  228. package/apps/pwa/out/_next/static/chunks/app/board/page-211810b6c93ab66a.js +0 -1
  229. package/apps/pwa/out/_next/static/chunks/app/chat/page-80497e93a878ddc5.js +0 -1
  230. package/apps/pwa/out/_next/static/chunks/app/control/page-cee86394de407aa6.js +0 -1
  231. package/apps/pwa/out/_next/static/chunks/app/design-check/page-4ff8edab44196b8c.js +0 -1
  232. package/apps/pwa/out/_next/static/chunks/app/editor/page-92e4f44428d5e290.js +0 -1
  233. package/apps/pwa/out/_next/static/chunks/app/intake/page-2a05dfe80c8bdf16.js +0 -1
  234. package/apps/pwa/out/_next/static/chunks/app/layout-d9979633aa82f8e6.js +0 -1
  235. package/apps/pwa/out/_next/static/chunks/app/market/page-f7857826c2f97ad7.js +0 -1
  236. package/apps/pwa/out/_next/static/chunks/app/not-found-9699b1786be6171d.js +0 -1
  237. package/apps/pwa/out/_next/static/chunks/app/observatory/page-43dd772aee8af75f.js +0 -1
  238. package/apps/pwa/out/_next/static/chunks/app/reflection/page-973ab7af59c718b7.js +0 -1
  239. package/apps/pwa/out/_next/static/chunks/app/scheduler/page-a9f8d3865b91e436.js +0 -1
  240. package/apps/pwa/out/_next/static/chunks/app/setup/done/page-d42b99cdfb9f27c6.js +0 -1
  241. package/apps/pwa/out/_next/static/chunks/app/setup/page-2764ff40dad5a995.js +0 -1
  242. package/apps/pwa/out/_next/static/chunks/app/share/page-c5f1152db116ff96.js +0 -1
  243. package/apps/pwa/out/_next/static/chunks/app/showroom/page-f933bca81ea38a6f.js +0 -1
  244. package/apps/pwa/out/_next/static/chunks/app/tasks/page-c1c95739c0bff3b8.js +0 -1
  245. package/apps/pwa/out/_next/static/chunks/app/term/page-a7aaffcf771662d8.js +0 -1
  246. package/apps/pwa/out/_next/static/chunks/app/trace/page-71d18eec33b9c667.js +0 -1
  247. package/apps/pwa/out/_next/static/chunks/app/workflows/chat-ui/page-2c4e59079ee357ac.js +0 -1
  248. package/apps/pwa/out/_next/static/chunks/app/workflows/page-a81f113f3ef89d72.js +0 -1
  249. package/apps/pwa/out/_next/static/chunks/app/worktrees/page-fc27b00e20733146.js +0 -1
  250. package/apps/pwa/out/_next/static/css/f537bdb19c69306a.css +0 -1
  251. /package/apps/pwa/out/_next/static/{keajW7DDyghEs1y8oJe6T → lywc8m_HDzH9MKSDQhyEj}/_buildManifest.js +0 -0
  252. /package/apps/pwa/out/_next/static/{keajW7DDyghEs1y8oJe6T → lywc8m_HDzH9MKSDQhyEj}/_ssgManifest.js +0 -0
@@ -10,6 +10,7 @@ import { getElanousConfigDir } from '../elanous-config-dir.js';
10
10
  import type { ExecRequest } from '../exec-requests/store.js';
11
11
  import { resolveDaemonEndpoint } from '../nexus/daemon-endpoint.js';
12
12
  import { parseSeatAddress, resolveSeat } from '../seat-address/seat-address.js';
13
+ import { answerAsSeat, isNoGraphFailure } from './seat-answer.js';
13
14
 
14
15
  const POLL_MS = 5_000;
15
16
  const TIMEOUT_MS = 30 * 60_000;
@@ -34,7 +35,9 @@ export interface SeatDocDeps {
34
35
  sleep?: (ms: number) => Promise<void>;
35
36
  now?: () => number;
36
37
  pwaUrl?: () => string | undefined;
37
- log?: (event: 'submitted' | 'research' | 'settled' | 'delivered' | 'delivery-failed', data: Record<string, unknown>) => void;
38
+ log?: (event: 'submitted' | 'research' | 'settled' | 'delivered' | 'delivery-failed' | 'seat-answer', data: Record<string, unknown>) => void;
39
+ /** EV12d — the seat's own answer when no installed graph fits (default = role file ⊕ owned checklist ⊕ recent, one LLM call). */
40
+ seatAnswer?: (seatAddress: string, question: string) => Promise<{ title: string; text: string } | null>;
38
41
  }
39
42
 
40
43
  export function isSeatDocRequest(text: string): boolean {
@@ -194,9 +197,20 @@ export async function submitSeatDocRequest({ text, reportTo, deps = {} }: {
194
197
  }
195
198
  log('settled', { id, status: result?.status ?? (lookupAvailable ? 'timeout' : 'lookup-unavailable'), lastConfirmedStatus });
196
199
  const links = result?.status === 'done' ? await fileLinks(id, result, deps) : new Map<string, string>();
200
+ let reply = message(id, result, withoutResearch, lookupAvailable, accessiblePwaUrl((deps.pwaUrl ?? chatPwaUrl)()), links);
201
+ if (isNoGraphFailure(result)) {
202
+ // EV12d — a question with no fitting graph gets the seat's own answer, not «문서 요청 실패».
203
+ const seat = result!.seats![0]!.seat;
204
+ try {
205
+ const answer = await (deps.seatAnswer ?? answerAsSeat)(seat, body);
206
+ if (answer) reply = `${answer.title} 답 (접수번호 ${id})\n${answer.text}`;
207
+ log('seat-answer', { id, seat, answered: !!answer });
208
+ } catch (error) {
209
+ log('seat-answer', { id, seat, answered: false, reason: String(error).slice(0, 200) });
210
+ }
211
+ }
197
212
  try {
198
- const sent = await (deps.sendOutbound ?? sendOutbound)(message(id, result, withoutResearch, lookupAvailable,
199
- accessiblePwaUrl((deps.pwaUrl ?? chatPwaUrl)()), links), 'report', reportTo);
213
+ const sent = await (deps.sendOutbound ?? sendOutbound)(reply, 'report', reportTo);
200
214
  log(sent ? 'delivered' : 'delivery-failed', { id });
201
215
  } catch (error) {
202
216
  log('delivery-failed', { id, reason: String(error).slice(0, 200) });
@@ -2,7 +2,8 @@ import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, re
2
2
  import { randomUUID } from 'node:crypto';
3
3
  import { dirname, join } from 'node:path';
4
4
  import { debug } from '../../debug/log.js';
5
- import { sendOutbound } from '../../domains/outbound-alert.js';
5
+ import { routeOutbound } from '../outbound/router.js';
6
+ import { getUserConfig } from '../../user-config.js';
6
7
  import { effectiveInstanceRoot } from '../../instance/resolve.js';
7
8
  import { jsonResponse } from './json-response.js';
8
9
 
@@ -22,7 +23,10 @@ interface ConsultRequest {
22
23
  export interface ConsultRequestsDeps {
23
24
  root?: () => string;
24
25
  now?: () => string;
25
- send?: typeof sendOutbound;
26
+ /** Owner alert. Default = the daemon's own in-process router (the same one `/v1/outbound` uses).
27
+ * ⛔ Never `sendOutbound` here: it is a synchronous curl to this very daemon's `/v1/outbound`, so inside the
28
+ * daemon it blocks the event loop until it times out (~25 s · `unreachable`) and the alert is lost (CS1 · 10-01). */
29
+ send?: (text: string, kind: string) => unknown;
26
30
  }
27
31
 
28
32
  function file(root: string): string {
@@ -107,11 +111,18 @@ export async function handleConsultRequests(req: Request, deps: ConsultRequestsD
107
111
  return jsonResponse({ error: 'journal-unavailable' }, 503);
108
112
  }
109
113
  debug.log('consult.request', 'accepted', { receiptId: entry.receiptId });
110
- try {
111
- (deps.send ?? sendOutbound)(
112
- `📮 상담 문의 ${entry.receiptId} · ${entry.name}(${entry.kind === 'personal' ? '개인' : (entry.org ?? '회사')}) · 관심 ${entry.interest}\n앱에서 보기`,
113
- 'alert',
114
- );
115
- } catch { /* the local receipt remains accepted if delivery is unavailable */ }
114
+ const alert = `📮 상담 문의 ${entry.receiptId} · ${entry.name}(${entry.kind === 'personal' ? '개인' : (entry.org ?? '회사')}) · 관심 ${entry.interest}\n앱에서 보기`;
115
+ // Fire and forget — the receipt is already on disk; the answer must not wait for the channel.
116
+ void Promise.resolve()
117
+ .then(() => (deps.send ?? notifyInProcess)(alert, 'alert'))
118
+ .catch((error: unknown) => { debug.log('consult.request', 'notify-failed', { receiptId: entry.receiptId, reason: error instanceof Error ? error.message : String(error) }); });
116
119
  return jsonResponse({ receiptId: entry.receiptId, receivedAt: entry.receivedAt }, 202);
117
120
  }
121
+
122
+ async function notifyInProcess(text: string, kind: string): Promise<void> {
123
+ const result = await routeOutbound(getUserConfig(), { text, markdown: false, kind });
124
+ debug.log('consult.request', result.delivered ? 'notified' : 'notify-failed', {
125
+ delivered: result.delivered,
126
+ channels: result.channels.map((channel) => `${channel.type}:${channel.ok ? 'ok' : 'fail'}`),
127
+ });
128
+ }
@@ -0,0 +1,108 @@
1
+ // ER2 — the Primary side of error reports. The bot VM forwards each accepted report here over the tailnet
2
+ // (ingest token). It is stored under <instance>/error-reports/YYYY/MM/DD/<reportId>.json for 30 days, and the
3
+ // operator gets one Telegram line per distinct error per hour — never the message, stack or identity.
4
+ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
5
+ import { join } from 'node:path';
6
+ import { debug } from '../../debug/log.js';
7
+ import { effectiveInstanceRoot } from '../../instance/resolve.js';
8
+ import type { ReceivedReport } from '../../hooks/error-report.js';
9
+ import { validateErrorReport } from '../../hooks/error-report.js';
10
+ import { jsonResponse } from './json-response.js';
11
+ import { routeOutboundInProcess } from './outbound-report.js';
12
+
13
+ export const ERROR_REPORT_KEEP_DAYS = 30;
14
+ const DAY_MS = 86_400_000;
15
+
16
+ export interface ErrorReportIngestDeps {
17
+ root?: () => string;
18
+ now?: () => number;
19
+ /** Operator alert. Default = the daemon's in-process router (never a self-call · OB8). */
20
+ alert?: (text: string, kind: string) => Promise<boolean>;
21
+ }
22
+
23
+ function storeDir(root: string): string { return join(root, 'error-reports'); }
24
+
25
+ /** Removes day folders older than the keep window. Returns how many days were removed. */
26
+ export function pruneErrorReports(root: string, now: number): number {
27
+ const base = storeDir(root);
28
+ if (!existsSync(base)) return 0;
29
+ let removed = 0;
30
+ for (const year of readdirSync(base).filter((n) => /^\d{4}$/.test(n))) {
31
+ for (const month of readdirSync(join(base, year)).filter((n) => /^\d{2}$/.test(n))) {
32
+ for (const day of readdirSync(join(base, year, month)).filter((n) => /^\d{2}$/.test(n))) {
33
+ const at = Date.parse(`${year}-${month}-${day}T00:00:00Z`);
34
+ if (Number.isFinite(at) && now - at > ERROR_REPORT_KEEP_DAYS * DAY_MS + DAY_MS) {
35
+ rmSync(join(base, year, month, day), { recursive: true, force: true });
36
+ removed++;
37
+ }
38
+ }
39
+ }
40
+ }
41
+ return removed;
42
+ }
43
+
44
+ /** One alert per dedupe per hour, remembered on disk so a restart does not repeat it. */
45
+ function shouldAlert(root: string, dedupe: string, now: number): boolean {
46
+ const path = join(storeDir(root), 'alerts.json');
47
+ let seen: Record<string, number> = {};
48
+ try { seen = JSON.parse(readFileSync(path, 'utf8')) as Record<string, number>; } catch { /* first alert */ }
49
+ for (const [k, t] of Object.entries(seen)) if (now - t > DAY_MS) delete seen[k];
50
+ const last = seen[dedupe];
51
+ if (last !== undefined && now - last < 3600_000) return false;
52
+ seen[dedupe] = now;
53
+ mkdirSync(storeDir(root), { recursive: true, mode: 0o700 });
54
+ writeFileSync(path, JSON.stringify(seen), { mode: 0o600 });
55
+ return true;
56
+ }
57
+
58
+ export function errorReportAlertText(item: ReceivedReport, count24h: number): string {
59
+ const r = item.report;
60
+ return `🧯 오류 보고 ${r.code} · v${r.app.version} · ${r.app.surface} · 같은 오류 24h ${count24h}회 · ${item.reportId}`;
61
+ }
62
+
63
+ /** Same dedupe stored in the last 24 h, counted from the store itself. */
64
+ function count24h(root: string, dedupe: string, now: number): number {
65
+ let n = 0;
66
+ for (const offset of [0, 1]) {
67
+ const d = new Date(now - offset * DAY_MS);
68
+ const dir = join(storeDir(root), String(d.getUTCFullYear()), String(d.getUTCMonth() + 1).padStart(2, '0'), String(d.getUTCDate()).padStart(2, '0'));
69
+ if (!existsSync(dir)) continue;
70
+ for (const name of readdirSync(dir).filter((f) => f.endsWith('.json'))) {
71
+ try {
72
+ const item = JSON.parse(readFileSync(join(dir, name), 'utf8')) as ReceivedReport;
73
+ if (item.dedupe === dedupe && now - Date.parse(item.receivedAt) < DAY_MS) n++;
74
+ } catch { /* a broken file is not counted */ }
75
+ }
76
+ }
77
+ return n;
78
+ }
79
+
80
+ /** POST /v1/reports/ingest — the caller (router) has already checked the ingest token. */
81
+ export async function handleErrorReportIngest(req: Request, deps: ErrorReportIngestDeps = {}): Promise<Response> {
82
+ const root = (deps.root ?? effectiveInstanceRoot)();
83
+ const now = (deps.now ?? Date.now)();
84
+ let body: unknown;
85
+ try { body = await req.json(); } catch { return jsonResponse({ error: 'invalid', field: 'body' }, 400); }
86
+ const item = body as Partial<ReceivedReport>;
87
+ if (typeof item.reportId !== 'string' || !/^er_[0-9a-z]{26}$/.test(item.reportId)) return jsonResponse({ error: 'invalid', field: 'reportId' }, 400);
88
+ if (typeof item.dedupe !== 'string' || !/^[0-9a-f]{12}$/.test(item.dedupe)) return jsonResponse({ error: 'invalid', field: 'dedupe' }, 400);
89
+ if (typeof item.receivedAt !== 'string' || !Number.isFinite(Date.parse(item.receivedAt))) return jsonResponse({ error: 'invalid', field: 'receivedAt' }, 400);
90
+ const checked = validateErrorReport(item.report);
91
+ if (!checked.ok) return jsonResponse({ error: 'invalid', field: `report.${checked.field}` }, 400);
92
+ const stored: ReceivedReport = { reportId: item.reportId, dedupe: item.dedupe, receivedAt: item.receivedAt, report: checked.report };
93
+ const at = new Date(Date.parse(stored.receivedAt));
94
+ const dir = join(storeDir(root), String(at.getUTCFullYear()), String(at.getUTCMonth() + 1).padStart(2, '0'), String(at.getUTCDate()).padStart(2, '0'));
95
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
96
+ writeFileSync(join(dir, `${stored.reportId}.json`), JSON.stringify(stored), { mode: 0o600 });
97
+ let pruned = 0;
98
+ try { pruned = pruneErrorReports(root, now); } catch { /* pruning never blocks a receipt */ }
99
+ const count = count24h(root, stored.dedupe, now);
100
+ const alerted = shouldAlert(root, stored.dedupe, now);
101
+ debug.log('error-report.ingest', 'stored', { code: stored.report.code, surface: stored.report.app.surface, count24h: count, alerted, pruned });
102
+ if (alerted) {
103
+ const alert = deps.alert ?? routeOutboundInProcess;
104
+ void Promise.resolve().then(() => alert(errorReportAlertText(stored, count), 'alert'))
105
+ .catch((error: unknown) => debug.log('error-report.ingest', 'alert-failed', { reason: error instanceof Error ? error.message : String(error) }));
106
+ }
107
+ return jsonResponse({ stored: true, reportId: stored.reportId, count }, 202);
108
+ }
@@ -15,6 +15,7 @@
15
15
  // Port policy (N1-2 decision): auto-pick from 31415 upward. Actual port
16
16
  // is recorded in runtime.json + printed in boot banner.
17
17
 
18
+ import { setInProcessOutbound } from '../../domains/outbound-alert.js';
18
19
  import { getUserConfig } from '../../user-config.js';
19
20
  import type { TelegramEvent } from '../../workflow-runtime/triggers/telegram-source.js';
20
21
  import { spawnSync } from 'node:child_process';
@@ -385,6 +386,8 @@ import {
385
386
  import { handleSessionTurnControl, SESSION_TURN_CONTROL_PATH } from '../../session/session-turn-control.js';
386
387
  import { authenticatePodCredential, handlePodGrokCredential, handlePodGithubCredential, POD_CREDENTIAL_GROK_PATH, POD_CREDENTIAL_GITHUB_PATH } from './pod-credential-api.js';
387
388
  import { handleOutboundReport } from './outbound-report.js';
389
+ import { handleErrorReportIngest } from './error-reports.js';
390
+ import { routeOutboundInProcess } from './outbound-report.js';
388
391
  import { handleSelfEvent } from './self-event.js';
389
392
  /** 매매 «실행»은 애드온이다(대표 09-20 결정 ③ · 별도 상용 저장소 · release/trading-export.yaml) — 공개 코어엔 없다.
390
393
  * 있으면 그 경로를 켜고, 없으면 501. 경로를 변수로 둬 공개본 타입 검사가 «모듈 없음»으로 막히지 않게 한다. */
@@ -757,6 +760,8 @@ export function startNexusHttpServer(opts: NexusHttpServerOpts): NexusHttpServer
757
760
  ...(websocketHandler ? { websocket: websocketHandler } : {}),
758
761
  } as unknown as Parameters<typeof Bun.serve>[0];
759
762
  server = Bun.serve(fetchOpts);
763
+ // OB8 — this process now answers /v1/outbound itself; its own sendOutbound calls must not curl it.
764
+ setInProcessOutbound(routeOutboundInProcess);
760
765
  // 데몬이 «실제로» 떴을 때만 재시작 복구(맡긴 일) — 주입된 러너(시험)는 건드리지 않는다.
761
766
  if (!opts.execRequests) { try { execRequestRunner().reconcileInterrupted(); } catch { /* 복구 실패가 서버 기동을 막지 않는다 */ } }
762
767
  resolvedPort = port;
@@ -781,6 +786,7 @@ export function startNexusHttpServer(opts: NexusHttpServerOpts): NexusHttpServer
781
786
  url,
782
787
  stop() {
783
788
  try { server!.stop(true); } catch { /* idempotent */ }
789
+ setInProcessOutbound(null);
784
790
  },
785
791
  };
786
792
  }
@@ -876,7 +882,7 @@ export async function routeRequest(
876
882
  const fallbackAuthorized = fallbackToken !== undefined && offered?.startsWith('Bearer ') === true
877
883
  && compareTokenConstTime(offered.slice('Bearer '.length).trim(), fallbackToken);
878
884
  if (!(opts.metaApi ? checkAuth(req, opts.metaApi) : fallbackAuthorized)) {
879
- const name = method === 'POST' && pathname === '/v1/tasks' && opts.metaApi
885
+ const name = method === 'POST' && (pathname === '/v1/tasks' || pathname === '/v1/reports/ingest') && opts.metaApi
880
886
  ? matchIngestAuthorization(req)?.name
881
887
  : undefined;
882
888
  if (name === undefined) {
@@ -1304,6 +1310,8 @@ export async function routeRequest(
1304
1310
  if (!opts.metaApi) return jsonResponse({ error: 'meta-api-not-wired' }, 503);
1305
1311
  return handleTaskCreatePost(req, opts.metaApi);
1306
1312
  }
1313
+ // ER2 — error reports forwarded by the bot VM (ingest token · same as /v1/tasks).
1314
+ if (method === 'POST' && pathname === '/v1/reports/ingest') return handleErrorReportIngest(req);
1307
1315
  if (method === 'POST' && pathname.startsWith('/v1/tasks/') && pathname.endsWith('/approve')) {
1308
1316
  if (!opts.metaApi) return jsonResponse({ error: 'meta-api-not-wired' }, 503);
1309
1317
  let taskId: string;
@@ -1969,6 +1977,18 @@ export async function routeRequest(
1969
1977
  }
1970
1978
  // HANDOFF §4.2 follow-up — skill-side mirror of the workflow
1971
1979
  // router. Same regex → LLM cascade, applied to the skill index.
1980
+ // SK2 (PWA) — skills the index could not read: list ⊕ confirmed repair.
1981
+ if (pathname === '/v1/skills/problems' || pathname === '/v1/skills/problems/repair') {
1982
+ if (!opts.metaApi) return jsonResponse({ error: 'meta-api-not-wired' }, 503);
1983
+ const { handleSkillProblems } = await import('./skill-problems.js');
1984
+ return handleSkillProblems(req, opts.metaApi);
1985
+ }
1986
+ // SC1 (PWA · beta) — «공유용 캡처»: masked screen PNG in, metadata chunks stripped, stored under <configDir>/captures.
1987
+ if (pathname === '/v1/captures' || pathname.startsWith('/v1/captures/')) {
1988
+ if (!opts.metaApi) return jsonResponse({ error: 'meta-api-not-wired' }, 503);
1989
+ const { handleShareCaptures } = await import('./share-captures.js');
1990
+ return handleShareCaptures(req, opts.metaApi);
1991
+ }
1972
1992
  if (pathname === '/v1/skills/route' && method === 'POST') {
1973
1993
  if (!opts.metaApi) return jsonResponse({ error: 'meta-api-not-wired' }, 503);
1974
1994
  return handleSkillRoute(req, opts.metaApi);
@@ -44,6 +44,13 @@ function json(body: unknown, status: number): Response {
44
44
  });
45
45
  }
46
46
 
47
+ /** The daemon's own sendOutbound path (OB8) — what POST /v1/outbound does, without the HTTP self-call. */
48
+ export async function routeOutboundInProcess(text: string, kind: string): Promise<boolean> {
49
+ const result = await routeOutbound(getUserConfig(), { text, markdown: false, kind });
50
+ if (result.delivered) recordOutboundEvent(text, kind);
51
+ return result.delivered;
52
+ }
53
+
47
54
  /** POST /v1/outbound — fan one message out to the routed channels. */
48
55
  export async function handleOutboundReport(req: Request, metaApi: MetaApiOpts): Promise<Response> {
49
56
  if (!checkAuth(req, metaApi)) return json({ error: 'unauthorized' }, 401);
@@ -0,0 +1,68 @@
1
+ // The terminals list must answer at once and must never share the event loop with a ledger scan: the running-runs query
2
+ // reads every run ledger synchronously (~5k files · 0.9–1.8 s on 10-02), which froze typing in the web terminal while the
3
+ // PWA polled the list. This keeps the last result as a snapshot and refreshes it in a separate bun process, on demand.
4
+ import { join } from 'node:path';
5
+ import { debug } from '../../debug/log.js';
6
+ import type { RunningRunsResult } from '../../self-implement/running-runs.js';
7
+
8
+ export const RUNNING_RUNS_REFRESH_MS = 15_000;
9
+ /** A refresh that has not answered by then is abandoned; the next request starts another. */
10
+ export const RUNNING_RUNS_REFRESH_TIMEOUT_MS = 60_000;
11
+
12
+ export interface RunningRunsSnapshot {
13
+ /** Last result, or null before the first refresh finished (subjects then keep unknown assessments). */
14
+ readonly result: RunningRunsResult | null;
15
+ /** Milliseconds since `result` was computed; null when there is none. */
16
+ readonly ageMs: number | null;
17
+ }
18
+
19
+ /** Runs the query somewhere other than this event loop and resolves with its JSON output. */
20
+ export type RunningRunsRunner = (includeTest: boolean) => Promise<RunningRunsResult>;
21
+
22
+ export const RUNNING_RUNS_CHILD_SCRIPT = join(import.meta.dir, 'running-runs-child.ts');
23
+
24
+ /** Default runner: `bun running-runs-child.ts [--include-test]` — the child does the ledger I/O, we only parse its stdout. */
25
+ export const childProcessRunner: RunningRunsRunner = async (includeTest) => {
26
+ const proc = Bun.spawn([process.execPath, RUNNING_RUNS_CHILD_SCRIPT, ...(includeTest ? ['--include-test'] : [])], {
27
+ stdout: 'pipe', stderr: 'ignore', stdin: 'ignore', env: process.env,
28
+ });
29
+ const timer = setTimeout(() => proc.kill(), RUNNING_RUNS_REFRESH_TIMEOUT_MS);
30
+ try {
31
+ const [out, code] = await Promise.all([new Response(proc.stdout).text(), proc.exited]);
32
+ if (code !== 0) throw new Error(`running-runs child exit ${code}`);
33
+ return JSON.parse(out) as RunningRunsResult;
34
+ } finally { clearTimeout(timer); }
35
+ };
36
+
37
+ export interface RunningRunsBackground {
38
+ /** Never blocks: returns the last snapshot and starts a refresh when it is stale and none is in flight. */
39
+ snapshot(options: { includeTest?: boolean }): RunningRunsSnapshot;
40
+ }
41
+
42
+ export function createRunningRunsBackground(runner: RunningRunsRunner = childProcessRunner, now: () => number = Date.now, refreshMs = RUNNING_RUNS_REFRESH_MS): RunningRunsBackground {
43
+ const slots = new Map<boolean, { at: number; result: RunningRunsResult }>();
44
+ const flights = new Set<boolean>();
45
+ const refresh = (includeTest: boolean): void => {
46
+ if (flights.has(includeTest)) return;
47
+ flights.add(includeTest);
48
+ const started = now();
49
+ void runner(includeTest)
50
+ .then((result) => {
51
+ slots.set(includeTest, { at: now(), result });
52
+ debug.log('nexus.terminals', 'running-runs-refreshed', { includeTest, ms: now() - started });
53
+ })
54
+ .catch((error: unknown) => {
55
+ debug.log('nexus.terminals', 'running-runs-refresh-failed', { includeTest, reason: error instanceof Error ? error.message.slice(0, 80) : 'unknown' }, { level: 'warn' });
56
+ })
57
+ .finally(() => { flights.delete(includeTest); });
58
+ };
59
+ return {
60
+ snapshot(options) {
61
+ const includeTest = options.includeTest === true;
62
+ const slot = slots.get(includeTest);
63
+ const t = now();
64
+ if (!slot || t - slot.at >= refreshMs) refresh(includeTest);
65
+ return slot ? { result: slot.result, ageMs: t - slot.at } : { result: null, ageMs: null };
66
+ },
67
+ };
68
+ }
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env bun
2
+ // Child of running-runs-background.ts: runs the running-runs query (all the ledger I/O) and prints the result as JSON.
3
+ import { queryRunningRuns } from '../../self-implement/running-runs.js';
4
+
5
+ const result = queryRunningRuns({ includeTest: process.argv.includes('--include-test'), caller: 'nexus.terminals' });
6
+ process.stdout.write(JSON.stringify(result));
@@ -0,0 +1,91 @@
1
+ // SC1 — 앱 안 «공유용 캡처»(beta) 받는 쪽. PWA 가 화면을 가린 뒤 PNG 로 올린다(티저·현장·보고용).
2
+ // POST /v1/captures body = image/png(≤ 8 MiB) → { id, bytes, droppedChunks }
3
+ // GET /v1/captures → { items: [{ id, bytes, at }] } (최신 먼저 · 50개)
4
+ // GET /v1/captures/<id> → image/png
5
+ // ⛔ 두 번째 방어: 받은 PNG 에서 «그림 아닌» 덩어리(tEXt·iTXt·zTXt·eXIf·iCCP·tIME 등 메타)를 전부 걷고 저장한다 —
6
+ // 화면 쪽 가림이 놓친 것이 파일 메타로 새지 않게. 남기는 것은 그림을 그리는 데 필요한 덩어리뿐이다.
7
+ import { randomBytes } from 'node:crypto';
8
+ import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
9
+ import { join } from 'node:path';
10
+ import { debug } from '../../debug/log.js';
11
+ import { getElanousConfigDir } from '../../elanous-config-dir.js';
12
+ import { jsonResponse } from './json-response.js';
13
+ import { checkAuth, type MetaApiOpts } from './meta-api.js';
14
+
15
+ export const SHARE_CAPTURES_PATH = '/v1/captures';
16
+ const MAX_BYTES = 8 * 1024 * 1024;
17
+ const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
18
+ /** Chunks needed to draw the picture. Everything else (text, EXIF, ICC profile, time…) is dropped. */
19
+ const KEEP = new Set(['IHDR', 'PLTE', 'IDAT', 'IEND', 'tRNS', 'gAMA', 'cHRM', 'sRGB', 'pHYs']);
20
+ const ID = /^share-\d{8}-\d{6}-[0-9a-f]{6}$/;
21
+
22
+ export interface ShareCapturesDeps { configDir?: string; now?: () => Date }
23
+
24
+ /** Rebuild a PNG with only drawing chunks. Returns null when it is not a well-formed PNG. */
25
+ export function stripPngMetadata(input: Uint8Array): { png: Buffer; dropped: string[] } | null {
26
+ const buf = Buffer.from(input);
27
+ if (buf.length < 8 + 12 || !buf.subarray(0, 8).equals(PNG_SIGNATURE)) return null;
28
+ const parts: Buffer[] = [PNG_SIGNATURE];
29
+ const dropped: string[] = [];
30
+ let offset = 8;
31
+ let sawHeader = false;
32
+ let sawEnd = false;
33
+ while (offset + 12 <= buf.length) {
34
+ const length = buf.readUInt32BE(offset);
35
+ const type = buf.subarray(offset + 4, offset + 8).toString('latin1');
36
+ const end = offset + 12 + length;
37
+ if (end > buf.length || !/^[A-Za-z]{4}$/.test(type)) return null;
38
+ if (!sawHeader && type !== 'IHDR') return null;
39
+ sawHeader = true;
40
+ if (KEEP.has(type)) parts.push(buf.subarray(offset, end)); else dropped.push(type);
41
+ offset = end;
42
+ if (type === 'IEND') { sawEnd = true; break; }
43
+ }
44
+ if (!sawEnd) return null;
45
+ return { png: Buffer.concat(parts), dropped };
46
+ }
47
+
48
+ function capturesDir(deps: ShareCapturesDeps): string {
49
+ return join(deps.configDir ?? getElanousConfigDir(), 'captures');
50
+ }
51
+
52
+ function newId(now: Date): string {
53
+ const p = (n: number) => String(n).padStart(2, '0');
54
+ return `share-${now.getFullYear()}${p(now.getMonth() + 1)}${p(now.getDate())}-${p(now.getHours())}${p(now.getMinutes())}${p(now.getSeconds())}-${randomBytes(3).toString('hex')}`;
55
+ }
56
+
57
+ export async function handleShareCaptures(req: Request, metaApi: MetaApiOpts, deps: ShareCapturesDeps = {}): Promise<Response> {
58
+ if (!checkAuth(req, metaApi)) return jsonResponse({ error: 'unauthorized' }, 401);
59
+ const pathname = new URL(req.url).pathname;
60
+ const dir = capturesDir(deps);
61
+
62
+ if (pathname === SHARE_CAPTURES_PATH && req.method === 'POST') {
63
+ const declared = Number(req.headers.get('content-length') ?? 0);
64
+ if (declared > MAX_BYTES) return jsonResponse({ error: 'too-large', maxBytes: MAX_BYTES }, 413);
65
+ const body = new Uint8Array(await req.arrayBuffer());
66
+ if (body.byteLength > MAX_BYTES) return jsonResponse({ error: 'too-large', maxBytes: MAX_BYTES }, 413);
67
+ const cleaned = stripPngMetadata(body);
68
+ if (!cleaned) return jsonResponse({ error: 'not-png' }, 400);
69
+ const id = newId((deps.now ?? (() => new Date()))());
70
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
71
+ writeFileSync(join(dir, `${id}.png`), cleaned.png, { mode: 0o600, flag: 'wx' });
72
+ debug.log('share-capture', 'stored', { id, bytes: cleaned.png.length, dropped: cleaned.dropped });
73
+ return jsonResponse({ id, bytes: cleaned.png.length, droppedChunks: cleaned.dropped }, 201);
74
+ }
75
+
76
+ if (pathname === SHARE_CAPTURES_PATH && req.method === 'GET') {
77
+ const items = existsSync(dir)
78
+ ? readdirSync(dir).filter((f) => f.endsWith('.png') && ID.test(f.slice(0, -4))).sort().reverse().slice(0, 50)
79
+ .map((f) => { const s = statSync(join(dir, f)); return { id: f.slice(0, -4), bytes: s.size, at: s.mtime.toISOString() }; })
80
+ : [];
81
+ return jsonResponse({ items });
82
+ }
83
+
84
+ const id = pathname.slice(SHARE_CAPTURES_PATH.length + 1);
85
+ if (req.method === 'GET' && ID.test(id)) {
86
+ const file = join(dir, `${id}.png`);
87
+ if (!existsSync(file)) return jsonResponse({ error: 'not-found' }, 404);
88
+ return new Response(readFileSync(file), { headers: { 'content-type': 'image/png', 'cache-control': 'no-store' } });
89
+ }
90
+ return jsonResponse({ error: 'not-found' }, 404);
91
+ }
@@ -0,0 +1,56 @@
1
+ // SK2 (PWA) — 색인이 못 읽은 스킬을 PWA 에도 «안내 ⊕ 확인 뒤 수리»로.
2
+ // GET /v1/skills/problems → { items: [{ id, name, code, fixable, hint? }] } — 경로 대신 해시 id 만 내보낸다
3
+ // POST /v1/skills/problems/repair → body { id } · 고칠 수 있는 것만 고친다(남의 도구 파일 — PWA 가 먼저 확인을 받는다)
4
+ import { createHash } from 'node:crypto';
5
+ import { debug } from '../../debug/log.js';
6
+ import { getSkillIndex, skillIndexProblems, type SkillIndexProblem } from '../../skills/index.js';
7
+ import { applySkillRepair, planSkillRepair } from '../../skills/repair.js';
8
+ import { jsonResponse } from './json-response.js';
9
+ import { checkAuth, type MetaApiOpts } from './meta-api.js';
10
+
11
+ export const SKILL_PROBLEMS_PATH = '/v1/skills/problems';
12
+
13
+ export interface SkillProblemsDeps {
14
+ problems?: () => SkillIndexProblem[];
15
+ }
16
+
17
+ function problemId(p: SkillIndexProblem): string {
18
+ return createHash('sha256').update(`${p.dir}\u0000${p.name}`).digest('hex').slice(0, 16);
19
+ }
20
+
21
+ function current(deps: SkillProblemsDeps): SkillIndexProblem[] {
22
+ if (deps.problems) return deps.problems();
23
+ getSkillIndex();
24
+ return skillIndexProblems();
25
+ }
26
+
27
+ export async function handleSkillProblems(req: Request, metaApi: MetaApiOpts, deps: SkillProblemsDeps = {}): Promise<Response> {
28
+ if (!checkAuth(req, metaApi)) return jsonResponse({ error: 'unauthorized' }, 401);
29
+ const pathname = new URL(req.url).pathname;
30
+ if (pathname === SKILL_PROBLEMS_PATH) {
31
+ if (req.method !== 'GET') return jsonResponse({ error: 'method-not-allowed' }, 405);
32
+ const items = current(deps).map((p) => {
33
+ const plan = planSkillRepair(p);
34
+ return {
35
+ id: problemId(p), name: p.name, code: p.code,
36
+ fixable: plan.kind === 'fixable' || plan.kind === 'fixable-permission',
37
+ ...(plan.kind === 'permission' || plan.kind === 'manual' ? { hint: plan.hint.replace(p.dir, '…') } : {}),
38
+ };
39
+ });
40
+ return jsonResponse({ items });
41
+ }
42
+ if (pathname === `${SKILL_PROBLEMS_PATH}/repair`) {
43
+ if (req.method !== 'POST') return jsonResponse({ error: 'method-not-allowed' }, 405);
44
+ let body: unknown;
45
+ try { body = await req.json(); } catch { return jsonResponse({ error: 'bad_request' }, 400); }
46
+ const id = body && typeof body === 'object' && typeof (body as { id?: unknown }).id === 'string' ? (body as { id: string }).id : '';
47
+ const problem = current(deps).find((p) => problemId(p) === id);
48
+ if (!problem) return jsonResponse({ error: 'not-found' }, 404);
49
+ const plan = planSkillRepair(problem);
50
+ if (plan.kind !== 'fixable' && plan.kind !== 'fixable-permission') return jsonResponse({ error: 'not-fixable', hint: plan.hint.replace(problem.dir, '…') }, 409);
51
+ const result = applySkillRepair(plan);
52
+ debug.log('skills.repair', 'pwa-request', { name: problem.name, ok: result.ok });
53
+ return result.ok ? jsonResponse({ ok: true, name: problem.name }) : jsonResponse({ ok: false, reason: result.reason }, 409);
54
+ }
55
+ return jsonResponse({ error: 'not-found' }, 404);
56
+ }
@@ -34,6 +34,7 @@ import { classifyPtyOwnerRunUsage, joinPtyLineage, resolveRunTermination, type P
34
34
  import { loadRunLedger, resolveFederatedRunLedgerDirectories, runLedgerDir } from '../../self-implement/run-ledger.js';
35
35
  import { requestRemotePtyControl, type PtyControlAction, type PtyControlPayload, type PtyControlRequestOptions, type PtyControlResult } from '../../pty-shell/pty-control-ipc.js';
36
36
  import { encodeSgrMouse, type PtyMouseInput } from '../../pty-shell/pty-mouse.js';
37
+ import { createRunningRunsBackground, type RunningRunsBackground } from './running-runs-background.js';
37
38
  import { queryRunningRuns, type RunningRunAssessment, type RunningRunPresence, type RunningRunStatus, type RunningRunsResult } from '../../self-implement/running-runs.js';
38
39
  // ⭐P4 §4-1 — 표시 폭(wide-char/CJK/emoji 인지) SSOT. PNG 캔버스 cols 파생용.
39
40
  import { cellWidth } from '../../ui/printer.js';
@@ -494,6 +495,12 @@ function runningRunsCacheFor(
494
495
  return binding.cache;
495
496
  }
496
497
 
498
+ let liveBackground: RunningRunsBackground | null = null;
499
+ function liveRunningRunsBackground(): RunningRunsBackground {
500
+ liveBackground ??= createRunningRunsBackground();
501
+ return liveBackground;
502
+ }
503
+
497
504
  const liveTerminalsListDeps: TerminalsListDeps = {
498
505
  ptyManifestTargets,
499
506
  listPtyManifestRows,
@@ -1054,17 +1061,26 @@ export function handleTerminalsList(req: Request, opts: MetaApiOpts, deps: Termi
1054
1061
  const resolveRunTerminationForRow = (runId: string): RunTermination => deps.runTerminated
1055
1062
  ? deps.runTerminated(runId, { includeTest })
1056
1063
  : 'ledger-indeterminate';
1057
- const runningRunsCache = runningRunsCacheFor(getRunningRuns, deps.now ?? Date.now);
1058
1064
  let runningRuns: RunningRunsResult | null = null;
1059
1065
  let runningRunsAgeMs = 0;
1060
1066
  let runningRunsQueryFailed = false;
1061
- try {
1062
- const cached = runningRunsCache({ includeTest });
1063
- runningRuns = cached.result;
1064
- runningRunsAgeMs = cached.ageMs;
1065
- } catch {
1066
- // Fail-soft and do not cache: subjects keep unknown assessments.
1067
- runningRunsQueryFailed = true;
1067
+ if (getRunningRuns === queryRunningRuns) {
1068
+ // Live daemon: never scan ledgers on this event loop (it froze web-terminal typing) — use the last snapshot a
1069
+ // separate process refreshed. Before the first one lands, subjects keep unknown assessments and the age is null.
1070
+ const snap = liveRunningRunsBackground().snapshot({ includeTest });
1071
+ runningRuns = snap.result;
1072
+ if (snap.ageMs === null) runningRunsQueryFailed = true;
1073
+ else runningRunsAgeMs = snap.ageMs;
1074
+ } else {
1075
+ const runningRunsCache = runningRunsCacheFor(getRunningRuns, deps.now ?? Date.now);
1076
+ try {
1077
+ const cached = runningRunsCache({ includeTest });
1078
+ runningRuns = cached.result;
1079
+ runningRunsAgeMs = cached.ageMs;
1080
+ } catch {
1081
+ // Fail-soft and do not cache: subjects keep unknown assessments.
1082
+ runningRunsQueryFailed = true;
1083
+ }
1068
1084
  }
1069
1085
  const ptyLessSubAgentCollection = collectPtyLessSubAgents(
1070
1086
  getLogStore,
@@ -2609,10 +2609,16 @@ export async function runNexus(opts: RunNexusOptions = {}): Promise<RunNexusHand
2609
2609
  runTurnImpl,
2610
2610
  getBot: () => getBotRef.current,
2611
2611
  });
2612
+ // DEC-TG(대표 10-02) — decision cards in the owner's Discord DM. Filled once the bot exists (below).
2613
+ let decisionWire: import('../decisions/discord-decision-cards.js').DiscordDecisionWire | null = null;
2612
2614
  const composedOnMessage = async (
2613
2615
  ctx: import('../discord.js').DcIncoming,
2614
2616
  streamer?: import('../discord.js').DcMessageStreamer,
2615
2617
  ): Promise<string | void> => {
2618
+ if (decisionWire && ctx.text.trim() === '/decisions') {
2619
+ const { discordDecisionsCommand } = await import('../decisions/discord-decision-cards.js');
2620
+ return discordDecisionsCommand(decisionWire.service, ctx);
2621
+ }
2616
2622
  const voiceReply = await voiceWire.dispatchVoiceCommand(ctx);
2617
2623
  if (voiceReply !== null) return voiceReply;
2618
2624
  return selfOnMessage(ctx, streamer as never);
@@ -2635,7 +2641,8 @@ export async function runNexus(opts: RunNexusOptions = {}): Promise<RunNexusHand
2635
2641
  dispatch: (event) => workflowDaemon!.dispatchDiscord(event),
2636
2642
  onMessage: composedOnMessage,
2637
2643
  onInteraction: async (raw) => {
2638
- // C1 버튼 탭 우선(elanous-q: 소비) → 아니면 C3 슬래시.
2644
+ // DEC-TG 결정 카드(dec:/decmemo:) → C1 버튼 탭(elanous-q: 소비) → 아니면 C3 슬래시.
2645
+ if (decisionWire && await decisionWire.onInteraction(raw)) return;
2639
2646
  if (await questionRuntime.handleComponentInteraction(raw)) return;
2640
2647
  await slashWire.onInteraction(raw);
2641
2648
  },
@@ -2644,6 +2651,14 @@ export async function runNexus(opts: RunNexusOptions = {}): Promise<RunNexusHand
2644
2651
  if (handle) {
2645
2652
  getBotRef.current = handle.bot;
2646
2653
  workflowDiscordBot = handle;
2654
+ if ((cfg.raw?.decisions as { cards?: unknown } | undefined)?.cards !== false) {
2655
+ try {
2656
+ const { attachDiscordDecisionCards } = await import('../decisions/discord-decision-cards.js');
2657
+ decisionWire = attachDiscordDecisionCards(handle.bot, cfg);
2658
+ } catch (err) {
2659
+ console.warn(`[nexus] decision cards (discord) not attached: ${err instanceof Error ? err.message : String(err)}`);
2660
+ }
2661
+ }
2647
2662
  void slashWire.registerCommands().catch((err: unknown) => {
2648
2663
  console.warn(`[nexus] slash registration failed: ${err instanceof Error ? err.message : String(err)}`);
2649
2664
  });
@@ -2919,6 +2934,15 @@ export async function runNexus(opts: RunNexusOptions = {}): Promise<RunNexusHand
2919
2934
  : readyCount === 1 ? readyServerIds[0] : undefined,
2920
2935
  readyCount,
2921
2936
  });
2937
+ // TERM1 ④ — the first web-terminal spawn used to pay the login-shell env capture (spawnSync · ~3 s with the
2938
+ // daemon frozen) and the terminal module import (~0.9 s). Warm both off the event loop shortly after boot.
2939
+ if (!opts.startNexusHttpServerFn) {
2940
+ const warm = setTimeout(() => {
2941
+ void import('../shell-env-bootstrap.js').then(({ warmCapturedEnv }) => warmCapturedEnv()).catch(() => undefined);
2942
+ void import('../web-terminal/preview-tap-registry.js').then(() => import('../preview/terminal.js')).catch(() => undefined);
2943
+ }, 1_500);
2944
+ (warm as { unref?: () => void }).unref?.();
2945
+ }
2922
2946
  httpServer = startHttpServer({
2923
2947
  state,
2924
2948
  registry,