@songsid/agend 2.2.0-beta.1 → 2.2.0-beta.11

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 (401) hide show
  1. package/README.md +1 -1
  2. package/README.zh-TW.md +1 -1
  3. package/dist/agent-cli-instructions.md +16 -9
  4. package/dist/agent-cli.js +15 -7
  5. package/dist/agent-cli.js.map +1 -1
  6. package/dist/agent-endpoint.js +24 -9
  7. package/dist/agent-endpoint.js.map +1 -1
  8. package/dist/agent-whats-new.d.ts +47 -0
  9. package/dist/agent-whats-new.js +147 -0
  10. package/dist/agent-whats-new.js.map +1 -0
  11. package/dist/auth-api.d.ts +5 -3
  12. package/dist/auth-api.js +19 -3
  13. package/dist/auth-api.js.map +1 -1
  14. package/dist/backend/antigravity.d.ts +8 -0
  15. package/dist/backend/antigravity.js +32 -9
  16. package/dist/backend/antigravity.js.map +1 -1
  17. package/dist/backend/claude-carry.d.ts +22 -0
  18. package/dist/backend/claude-carry.js +170 -0
  19. package/dist/backend/claude-carry.js.map +1 -0
  20. package/dist/backend/claude-code.d.ts +65 -2
  21. package/dist/backend/claude-code.js +226 -33
  22. package/dist/backend/claude-code.js.map +1 -1
  23. package/dist/backend/codex-metadata.d.ts +33 -0
  24. package/dist/backend/codex-metadata.js +122 -18
  25. package/dist/backend/codex-metadata.js.map +1 -1
  26. package/dist/backend/codex.d.ts +3 -0
  27. package/dist/backend/codex.js +17 -1
  28. package/dist/backend/codex.js.map +1 -1
  29. package/dist/backend/credential-profile.d.ts +40 -3
  30. package/dist/backend/credential-profile.js +113 -5
  31. package/dist/backend/credential-profile.js.map +1 -1
  32. package/dist/backend/effort-metadata.d.ts +37 -1
  33. package/dist/backend/effort-metadata.js +86 -15
  34. package/dist/backend/effort-metadata.js.map +1 -1
  35. package/dist/backend/grok.d.ts +4 -0
  36. package/dist/backend/grok.js +14 -0
  37. package/dist/backend/grok.js.map +1 -1
  38. package/dist/backend/kiro-engine-ledger.d.ts +5 -0
  39. package/dist/backend/kiro-engine-ledger.js +20 -0
  40. package/dist/backend/kiro-engine-ledger.js.map +1 -1
  41. package/dist/backend/kiro-identity.d.ts +6 -0
  42. package/dist/backend/kiro-identity.js +19 -5
  43. package/dist/backend/kiro-identity.js.map +1 -1
  44. package/dist/backend/kiro.d.ts +39 -3
  45. package/dist/backend/kiro.js +143 -19
  46. package/dist/backend/kiro.js.map +1 -1
  47. package/dist/backend/launch-effort.d.ts +61 -0
  48. package/dist/backend/launch-effort.js +170 -0
  49. package/dist/backend/launch-effort.js.map +1 -0
  50. package/dist/backend/muse.d.ts +84 -0
  51. package/dist/backend/muse.js +449 -53
  52. package/dist/backend/muse.js.map +1 -1
  53. package/dist/backend/types.d.ts +47 -4
  54. package/dist/backend/types.js +12 -1
  55. package/dist/backend/types.js.map +1 -1
  56. package/dist/backend-command.d.ts +91 -0
  57. package/dist/backend-command.js +122 -0
  58. package/dist/backend-command.js.map +1 -0
  59. package/dist/background-work-ended.d.ts +18 -0
  60. package/dist/background-work-ended.js +30 -0
  61. package/dist/background-work-ended.js.map +1 -0
  62. package/dist/cache-service.d.ts +2 -0
  63. package/dist/cache-service.js +2 -1
  64. package/dist/cache-service.js.map +1 -1
  65. package/dist/channel/adapters/discord.d.ts +36 -1
  66. package/dist/channel/adapters/discord.js +126 -4
  67. package/dist/channel/adapters/discord.js.map +1 -1
  68. package/dist/channel/adapters/telegram.d.ts +42 -2
  69. package/dist/channel/adapters/telegram.js +142 -23
  70. package/dist/channel/adapters/telegram.js.map +1 -1
  71. package/dist/channel/mcp-server.js +9 -1
  72. package/dist/channel/mcp-server.js.map +1 -1
  73. package/dist/channel/mcp-tools.d.ts +11 -0
  74. package/dist/channel/mcp-tools.js +26 -13
  75. package/dist/channel/mcp-tools.js.map +1 -1
  76. package/dist/channel/message-edit-errors.d.ts +5 -0
  77. package/dist/channel/message-edit-errors.js +24 -0
  78. package/dist/channel/message-edit-errors.js.map +1 -0
  79. package/dist/channel/telegram-markdown.d.ts +6 -0
  80. package/dist/channel/telegram-markdown.js +104 -0
  81. package/dist/channel/telegram-markdown.js.map +1 -0
  82. package/dist/channel/tool-router.d.ts +2 -0
  83. package/dist/channel/tool-router.js +10 -4
  84. package/dist/channel/tool-router.js.map +1 -1
  85. package/dist/channel/types.d.ts +55 -3
  86. package/dist/cli.js +167 -39
  87. package/dist/cli.js.map +1 -1
  88. package/dist/command-table.d.ts +2 -2
  89. package/dist/command-table.js +6 -3
  90. package/dist/command-table.js.map +1 -1
  91. package/dist/config-validator.js +59 -0
  92. package/dist/config-validator.js.map +1 -1
  93. package/dist/cross-instance-envelope.d.ts +15 -2
  94. package/dist/cross-instance-envelope.js +29 -5
  95. package/dist/cross-instance-envelope.js.map +1 -1
  96. package/dist/daemon.d.ts +156 -1
  97. package/dist/daemon.js +773 -89
  98. package/dist/daemon.js.map +1 -1
  99. package/dist/delivery-outbox.d.ts +70 -8
  100. package/dist/delivery-outbox.js +315 -24
  101. package/dist/delivery-outbox.js.map +1 -1
  102. package/dist/delivery-reconciliation.d.ts +18 -1
  103. package/dist/delivery-reconciliation.js +67 -2
  104. package/dist/delivery-reconciliation.js.map +1 -1
  105. package/dist/fleet-context.d.ts +10 -1
  106. package/dist/fleet-manager.d.ts +404 -13
  107. package/dist/fleet-manager.js +2039 -210
  108. package/dist/fleet-manager.js.map +1 -1
  109. package/dist/general-knowledge/skills/backend-providers/SKILL.md +1 -1
  110. package/dist/general-knowledge/skills/credential-profiles/SKILL.md +92 -21
  111. package/dist/general-knowledge/skills/cross-instance-messaging/SKILL.md +27 -3
  112. package/dist/general-knowledge/skills/delegation-playbook/SKILL.md +8 -8
  113. package/dist/general-knowledge/skills/fleet-config/SKILL.md +5 -5
  114. package/dist/general-knowledge/skills/fleet-health/SKILL.md +4 -4
  115. package/dist/general-knowledge/skills/fleet-restart/SKILL.md +13 -13
  116. package/dist/general-knowledge/skills/instance-lifecycle/SKILL.md +3 -2
  117. package/dist/general-knowledge/skills/kiro-engine-migration/SKILL.md +6 -0
  118. package/dist/general-knowledge/skills/model-discovery/SKILL.md +12 -9
  119. package/dist/general-knowledge/skills/multi-channel/SKILL.md +8 -2
  120. package/dist/general-knowledge/skills/scheduling/SKILL.md +5 -3
  121. package/dist/general-knowledge/skills/session-management/SKILL.md +6 -5
  122. package/dist/general-knowledge/skills/tui-effort/SKILL.md +2 -2
  123. package/dist/general-knowledge/skills/worker-collaboration/SKILL.md +8 -3
  124. package/dist/general-knowledge/steering/core-rules.md +8 -7
  125. package/dist/host-memory.d.ts +10 -0
  126. package/dist/host-memory.js +33 -1
  127. package/dist/host-memory.js.map +1 -1
  128. package/dist/human-delivery-receipts.d.ts +83 -0
  129. package/dist/human-delivery-receipts.js +194 -0
  130. package/dist/human-delivery-receipts.js.map +1 -0
  131. package/dist/instance-avatar.d.ts +36 -0
  132. package/dist/instance-avatar.js +57 -0
  133. package/dist/instance-avatar.js.map +1 -0
  134. package/dist/instance-config-impact.js +6 -0
  135. package/dist/instance-config-impact.js.map +1 -1
  136. package/dist/instance-lifecycle.d.ts +29 -6
  137. package/dist/instance-lifecycle.js +79 -28
  138. package/dist/instance-lifecycle.js.map +1 -1
  139. package/dist/instructions.d.ts +3 -1
  140. package/dist/instructions.js +11 -8
  141. package/dist/instructions.js.map +1 -1
  142. package/dist/kiro-log-gate.d.ts +42 -0
  143. package/dist/kiro-log-gate.js +39 -0
  144. package/dist/kiro-log-gate.js.map +1 -0
  145. package/dist/locale.js +226 -44
  146. package/dist/locale.js.map +1 -1
  147. package/dist/log-service.d.ts +99 -0
  148. package/dist/log-service.js +243 -0
  149. package/dist/log-service.js.map +1 -0
  150. package/dist/log-steps.d.ts +169 -0
  151. package/dist/log-steps.js +858 -0
  152. package/dist/log-steps.js.map +1 -0
  153. package/dist/login-controller.d.ts +20 -3
  154. package/dist/login-controller.js +71 -19
  155. package/dist/login-controller.js.map +1 -1
  156. package/dist/login-flows.d.ts +5 -2
  157. package/dist/login-flows.js +5 -4
  158. package/dist/login-flows.js.map +1 -1
  159. package/dist/memory-pressure.d.ts +13 -1
  160. package/dist/memory-pressure.js +50 -10
  161. package/dist/memory-pressure.js.map +1 -1
  162. package/dist/needs-you-hub.js +2 -0
  163. package/dist/needs-you-hub.js.map +1 -1
  164. package/dist/needs-you-live.d.ts +6 -0
  165. package/dist/needs-you-live.js +23 -3
  166. package/dist/needs-you-live.js.map +1 -1
  167. package/dist/next-steps.js +1 -1
  168. package/dist/next-steps.js.map +1 -1
  169. package/dist/outbound-handlers.d.ts +100 -0
  170. package/dist/outbound-handlers.js +284 -97
  171. package/dist/outbound-handlers.js.map +1 -1
  172. package/dist/outbound-schemas.d.ts +38 -15
  173. package/dist/outbound-schemas.js +36 -8
  174. package/dist/outbound-schemas.js.map +1 -1
  175. package/dist/public-link-progress.d.ts +1 -1
  176. package/dist/public-link-progress.js +8 -2
  177. package/dist/public-link-progress.js.map +1 -1
  178. package/dist/public-preview.d.ts +101 -0
  179. package/dist/public-preview.js +236 -0
  180. package/dist/public-preview.js.map +1 -0
  181. package/dist/public-web-gateway.js +14 -4
  182. package/dist/public-web-gateway.js.map +1 -1
  183. package/dist/public-web-link.d.ts +24 -1
  184. package/dist/public-web-link.js +63 -9
  185. package/dist/public-web-link.js.map +1 -1
  186. package/dist/reply-buttons-controller.d.ts +89 -5
  187. package/dist/reply-buttons-controller.js +273 -8
  188. package/dist/reply-buttons-controller.js.map +1 -1
  189. package/dist/reply-buttons.d.ts +48 -3
  190. package/dist/reply-buttons.js +105 -10
  191. package/dist/reply-buttons.js.map +1 -1
  192. package/dist/reply-polls.d.ts +85 -0
  193. package/dist/reply-polls.js +154 -0
  194. package/dist/reply-polls.js.map +1 -0
  195. package/dist/restart-guard.d.ts +6 -3
  196. package/dist/restart-guard.js +6 -3
  197. package/dist/restart-guard.js.map +1 -1
  198. package/dist/restart-progress.d.ts +33 -0
  199. package/dist/restart-progress.js +30 -0
  200. package/dist/restart-progress.js.map +1 -1
  201. package/dist/service-activation.d.ts +15 -7
  202. package/dist/service-activation.js +46 -29
  203. package/dist/service-activation.js.map +1 -1
  204. package/dist/service-installer.d.ts +31 -0
  205. package/dist/service-installer.js +41 -2
  206. package/dist/service-installer.js.map +1 -1
  207. package/dist/settings-api.js +85 -25
  208. package/dist/settings-api.js.map +1 -1
  209. package/dist/settings-change.js +72 -1
  210. package/dist/settings-change.js.map +1 -1
  211. package/dist/settings-effect.js +28 -4
  212. package/dist/settings-effect.js.map +1 -1
  213. package/dist/settings-http-confirmation.js +7 -0
  214. package/dist/settings-http-confirmation.js.map +1 -1
  215. package/dist/setup-form.d.ts +1 -1
  216. package/dist/setup-form.js +1 -1
  217. package/dist/spawn-gate.js +5 -1
  218. package/dist/spawn-gate.js.map +1 -1
  219. package/dist/spawn-hold-reply.d.ts +12 -0
  220. package/dist/spawn-hold-reply.js +39 -0
  221. package/dist/spawn-hold-reply.js.map +1 -0
  222. package/dist/status-emojis.d.ts +10 -3
  223. package/dist/status-emojis.js +11 -2
  224. package/dist/status-emojis.js.map +1 -1
  225. package/dist/systemd-peer-bus.d.ts +95 -0
  226. package/dist/systemd-peer-bus.js +430 -0
  227. package/dist/systemd-peer-bus.js.map +1 -0
  228. package/dist/systemd-runtime.d.ts +27 -1
  229. package/dist/systemd-runtime.js +69 -7
  230. package/dist/systemd-runtime.js.map +1 -1
  231. package/dist/tips.js +4 -4
  232. package/dist/tips.js.map +1 -1
  233. package/dist/tool-permissions.js +3 -3
  234. package/dist/tool-permissions.js.map +1 -1
  235. package/dist/tool-progress.js +3 -2
  236. package/dist/tool-progress.js.map +1 -1
  237. package/dist/topic-commands.d.ts +42 -2
  238. package/dist/topic-commands.js +101 -11
  239. package/dist/topic-commands.js.map +1 -1
  240. package/dist/transcript-delta-reader.d.ts +3 -3
  241. package/dist/transcript-delta-reader.js +8 -8
  242. package/dist/transcript-delta-reader.js.map +1 -1
  243. package/dist/transcript-jsonl.d.ts +80 -3
  244. package/dist/transcript-jsonl.js +147 -10
  245. package/dist/transcript-jsonl.js.map +1 -1
  246. package/dist/transcript-monitor.d.ts +19 -0
  247. package/dist/transcript-monitor.js +87 -15
  248. package/dist/transcript-monitor.js.map +1 -1
  249. package/dist/transcript-sources.d.ts +78 -0
  250. package/dist/transcript-sources.js +375 -64
  251. package/dist/transcript-sources.js.map +1 -1
  252. package/dist/transcript-turns.d.ts +15 -2
  253. package/dist/transcript-turns.js +15 -2
  254. package/dist/transcript-turns.js.map +1 -1
  255. package/dist/tunnel/cloudflared.d.ts +7 -15
  256. package/dist/tunnel/cloudflared.js +36 -1
  257. package/dist/tunnel/cloudflared.js.map +1 -1
  258. package/dist/tunnel/purpose-lane.d.ts +5 -1
  259. package/dist/tunnel/purpose-lane.js +14 -7
  260. package/dist/tunnel/purpose-lane.js.map +1 -1
  261. package/dist/tunnel/types.d.ts +5 -1
  262. package/dist/tunnel/types.js.map +1 -1
  263. package/dist/turn-resume.d.ts +2 -0
  264. package/dist/turn-resume.js +1 -1
  265. package/dist/turn-resume.js.map +1 -1
  266. package/dist/types.d.ts +25 -0
  267. package/dist/ui/chat-commands.js +1 -0
  268. package/dist/ui/chat-emojis.js +33 -0
  269. package/dist/ui/chat-mentions.js +63 -0
  270. package/dist/ui/chat-render.js +61 -4
  271. package/dist/ui/chat-store.js +37 -3
  272. package/dist/ui/chat-strings.js +40 -20
  273. package/dist/ui/chat-thread.js +111 -12
  274. package/dist/ui/chat-time.js +80 -0
  275. package/dist/ui/panel-chat.js +41 -10
  276. package/dist/ui/panel-details.js +2 -0
  277. package/dist/ui/panel-fleet.js +199 -57
  278. package/dist/ui/panel-log.js +428 -0
  279. package/dist/ui/panel-needs.js +6 -2
  280. package/dist/ui/panel-settings.js +184 -32
  281. package/dist/ui/preview-panel.js +55 -15
  282. package/dist/ui/preview-status.js +28 -0
  283. package/dist/ui/preview.js +44 -3
  284. package/dist/ui/settings-dialogs.js +7 -2
  285. package/dist/ui/settings-model.js +2 -1
  286. package/dist/ui/settings-strings.js +56 -4
  287. package/dist/ui/settings-token.js +1 -1
  288. package/dist/ui/shared/app-i18n.js +43 -17
  289. package/dist/ui/shared/app-prefs.js +250 -0
  290. package/dist/ui/shared/app-route.js +7 -2
  291. package/dist/ui/shared/app-shell.js +165 -8
  292. package/dist/ui/shared/app-store.js +5 -1
  293. package/dist/ui/shared/app-stream.js +26 -5
  294. package/dist/ui/shared/app.css +163 -22
  295. package/dist/ui/shared/app.js +11 -1
  296. package/dist/ui/shared/fonts/README.md +1 -1
  297. package/dist/ui/shared/header-tools.js +55 -5
  298. package/dist/ui/shared/instance-nav.js +241 -45
  299. package/dist/ui/shared/instance-switch.js +4 -4
  300. package/dist/ui/shared/panel-view.js +6 -0
  301. package/dist/ui/shared/signin.js +7 -7
  302. package/dist/ui/shared/theme.js +39 -7
  303. package/dist/ui/shared/tokens.css +1 -1
  304. package/dist/ui/shared/ui-icons.js +1 -0
  305. package/dist/ui/shared/usage-dialog.js +23 -5
  306. package/dist/ui/shared/view-strings.js +32 -4
  307. package/dist/ui/signin.html +1 -1
  308. package/dist/unrecognised-prompt.d.ts +23 -0
  309. package/dist/unrecognised-prompt.js +74 -0
  310. package/dist/unrecognised-prompt.js.map +1 -0
  311. package/dist/update-check.d.ts +31 -0
  312. package/dist/update-check.js +47 -0
  313. package/dist/update-check.js.map +1 -1
  314. package/dist/update-install.d.ts +28 -2
  315. package/dist/update-install.js +22 -4
  316. package/dist/update-install.js.map +1 -1
  317. package/dist/update-launch.d.ts +46 -1
  318. package/dist/update-launch.js +146 -3
  319. package/dist/update-launch.js.map +1 -1
  320. package/dist/update-marker.d.ts +51 -0
  321. package/dist/update-marker.js +61 -0
  322. package/dist/update-marker.js.map +1 -1
  323. package/dist/update-preflight.d.ts +100 -0
  324. package/dist/update-preflight.js +264 -0
  325. package/dist/update-preflight.js.map +1 -0
  326. package/dist/updater-caller.d.ts +31 -0
  327. package/dist/updater-caller.js +109 -0
  328. package/dist/updater-caller.js.map +1 -0
  329. package/dist/usage/i18n-keys.d.ts +1 -1
  330. package/dist/usage/i18n-keys.js +1 -1
  331. package/dist/usage/i18n-keys.js.map +1 -1
  332. package/dist/usage/providers.d.ts +34 -5
  333. package/dist/usage/providers.js +160 -27
  334. package/dist/usage/providers.js.map +1 -1
  335. package/dist/usage/statusline-usage.d.ts +6 -2
  336. package/dist/usage/statusline-usage.js +10 -4
  337. package/dist/usage/statusline-usage.js.map +1 -1
  338. package/dist/view-api.d.ts +1 -1
  339. package/dist/view-api.js +38 -8
  340. package/dist/view-api.js.map +1 -1
  341. package/dist/wake-coordinator.d.ts +11 -0
  342. package/dist/wake-coordinator.js +38 -4
  343. package/dist/wake-coordinator.js.map +1 -1
  344. package/dist/web-api.d.ts +89 -4
  345. package/dist/web-api.js +551 -45
  346. package/dist/web-api.js.map +1 -1
  347. package/dist/web-auth.d.ts +2 -2
  348. package/dist/web-auth.js +9 -4
  349. package/dist/web-auth.js.map +1 -1
  350. package/dist/web-chat-history.d.ts +12 -1
  351. package/dist/web-chat-history.js +29 -7
  352. package/dist/web-chat-history.js.map +1 -1
  353. package/dist/web-commands.d.ts +27 -4
  354. package/dist/web-commands.js +19 -4
  355. package/dist/web-commands.js.map +1 -1
  356. package/dist/web-host-guard.d.ts +3 -11
  357. package/dist/web-host-guard.js +3 -12
  358. package/dist/web-host-guard.js.map +1 -1
  359. package/dist/web-images.d.ts +47 -0
  360. package/dist/web-images.js +169 -0
  361. package/dist/web-images.js.map +1 -0
  362. package/dist/web-login.d.ts +14 -0
  363. package/dist/web-login.js +4 -2
  364. package/dist/web-login.js.map +1 -1
  365. package/dist/web-mentions.d.ts +44 -0
  366. package/dist/web-mentions.js +75 -0
  367. package/dist/web-mentions.js.map +1 -0
  368. package/dist/web-prefs.d.ts +81 -0
  369. package/dist/web-prefs.js +297 -0
  370. package/dist/web-prefs.js.map +1 -0
  371. package/dist/web-preview.d.ts +14 -1
  372. package/dist/web-preview.js +35 -13
  373. package/dist/web-preview.js.map +1 -1
  374. package/dist/web-profile.d.ts +32 -0
  375. package/dist/web-profile.js +132 -0
  376. package/dist/web-profile.js.map +1 -0
  377. package/dist/web-reply-files.d.ts +45 -0
  378. package/dist/web-reply-files.js +223 -0
  379. package/dist/web-reply-files.js.map +1 -0
  380. package/dist/web-schedules.d.ts +12 -0
  381. package/dist/web-schedules.js +34 -0
  382. package/dist/web-schedules.js.map +1 -0
  383. package/dist/web-server-drain.d.ts +10 -0
  384. package/dist/web-server-drain.js +50 -0
  385. package/dist/web-server-drain.js.map +1 -0
  386. package/dist/web-session.d.ts +50 -1
  387. package/dist/web-session.js +63 -6
  388. package/dist/web-session.js.map +1 -1
  389. package/dist/web-settings-patch.d.ts +23 -0
  390. package/dist/web-settings-patch.js +77 -0
  391. package/dist/web-settings-patch.js.map +1 -0
  392. package/dist/web-shell-routes.d.ts +5 -1
  393. package/dist/web-shell-routes.js +10 -1
  394. package/dist/web-shell-routes.js.map +1 -1
  395. package/dist/web-upload.d.ts +17 -4
  396. package/dist/web-upload.js +64 -13
  397. package/dist/web-upload.js.map +1 -1
  398. package/dist/workflow-templates/default.md +1 -1
  399. package/package.json +1 -1
  400. package/templates/launchd.plist.ejs +3 -1
  401. package/templates/systemd.service.ejs +2 -1
