agim-cli 1.0.1
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.
- package/CHANGELOG.md +1234 -0
- package/LICENSE +21 -0
- package/README.md +422 -0
- package/README.zh-CN.md +414 -0
- package/dist/cli-ui/cmd-handlers.d.ts +11 -0
- package/dist/cli-ui/cmd-handlers.d.ts.map +1 -0
- package/dist/cli-ui/cmd-handlers.js +240 -0
- package/dist/cli-ui/cmd-handlers.js.map +1 -0
- package/dist/cli-ui/config-wizard.d.ts +3 -0
- package/dist/cli-ui/config-wizard.d.ts.map +1 -0
- package/dist/cli-ui/config-wizard.js +851 -0
- package/dist/cli-ui/config-wizard.js.map +1 -0
- package/dist/cli-ui/entry-menu.d.ts +28 -0
- package/dist/cli-ui/entry-menu.d.ts.map +1 -0
- package/dist/cli-ui/entry-menu.js +50 -0
- package/dist/cli-ui/entry-menu.js.map +1 -0
- package/dist/cli-ui/env-file.d.ts +35 -0
- package/dist/cli-ui/env-file.d.ts.map +1 -0
- package/dist/cli-ui/env-file.js +163 -0
- package/dist/cli-ui/env-file.js.map +1 -0
- package/dist/cli-ui/i18n.d.ts +204 -0
- package/dist/cli-ui/i18n.d.ts.map +1 -0
- package/dist/cli-ui/i18n.js +455 -0
- package/dist/cli-ui/i18n.js.map +1 -0
- package/dist/cli-ui/lang-picker.d.ts +10 -0
- package/dist/cli-ui/lang-picker.d.ts.map +1 -0
- package/dist/cli-ui/lang-picker.js +33 -0
- package/dist/cli-ui/lang-picker.js.map +1 -0
- package/dist/cli-ui/paths.d.ts +4 -0
- package/dist/cli-ui/paths.d.ts.map +1 -0
- package/dist/cli-ui/paths.js +11 -0
- package/dist/cli-ui/paths.js.map +1 -0
- package/dist/cli-ui/prompts.d.ts +65 -0
- package/dist/cli-ui/prompts.d.ts.map +1 -0
- package/dist/cli-ui/prompts.js +125 -0
- package/dist/cli-ui/prompts.js.map +1 -0
- package/dist/cli-ui/service.d.ts +41 -0
- package/dist/cli-ui/service.d.ts.map +1 -0
- package/dist/cli-ui/service.js +241 -0
- package/dist/cli-ui/service.js.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +1143 -0
- package/dist/cli.js.map +1 -0
- package/dist/core/acp-server.d.ts +8 -0
- package/dist/core/acp-server.d.ts.map +1 -0
- package/dist/core/acp-server.js +266 -0
- package/dist/core/acp-server.js.map +1 -0
- package/dist/core/agent-base.d.ts +94 -0
- package/dist/core/agent-base.d.ts.map +1 -0
- package/dist/core/agent-base.js +373 -0
- package/dist/core/agent-base.js.map +1 -0
- package/dist/core/agent-cwd.d.ts +48 -0
- package/dist/core/agent-cwd.d.ts.map +1 -0
- package/dist/core/agent-cwd.js +181 -0
- package/dist/core/agent-cwd.js.map +1 -0
- package/dist/core/agent-helper.d.ts +65 -0
- package/dist/core/agent-helper.d.ts.map +1 -0
- package/dist/core/agent-helper.js +150 -0
- package/dist/core/agent-helper.js.map +1 -0
- package/dist/core/agim-paths.d.ts +10 -0
- package/dist/core/agim-paths.d.ts.map +1 -0
- package/dist/core/agim-paths.js +64 -0
- package/dist/core/agim-paths.js.map +1 -0
- package/dist/core/approval-bus.d.ts +300 -0
- package/dist/core/approval-bus.d.ts.map +1 -0
- package/dist/core/approval-bus.js +990 -0
- package/dist/core/approval-bus.js.map +1 -0
- package/dist/core/approval-router.d.ts +101 -0
- package/dist/core/approval-router.d.ts.map +1 -0
- package/dist/core/approval-router.js +540 -0
- package/dist/core/approval-router.js.map +1 -0
- package/dist/core/audit-log.d.ts +55 -0
- package/dist/core/audit-log.d.ts.map +1 -0
- package/dist/core/audit-log.js +203 -0
- package/dist/core/audit-log.js.map +1 -0
- package/dist/core/bgjob-reader.d.ts +65 -0
- package/dist/core/bgjob-reader.d.ts.map +1 -0
- package/dist/core/bgjob-reader.js +212 -0
- package/dist/core/bgjob-reader.js.map +1 -0
- package/dist/core/circuit-breaker.d.ts +37 -0
- package/dist/core/circuit-breaker.d.ts.map +1 -0
- package/dist/core/circuit-breaker.js +115 -0
- package/dist/core/circuit-breaker.js.map +1 -0
- package/dist/core/commands/agent.d.ts +4 -0
- package/dist/core/commands/agent.d.ts.map +1 -0
- package/dist/core/commands/agent.js +40 -0
- package/dist/core/commands/agent.js.map +1 -0
- package/dist/core/commands/approval.d.ts +3 -0
- package/dist/core/commands/approval.d.ts.map +1 -0
- package/dist/core/commands/approval.js +85 -0
- package/dist/core/commands/approval.js.map +1 -0
- package/dist/core/commands/audit.d.ts +3 -0
- package/dist/core/commands/audit.d.ts.map +1 -0
- package/dist/core/commands/audit.js +84 -0
- package/dist/core/commands/audit.js.map +1 -0
- package/dist/core/commands/builtin.d.ts +3 -0
- package/dist/core/commands/builtin.d.ts.map +1 -0
- package/dist/core/commands/builtin.js +304 -0
- package/dist/core/commands/builtin.js.map +1 -0
- package/dist/core/commands/cron.d.ts +3 -0
- package/dist/core/commands/cron.d.ts.map +1 -0
- package/dist/core/commands/cron.js +128 -0
- package/dist/core/commands/cron.js.map +1 -0
- package/dist/core/commands/job.d.ts +3 -0
- package/dist/core/commands/job.d.ts.map +1 -0
- package/dist/core/commands/job.js +195 -0
- package/dist/core/commands/job.js.map +1 -0
- package/dist/core/commands/memo.d.ts +3 -0
- package/dist/core/commands/memo.d.ts.map +1 -0
- package/dist/core/commands/memo.js +151 -0
- package/dist/core/commands/memo.js.map +1 -0
- package/dist/core/commands/model.d.ts +9 -0
- package/dist/core/commands/model.d.ts.map +1 -0
- package/dist/core/commands/model.js +183 -0
- package/dist/core/commands/model.js.map +1 -0
- package/dist/core/commands/plan.d.ts +3 -0
- package/dist/core/commands/plan.d.ts.map +1 -0
- package/dist/core/commands/plan.js +75 -0
- package/dist/core/commands/plan.js.map +1 -0
- package/dist/core/commands/remind.d.ts +3 -0
- package/dist/core/commands/remind.d.ts.map +1 -0
- package/dist/core/commands/remind.js +271 -0
- package/dist/core/commands/remind.js.map +1 -0
- package/dist/core/commands/router.d.ts +3 -0
- package/dist/core/commands/router.d.ts.map +1 -0
- package/dist/core/commands/router.js +71 -0
- package/dist/core/commands/router.js.map +1 -0
- package/dist/core/commands/sessions.d.ts +3 -0
- package/dist/core/commands/sessions.d.ts.map +1 -0
- package/dist/core/commands/sessions.js +88 -0
- package/dist/core/commands/sessions.js.map +1 -0
- package/dist/core/commands/stats.d.ts +3 -0
- package/dist/core/commands/stats.d.ts.map +1 -0
- package/dist/core/commands/stats.js +73 -0
- package/dist/core/commands/stats.js.map +1 -0
- package/dist/core/commands/think.d.ts +3 -0
- package/dist/core/commands/think.d.ts.map +1 -0
- package/dist/core/commands/think.js +28 -0
- package/dist/core/commands/think.js.map +1 -0
- package/dist/core/commands/workspaces.d.ts +3 -0
- package/dist/core/commands/workspaces.d.ts.map +1 -0
- package/dist/core/commands/workspaces.js +47 -0
- package/dist/core/commands/workspaces.js.map +1 -0
- package/dist/core/config-schema.d.ts +60 -0
- package/dist/core/config-schema.d.ts.map +1 -0
- package/dist/core/config-schema.js +75 -0
- package/dist/core/config-schema.js.map +1 -0
- package/dist/core/coord-systems.d.ts +65 -0
- package/dist/core/coord-systems.d.ts.map +1 -0
- package/dist/core/coord-systems.js +229 -0
- package/dist/core/coord-systems.js.map +1 -0
- package/dist/core/cron.d.ts +29 -0
- package/dist/core/cron.d.ts.map +1 -0
- package/dist/core/cron.js +184 -0
- package/dist/core/cron.js.map +1 -0
- package/dist/core/event-bus.d.ts +80 -0
- package/dist/core/event-bus.d.ts.map +1 -0
- package/dist/core/event-bus.js +62 -0
- package/dist/core/event-bus.js.map +1 -0
- package/dist/core/intent-llm.d.ts +27 -0
- package/dist/core/intent-llm.d.ts.map +1 -0
- package/dist/core/intent-llm.js +170 -0
- package/dist/core/intent-llm.js.map +1 -0
- package/dist/core/intent.d.ts +12 -0
- package/dist/core/intent.d.ts.map +1 -0
- package/dist/core/intent.js +187 -0
- package/dist/core/intent.js.map +1 -0
- package/dist/core/job-board.d.ts +82 -0
- package/dist/core/job-board.d.ts.map +1 -0
- package/dist/core/job-board.js +379 -0
- package/dist/core/job-board.js.map +1 -0
- package/dist/core/location-context.d.ts +32 -0
- package/dist/core/location-context.d.ts.map +1 -0
- package/dist/core/location-context.js +69 -0
- package/dist/core/location-context.js.map +1 -0
- package/dist/core/location-token.d.ts +57 -0
- package/dist/core/location-token.d.ts.map +1 -0
- package/dist/core/location-token.js +128 -0
- package/dist/core/location-token.js.map +1 -0
- package/dist/core/logger.d.ts +6 -0
- package/dist/core/logger.d.ts.map +1 -0
- package/dist/core/logger.js +54 -0
- package/dist/core/logger.js.map +1 -0
- package/dist/core/memo-rpc.d.ts +13 -0
- package/dist/core/memo-rpc.d.ts.map +1 -0
- package/dist/core/memo-rpc.js +288 -0
- package/dist/core/memo-rpc.js.map +1 -0
- package/dist/core/memos.d.ts +163 -0
- package/dist/core/memos.d.ts.map +1 -0
- package/dist/core/memos.js +502 -0
- package/dist/core/memos.js.map +1 -0
- package/dist/core/metrics.d.ts +55 -0
- package/dist/core/metrics.d.ts.map +1 -0
- package/dist/core/metrics.js +291 -0
- package/dist/core/metrics.js.map +1 -0
- package/dist/core/onboarding.d.ts +99 -0
- package/dist/core/onboarding.d.ts.map +1 -0
- package/dist/core/onboarding.js +426 -0
- package/dist/core/onboarding.js.map +1 -0
- package/dist/core/pending-reminder.d.ts +25 -0
- package/dist/core/pending-reminder.d.ts.map +1 -0
- package/dist/core/pending-reminder.js +53 -0
- package/dist/core/pending-reminder.js.map +1 -0
- package/dist/core/rate-limiter.d.ts +44 -0
- package/dist/core/rate-limiter.d.ts.map +1 -0
- package/dist/core/rate-limiter.js +115 -0
- package/dist/core/rate-limiter.js.map +1 -0
- package/dist/core/registry.d.ts +32 -0
- package/dist/core/registry.d.ts.map +1 -0
- package/dist/core/registry.js +126 -0
- package/dist/core/registry.js.map +1 -0
- package/dist/core/remind-intent.d.ts +25 -0
- package/dist/core/remind-intent.d.ts.map +1 -0
- package/dist/core/remind-intent.js +196 -0
- package/dist/core/remind-intent.js.map +1 -0
- package/dist/core/reminder-rpc.d.ts +17 -0
- package/dist/core/reminder-rpc.d.ts.map +1 -0
- package/dist/core/reminder-rpc.js +169 -0
- package/dist/core/reminder-rpc.js.map +1 -0
- package/dist/core/reminders.d.ts +159 -0
- package/dist/core/reminders.d.ts.map +1 -0
- package/dist/core/reminders.js +977 -0
- package/dist/core/reminders.js.map +1 -0
- package/dist/core/router.d.ts +55 -0
- package/dist/core/router.d.ts.map +1 -0
- package/dist/core/router.js +497 -0
- package/dist/core/router.js.map +1 -0
- package/dist/core/schedule.d.ts +65 -0
- package/dist/core/schedule.d.ts.map +1 -0
- package/dist/core/schedule.js +323 -0
- package/dist/core/schedule.js.map +1 -0
- package/dist/core/session.d.ts +182 -0
- package/dist/core/session.d.ts.map +1 -0
- package/dist/core/session.js +807 -0
- package/dist/core/session.js.map +1 -0
- package/dist/core/sqlite-helper.d.ts +37 -0
- package/dist/core/sqlite-helper.d.ts.map +1 -0
- package/dist/core/sqlite-helper.js +79 -0
- package/dist/core/sqlite-helper.js.map +1 -0
- package/dist/core/transcribe.d.ts +25 -0
- package/dist/core/transcribe.d.ts.map +1 -0
- package/dist/core/transcribe.js +217 -0
- package/dist/core/transcribe.js.map +1 -0
- package/dist/core/types.d.ts +360 -0
- package/dist/core/types.d.ts.map +1 -0
- package/dist/core/types.js +3 -0
- package/dist/core/types.js.map +1 -0
- package/dist/core/workspace.d.ts +67 -0
- package/dist/core/workspace.d.ts.map +1 -0
- package/dist/core/workspace.js +113 -0
- package/dist/core/workspace.js.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -0
- package/dist/plugins/agents/acp/acp-adapter.d.ts +16 -0
- package/dist/plugins/agents/acp/acp-adapter.d.ts.map +1 -0
- package/dist/plugins/agents/acp/acp-adapter.js +49 -0
- package/dist/plugins/agents/acp/acp-adapter.js.map +1 -0
- package/dist/plugins/agents/acp/acp-client.d.ts +32 -0
- package/dist/plugins/agents/acp/acp-client.d.ts.map +1 -0
- package/dist/plugins/agents/acp/acp-client.js +177 -0
- package/dist/plugins/agents/acp/acp-client.js.map +1 -0
- package/dist/plugins/agents/acp/discovery.d.ts +19 -0
- package/dist/plugins/agents/acp/discovery.d.ts.map +1 -0
- package/dist/plugins/agents/acp/discovery.js +111 -0
- package/dist/plugins/agents/acp/discovery.js.map +1 -0
- package/dist/plugins/agents/acp/index.d.ts +4 -0
- package/dist/plugins/agents/acp/index.d.ts.map +1 -0
- package/dist/plugins/agents/acp/index.js +4 -0
- package/dist/plugins/agents/acp/index.js.map +1 -0
- package/dist/plugins/agents/acp/types.d.ts +62 -0
- package/dist/plugins/agents/acp/types.d.ts.map +1 -0
- package/dist/plugins/agents/acp/types.js +5 -0
- package/dist/plugins/agents/acp/types.js.map +1 -0
- package/dist/plugins/agents/claude-code/index.d.ts +25 -0
- package/dist/plugins/agents/claude-code/index.d.ts.map +1 -0
- package/dist/plugins/agents/claude-code/index.js +184 -0
- package/dist/plugins/agents/claude-code/index.js.map +1 -0
- package/dist/plugins/agents/claude-code/mcp-approval-server.d.ts +59 -0
- package/dist/plugins/agents/claude-code/mcp-approval-server.d.ts.map +1 -0
- package/dist/plugins/agents/claude-code/mcp-approval-server.js +645 -0
- package/dist/plugins/agents/claude-code/mcp-approval-server.js.map +1 -0
- package/dist/plugins/agents/codex/build-mcp-cli-args.d.ts +28 -0
- package/dist/plugins/agents/codex/build-mcp-cli-args.d.ts.map +1 -0
- package/dist/plugins/agents/codex/build-mcp-cli-args.js +74 -0
- package/dist/plugins/agents/codex/build-mcp-cli-args.js.map +1 -0
- package/dist/plugins/agents/codex/index.d.ts +53 -0
- package/dist/plugins/agents/codex/index.d.ts.map +1 -0
- package/dist/plugins/agents/codex/index.js +341 -0
- package/dist/plugins/agents/codex/index.js.map +1 -0
- package/dist/plugins/agents/copilot/index.d.ts +35 -0
- package/dist/plugins/agents/copilot/index.d.ts.map +1 -0
- package/dist/plugins/agents/copilot/index.js +182 -0
- package/dist/plugins/agents/copilot/index.js.map +1 -0
- package/dist/plugins/agents/opencode/ensure-mcp-config.d.ts +11 -0
- package/dist/plugins/agents/opencode/ensure-mcp-config.d.ts.map +1 -0
- package/dist/plugins/agents/opencode/ensure-mcp-config.js +100 -0
- package/dist/plugins/agents/opencode/ensure-mcp-config.js.map +1 -0
- package/dist/plugins/agents/opencode/index.d.ts +5 -0
- package/dist/plugins/agents/opencode/index.d.ts.map +1 -0
- package/dist/plugins/agents/opencode/index.js +30 -0
- package/dist/plugins/agents/opencode/index.js.map +1 -0
- package/dist/plugins/agents/opencode/opencode-http-adapter.d.ts +166 -0
- package/dist/plugins/agents/opencode/opencode-http-adapter.d.ts.map +1 -0
- package/dist/plugins/agents/opencode/opencode-http-adapter.js +682 -0
- package/dist/plugins/agents/opencode/opencode-http-adapter.js.map +1 -0
- package/dist/plugins/agents/opencode/opencode-stdio-adapter.d.ts +32 -0
- package/dist/plugins/agents/opencode/opencode-stdio-adapter.d.ts.map +1 -0
- package/dist/plugins/agents/opencode/opencode-stdio-adapter.js +137 -0
- package/dist/plugins/agents/opencode/opencode-stdio-adapter.js.map +1 -0
- package/dist/plugins/agents/opencode/serve-manager.d.ts +27 -0
- package/dist/plugins/agents/opencode/serve-manager.d.ts.map +1 -0
- package/dist/plugins/agents/opencode/serve-manager.js +194 -0
- package/dist/plugins/agents/opencode/serve-manager.js.map +1 -0
- package/dist/plugins/messengers/dingtalk/dingtalk-adapter.d.ts +57 -0
- package/dist/plugins/messengers/dingtalk/dingtalk-adapter.d.ts.map +1 -0
- package/dist/plugins/messengers/dingtalk/dingtalk-adapter.js +409 -0
- package/dist/plugins/messengers/dingtalk/dingtalk-adapter.js.map +1 -0
- package/dist/plugins/messengers/dingtalk/dingtalk-client.d.ts +48 -0
- package/dist/plugins/messengers/dingtalk/dingtalk-client.d.ts.map +1 -0
- package/dist/plugins/messengers/dingtalk/dingtalk-client.js +236 -0
- package/dist/plugins/messengers/dingtalk/dingtalk-client.js.map +1 -0
- package/dist/plugins/messengers/dingtalk/index.d.ts +3 -0
- package/dist/plugins/messengers/dingtalk/index.d.ts.map +1 -0
- package/dist/plugins/messengers/dingtalk/index.js +3 -0
- package/dist/plugins/messengers/dingtalk/index.js.map +1 -0
- package/dist/plugins/messengers/dingtalk/link-coords.d.ts +23 -0
- package/dist/plugins/messengers/dingtalk/link-coords.d.ts.map +1 -0
- package/dist/plugins/messengers/dingtalk/link-coords.js +89 -0
- package/dist/plugins/messengers/dingtalk/link-coords.js.map +1 -0
- package/dist/plugins/messengers/dingtalk/media-store.d.ts +16 -0
- package/dist/plugins/messengers/dingtalk/media-store.d.ts.map +1 -0
- package/dist/plugins/messengers/dingtalk/media-store.js +77 -0
- package/dist/plugins/messengers/dingtalk/media-store.js.map +1 -0
- package/dist/plugins/messengers/dingtalk/types.d.ts +82 -0
- package/dist/plugins/messengers/dingtalk/types.d.ts.map +1 -0
- package/dist/plugins/messengers/dingtalk/types.js +14 -0
- package/dist/plugins/messengers/dingtalk/types.js.map +1 -0
- package/dist/plugins/messengers/discord/discord-adapter.d.ts +21 -0
- package/dist/plugins/messengers/discord/discord-adapter.d.ts.map +1 -0
- package/dist/plugins/messengers/discord/discord-adapter.js +238 -0
- package/dist/plugins/messengers/discord/discord-adapter.js.map +1 -0
- package/dist/plugins/messengers/discord/index.d.ts +4 -0
- package/dist/plugins/messengers/discord/index.d.ts.map +1 -0
- package/dist/plugins/messengers/discord/index.js +4 -0
- package/dist/plugins/messengers/discord/index.js.map +1 -0
- package/dist/plugins/messengers/discord/markdown-to-discord.d.ts +11 -0
- package/dist/plugins/messengers/discord/markdown-to-discord.d.ts.map +1 -0
- package/dist/plugins/messengers/discord/markdown-to-discord.js +59 -0
- package/dist/plugins/messengers/discord/markdown-to-discord.js.map +1 -0
- package/dist/plugins/messengers/discord/types.d.ts +9 -0
- package/dist/plugins/messengers/discord/types.d.ts.map +1 -0
- package/dist/plugins/messengers/discord/types.js +3 -0
- package/dist/plugins/messengers/discord/types.js.map +1 -0
- package/dist/plugins/messengers/email/email-adapter.d.ts +33 -0
- package/dist/plugins/messengers/email/email-adapter.d.ts.map +1 -0
- package/dist/plugins/messengers/email/email-adapter.js +137 -0
- package/dist/plugins/messengers/email/email-adapter.js.map +1 -0
- package/dist/plugins/messengers/feishu/card-builder.d.ts +23 -0
- package/dist/plugins/messengers/feishu/card-builder.d.ts.map +1 -0
- package/dist/plugins/messengers/feishu/card-builder.js +89 -0
- package/dist/plugins/messengers/feishu/card-builder.js.map +1 -0
- package/dist/plugins/messengers/feishu/feishu-adapter.d.ts +23 -0
- package/dist/plugins/messengers/feishu/feishu-adapter.d.ts.map +1 -0
- package/dist/plugins/messengers/feishu/feishu-adapter.js +250 -0
- package/dist/plugins/messengers/feishu/feishu-adapter.js.map +1 -0
- package/dist/plugins/messengers/feishu/feishu-client.d.ts +43 -0
- package/dist/plugins/messengers/feishu/feishu-client.d.ts.map +1 -0
- package/dist/plugins/messengers/feishu/feishu-client.js +118 -0
- package/dist/plugins/messengers/feishu/feishu-client.js.map +1 -0
- package/dist/plugins/messengers/feishu/index.d.ts +4 -0
- package/dist/plugins/messengers/feishu/index.d.ts.map +1 -0
- package/dist/plugins/messengers/feishu/index.js +4 -0
- package/dist/plugins/messengers/feishu/index.js.map +1 -0
- package/dist/plugins/messengers/feishu/types.d.ts +113 -0
- package/dist/plugins/messengers/feishu/types.d.ts.map +1 -0
- package/dist/plugins/messengers/feishu/types.js +4 -0
- package/dist/plugins/messengers/feishu/types.js.map +1 -0
- package/dist/plugins/messengers/telegram/index.d.ts +4 -0
- package/dist/plugins/messengers/telegram/index.d.ts.map +1 -0
- package/dist/plugins/messengers/telegram/index.js +4 -0
- package/dist/plugins/messengers/telegram/index.js.map +1 -0
- package/dist/plugins/messengers/telegram/markdown-to-html.d.ts +5 -0
- package/dist/plugins/messengers/telegram/markdown-to-html.d.ts.map +1 -0
- package/dist/plugins/messengers/telegram/markdown-to-html.js +186 -0
- package/dist/plugins/messengers/telegram/markdown-to-html.js.map +1 -0
- package/dist/plugins/messengers/telegram/media-download.d.ts +59 -0
- package/dist/plugins/messengers/telegram/media-download.d.ts.map +1 -0
- package/dist/plugins/messengers/telegram/media-download.js +228 -0
- package/dist/plugins/messengers/telegram/media-download.js.map +1 -0
- package/dist/plugins/messengers/telegram/telegram-adapter.d.ts +77 -0
- package/dist/plugins/messengers/telegram/telegram-adapter.d.ts.map +1 -0
- package/dist/plugins/messengers/telegram/telegram-adapter.js +880 -0
- package/dist/plugins/messengers/telegram/telegram-adapter.js.map +1 -0
- package/dist/plugins/messengers/telegram/types.d.ts +47 -0
- package/dist/plugins/messengers/telegram/types.d.ts.map +1 -0
- package/dist/plugins/messengers/telegram/types.js +3 -0
- package/dist/plugins/messengers/telegram/types.js.map +1 -0
- package/dist/plugins/messengers/wechat/context-store.d.ts +18 -0
- package/dist/plugins/messengers/wechat/context-store.d.ts.map +1 -0
- package/dist/plugins/messengers/wechat/context-store.js +105 -0
- package/dist/plugins/messengers/wechat/context-store.js.map +1 -0
- package/dist/plugins/messengers/wechat/ilink-adapter.d.ts +71 -0
- package/dist/plugins/messengers/wechat/ilink-adapter.d.ts.map +1 -0
- package/dist/plugins/messengers/wechat/ilink-adapter.js +664 -0
- package/dist/plugins/messengers/wechat/ilink-adapter.js.map +1 -0
- package/dist/plugins/messengers/wechat/ilink-client.d.ts +75 -0
- package/dist/plugins/messengers/wechat/ilink-client.d.ts.map +1 -0
- package/dist/plugins/messengers/wechat/ilink-client.js +331 -0
- package/dist/plugins/messengers/wechat/ilink-client.js.map +1 -0
- package/dist/plugins/messengers/wechat/ilink-types.d.ts +181 -0
- package/dist/plugins/messengers/wechat/ilink-types.d.ts.map +1 -0
- package/dist/plugins/messengers/wechat/ilink-types.js +22 -0
- package/dist/plugins/messengers/wechat/ilink-types.js.map +1 -0
- package/dist/plugins/messengers/wechat/media-download.d.ts +32 -0
- package/dist/plugins/messengers/wechat/media-download.d.ts.map +1 -0
- package/dist/plugins/messengers/wechat/media-download.js +78 -0
- package/dist/plugins/messengers/wechat/media-download.js.map +1 -0
- package/dist/scripts/migrate-gcj02-to-wgs84.d.ts +3 -0
- package/dist/scripts/migrate-gcj02-to-wgs84.d.ts.map +1 -0
- package/dist/scripts/migrate-gcj02-to-wgs84.js +52 -0
- package/dist/scripts/migrate-gcj02-to-wgs84.js.map +1 -0
- package/dist/utils/backoff.d.ts +35 -0
- package/dist/utils/backoff.d.ts.map +1 -0
- package/dist/utils/backoff.js +59 -0
- package/dist/utils/backoff.js.map +1 -0
- package/dist/utils/cross-platform.d.ts +26 -0
- package/dist/utils/cross-platform.d.ts.map +1 -0
- package/dist/utils/cross-platform.js +58 -0
- package/dist/utils/cross-platform.js.map +1 -0
- package/dist/utils/message-split.d.ts +14 -0
- package/dist/utils/message-split.d.ts.map +1 -0
- package/dist/utils/message-split.js +65 -0
- package/dist/utils/message-split.js.map +1 -0
- package/dist/utils/safe-equal.d.ts +2 -0
- package/dist/utils/safe-equal.d.ts.map +1 -0
- package/dist/utils/safe-equal.js +11 -0
- package/dist/utils/safe-equal.js.map +1 -0
- package/dist/web/public/_app.js +196 -0
- package/dist/web/public/index.html +936 -0
- package/dist/web/public/loc.html +305 -0
- package/dist/web/public/login.html +106 -0
- package/dist/web/public/memos.html +271 -0
- package/dist/web/public/reminders.html +234 -0
- package/dist/web/public/settings.html +1355 -0
- package/dist/web/public/tasks.html +1835 -0
- package/dist/web/server.d.ts +12 -0
- package/dist/web/server.d.ts.map +1 -0
- package/dist/web/server.js +2399 -0
- package/dist/web/server.js.map +1 -0
- package/package.json +92 -0
|
@@ -0,0 +1,2399 @@
|
|
|
1
|
+
// Web chat server — HTTP + WebSocket for browser-based agent interaction
|
|
2
|
+
import { createServer } from 'node:http';
|
|
3
|
+
import { readFileSync, existsSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
4
|
+
import { readdir, stat, readFile, writeFile, rename, unlink, realpath, open } from 'node:fs/promises';
|
|
5
|
+
import { join, dirname, resolve as resolvePath, sep as pathSep, relative as relativePath } from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import { AGIM_HOME } from '../core/agim-paths.js';
|
|
8
|
+
import { randomBytes, createHmac, timingSafeEqual } from 'node:crypto';
|
|
9
|
+
import { WebSocketServer } from 'ws';
|
|
10
|
+
import { parseMessage, routeMessage } from '../core/router.js';
|
|
11
|
+
import { sessionManager } from '../core/session.js';
|
|
12
|
+
import { registry } from '../core/registry.js';
|
|
13
|
+
import { generateTraceId, createLogger, logger as rootLogger } from '../core/logger.js';
|
|
14
|
+
import { validateConfig } from '../core/config-schema.js';
|
|
15
|
+
import { safeEqual } from '../utils/safe-equal.js';
|
|
16
|
+
import { consumeToken, peekToken } from '../core/location-token.js';
|
|
17
|
+
import { createMemo, updateMemo, getMemo, mapUrls } from '../core/memos.js';
|
|
18
|
+
import { normalizeIncomingCoords } from '../core/coord-systems.js';
|
|
19
|
+
const webLog = rootLogger.child({ component: 'web' });
|
|
20
|
+
/**
|
|
21
|
+
* Module-level reference to the button-callback handler that approval-router
|
|
22
|
+
* registers on our synthetic web messenger. The WS message switch dispatches
|
|
23
|
+
* `approval-action` events through this so an in-page approval button click
|
|
24
|
+
* flows back into approvalBus.resolvePending(), same path as a Telegram
|
|
25
|
+
* inline-button tap. Set by the web messenger's `onButtonCallback`; remains
|
|
26
|
+
* undefined until approval-router installs (which happens before this file's
|
|
27
|
+
* exported startWebServer is called from cli.ts).
|
|
28
|
+
*/
|
|
29
|
+
let webButtonHandler;
|
|
30
|
+
import { isAgentAvailableCached, loadConfig, saveConfig, } from '../core/onboarding.js';
|
|
31
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
32
|
+
const PUBLIC_DIR = join(__dirname, 'public');
|
|
33
|
+
const DEFAULT_PORT = 3000;
|
|
34
|
+
const WEB_TOKEN_DIR = AGIM_HOME;
|
|
35
|
+
const WEB_TOKEN_FILE = join(WEB_TOKEN_DIR, 'web-token');
|
|
36
|
+
function generateToken() {
|
|
37
|
+
return randomBytes(32).toString('hex');
|
|
38
|
+
}
|
|
39
|
+
function getOrCreateWebToken() {
|
|
40
|
+
try {
|
|
41
|
+
return readFileSync(WEB_TOKEN_FILE, 'utf-8').trim();
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
const token = generateToken();
|
|
45
|
+
mkdirSync(WEB_TOKEN_DIR, { recursive: true });
|
|
46
|
+
writeFileSync(WEB_TOKEN_FILE, token, { mode: 0o600 });
|
|
47
|
+
return token;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
function isMasked(value) {
|
|
51
|
+
if (!value)
|
|
52
|
+
return false;
|
|
53
|
+
return /^.{0,2}\*{2,}.{0,2}$/.test(value);
|
|
54
|
+
}
|
|
55
|
+
export function createSerialQueue() {
|
|
56
|
+
let queue = Promise.resolve();
|
|
57
|
+
return (fn) => {
|
|
58
|
+
const run = queue.then(fn, fn);
|
|
59
|
+
queue = run.catch(() => { });
|
|
60
|
+
void run;
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Start the web chat server
|
|
65
|
+
*/
|
|
66
|
+
export async function startWebServer(options) {
|
|
67
|
+
const port = options.port || DEFAULT_PORT;
|
|
68
|
+
const bindHost = process.env.IMHUB_WEB_BIND || '127.0.0.1';
|
|
69
|
+
const webToken = getOrCreateWebToken();
|
|
70
|
+
const clients = new Map();
|
|
71
|
+
// Auth-required mode is now scoped to public binds. When listening on
|
|
72
|
+
// 127.0.0.1 / ::1 / localhost the web console is wide open — whoever has
|
|
73
|
+
// shell on this host already owns it, and asking small users to paste a
|
|
74
|
+
// token they don't understand was the biggest friction point in onboarding.
|
|
75
|
+
// For 0.0.0.0 / LAN / WAN binds we keep the token requirement so a
|
|
76
|
+
// copy-pasted IMHUB_WEB_BIND=0.0.0.0 doesn't expose memos/reminders/config.
|
|
77
|
+
//
|
|
78
|
+
// Escape hatches via env:
|
|
79
|
+
// IMHUB_WEB_REQUIRE_AUTH=1 — force token even on localhost (legacy / paranoid)
|
|
80
|
+
// IMHUB_WEB_REQUIRE_AUTH=0 — force NO token even on public bind (you know
|
|
81
|
+
// what you're doing — proxy + HTTPS + external auth)
|
|
82
|
+
const isPublicBind = bindHost !== '127.0.0.1' && bindHost !== '::1' && bindHost !== 'localhost';
|
|
83
|
+
const requireAuth = (() => {
|
|
84
|
+
const forced = process.env.IMHUB_WEB_REQUIRE_AUTH;
|
|
85
|
+
if (forced === '1')
|
|
86
|
+
return true;
|
|
87
|
+
if (forced === '0')
|
|
88
|
+
return false;
|
|
89
|
+
return isPublicBind;
|
|
90
|
+
})();
|
|
91
|
+
// Per-process secret for signing session cookies. Not persisted — browser
|
|
92
|
+
// sessions expire on restart, which is acceptable for a dev tool.
|
|
93
|
+
const cookieSecret = randomBytes(32).toString('hex');
|
|
94
|
+
const COOKIE_NAME = 'imhub_session';
|
|
95
|
+
function makeSessionCookie() {
|
|
96
|
+
return createHmac('sha256', cookieSecret).update(webToken).digest('hex');
|
|
97
|
+
}
|
|
98
|
+
function isValidSessionCookie(cookie) {
|
|
99
|
+
const expected = makeSessionCookie();
|
|
100
|
+
if (cookie.length !== expected.length)
|
|
101
|
+
return false;
|
|
102
|
+
try {
|
|
103
|
+
return timingSafeEqual(Buffer.from(cookie, 'utf8'), Buffer.from(expected, 'utf8'));
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
function parseCookies(req) {
|
|
110
|
+
const hdr = req.headers.cookie || '';
|
|
111
|
+
const out = {};
|
|
112
|
+
for (const pair of hdr.split(';')) {
|
|
113
|
+
const eq = pair.indexOf('=');
|
|
114
|
+
if (eq < 0)
|
|
115
|
+
continue;
|
|
116
|
+
out[pair.slice(0, eq).trim()] = pair.slice(eq + 1).trim();
|
|
117
|
+
}
|
|
118
|
+
return out;
|
|
119
|
+
}
|
|
120
|
+
function hasValidSession(req) {
|
|
121
|
+
const cookies = parseCookies(req);
|
|
122
|
+
return isValidSessionCookie(cookies[COOKIE_NAME] || '');
|
|
123
|
+
}
|
|
124
|
+
function setSessionCookie(req, res) {
|
|
125
|
+
const val = makeSessionCookie();
|
|
126
|
+
const parts = [`${COOKIE_NAME}=${val}`, 'HttpOnly', 'SameSite=Strict', 'Path=/'];
|
|
127
|
+
// Mark Secure when we have any signal we're served over TLS:
|
|
128
|
+
// - public bind (assume reverse proxy will/should terminate TLS)
|
|
129
|
+
// - X-Forwarded-Proto: https from a trusted reverse proxy
|
|
130
|
+
// For pure localhost dev (no proxy) we omit Secure so plain HTTP works.
|
|
131
|
+
const isPublicBind = bindHost !== '127.0.0.1' && bindHost !== '::1' && bindHost !== 'localhost';
|
|
132
|
+
const xfp = String(req.headers['x-forwarded-proto'] || '').split(',')[0].trim().toLowerCase();
|
|
133
|
+
if (isPublicBind || xfp === 'https')
|
|
134
|
+
parts.push('Secure');
|
|
135
|
+
res.setHeader('Set-Cookie', parts.join('; '));
|
|
136
|
+
}
|
|
137
|
+
if (isPublicBind) {
|
|
138
|
+
webLog.warn({
|
|
139
|
+
event: 'web.public_bind_warning',
|
|
140
|
+
bind: bindHost,
|
|
141
|
+
requireAuth,
|
|
142
|
+
}, requireAuth
|
|
143
|
+
? 'Web server bound to a non-localhost address — token auth REQUIRED; front it with a reverse proxy that terminates TLS'
|
|
144
|
+
: 'Web server bound publicly with IMHUB_WEB_REQUIRE_AUTH=0 — caller assumes responsibility for upstream auth');
|
|
145
|
+
}
|
|
146
|
+
webLog.info({
|
|
147
|
+
event: 'web.auth_mode',
|
|
148
|
+
bind: bindHost,
|
|
149
|
+
requireAuth,
|
|
150
|
+
}, requireAuth
|
|
151
|
+
? 'Web console: token auth REQUIRED (login page at /login)'
|
|
152
|
+
: 'Web console: token auth DISABLED (localhost bind — open access)');
|
|
153
|
+
// HTTP request handler — static files + REST API
|
|
154
|
+
const httpServer = createServer(async (req, res) => {
|
|
155
|
+
const url = new URL(req.url || '/', `http://localhost:${port}`);
|
|
156
|
+
// Login page — always accessible
|
|
157
|
+
if (url.pathname === '/login' || url.pathname === '/login.html') {
|
|
158
|
+
return serveStatic(res, join(PUBLIC_DIR, 'login.html'), 'text/html; charset=utf-8');
|
|
159
|
+
}
|
|
160
|
+
// POST /api/auth/login — validate token, set session cookie
|
|
161
|
+
if (url.pathname === '/api/auth/login' && req.method === 'POST') {
|
|
162
|
+
const body = await readBody(req, res);
|
|
163
|
+
let parsed;
|
|
164
|
+
try {
|
|
165
|
+
parsed = JSON.parse(body);
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
sendJson(res, 400, { error: 'Invalid JSON' });
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
if (typeof parsed.token === 'string' && safeEqual(parsed.token, webToken)) {
|
|
172
|
+
setSessionCookie(req, res);
|
|
173
|
+
sendJson(res, 200, { ok: true });
|
|
174
|
+
}
|
|
175
|
+
else {
|
|
176
|
+
sendJson(res, 401, { error: 'Invalid token' });
|
|
177
|
+
}
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
// Static pages — gated by session cookie ONLY when requireAuth is on
|
|
181
|
+
// (i.e. the server is bound publicly). Localhost binds skip the gate
|
|
182
|
+
// entirely so small users don't need to know what a token is.
|
|
183
|
+
if (url.pathname === '/' || url.pathname === '/index.html' ||
|
|
184
|
+
url.pathname === '/settings' || url.pathname === '/settings.html' ||
|
|
185
|
+
url.pathname === '/tasks' || url.pathname === '/tasks.html' ||
|
|
186
|
+
url.pathname === '/reminders' || url.pathname === '/reminders.html' ||
|
|
187
|
+
url.pathname === '/memos' || url.pathname === '/memos.html') {
|
|
188
|
+
if (requireAuth && !hasValidSession(req)) {
|
|
189
|
+
res.writeHead(302, { Location: `/login?next=${encodeURIComponent(url.pathname)}` });
|
|
190
|
+
res.end();
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
const fileMap = {
|
|
194
|
+
'/': 'index.html', '/index.html': 'index.html',
|
|
195
|
+
'/settings': 'settings.html', '/settings.html': 'settings.html',
|
|
196
|
+
'/tasks': 'tasks.html', '/tasks.html': 'tasks.html',
|
|
197
|
+
'/reminders': 'reminders.html', '/reminders.html': 'reminders.html',
|
|
198
|
+
'/memos': 'memos.html', '/memos.html': 'memos.html',
|
|
199
|
+
};
|
|
200
|
+
return servePageHtml(res, join(PUBLIC_DIR, fileMap[url.pathname]));
|
|
201
|
+
}
|
|
202
|
+
// M4: /api/health is intentionally public (k8s liveness probe friendly)
|
|
203
|
+
// — declare it BEFORE the /api/* token gate so callers don't need to
|
|
204
|
+
// know the web token. Returns only operational status, not config.
|
|
205
|
+
if (url.pathname === '/api/health' && req.method === 'GET') {
|
|
206
|
+
return handleHealth(req, res);
|
|
207
|
+
}
|
|
208
|
+
// Shared web-console utilities (theme manager + i18n + error boundary
|
|
209
|
+
// + auth-aware fetch). Loaded synchronously by every static page in
|
|
210
|
+
// <head> so the theme can apply before first paint. No secrets — safe
|
|
211
|
+
// to serve un-authenticated.
|
|
212
|
+
if (url.pathname === '/_app.js' && req.method === 'GET') {
|
|
213
|
+
return serveStatic(res, join(PUBLIC_DIR, '_app.js'), 'application/javascript; charset=utf-8');
|
|
214
|
+
}
|
|
215
|
+
// ─── Location capture endpoints (public; auth via single-use token) ───
|
|
216
|
+
// Public on purpose — the user reaches these via a token-bearing link
|
|
217
|
+
// sent into their IM thread. Token (32 hex chars, 10-min TTL, single
|
|
218
|
+
// use) is the credential. Designed to sit behind a CDN that proxies
|
|
219
|
+
// agent.iclaw.host → this port. **CDN MUST whitelist only /loc and
|
|
220
|
+
// /api/loc** — other paths on :3000 (/tasks, /api/*, /reminders) are
|
|
221
|
+
// operator-only and would leak if the CDN catch-all forwards.
|
|
222
|
+
if (url.pathname === '/loc' && req.method === 'GET') {
|
|
223
|
+
return serveStatic(res, join(PUBLIC_DIR, 'loc.html'), 'text/html; charset=utf-8');
|
|
224
|
+
}
|
|
225
|
+
// Short alias: /l/<token> serves the same H5 page. The page auto-detects
|
|
226
|
+
// either ?t= query or /l/<token> path. Lets us issue ~38-char URLs
|
|
227
|
+
// (vs 70+ for /loc?t=<32-hex>) which keeps WeChat chat cleaner.
|
|
228
|
+
if (url.pathname.startsWith('/l/') && req.method === 'GET') {
|
|
229
|
+
return serveStatic(res, join(PUBLIC_DIR, 'loc.html'), 'text/html; charset=utf-8');
|
|
230
|
+
}
|
|
231
|
+
// GET /api/loc/info?t=… — read-only peek for the H5 page to show what
|
|
232
|
+
// is being recorded. Does NOT consume the token — that happens on POST.
|
|
233
|
+
// Returns 410 when the token is expired/unknown so the page can render
|
|
234
|
+
// a hard-stop "link expired" state instead of pretending and failing
|
|
235
|
+
// on the POST.
|
|
236
|
+
if (url.pathname === '/api/loc/info' && req.method === 'GET') {
|
|
237
|
+
const tk = url.searchParams.get('t') || '';
|
|
238
|
+
if (!tk) {
|
|
239
|
+
sendJson(res, 400, { error: 'missing t' });
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
const peek = peekToken(tk);
|
|
243
|
+
if (!peek) {
|
|
244
|
+
sendJson(res, 410, { error: 'token expired or unknown' });
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
// For augment-mode (memo_id present), tell the H5 page so it can show
|
|
248
|
+
// "正在补充'<existing what>'的位置" instead of "正在记录: <what>".
|
|
249
|
+
let augmentLabel;
|
|
250
|
+
if (peek.memoId != null) {
|
|
251
|
+
const existing = getMemo(peek.memoId, { userId: peek.ctx.userId });
|
|
252
|
+
if (existing)
|
|
253
|
+
augmentLabel = existing.what;
|
|
254
|
+
}
|
|
255
|
+
sendJson(res, 200, {
|
|
256
|
+
ok: true,
|
|
257
|
+
what: peek.what,
|
|
258
|
+
memoId: peek.memoId ?? undefined,
|
|
259
|
+
augmentLabel,
|
|
260
|
+
expiresAt: new Date(peek.expiresAt).toISOString(),
|
|
261
|
+
});
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
if (url.pathname === '/api/loc' && req.method === 'POST') {
|
|
265
|
+
const body = await readBody(req, res);
|
|
266
|
+
let parsed;
|
|
267
|
+
try {
|
|
268
|
+
parsed = JSON.parse(body);
|
|
269
|
+
}
|
|
270
|
+
catch {
|
|
271
|
+
sendJson(res, 400, { error: 'invalid JSON' });
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
const tk = typeof parsed.t === 'string' ? parsed.t : '';
|
|
275
|
+
const rawLat = typeof parsed.lat === 'number' ? parsed.lat : NaN;
|
|
276
|
+
const rawLng = typeof parsed.lng === 'number' ? parsed.lng : NaN;
|
|
277
|
+
if (!tk || !Number.isFinite(rawLat) || !Number.isFinite(rawLng)) {
|
|
278
|
+
sendJson(res, 400, { error: '请求体缺少 t / lat / lng' });
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
if (rawLat < -90 || rawLat > 90 || rawLng < -180 || rawLng > 180) {
|
|
282
|
+
sendJson(res, 400, { error: '坐标超出有效范围' });
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
// navigator.geolocation returns whatever the host engine hands the
|
|
286
|
+
// page. The coord system varies by browser+OS:
|
|
287
|
+
// - iOS Safari / WebKit in mainland China: GCJ-02 (Apple's PRC
|
|
288
|
+
// compliance offset baked into Core Location).
|
|
289
|
+
// - WeChat X5 webview / Android Chrome without GMS: raw WGS-84.
|
|
290
|
+
// - PC browsers anywhere: WGS-84.
|
|
291
|
+
// Default to WGS-84 pass-through (matches the most common Chinese
|
|
292
|
+
// mobile path — WeChat — which was silently miscalibrated before
|
|
293
|
+
// the 2026-05-12 fix). iOS users in China set IMHUB_H5_COORDS_GCJ02=1
|
|
294
|
+
// to restore the GCJ→WGS path. See core/coord-systems.ts.
|
|
295
|
+
const norm = normalizeIncomingCoords('h5-browser-geolocation', rawLat, rawLng);
|
|
296
|
+
const lat = norm.lat;
|
|
297
|
+
const lng = norm.lng;
|
|
298
|
+
const cr = consumeToken(tk);
|
|
299
|
+
if (!cr.ok) {
|
|
300
|
+
const msg = cr.reason === 'expired' ? '链接已过期,请重新让 bot 发起记录' : '链接无效或已被使用';
|
|
301
|
+
sendJson(res, 410, { error: msg });
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
const ctx = cr.ctx;
|
|
305
|
+
const what = cr.what || '';
|
|
306
|
+
const accuracy = typeof parsed.accuracy === 'number' ? parsed.accuracy : 0;
|
|
307
|
+
// Two paths:
|
|
308
|
+
// - cr.memoId set → UPDATE existing memo's where_*
|
|
309
|
+
// - cr.memoId null → CREATE new memo with what + where_*
|
|
310
|
+
let memoId = null;
|
|
311
|
+
let displayWhat = what;
|
|
312
|
+
try {
|
|
313
|
+
if (cr.memoId != null) {
|
|
314
|
+
const existing = getMemo(cr.memoId, { userId: ctx.userId });
|
|
315
|
+
if (existing) {
|
|
316
|
+
updateMemo(cr.memoId, {
|
|
317
|
+
whereLat: lat,
|
|
318
|
+
whereLng: lng,
|
|
319
|
+
}, { userId: ctx.userId });
|
|
320
|
+
memoId = cr.memoId;
|
|
321
|
+
displayWhat = existing.what; // memo's own what wins for reply
|
|
322
|
+
}
|
|
323
|
+
else {
|
|
324
|
+
// Memo got deleted between issue + consume? Fall back to creating
|
|
325
|
+
// a new one with whatever `what` the token had — better than
|
|
326
|
+
// dropping the user's grant.
|
|
327
|
+
memoId = createMemo({
|
|
328
|
+
platform: ctx.platform, channelId: ctx.channelId,
|
|
329
|
+
threadId: ctx.threadId, userId: ctx.userId,
|
|
330
|
+
what: what || '位置',
|
|
331
|
+
whereLat: lat, whereLng: lng,
|
|
332
|
+
source: 'browser',
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
else {
|
|
337
|
+
memoId = createMemo({
|
|
338
|
+
platform: ctx.platform, channelId: ctx.channelId,
|
|
339
|
+
threadId: ctx.threadId, userId: ctx.userId,
|
|
340
|
+
what: what || '位置',
|
|
341
|
+
whereLat: lat, whereLng: lng,
|
|
342
|
+
source: 'browser',
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
catch (err) {
|
|
347
|
+
webLog.warn({ event: 'web.loc.memo_failed', err: String(err) }, 'failed to write/update memo');
|
|
348
|
+
}
|
|
349
|
+
const messengerName = ctx.platform === 'wechat' ? 'wechat-ilink' : ctx.platform;
|
|
350
|
+
const messenger = registry.getMessenger(messengerName);
|
|
351
|
+
const idTag = memoId ? `#${memoId}` : '';
|
|
352
|
+
const accTxt = accuracy > 0 ? ` (±${Math.round(accuracy)}m)` : '';
|
|
353
|
+
const headLabel = displayWhat ? `'${displayWhat}'` : '';
|
|
354
|
+
const urls = mapUrls(lat, lng, displayWhat || '当前位置', '');
|
|
355
|
+
const verb = cr.memoId != null ? '已补全' : '已记下';
|
|
356
|
+
const reply = [
|
|
357
|
+
`✅ ${verb}${headLabel} ${idTag}`.trim(),
|
|
358
|
+
` 坐标: ${lat.toFixed(6)}, ${lng.toFixed(6)}${accTxt}`,
|
|
359
|
+
'',
|
|
360
|
+
` 🗺 [百度地图](${urls.baidu}) · [高德地图](${urls.amap}) · [Google](${urls.google})`,
|
|
361
|
+
].filter(Boolean).join('\n');
|
|
362
|
+
if (messenger) {
|
|
363
|
+
messenger.sendMessage(ctx.threadId, reply).catch((err) => {
|
|
364
|
+
webLog.warn({ event: 'web.loc.dispatch_failed', threadId: ctx.threadId, err: String(err) });
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
else {
|
|
368
|
+
webLog.warn({ event: 'web.loc.no_messenger', platform: ctx.platform });
|
|
369
|
+
}
|
|
370
|
+
sendJson(res, 200, { ok: true, id: memoId, lat, lng });
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
// REST API — gated by header token OR session cookie ONLY when
|
|
374
|
+
// requireAuth is on. Localhost binds let the call through; same
|
|
375
|
+
// trust model as the static pages above.
|
|
376
|
+
if (requireAuth && url.pathname.startsWith('/api/')) {
|
|
377
|
+
const token = req.headers['x-im-hub-token'] || '';
|
|
378
|
+
if (!safeEqual(token, webToken) && !hasValidSession(req)) {
|
|
379
|
+
res.writeHead(401);
|
|
380
|
+
res.end(JSON.stringify({ error: 'Unauthorized' }));
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
// REST API
|
|
385
|
+
if (url.pathname === '/api/config' && req.method === 'GET') {
|
|
386
|
+
return handleGetConfig(req, res);
|
|
387
|
+
}
|
|
388
|
+
if (url.pathname === '/api/config' && req.method === 'PUT') {
|
|
389
|
+
return handlePutConfig(req, res);
|
|
390
|
+
}
|
|
391
|
+
if (url.pathname === '/api/agents/status' && req.method === 'GET') {
|
|
392
|
+
return handleAgentsStatus(req, res);
|
|
393
|
+
}
|
|
394
|
+
if (url.pathname === '/api/agents/acp/test' && req.method === 'POST') {
|
|
395
|
+
return handleAcpTest(req, res);
|
|
396
|
+
}
|
|
397
|
+
if (url.pathname === '/api/agents/acp/discover' && req.method === 'POST') {
|
|
398
|
+
return handleAcpDiscover(req, res);
|
|
399
|
+
}
|
|
400
|
+
// Jobs
|
|
401
|
+
if (url.pathname === '/api/jobs' && req.method === 'GET') {
|
|
402
|
+
return handleListJobs(req, res, url);
|
|
403
|
+
}
|
|
404
|
+
const jobIdMatch = url.pathname.match(/^\/api\/jobs\/(\d+)$/);
|
|
405
|
+
if (jobIdMatch && req.method === 'GET') {
|
|
406
|
+
return handleGetJob(req, res, parseInt(jobIdMatch[1], 10));
|
|
407
|
+
}
|
|
408
|
+
const jobCancelMatch = url.pathname.match(/^\/api\/jobs\/(\d+)\/cancel$/);
|
|
409
|
+
if (jobCancelMatch && req.method === 'POST') {
|
|
410
|
+
return handleCancelJob(req, res, parseInt(jobCancelMatch[1], 10));
|
|
411
|
+
}
|
|
412
|
+
const jobRunMatch = url.pathname.match(/^\/api\/jobs\/(\d+)\/run$/);
|
|
413
|
+
if (jobRunMatch && req.method === 'POST') {
|
|
414
|
+
return handleRunJob(req, res, parseInt(jobRunMatch[1], 10));
|
|
415
|
+
}
|
|
416
|
+
if (url.pathname === '/api/jobs' && req.method === 'POST') {
|
|
417
|
+
return handleCreateJob(req, res);
|
|
418
|
+
}
|
|
419
|
+
// bgjobs (read-only view of ~/.claude/bgjobs, ~/.config/opencode/bgjobs, ~/.codex/bgjobs)
|
|
420
|
+
if (url.pathname === '/api/bgjobs' && req.method === 'GET') {
|
|
421
|
+
return handleListBgjobs(req, res, url);
|
|
422
|
+
}
|
|
423
|
+
const bgjobIdMatch = url.pathname.match(/^\/api\/bgjobs\/([\w.-]+)$/);
|
|
424
|
+
if (bgjobIdMatch && req.method === 'GET') {
|
|
425
|
+
return handleGetBgjob(req, res, bgjobIdMatch[1], url);
|
|
426
|
+
}
|
|
427
|
+
// Subtasks (flattened view of session.subtasks across all conversations)
|
|
428
|
+
if (url.pathname === '/api/subtasks' && req.method === 'GET') {
|
|
429
|
+
return handleListSubtasks(req, res, url);
|
|
430
|
+
}
|
|
431
|
+
// Schedules
|
|
432
|
+
if (url.pathname === '/api/schedules' && req.method === 'GET') {
|
|
433
|
+
return handleListSchedules(req, res, url);
|
|
434
|
+
}
|
|
435
|
+
// Reminders — list / cancel / snooze. Web-only path (the IM-side path
|
|
436
|
+
// is /remind slash command). Auth via the same web session cookie as
|
|
437
|
+
// every other /api/* endpoint above; no per-user filtering yet, so
|
|
438
|
+
// single-operator deployments only.
|
|
439
|
+
if (url.pathname === '/api/reminders' && req.method === 'GET') {
|
|
440
|
+
return handleListReminders(req, res, url);
|
|
441
|
+
}
|
|
442
|
+
const reminderCancelMatch = url.pathname.match(/^\/api\/reminders\/(\d+)\/cancel$/);
|
|
443
|
+
if (reminderCancelMatch && req.method === 'POST') {
|
|
444
|
+
return handleCancelReminderApi(req, res, Number.parseInt(reminderCancelMatch[1], 10));
|
|
445
|
+
}
|
|
446
|
+
const reminderSnoozeMatch = url.pathname.match(/^\/api\/reminders\/(\d+)\/snooze$/);
|
|
447
|
+
if (reminderSnoozeMatch && req.method === 'POST') {
|
|
448
|
+
return handleSnoozeReminderApi(req, res, Number.parseInt(reminderSnoozeMatch[1], 10));
|
|
449
|
+
}
|
|
450
|
+
// /api/memos — search / list / delete. List uses the same searchMemos
|
|
451
|
+
// function the MCP tool exposes; query/who/what/has_location/limit
|
|
452
|
+
// come through as URL params.
|
|
453
|
+
if (url.pathname === '/api/memos' && req.method === 'GET') {
|
|
454
|
+
return handleListMemos(req, res, url);
|
|
455
|
+
}
|
|
456
|
+
const memoIdMatch = url.pathname.match(/^\/api\/memos\/(\d+)$/);
|
|
457
|
+
if (memoIdMatch && req.method === 'DELETE') {
|
|
458
|
+
return handleDeleteMemo(req, res, Number.parseInt(memoIdMatch[1], 10));
|
|
459
|
+
}
|
|
460
|
+
// /api/env — read/write SMTP + Baidu AK + IMHUB_WEB_BIND. Values
|
|
461
|
+
// sensitive enough that GET returns them masked (only the last 4 chars
|
|
462
|
+
// visible) unless an explicit ?reveal=1 is passed (still auth-gated).
|
|
463
|
+
if (url.pathname === '/api/env' && req.method === 'GET') {
|
|
464
|
+
return handleGetEnv(req, res, url);
|
|
465
|
+
}
|
|
466
|
+
if (url.pathname === '/api/env' && req.method === 'PUT') {
|
|
467
|
+
return handlePutEnv(req, res);
|
|
468
|
+
}
|
|
469
|
+
if (url.pathname === '/api/workspaces' && req.method === 'GET') {
|
|
470
|
+
return handleListWorkspaces(req, res, url);
|
|
471
|
+
}
|
|
472
|
+
if (url.pathname === '/api/workspaces' && req.method === 'POST') {
|
|
473
|
+
return handleCreateOrUpdateWorkspace(req, res);
|
|
474
|
+
}
|
|
475
|
+
const workspaceIdMatch = url.pathname.match(/^\/api\/workspaces\/([^/]+)$/);
|
|
476
|
+
if (workspaceIdMatch && req.method === 'PATCH') {
|
|
477
|
+
return handleCreateOrUpdateWorkspace(req, res, workspaceIdMatch[1]);
|
|
478
|
+
}
|
|
479
|
+
if (workspaceIdMatch && req.method === 'DELETE') {
|
|
480
|
+
return handleDeleteWorkspace(req, res, workspaceIdMatch[1]);
|
|
481
|
+
}
|
|
482
|
+
if (url.pathname === '/api/metrics' && req.method === 'GET') {
|
|
483
|
+
return handleMetrics(req, res, url);
|
|
484
|
+
}
|
|
485
|
+
if (url.pathname === '/api/audit' && req.method === 'GET') {
|
|
486
|
+
return handleAudit(req, res, url);
|
|
487
|
+
}
|
|
488
|
+
// PR-B: agent health snapshot (circuit breaker + rate-limiter remaining
|
|
489
|
+
// + latency p50/95/99) consumed by the Health tab in /tasks.
|
|
490
|
+
if (url.pathname === '/api/agent-health' && req.method === 'GET') {
|
|
491
|
+
return handleAgentHealth(req, res);
|
|
492
|
+
}
|
|
493
|
+
// PR-B: HITL approvals — global pending list + per-reqId resolve.
|
|
494
|
+
if (url.pathname === '/api/approvals' && req.method === 'GET') {
|
|
495
|
+
return handleListApprovals(req, res);
|
|
496
|
+
}
|
|
497
|
+
const approvalResolveMatch = url.pathname.match(/^\/api\/approvals\/([^/]+)\/resolve$/);
|
|
498
|
+
if (approvalResolveMatch && req.method === 'POST') {
|
|
499
|
+
return handleResolveApproval(req, res, approvalResolveMatch[1]);
|
|
500
|
+
}
|
|
501
|
+
// PR-D: Agent workspace file browser. Read-only inspection of
|
|
502
|
+
// ~/.im-hub-workspaces/<agent>/ contents — list dirs, peek small
|
|
503
|
+
// text files. PUT path supports inline editing (annotate CLAUDE.md,
|
|
504
|
+
// AGENTS.md, etc.) — same traversal/size guards as GET.
|
|
505
|
+
if (url.pathname === '/api/workspace-files' && req.method === 'GET') {
|
|
506
|
+
return handleWorkspaceFiles(req, res, url);
|
|
507
|
+
}
|
|
508
|
+
if (url.pathname === '/api/workspace-files' && req.method === 'PUT') {
|
|
509
|
+
return handleWorkspaceFileWrite(req, res, url);
|
|
510
|
+
}
|
|
511
|
+
// PR-D: Job batch operations. Same semantics as /api/jobs/:id/cancel
|
|
512
|
+
// and /run but accepts an array of ids in one request — saves N
|
|
513
|
+
// round-trips when the user multi-selects a long list.
|
|
514
|
+
if (url.pathname === '/api/jobs/batch-cancel' && req.method === 'POST') {
|
|
515
|
+
return handleBatchJob(req, res, 'cancel');
|
|
516
|
+
}
|
|
517
|
+
if (url.pathname === '/api/jobs/batch-run' && req.method === 'POST') {
|
|
518
|
+
return handleBatchJob(req, res, 'run', options.defaultAgent);
|
|
519
|
+
}
|
|
520
|
+
// PR-C: SSE event stream — audit / approval / job / metrics events
|
|
521
|
+
// pushed real-time so the dashboard stops polling. EventSource has no
|
|
522
|
+
// header API, so the token rides in `?token=<webToken>` (same shape
|
|
523
|
+
// the WS upgrade uses). Auth is validated inside the handler since
|
|
524
|
+
// /events is outside the /api/* token gate above.
|
|
525
|
+
if (url.pathname === '/events' && req.method === 'GET') {
|
|
526
|
+
const evToken = url.searchParams.get('token') || '';
|
|
527
|
+
if (requireAuth && !safeEqual(evToken, webToken) && !hasValidSession(req)) {
|
|
528
|
+
res.writeHead(401, { 'Content-Type': 'text/plain' });
|
|
529
|
+
res.end('Unauthorized');
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
return handleEventsSSE(req, res);
|
|
533
|
+
}
|
|
534
|
+
// /api/health handled above the token gate (M4) — keep this comment so
|
|
535
|
+
// future contributors don't re-add the route inside the auth block.
|
|
536
|
+
if (url.pathname === '/api/notify' && req.method === 'POST') {
|
|
537
|
+
return handleNotify(req, res);
|
|
538
|
+
}
|
|
539
|
+
if (url.pathname === '/api/invoke' && req.method === 'POST') {
|
|
540
|
+
return handleInvoke(req, res, options.defaultAgent);
|
|
541
|
+
}
|
|
542
|
+
res.writeHead(404);
|
|
543
|
+
res.end('Not found');
|
|
544
|
+
});
|
|
545
|
+
// WebSocket server
|
|
546
|
+
const wss = new WebSocketServer({ server: httpServer });
|
|
547
|
+
// M3: cap concurrent WS clients so a leaked / shared web token can't OOM
|
|
548
|
+
// the host by opening unbounded connections. Default 100 is generous for
|
|
549
|
+
// a single-user / small-team setup; production multi-tenant should set
|
|
550
|
+
// IMHUB_MAX_WS_CLIENTS to a higher value.
|
|
551
|
+
const maxWsClients = (() => {
|
|
552
|
+
const raw = process.env.IMHUB_MAX_WS_CLIENTS;
|
|
553
|
+
if (raw) {
|
|
554
|
+
const n = parseInt(raw, 10);
|
|
555
|
+
if (Number.isFinite(n) && n > 0)
|
|
556
|
+
return n;
|
|
557
|
+
}
|
|
558
|
+
return 100;
|
|
559
|
+
})();
|
|
560
|
+
wss.on('connection', (ws, req) => {
|
|
561
|
+
if (clients.size >= maxWsClients) {
|
|
562
|
+
// 1013 = "Try Again Later" per RFC 6455. Slightly nicer than a flat
|
|
563
|
+
// close — clients with reconnect logic will back off.
|
|
564
|
+
webLog.warn({
|
|
565
|
+
event: 'ws.cap_reached',
|
|
566
|
+
active: clients.size,
|
|
567
|
+
cap: maxWsClients,
|
|
568
|
+
}, 'WS connection refused (cap)');
|
|
569
|
+
ws.close(1013, 'Server too busy');
|
|
570
|
+
return;
|
|
571
|
+
}
|
|
572
|
+
// Verify token from URL query or session cookie before accepting connection
|
|
573
|
+
// — only when requireAuth (public bind). Localhost connections are trusted.
|
|
574
|
+
const wsUrl = new URL(req.url || '/', `http://localhost:${port}`);
|
|
575
|
+
const wsToken = wsUrl.searchParams.get('token') || '';
|
|
576
|
+
if (requireAuth && !safeEqual(wsToken, webToken) && !hasValidSession(req)) {
|
|
577
|
+
ws.close(1008, 'Unauthorized');
|
|
578
|
+
return;
|
|
579
|
+
}
|
|
580
|
+
const clientId = `web-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
581
|
+
const client = { ws, id: clientId, agent: options.defaultAgent };
|
|
582
|
+
clients.set(clientId, client);
|
|
583
|
+
webLog.info({ clientId }, 'Client connected');
|
|
584
|
+
// Send available agents list
|
|
585
|
+
sendToClient(ws, {
|
|
586
|
+
type: 'init',
|
|
587
|
+
agents: registry.listAgents(),
|
|
588
|
+
defaultAgent: options.defaultAgent,
|
|
589
|
+
clientId,
|
|
590
|
+
});
|
|
591
|
+
// Load existing session history if available
|
|
592
|
+
sendSessionHistory(ws, clientId, options.defaultAgent);
|
|
593
|
+
const enqueueInbound = createSerialQueue();
|
|
594
|
+
ws.on('message', (data) => {
|
|
595
|
+
enqueueInbound(async () => {
|
|
596
|
+
try {
|
|
597
|
+
const msg = JSON.parse(data.toString());
|
|
598
|
+
// Approval-button click intercept. The user tapped an in-page
|
|
599
|
+
// approval card button; route it through the web messenger's
|
|
600
|
+
// button handler (registered by approval-router on install) the
|
|
601
|
+
// same way a Telegram inline-keyboard tap is routed. We don't
|
|
602
|
+
// call handleClientMessage for these — they're not chat input.
|
|
603
|
+
if (msg && msg.type === 'approval-action') {
|
|
604
|
+
const actionData = String(msg.data || '');
|
|
605
|
+
const messageId = String(msg.messageId || '');
|
|
606
|
+
webLog.info({
|
|
607
|
+
event: 'approval.web.click_received',
|
|
608
|
+
clientId, data: actionData, messageId,
|
|
609
|
+
handlerBound: !!webButtonHandler,
|
|
610
|
+
});
|
|
611
|
+
if (!actionData || !messageId) {
|
|
612
|
+
sendToClient(ws, { type: 'error', message: 'approval-action missing data/messageId' });
|
|
613
|
+
return;
|
|
614
|
+
}
|
|
615
|
+
if (!webButtonHandler) {
|
|
616
|
+
// Without the handler, a click would silently no-op forever — the
|
|
617
|
+
// failure mode that PR-A's fix patches. Tell the user and the
|
|
618
|
+
// operator (via log) instead of dropping the click.
|
|
619
|
+
const why = 'approval handler not bound (router not installed?). Restart im-hub to rebind.';
|
|
620
|
+
webLog.warn({ event: 'approval.web.no_handler', clientId, data: actionData, messageId }, why);
|
|
621
|
+
sendToClient(ws, { type: 'error', message: why });
|
|
622
|
+
return;
|
|
623
|
+
}
|
|
624
|
+
try {
|
|
625
|
+
// Most messengers' ButtonCallback#ack updates a platform-native
|
|
626
|
+
// toast / loading spinner. The web client doesn't have one, so
|
|
627
|
+
// ack is a no-op resolving to the in-page status the page itself
|
|
628
|
+
// chose to render after click.
|
|
629
|
+
await webButtonHandler({
|
|
630
|
+
data: actionData, threadId: clientId, userId: `web:${clientId}`,
|
|
631
|
+
userDisplay: 'Web', messageId, ack: async () => { },
|
|
632
|
+
});
|
|
633
|
+
webLog.info({ event: 'approval.web.click_resolved', clientId, data: actionData });
|
|
634
|
+
}
|
|
635
|
+
catch (err) {
|
|
636
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
637
|
+
webLog.error({ event: 'approval.web.click_failed', clientId, data: actionData, err: errMsg });
|
|
638
|
+
sendToClient(ws, { type: 'error', message: `Approval click failed: ${errMsg}` });
|
|
639
|
+
}
|
|
640
|
+
return;
|
|
641
|
+
}
|
|
642
|
+
await handleClientMessage(client, msg, options.defaultAgent);
|
|
643
|
+
}
|
|
644
|
+
catch (err) {
|
|
645
|
+
webLog.error({ clientId, err: err instanceof Error ? err.message : String(err) }, 'Error parsing client message');
|
|
646
|
+
sendToClient(ws, { type: 'error', message: 'Invalid message format' });
|
|
647
|
+
}
|
|
648
|
+
});
|
|
649
|
+
});
|
|
650
|
+
ws.on('close', () => {
|
|
651
|
+
webLog.info({ clientId }, 'Client disconnected');
|
|
652
|
+
clients.delete(clientId);
|
|
653
|
+
});
|
|
654
|
+
ws.on('error', (err) => {
|
|
655
|
+
webLog.error({ clientId, err: err instanceof Error ? err.message : String(err) }, 'Client WebSocket error');
|
|
656
|
+
clients.delete(clientId);
|
|
657
|
+
});
|
|
658
|
+
});
|
|
659
|
+
// Default to loopback; operators can opt into LAN/public exposure with
|
|
660
|
+
// IMHUB_WEB_BIND=0.0.0.0 behind their firewall/reverse proxy.
|
|
661
|
+
await new Promise((resolve, reject) => {
|
|
662
|
+
httpServer.on('error', reject);
|
|
663
|
+
httpServer.listen(port, bindHost, () => resolve());
|
|
664
|
+
});
|
|
665
|
+
webLog.info({ port, bindHost }, `Chat UI available at http://${bindHost === '0.0.0.0' ? 'localhost' : bindHost}:${port}`);
|
|
666
|
+
// ============================================================
|
|
667
|
+
// Web messenger registration (HITL approval bridge)
|
|
668
|
+
// ============================================================
|
|
669
|
+
// Register a synthetic messenger named 'web' so approval-router (which
|
|
670
|
+
// resolves the target messenger by platform name) can deliver approval
|
|
671
|
+
// prompts AND outcome edits to the matching browser tab over the
|
|
672
|
+
// existing WebSocket. Chat ingress is unaffected — incoming chat
|
|
673
|
+
// messages still flow through handleClientMessage / routeMessage as
|
|
674
|
+
// before; this messenger only forwards what the bus wants to push.
|
|
675
|
+
//
|
|
676
|
+
// threadId is the WS clientId (RouteContext.threadId === client.id for
|
|
677
|
+
// the web platform). We resolve the matching client at delivery time;
|
|
678
|
+
// if the client has disconnected the send is a no-op and the bus's own
|
|
679
|
+
// auto-deny / sidecar-disconnect path takes over.
|
|
680
|
+
let cardSeq = 0;
|
|
681
|
+
const webMessenger = {
|
|
682
|
+
name: 'web',
|
|
683
|
+
start: async () => { },
|
|
684
|
+
stop: async () => { },
|
|
685
|
+
onMessage: () => { },
|
|
686
|
+
async sendMessage(threadId, text) {
|
|
687
|
+
const c = clients.get(threadId);
|
|
688
|
+
if (!c || c.ws.readyState !== c.ws.OPEN)
|
|
689
|
+
return;
|
|
690
|
+
sendToClient(c.ws, { type: 'approval-text', text });
|
|
691
|
+
},
|
|
692
|
+
async sendApprovalCard(threadId, prompt) {
|
|
693
|
+
const c = clients.get(threadId);
|
|
694
|
+
const messageId = `web-card-${++cardSeq}-${Date.now().toString(36)}`;
|
|
695
|
+
if (c && c.ws.readyState === c.ws.OPEN) {
|
|
696
|
+
sendToClient(c.ws, { type: 'approval-card', messageId, prompt });
|
|
697
|
+
}
|
|
698
|
+
return { messageId };
|
|
699
|
+
},
|
|
700
|
+
async editApprovalCard(threadId, messageId, outcome) {
|
|
701
|
+
const c = clients.get(threadId);
|
|
702
|
+
if (!c || c.ws.readyState !== c.ws.OPEN)
|
|
703
|
+
return;
|
|
704
|
+
sendToClient(c.ws, { type: 'approval-card-edit', messageId, outcome });
|
|
705
|
+
},
|
|
706
|
+
onButtonCallback(handler) {
|
|
707
|
+
webButtonHandler = handler;
|
|
708
|
+
webLog.info({ event: 'approval.web.handler_bound' }, 'web messenger button-callback handler attached');
|
|
709
|
+
},
|
|
710
|
+
};
|
|
711
|
+
registry.registerMessenger(webMessenger);
|
|
712
|
+
// approval-router's install() loop bound buttonCallback only for messengers
|
|
713
|
+
// registered BEFORE install. Our web messenger was just registered (after
|
|
714
|
+
// install), so we have to wire it ourselves — otherwise in-page approval
|
|
715
|
+
// card clicks fire WS 'approval-action' messages with no handler on the
|
|
716
|
+
// server side and silently do nothing. bindButtonHandlerForPlatform is a
|
|
717
|
+
// no-op if approval-router hasn't been install()'d yet (e.g. degraded
|
|
718
|
+
// mode where the bus failed to start).
|
|
719
|
+
try {
|
|
720
|
+
const { bindButtonHandlerForPlatform } = await import('../core/approval-router.js');
|
|
721
|
+
bindButtonHandlerForPlatform('web');
|
|
722
|
+
if (!webButtonHandler) {
|
|
723
|
+
// bindButtonHandlerForPlatform is a silent no-op when `installed` is
|
|
724
|
+
// null on the router (bus failed to start, or cli skipped install).
|
|
725
|
+
// Log so an operator who's confused why approval clicks don't work
|
|
726
|
+
// sees a clear breadcrumb at startup.
|
|
727
|
+
webLog.warn({ event: 'approval.web.bind_skipped' }, 'approval-router not installed — web approval clicks will fail until restart');
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
catch (err) {
|
|
731
|
+
webLog.warn({ event: 'approval.web.bind_error', err: err instanceof Error ? err.message : String(err) }, 'approval-router button-handler binding threw');
|
|
732
|
+
}
|
|
733
|
+
// PR-C: periodic metrics tick. Publishes a per-agent snapshot every 5s
|
|
734
|
+
// so the dashboard's Health sparkline can advance even when there are
|
|
735
|
+
// no audit events firing. The bus's recent buffer keeps the latest
|
|
736
|
+
// tick available to fresh SSE connections, so a tab opened mid-cycle
|
|
737
|
+
// sees current data without waiting up to 5 s.
|
|
738
|
+
const metricsTick = setInterval(async () => {
|
|
739
|
+
try {
|
|
740
|
+
const { eventBus } = await import('../core/event-bus.js');
|
|
741
|
+
const { snapshot } = await import('../core/metrics.js');
|
|
742
|
+
const snap = snapshot();
|
|
743
|
+
eventBus.publish({
|
|
744
|
+
type: 'metrics',
|
|
745
|
+
ts: new Date().toISOString(),
|
|
746
|
+
agents: snap.agents.map((a) => ({
|
|
747
|
+
agent: a.agent, total: a.total, success: a.success, failure: a.failure,
|
|
748
|
+
p50Ms: a.p50Ms, p95Ms: a.p95Ms, p99Ms: a.p99Ms,
|
|
749
|
+
})),
|
|
750
|
+
});
|
|
751
|
+
}
|
|
752
|
+
catch { /* swallow — metrics tick must never break the web server */ }
|
|
753
|
+
}, 5_000);
|
|
754
|
+
if (typeof metricsTick === 'object' && metricsTick && 'unref' in metricsTick) {
|
|
755
|
+
metricsTick.unref();
|
|
756
|
+
}
|
|
757
|
+
return {
|
|
758
|
+
port,
|
|
759
|
+
close: () => {
|
|
760
|
+
// Close all WebSocket connections
|
|
761
|
+
for (const [id, client] of clients) {
|
|
762
|
+
client.ws.close();
|
|
763
|
+
clients.delete(id);
|
|
764
|
+
}
|
|
765
|
+
wss.close();
|
|
766
|
+
httpServer.close();
|
|
767
|
+
},
|
|
768
|
+
};
|
|
769
|
+
}
|
|
770
|
+
// ============================================
|
|
771
|
+
// REST API handlers
|
|
772
|
+
// ============================================
|
|
773
|
+
async function handleGetConfig(_req, res) {
|
|
774
|
+
try {
|
|
775
|
+
const config = await loadConfig();
|
|
776
|
+
const agentStatus = await getAgentStatuses();
|
|
777
|
+
sendJson(res, 200, {
|
|
778
|
+
messengers: config.messengers,
|
|
779
|
+
agents: config.agents,
|
|
780
|
+
defaultAgent: config.defaultAgent,
|
|
781
|
+
telegram: config.telegram
|
|
782
|
+
? { botToken: mask(config.telegram.botToken), channelId: config.telegram.channelId }
|
|
783
|
+
: undefined,
|
|
784
|
+
feishu: config.feishu
|
|
785
|
+
? { appId: config.feishu.appId, appSecret: mask(config.feishu.appSecret) }
|
|
786
|
+
: undefined,
|
|
787
|
+
acpAgents: config.acpAgents?.map(a => ({
|
|
788
|
+
...a,
|
|
789
|
+
auth: a.auth
|
|
790
|
+
? { ...a.auth, token: a.auth.token ? mask(a.auth.token) : undefined }
|
|
791
|
+
: undefined,
|
|
792
|
+
})),
|
|
793
|
+
webPort: config.webPort,
|
|
794
|
+
agentStatus,
|
|
795
|
+
});
|
|
796
|
+
}
|
|
797
|
+
catch (_err) {
|
|
798
|
+
sendJson(res, 500, { error: 'Failed to load config' });
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
async function handlePutConfig(req, res) {
|
|
802
|
+
try {
|
|
803
|
+
const body = await readBody(req, res);
|
|
804
|
+
const incoming = JSON.parse(body);
|
|
805
|
+
const existing = await loadConfig();
|
|
806
|
+
const merged = { ...existing };
|
|
807
|
+
for (const key of Object.keys(incoming)) {
|
|
808
|
+
const val = incoming[key];
|
|
809
|
+
// Deep-protect nested known-masked paths so `ab****yz` never overwrites true value
|
|
810
|
+
if (key === 'telegram' && typeof val === 'object' && val !== null) {
|
|
811
|
+
const t = val;
|
|
812
|
+
merged.telegram = {
|
|
813
|
+
...(existing.telegram || {}),
|
|
814
|
+
...t,
|
|
815
|
+
botToken: typeof t.botToken === 'string' && isMasked(t.botToken) ? existing.telegram?.botToken : t.botToken,
|
|
816
|
+
};
|
|
817
|
+
continue;
|
|
818
|
+
}
|
|
819
|
+
if (key === 'feishu' && typeof val === 'object' && val !== null) {
|
|
820
|
+
const f = val;
|
|
821
|
+
merged.feishu = {
|
|
822
|
+
...(existing.feishu || {}),
|
|
823
|
+
...f,
|
|
824
|
+
appSecret: typeof f.appSecret === 'string' && isMasked(f.appSecret) ? existing.feishu?.appSecret : f.appSecret,
|
|
825
|
+
};
|
|
826
|
+
continue;
|
|
827
|
+
}
|
|
828
|
+
if (key === 'acpAgents' && Array.isArray(val)) {
|
|
829
|
+
merged.acpAgents = val.map((item, i) => {
|
|
830
|
+
const a = item;
|
|
831
|
+
const old = existing.acpAgents?.[i];
|
|
832
|
+
if (a?.auth && typeof a.auth === 'object' && typeof a.auth.token === 'string' && isMasked(a.auth.token)) {
|
|
833
|
+
return { ...a, auth: { ...a.auth, token: old?.auth?.token } };
|
|
834
|
+
}
|
|
835
|
+
return a;
|
|
836
|
+
});
|
|
837
|
+
continue;
|
|
838
|
+
}
|
|
839
|
+
if (typeof val === 'string' && isMasked(val)) {
|
|
840
|
+
continue;
|
|
841
|
+
}
|
|
842
|
+
merged[key] = val;
|
|
843
|
+
}
|
|
844
|
+
const result = validateConfig(merged);
|
|
845
|
+
if (!result.ok) {
|
|
846
|
+
sendJson(res, 400, { error: 'Config validation failed', details: result.errors });
|
|
847
|
+
return;
|
|
848
|
+
}
|
|
849
|
+
await saveConfig(result.config);
|
|
850
|
+
sendJson(res, 200, { ok: true });
|
|
851
|
+
}
|
|
852
|
+
catch (err) {
|
|
853
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
854
|
+
sendJson(res, 400, { error: msg });
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
async function handleAgentsStatus(_req, res) {
|
|
858
|
+
try {
|
|
859
|
+
const agentStatus = await getAgentStatuses();
|
|
860
|
+
sendJson(res, 200, agentStatus);
|
|
861
|
+
}
|
|
862
|
+
catch (_err) {
|
|
863
|
+
sendJson(res, 500, { error: 'Failed to check agents' });
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
async function handleListWorkspaces(_req, res, url) {
|
|
867
|
+
try {
|
|
868
|
+
const { workspaceRegistry } = await import('../core/workspace.js');
|
|
869
|
+
// ?full=1 returns the full WorkspaceConfig (including member IDs)
|
|
870
|
+
// for the settings editor; default is the summary shape (member count
|
|
871
|
+
// only) used elsewhere.
|
|
872
|
+
const wantFull = url?.searchParams.get('full') === '1';
|
|
873
|
+
sendJson(res, 200, {
|
|
874
|
+
workspaces: wantFull ? workspaceRegistry.listFull() : workspaceRegistry.list(),
|
|
875
|
+
});
|
|
876
|
+
}
|
|
877
|
+
catch (err) {
|
|
878
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
879
|
+
sendJson(res, 500, { error: msg });
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
/**
|
|
883
|
+
* Validate + sanitize an incoming WorkspaceConfig from the settings
|
|
884
|
+
* editor. Returns a clean object on success or a string error message
|
|
885
|
+
* on failure. Reused by POST and PATCH so behavior is identical.
|
|
886
|
+
*/
|
|
887
|
+
function validateWorkspacePayload(raw, expectedId) {
|
|
888
|
+
if (!raw || typeof raw !== 'object')
|
|
889
|
+
return { ok: false, error: 'body must be a JSON object' };
|
|
890
|
+
const o = raw;
|
|
891
|
+
const id = String(o.id || '').trim();
|
|
892
|
+
if (!id)
|
|
893
|
+
return { ok: false, error: 'id is required' };
|
|
894
|
+
if (!/^[a-zA-Z0-9_-]+$/.test(id))
|
|
895
|
+
return { ok: false, error: 'id must match [a-zA-Z0-9_-]+' };
|
|
896
|
+
if (id === 'default' && expectedId !== 'default') {
|
|
897
|
+
return { ok: false, error: '"default" workspace is reserved (use PATCH to edit)' };
|
|
898
|
+
}
|
|
899
|
+
if (expectedId && expectedId !== id) {
|
|
900
|
+
return { ok: false, error: `id mismatch: URL is ${expectedId}, body is ${id}` };
|
|
901
|
+
}
|
|
902
|
+
const name = String(o.name || id);
|
|
903
|
+
const agents = Array.isArray(o.agents) ? o.agents.filter((a) => typeof a === 'string') : [];
|
|
904
|
+
const members = Array.isArray(o.members) ? o.members.filter((m) => typeof m === 'string') : undefined;
|
|
905
|
+
let rateLimit;
|
|
906
|
+
if (o.rateLimit && typeof o.rateLimit === 'object') {
|
|
907
|
+
const r = o.rateLimit;
|
|
908
|
+
const rate = Number(r.rate);
|
|
909
|
+
const intervalSec = Number(r.intervalSec);
|
|
910
|
+
const burst = Number(r.burst);
|
|
911
|
+
if (!Number.isFinite(rate) || rate <= 0
|
|
912
|
+
|| !Number.isFinite(intervalSec) || intervalSec <= 0
|
|
913
|
+
|| !Number.isFinite(burst) || burst <= 0) {
|
|
914
|
+
return { ok: false, error: 'rateLimit.rate / intervalSec / burst must be positive numbers' };
|
|
915
|
+
}
|
|
916
|
+
rateLimit = { rate, intervalSec, burst };
|
|
917
|
+
}
|
|
918
|
+
return { ok: true, cfg: { id, name, agents, members, rateLimit } };
|
|
919
|
+
}
|
|
920
|
+
/**
|
|
921
|
+
* Persist the workspaces array back to ~/.im-hub/config.json so changes
|
|
922
|
+
* survive a restart. We do not touch other config fields — settings.html
|
|
923
|
+
* has its own /api/config PUT for that. Best-effort: a write failure is
|
|
924
|
+
* logged but the in-memory registry has already been updated, so the
|
|
925
|
+
* change is live until the next process boot.
|
|
926
|
+
*/
|
|
927
|
+
async function persistWorkspacesToConfig(workspaces) {
|
|
928
|
+
const config = await loadConfig();
|
|
929
|
+
config.workspaces = workspaces;
|
|
930
|
+
await saveConfig(config);
|
|
931
|
+
}
|
|
932
|
+
async function handleCreateOrUpdateWorkspace(req, res, expectedId) {
|
|
933
|
+
try {
|
|
934
|
+
const body = await readBody(req, res);
|
|
935
|
+
let parsed;
|
|
936
|
+
try {
|
|
937
|
+
parsed = JSON.parse(body);
|
|
938
|
+
}
|
|
939
|
+
catch {
|
|
940
|
+
sendJson(res, 400, { ok: false, error: 'Invalid JSON body' });
|
|
941
|
+
return;
|
|
942
|
+
}
|
|
943
|
+
const v = validateWorkspacePayload(parsed, expectedId);
|
|
944
|
+
if (!v.ok) {
|
|
945
|
+
sendJson(res, 400, { ok: false, error: v.error });
|
|
946
|
+
return;
|
|
947
|
+
}
|
|
948
|
+
const { workspaceRegistry } = await import('../core/workspace.js');
|
|
949
|
+
workspaceRegistry.add(v.cfg);
|
|
950
|
+
await persistWorkspacesToConfig(workspaceRegistry.listFull().filter((w) => w.id !== 'default'));
|
|
951
|
+
sendJson(res, 200, { ok: true, workspace: v.cfg });
|
|
952
|
+
}
|
|
953
|
+
catch (err) {
|
|
954
|
+
sendJson(res, 500, { ok: false, error: err instanceof Error ? err.message : String(err) });
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
async function handleDeleteWorkspace(_req, res, id) {
|
|
958
|
+
try {
|
|
959
|
+
const { workspaceRegistry } = await import('../core/workspace.js');
|
|
960
|
+
if (id === 'default') {
|
|
961
|
+
sendJson(res, 400, { ok: false, error: 'cannot delete the default workspace' });
|
|
962
|
+
return;
|
|
963
|
+
}
|
|
964
|
+
const removed = workspaceRegistry.remove(id);
|
|
965
|
+
if (!removed) {
|
|
966
|
+
sendJson(res, 404, { ok: false, error: `workspace "${id}" not found` });
|
|
967
|
+
return;
|
|
968
|
+
}
|
|
969
|
+
await persistWorkspacesToConfig(workspaceRegistry.listFull().filter((w) => w.id !== 'default'));
|
|
970
|
+
sendJson(res, 200, { ok: true });
|
|
971
|
+
}
|
|
972
|
+
catch (err) {
|
|
973
|
+
sendJson(res, 500, { ok: false, error: err instanceof Error ? err.message : String(err) });
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
async function handleMetrics(_req, res, url) {
|
|
977
|
+
try {
|
|
978
|
+
const fmt = url.searchParams.get('format') || 'prom';
|
|
979
|
+
const { snapshot, toPrometheus } = await import('../core/metrics.js');
|
|
980
|
+
if (fmt === 'json') {
|
|
981
|
+
sendJson(res, 200, snapshot());
|
|
982
|
+
return;
|
|
983
|
+
}
|
|
984
|
+
res.writeHead(200, { 'Content-Type': 'text/plain; version=0.0.4' });
|
|
985
|
+
res.end(toPrometheus());
|
|
986
|
+
}
|
|
987
|
+
catch (err) {
|
|
988
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
989
|
+
sendJson(res, 500, { error: msg });
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
/**
|
|
993
|
+
* POST /api/notify → push a message to an IM thread.
|
|
994
|
+
*
|
|
995
|
+
* Body: { platform, threadId, text, card? }
|
|
996
|
+
* Use case: external systems (CI / monitoring / cron) pushing notices
|
|
997
|
+
* back to a chat thread without going through the Agent layer.
|
|
998
|
+
*/
|
|
999
|
+
async function handleNotify(req, res) {
|
|
1000
|
+
try {
|
|
1001
|
+
const body = await readBody(req, res);
|
|
1002
|
+
const { platform, threadId, text, card } = JSON.parse(body);
|
|
1003
|
+
if (!platform || !threadId || (!text && !card)) {
|
|
1004
|
+
sendJson(res, 400, { error: 'Missing platform / threadId / (text|card)' });
|
|
1005
|
+
return;
|
|
1006
|
+
}
|
|
1007
|
+
// Map platform name to messenger plugin name.
|
|
1008
|
+
const messengerName = platform === 'wechat' ? 'wechat-ilink' : platform;
|
|
1009
|
+
const messenger = registry.getMessenger(messengerName);
|
|
1010
|
+
if (!messenger) {
|
|
1011
|
+
sendJson(res, 404, { error: `Messenger "${platform}" not registered` });
|
|
1012
|
+
return;
|
|
1013
|
+
}
|
|
1014
|
+
const traceId = generateTraceId();
|
|
1015
|
+
const log = createLogger({ traceId, platform, component: 'notify' });
|
|
1016
|
+
log.info({ threadId, hasCard: !!card, textLen: text?.length || 0 }, 'notify in');
|
|
1017
|
+
if (card && typeof messenger.sendCard === 'function') {
|
|
1018
|
+
await messenger.sendCard(threadId, card);
|
|
1019
|
+
}
|
|
1020
|
+
else if (text) {
|
|
1021
|
+
await messenger.sendMessage(threadId, text);
|
|
1022
|
+
}
|
|
1023
|
+
else {
|
|
1024
|
+
sendJson(res, 400, { error: 'card requires sendCard support, otherwise text is required' });
|
|
1025
|
+
return;
|
|
1026
|
+
}
|
|
1027
|
+
sendJson(res, 200, { ok: true, traceId });
|
|
1028
|
+
}
|
|
1029
|
+
catch (err) {
|
|
1030
|
+
const e = err;
|
|
1031
|
+
if (e?.handled)
|
|
1032
|
+
return;
|
|
1033
|
+
const status = e?.statusCode || 500;
|
|
1034
|
+
const msg = e instanceof Error ? e.message : String(err);
|
|
1035
|
+
if (!res.headersSent)
|
|
1036
|
+
sendJson(res, status, { error: msg });
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
/**
|
|
1040
|
+
* POST /api/invoke → run an agent prompt as if it came from a user.
|
|
1041
|
+
*
|
|
1042
|
+
* Body: { prompt, agent?, userId?, platform? }
|
|
1043
|
+
* Returns a JSON response with the full text (for streaming use the ACP
|
|
1044
|
+
* server's POST /tasks?mode=stream instead).
|
|
1045
|
+
*/
|
|
1046
|
+
async function handleInvoke(req, res, defaultAgent) {
|
|
1047
|
+
try {
|
|
1048
|
+
const body = await readBody(req, res);
|
|
1049
|
+
const parsed = JSON.parse(body);
|
|
1050
|
+
if (!parsed.prompt) {
|
|
1051
|
+
sendJson(res, 400, { error: 'Missing prompt' });
|
|
1052
|
+
return;
|
|
1053
|
+
}
|
|
1054
|
+
const agentName = parsed.agent || defaultAgent;
|
|
1055
|
+
const promptText = parsed.agent ? `/${parsed.agent} ${parsed.prompt}` : parsed.prompt;
|
|
1056
|
+
const traceId = generateTraceId();
|
|
1057
|
+
const platform = parsed.platform || 'rest';
|
|
1058
|
+
const log = createLogger({ traceId, platform, component: 'invoke' });
|
|
1059
|
+
log.info({ agent: agentName, promptLen: parsed.prompt.length }, 'invoke in');
|
|
1060
|
+
const routeCtx = {
|
|
1061
|
+
threadId: `rest:${traceId}`,
|
|
1062
|
+
channelId: 'rest',
|
|
1063
|
+
platform,
|
|
1064
|
+
defaultAgent: agentName,
|
|
1065
|
+
traceId,
|
|
1066
|
+
logger: log,
|
|
1067
|
+
userId: parsed.userId || 'rest-caller',
|
|
1068
|
+
};
|
|
1069
|
+
const parsedMsg = parseMessage(promptText);
|
|
1070
|
+
const result = await routeMessage(parsedMsg, routeCtx);
|
|
1071
|
+
let fullText = '';
|
|
1072
|
+
if (typeof result === 'string') {
|
|
1073
|
+
fullText = result;
|
|
1074
|
+
}
|
|
1075
|
+
else {
|
|
1076
|
+
for await (const chunk of result)
|
|
1077
|
+
fullText += chunk;
|
|
1078
|
+
}
|
|
1079
|
+
sendJson(res, 200, { ok: true, traceId, output: { content: fullText } });
|
|
1080
|
+
}
|
|
1081
|
+
catch (err) {
|
|
1082
|
+
const e = err;
|
|
1083
|
+
if (e?.handled)
|
|
1084
|
+
return;
|
|
1085
|
+
const status = e?.statusCode || 500;
|
|
1086
|
+
const msg = e instanceof Error ? e.message : String(err);
|
|
1087
|
+
if (!res.headersSent)
|
|
1088
|
+
sendJson(res, status, { error: msg });
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
async function handleHealth(_req, res) {
|
|
1092
|
+
// Quick check: agent availability snapshot. Already used by settings UI;
|
|
1093
|
+
// exposing it under /api/health gives ops a stable URL.
|
|
1094
|
+
try {
|
|
1095
|
+
const status = await getAgentStatuses();
|
|
1096
|
+
const anyHealthy = Object.values(status).some(Boolean);
|
|
1097
|
+
sendJson(res, anyHealthy ? 200 : 503, {
|
|
1098
|
+
ok: anyHealthy,
|
|
1099
|
+
agents: status,
|
|
1100
|
+
uptimeSec: Math.round(process.uptime()),
|
|
1101
|
+
});
|
|
1102
|
+
}
|
|
1103
|
+
catch (err) {
|
|
1104
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1105
|
+
sendJson(res, 500, { ok: false, error: msg });
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
async function handleListJobs(_req, res, url) {
|
|
1109
|
+
try {
|
|
1110
|
+
const { listJobs, getJobStats } = await import('../core/job-board.js');
|
|
1111
|
+
const limit = Math.min(Math.max(parseInt(url.searchParams.get('limit') || '50', 10) || 50, 1), 500);
|
|
1112
|
+
const status = url.searchParams.get('status');
|
|
1113
|
+
const agent = url.searchParams.get('agent') || undefined;
|
|
1114
|
+
const jobs = listJobs(limit, status || undefined, agent ? { agent } : {});
|
|
1115
|
+
const stats = getJobStats();
|
|
1116
|
+
sendJson(res, 200, { jobs, stats });
|
|
1117
|
+
}
|
|
1118
|
+
catch (err) {
|
|
1119
|
+
sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) });
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
async function handleGetJob(_req, res, id) {
|
|
1123
|
+
try {
|
|
1124
|
+
const { getJob } = await import('../core/job-board.js');
|
|
1125
|
+
const job = getJob(id);
|
|
1126
|
+
if (!job) {
|
|
1127
|
+
sendJson(res, 404, { error: 'Job not found' });
|
|
1128
|
+
return;
|
|
1129
|
+
}
|
|
1130
|
+
sendJson(res, 200, { job });
|
|
1131
|
+
}
|
|
1132
|
+
catch (err) {
|
|
1133
|
+
sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) });
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
async function handleCancelJob(_req, res, id) {
|
|
1137
|
+
try {
|
|
1138
|
+
const { cancelJob } = await import('../core/job-board.js');
|
|
1139
|
+
sendJson(res, 200, { ok: cancelJob(id) });
|
|
1140
|
+
}
|
|
1141
|
+
catch (err) {
|
|
1142
|
+
sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) });
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
async function handleRunJob(_req, res, id) {
|
|
1146
|
+
try {
|
|
1147
|
+
const { getJob, runJob } = await import('../core/job-board.js');
|
|
1148
|
+
const { AgentBase } = await import('../core/agent-base.js');
|
|
1149
|
+
const job = getJob(id);
|
|
1150
|
+
if (!job) {
|
|
1151
|
+
sendJson(res, 404, { error: 'Job not found' });
|
|
1152
|
+
return;
|
|
1153
|
+
}
|
|
1154
|
+
const agent = registry.findAgent(job.agent);
|
|
1155
|
+
if (!agent) {
|
|
1156
|
+
sendJson(res, 404, { error: `Agent "${job.agent}" not registered` });
|
|
1157
|
+
return;
|
|
1158
|
+
}
|
|
1159
|
+
const traceId = generateTraceId();
|
|
1160
|
+
const log = createLogger({ traceId, platform: 'web', component: 'job-run' });
|
|
1161
|
+
// Fire and forget — UI polls /api/jobs/:id for status.
|
|
1162
|
+
void runJob(id, async function* (j, _logger, signal) {
|
|
1163
|
+
if (agent instanceof AgentBase) {
|
|
1164
|
+
const text = await agent.spawnAndCollect(j.prompt, signal);
|
|
1165
|
+
if (text)
|
|
1166
|
+
yield text;
|
|
1167
|
+
}
|
|
1168
|
+
else {
|
|
1169
|
+
for await (const chunk of agent.sendPrompt(`web-job-${j.id}`, j.prompt, [])) {
|
|
1170
|
+
if (signal.aborted)
|
|
1171
|
+
break;
|
|
1172
|
+
yield chunk;
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
1175
|
+
}, log).catch(() => { });
|
|
1176
|
+
sendJson(res, 200, { ok: true, traceId });
|
|
1177
|
+
}
|
|
1178
|
+
catch (err) {
|
|
1179
|
+
sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) });
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
async function handleCreateJob(req, res) {
|
|
1183
|
+
try {
|
|
1184
|
+
const body = await readBody(req, res);
|
|
1185
|
+
const { agent, prompt } = JSON.parse(body);
|
|
1186
|
+
if (!agent || !prompt) {
|
|
1187
|
+
sendJson(res, 400, { error: 'Missing agent / prompt' });
|
|
1188
|
+
return;
|
|
1189
|
+
}
|
|
1190
|
+
if (!registry.findAgent(agent)) {
|
|
1191
|
+
sendJson(res, 404, { error: `Agent "${agent}" not registered` });
|
|
1192
|
+
return;
|
|
1193
|
+
}
|
|
1194
|
+
const { createJob } = await import('../core/job-board.js');
|
|
1195
|
+
const id = createJob(agent, prompt);
|
|
1196
|
+
sendJson(res, 200, { ok: true, id });
|
|
1197
|
+
}
|
|
1198
|
+
catch (err) {
|
|
1199
|
+
const e = err;
|
|
1200
|
+
if (e?.handled)
|
|
1201
|
+
return;
|
|
1202
|
+
if (!res.headersSent)
|
|
1203
|
+
sendJson(res, e?.statusCode || 500, { error: e instanceof Error ? e.message : String(err) });
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
async function handleListSchedules(_req, res, url) {
|
|
1207
|
+
try {
|
|
1208
|
+
const { listSchedules } = await import('../core/schedule.js');
|
|
1209
|
+
const agent = url.searchParams.get('agent') || undefined;
|
|
1210
|
+
sendJson(res, 200, { schedules: listSchedules(50, agent ? { agent } : {}) });
|
|
1211
|
+
}
|
|
1212
|
+
catch (err) {
|
|
1213
|
+
sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) });
|
|
1214
|
+
}
|
|
1215
|
+
}
|
|
1216
|
+
/**
|
|
1217
|
+
* GET /api/reminders?status=pending&limit=100
|
|
1218
|
+
* Returns a flat list of reminders. status filter is optional; without it
|
|
1219
|
+
* we only return pending — that's the 99% UI use-case (the page is a
|
|
1220
|
+
* "what's queued up" view, not an audit log).
|
|
1221
|
+
*/
|
|
1222
|
+
async function handleListReminders(_req, res, url) {
|
|
1223
|
+
try {
|
|
1224
|
+
const { listReminders, describeRecurrence } = await import('../core/reminders.js');
|
|
1225
|
+
const status = url.searchParams.get('status');
|
|
1226
|
+
const limit = Math.min(Math.max(parseInt(url.searchParams.get('limit') || '100', 10) || 100, 1), 500);
|
|
1227
|
+
const rows = listReminders({ status: status ?? 'pending', limit });
|
|
1228
|
+
const annotated = rows.map((r) => ({
|
|
1229
|
+
...r,
|
|
1230
|
+
recurrence_label: r.recurrence ? describeRecurrence(r.recurrence) : null,
|
|
1231
|
+
}));
|
|
1232
|
+
sendJson(res, 200, { reminders: annotated });
|
|
1233
|
+
}
|
|
1234
|
+
catch (err) {
|
|
1235
|
+
sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) });
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1238
|
+
async function handleCancelReminderApi(_req, res, id) {
|
|
1239
|
+
if (!Number.isFinite(id) || id <= 0) {
|
|
1240
|
+
sendJson(res, 400, { error: 'invalid id' });
|
|
1241
|
+
return;
|
|
1242
|
+
}
|
|
1243
|
+
try {
|
|
1244
|
+
const { cancelReminder } = await import('../core/reminders.js');
|
|
1245
|
+
const ok = cancelReminder(id);
|
|
1246
|
+
if (!ok) {
|
|
1247
|
+
sendJson(res, 404, { error: 'not found or not cancellable' });
|
|
1248
|
+
return;
|
|
1249
|
+
}
|
|
1250
|
+
sendJson(res, 200, { ok: true, id });
|
|
1251
|
+
}
|
|
1252
|
+
catch (err) {
|
|
1253
|
+
sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) });
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
1256
|
+
async function handleSnoozeReminderApi(req, res, id) {
|
|
1257
|
+
if (!Number.isFinite(id) || id <= 0) {
|
|
1258
|
+
sendJson(res, 400, { error: 'invalid id' });
|
|
1259
|
+
return;
|
|
1260
|
+
}
|
|
1261
|
+
try {
|
|
1262
|
+
const body = await readBody(req, res);
|
|
1263
|
+
const parsed = JSON.parse(body || '{}');
|
|
1264
|
+
const duration = parsed.duration?.trim() || '';
|
|
1265
|
+
if (!duration) {
|
|
1266
|
+
sendJson(res, 400, { error: 'duration required (e.g. "5m")' });
|
|
1267
|
+
return;
|
|
1268
|
+
}
|
|
1269
|
+
const { snoozeReminder, parseDuration, ReminderError } = await import('../core/reminders.js');
|
|
1270
|
+
const ms = parseDuration(duration);
|
|
1271
|
+
if (ms === null) {
|
|
1272
|
+
sendJson(res, 400, { error: `bad duration "${duration}"` });
|
|
1273
|
+
return;
|
|
1274
|
+
}
|
|
1275
|
+
try {
|
|
1276
|
+
const newId = snoozeReminder(id, ms);
|
|
1277
|
+
sendJson(res, 200, { ok: true, originalId: id, newId, duration });
|
|
1278
|
+
}
|
|
1279
|
+
catch (err) {
|
|
1280
|
+
if (err instanceof ReminderError) {
|
|
1281
|
+
sendJson(res, 400, { error: err.message });
|
|
1282
|
+
return;
|
|
1283
|
+
}
|
|
1284
|
+
throw err;
|
|
1285
|
+
}
|
|
1286
|
+
}
|
|
1287
|
+
catch (err) {
|
|
1288
|
+
sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) });
|
|
1289
|
+
}
|
|
1290
|
+
}
|
|
1291
|
+
// ─── memos ─────────────────────────────────────────────────────────────
|
|
1292
|
+
async function handleListMemos(_req, res, url) {
|
|
1293
|
+
try {
|
|
1294
|
+
const { searchMemos } = await import('../core/memos.js');
|
|
1295
|
+
const query = url.searchParams.get('query') || undefined;
|
|
1296
|
+
const who = url.searchParams.get('who') || undefined;
|
|
1297
|
+
const what = url.searchParams.get('what') || undefined;
|
|
1298
|
+
const hasLocation = url.searchParams.get('has_location') === 'true' ? true : undefined;
|
|
1299
|
+
const includeExpired = url.searchParams.get('include_expired') === 'true' ? true : undefined;
|
|
1300
|
+
const limitRaw = parseInt(url.searchParams.get('limit') || '50', 10);
|
|
1301
|
+
const limit = Number.isFinite(limitRaw) ? Math.min(Math.max(limitRaw, 1), 200) : 50;
|
|
1302
|
+
const rows = searchMemos({ query, who, what, hasLocation, includeExpired, limit });
|
|
1303
|
+
// Strip ownership noise but keep everything searchable / displayable.
|
|
1304
|
+
// mapUrls is added per-row so the dashboard can render map links.
|
|
1305
|
+
const { mapUrls: makeMapUrls } = await import('../core/memos.js');
|
|
1306
|
+
const items = rows.map((m) => {
|
|
1307
|
+
const out = {
|
|
1308
|
+
id: m.id,
|
|
1309
|
+
platform: m.platform,
|
|
1310
|
+
userId: m.user_id,
|
|
1311
|
+
what: m.what,
|
|
1312
|
+
who: m.who,
|
|
1313
|
+
whenAt: m.when_at,
|
|
1314
|
+
whenText: m.when_text,
|
|
1315
|
+
where_lat: m.where_lat,
|
|
1316
|
+
where_lng: m.where_lng,
|
|
1317
|
+
where_label: m.where_label,
|
|
1318
|
+
how: m.how,
|
|
1319
|
+
why: m.why,
|
|
1320
|
+
memo: m.memo,
|
|
1321
|
+
source: m.source,
|
|
1322
|
+
expiresAt: m.expires_at,
|
|
1323
|
+
createdAt: m.created_at,
|
|
1324
|
+
updatedAt: m.updated_at,
|
|
1325
|
+
};
|
|
1326
|
+
if (m.where_lat != null && m.where_lng != null) {
|
|
1327
|
+
out.mapUrls = makeMapUrls(m.where_lat, m.where_lng, m.where_label || m.what, '');
|
|
1328
|
+
}
|
|
1329
|
+
return out;
|
|
1330
|
+
});
|
|
1331
|
+
sendJson(res, 200, { memos: items });
|
|
1332
|
+
}
|
|
1333
|
+
catch (err) {
|
|
1334
|
+
sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) });
|
|
1335
|
+
}
|
|
1336
|
+
}
|
|
1337
|
+
async function handleDeleteMemo(_req, res, id) {
|
|
1338
|
+
if (!Number.isFinite(id) || id <= 0) {
|
|
1339
|
+
sendJson(res, 400, { error: 'invalid id' });
|
|
1340
|
+
return;
|
|
1341
|
+
}
|
|
1342
|
+
try {
|
|
1343
|
+
const { deleteMemo } = await import('../core/memos.js');
|
|
1344
|
+
const ok = deleteMemo(id);
|
|
1345
|
+
if (!ok) {
|
|
1346
|
+
sendJson(res, 404, { error: `memo #${id} not found` });
|
|
1347
|
+
return;
|
|
1348
|
+
}
|
|
1349
|
+
sendJson(res, 200, { ok: true, id });
|
|
1350
|
+
}
|
|
1351
|
+
catch (err) {
|
|
1352
|
+
sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) });
|
|
1353
|
+
}
|
|
1354
|
+
}
|
|
1355
|
+
// ─── env file (SMTP + Baidu AK + …) ────────────────────────────────────
|
|
1356
|
+
const ENV_EDITABLE_KEYS = [
|
|
1357
|
+
'IMHUB_SMTP_HOST', 'IMHUB_SMTP_PORT', 'IMHUB_SMTP_USER', 'IMHUB_SMTP_PASS',
|
|
1358
|
+
'IMHUB_SMTP_FROM', 'IMHUB_SMTP_SECURE',
|
|
1359
|
+
'IMHUB_BAIDU_MAP_AK',
|
|
1360
|
+
'IMHUB_LOC_BASE_URL', 'IMHUB_TZ_OFFSET_HOURS',
|
|
1361
|
+
];
|
|
1362
|
+
const SECRET_KEYS = new Set(['IMHUB_SMTP_PASS', 'IMHUB_BAIDU_MAP_AK']);
|
|
1363
|
+
function maskSecret(v) {
|
|
1364
|
+
if (!v)
|
|
1365
|
+
return '';
|
|
1366
|
+
if (v.length <= 8)
|
|
1367
|
+
return '*'.repeat(v.length);
|
|
1368
|
+
return v.slice(0, 4) + '*'.repeat(Math.max(0, v.length - 8)) + v.slice(-4);
|
|
1369
|
+
}
|
|
1370
|
+
async function handleGetEnv(_req, res, url) {
|
|
1371
|
+
try {
|
|
1372
|
+
const { readEnvFile } = await import('../cli-ui/env-file.js');
|
|
1373
|
+
const env = readEnvFile();
|
|
1374
|
+
const reveal = url.searchParams.get('reveal') === '1';
|
|
1375
|
+
const out = {};
|
|
1376
|
+
for (const key of ENV_EDITABLE_KEYS) {
|
|
1377
|
+
const v = env[key];
|
|
1378
|
+
if (v === undefined)
|
|
1379
|
+
continue;
|
|
1380
|
+
out[key] = reveal ? v : (SECRET_KEYS.has(key) ? maskSecret(v) : v);
|
|
1381
|
+
}
|
|
1382
|
+
sendJson(res, 200, { env: out, secretKeys: Array.from(SECRET_KEYS) });
|
|
1383
|
+
}
|
|
1384
|
+
catch (err) {
|
|
1385
|
+
sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) });
|
|
1386
|
+
}
|
|
1387
|
+
}
|
|
1388
|
+
async function handlePutEnv(req, res) {
|
|
1389
|
+
try {
|
|
1390
|
+
const body = await readBody(req, res);
|
|
1391
|
+
const parsed = JSON.parse(body || '{}');
|
|
1392
|
+
const updates = parsed.updates;
|
|
1393
|
+
if (!updates || typeof updates !== 'object') {
|
|
1394
|
+
sendJson(res, 400, { error: 'updates object required' });
|
|
1395
|
+
return;
|
|
1396
|
+
}
|
|
1397
|
+
// Filter to whitelist — never let arbitrary keys through.
|
|
1398
|
+
const safe = {};
|
|
1399
|
+
for (const [k, v] of Object.entries(updates)) {
|
|
1400
|
+
if (!ENV_EDITABLE_KEYS.includes(k))
|
|
1401
|
+
continue;
|
|
1402
|
+
if (v === null || typeof v === 'string')
|
|
1403
|
+
safe[k] = v;
|
|
1404
|
+
}
|
|
1405
|
+
if (Object.keys(safe).length === 0) {
|
|
1406
|
+
sendJson(res, 400, { error: 'no editable keys in updates' });
|
|
1407
|
+
return;
|
|
1408
|
+
}
|
|
1409
|
+
const { updateEnvFile } = await import('../cli-ui/env-file.js');
|
|
1410
|
+
updateEnvFile(safe);
|
|
1411
|
+
sendJson(res, 200, { ok: true, updated: Object.keys(safe) });
|
|
1412
|
+
}
|
|
1413
|
+
catch (err) {
|
|
1414
|
+
sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) });
|
|
1415
|
+
}
|
|
1416
|
+
}
|
|
1417
|
+
async function handleListBgjobs(_req, res, url) {
|
|
1418
|
+
try {
|
|
1419
|
+
const { resolveRoots, listJobsForRoot, listAllJobs } = await import('../core/bgjob-reader.js');
|
|
1420
|
+
const rootId = url.searchParams.get('root');
|
|
1421
|
+
if (rootId) {
|
|
1422
|
+
// Single-root view — used by the dashboard's root selector.
|
|
1423
|
+
const root = resolveRoots().find((r) => r.id === rootId);
|
|
1424
|
+
if (!root) {
|
|
1425
|
+
sendJson(res, 404, { error: `bgjob root "${rootId}" not configured` });
|
|
1426
|
+
return;
|
|
1427
|
+
}
|
|
1428
|
+
const jobs = await listJobsForRoot(root);
|
|
1429
|
+
sendJson(res, 200, { roots: [{ id: root.id, label: root.label, path: root.path }], jobs });
|
|
1430
|
+
return;
|
|
1431
|
+
}
|
|
1432
|
+
// No root specified: return all roots' metadata + jobs grouped by root.
|
|
1433
|
+
const all = await listAllJobs();
|
|
1434
|
+
sendJson(res, 200, {
|
|
1435
|
+
roots: all.map(({ root }) => ({ id: root.id, label: root.label, path: root.path })),
|
|
1436
|
+
groups: all.map(({ root, jobs }) => ({ rootId: root.id, jobs })),
|
|
1437
|
+
});
|
|
1438
|
+
}
|
|
1439
|
+
catch (err) {
|
|
1440
|
+
sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) });
|
|
1441
|
+
}
|
|
1442
|
+
}
|
|
1443
|
+
async function handleGetBgjob(_req, res, id, url) {
|
|
1444
|
+
try {
|
|
1445
|
+
const { findRoot, getJobDetail, resolveRoots, listJobsForRoot } = await import('../core/bgjob-reader.js');
|
|
1446
|
+
const tail = Math.min(Math.max(parseInt(url.searchParams.get('tail') || '200', 10) || 200, 1), 5000);
|
|
1447
|
+
const rootId = url.searchParams.get('root');
|
|
1448
|
+
if (rootId) {
|
|
1449
|
+
const root = findRoot(rootId);
|
|
1450
|
+
if (!root) {
|
|
1451
|
+
sendJson(res, 404, { error: `bgjob root "${rootId}" not configured` });
|
|
1452
|
+
return;
|
|
1453
|
+
}
|
|
1454
|
+
const job = await getJobDetail(root, id, tail);
|
|
1455
|
+
if (!job) {
|
|
1456
|
+
sendJson(res, 404, { error: 'Job not found' });
|
|
1457
|
+
return;
|
|
1458
|
+
}
|
|
1459
|
+
sendJson(res, 200, { job });
|
|
1460
|
+
return;
|
|
1461
|
+
}
|
|
1462
|
+
// No root: try every configured root, return first hit.
|
|
1463
|
+
for (const root of resolveRoots()) {
|
|
1464
|
+
const summaries = await listJobsForRoot(root);
|
|
1465
|
+
if (!summaries.some((s) => s.id === id))
|
|
1466
|
+
continue;
|
|
1467
|
+
const job = await getJobDetail(root, id, tail);
|
|
1468
|
+
if (job) {
|
|
1469
|
+
sendJson(res, 200, { job });
|
|
1470
|
+
return;
|
|
1471
|
+
}
|
|
1472
|
+
}
|
|
1473
|
+
sendJson(res, 404, { error: 'Job not found' });
|
|
1474
|
+
}
|
|
1475
|
+
catch (err) {
|
|
1476
|
+
sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) });
|
|
1477
|
+
}
|
|
1478
|
+
}
|
|
1479
|
+
async function handleListSubtasks(_req, res, url) {
|
|
1480
|
+
try {
|
|
1481
|
+
const { sessionManager } = await import('../core/session.js');
|
|
1482
|
+
const agent = url.searchParams.get('agent') || undefined;
|
|
1483
|
+
const subtasks = await sessionManager.listAllSubtasks(agent ? { agent } : {});
|
|
1484
|
+
sendJson(res, 200, { subtasks });
|
|
1485
|
+
}
|
|
1486
|
+
catch (err) {
|
|
1487
|
+
sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) });
|
|
1488
|
+
}
|
|
1489
|
+
}
|
|
1490
|
+
async function handleAudit(_req, res, url) {
|
|
1491
|
+
try {
|
|
1492
|
+
const { queryInvocations, getStats } = await import('../core/audit-log.js');
|
|
1493
|
+
const limit = Math.min(Math.max(parseInt(url.searchParams.get('limit') || '100', 10) || 100, 1), 1000);
|
|
1494
|
+
const days = parseInt(url.searchParams.get('days') || '7', 10) || 7;
|
|
1495
|
+
const agent = url.searchParams.get('agent') || undefined;
|
|
1496
|
+
const platform = url.searchParams.get('platform') || undefined;
|
|
1497
|
+
const userId = url.searchParams.get('user') || undefined;
|
|
1498
|
+
const intent = url.searchParams.get('intent') || undefined;
|
|
1499
|
+
const rows = queryInvocations({ limit, days, agent, platform, userId, intent });
|
|
1500
|
+
const stats = getStats();
|
|
1501
|
+
sendJson(res, 200, { invocations: rows, stats });
|
|
1502
|
+
}
|
|
1503
|
+
catch (err) {
|
|
1504
|
+
sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) });
|
|
1505
|
+
}
|
|
1506
|
+
}
|
|
1507
|
+
/**
|
|
1508
|
+
* Per-agent operational health snapshot. Drives the Health tab in /tasks.
|
|
1509
|
+
*
|
|
1510
|
+
* Combines three independent live data sources:
|
|
1511
|
+
* - circuit breaker phase / cooldown remaining (core/circuit-breaker.ts)
|
|
1512
|
+
* - rate-limiter remaining tokens & config (core/rate-limiter.ts agentLimiter)
|
|
1513
|
+
* - latency p50 / p95 / p99 + invocation totals (core/metrics.ts snapshot)
|
|
1514
|
+
*
|
|
1515
|
+
* No persistence — pure read of in-memory state. Cheap to call (<1 ms for
|
|
1516
|
+
* a typical agent fleet) so the page is happy to poll on a 5 s tick.
|
|
1517
|
+
*/
|
|
1518
|
+
async function handleAgentHealth(_req, res) {
|
|
1519
|
+
try {
|
|
1520
|
+
const { circuitBreaker } = await import('../core/circuit-breaker.js');
|
|
1521
|
+
const { agentLimiter } = await import('../core/rate-limiter.js');
|
|
1522
|
+
const { snapshot } = await import('../core/metrics.js');
|
|
1523
|
+
const snap = snapshot();
|
|
1524
|
+
const now = Date.now();
|
|
1525
|
+
const agents = registry.listAgents().map((name) => {
|
|
1526
|
+
const breaker = circuitBreaker.getStatus(name);
|
|
1527
|
+
const cooldownRemainingMs = breaker.openedAt && breaker.phase !== 'closed'
|
|
1528
|
+
? Math.max(0, breaker.cooldownMs - (now - breaker.openedAt))
|
|
1529
|
+
: 0;
|
|
1530
|
+
const rate = agentLimiter.status(name);
|
|
1531
|
+
const m = snap.agents.find((a) => a.agent === name);
|
|
1532
|
+
return {
|
|
1533
|
+
agent: name,
|
|
1534
|
+
breaker: {
|
|
1535
|
+
phase: breaker.phase,
|
|
1536
|
+
failures: breaker.failures,
|
|
1537
|
+
cooldownMs: breaker.cooldownMs,
|
|
1538
|
+
cooldownRemainingMs,
|
|
1539
|
+
},
|
|
1540
|
+
rate: {
|
|
1541
|
+
remaining: rate.remaining,
|
|
1542
|
+
rate: rate.rate,
|
|
1543
|
+
intervalSec: rate.intervalSec,
|
|
1544
|
+
},
|
|
1545
|
+
invocations: m
|
|
1546
|
+
? {
|
|
1547
|
+
total: m.total,
|
|
1548
|
+
success: m.success,
|
|
1549
|
+
failure: m.failure,
|
|
1550
|
+
successRate: m.successRate,
|
|
1551
|
+
costSum: m.costSum,
|
|
1552
|
+
sampleCount: m.sampleCount,
|
|
1553
|
+
p50Ms: m.p50Ms,
|
|
1554
|
+
p95Ms: m.p95Ms,
|
|
1555
|
+
p99Ms: m.p99Ms,
|
|
1556
|
+
}
|
|
1557
|
+
: null,
|
|
1558
|
+
};
|
|
1559
|
+
});
|
|
1560
|
+
sendJson(res, 200, { agents, uptimeSec: snap.uptimeSec });
|
|
1561
|
+
}
|
|
1562
|
+
catch (err) {
|
|
1563
|
+
sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) });
|
|
1564
|
+
}
|
|
1565
|
+
}
|
|
1566
|
+
/**
|
|
1567
|
+
* List every currently-pending HITL approval across all sessions /
|
|
1568
|
+
* platforms. Used by the global Approvals tab in /tasks so the operator
|
|
1569
|
+
* can see at a glance whether something is waiting on a y/n that nobody
|
|
1570
|
+
* is around to give.
|
|
1571
|
+
*/
|
|
1572
|
+
async function handleListApprovals(_req, res) {
|
|
1573
|
+
try {
|
|
1574
|
+
const { approvalBus } = await import('../core/approval-bus.js');
|
|
1575
|
+
const pending = approvalBus.listPending();
|
|
1576
|
+
const metrics = approvalBus.getMetrics();
|
|
1577
|
+
sendJson(res, 200, { pending, metrics });
|
|
1578
|
+
}
|
|
1579
|
+
catch (err) {
|
|
1580
|
+
sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) });
|
|
1581
|
+
}
|
|
1582
|
+
}
|
|
1583
|
+
/**
|
|
1584
|
+
* Resolve an approval by reqId (admin / dashboard path). Body:
|
|
1585
|
+
* { behavior: 'allow' | 'deny', autoAllowFurther?: boolean, message?: string }
|
|
1586
|
+
*
|
|
1587
|
+
* Uses resolvePendingByReqId for precise targeting — avoids the FIFO head
|
|
1588
|
+
* ambiguity when multiple approvals are queued on the same thread.
|
|
1589
|
+
*/
|
|
1590
|
+
async function handleResolveApproval(req, res, reqId) {
|
|
1591
|
+
try {
|
|
1592
|
+
const body = await readBody(req, res);
|
|
1593
|
+
let parsed;
|
|
1594
|
+
try {
|
|
1595
|
+
parsed = JSON.parse(body);
|
|
1596
|
+
}
|
|
1597
|
+
catch {
|
|
1598
|
+
sendJson(res, 400, { ok: false, error: 'Invalid JSON body' });
|
|
1599
|
+
return;
|
|
1600
|
+
}
|
|
1601
|
+
if (parsed.behavior !== 'allow' && parsed.behavior !== 'deny') {
|
|
1602
|
+
sendJson(res, 400, { ok: false, error: 'behavior must be "allow" or "deny"' });
|
|
1603
|
+
return;
|
|
1604
|
+
}
|
|
1605
|
+
const { approvalBus } = await import('../core/approval-bus.js');
|
|
1606
|
+
const decision = parsed.behavior === 'allow'
|
|
1607
|
+
? { behavior: 'allow', autoAllowFurther: parsed.autoAllowFurther === true }
|
|
1608
|
+
: { behavior: 'deny', message: parsed.message || 'denied via dashboard' };
|
|
1609
|
+
const resolved = approvalBus.resolvePendingByReqId(reqId, decision);
|
|
1610
|
+
if (!resolved) {
|
|
1611
|
+
sendJson(res, 404, { ok: false, error: 'Approval not pending (may have already resolved or timed out)' });
|
|
1612
|
+
return;
|
|
1613
|
+
}
|
|
1614
|
+
sendJson(res, 200, { ok: true, resolved });
|
|
1615
|
+
}
|
|
1616
|
+
catch (err) {
|
|
1617
|
+
sendJson(res, 500, { ok: false, error: err instanceof Error ? err.message : String(err) });
|
|
1618
|
+
}
|
|
1619
|
+
}
|
|
1620
|
+
/** Hard cap on file content we'll ship over the wire — avoids OOM and keeps
|
|
1621
|
+
* the browser responsive. Matches the soft limit our log-tail endpoints use. */
|
|
1622
|
+
const WORKSPACE_FILE_MAX_BYTES = 1 * 1024 * 1024;
|
|
1623
|
+
/** Bytes scanned for a binary heuristic. Null byte in this window → binary. */
|
|
1624
|
+
const BINARY_PROBE_BYTES = 8 * 1024;
|
|
1625
|
+
/**
|
|
1626
|
+
* Resolve a user-supplied path under a workspace base and verify that the
|
|
1627
|
+
* *real* filesystem path (after symlink resolution) still lives under `base`.
|
|
1628
|
+
* Returns the validated real path, or null + sends an error response.
|
|
1629
|
+
*/
|
|
1630
|
+
async function validateWorkspacePath(base, userPath, res) {
|
|
1631
|
+
const target = resolvePath(base, userPath);
|
|
1632
|
+
if (target !== base && !target.startsWith(base + pathSep)) {
|
|
1633
|
+
sendJson(res, 400, { error: 'Path escapes workspace root' });
|
|
1634
|
+
return null;
|
|
1635
|
+
}
|
|
1636
|
+
let real;
|
|
1637
|
+
try {
|
|
1638
|
+
real = await realpath(target);
|
|
1639
|
+
}
|
|
1640
|
+
catch (err) {
|
|
1641
|
+
const e = err;
|
|
1642
|
+
if (e.code === 'ENOENT') {
|
|
1643
|
+
sendJson(res, 404, { error: 'Not found', path: relativePath(base, target) });
|
|
1644
|
+
return null;
|
|
1645
|
+
}
|
|
1646
|
+
throw err;
|
|
1647
|
+
}
|
|
1648
|
+
const realBase = await realpath(base);
|
|
1649
|
+
if (real !== realBase && !real.startsWith(realBase + pathSep)) {
|
|
1650
|
+
sendJson(res, 403, { error: 'Path resolves outside workspace root (symlink escape)' });
|
|
1651
|
+
return null;
|
|
1652
|
+
}
|
|
1653
|
+
return real;
|
|
1654
|
+
}
|
|
1655
|
+
/**
|
|
1656
|
+
* Read-only view of `~/.im-hub-workspaces/<agent>/`. The Files tab in /tasks
|
|
1657
|
+
* uses it to inspect what an IM-context agent is reading and writing into its
|
|
1658
|
+
* pinned workspace (CLAUDE.md, AGENTS.md, scratch notes, etc.).
|
|
1659
|
+
*
|
|
1660
|
+
* Query params:
|
|
1661
|
+
* agent — required, MUST match a registered agent name. We reject anything
|
|
1662
|
+
* else to keep `agent` from sneaking traversal segments past the
|
|
1663
|
+
* join (e.g. `?agent=../../etc`).
|
|
1664
|
+
* path — optional relative path under the agent's workspace. Defaults to ''.
|
|
1665
|
+
*
|
|
1666
|
+
* Response shape:
|
|
1667
|
+
* { type:'dir', entries:[{name,isDir,size,mtime}] }
|
|
1668
|
+
* { type:'file', content, size, encoding:'utf-8'|'base64', truncated }
|
|
1669
|
+
*
|
|
1670
|
+
* Path-traversal defense: after `resolvePath(base, userPath)` we verify the
|
|
1671
|
+
* result is exactly `base` or starts with `base + sep`. A `..`-laden path
|
|
1672
|
+
* collapses outside the base and gets rejected before any read.
|
|
1673
|
+
*
|
|
1674
|
+
* Edits / writes intentionally not exposed; ops use plain ssh.
|
|
1675
|
+
*/
|
|
1676
|
+
async function handleWorkspaceFiles(_req, res, url) {
|
|
1677
|
+
try {
|
|
1678
|
+
const agent = url.searchParams.get('agent') || '';
|
|
1679
|
+
const userPath = url.searchParams.get('path') || '';
|
|
1680
|
+
if (!agent) {
|
|
1681
|
+
sendJson(res, 400, { error: 'Missing required ?agent=' });
|
|
1682
|
+
return;
|
|
1683
|
+
}
|
|
1684
|
+
// Whitelist agent against the registry. Even an empty registry won't
|
|
1685
|
+
// expose anything because a non-registered name fails this check.
|
|
1686
|
+
const known = new Set(registry.listAgents());
|
|
1687
|
+
if (!known.has(agent)) {
|
|
1688
|
+
sendJson(res, 404, { error: `Unknown agent "${agent}"` });
|
|
1689
|
+
return;
|
|
1690
|
+
}
|
|
1691
|
+
const { defaultAgentCwd } = await import('../core/agent-cwd.js');
|
|
1692
|
+
const base = resolvePath(defaultAgentCwd(agent));
|
|
1693
|
+
const target = await validateWorkspacePath(base, userPath, res);
|
|
1694
|
+
if (!target)
|
|
1695
|
+
return;
|
|
1696
|
+
const st = await stat(target);
|
|
1697
|
+
if (st.isDirectory()) {
|
|
1698
|
+
const realBase = await realpath(base);
|
|
1699
|
+
const names = await readdir(target);
|
|
1700
|
+
const entries = await Promise.all(names.map(async (name) => {
|
|
1701
|
+
try {
|
|
1702
|
+
const childPath = join(target, name);
|
|
1703
|
+
let childReal;
|
|
1704
|
+
try {
|
|
1705
|
+
childReal = await realpath(childPath);
|
|
1706
|
+
}
|
|
1707
|
+
catch {
|
|
1708
|
+
return { name, isDir: false, size: null, mtime: null, broken: true };
|
|
1709
|
+
}
|
|
1710
|
+
if (childReal !== realBase && !childReal.startsWith(realBase + pathSep)) {
|
|
1711
|
+
return { name, isDir: false, size: null, mtime: null, symlink_escape: true };
|
|
1712
|
+
}
|
|
1713
|
+
const sub = await stat(childPath);
|
|
1714
|
+
return {
|
|
1715
|
+
name,
|
|
1716
|
+
isDir: sub.isDirectory(),
|
|
1717
|
+
size: sub.isDirectory() ? null : sub.size,
|
|
1718
|
+
mtime: sub.mtime.toISOString(),
|
|
1719
|
+
};
|
|
1720
|
+
}
|
|
1721
|
+
catch {
|
|
1722
|
+
return { name, isDir: false, size: null, mtime: null, broken: true };
|
|
1723
|
+
}
|
|
1724
|
+
}));
|
|
1725
|
+
entries.sort((a, b) => {
|
|
1726
|
+
if (a.isDir !== b.isDir)
|
|
1727
|
+
return a.isDir ? -1 : 1;
|
|
1728
|
+
return a.name.localeCompare(b.name, undefined, { sensitivity: 'base' });
|
|
1729
|
+
});
|
|
1730
|
+
sendJson(res, 200, {
|
|
1731
|
+
type: 'dir',
|
|
1732
|
+
agent,
|
|
1733
|
+
path: relativePath(base, target),
|
|
1734
|
+
base,
|
|
1735
|
+
entries,
|
|
1736
|
+
});
|
|
1737
|
+
return;
|
|
1738
|
+
}
|
|
1739
|
+
if (!st.isFile()) {
|
|
1740
|
+
sendJson(res, 400, { error: 'Not a regular file' });
|
|
1741
|
+
return;
|
|
1742
|
+
}
|
|
1743
|
+
const truncated = st.size > WORKSPACE_FILE_MAX_BYTES;
|
|
1744
|
+
let buf;
|
|
1745
|
+
if (truncated) {
|
|
1746
|
+
const fd = await open(target, 'r');
|
|
1747
|
+
try {
|
|
1748
|
+
buf = Buffer.alloc(WORKSPACE_FILE_MAX_BYTES);
|
|
1749
|
+
const { bytesRead } = await fd.read(buf, 0, WORKSPACE_FILE_MAX_BYTES, 0);
|
|
1750
|
+
buf = buf.subarray(0, bytesRead);
|
|
1751
|
+
}
|
|
1752
|
+
finally {
|
|
1753
|
+
await fd.close();
|
|
1754
|
+
}
|
|
1755
|
+
}
|
|
1756
|
+
else {
|
|
1757
|
+
buf = await readFile(target);
|
|
1758
|
+
}
|
|
1759
|
+
const probe = buf.subarray(0, Math.min(buf.length, BINARY_PROBE_BYTES));
|
|
1760
|
+
const isBinary = probe.includes(0);
|
|
1761
|
+
sendJson(res, 200, {
|
|
1762
|
+
type: 'file',
|
|
1763
|
+
agent,
|
|
1764
|
+
path: relativePath(base, target),
|
|
1765
|
+
size: st.size,
|
|
1766
|
+
mtime: st.mtime.toISOString(),
|
|
1767
|
+
encoding: isBinary ? 'base64' : 'utf-8',
|
|
1768
|
+
content: isBinary ? buf.toString('base64') : buf.toString('utf-8'),
|
|
1769
|
+
truncated,
|
|
1770
|
+
});
|
|
1771
|
+
}
|
|
1772
|
+
catch (err) {
|
|
1773
|
+
sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) });
|
|
1774
|
+
}
|
|
1775
|
+
}
|
|
1776
|
+
/**
|
|
1777
|
+
* Inline edit of a workspace file. UI use-case: annotate the agent's
|
|
1778
|
+
* CLAUDE.md / AGENTS.md / scratch notes from the dashboard without
|
|
1779
|
+
* shelling into the host.
|
|
1780
|
+
*
|
|
1781
|
+
* Body: { content: string } — UTF-8 text only (binary writes refused).
|
|
1782
|
+
* Response: { ok: true, size, mtime } on success.
|
|
1783
|
+
*
|
|
1784
|
+
* Safety:
|
|
1785
|
+
* - Same agent + path traversal guards as the GET handler.
|
|
1786
|
+
* - 1 MiB hard cap on `content` (matches the read cap so a roundtrip
|
|
1787
|
+
* edit can't grow a file beyond what the read can show).
|
|
1788
|
+
* - Atomic write: stage to `<target>.tmp.<rand>` then `rename` so a
|
|
1789
|
+
* crash mid-write can't leave a half-truncated file. The .tmp file
|
|
1790
|
+
* is unlinked on any error path.
|
|
1791
|
+
* - Refuses to overwrite a directory; refuses to create a parent dir
|
|
1792
|
+
* that doesn't exist (no implicit mkdir-p — keeps surprises out).
|
|
1793
|
+
*/
|
|
1794
|
+
async function handleWorkspaceFileWrite(req, res, url) {
|
|
1795
|
+
try {
|
|
1796
|
+
const agent = url.searchParams.get('agent') || '';
|
|
1797
|
+
const userPath = url.searchParams.get('path') || '';
|
|
1798
|
+
if (!agent) {
|
|
1799
|
+
sendJson(res, 400, { error: 'Missing required ?agent=' });
|
|
1800
|
+
return;
|
|
1801
|
+
}
|
|
1802
|
+
if (!userPath) {
|
|
1803
|
+
sendJson(res, 400, { error: 'Missing required ?path=' });
|
|
1804
|
+
return;
|
|
1805
|
+
}
|
|
1806
|
+
const known = new Set(registry.listAgents());
|
|
1807
|
+
if (!known.has(agent)) {
|
|
1808
|
+
sendJson(res, 404, { error: `Unknown agent "${agent}"` });
|
|
1809
|
+
return;
|
|
1810
|
+
}
|
|
1811
|
+
const { defaultAgentCwd } = await import('../core/agent-cwd.js');
|
|
1812
|
+
const base = resolvePath(defaultAgentCwd(agent));
|
|
1813
|
+
// For writes, the file may not yet exist so realpath would fail.
|
|
1814
|
+
// Validate the parent directory via realpath and verify the logical
|
|
1815
|
+
// path stays under base as a prefix check.
|
|
1816
|
+
const logicalTarget = resolvePath(base, userPath);
|
|
1817
|
+
if (logicalTarget !== base && !logicalTarget.startsWith(base + pathSep)) {
|
|
1818
|
+
sendJson(res, 400, { error: 'Path escapes workspace root' });
|
|
1819
|
+
return;
|
|
1820
|
+
}
|
|
1821
|
+
if (logicalTarget === base) {
|
|
1822
|
+
sendJson(res, 400, { error: 'Cannot overwrite workspace root' });
|
|
1823
|
+
return;
|
|
1824
|
+
}
|
|
1825
|
+
// Verify the parent directory resolves inside the workspace.
|
|
1826
|
+
const parentDir = dirname(logicalTarget);
|
|
1827
|
+
try {
|
|
1828
|
+
const realParent = await realpath(parentDir);
|
|
1829
|
+
const realBase = await realpath(base);
|
|
1830
|
+
if (realParent !== realBase && !realParent.startsWith(realBase + pathSep)) {
|
|
1831
|
+
sendJson(res, 403, { error: 'Parent path resolves outside workspace root (symlink escape)' });
|
|
1832
|
+
return;
|
|
1833
|
+
}
|
|
1834
|
+
}
|
|
1835
|
+
catch (err) {
|
|
1836
|
+
const e = err;
|
|
1837
|
+
if (e.code === 'ENOENT') {
|
|
1838
|
+
sendJson(res, 400, { error: 'Parent directory does not exist' });
|
|
1839
|
+
return;
|
|
1840
|
+
}
|
|
1841
|
+
throw err;
|
|
1842
|
+
}
|
|
1843
|
+
// If the file itself already exists, ensure its real path is inside too.
|
|
1844
|
+
try {
|
|
1845
|
+
const realTarget = await realpath(logicalTarget);
|
|
1846
|
+
const realBase = await realpath(base);
|
|
1847
|
+
if (realTarget !== realBase && !realTarget.startsWith(realBase + pathSep)) {
|
|
1848
|
+
sendJson(res, 403, { error: 'Path resolves outside workspace root (symlink escape)' });
|
|
1849
|
+
return;
|
|
1850
|
+
}
|
|
1851
|
+
}
|
|
1852
|
+
catch {
|
|
1853
|
+
// ENOENT is fine — the file doesn't exist yet
|
|
1854
|
+
}
|
|
1855
|
+
const target = logicalTarget;
|
|
1856
|
+
const body = await readBody(req, res);
|
|
1857
|
+
let parsed;
|
|
1858
|
+
try {
|
|
1859
|
+
parsed = JSON.parse(body);
|
|
1860
|
+
}
|
|
1861
|
+
catch {
|
|
1862
|
+
sendJson(res, 400, { error: 'Invalid JSON body' });
|
|
1863
|
+
return;
|
|
1864
|
+
}
|
|
1865
|
+
if (typeof parsed.content !== 'string') {
|
|
1866
|
+
sendJson(res, 400, { error: 'content must be a string' });
|
|
1867
|
+
return;
|
|
1868
|
+
}
|
|
1869
|
+
const content = parsed.content;
|
|
1870
|
+
// Encode early so the size check is on bytes, not chars (a single
|
|
1871
|
+
// CJK char is 3 bytes UTF-8 — char-count would lie about file size).
|
|
1872
|
+
const buf = Buffer.from(content, 'utf-8');
|
|
1873
|
+
if (buf.length > WORKSPACE_FILE_MAX_BYTES) {
|
|
1874
|
+
sendJson(res, 413, { error: 'Content exceeds 1 MiB cap' });
|
|
1875
|
+
return;
|
|
1876
|
+
}
|
|
1877
|
+
// Reject content that contains a NUL byte. UTF-8 text never has one;
|
|
1878
|
+
// accidental binary upload here would corrupt the editor on next read.
|
|
1879
|
+
if (buf.includes(0)) {
|
|
1880
|
+
sendJson(res, 400, { error: 'NUL byte in content — only UTF-8 text accepted' });
|
|
1881
|
+
return;
|
|
1882
|
+
}
|
|
1883
|
+
// Existing-target guards: must not be a directory; parent dir must
|
|
1884
|
+
// exist (no implicit mkdir-p — too easy to typo a deep path and
|
|
1885
|
+
// create a hidden mess).
|
|
1886
|
+
try {
|
|
1887
|
+
const st = await stat(target);
|
|
1888
|
+
if (st.isDirectory()) {
|
|
1889
|
+
sendJson(res, 400, { error: 'Target is a directory' });
|
|
1890
|
+
return;
|
|
1891
|
+
}
|
|
1892
|
+
}
|
|
1893
|
+
catch (err) {
|
|
1894
|
+
const e = err;
|
|
1895
|
+
if (e.code !== 'ENOENT')
|
|
1896
|
+
throw err;
|
|
1897
|
+
// Doesn't exist yet — that's fine for new-file writes, but the
|
|
1898
|
+
// parent directory must be present.
|
|
1899
|
+
const parent = dirname(target);
|
|
1900
|
+
try {
|
|
1901
|
+
const ps = await stat(parent);
|
|
1902
|
+
if (!ps.isDirectory()) {
|
|
1903
|
+
sendJson(res, 400, { error: 'Parent path is not a directory' });
|
|
1904
|
+
return;
|
|
1905
|
+
}
|
|
1906
|
+
}
|
|
1907
|
+
catch {
|
|
1908
|
+
sendJson(res, 400, { error: 'Parent directory does not exist' });
|
|
1909
|
+
return;
|
|
1910
|
+
}
|
|
1911
|
+
}
|
|
1912
|
+
// Atomic write. crypto.randomBytes is the cheapest unique suffix and
|
|
1913
|
+
// already imported up top.
|
|
1914
|
+
const tmp = `${target}.tmp.${randomBytes(6).toString('hex')}`;
|
|
1915
|
+
try {
|
|
1916
|
+
await writeFile(tmp, buf, { mode: 0o600 });
|
|
1917
|
+
await rename(tmp, target);
|
|
1918
|
+
}
|
|
1919
|
+
catch (err) {
|
|
1920
|
+
try {
|
|
1921
|
+
await unlink(tmp);
|
|
1922
|
+
}
|
|
1923
|
+
catch { /* tmp may not have been created */ }
|
|
1924
|
+
throw err;
|
|
1925
|
+
}
|
|
1926
|
+
const finalSt = await stat(target);
|
|
1927
|
+
sendJson(res, 200, {
|
|
1928
|
+
ok: true,
|
|
1929
|
+
agent,
|
|
1930
|
+
path: relativePath(base, target),
|
|
1931
|
+
size: finalSt.size,
|
|
1932
|
+
mtime: finalSt.mtime.toISOString(),
|
|
1933
|
+
});
|
|
1934
|
+
}
|
|
1935
|
+
catch (err) {
|
|
1936
|
+
const e = err;
|
|
1937
|
+
if (e?.handled)
|
|
1938
|
+
return;
|
|
1939
|
+
if (!res.headersSent)
|
|
1940
|
+
sendJson(res, e?.statusCode || 500, { error: e instanceof Error ? e.message : String(err) });
|
|
1941
|
+
}
|
|
1942
|
+
}
|
|
1943
|
+
/**
|
|
1944
|
+
* Run cancel/run across an array of job ids in one request. Saves N round
|
|
1945
|
+
* trips when the user multi-selects a long list in the Jobs tab.
|
|
1946
|
+
*
|
|
1947
|
+
* Body: { ids: number[] }
|
|
1948
|
+
* Response: { results: Array<{ id, ok, error?, traceId? }> }
|
|
1949
|
+
*
|
|
1950
|
+
* Per-id failures don't fail the whole request — each entry carries its own
|
|
1951
|
+
* status so the UI can mark partial success.
|
|
1952
|
+
*/
|
|
1953
|
+
async function handleBatchJob(req, res, action, defaultAgent) {
|
|
1954
|
+
try {
|
|
1955
|
+
const body = await readBody(req, res);
|
|
1956
|
+
let parsed;
|
|
1957
|
+
try {
|
|
1958
|
+
parsed = JSON.parse(body);
|
|
1959
|
+
}
|
|
1960
|
+
catch {
|
|
1961
|
+
sendJson(res, 400, { error: 'Invalid JSON body' });
|
|
1962
|
+
return;
|
|
1963
|
+
}
|
|
1964
|
+
if (!Array.isArray(parsed.ids) || parsed.ids.length === 0) {
|
|
1965
|
+
sendJson(res, 400, { error: 'ids must be a non-empty array of numbers' });
|
|
1966
|
+
return;
|
|
1967
|
+
}
|
|
1968
|
+
// Cap so a runaway client can't queue thousands of spawns at once. Same
|
|
1969
|
+
// ceiling we use elsewhere for batched ops.
|
|
1970
|
+
if (parsed.ids.length > 100) {
|
|
1971
|
+
sendJson(res, 400, { error: 'Maximum 100 ids per batch' });
|
|
1972
|
+
return;
|
|
1973
|
+
}
|
|
1974
|
+
const ids = parsed.ids
|
|
1975
|
+
.map((x) => (typeof x === 'number' ? x : parseInt(String(x), 10)))
|
|
1976
|
+
.filter((n) => Number.isFinite(n) && n > 0);
|
|
1977
|
+
if (ids.length === 0) {
|
|
1978
|
+
sendJson(res, 400, { error: 'No valid ids in array' });
|
|
1979
|
+
return;
|
|
1980
|
+
}
|
|
1981
|
+
const { getJob, cancelJob, runJob } = await import('../core/job-board.js');
|
|
1982
|
+
const { AgentBase } = await import('../core/agent-base.js');
|
|
1983
|
+
const results = await Promise.all(ids.map(async (id) => {
|
|
1984
|
+
try {
|
|
1985
|
+
if (action === 'cancel') {
|
|
1986
|
+
return { id, ok: cancelJob(id) };
|
|
1987
|
+
}
|
|
1988
|
+
const job = getJob(id);
|
|
1989
|
+
if (!job)
|
|
1990
|
+
return { id, ok: false, error: 'Job not found' };
|
|
1991
|
+
const agent = registry.findAgent(job.agent);
|
|
1992
|
+
if (!agent)
|
|
1993
|
+
return { id, ok: false, error: `Agent "${job.agent}" not registered` };
|
|
1994
|
+
const traceId = generateTraceId();
|
|
1995
|
+
const log = createLogger({ traceId, platform: 'web', component: 'job-run-batch' });
|
|
1996
|
+
// Same fire-and-forget pattern as handleRunJob — the dashboard
|
|
1997
|
+
// streams status from /events / /api/jobs.
|
|
1998
|
+
void runJob(id, async function* (j, _logger, signal) {
|
|
1999
|
+
if (agent instanceof AgentBase) {
|
|
2000
|
+
const text = await agent.spawnAndCollect(j.prompt, signal);
|
|
2001
|
+
if (text)
|
|
2002
|
+
yield text;
|
|
2003
|
+
}
|
|
2004
|
+
else {
|
|
2005
|
+
for await (const chunk of agent.sendPrompt(`web-job-${j.id}`, j.prompt, [])) {
|
|
2006
|
+
if (signal.aborted)
|
|
2007
|
+
break;
|
|
2008
|
+
yield chunk;
|
|
2009
|
+
}
|
|
2010
|
+
}
|
|
2011
|
+
}, log).catch(() => { });
|
|
2012
|
+
return { id, ok: true, traceId };
|
|
2013
|
+
}
|
|
2014
|
+
catch (err) {
|
|
2015
|
+
return { id, ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
2016
|
+
}
|
|
2017
|
+
}));
|
|
2018
|
+
// defaultAgent isn't used directly — runJob reads job.agent — but we
|
|
2019
|
+
// accept it to keep the call-site symmetric with handleInvoke.
|
|
2020
|
+
void defaultAgent;
|
|
2021
|
+
sendJson(res, 200, { results });
|
|
2022
|
+
}
|
|
2023
|
+
catch (err) {
|
|
2024
|
+
const e = err;
|
|
2025
|
+
if (e?.handled)
|
|
2026
|
+
return;
|
|
2027
|
+
if (!res.headersSent)
|
|
2028
|
+
sendJson(res, e?.statusCode || 500, { error: e instanceof Error ? e.message : String(err) });
|
|
2029
|
+
}
|
|
2030
|
+
}
|
|
2031
|
+
/**
|
|
2032
|
+
* Server-Sent Events stream for real-time dashboard updates. Subscribes
|
|
2033
|
+
* to the in-process event-bus and forwards every event as an SSE frame.
|
|
2034
|
+
*
|
|
2035
|
+
* On connect we replay the last ~200 events from the bus's recent ring
|
|
2036
|
+
* so a freshly-opened tab doesn't have to wait for the next event to
|
|
2037
|
+
* have any context.
|
|
2038
|
+
*
|
|
2039
|
+
* Heartbeats every 25 s — Node's default keepalive isn't enough for some
|
|
2040
|
+
* proxies (nginx default is 60s idle close, browsers reconnect EventSource
|
|
2041
|
+
* automatically but we'd rather avoid the churn).
|
|
2042
|
+
*
|
|
2043
|
+
* Token-gated like every other /api endpoint via the upstream guard.
|
|
2044
|
+
*/
|
|
2045
|
+
async function handleEventsSSE(req, res) {
|
|
2046
|
+
const { eventBus } = await import('../core/event-bus.js');
|
|
2047
|
+
res.writeHead(200, {
|
|
2048
|
+
'Content-Type': 'text/event-stream; charset=utf-8',
|
|
2049
|
+
'Cache-Control': 'no-cache, no-transform',
|
|
2050
|
+
'Connection': 'keep-alive',
|
|
2051
|
+
'X-Accel-Buffering': 'no',
|
|
2052
|
+
});
|
|
2053
|
+
// Tell the client what we count as "now" so it can disambiguate replay
|
|
2054
|
+
// from live events.
|
|
2055
|
+
res.write(`event: hello\ndata: ${JSON.stringify({ ts: new Date().toISOString() })}\n\n`);
|
|
2056
|
+
// Replay recent buffer.
|
|
2057
|
+
for (const e of eventBus.getRecent()) {
|
|
2058
|
+
res.write(`event: ${e.type}\ndata: ${JSON.stringify(e)}\n\n`);
|
|
2059
|
+
}
|
|
2060
|
+
const onEvent = (e) => {
|
|
2061
|
+
try {
|
|
2062
|
+
res.write(`event: ${e.type}\ndata: ${JSON.stringify(e)}\n\n`);
|
|
2063
|
+
}
|
|
2064
|
+
catch {
|
|
2065
|
+
// Likely socket closed. The 'close' handler below will clean up;
|
|
2066
|
+
// swallow here so a downstream listener error doesn't propagate.
|
|
2067
|
+
}
|
|
2068
|
+
};
|
|
2069
|
+
eventBus.on('event', onEvent);
|
|
2070
|
+
// Periodic keepalive comment so proxies don't close idle connections.
|
|
2071
|
+
// SSE comments start with ':' and are ignored by EventSource clients.
|
|
2072
|
+
const heartbeat = setInterval(() => {
|
|
2073
|
+
try {
|
|
2074
|
+
res.write(': keepalive\n\n');
|
|
2075
|
+
}
|
|
2076
|
+
catch { /* socket closed */ }
|
|
2077
|
+
}, 25_000);
|
|
2078
|
+
if (typeof heartbeat === 'object' && heartbeat && 'unref' in heartbeat) {
|
|
2079
|
+
heartbeat.unref();
|
|
2080
|
+
}
|
|
2081
|
+
const cleanup = () => {
|
|
2082
|
+
clearInterval(heartbeat);
|
|
2083
|
+
eventBus.off('event', onEvent);
|
|
2084
|
+
};
|
|
2085
|
+
req.on('close', cleanup);
|
|
2086
|
+
req.on('error', cleanup);
|
|
2087
|
+
}
|
|
2088
|
+
async function handleAcpDiscover(req, res) {
|
|
2089
|
+
try {
|
|
2090
|
+
const body = await readBody(req, res);
|
|
2091
|
+
const { baseUrl, register } = JSON.parse(body);
|
|
2092
|
+
if (!baseUrl) {
|
|
2093
|
+
sendJson(res, 400, { error: 'Missing baseUrl' });
|
|
2094
|
+
return;
|
|
2095
|
+
}
|
|
2096
|
+
const { discoverAgents } = await import('../plugins/agents/acp/discovery.js');
|
|
2097
|
+
const result = await discoverAgents(baseUrl);
|
|
2098
|
+
if (register) {
|
|
2099
|
+
await registry.loadACPAgents(result.agents);
|
|
2100
|
+
}
|
|
2101
|
+
sendJson(res, 200, { ok: true, baseUrl: result.baseUrl, agents: result.agents });
|
|
2102
|
+
}
|
|
2103
|
+
catch (err) {
|
|
2104
|
+
const e = err;
|
|
2105
|
+
if (e?.handled)
|
|
2106
|
+
return;
|
|
2107
|
+
const status = e?.statusCode || 500;
|
|
2108
|
+
const msg = e instanceof Error ? e.message : String(err);
|
|
2109
|
+
if (!res.headersSent)
|
|
2110
|
+
sendJson(res, status, { ok: false, error: msg });
|
|
2111
|
+
}
|
|
2112
|
+
}
|
|
2113
|
+
async function handleAcpTest(req, res) {
|
|
2114
|
+
try {
|
|
2115
|
+
const body = await readBody(req, res);
|
|
2116
|
+
// M11: bare JSON.parse threw a SyntaxError that bubbled out into the
|
|
2117
|
+
// outer catch and surfaced as a "500-ish 400" with the parser's raw
|
|
2118
|
+
// message. Validate explicitly so malformed bodies get a clean 400.
|
|
2119
|
+
let parsed;
|
|
2120
|
+
try {
|
|
2121
|
+
parsed = JSON.parse(body);
|
|
2122
|
+
}
|
|
2123
|
+
catch {
|
|
2124
|
+
sendJson(res, 400, { ok: false, error: 'Invalid JSON body' });
|
|
2125
|
+
return;
|
|
2126
|
+
}
|
|
2127
|
+
const { endpoint, auth } = parsed;
|
|
2128
|
+
if (!endpoint || typeof endpoint !== 'string') {
|
|
2129
|
+
sendJson(res, 400, { ok: false, error: 'Missing or invalid "endpoint"' });
|
|
2130
|
+
return;
|
|
2131
|
+
}
|
|
2132
|
+
// Dynamic import to avoid circular deps
|
|
2133
|
+
const { ACPClient } = await import('../plugins/agents/acp/acp-client.js');
|
|
2134
|
+
const client = new ACPClient({ name: 'test', endpoint, auth: auth });
|
|
2135
|
+
const manifest = await client.fetchManifest();
|
|
2136
|
+
sendJson(res, 200, {
|
|
2137
|
+
ok: true,
|
|
2138
|
+
name: manifest.name,
|
|
2139
|
+
description: manifest.description,
|
|
2140
|
+
});
|
|
2141
|
+
}
|
|
2142
|
+
catch (err) {
|
|
2143
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
2144
|
+
sendJson(res, 400, { ok: false, error: msg });
|
|
2145
|
+
}
|
|
2146
|
+
}
|
|
2147
|
+
// ============================================
|
|
2148
|
+
// Helpers
|
|
2149
|
+
// ============================================
|
|
2150
|
+
async function getAgentStatuses() {
|
|
2151
|
+
const agents = registry.listAgents();
|
|
2152
|
+
const status = {};
|
|
2153
|
+
await Promise.all(agents.map(async (name) => {
|
|
2154
|
+
const agent = registry.findAgent(name);
|
|
2155
|
+
if (agent) {
|
|
2156
|
+
try {
|
|
2157
|
+
status[name] = await agent.isAvailable();
|
|
2158
|
+
}
|
|
2159
|
+
catch {
|
|
2160
|
+
status[name] = false;
|
|
2161
|
+
}
|
|
2162
|
+
}
|
|
2163
|
+
}));
|
|
2164
|
+
return status;
|
|
2165
|
+
}
|
|
2166
|
+
function mask(value) {
|
|
2167
|
+
if (!value)
|
|
2168
|
+
return '';
|
|
2169
|
+
if (value.length <= 4)
|
|
2170
|
+
return '****';
|
|
2171
|
+
return `${value.slice(0, 2)}****${value.slice(-2)}`;
|
|
2172
|
+
}
|
|
2173
|
+
/** Hard cap on inbound JSON bodies for the Web REST API. */
|
|
2174
|
+
const MAX_API_BODY_BYTES = 1 * 1024 * 1024; // 1 MiB
|
|
2175
|
+
function readBody(req, res) {
|
|
2176
|
+
return new Promise((resolve, reject) => {
|
|
2177
|
+
const chunks = [];
|
|
2178
|
+
let total = 0;
|
|
2179
|
+
let aborted = false;
|
|
2180
|
+
req.on('data', (chunk) => {
|
|
2181
|
+
if (aborted)
|
|
2182
|
+
return;
|
|
2183
|
+
total += chunk.length;
|
|
2184
|
+
if (total > MAX_API_BODY_BYTES) {
|
|
2185
|
+
aborted = true;
|
|
2186
|
+
if (res && !res.headersSent) {
|
|
2187
|
+
sendJson(res, 413, { error: 'Request body too large' });
|
|
2188
|
+
}
|
|
2189
|
+
const err = new Error('Request body too large');
|
|
2190
|
+
err.statusCode = 413;
|
|
2191
|
+
err.handled = !!res;
|
|
2192
|
+
reject(err);
|
|
2193
|
+
return;
|
|
2194
|
+
}
|
|
2195
|
+
chunks.push(chunk);
|
|
2196
|
+
});
|
|
2197
|
+
req.on('end', () => {
|
|
2198
|
+
if (aborted)
|
|
2199
|
+
return;
|
|
2200
|
+
resolve(Buffer.concat(chunks).toString('utf-8'));
|
|
2201
|
+
});
|
|
2202
|
+
req.on('error', (err) => {
|
|
2203
|
+
if (!aborted)
|
|
2204
|
+
reject(err);
|
|
2205
|
+
});
|
|
2206
|
+
});
|
|
2207
|
+
}
|
|
2208
|
+
function sendJson(res, status, data) {
|
|
2209
|
+
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
2210
|
+
res.end(JSON.stringify(data));
|
|
2211
|
+
}
|
|
2212
|
+
// ============================================
|
|
2213
|
+
// WebSocket chat handlers
|
|
2214
|
+
// ============================================
|
|
2215
|
+
/**
|
|
2216
|
+
* Handle a message from a web client
|
|
2217
|
+
*/
|
|
2218
|
+
async function handleClientMessage(client, msg, defaultAgent) {
|
|
2219
|
+
const { ws, id: clientId } = client;
|
|
2220
|
+
switch (msg.type) {
|
|
2221
|
+
case 'message': {
|
|
2222
|
+
if (!msg.text?.trim())
|
|
2223
|
+
return;
|
|
2224
|
+
const text = msg.text.trim();
|
|
2225
|
+
const traceId = generateTraceId();
|
|
2226
|
+
const logger = createLogger({ traceId, platform: 'web', component: 'web' });
|
|
2227
|
+
if (msg.agent && msg.agent !== client.agent) {
|
|
2228
|
+
client.agent = msg.agent;
|
|
2229
|
+
}
|
|
2230
|
+
const parsed = parseMessage(text);
|
|
2231
|
+
try {
|
|
2232
|
+
const routeCtx = {
|
|
2233
|
+
threadId: clientId,
|
|
2234
|
+
channelId: 'web',
|
|
2235
|
+
platform: 'web',
|
|
2236
|
+
defaultAgent: client.agent,
|
|
2237
|
+
traceId,
|
|
2238
|
+
logger,
|
|
2239
|
+
userId: `web:${clientId}`,
|
|
2240
|
+
};
|
|
2241
|
+
logger.info({ event: 'message.received', text: text.substring(0, 120) });
|
|
2242
|
+
const result = await routeMessage(parsed, routeCtx);
|
|
2243
|
+
// String response (built-in commands, errors)
|
|
2244
|
+
if (typeof result === 'string') {
|
|
2245
|
+
sendToClient(ws, { type: 'done', text: result });
|
|
2246
|
+
return;
|
|
2247
|
+
}
|
|
2248
|
+
// Streaming response (agent responses)
|
|
2249
|
+
let fullText = '';
|
|
2250
|
+
for await (const chunk of result) {
|
|
2251
|
+
fullText += chunk;
|
|
2252
|
+
// L1: defer when the per-socket send buffer is full. Without
|
|
2253
|
+
// this, a slow client lets the chunk producer keep allocating
|
|
2254
|
+
// frames into the kernel + ws-internal queue, which can grow
|
|
2255
|
+
// to GBs for a long agent response.
|
|
2256
|
+
await awaitWsDrain(ws);
|
|
2257
|
+
if (ws.readyState !== ws.OPEN)
|
|
2258
|
+
break;
|
|
2259
|
+
sendToClient(ws, { type: 'chunk', text: chunk });
|
|
2260
|
+
}
|
|
2261
|
+
sendToClient(ws, { type: 'done', text: fullText });
|
|
2262
|
+
}
|
|
2263
|
+
catch (err) {
|
|
2264
|
+
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
2265
|
+
const stack = err instanceof Error ? err.stack : undefined;
|
|
2266
|
+
logger.error({ event: 'web.handle.error', err: errorMsg, stack }, 'Error handling client message');
|
|
2267
|
+
sendToClient(ws, { type: 'error', message: `Agent error: ${errorMsg}` });
|
|
2268
|
+
}
|
|
2269
|
+
break;
|
|
2270
|
+
}
|
|
2271
|
+
case 'switch-agent': {
|
|
2272
|
+
if (!msg.agent)
|
|
2273
|
+
return;
|
|
2274
|
+
const agent = registry.findAgent(msg.agent);
|
|
2275
|
+
if (!agent) {
|
|
2276
|
+
sendToClient(ws, { type: 'error', message: `Agent "${msg.agent}" not found` });
|
|
2277
|
+
return;
|
|
2278
|
+
}
|
|
2279
|
+
if (!(await isAgentAvailableCached(agent.name))) {
|
|
2280
|
+
sendToClient(ws, { type: 'error', message: `Agent "${agent.name}" is not available` });
|
|
2281
|
+
return;
|
|
2282
|
+
}
|
|
2283
|
+
client.agent = agent.name;
|
|
2284
|
+
await sessionManager.switchAgent('web', 'web', clientId, agent.name);
|
|
2285
|
+
sendToClient(ws, { type: 'agent-switched', agent: agent.name });
|
|
2286
|
+
break;
|
|
2287
|
+
}
|
|
2288
|
+
case 'get-agents': {
|
|
2289
|
+
const agents = registry.listAgents();
|
|
2290
|
+
sendToClient(ws, { type: 'agents', agents });
|
|
2291
|
+
break;
|
|
2292
|
+
}
|
|
2293
|
+
case 'get-history': {
|
|
2294
|
+
await sendSessionHistory(ws, clientId, defaultAgent);
|
|
2295
|
+
break;
|
|
2296
|
+
}
|
|
2297
|
+
}
|
|
2298
|
+
}
|
|
2299
|
+
/**
|
|
2300
|
+
* Send session history to a client
|
|
2301
|
+
*/
|
|
2302
|
+
async function sendSessionHistory(ws, clientId, _defaultAgent) {
|
|
2303
|
+
const history = await sessionManager.getSessionWithHistory('web', 'web', clientId);
|
|
2304
|
+
if (history && history.messages.length > 0) {
|
|
2305
|
+
sendToClient(ws, {
|
|
2306
|
+
type: 'history',
|
|
2307
|
+
messages: history.messages,
|
|
2308
|
+
agent: history.session.agent,
|
|
2309
|
+
});
|
|
2310
|
+
}
|
|
2311
|
+
}
|
|
2312
|
+
/** L1: backpressure threshold for the WS send path. When `ws.bufferedAmount`
|
|
2313
|
+
* exceeds this, the streaming chunk loop awaits a tick instead of piling up
|
|
2314
|
+
* more frames. 4 MiB tolerates a few seconds of slow client without
|
|
2315
|
+
* unbounded memory growth — Node's WebSocket impl honors the kernel
|
|
2316
|
+
* send buffer behind this number. */
|
|
2317
|
+
const WS_BACKPRESSURE_HIGHWATER_BYTES = 4 * 1024 * 1024;
|
|
2318
|
+
/**
|
|
2319
|
+
* Send a JSON message to a WebSocket client
|
|
2320
|
+
*/
|
|
2321
|
+
function sendToClient(ws, data) {
|
|
2322
|
+
if (ws.readyState === ws.OPEN) {
|
|
2323
|
+
ws.send(JSON.stringify(data));
|
|
2324
|
+
}
|
|
2325
|
+
}
|
|
2326
|
+
/**
|
|
2327
|
+
* Wait until `ws.bufferedAmount` drops below the highwater mark, or the
|
|
2328
|
+
* socket closes, or the timeout fires. Used by the streaming chunk loop to
|
|
2329
|
+
* stop piling up frames at slow clients.
|
|
2330
|
+
*
|
|
2331
|
+
* Polls every 50 ms — node's `ws` doesn't emit a `drain` event we can hook,
|
|
2332
|
+
* but the buffered amount drops monotonically once the kernel ACKs flush.
|
|
2333
|
+
* Bounded by IMHUB_WS_BACKPRESSURE_TIMEOUT_MS (default 5 s) so a frozen
|
|
2334
|
+
* client can't wedge the agent's chunk producer indefinitely.
|
|
2335
|
+
*/
|
|
2336
|
+
async function awaitWsDrain(ws) {
|
|
2337
|
+
if (ws.bufferedAmount < WS_BACKPRESSURE_HIGHWATER_BYTES)
|
|
2338
|
+
return;
|
|
2339
|
+
const timeoutMs = (() => {
|
|
2340
|
+
const raw = process.env.IMHUB_WS_BACKPRESSURE_TIMEOUT_MS;
|
|
2341
|
+
if (raw) {
|
|
2342
|
+
const n = parseInt(raw, 10);
|
|
2343
|
+
if (Number.isFinite(n) && n > 0)
|
|
2344
|
+
return n;
|
|
2345
|
+
}
|
|
2346
|
+
return 5_000;
|
|
2347
|
+
})();
|
|
2348
|
+
const startedAt = Date.now();
|
|
2349
|
+
while (ws.readyState === ws.OPEN
|
|
2350
|
+
&& ws.bufferedAmount >= WS_BACKPRESSURE_HIGHWATER_BYTES
|
|
2351
|
+
&& Date.now() - startedAt < timeoutMs) {
|
|
2352
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
2353
|
+
}
|
|
2354
|
+
}
|
|
2355
|
+
/**
|
|
2356
|
+
* Serve a static file (no token injection needed)
|
|
2357
|
+
*/
|
|
2358
|
+
function serveStatic(res, filePath, contentType) {
|
|
2359
|
+
if (!existsSync(filePath)) {
|
|
2360
|
+
res.writeHead(404);
|
|
2361
|
+
res.end('Not found');
|
|
2362
|
+
return;
|
|
2363
|
+
}
|
|
2364
|
+
const content = readFileSync(filePath);
|
|
2365
|
+
res.writeHead(200, { 'Content-Type': contentType });
|
|
2366
|
+
res.end(content);
|
|
2367
|
+
}
|
|
2368
|
+
/**
|
|
2369
|
+
* Serve HTML pages with security headers. Token is no longer injected —
|
|
2370
|
+
* the browser authenticates via httpOnly session cookie set at /login.
|
|
2371
|
+
*/
|
|
2372
|
+
function servePageHtml(res, filePath) {
|
|
2373
|
+
if (!existsSync(filePath)) {
|
|
2374
|
+
res.writeHead(404);
|
|
2375
|
+
res.end('Not found');
|
|
2376
|
+
return;
|
|
2377
|
+
}
|
|
2378
|
+
const html = readFileSync(filePath, 'utf-8');
|
|
2379
|
+
res.writeHead(200, {
|
|
2380
|
+
'Content-Type': 'text/html; charset=utf-8',
|
|
2381
|
+
'Cache-Control': 'no-cache, must-revalidate',
|
|
2382
|
+
'X-Frame-Options': 'DENY',
|
|
2383
|
+
'X-Content-Type-Options': 'nosniff',
|
|
2384
|
+
'Referrer-Policy': 'no-referrer',
|
|
2385
|
+
'Content-Security-Policy': [
|
|
2386
|
+
"default-src 'self'",
|
|
2387
|
+
"connect-src 'self' ws: wss:",
|
|
2388
|
+
"script-src 'self' 'unsafe-inline'",
|
|
2389
|
+
"style-src 'self' 'unsafe-inline'",
|
|
2390
|
+
"img-src 'self' data:",
|
|
2391
|
+
"font-src 'self' data:",
|
|
2392
|
+
"frame-ancestors 'none'",
|
|
2393
|
+
"base-uri 'self'",
|
|
2394
|
+
"form-action 'self'",
|
|
2395
|
+
].join('; '),
|
|
2396
|
+
});
|
|
2397
|
+
res.end(html);
|
|
2398
|
+
}
|
|
2399
|
+
//# sourceMappingURL=server.js.map
|