package/dist/web-api.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { webHumanOrigin } from "./human-delivery-receipts.js";
2
+ import { withSpawnHoldReply } from "./spawn-hold-reply.js";
1
3
  import { binaryProbe } from "./binary-probe.js";
2
4
  import { settingsRequestExecution, settingsWrite, isSettingsReplay } from "./settings-request-capability.js";
3
5
  import { readBoundedWebBody } from "./web-body.js";
@@ -16,7 +18,9 @@ import { shellRoute } from "./web-shell-routes.js";
16
18
  import { readFileSync } from "node:fs";
17
19
  import { join, dirname } from "node:path";
18
20
  import { fileURLToPath } from "node:url";
19
- import { CreateInstanceArgs, validateArgs } from "./outbound-schemas.js";
21
+ import { CreateInstanceArgs, CreateScheduleArgs, UpdateScheduleArgs, validateArgs } from "./outbound-schemas.js";
22
+ import { scheduleView } from "./web-schedules.js";
23
+ import { DEFAULT_SCHEDULER_CONFIG } from "./scheduler/index.js";
20
24
  import { readStatuslineModel, resolveInstanceContext } from "./topic-commands.js";
21
25
  import { z } from "zod";
22
26
  import { evaluateWebRequest, isPassiveWebRead, isSecureRequest, isWebRequestAuthorized, WEB_TOKEN_INVALID_MESSAGE } from "./web-auth.js";
@@ -24,8 +28,13 @@ import { dashboardOriginFor } from "./web-preview.js";
24
28
  import { newWebMessageId, parseLastEventId } from "./web-chat-history.js";
25
29
  import { attachmentDelivery, displayName, INLINE_MIME, isFileId, MEDIA_RANGE_MAX, publicAttachment, sniffUpload, UPLOAD_LIMITS, wellFormed } from "./web-upload.js";
26
30
  import { getAgendHome } from "./paths.js";
31
+ import { OPERATOR } from "./web-prefs.js";
32
+ import { WEB_NAME_FALLBACK } from "./web-profile.js";
33
+ import { commitTeamCreate, commitTeamUpdate } from "./outbound-handlers.js";
34
+ import { parseMentionQuery } from "./web-mentions.js";
27
35
  import { authorizeExplicitInstanceRemoval } from "./instance-removal.js";
28
36
  import { measureSyncWork } from "./sync-work-attribution.js";
37
+ import { resolveTz } from "./tz-utils.js";
29
38
  // ── Strict public-facing schemas ────────────────────────────────────────────
30
39
  // web-api endpoints must reject unknown fields so the dashboard cannot inject
31
40
  // internal-only flags that would reach handleCreate/scheduler/config writers.
@@ -45,18 +54,17 @@ const TaskUpdateSchema = z.object({
45
54
  description: z.string().max(MAX_TEXT).optional(),
46
55
  priority: z.string().max(64).optional(),
47
56
  }).strict();
48
- const ScheduleCreateSchema = z.object({
49
- cron: z.string().min(1).max(256),
50
- message: z.string().min(1).max(MAX_TEXT),
51
- target: z.string().min(1).max(256),
52
- label: z.string().max(256).optional(),
53
- timezone: z.string().max(128).optional(),
54
- }).strict();
55
57
  const TeamCreateSchema = z.object({
56
58
  name: z.string().min(1).max(128).regex(/^[A-Za-z0-9._-]+$/),
57
59
  members: z.array(z.string().min(1).max(256)).min(1).max(256),
58
60
  description: z.string().max(MAX_TEXT).optional(),
59
61
  }).strict();
62
+ // #1689: an edit of a team — its members as they should be, and its description ("" clears it). No name: a team is
63
+ // not renamed.
64
+ const TeamEditSchema = z.object({
65
+ members: z.array(z.string().min(1).max(256)).min(1).max(256),
66
+ description: z.string().max(MAX_TEXT).optional(),
67
+ }).strict();
60
68
  const ConfigUpdateSchema = z.object({
61
69
  channel: z.object({
62
70
  group_id: z.union([z.number(), z.string()]).optional(),
@@ -130,6 +138,49 @@ function json(res, status, data) {
130
138
  res.writeHead(status);
131
139
  res.end(JSON.stringify(data));
132
140
  }
141
+ /**
142
+ * #1630 (#1635 review): an answer that waited on something (a list, a download) is sent only if the request is still
143
+ * allowed — the session not revoked meanwhile, a public link's exposure still current. Otherwise 401, and nothing.
144
+ */
145
+ function stillAuthorized(req, res, url, ctx) {
146
+ if (!permitWebContinuation(req, res, ctx))
147
+ return false;
148
+ if (isWebRequestAuthorized(req, url, ctx.webToken, ctx.webSessions, { touch: false }))
149
+ return true;
150
+ json(res, 401, { error: WEB_TOKEN_INVALID_MESSAGE });
151
+ return false;
152
+ }
153
+ /**
154
+ * #1827: whose a request is, asked NOW (after its body arrived: a session revoked, expired or rotated away meanwhile is
155
+ * no one — `null`, never the operator). An authorized session is the account it was signed in as, or (an `agend web`
156
+ * session, the CLI's header token) none: the operator. For recognition only; nothing here authorizes anything.
157
+ */
158
+ function webAccountOf(req, url, ctx) {
159
+ const verdict = evaluateWebRequest(req, url, ctx.webToken ?? null, ctx.webSessions, { touch: false });
160
+ if (verdict.kind !== "allow")
161
+ return null;
162
+ return verdict.via === "session" && verdict.session?.owner ? { owner: verdict.session.owner } : {};
163
+ }
164
+ function webPersonOf(req, url, ctx) {
165
+ // No people kept here: everyone is "web-user", as before, and there is no identity to protect.
166
+ if (!ctx.webPerson)
167
+ return { key: OPERATOR, bound: false, userId: WEB_NAME_FALLBACK, voterKey: "web:operator", name: WEB_NAME_FALLBACK, set: false };
168
+ const who = webAccountOf(req, url, ctx);
169
+ return who ? ctx.webPerson(who.owner) : null;
170
+ }
171
+ /** #1827: the person, or the request is answered 401 here (it is no longer authorized) and null is returned. */
172
+ function webPersonOr401(req, res, url, ctx) {
173
+ const p = webPersonOf(req, url, ctx);
174
+ if (!p)
175
+ json(res, 401, { error: WEB_TOKEN_INVALID_MESSAGE });
176
+ return p;
177
+ }
178
+ /** #1827: what the page's profile card reads — the name they chose (or null), the one in use, and how agents know them. */
179
+ function profileBody(p) {
180
+ return { name: p.set ? p.name : null, shown: p.name, id: p.userId, bound: p.bound, ...(p.platformName !== undefined ? { platform_name: p.platformName } : {}) };
181
+ }
182
+ /** #1823: an SSE frame with the server's clock (no id: it is not a message, and a reconnect gets a fresh one). */
183
+ function sseClock() { return `event: clock\ndata: ${JSON.stringify({ now: Date.now() })}\n\n`; }
133
184
  /**
134
185
  * Handle a Web UI request. Returns true if handled, false to pass through.
135
186
  */
@@ -242,6 +293,8 @@ export function handleWebRequest(req, res, url, ctx) {
242
293
  // #1577: the newest message this answer accounts for — after a restart, the last restored one until this process
243
294
  // records its own (`<boot>-0` would be after(0): the whole restored history again on every poll).
244
295
  cursor: history ? history.pollCursor() : null,
296
+ // #1823 (#1830 review): the server's clock now, for the page's poll countdown (a poll's deadline is the server's).
297
+ now: Date.now(),
245
298
  });
246
299
  return true;
247
300
  }
@@ -255,7 +308,9 @@ export function handleWebRequest(req, res, url, ctx) {
255
308
  "Cache-Control": "no-cache",
256
309
  Connection: "keep-alive",
257
310
  });
258
- res.write(`event: status\ndata: ${JSON.stringify(ctx.getUiStatus())}\n\n`);
311
+ // #1823 (#1830 review): with the status, the server's clock — now and with every heartbeat — that the page counts a
312
+ // poll down by (one write: the frames a connect sends are unchanged in number).
313
+ res.write(`event: status\ndata: ${JSON.stringify(ctx.getUiStatus())}\n\n${sseClock()}`);
259
314
  // An EventSource that reconnects says what it last saw: send what it missed, before anything new.
260
315
  const lastSeen = parseLastEventId(req.headers["last-event-id"]);
261
316
  if (lastSeen !== null && ctx.webChatHistory) {
@@ -291,7 +346,7 @@ export function handleWebRequest(req, res, url, ctx) {
291
346
  }
292
347
  try {
293
348
  // Every open dashboard tab, every 10 s: the status of every instance, serialized on the fleet's loop (#1235).
294
- res.write(measureSyncWork("web.sseStatus", () => `event: status\ndata: ${JSON.stringify(ctx.getUiStatus())}\n\n`));
349
+ res.write(measureSyncWork("web.sseStatus", () => `event: status\ndata: ${JSON.stringify(ctx.getUiStatus())}\n\n`) + sseClock());
295
350
  }
296
351
  catch {
297
352
  cleanup();
@@ -328,6 +383,45 @@ export function handleWebRequest(req, res, url, ctx) {
328
383
  json(res, 200, { messages: ctx.webChatHistory?.list(instance, limit) ?? [], boot: ctx.webChatHistory?.boot ?? null, lastId: ctx.webChatHistory?.lastId ?? 0 });
329
384
  return true;
330
385
  }
386
+ // ── #1218: the Log tab ─────────────────────────────────
387
+ // An instance's steps, read on demand from its own transcript (log-service.ts). Read-only, and offered on the public
388
+ // link on purpose (the user's decision): the same masking applies to every viewer. Passive (web-auth.ts).
389
+ if (method === "GET" && path === "/ui/steps") {
390
+ const p = url.searchParams;
391
+ const instance = p.get("instance") ?? "";
392
+ const num = (k) => {
393
+ const v = p.get(k);
394
+ if (v === null)
395
+ return undefined;
396
+ const n = Number(v);
397
+ return /^\d{1,15}$/.test(v) && Number.isSafeInteger(n) ? n : null;
398
+ };
399
+ const after = num("after"), before = num("before"), ev = num("ev"), limit = num("limit");
400
+ const key = p.get("key");
401
+ if (!instance || instance.length > 128 || after === null || before === null || ev === null || limit === null
402
+ || (limit !== undefined && (limit < 1 || limit > 200)) || (key !== null && !/^\d{1,20}-\d{1,20}$/.test(key))) {
403
+ json(res, 400, { error: "instance (1-128 chars); after, before, ev: offsets; limit 1-200; key: <dev>-<ino>" });
404
+ return true;
405
+ }
406
+ if (!ctx.logSteps) {
407
+ json(res, 404, { error: "not available" });
408
+ return true;
409
+ }
410
+ ctx.logSteps(instance, {
411
+ ...(after !== undefined ? { after } : {}), ...(before !== undefined ? { before } : {}), ...(ev !== undefined ? { ev } : {}),
412
+ ...(limit !== undefined ? { limit } : {}), ...(key !== null ? { key } : {}),
413
+ skip: p.get("skip") === "1", watch: p.get("watch") === "1",
414
+ }).then(answer => {
415
+ if (answer === null)
416
+ json(res, 404, { error: `Instance not found: ${instance}` });
417
+ else
418
+ json(res, 200, answer);
419
+ }).catch(err => {
420
+ ctx.logger.error({ err: err.message, instance }, "Log steps read failed");
421
+ json(res, 500, { error: "could not read the steps" });
422
+ });
423
+ return true;
424
+ }
331
425
  // ── Send message ───────────────────────────────────────
332
426
  if (method === "POST" && path === "/ui/send") {
333
427
  handleSendMessage(req, res, ctx);
@@ -365,6 +459,66 @@ export function handleWebRequest(req, res, url, ctx) {
365
459
  res.end(bytes);
366
460
  return true;
367
461
  }
462
+ // ── #1630: avatars and Discord server emoji, same-origin (the page never loads a platform's CDN; img-src stays 'self') ──
463
+ const imageMatch = path.match(/^\/ui\/img\/(bot|avatar|emoji)\/([^/]+)$/);
464
+ if (method === "GET" && imageMatch) {
465
+ const kind = imageMatch[1];
466
+ let key;
467
+ try {
468
+ key = decodeURIComponent(imageMatch[2]);
469
+ }
470
+ catch {
471
+ json(res, 404, { error: "No such image" });
472
+ return true;
473
+ }
474
+ const send = (image) => {
475
+ if (res.headersSent || res.destroyed)
476
+ return;
477
+ if (!stillAuthorized(req, res, url, ctx))
478
+ return;
479
+ if (!image) {
480
+ json(res, 404, { error: "No such image" });
481
+ return;
482
+ }
483
+ res.setHeader("Content-Type", image.mime);
484
+ res.setHeader("X-Content-Type-Options", "nosniff");
485
+ // A changed avatar is a new URL (?v=); an emoji id is one picture for good.
486
+ res.setHeader("Cache-Control", "private, max-age=86400");
487
+ res.setHeader("Content-Security-Policy", "default-src 'none'; sandbox");
488
+ res.writeHead(200);
489
+ res.end(image.bytes);
490
+ };
491
+ if (kind === "emoji") {
492
+ (ctx.webEmoji ? ctx.webEmoji(key) : Promise.resolve(null)).then(send, () => send(null));
493
+ return true;
494
+ }
495
+ send(ctx.webImage?.(kind, key) ?? null);
496
+ return true;
497
+ }
498
+ if (method === "GET" && path === "/ui/emojis") {
499
+ const answer = (emojis) => { if (!res.headersSent && !res.destroyed && stillAuthorized(req, res, url, ctx))
500
+ json(res, 200, { emojis }); };
501
+ (ctx.webEmojiList ? ctx.webEmojiList() : Promise.resolve([])).then(answer, () => answer([]));
502
+ return true;
503
+ }
504
+ // #1682: the names of the mention ids a page found in what it rendered (at most MAX_MENTION_IDS, digits only) — only
505
+ // those, so no page (the public link included) ever gets a directory; from memory, never a request.
506
+ if (method === "GET" && path === "/ui/mentions") {
507
+ const parsed = parseMentionQuery(url.searchParams);
508
+ if (!parsed.ok) {
509
+ json(res, 400, { error: parsed.error });
510
+ return true;
511
+ }
512
+ let names = { u: {}, r: {}, c: {} };
513
+ try {
514
+ names = ctx.webMentionNames?.(parsed.query) ?? names;
515
+ }
516
+ catch (err) {
517
+ ctx.logger.debug({ err: err.message }, "mention names unavailable");
518
+ }
519
+ json(res, 200, names);
520
+ return true;
521
+ }
368
522
  // ── Fleet prompts (C4): the hang / exit / interactive-prompt buttons, answerable here too ──
369
523
  if (method === "GET" && path === "/ui/prompts") {
370
524
  json(res, 200, { prompts: ctx.listWebPrompts?.() ?? [] });
@@ -409,6 +563,63 @@ export function handleWebRequest(req, res, url, ctx) {
409
563
  });
410
564
  return true;
411
565
  }
566
+ // #1827: the person's own name — the one agents and chats see for their web messages. Their own profile, so the public
567
+ // link may read and set it too (profile edits are allowed there); a read-only session may as well: it is about them.
568
+ if (path === "/ui/profile" && method === "GET") {
569
+ if (!ctx.webPerson) {
570
+ json(res, 404, { error: "Profiles are not kept on this fleet" });
571
+ return true;
572
+ }
573
+ const p = webPersonOr401(req, res, url, ctx);
574
+ if (p)
575
+ json(res, 200, profileBody(p));
576
+ return true;
577
+ }
578
+ if (path === "/ui/profile" && method === "POST") {
579
+ if (!ctx.setWebPersonName) {
580
+ json(res, 404, { error: "Profiles are not kept on this fleet" });
581
+ return true;
582
+ }
583
+ const set = ctx.setWebPersonName.bind(ctx);
584
+ (async () => {
585
+ let body;
586
+ try {
587
+ body = await parseBody(req);
588
+ }
589
+ catch {
590
+ json(res, 400, { error: "Invalid JSON" });
591
+ return;
592
+ }
593
+ if (!permitWebContinuation(req, res, ctx))
594
+ return;
595
+ if (!body || typeof body !== "object" || Array.isArray(body) || !Object.hasOwn(body, "name")) {
596
+ json(res, 400, { error: "name required (text, or null to clear)" });
597
+ return;
598
+ }
599
+ // Whose: asked once the body is in. A session revoked, expired or rotated away meanwhile writes nothing — it is
600
+ // not the operator (#1833 review).
601
+ const who = webAccountOf(req, url, ctx);
602
+ if (!who) {
603
+ json(res, 401, { error: WEB_TOKEN_INVALID_MESSAGE });
604
+ return;
605
+ }
606
+ const r = set(who.owner, body.name);
607
+ if (!r.ok) {
608
+ json(res, r.status, { error: r.error });
609
+ return;
610
+ }
611
+ // The person's other devices read it at their next status (the prefs rev moved).
612
+ ctx.emitSseEvent("status", ctx.getUiStatus());
613
+ json(res, 200, profileBody(r.person));
614
+ })().catch(err => {
615
+ ctx.logger.error({ err: err.message }, "Saving the web name failed");
616
+ try {
617
+ json(res, 500, { error: "Saving the name failed" });
618
+ }
619
+ catch { /* already answered */ }
620
+ });
621
+ return true;
622
+ }
412
623
  // #1266: a click on an agent reply's button. The set id travels in the body; the public link may click too (a click
413
624
  // is a message, and the public link may send messages).
414
625
  if (method === "POST" && path === "/ui/reply-button") {
@@ -433,8 +644,14 @@ export function handleWebRequest(req, res, url, ctx) {
433
644
  json(res, 400, { error: "instance, id and index required" });
434
645
  return;
435
646
  }
436
- const r = await click(instance, id, index);
437
- json(res, r.status, r.status === 200 ? { answered: true } : { error: r.error ?? "Refused", ...(r.status === 409 ? { gone: true } : {}) });
647
+ // #1823/#1827: the click is this page's person's — on a poll their vote (one per person, on every device of
648
+ // theirs), on one-click buttons their choice — under their web id and name. A request that no longer passes the
649
+ // gate (a session revoked meanwhile) clicks nothing as anyone.
650
+ const person = webPersonOr401(req, res, url, ctx);
651
+ if (!person)
652
+ return;
653
+ const r = await click(instance, id, index, { key: person.voterKey, userId: person.userId, name: person.name });
654
+ json(res, r.status, r.status === 200 ? { answered: true, ...(r.options ? { options: r.options } : {}) } : { error: r.error ?? "Refused", ...(r.status === 409 ? { gone: true } : {}) });
438
655
  })().catch(err => {
439
656
  ctx.logger.error({ err: err.message }, "Web reply-button click failed");
440
657
  try {
@@ -444,6 +661,97 @@ export function handleWebRequest(req, res, url, ctx) {
444
661
  });
445
662
  return true;
446
663
  }
664
+ // #1723: the public link page's preview, asked while it says "Preview is starting…" — its state and, once up, its
665
+ // origin and boot. Public link only: a local page's preview is decided by its own load.
666
+ if (path === "/ui/preview-status" && method === "GET") {
667
+ const gateway = gatewayRequestContext(req);
668
+ if (!gateway || !ctx.publicPreviewForUi) {
669
+ json(res, 404, { error: "not offered here" });
670
+ return true;
671
+ }
672
+ const p = ctx.publicPreviewForUi(gateway.exposureId, gateway.expectedOrigin);
673
+ json(res, 200, { code: p.code, previewOrigin: p.previewOrigin, boot: p.previewOrigin ? p.boot : null, detail: p.detail ?? null });
674
+ return true;
675
+ }
676
+ // #1665: the web preferences that follow the person between devices. Read by any page of the panel, the public link
677
+ // included (it reads what is in force); written only by a signed-in admin session or the CLI's header token — never
678
+ // over the public link (absent from its route manifest, and refused here too), never by a read-only session.
679
+ if (path === "/ui/prefs" && method === "GET") {
680
+ if (!ctx.webPrefs) {
681
+ json(res, 404, { error: "Preferences are not kept on this fleet" });
682
+ return true;
683
+ }
684
+ json(res, 200, ctx.webPrefs.read(OPERATOR));
685
+ return true;
686
+ }
687
+ if (path === "/ui/prefs" && method === "POST") {
688
+ const prefs = ctx.webPrefs;
689
+ if (!prefs) {
690
+ json(res, 404, { error: "Preferences are not kept on this fleet" });
691
+ return true;
692
+ }
693
+ if (gatewayRequestContext(req)) {
694
+ json(res, 403, { error: "prefs_not_over_public_link" });
695
+ return true;
696
+ }
697
+ // Who may write, asked now and again once the body is in: a session revoked or expired, or a token rotated, while
698
+ // the body was on its way writes nothing and announces nothing.
699
+ const writer = () => {
700
+ const verdict = evaluateWebRequest(req, url, ctx.webToken ?? null, ctx.webSessions, { touch: false });
701
+ if (verdict.kind !== "allow")
702
+ return 401;
703
+ return verdict.via === "session" && verdict.session?.tier !== "admin" ? 403 : 200;
704
+ };
705
+ const refuse = (code) => json(res, code, code === 401 ? { error: WEB_TOKEN_INVALID_MESSAGE } : { error: "prefs_read_only" });
706
+ const first = writer();
707
+ if (first !== 200) {
708
+ refuse(first);
709
+ return true;
710
+ }
711
+ (async () => {
712
+ let body;
713
+ try {
714
+ body = await parseBody(req);
715
+ }
716
+ catch {
717
+ json(res, 400, { error: "Invalid JSON" });
718
+ return;
719
+ }
720
+ if (!permitWebContinuation(req, res, ctx))
721
+ return;
722
+ if (!body || typeof body !== "object" || Array.isArray(body)) {
723
+ json(res, 400, { error: "set required" });
724
+ return;
725
+ }
726
+ const parsed = prefs.parsePatch(body.set);
727
+ if (!parsed.ok) {
728
+ json(res, 400, { error: parsed.error });
729
+ return;
730
+ }
731
+ const now = writer();
732
+ if (now !== 200) {
733
+ refuse(now);
734
+ return;
735
+ }
736
+ const onlyIfUnset = body.onlyIfUnset === true;
737
+ const written = prefs.write(OPERATOR, parsed.patch, { onlyIfUnset });
738
+ if (!written.ok) {
739
+ json(res, written.status, { error: written.error });
740
+ return;
741
+ }
742
+ // Another device sees the new rev in the next status frame — at once on the live stream.
743
+ if (written.changed.length)
744
+ ctx.emitSseEvent("status", ctx.getUiStatus());
745
+ json(res, 200, { ...written.view, changed: written.changed });
746
+ })().catch(err => {
747
+ ctx.logger.error({ err: err.message }, "Saving web preferences failed");
748
+ try {
749
+ json(res, 500, { error: "Saving preferences failed" });
750
+ }
751
+ catch { /* already answered */ }
752
+ });
753
+ return true;
754
+ }
447
755
  // #1386: Acknowledge a "Needs you" delivery item — a session write (CSRF, checked by the gate) or the CLI's header
448
756
  // token. Who did it is recorded by principal: the session's public handle, never the session id or a credential.
449
757
  if (method === "POST" && path === "/ui/needs/ack") {
@@ -519,7 +827,10 @@ export function handleWebRequest(req, res, url, ctx) {
519
827
  json(res, 400, { error: "invalid confirmation" });
520
828
  return;
521
829
  }
522
- const r = await run({ instance, command, args: str(body.args, MAX_COMMAND_ARGS), confirm: body.confirm }, { publicLink: !!gatewayRequestContext(req) });
830
+ const person = webPersonOr401(req, res, url, ctx); // #1827: asked again, now the body is in
831
+ if (!person)
832
+ return;
833
+ const r = await run({ instance, command, args: str(body.args, MAX_COMMAND_ARGS), confirm: body.confirm }, { publicLink: !!gatewayRequestContext(req), person });
523
834
  json(res, r.status, r.body);
524
835
  })().catch(err => {
525
836
  ctx.logger.error({ err: err.message }, "Web command failed");
@@ -553,7 +864,7 @@ export function handleWebRequest(req, res, url, ctx) {
553
864
  json(res, 409, { error: `${name} is not running` });
554
865
  return true;
555
866
  }
556
- ctx.eventLog?.logActivity("cancel", "web-user", "stop the current reply", name);
867
+ ctx.eventLog?.logActivity("cancel", webPersonOf(req, url, ctx)?.name ?? WEB_NAME_FALLBACK, "stop the current reply", name);
557
868
  json(res, 200, { cancelled: name });
558
869
  return true;
559
870
  }
@@ -586,13 +897,18 @@ export function handleWebRequest(req, res, url, ctx) {
586
897
  }
587
898
  const topicMode = ctx.fleetConfig?.channel?.mode === "topic";
588
899
  (async () => {
900
+ let held = false;
589
901
  try {
590
- await ctx.startInstance(name, config, topicMode ?? false);
902
+ await withSpawnHoldReply(async (message) => { held = true; json(res, 202, { held: true, message, instance: name }); }, () => ctx.startInstance(name, config, topicMode ?? false));
591
903
  ctx.emitSseEvent("status", ctx.getUiStatus());
592
- json(res, 200, { started: name });
904
+ if (!held)
905
+ json(res, 200, { started: name });
593
906
  }
594
907
  catch (err) {
595
- json(res, 500, { error: err.message });
908
+ if (!held)
909
+ json(res, 500, { error: err.message });
910
+ else
911
+ ctx.logger.error({ err, name }, "Held instance action failed");
596
912
  }
597
913
  })();
598
914
  return true;
@@ -705,14 +1021,19 @@ export function handleWebRequest(req, res, url, ctx) {
705
1021
  if (method === "POST" && restartMatch) {
706
1022
  const name = decodeURIComponent(restartMatch[1]);
707
1023
  (async () => {
1024
+ let held = false;
708
1025
  try {
709
- await ctx.restartSingleInstance(name, { explicit: true });
1026
+ await withSpawnHoldReply(async (message) => { held = true; json(res, 202, { held: true, message, instance: name }); }, () => ctx.restartSingleInstance(name, { explicit: true }));
710
1027
  ctx.emitSseEvent("status", ctx.getUiStatus());
711
- json(res, 200, { restarted: name });
1028
+ if (!held)
1029
+ json(res, 200, { restarted: name });
712
1030
  }
713
1031
  catch (err) {
714
1032
  const status = err.message.includes("not found") ? 404 : 500;
715
- json(res, status, { error: err.message });
1033
+ if (!held)
1034
+ json(res, status, { error: err.message });
1035
+ else
1036
+ ctx.logger.error({ err, name }, "Held instance action failed");
716
1037
  }
717
1038
  })();
718
1039
  return true;
@@ -774,12 +1095,15 @@ export function handleWebRequest(req, res, url, ctx) {
774
1095
  const parsed = parseOrReject(TaskCreateSchema, body, res);
775
1096
  if (!parsed)
776
1097
  return;
1098
+ const person = webPersonOr401(req, res, url, ctx); // #1827: asked again, now the body is in
1099
+ if (!person)
1100
+ return;
777
1101
  const task = ctx.scheduler.db.createTask({
778
1102
  title: parsed.title,
779
1103
  description: parsed.description,
780
1104
  priority: parsed.priority,
781
1105
  assignee: parsed.assignee,
782
- created_by: "web-user",
1106
+ created_by: person.name, // #1827
783
1107
  });
784
1108
  json(res, 200, task);
785
1109
  }
@@ -806,7 +1130,10 @@ export function handleWebRequest(req, res, url, ctx) {
806
1130
  return;
807
1131
  let result;
808
1132
  if (parsed.action === "claim") {
809
- result = ctx.scheduler.db.claimTask(id, parsed.assignee || "web-user");
1133
+ const person = parsed.assignee ? null : webPersonOr401(req, res, url, ctx); // #1827: the default is the person
1134
+ if (!parsed.assignee && !person)
1135
+ return;
1136
+ result = ctx.scheduler.db.claimTask(id, parsed.assignee || person.name);
810
1137
  }
811
1138
  else if (parsed.action === "complete") {
812
1139
  result = ctx.scheduler.db.completeTask(id, parsed.result);
@@ -830,10 +1157,28 @@ export function handleWebRequest(req, res, url, ctx) {
830
1157
  json(res, 200, { schedules: [] });
831
1158
  return true;
832
1159
  }
833
- json(res, 200, { schedules: ctx.scheduler.list() });
1160
+ // #1724: each row with its next run and one-shot time in the fleet's zone — worked out here, not on the page.
1161
+ const scheduler = ctx.scheduler;
1162
+ const now = Date.now();
1163
+ json(res, 200, {
1164
+ schedules: scheduler.list().map((s) => scheduleView(s, scheduler.getRetry?.(s.id) ?? null, now)),
1165
+ // The zone a new schedule (and a "Once" date and time typed without one) is read in.
1166
+ default_timezone: ctx.fleetConfig?.defaults?.scheduler?.default_timezone
1167
+ ?? DEFAULT_SCHEDULER_CONFIG.default_timezone,
1168
+ });
834
1169
  return true;
835
1170
  }
1171
+ // #1724: who may write a schedule, asked again once the body is in (a session revoked meanwhile, a non-admin tier).
1172
+ const scheduleWriterGone = () => {
1173
+ const verdict = evaluateWebRequest(req, url, ctx.webToken ?? null, ctx.webSessions, { touch: false });
1174
+ return verdict.kind !== "allow" || (verdict.via === "session" && verdict.session?.tier !== "admin");
1175
+ };
836
1176
  if (method === "POST" && path === "/ui/schedules") {
1177
+ // #1724: schedules are not created, edited or deleted over the public link (absent from its manifest too, #1689).
1178
+ if (gatewayRequestContext(req)) {
1179
+ json(res, 403, { error: "schedule_not_over_public_link" });
1180
+ return true;
1181
+ }
837
1182
  if (!ctx.scheduler) {
838
1183
  json(res, 500, { error: "Scheduler not initialized" });
839
1184
  return true;
@@ -843,10 +1188,23 @@ export function handleWebRequest(req, res, url, ctx) {
843
1188
  const body = await parseBody(req);
844
1189
  if (!permitWebContinuation(req, res, ctx))
845
1190
  return;
846
- const parsed = parseOrReject(ScheduleCreateSchema, body, res);
1191
+ if (scheduleWriterGone()) {
1192
+ json(res, 401, { error: WEB_TOKEN_INVALID_MESSAGE });
1193
+ return;
1194
+ }
1195
+ // #1724: the create_schedule tool's own schema — cron or at (exactly one) — plus a target, which the web names.
1196
+ const parsed = parseOrReject(CreateScheduleArgs, body, res);
847
1197
  if (!parsed)
848
1198
  return;
849
- const schedule = ctx.scheduler.create(parsed);
1199
+ if (typeof parsed.target !== "string" || !parsed.target) {
1200
+ json(res, 400, { error: "target: an instance is required" });
1201
+ return;
1202
+ }
1203
+ const schedule = ctx.scheduler.create({
1204
+ cron: parsed.cron, at: parsed.at, message: parsed.message, target: parsed.target, label: parsed.label, timezone: parsed.timezone,
1205
+ ...(parsed.silent !== undefined ? { silent: parsed.silent } : {}),
1206
+ source: "web", reply_chat_id: "", reply_thread_id: null,
1207
+ });
850
1208
  json(res, 200, schedule);
851
1209
  }
852
1210
  catch (err) {
@@ -856,7 +1214,48 @@ export function handleWebRequest(req, res, url, ctx) {
856
1214
  return true;
857
1215
  }
858
1216
  const schedDelMatch = path.match(/^\/ui\/schedules\/(.+)$/);
1217
+ // #1724: Edit — the update_schedule tool's validation (UpdateScheduleArgs) and its call (scheduler.update): cron and at
1218
+ // clear each other there; silent is not touched.
1219
+ if (method === "PATCH" && schedDelMatch) {
1220
+ if (gatewayRequestContext(req)) {
1221
+ json(res, 403, { error: "schedule_not_over_public_link" });
1222
+ return true;
1223
+ }
1224
+ if (!ctx.scheduler || typeof ctx.scheduler.update !== "function") {
1225
+ json(res, 500, { error: "Scheduler not initialized" });
1226
+ return true;
1227
+ }
1228
+ const id = decodeURIComponent(schedDelMatch[1]);
1229
+ (async () => {
1230
+ try {
1231
+ const body = await parseBody(req);
1232
+ if (!permitWebContinuation(req, res, ctx))
1233
+ return;
1234
+ if (scheduleWriterGone()) {
1235
+ json(res, 401, { error: WEB_TOKEN_INVALID_MESSAGE });
1236
+ return;
1237
+ }
1238
+ if (!body || typeof body !== "object" || Array.isArray(body) || Object.hasOwn(body, "id")) {
1239
+ json(res, 400, { error: "expected the fields to change (the id is the path's)" });
1240
+ return;
1241
+ }
1242
+ const parsed = parseOrReject(UpdateScheduleArgs, { ...body, id }, res);
1243
+ if (!parsed)
1244
+ return;
1245
+ const { id: _id, ...changes } = parsed;
1246
+ json(res, 200, ctx.scheduler.update(id, changes));
1247
+ }
1248
+ catch (err) {
1249
+ json(res, 400, { error: err.message });
1250
+ }
1251
+ })();
1252
+ return true;
1253
+ }
859
1254
  if (method === "DELETE" && schedDelMatch) {
1255
+ if (gatewayRequestContext(req)) {
1256
+ json(res, 403, { error: "schedule_not_over_public_link" });
1257
+ return true;
1258
+ }
860
1259
  if (!ctx.scheduler) {
861
1260
  json(res, 500, { error: "Scheduler not initialized" });
862
1261
  return true;
@@ -893,26 +1292,82 @@ export function handleWebRequest(req, res, url, ctx) {
893
1292
  return true;
894
1293
  }
895
1294
  if (method === "POST" && path === "/ui/teams") {
1295
+ // #1689: not over the public link (absent from its manifest too), like editing and deleting a team.
1296
+ if (gatewayRequestContext(req)) {
1297
+ json(res, 403, { error: "teams_not_over_public_link" });
1298
+ return true;
1299
+ }
896
1300
  (async () => {
897
1301
  try {
898
1302
  const body = await parseBody(req);
899
1303
  if (!permitWebContinuation(req, res, ctx))
900
1304
  return;
1305
+ // #1692: who may write, asked again once the body is in (as the edit does).
1306
+ const verdict = evaluateWebRequest(req, url, ctx.webToken ?? null, ctx.webSessions, { touch: false });
1307
+ if (verdict.kind !== "allow" || (verdict.via === "session" && verdict.session?.tier !== "admin")) {
1308
+ json(res, 401, { error: WEB_TOKEN_INVALID_MESSAGE });
1309
+ return;
1310
+ }
901
1311
  const parsed = parseOrReject(TeamCreateSchema, body, res);
902
1312
  if (!parsed)
903
1313
  return;
904
- if (!ctx.fleetConfig) {
905
- json(res, 500, { error: "No fleet config" });
1314
+ // #1692: the create_team path — members checked, an existing team never overwritten, a failed save undone.
1315
+ const r = commitTeamCreate(ctx.fleetConfig, parsed, () => ctx.saveFleetConfig());
1316
+ if (!r.ok) {
1317
+ json(res, r.status, { error: r.error });
906
1318
  return;
907
1319
  }
908
- if (!ctx.fleetConfig.teams)
909
- ctx.fleetConfig.teams = {};
910
- ctx.fleetConfig.teams[parsed.name] = {
911
- members: parsed.members,
912
- description: parsed.description,
913
- };
914
- ctx.saveFleetConfig();
915
- json(res, 200, { created: parsed.name });
1320
+ json(res, 200, r.result);
1321
+ }
1322
+ catch (err) {
1323
+ json(res, 400, { error: err.message });
1324
+ }
1325
+ })();
1326
+ return true;
1327
+ }
1328
+ // #1689: edit a team — the same update as the update_team tool (applyTeamUpdate), from the members the page sends:
1329
+ // what to add and remove is worked out against the team as it is now. Never over the public link.
1330
+ const teamEditMatch = path.match(/^\/ui\/teams\/(.+)$/);
1331
+ if ((method === "PATCH" || method === "PUT") && teamEditMatch) {
1332
+ if (gatewayRequestContext(req)) {
1333
+ json(res, 403, { error: "teams_not_over_public_link" });
1334
+ return true;
1335
+ }
1336
+ const name = decodeURIComponent(teamEditMatch[1]);
1337
+ // Who may write, asked again once the body is in (#1693 review): a session revoked or a token rotated meanwhile
1338
+ // writes nothing.
1339
+ const writerNow = () => {
1340
+ const verdict = evaluateWebRequest(req, url, ctx.webToken ?? null, ctx.webSessions, { touch: false });
1341
+ return verdict.kind === "allow" && !(verdict.via === "session" && verdict.session?.tier !== "admin");
1342
+ };
1343
+ (async () => {
1344
+ try {
1345
+ const body = await parseBody(req);
1346
+ if (!permitWebContinuation(req, res, ctx))
1347
+ return;
1348
+ if (!writerNow()) {
1349
+ json(res, 401, { error: WEB_TOKEN_INVALID_MESSAGE });
1350
+ return;
1351
+ }
1352
+ const parsed = parseOrReject(TeamEditSchema, body, res);
1353
+ if (!parsed)
1354
+ return;
1355
+ const teams = ctx.fleetConfig?.teams;
1356
+ const current = teams && Object.hasOwn(teams, name) ? teams[name].members : null;
1357
+ if (!current) {
1358
+ json(res, 404, { error: `Team not found: ${name}` });
1359
+ return;
1360
+ }
1361
+ const want = [...new Set(parsed.members)];
1362
+ // The update and its save as one: a failed save leaves the team as it was (500).
1363
+ const r = commitTeamUpdate(ctx.fleetConfig, {
1364
+ name, add: want.filter(m => !current.includes(m)), remove: current.filter(m => !want.includes(m)), description: parsed.description,
1365
+ }, () => ctx.saveFleetConfig());
1366
+ if (!r.ok) {
1367
+ json(res, r.status, { error: r.error });
1368
+ return;
1369
+ }
1370
+ json(res, 200, { updated: r.result });
916
1371
  }
917
1372
  catch (err) {
918
1373
  json(res, 400, { error: err.message });
@@ -922,8 +1377,13 @@ export function handleWebRequest(req, res, url, ctx) {
922
1377
  }
923
1378
  const teamDelMatch = path.match(/^\/ui\/teams\/(.+)$/);
924
1379
  if (method === "DELETE" && teamDelMatch) {
1380
+ if (gatewayRequestContext(req)) {
1381
+ json(res, 403, { error: "teams_not_over_public_link" });
1382
+ return true;
1383
+ }
925
1384
  const name = decodeURIComponent(teamDelMatch[1]);
926
- if (!ctx.fleetConfig?.teams?.[name]) {
1385
+ // Own keys (#1689): a team is one fleet.yaml defines — "__proto__" or "constructor" is not found.
1386
+ if (!ctx.fleetConfig?.teams || !Object.hasOwn(ctx.fleetConfig.teams, name)) {
927
1387
  json(res, 404, { error: `Team not found: ${name}` });
928
1388
  return true;
929
1389
  }
@@ -1062,6 +1522,14 @@ function handleSendMessage(req, res, ctx) {
1062
1522
  return;
1063
1523
  }
1064
1524
  const message = typed;
1525
+ // #1827: who is writing — the name agents and chats see, and the id they tell people apart by. Asked now that the
1526
+ // body is in: a session revoked meanwhile sends nothing (not as the operator).
1527
+ const person = webPersonOf(req, new URL(req.url ?? "/", "http://localhost"), ctx);
1528
+ if (!person) {
1529
+ ctx.webFiles?.release(files);
1530
+ json(res, 401, { error: WEB_TOKEN_INVALID_MESSAGE });
1531
+ return;
1532
+ }
1065
1533
  const ts = new Date().toISOString();
1066
1534
  // The id the agent is given, and the one its delivery reports come back under: the page's ticks.
1067
1535
  const messageId = newWebMessageId();
@@ -1080,7 +1548,7 @@ function handleSendMessage(req, res, ctx) {
1080
1548
  // opt-in resolved at send time inside sendClassicWebEcho.
1081
1549
  const canClassicEcho = ctx.isClassicInstance?.(instance) === true && !!ctx.sendClassicWebEcho;
1082
1550
  const settleClassicEcho = canClassicEcho ? ctx.reserveWebChannelEcho?.(instance, async () => {
1083
- await ctx.sendClassicWebEcho(instance, formatWebChannelEcho("web-user", preview, t("web.echo_full_text")));
1551
+ await ctx.sendClassicWebEcho(instance, formatWebChannelEcho(person.name, preview, t("web.echo_full_text")), person.name);
1084
1552
  }) : undefined;
1085
1553
  const settleEcho = canEcho ? ctx.reserveWebChannelEcho?.(instance, async () => {
1086
1554
  // A replacement adapter or edited binding is not the route we reserved.
@@ -1088,18 +1556,21 @@ function handleSendMessage(req, res, ctx) {
1088
1556
  || (ctx.getGroupIdForInstance?.(instance) ?? String(ctx.fleetConfig?.channel?.group_id ?? "")) !== groupId
1089
1557
  || ctx.fleetConfig?.instances[instance]?.topic_id !== topicId)
1090
1558
  return;
1091
- return syncAdapter.sendText(String(groupId), formatWebChannelEcho("web-user", preview, t("web.echo_full_text")), { threadId: String(topicId), format: "text", allowedMentions: { parse: [] } });
1559
+ return syncAdapter.sendText(String(groupId), formatWebChannelEcho(person.name, preview, t("web.echo_full_text")), { threadId: String(topicId), format: "text", allowedMentions: { parse: [] } });
1092
1560
  }) : undefined;
1093
1561
  let delivered;
1094
1562
  try {
1095
- delivered = await ctx.deliverToInstance(instance, {
1563
+ const deliver = (name, payload) => ctx.deliverHumanToInstance
1564
+ ? ctx.deliverHumanToInstance(name, payload, webHumanOrigin(), typed)
1565
+ : ctx.deliverToInstance(name, payload);
1566
+ delivered = await deliver(instance, {
1096
1567
  type: "fleet_inbound",
1097
1568
  content: delivery.text,
1098
1569
  targetSession: instance,
1099
1570
  meta: {
1100
1571
  chat_id: groupId ? String(groupId) : "",
1101
1572
  message_id: messageId,
1102
- user: "web-user", user_id: "web-user",
1573
+ user: person.name, user_id: person.userId,
1103
1574
  ts,
1104
1575
  thread_id: topicId != null ? String(topicId) : "",
1105
1576
  source: "web",
@@ -1129,10 +1600,17 @@ function handleSendMessage(req, res, ctx) {
1129
1600
  }
1130
1601
  settleEcho?.(true);
1131
1602
  settleClassicEcho?.(true);
1603
+ // #1666: the same cancel/progress bubble a platform inbound starts, in the topic the echo went to — a second turn
1604
+ // on the echo's lane, so the topic reads "echo, then bubble" and a cancel in between drops it (the lane's epoch).
1605
+ // Not a copy (#1668): a slow bubble never drops the next message's echo, and a slow echo never drops the bubble.
1606
+ if (settleEcho && ctx.startWebChatProgress) {
1607
+ const startProgress = ctx.startWebChatProgress.bind(ctx);
1608
+ ctx.reserveWebChannelEcho?.(instance, () => startProgress(instance), { copy: false })(true);
1609
+ }
1132
1610
  ctx.webFiles?.commit(files);
1133
- ctx.lastInboundUser.set(instance, "web-user");
1134
- ctx.eventLog?.logActivity("message", "web-user", (message || `[${files.length} file(s)]`).slice(0, 200), instance);
1135
- ctx.emitSseEvent("message", { instance, sender: "web-user", role: "user", text: message, ts, attachments: files.map(publicAttachment), messageId });
1611
+ ctx.lastInboundUser.set(instance, person.name);
1612
+ ctx.eventLog?.logActivity("message", person.name, (message || `[${files.length} file(s)]`).slice(0, 200), instance);
1613
+ ctx.emitSseEvent("message", { instance, sender: person.name, role: "user", text: message, ts, attachments: files.map(publicAttachment), messageId });
1136
1614
  json(res, 200, { sent: true, messageId });
1137
1615
  }
1138
1616
  catch {
@@ -1209,8 +1687,12 @@ function handleUpload(req, res, url, ctx) {
1209
1687
  });
1210
1688
  }
1211
1689
  function shellPreview(req, ctx, mode) {
1212
- if (mode !== "full" || gatewayRequestContext(req))
1690
+ if (mode !== "full")
1213
1691
  return null;
1692
+ // #1723: a page of the public link asks its own exposure's preview — never the local listener's Host-based choice.
1693
+ const gateway = gatewayRequestContext(req);
1694
+ if (gateway)
1695
+ return ctx.publicPreviewForUi?.(gateway.exposureId, gateway.expectedOrigin) ?? null;
1214
1696
  return ctx.previewForUi?.(typeof req.headers.host === "string" ? req.headers.host : undefined, isSecureRequest(req)) ?? null;
1215
1697
  }
1216
1698
  /**
@@ -1259,6 +1741,21 @@ function serveMediaRange(req, res, files, id, file) {
1259
1741
  res.writeHead(ranged ? 206 : 200);
1260
1742
  res.end(got.bytes);
1261
1743
  }
1744
+ /**
1745
+ * #1665: `<html>` carrying the host's preferences (data-prefs: `{"rev":n,"values":{…}}`, attribute-escaped). theme.js (a
1746
+ * blocking script in <head>) and the app's dictionary take the theme and language from it over the browser's cache,
1747
+ * and app-prefs.js copies every value into that cache before any panel reads it — so no device paints a stale theme
1748
+ * or replays a tour already seen, a new device included. Only for the signed-in app (the public link reads what is in
1749
+ * force too); the anonymous View reader and the sign-in page keep the browser's own.
1750
+ */
1751
+ function shellHtmlTag(ctx, mode) {
1752
+ if (mode !== "full" || !ctx.webPrefs)
1753
+ return '<html lang="en">';
1754
+ const view = ctx.webPrefs.read(OPERATOR);
1755
+ const lang = view.values.lang === "zh-TW" ? "zh-Hant" : "en";
1756
+ const attr = (v) => v.replace(/[&<>"']/g, c => `&#${c.charCodeAt(0)};`);
1757
+ return `<html lang="${lang}" data-prefs="${attr(JSON.stringify({ rev: view.rev, values: view.values }))}">`;
1758
+ }
1262
1759
  function shellBodyTag(req, ctx, mode) {
1263
1760
  const p = shellPreview(req, ctx, mode);
1264
1761
  const attr = (v) => String(v ?? "").replace(/[&<>"']/g, c => `&#${c.charCodeAt(0)};`);
@@ -1266,8 +1763,14 @@ function shellBodyTag(req, ctx, mode) {
1266
1763
  + ` data-preview-boot="${attr(p?.previewOrigin ? p.boot : "")}" data-preview-reason="${attr(p ? p.reason : "Previews are not available on this fleet.")}"`
1267
1764
  // #1554: no availability asked (the public link, or not the full app) still says why, in the page's language.
1268
1765
  + ` data-preview-reason-code="${attr(p ? p.code ?? "" : gatewayRequestContext(req) ? "publicLink" : "notOffered")}"`
1766
+ // #1723: why the public link's preview tunnel failed (a fixed reason the page words), when it did.
1767
+ + `${p?.detail ? ` data-preview-detail="${attr(p.detail)}"` : ""}`
1269
1768
  // #1523 N3: whether the fleet serves /api/ai-usage (web.usage_panel), for every page's ◔.
1270
1769
  + ` data-usage-panel="${ctx.fleetConfig?.web?.usage_panel === false ? "0" : "1"}"`
1770
+ // #1218: whether the Log tab is on (web.log_tab; off by default): the tab says so and offers to turn it on.
1771
+ + ` data-log-tab="${ctx.fleetConfig?.web?.log_tab === true ? "1" : "0"}"`
1772
+ // #1770: the fleet's zone (chat-logs' and fleet.log's), in which the chat reads its message times and days.
1773
+ + ` data-fleet-tz="${attr(resolveTz())}"`
1271
1774
  // #1589: the public link shows attachments as downloads only (no inline text or media): its scope is unchanged.
1272
1775
  + `${gatewayRequestContext(req) ? ' data-public-link="1"' : ""}>`;
1273
1776
  }
@@ -1282,7 +1785,10 @@ export function serveAppShell(req, res, ctx, mode) {
1282
1785
  const own = mode === "full" && !gatewayRequestContext(req)
1283
1786
  ? dashboardOriginFor(typeof req.headers.host === "string" ? req.headers.host : undefined, isSecureRequest(req)) : null;
1284
1787
  const mediaSrc = own ? `${own}/ui/file/` : "'none'";
1285
- sendPanelHtml(res, html.replace("<body>", shellBodyTag(req, ctx, mode)), 200, {}, { ...(p?.previewOrigin ? { frameSrc: `${p.previewOrigin}/frame` } : {}), mediaSrc });
1788
+ // A public-link page frames what its exposure's preview names (the quick-tunnel wildcard: its host is not known when
1789
+ // the page may load); every other page frames exactly the origin this load chose (#1723).
1790
+ const frameSrc = p && "frameSrc" in p ? p.frameSrc : p?.previewOrigin ? `${p.previewOrigin}/frame` : null;
1791
+ sendPanelHtml(res, html.replace('<html lang="en">', shellHtmlTag(ctx, mode)).replace("<body>", shellBodyTag(req, ctx, mode)), 200, {}, { ...(frameSrc ? { frameSrc } : {}), mediaSrc });
1286
1792
  }
1287
1793
  catch {
1288
1794
  json(res, 500, { error: "app.html not found" });