im-hub-pro 0.2.29
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +497 -0
- package/README.zh-CN.md +496 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +921 -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 +374 -0
- package/dist/core/agent-base.js.map +1 -0
- package/dist/core/agent-cwd.d.ts +45 -0
- package/dist/core/agent-cwd.d.ts.map +1 -0
- package/dist/core/agent-cwd.js +178 -0
- package/dist/core/agent-cwd.js.map +1 -0
- package/dist/core/agent-cwd.test.d.ts +2 -0
- package/dist/core/agent-cwd.test.d.ts.map +1 -0
- package/dist/core/agent-cwd.test.js +149 -0
- package/dist/core/agent-cwd.test.js.map +1 -0
- package/dist/core/approval-bus.d.ts +232 -0
- package/dist/core/approval-bus.d.ts.map +1 -0
- package/dist/core/approval-bus.js +703 -0
- package/dist/core/approval-bus.js.map +1 -0
- package/dist/core/approval-bus.synthetic.test.d.ts +2 -0
- package/dist/core/approval-bus.synthetic.test.d.ts.map +1 -0
- package/dist/core/approval-bus.synthetic.test.js +182 -0
- package/dist/core/approval-bus.synthetic.test.js.map +1 -0
- package/dist/core/approval-bus.test.d.ts +2 -0
- package/dist/core/approval-bus.test.d.ts.map +1 -0
- package/dist/core/approval-bus.test.js +537 -0
- package/dist/core/approval-bus.test.js.map +1 -0
- package/dist/core/approval-router.d.ts +95 -0
- package/dist/core/approval-router.d.ts.map +1 -0
- package/dist/core/approval-router.js +450 -0
- package/dist/core/approval-router.js.map +1 -0
- package/dist/core/approval-router.test.d.ts +2 -0
- package/dist/core/approval-router.test.d.ts.map +1 -0
- package/dist/core/approval-router.test.js +413 -0
- package/dist/core/approval-router.test.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/bgjob-reader.test.d.ts +2 -0
- package/dist/core/bgjob-reader.test.d.ts.map +1 -0
- package/dist/core/bgjob-reader.test.js +178 -0
- package/dist/core/bgjob-reader.test.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 +21 -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 +44 -0
- package/dist/core/commands/approval.js.map +1 -0
- package/dist/core/commands/approval.test.d.ts +2 -0
- package/dist/core/commands/approval.test.d.ts.map +1 -0
- package/dist/core/commands/approval.test.js +85 -0
- package/dist/core/commands/approval.test.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 +26 -0
- package/dist/core/commands/builtin.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/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/plan.test.d.ts +2 -0
- package/dist/core/commands/plan.test.d.ts.map +1 -0
- package/dist/core/commands/plan.test.js +122 -0
- package/dist/core/commands/plan.test.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/schedule.d.ts +3 -0
- package/dist/core/commands/schedule.d.ts.map +1 -0
- package/dist/core/commands/schedule.js +123 -0
- package/dist/core/commands/schedule.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 +58 -0
- package/dist/core/config-schema.d.ts.map +1 -0
- package/dist/core/config-schema.js +63 -0
- package/dist/core/config-schema.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 +84 -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/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/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 +94 -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/onboarding.test.d.ts +2 -0
- package/dist/core/onboarding.test.d.ts.map +1 -0
- package/dist/core/onboarding.test.js +112 -0
- package/dist/core/onboarding.test.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 +122 -0
- package/dist/core/registry.js.map +1 -0
- package/dist/core/router.d.ts +41 -0
- package/dist/core/router.d.ts.map +1 -0
- package/dist/core/router.js +431 -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 +316 -0
- package/dist/core/schedule.js.map +1 -0
- package/dist/core/session-subtasks.test.d.ts +2 -0
- package/dist/core/session-subtasks.test.d.ts.map +1 -0
- package/dist/core/session-subtasks.test.js +88 -0
- package/dist/core/session-subtasks.test.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 +774 -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/transcribe.test.d.ts +2 -0
- package/dist/core/transcribe.test.d.ts.map +1 -0
- package/dist/core/transcribe.test.js +163 -0
- package/dist/core/transcribe.test.js.map +1 -0
- package/dist/core/types.d.ts +352 -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 +175 -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 +109 -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/adapter.test.d.ts +2 -0
- package/dist/plugins/agents/claude-code/adapter.test.d.ts.map +1 -0
- package/dist/plugins/agents/claude-code/adapter.test.js +195 -0
- package/dist/plugins/agents/claude-code/adapter.test.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 +42 -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 +235 -0
- package/dist/plugins/agents/claude-code/mcp-approval-server.js.map +1 -0
- package/dist/plugins/agents/claude-code/mcp-approval-server.test.d.ts +2 -0
- package/dist/plugins/agents/claude-code/mcp-approval-server.test.d.ts.map +1 -0
- package/dist/plugins/agents/claude-code/mcp-approval-server.test.js +188 -0
- package/dist/plugins/agents/claude-code/mcp-approval-server.test.js.map +1 -0
- package/dist/plugins/agents/codex/adapter.test.d.ts +2 -0
- package/dist/plugins/agents/codex/adapter.test.d.ts.map +1 -0
- package/dist/plugins/agents/codex/adapter.test.js +192 -0
- package/dist/plugins/agents/codex/adapter.test.js.map +1 -0
- package/dist/plugins/agents/codex/index.d.ts +37 -0
- package/dist/plugins/agents/codex/index.d.ts.map +1 -0
- package/dist/plugins/agents/codex/index.js +254 -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/adapter.test.d.ts +2 -0
- package/dist/plugins/agents/opencode/adapter.test.d.ts.map +1 -0
- package/dist/plugins/agents/opencode/adapter.test.js +139 -0
- package/dist/plugins/agents/opencode/adapter.test.js.map +1 -0
- package/dist/plugins/agents/opencode/http-adapter.test.d.ts +2 -0
- package/dist/plugins/agents/opencode/http-adapter.test.d.ts.map +1 -0
- package/dist/plugins/agents/opencode/http-adapter.test.js +492 -0
- package/dist/plugins/agents/opencode/http-adapter.test.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 +138 -0
- package/dist/plugins/agents/opencode/opencode-http-adapter.d.ts.map +1 -0
- package/dist/plugins/agents/opencode/opencode-http-adapter.js +549 -0
- package/dist/plugins/agents/opencode/opencode-http-adapter.js.map +1 -0
- package/dist/plugins/agents/opencode/opencode-stdio-adapter.d.ts +24 -0
- package/dist/plugins/agents/opencode/opencode-stdio-adapter.d.ts.map +1 -0
- package/dist/plugins/agents/opencode/opencode-stdio-adapter.js +103 -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 +190 -0
- package/dist/plugins/agents/opencode/serve-manager.js.map +1 -0
- package/dist/plugins/messengers/discord/discord-adapter.d.ts +22 -0
- package/dist/plugins/messengers/discord/discord-adapter.d.ts.map +1 -0
- package/dist/plugins/messengers/discord/discord-adapter.js +241 -0
- package/dist/plugins/messengers/discord/discord-adapter.js.map +1 -0
- package/dist/plugins/messengers/discord/discord-adapter.test.d.ts +2 -0
- package/dist/plugins/messengers/discord/discord-adapter.test.d.ts.map +1 -0
- package/dist/plugins/messengers/discord/discord-adapter.test.js +332 -0
- package/dist/plugins/messengers/discord/discord-adapter.test.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/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 +33 -0
- package/dist/plugins/messengers/feishu/feishu-adapter.d.ts.map +1 -0
- package/dist/plugins/messengers/feishu/feishu-adapter.js +195 -0
- package/dist/plugins/messengers/feishu/feishu-adapter.js.map +1 -0
- package/dist/plugins/messengers/feishu/feishu-client.d.ts +44 -0
- package/dist/plugins/messengers/feishu/feishu-client.d.ts.map +1 -0
- package/dist/plugins/messengers/feishu/feishu-client.js +120 -0
- package/dist/plugins/messengers/feishu/feishu-client.js.map +1 -0
- package/dist/plugins/messengers/feishu/feishu-dedup.test.d.ts +2 -0
- package/dist/plugins/messengers/feishu/feishu-dedup.test.d.ts.map +1 -0
- package/dist/plugins/messengers/feishu/feishu-dedup.test.js +70 -0
- package/dist/plugins/messengers/feishu/feishu-dedup.test.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 +51 -0
- package/dist/plugins/messengers/telegram/media-download.d.ts.map +1 -0
- package/dist/plugins/messengers/telegram/media-download.js +224 -0
- package/dist/plugins/messengers/telegram/media-download.js.map +1 -0
- package/dist/plugins/messengers/telegram/media-download.test.d.ts +2 -0
- package/dist/plugins/messengers/telegram/media-download.test.d.ts.map +1 -0
- package/dist/plugins/messengers/telegram/media-download.test.js +125 -0
- package/dist/plugins/messengers/telegram/media-download.test.js.map +1 -0
- package/dist/plugins/messengers/telegram/telegram-adapter.d.ts +62 -0
- package/dist/plugins/messengers/telegram/telegram-adapter.d.ts.map +1 -0
- package/dist/plugins/messengers/telegram/telegram-adapter.js +653 -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/ilink-adapter.d.ts +68 -0
- package/dist/plugins/messengers/wechat/ilink-adapter.d.ts.map +1 -0
- package/dist/plugins/messengers/wechat/ilink-adapter.js +483 -0
- package/dist/plugins/messengers/wechat/ilink-adapter.js.map +1 -0
- package/dist/plugins/messengers/wechat/ilink-client.d.ts +66 -0
- package/dist/plugins/messengers/wechat/ilink-client.d.ts.map +1 -0
- package/dist/plugins/messengers/wechat/ilink-client.js +288 -0
- package/dist/plugins/messengers/wechat/ilink-client.js.map +1 -0
- package/dist/plugins/messengers/wechat/ilink-types.d.ts +173 -0
- package/dist/plugins/messengers/wechat/ilink-types.d.ts.map +1 -0
- package/dist/plugins/messengers/wechat/ilink-types.js +12 -0
- package/dist/plugins/messengers/wechat/ilink-types.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 +935 -0
- package/dist/web/public/settings.html +1181 -0
- package/dist/web/public/tasks.html +1827 -0
- package/dist/web/server.d.ts +11 -0
- package/dist/web/server.d.ts.map +1 -0
- package/dist/web/server.js +1820 -0
- package/dist/web/server.js.map +1 -0
- package/package.json +73 -0
|
@@ -0,0 +1,1820 @@
|
|
|
1
|
+
// Web chat server — HTTP + WebSocket for browser-based agent interaction
|
|
2
|
+
import { createServer } from 'http';
|
|
3
|
+
import { readFileSync, existsSync, writeFileSync, mkdirSync } from 'fs';
|
|
4
|
+
import { readdir, stat, readFile, writeFile, rename, unlink } from 'fs/promises';
|
|
5
|
+
import { join, dirname, resolve as resolvePath, sep as pathSep, relative as relativePath } from 'path';
|
|
6
|
+
import { fileURLToPath } from 'url';
|
|
7
|
+
import { homedir } from 'os';
|
|
8
|
+
import { randomBytes } from '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
|
+
const webLog = rootLogger.child({ component: 'web' });
|
|
17
|
+
/**
|
|
18
|
+
* Module-level reference to the button-callback handler that approval-router
|
|
19
|
+
* registers on our synthetic web messenger. The WS message switch dispatches
|
|
20
|
+
* `approval-action` events through this so an in-page approval button click
|
|
21
|
+
* flows back into approvalBus.resolvePending(), same path as a Telegram
|
|
22
|
+
* inline-button tap. Set by the web messenger's `onButtonCallback`; remains
|
|
23
|
+
* undefined until approval-router installs (which happens before this file's
|
|
24
|
+
* exported startWebServer is called from cli.ts).
|
|
25
|
+
*/
|
|
26
|
+
let webButtonHandler;
|
|
27
|
+
import { isAgentAvailableCached, loadConfig, saveConfig, } from '../core/onboarding.js';
|
|
28
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
29
|
+
const PUBLIC_DIR = join(__dirname, 'public');
|
|
30
|
+
const DEFAULT_PORT = 3000;
|
|
31
|
+
const WEB_TOKEN_DIR = join(homedir(), '.im-hub');
|
|
32
|
+
const WEB_TOKEN_FILE = join(WEB_TOKEN_DIR, 'web-token');
|
|
33
|
+
function generateToken() {
|
|
34
|
+
return randomBytes(32).toString('hex');
|
|
35
|
+
}
|
|
36
|
+
function getOrCreateWebToken() {
|
|
37
|
+
try {
|
|
38
|
+
return readFileSync(WEB_TOKEN_FILE, 'utf-8').trim();
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
const token = generateToken();
|
|
42
|
+
mkdirSync(WEB_TOKEN_DIR, { recursive: true });
|
|
43
|
+
writeFileSync(WEB_TOKEN_FILE, token, { mode: 0o600 });
|
|
44
|
+
return token;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
function isMasked(value) {
|
|
48
|
+
if (!value)
|
|
49
|
+
return false;
|
|
50
|
+
return /^.{0,2}\*{2,}.{0,2}$/.test(value);
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Start the web chat server
|
|
54
|
+
*/
|
|
55
|
+
export async function startWebServer(options) {
|
|
56
|
+
const port = options.port || DEFAULT_PORT;
|
|
57
|
+
const webToken = getOrCreateWebToken();
|
|
58
|
+
const clients = new Map();
|
|
59
|
+
// HTTP request handler — static files + REST API
|
|
60
|
+
const httpServer = createServer(async (req, res) => {
|
|
61
|
+
const url = new URL(req.url || '/', `http://localhost:${port}`);
|
|
62
|
+
// Static pages
|
|
63
|
+
if (url.pathname === '/' || url.pathname === '/index.html') {
|
|
64
|
+
return serveIndexHtml(res, join(PUBLIC_DIR, 'index.html'), webToken);
|
|
65
|
+
}
|
|
66
|
+
if (url.pathname === '/settings' || url.pathname === '/settings.html') {
|
|
67
|
+
return serveIndexHtml(res, join(PUBLIC_DIR, 'settings.html'), webToken);
|
|
68
|
+
}
|
|
69
|
+
if (url.pathname === '/tasks' || url.pathname === '/tasks.html') {
|
|
70
|
+
return serveIndexHtml(res, join(PUBLIC_DIR, 'tasks.html'), webToken);
|
|
71
|
+
}
|
|
72
|
+
// M4: /api/health is intentionally public (k8s liveness probe friendly)
|
|
73
|
+
// — declare it BEFORE the /api/* token gate so callers don't need to
|
|
74
|
+
// know the web token. Returns only operational status, not config.
|
|
75
|
+
if (url.pathname === '/api/health' && req.method === 'GET') {
|
|
76
|
+
return handleHealth(req, res);
|
|
77
|
+
}
|
|
78
|
+
// Shared web-console utilities (theme manager + i18n + error boundary
|
|
79
|
+
// + auth-aware fetch). Loaded synchronously by every static page in
|
|
80
|
+
// <head> so the theme can apply before first paint. No secrets — safe
|
|
81
|
+
// to serve un-authenticated.
|
|
82
|
+
if (url.pathname === '/_app.js' && req.method === 'GET') {
|
|
83
|
+
return serveStatic(res, join(PUBLIC_DIR, '_app.js'), 'application/javascript; charset=utf-8');
|
|
84
|
+
}
|
|
85
|
+
// REST API — require auth token
|
|
86
|
+
if (url.pathname.startsWith('/api/')) {
|
|
87
|
+
const token = req.headers['x-im-hub-token'] || '';
|
|
88
|
+
if (!safeEqual(token, webToken)) {
|
|
89
|
+
res.writeHead(401);
|
|
90
|
+
res.end(JSON.stringify({ error: 'Unauthorized' }));
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
// REST API
|
|
95
|
+
if (url.pathname === '/api/config' && req.method === 'GET') {
|
|
96
|
+
return handleGetConfig(req, res);
|
|
97
|
+
}
|
|
98
|
+
if (url.pathname === '/api/config' && req.method === 'PUT') {
|
|
99
|
+
return handlePutConfig(req, res);
|
|
100
|
+
}
|
|
101
|
+
if (url.pathname === '/api/agents/status' && req.method === 'GET') {
|
|
102
|
+
return handleAgentsStatus(req, res);
|
|
103
|
+
}
|
|
104
|
+
if (url.pathname === '/api/agents/acp/test' && req.method === 'POST') {
|
|
105
|
+
return handleAcpTest(req, res);
|
|
106
|
+
}
|
|
107
|
+
if (url.pathname === '/api/agents/acp/discover' && req.method === 'POST') {
|
|
108
|
+
return handleAcpDiscover(req, res);
|
|
109
|
+
}
|
|
110
|
+
// Jobs
|
|
111
|
+
if (url.pathname === '/api/jobs' && req.method === 'GET') {
|
|
112
|
+
return handleListJobs(req, res, url);
|
|
113
|
+
}
|
|
114
|
+
const jobIdMatch = url.pathname.match(/^\/api\/jobs\/(\d+)$/);
|
|
115
|
+
if (jobIdMatch && req.method === 'GET') {
|
|
116
|
+
return handleGetJob(req, res, parseInt(jobIdMatch[1], 10));
|
|
117
|
+
}
|
|
118
|
+
const jobCancelMatch = url.pathname.match(/^\/api\/jobs\/(\d+)\/cancel$/);
|
|
119
|
+
if (jobCancelMatch && req.method === 'POST') {
|
|
120
|
+
return handleCancelJob(req, res, parseInt(jobCancelMatch[1], 10));
|
|
121
|
+
}
|
|
122
|
+
const jobRunMatch = url.pathname.match(/^\/api\/jobs\/(\d+)\/run$/);
|
|
123
|
+
if (jobRunMatch && req.method === 'POST') {
|
|
124
|
+
return handleRunJob(req, res, parseInt(jobRunMatch[1], 10));
|
|
125
|
+
}
|
|
126
|
+
if (url.pathname === '/api/jobs' && req.method === 'POST') {
|
|
127
|
+
return handleCreateJob(req, res);
|
|
128
|
+
}
|
|
129
|
+
// bgjobs (read-only view of ~/.claude/bgjobs, ~/.config/opencode/bgjobs, ~/.codex/bgjobs)
|
|
130
|
+
if (url.pathname === '/api/bgjobs' && req.method === 'GET') {
|
|
131
|
+
return handleListBgjobs(req, res, url);
|
|
132
|
+
}
|
|
133
|
+
const bgjobIdMatch = url.pathname.match(/^\/api\/bgjobs\/([\w.-]+)$/);
|
|
134
|
+
if (bgjobIdMatch && req.method === 'GET') {
|
|
135
|
+
return handleGetBgjob(req, res, bgjobIdMatch[1], url);
|
|
136
|
+
}
|
|
137
|
+
// Subtasks (flattened view of session.subtasks across all conversations)
|
|
138
|
+
if (url.pathname === '/api/subtasks' && req.method === 'GET') {
|
|
139
|
+
return handleListSubtasks(req, res, url);
|
|
140
|
+
}
|
|
141
|
+
// Schedules
|
|
142
|
+
if (url.pathname === '/api/schedules' && req.method === 'GET') {
|
|
143
|
+
return handleListSchedules(req, res, url);
|
|
144
|
+
}
|
|
145
|
+
if (url.pathname === '/api/workspaces' && req.method === 'GET') {
|
|
146
|
+
return handleListWorkspaces(req, res, url);
|
|
147
|
+
}
|
|
148
|
+
if (url.pathname === '/api/workspaces' && req.method === 'POST') {
|
|
149
|
+
return handleCreateOrUpdateWorkspace(req, res);
|
|
150
|
+
}
|
|
151
|
+
const workspaceIdMatch = url.pathname.match(/^\/api\/workspaces\/([^/]+)$/);
|
|
152
|
+
if (workspaceIdMatch && req.method === 'PATCH') {
|
|
153
|
+
return handleCreateOrUpdateWorkspace(req, res, workspaceIdMatch[1]);
|
|
154
|
+
}
|
|
155
|
+
if (workspaceIdMatch && req.method === 'DELETE') {
|
|
156
|
+
return handleDeleteWorkspace(req, res, workspaceIdMatch[1]);
|
|
157
|
+
}
|
|
158
|
+
if (url.pathname === '/api/metrics' && req.method === 'GET') {
|
|
159
|
+
return handleMetrics(req, res, url);
|
|
160
|
+
}
|
|
161
|
+
if (url.pathname === '/api/audit' && req.method === 'GET') {
|
|
162
|
+
return handleAudit(req, res, url);
|
|
163
|
+
}
|
|
164
|
+
// PR-B: agent health snapshot (circuit breaker + rate-limiter remaining
|
|
165
|
+
// + latency p50/95/99) consumed by the Health tab in /tasks.
|
|
166
|
+
if (url.pathname === '/api/agent-health' && req.method === 'GET') {
|
|
167
|
+
return handleAgentHealth(req, res);
|
|
168
|
+
}
|
|
169
|
+
// PR-B: HITL approvals — global pending list + per-reqId resolve.
|
|
170
|
+
if (url.pathname === '/api/approvals' && req.method === 'GET') {
|
|
171
|
+
return handleListApprovals(req, res);
|
|
172
|
+
}
|
|
173
|
+
const approvalResolveMatch = url.pathname.match(/^\/api\/approvals\/([^/]+)\/resolve$/);
|
|
174
|
+
if (approvalResolveMatch && req.method === 'POST') {
|
|
175
|
+
return handleResolveApproval(req, res, approvalResolveMatch[1]);
|
|
176
|
+
}
|
|
177
|
+
// PR-D: Agent workspace file browser. Read-only inspection of
|
|
178
|
+
// ~/.im-hub-workspaces/<agent>/ contents — list dirs, peek small
|
|
179
|
+
// text files. PUT path supports inline editing (annotate CLAUDE.md,
|
|
180
|
+
// AGENTS.md, etc.) — same traversal/size guards as GET.
|
|
181
|
+
if (url.pathname === '/api/workspace-files' && req.method === 'GET') {
|
|
182
|
+
return handleWorkspaceFiles(req, res, url);
|
|
183
|
+
}
|
|
184
|
+
if (url.pathname === '/api/workspace-files' && req.method === 'PUT') {
|
|
185
|
+
return handleWorkspaceFileWrite(req, res, url);
|
|
186
|
+
}
|
|
187
|
+
// PR-D: Job batch operations. Same semantics as /api/jobs/:id/cancel
|
|
188
|
+
// and /run but accepts an array of ids in one request — saves N
|
|
189
|
+
// round-trips when the user multi-selects a long list.
|
|
190
|
+
if (url.pathname === '/api/jobs/batch-cancel' && req.method === 'POST') {
|
|
191
|
+
return handleBatchJob(req, res, 'cancel');
|
|
192
|
+
}
|
|
193
|
+
if (url.pathname === '/api/jobs/batch-run' && req.method === 'POST') {
|
|
194
|
+
return handleBatchJob(req, res, 'run', options.defaultAgent);
|
|
195
|
+
}
|
|
196
|
+
// PR-C: SSE event stream — audit / approval / job / metrics events
|
|
197
|
+
// pushed real-time so the dashboard stops polling. EventSource has no
|
|
198
|
+
// header API, so the token rides in `?token=<webToken>` (same shape
|
|
199
|
+
// the WS upgrade uses). Auth is validated inside the handler since
|
|
200
|
+
// /events is outside the /api/* token gate above.
|
|
201
|
+
if (url.pathname === '/events' && req.method === 'GET') {
|
|
202
|
+
const evToken = url.searchParams.get('token') || '';
|
|
203
|
+
if (!safeEqual(evToken, webToken)) {
|
|
204
|
+
res.writeHead(401, { 'Content-Type': 'text/plain' });
|
|
205
|
+
res.end('Unauthorized');
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
return handleEventsSSE(req, res);
|
|
209
|
+
}
|
|
210
|
+
// /api/health handled above the token gate (M4) — keep this comment so
|
|
211
|
+
// future contributors don't re-add the route inside the auth block.
|
|
212
|
+
if (url.pathname === '/api/notify' && req.method === 'POST') {
|
|
213
|
+
return handleNotify(req, res);
|
|
214
|
+
}
|
|
215
|
+
if (url.pathname === '/api/invoke' && req.method === 'POST') {
|
|
216
|
+
return handleInvoke(req, res, options.defaultAgent);
|
|
217
|
+
}
|
|
218
|
+
res.writeHead(404);
|
|
219
|
+
res.end('Not found');
|
|
220
|
+
});
|
|
221
|
+
// WebSocket server
|
|
222
|
+
const wss = new WebSocketServer({ server: httpServer });
|
|
223
|
+
// M3: cap concurrent WS clients so a leaked / shared web token can't OOM
|
|
224
|
+
// the host by opening unbounded connections. Default 100 is generous for
|
|
225
|
+
// a single-user / small-team setup; production multi-tenant should set
|
|
226
|
+
// IMHUB_MAX_WS_CLIENTS to a higher value.
|
|
227
|
+
const maxWsClients = (() => {
|
|
228
|
+
const raw = process.env.IMHUB_MAX_WS_CLIENTS;
|
|
229
|
+
if (raw) {
|
|
230
|
+
const n = parseInt(raw, 10);
|
|
231
|
+
if (Number.isFinite(n) && n > 0)
|
|
232
|
+
return n;
|
|
233
|
+
}
|
|
234
|
+
return 100;
|
|
235
|
+
})();
|
|
236
|
+
wss.on('connection', (ws, req) => {
|
|
237
|
+
if (clients.size >= maxWsClients) {
|
|
238
|
+
// 1013 = "Try Again Later" per RFC 6455. Slightly nicer than a flat
|
|
239
|
+
// close — clients with reconnect logic will back off.
|
|
240
|
+
webLog.warn({
|
|
241
|
+
event: 'ws.cap_reached',
|
|
242
|
+
active: clients.size,
|
|
243
|
+
cap: maxWsClients,
|
|
244
|
+
}, 'WS connection refused (cap)');
|
|
245
|
+
ws.close(1013, 'Server too busy');
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
// Verify token from URL query before accepting connection
|
|
249
|
+
const wsUrl = new URL(req.url || '/', `http://localhost:${port}`);
|
|
250
|
+
const wsToken = wsUrl.searchParams.get('token') || '';
|
|
251
|
+
if (!safeEqual(wsToken, webToken)) {
|
|
252
|
+
ws.close(1008, 'Unauthorized');
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
const clientId = `web-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
256
|
+
const client = { ws, id: clientId, agent: options.defaultAgent };
|
|
257
|
+
clients.set(clientId, client);
|
|
258
|
+
webLog.info({ clientId }, 'Client connected');
|
|
259
|
+
// Send available agents list
|
|
260
|
+
sendToClient(ws, {
|
|
261
|
+
type: 'init',
|
|
262
|
+
agents: registry.listAgents(),
|
|
263
|
+
defaultAgent: options.defaultAgent,
|
|
264
|
+
clientId,
|
|
265
|
+
});
|
|
266
|
+
// Load existing session history if available
|
|
267
|
+
sendSessionHistory(ws, clientId, options.defaultAgent);
|
|
268
|
+
ws.on('message', async (data) => {
|
|
269
|
+
try {
|
|
270
|
+
const msg = JSON.parse(data.toString());
|
|
271
|
+
// Approval-button click intercept. The user tapped an in-page
|
|
272
|
+
// approval card button; route it through the web messenger's
|
|
273
|
+
// button handler (registered by approval-router on install) the
|
|
274
|
+
// same way a Telegram inline-keyboard tap is routed. We don't
|
|
275
|
+
// call handleClientMessage for these — they're not chat input.
|
|
276
|
+
if (msg && msg.type === 'approval-action') {
|
|
277
|
+
const actionData = String(msg.data || '');
|
|
278
|
+
const messageId = String(msg.messageId || '');
|
|
279
|
+
webLog.info({
|
|
280
|
+
event: 'approval.web.click_received',
|
|
281
|
+
clientId, data: actionData, messageId,
|
|
282
|
+
handlerBound: !!webButtonHandler,
|
|
283
|
+
});
|
|
284
|
+
if (!actionData || !messageId) {
|
|
285
|
+
sendToClient(ws, { type: 'error', message: 'approval-action missing data/messageId' });
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
if (!webButtonHandler) {
|
|
289
|
+
// Without the handler, a click would silently no-op forever — the
|
|
290
|
+
// failure mode that PR-A's fix patches. Tell the user and the
|
|
291
|
+
// operator (via log) instead of dropping the click.
|
|
292
|
+
const why = 'approval handler not bound (router not installed?). Restart im-hub to rebind.';
|
|
293
|
+
webLog.warn({ event: 'approval.web.no_handler', clientId, data: actionData, messageId }, why);
|
|
294
|
+
sendToClient(ws, { type: 'error', message: why });
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
try {
|
|
298
|
+
// Most messengers' ButtonCallback#ack updates a platform-native
|
|
299
|
+
// toast / loading spinner. The web client doesn't have one, so
|
|
300
|
+
// ack is a no-op resolving to the in-page status the page itself
|
|
301
|
+
// chose to render after click.
|
|
302
|
+
await webButtonHandler({
|
|
303
|
+
data: actionData, threadId: clientId, userId: `web:${clientId}`,
|
|
304
|
+
userDisplay: 'Web', messageId, ack: async () => { },
|
|
305
|
+
});
|
|
306
|
+
webLog.info({ event: 'approval.web.click_resolved', clientId, data: actionData });
|
|
307
|
+
}
|
|
308
|
+
catch (err) {
|
|
309
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
310
|
+
webLog.error({ event: 'approval.web.click_failed', clientId, data: actionData, err: errMsg });
|
|
311
|
+
sendToClient(ws, { type: 'error', message: `Approval click failed: ${errMsg}` });
|
|
312
|
+
}
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
await handleClientMessage(client, msg, options.defaultAgent);
|
|
316
|
+
}
|
|
317
|
+
catch (err) {
|
|
318
|
+
webLog.error({ clientId, err: err instanceof Error ? err.message : String(err) }, 'Error parsing client message');
|
|
319
|
+
sendToClient(ws, { type: 'error', message: 'Invalid message format' });
|
|
320
|
+
}
|
|
321
|
+
});
|
|
322
|
+
ws.on('close', () => {
|
|
323
|
+
webLog.info({ clientId }, 'Client disconnected');
|
|
324
|
+
clients.delete(clientId);
|
|
325
|
+
});
|
|
326
|
+
ws.on('error', (err) => {
|
|
327
|
+
webLog.error({ clientId, err: err instanceof Error ? err.message : String(err) }, 'Client WebSocket error');
|
|
328
|
+
clients.delete(clientId);
|
|
329
|
+
});
|
|
330
|
+
});
|
|
331
|
+
// Start listening on all interfaces
|
|
332
|
+
await new Promise((resolve, reject) => {
|
|
333
|
+
httpServer.on('error', reject);
|
|
334
|
+
httpServer.listen(port, '0.0.0.0', () => resolve());
|
|
335
|
+
});
|
|
336
|
+
webLog.info({ port }, `Chat UI available at http://localhost:${port}`);
|
|
337
|
+
// ============================================================
|
|
338
|
+
// Web messenger registration (HITL approval bridge)
|
|
339
|
+
// ============================================================
|
|
340
|
+
// Register a synthetic messenger named 'web' so approval-router (which
|
|
341
|
+
// resolves the target messenger by platform name) can deliver approval
|
|
342
|
+
// prompts AND outcome edits to the matching browser tab over the
|
|
343
|
+
// existing WebSocket. Chat ingress is unaffected — incoming chat
|
|
344
|
+
// messages still flow through handleClientMessage / routeMessage as
|
|
345
|
+
// before; this messenger only forwards what the bus wants to push.
|
|
346
|
+
//
|
|
347
|
+
// threadId is the WS clientId (RouteContext.threadId === client.id for
|
|
348
|
+
// the web platform). We resolve the matching client at delivery time;
|
|
349
|
+
// if the client has disconnected the send is a no-op and the bus's own
|
|
350
|
+
// auto-deny / sidecar-disconnect path takes over.
|
|
351
|
+
let cardSeq = 0;
|
|
352
|
+
const webMessenger = {
|
|
353
|
+
name: 'web',
|
|
354
|
+
start: async () => { },
|
|
355
|
+
stop: async () => { },
|
|
356
|
+
onMessage: () => { },
|
|
357
|
+
async sendMessage(threadId, text) {
|
|
358
|
+
const c = clients.get(threadId);
|
|
359
|
+
if (!c || c.ws.readyState !== c.ws.OPEN)
|
|
360
|
+
return;
|
|
361
|
+
sendToClient(c.ws, { type: 'approval-text', text });
|
|
362
|
+
},
|
|
363
|
+
async sendApprovalCard(threadId, prompt) {
|
|
364
|
+
const c = clients.get(threadId);
|
|
365
|
+
const messageId = `web-card-${++cardSeq}-${Date.now().toString(36)}`;
|
|
366
|
+
if (c && c.ws.readyState === c.ws.OPEN) {
|
|
367
|
+
sendToClient(c.ws, { type: 'approval-card', messageId, prompt });
|
|
368
|
+
}
|
|
369
|
+
return { messageId };
|
|
370
|
+
},
|
|
371
|
+
async editApprovalCard(threadId, messageId, outcome) {
|
|
372
|
+
const c = clients.get(threadId);
|
|
373
|
+
if (!c || c.ws.readyState !== c.ws.OPEN)
|
|
374
|
+
return;
|
|
375
|
+
sendToClient(c.ws, { type: 'approval-card-edit', messageId, outcome });
|
|
376
|
+
},
|
|
377
|
+
onButtonCallback(handler) {
|
|
378
|
+
webButtonHandler = handler;
|
|
379
|
+
webLog.info({ event: 'approval.web.handler_bound' }, 'web messenger button-callback handler attached');
|
|
380
|
+
},
|
|
381
|
+
};
|
|
382
|
+
registry.registerMessenger(webMessenger);
|
|
383
|
+
// approval-router's install() loop bound buttonCallback only for messengers
|
|
384
|
+
// registered BEFORE install. Our web messenger was just registered (after
|
|
385
|
+
// install), so we have to wire it ourselves — otherwise in-page approval
|
|
386
|
+
// card clicks fire WS 'approval-action' messages with no handler on the
|
|
387
|
+
// server side and silently do nothing. bindButtonHandlerForPlatform is a
|
|
388
|
+
// no-op if approval-router hasn't been install()'d yet (e.g. degraded
|
|
389
|
+
// mode where the bus failed to start).
|
|
390
|
+
try {
|
|
391
|
+
const { bindButtonHandlerForPlatform } = await import('../core/approval-router.js');
|
|
392
|
+
bindButtonHandlerForPlatform('web');
|
|
393
|
+
if (!webButtonHandler) {
|
|
394
|
+
// bindButtonHandlerForPlatform is a silent no-op when `installed` is
|
|
395
|
+
// null on the router (bus failed to start, or cli skipped install).
|
|
396
|
+
// Log so an operator who's confused why approval clicks don't work
|
|
397
|
+
// sees a clear breadcrumb at startup.
|
|
398
|
+
webLog.warn({ event: 'approval.web.bind_skipped' }, 'approval-router not installed — web approval clicks will fail until restart');
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
catch (err) {
|
|
402
|
+
webLog.warn({ event: 'approval.web.bind_error', err: err instanceof Error ? err.message : String(err) }, 'approval-router button-handler binding threw');
|
|
403
|
+
}
|
|
404
|
+
// PR-C: periodic metrics tick. Publishes a per-agent snapshot every 5s
|
|
405
|
+
// so the dashboard's Health sparkline can advance even when there are
|
|
406
|
+
// no audit events firing. The bus's recent buffer keeps the latest
|
|
407
|
+
// tick available to fresh SSE connections, so a tab opened mid-cycle
|
|
408
|
+
// sees current data without waiting up to 5 s.
|
|
409
|
+
const metricsTick = setInterval(async () => {
|
|
410
|
+
try {
|
|
411
|
+
const { eventBus } = await import('../core/event-bus.js');
|
|
412
|
+
const { snapshot } = await import('../core/metrics.js');
|
|
413
|
+
const snap = snapshot();
|
|
414
|
+
eventBus.publish({
|
|
415
|
+
type: 'metrics',
|
|
416
|
+
ts: new Date().toISOString(),
|
|
417
|
+
agents: snap.agents.map((a) => ({
|
|
418
|
+
agent: a.agent, total: a.total, success: a.success, failure: a.failure,
|
|
419
|
+
p50Ms: a.p50Ms, p95Ms: a.p95Ms, p99Ms: a.p99Ms,
|
|
420
|
+
})),
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
catch { /* swallow — metrics tick must never break the web server */ }
|
|
424
|
+
}, 5_000);
|
|
425
|
+
if (typeof metricsTick === 'object' && metricsTick && 'unref' in metricsTick) {
|
|
426
|
+
metricsTick.unref();
|
|
427
|
+
}
|
|
428
|
+
return {
|
|
429
|
+
port,
|
|
430
|
+
close: () => {
|
|
431
|
+
// Close all WebSocket connections
|
|
432
|
+
for (const [id, client] of clients) {
|
|
433
|
+
client.ws.close();
|
|
434
|
+
clients.delete(id);
|
|
435
|
+
}
|
|
436
|
+
wss.close();
|
|
437
|
+
httpServer.close();
|
|
438
|
+
},
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
// ============================================
|
|
442
|
+
// REST API handlers
|
|
443
|
+
// ============================================
|
|
444
|
+
async function handleGetConfig(_req, res) {
|
|
445
|
+
try {
|
|
446
|
+
const config = await loadConfig();
|
|
447
|
+
const agentStatus = await getAgentStatuses();
|
|
448
|
+
sendJson(res, 200, {
|
|
449
|
+
messengers: config.messengers,
|
|
450
|
+
agents: config.agents,
|
|
451
|
+
defaultAgent: config.defaultAgent,
|
|
452
|
+
telegram: config.telegram
|
|
453
|
+
? { botToken: mask(config.telegram.botToken), channelId: config.telegram.channelId }
|
|
454
|
+
: undefined,
|
|
455
|
+
feishu: config.feishu
|
|
456
|
+
? { appId: config.feishu.appId, appSecret: mask(config.feishu.appSecret) }
|
|
457
|
+
: undefined,
|
|
458
|
+
acpAgents: config.acpAgents?.map(a => ({
|
|
459
|
+
...a,
|
|
460
|
+
auth: a.auth
|
|
461
|
+
? { ...a.auth, token: a.auth.token ? mask(a.auth.token) : undefined }
|
|
462
|
+
: undefined,
|
|
463
|
+
})),
|
|
464
|
+
webPort: config.webPort,
|
|
465
|
+
agentStatus,
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
catch (err) {
|
|
469
|
+
sendJson(res, 500, { error: 'Failed to load config' });
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
async function handlePutConfig(req, res) {
|
|
473
|
+
try {
|
|
474
|
+
const body = await readBody(req, res);
|
|
475
|
+
const incoming = JSON.parse(body);
|
|
476
|
+
const existing = await loadConfig();
|
|
477
|
+
const merged = { ...existing };
|
|
478
|
+
for (const key of Object.keys(incoming)) {
|
|
479
|
+
const val = incoming[key];
|
|
480
|
+
// Deep-protect nested known-masked paths so `ab****yz` never overwrites true value
|
|
481
|
+
if (key === 'telegram' && typeof val === 'object' && val !== null) {
|
|
482
|
+
const t = val;
|
|
483
|
+
merged.telegram = {
|
|
484
|
+
...(existing.telegram || {}),
|
|
485
|
+
...t,
|
|
486
|
+
botToken: typeof t.botToken === 'string' && isMasked(t.botToken) ? existing.telegram?.botToken : t.botToken,
|
|
487
|
+
};
|
|
488
|
+
continue;
|
|
489
|
+
}
|
|
490
|
+
if (key === 'feishu' && typeof val === 'object' && val !== null) {
|
|
491
|
+
const f = val;
|
|
492
|
+
merged.feishu = {
|
|
493
|
+
...(existing.feishu || {}),
|
|
494
|
+
...f,
|
|
495
|
+
appSecret: typeof f.appSecret === 'string' && isMasked(f.appSecret) ? existing.feishu?.appSecret : f.appSecret,
|
|
496
|
+
};
|
|
497
|
+
continue;
|
|
498
|
+
}
|
|
499
|
+
if (key === 'acpAgents' && Array.isArray(val)) {
|
|
500
|
+
merged.acpAgents = val.map((item, i) => {
|
|
501
|
+
const a = item;
|
|
502
|
+
const old = existing.acpAgents?.[i];
|
|
503
|
+
if (a?.auth && typeof a.auth === 'object' && typeof a.auth.token === 'string' && isMasked(a.auth.token)) {
|
|
504
|
+
return { ...a, auth: { ...a.auth, token: old?.auth?.token } };
|
|
505
|
+
}
|
|
506
|
+
return a;
|
|
507
|
+
});
|
|
508
|
+
continue;
|
|
509
|
+
}
|
|
510
|
+
if (typeof val === 'string' && isMasked(val)) {
|
|
511
|
+
continue;
|
|
512
|
+
}
|
|
513
|
+
merged[key] = val;
|
|
514
|
+
}
|
|
515
|
+
const result = validateConfig(merged);
|
|
516
|
+
if (!result.ok) {
|
|
517
|
+
sendJson(res, 400, { error: 'Config validation failed', details: result.errors });
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
await saveConfig(result.config);
|
|
521
|
+
sendJson(res, 200, { ok: true });
|
|
522
|
+
}
|
|
523
|
+
catch (err) {
|
|
524
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
525
|
+
sendJson(res, 400, { error: msg });
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
async function handleAgentsStatus(_req, res) {
|
|
529
|
+
try {
|
|
530
|
+
const agentStatus = await getAgentStatuses();
|
|
531
|
+
sendJson(res, 200, agentStatus);
|
|
532
|
+
}
|
|
533
|
+
catch (err) {
|
|
534
|
+
sendJson(res, 500, { error: 'Failed to check agents' });
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
async function handleListWorkspaces(_req, res, url) {
|
|
538
|
+
try {
|
|
539
|
+
const { workspaceRegistry } = await import('../core/workspace.js');
|
|
540
|
+
// ?full=1 returns the full WorkspaceConfig (including member IDs)
|
|
541
|
+
// for the settings editor; default is the summary shape (member count
|
|
542
|
+
// only) used elsewhere.
|
|
543
|
+
const wantFull = url?.searchParams.get('full') === '1';
|
|
544
|
+
sendJson(res, 200, {
|
|
545
|
+
workspaces: wantFull ? workspaceRegistry.listFull() : workspaceRegistry.list(),
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
catch (err) {
|
|
549
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
550
|
+
sendJson(res, 500, { error: msg });
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
/**
|
|
554
|
+
* Validate + sanitize an incoming WorkspaceConfig from the settings
|
|
555
|
+
* editor. Returns a clean object on success or a string error message
|
|
556
|
+
* on failure. Reused by POST and PATCH so behavior is identical.
|
|
557
|
+
*/
|
|
558
|
+
function validateWorkspacePayload(raw, expectedId) {
|
|
559
|
+
if (!raw || typeof raw !== 'object')
|
|
560
|
+
return { ok: false, error: 'body must be a JSON object' };
|
|
561
|
+
const o = raw;
|
|
562
|
+
const id = String(o.id || '').trim();
|
|
563
|
+
if (!id)
|
|
564
|
+
return { ok: false, error: 'id is required' };
|
|
565
|
+
if (!/^[a-zA-Z0-9_-]+$/.test(id))
|
|
566
|
+
return { ok: false, error: 'id must match [a-zA-Z0-9_-]+' };
|
|
567
|
+
if (id === 'default' && expectedId !== 'default') {
|
|
568
|
+
return { ok: false, error: '"default" workspace is reserved (use PATCH to edit)' };
|
|
569
|
+
}
|
|
570
|
+
if (expectedId && expectedId !== id) {
|
|
571
|
+
return { ok: false, error: `id mismatch: URL is ${expectedId}, body is ${id}` };
|
|
572
|
+
}
|
|
573
|
+
const name = String(o.name || id);
|
|
574
|
+
const agents = Array.isArray(o.agents) ? o.agents.filter((a) => typeof a === 'string') : [];
|
|
575
|
+
const members = Array.isArray(o.members) ? o.members.filter((m) => typeof m === 'string') : undefined;
|
|
576
|
+
let rateLimit;
|
|
577
|
+
if (o.rateLimit && typeof o.rateLimit === 'object') {
|
|
578
|
+
const r = o.rateLimit;
|
|
579
|
+
const rate = Number(r.rate);
|
|
580
|
+
const intervalSec = Number(r.intervalSec);
|
|
581
|
+
const burst = Number(r.burst);
|
|
582
|
+
if (!Number.isFinite(rate) || rate <= 0
|
|
583
|
+
|| !Number.isFinite(intervalSec) || intervalSec <= 0
|
|
584
|
+
|| !Number.isFinite(burst) || burst <= 0) {
|
|
585
|
+
return { ok: false, error: 'rateLimit.rate / intervalSec / burst must be positive numbers' };
|
|
586
|
+
}
|
|
587
|
+
rateLimit = { rate, intervalSec, burst };
|
|
588
|
+
}
|
|
589
|
+
return { ok: true, cfg: { id, name, agents, members, rateLimit } };
|
|
590
|
+
}
|
|
591
|
+
/**
|
|
592
|
+
* Persist the workspaces array back to ~/.im-hub/config.json so changes
|
|
593
|
+
* survive a restart. We do not touch other config fields — settings.html
|
|
594
|
+
* has its own /api/config PUT for that. Best-effort: a write failure is
|
|
595
|
+
* logged but the in-memory registry has already been updated, so the
|
|
596
|
+
* change is live until the next process boot.
|
|
597
|
+
*/
|
|
598
|
+
async function persistWorkspacesToConfig(workspaces) {
|
|
599
|
+
const config = await loadConfig();
|
|
600
|
+
config.workspaces = workspaces;
|
|
601
|
+
await saveConfig(config);
|
|
602
|
+
}
|
|
603
|
+
async function handleCreateOrUpdateWorkspace(req, res, expectedId) {
|
|
604
|
+
try {
|
|
605
|
+
const body = await readBody(req, res);
|
|
606
|
+
let parsed;
|
|
607
|
+
try {
|
|
608
|
+
parsed = JSON.parse(body);
|
|
609
|
+
}
|
|
610
|
+
catch {
|
|
611
|
+
sendJson(res, 400, { ok: false, error: 'Invalid JSON body' });
|
|
612
|
+
return;
|
|
613
|
+
}
|
|
614
|
+
const v = validateWorkspacePayload(parsed, expectedId);
|
|
615
|
+
if (!v.ok) {
|
|
616
|
+
sendJson(res, 400, { ok: false, error: v.error });
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
const { workspaceRegistry } = await import('../core/workspace.js');
|
|
620
|
+
workspaceRegistry.add(v.cfg);
|
|
621
|
+
await persistWorkspacesToConfig(workspaceRegistry.listFull().filter((w) => w.id !== 'default'));
|
|
622
|
+
sendJson(res, 200, { ok: true, workspace: v.cfg });
|
|
623
|
+
}
|
|
624
|
+
catch (err) {
|
|
625
|
+
sendJson(res, 500, { ok: false, error: err instanceof Error ? err.message : String(err) });
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
async function handleDeleteWorkspace(_req, res, id) {
|
|
629
|
+
try {
|
|
630
|
+
const { workspaceRegistry } = await import('../core/workspace.js');
|
|
631
|
+
if (id === 'default') {
|
|
632
|
+
sendJson(res, 400, { ok: false, error: 'cannot delete the default workspace' });
|
|
633
|
+
return;
|
|
634
|
+
}
|
|
635
|
+
const removed = workspaceRegistry.remove(id);
|
|
636
|
+
if (!removed) {
|
|
637
|
+
sendJson(res, 404, { ok: false, error: `workspace "${id}" not found` });
|
|
638
|
+
return;
|
|
639
|
+
}
|
|
640
|
+
await persistWorkspacesToConfig(workspaceRegistry.listFull().filter((w) => w.id !== 'default'));
|
|
641
|
+
sendJson(res, 200, { ok: true });
|
|
642
|
+
}
|
|
643
|
+
catch (err) {
|
|
644
|
+
sendJson(res, 500, { ok: false, error: err instanceof Error ? err.message : String(err) });
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
async function handleMetrics(_req, res, url) {
|
|
648
|
+
try {
|
|
649
|
+
const fmt = url.searchParams.get('format') || 'prom';
|
|
650
|
+
const { snapshot, toPrometheus } = await import('../core/metrics.js');
|
|
651
|
+
if (fmt === 'json') {
|
|
652
|
+
sendJson(res, 200, snapshot());
|
|
653
|
+
return;
|
|
654
|
+
}
|
|
655
|
+
res.writeHead(200, { 'Content-Type': 'text/plain; version=0.0.4' });
|
|
656
|
+
res.end(toPrometheus());
|
|
657
|
+
}
|
|
658
|
+
catch (err) {
|
|
659
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
660
|
+
sendJson(res, 500, { error: msg });
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
/**
|
|
664
|
+
* POST /api/notify → push a message to an IM thread.
|
|
665
|
+
*
|
|
666
|
+
* Body: { platform, threadId, text, card? }
|
|
667
|
+
* Use case: external systems (CI / monitoring / cron) pushing notices
|
|
668
|
+
* back to a chat thread without going through the Agent layer.
|
|
669
|
+
*/
|
|
670
|
+
async function handleNotify(req, res) {
|
|
671
|
+
try {
|
|
672
|
+
const body = await readBody(req, res);
|
|
673
|
+
const { platform, threadId, text, card } = JSON.parse(body);
|
|
674
|
+
if (!platform || !threadId || (!text && !card)) {
|
|
675
|
+
sendJson(res, 400, { error: 'Missing platform / threadId / (text|card)' });
|
|
676
|
+
return;
|
|
677
|
+
}
|
|
678
|
+
// Map platform name to messenger plugin name.
|
|
679
|
+
const messengerName = platform === 'wechat' ? 'wechat-ilink' : platform;
|
|
680
|
+
const messenger = registry.getMessenger(messengerName);
|
|
681
|
+
if (!messenger) {
|
|
682
|
+
sendJson(res, 404, { error: `Messenger "${platform}" not registered` });
|
|
683
|
+
return;
|
|
684
|
+
}
|
|
685
|
+
const traceId = generateTraceId();
|
|
686
|
+
const log = createLogger({ traceId, platform, component: 'notify' });
|
|
687
|
+
log.info({ threadId, hasCard: !!card, textLen: text?.length || 0 }, 'notify in');
|
|
688
|
+
if (card && typeof messenger.sendCard === 'function') {
|
|
689
|
+
await messenger.sendCard(threadId, card);
|
|
690
|
+
}
|
|
691
|
+
else if (text) {
|
|
692
|
+
await messenger.sendMessage(threadId, text);
|
|
693
|
+
}
|
|
694
|
+
else {
|
|
695
|
+
sendJson(res, 400, { error: 'card requires sendCard support, otherwise text is required' });
|
|
696
|
+
return;
|
|
697
|
+
}
|
|
698
|
+
sendJson(res, 200, { ok: true, traceId });
|
|
699
|
+
}
|
|
700
|
+
catch (err) {
|
|
701
|
+
const e = err;
|
|
702
|
+
if (e?.handled)
|
|
703
|
+
return;
|
|
704
|
+
const status = e?.statusCode || 500;
|
|
705
|
+
const msg = e instanceof Error ? e.message : String(err);
|
|
706
|
+
if (!res.headersSent)
|
|
707
|
+
sendJson(res, status, { error: msg });
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
/**
|
|
711
|
+
* POST /api/invoke → run an agent prompt as if it came from a user.
|
|
712
|
+
*
|
|
713
|
+
* Body: { prompt, agent?, userId?, platform? }
|
|
714
|
+
* Returns a JSON response with the full text (for streaming use the ACP
|
|
715
|
+
* server's POST /tasks?mode=stream instead).
|
|
716
|
+
*/
|
|
717
|
+
async function handleInvoke(req, res, defaultAgent) {
|
|
718
|
+
try {
|
|
719
|
+
const body = await readBody(req, res);
|
|
720
|
+
const parsed = JSON.parse(body);
|
|
721
|
+
if (!parsed.prompt) {
|
|
722
|
+
sendJson(res, 400, { error: 'Missing prompt' });
|
|
723
|
+
return;
|
|
724
|
+
}
|
|
725
|
+
const agentName = parsed.agent || defaultAgent;
|
|
726
|
+
const promptText = parsed.agent ? `/${parsed.agent} ${parsed.prompt}` : parsed.prompt;
|
|
727
|
+
const traceId = generateTraceId();
|
|
728
|
+
const platform = parsed.platform || 'rest';
|
|
729
|
+
const log = createLogger({ traceId, platform, component: 'invoke' });
|
|
730
|
+
log.info({ agent: agentName, promptLen: parsed.prompt.length }, 'invoke in');
|
|
731
|
+
const routeCtx = {
|
|
732
|
+
threadId: `rest:${traceId}`,
|
|
733
|
+
channelId: 'rest',
|
|
734
|
+
platform,
|
|
735
|
+
defaultAgent: agentName,
|
|
736
|
+
traceId,
|
|
737
|
+
logger: log,
|
|
738
|
+
userId: parsed.userId || 'rest-caller',
|
|
739
|
+
};
|
|
740
|
+
const parsedMsg = parseMessage(promptText);
|
|
741
|
+
const result = await routeMessage(parsedMsg, routeCtx);
|
|
742
|
+
let fullText = '';
|
|
743
|
+
if (typeof result === 'string') {
|
|
744
|
+
fullText = result;
|
|
745
|
+
}
|
|
746
|
+
else {
|
|
747
|
+
for await (const chunk of result)
|
|
748
|
+
fullText += chunk;
|
|
749
|
+
}
|
|
750
|
+
sendJson(res, 200, { ok: true, traceId, output: { content: fullText } });
|
|
751
|
+
}
|
|
752
|
+
catch (err) {
|
|
753
|
+
const e = err;
|
|
754
|
+
if (e?.handled)
|
|
755
|
+
return;
|
|
756
|
+
const status = e?.statusCode || 500;
|
|
757
|
+
const msg = e instanceof Error ? e.message : String(err);
|
|
758
|
+
if (!res.headersSent)
|
|
759
|
+
sendJson(res, status, { error: msg });
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
async function handleHealth(_req, res) {
|
|
763
|
+
// Quick check: agent availability snapshot. Already used by settings UI;
|
|
764
|
+
// exposing it under /api/health gives ops a stable URL.
|
|
765
|
+
try {
|
|
766
|
+
const status = await getAgentStatuses();
|
|
767
|
+
const anyHealthy = Object.values(status).some(Boolean);
|
|
768
|
+
sendJson(res, anyHealthy ? 200 : 503, {
|
|
769
|
+
ok: anyHealthy,
|
|
770
|
+
agents: status,
|
|
771
|
+
uptimeSec: Math.round(process.uptime()),
|
|
772
|
+
});
|
|
773
|
+
}
|
|
774
|
+
catch (err) {
|
|
775
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
776
|
+
sendJson(res, 500, { ok: false, error: msg });
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
async function handleListJobs(_req, res, url) {
|
|
780
|
+
try {
|
|
781
|
+
const { listJobs, getJobStats } = await import('../core/job-board.js');
|
|
782
|
+
const limit = Math.min(Math.max(parseInt(url.searchParams.get('limit') || '50', 10) || 50, 1), 500);
|
|
783
|
+
const status = url.searchParams.get('status');
|
|
784
|
+
const agent = url.searchParams.get('agent') || undefined;
|
|
785
|
+
const jobs = listJobs(limit, status || undefined, agent ? { agent } : {});
|
|
786
|
+
const stats = getJobStats();
|
|
787
|
+
sendJson(res, 200, { jobs, stats });
|
|
788
|
+
}
|
|
789
|
+
catch (err) {
|
|
790
|
+
sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) });
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
async function handleGetJob(_req, res, id) {
|
|
794
|
+
try {
|
|
795
|
+
const { getJob } = await import('../core/job-board.js');
|
|
796
|
+
const job = getJob(id);
|
|
797
|
+
if (!job) {
|
|
798
|
+
sendJson(res, 404, { error: 'Job not found' });
|
|
799
|
+
return;
|
|
800
|
+
}
|
|
801
|
+
sendJson(res, 200, { job });
|
|
802
|
+
}
|
|
803
|
+
catch (err) {
|
|
804
|
+
sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) });
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
async function handleCancelJob(_req, res, id) {
|
|
808
|
+
try {
|
|
809
|
+
const { cancelJob } = await import('../core/job-board.js');
|
|
810
|
+
sendJson(res, 200, { ok: cancelJob(id) });
|
|
811
|
+
}
|
|
812
|
+
catch (err) {
|
|
813
|
+
sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) });
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
async function handleRunJob(req, res, id) {
|
|
817
|
+
try {
|
|
818
|
+
const { getJob, runJob } = await import('../core/job-board.js');
|
|
819
|
+
const { AgentBase } = await import('../core/agent-base.js');
|
|
820
|
+
const job = getJob(id);
|
|
821
|
+
if (!job) {
|
|
822
|
+
sendJson(res, 404, { error: 'Job not found' });
|
|
823
|
+
return;
|
|
824
|
+
}
|
|
825
|
+
const agent = registry.findAgent(job.agent);
|
|
826
|
+
if (!agent) {
|
|
827
|
+
sendJson(res, 404, { error: `Agent "${job.agent}" not registered` });
|
|
828
|
+
return;
|
|
829
|
+
}
|
|
830
|
+
const traceId = generateTraceId();
|
|
831
|
+
const log = createLogger({ traceId, platform: 'web', component: 'job-run' });
|
|
832
|
+
// Fire and forget — UI polls /api/jobs/:id for status.
|
|
833
|
+
void runJob(id, async function* (j, _logger, signal) {
|
|
834
|
+
if (agent instanceof AgentBase) {
|
|
835
|
+
const text = await agent.spawnAndCollect(j.prompt, signal);
|
|
836
|
+
if (text)
|
|
837
|
+
yield text;
|
|
838
|
+
}
|
|
839
|
+
else {
|
|
840
|
+
for await (const chunk of agent.sendPrompt(`web-job-${j.id}`, j.prompt, [])) {
|
|
841
|
+
if (signal.aborted)
|
|
842
|
+
break;
|
|
843
|
+
yield chunk;
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
}, log).catch(() => { });
|
|
847
|
+
sendJson(res, 200, { ok: true, traceId });
|
|
848
|
+
}
|
|
849
|
+
catch (err) {
|
|
850
|
+
sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) });
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
async function handleCreateJob(req, res) {
|
|
854
|
+
try {
|
|
855
|
+
const body = await readBody(req, res);
|
|
856
|
+
const { agent, prompt } = JSON.parse(body);
|
|
857
|
+
if (!agent || !prompt) {
|
|
858
|
+
sendJson(res, 400, { error: 'Missing agent / prompt' });
|
|
859
|
+
return;
|
|
860
|
+
}
|
|
861
|
+
if (!registry.findAgent(agent)) {
|
|
862
|
+
sendJson(res, 404, { error: `Agent "${agent}" not registered` });
|
|
863
|
+
return;
|
|
864
|
+
}
|
|
865
|
+
const { createJob } = await import('../core/job-board.js');
|
|
866
|
+
const id = createJob(agent, prompt);
|
|
867
|
+
sendJson(res, 200, { ok: true, id });
|
|
868
|
+
}
|
|
869
|
+
catch (err) {
|
|
870
|
+
const e = err;
|
|
871
|
+
if (e?.handled)
|
|
872
|
+
return;
|
|
873
|
+
if (!res.headersSent)
|
|
874
|
+
sendJson(res, e?.statusCode || 500, { error: e instanceof Error ? e.message : String(err) });
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
async function handleListSchedules(_req, res, url) {
|
|
878
|
+
try {
|
|
879
|
+
const { listSchedules } = await import('../core/schedule.js');
|
|
880
|
+
const agent = url.searchParams.get('agent') || undefined;
|
|
881
|
+
sendJson(res, 200, { schedules: listSchedules(50, agent ? { agent } : {}) });
|
|
882
|
+
}
|
|
883
|
+
catch (err) {
|
|
884
|
+
sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) });
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
async function handleListBgjobs(_req, res, url) {
|
|
888
|
+
try {
|
|
889
|
+
const { resolveRoots, listJobsForRoot, listAllJobs } = await import('../core/bgjob-reader.js');
|
|
890
|
+
const rootId = url.searchParams.get('root');
|
|
891
|
+
if (rootId) {
|
|
892
|
+
// Single-root view — used by the dashboard's root selector.
|
|
893
|
+
const root = resolveRoots().find((r) => r.id === rootId);
|
|
894
|
+
if (!root) {
|
|
895
|
+
sendJson(res, 404, { error: `bgjob root "${rootId}" not configured` });
|
|
896
|
+
return;
|
|
897
|
+
}
|
|
898
|
+
const jobs = await listJobsForRoot(root);
|
|
899
|
+
sendJson(res, 200, { roots: [{ id: root.id, label: root.label, path: root.path }], jobs });
|
|
900
|
+
return;
|
|
901
|
+
}
|
|
902
|
+
// No root specified: return all roots' metadata + jobs grouped by root.
|
|
903
|
+
const all = await listAllJobs();
|
|
904
|
+
sendJson(res, 200, {
|
|
905
|
+
roots: all.map(({ root }) => ({ id: root.id, label: root.label, path: root.path })),
|
|
906
|
+
groups: all.map(({ root, jobs }) => ({ rootId: root.id, jobs })),
|
|
907
|
+
});
|
|
908
|
+
}
|
|
909
|
+
catch (err) {
|
|
910
|
+
sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) });
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
async function handleGetBgjob(_req, res, id, url) {
|
|
914
|
+
try {
|
|
915
|
+
const { findRoot, getJobDetail, resolveRoots, listJobsForRoot } = await import('../core/bgjob-reader.js');
|
|
916
|
+
const tail = Math.min(Math.max(parseInt(url.searchParams.get('tail') || '200', 10) || 200, 1), 5000);
|
|
917
|
+
const rootId = url.searchParams.get('root');
|
|
918
|
+
if (rootId) {
|
|
919
|
+
const root = findRoot(rootId);
|
|
920
|
+
if (!root) {
|
|
921
|
+
sendJson(res, 404, { error: `bgjob root "${rootId}" not configured` });
|
|
922
|
+
return;
|
|
923
|
+
}
|
|
924
|
+
const job = await getJobDetail(root, id, tail);
|
|
925
|
+
if (!job) {
|
|
926
|
+
sendJson(res, 404, { error: 'Job not found' });
|
|
927
|
+
return;
|
|
928
|
+
}
|
|
929
|
+
sendJson(res, 200, { job });
|
|
930
|
+
return;
|
|
931
|
+
}
|
|
932
|
+
// No root: try every configured root, return first hit.
|
|
933
|
+
for (const root of resolveRoots()) {
|
|
934
|
+
const summaries = await listJobsForRoot(root);
|
|
935
|
+
if (!summaries.some((s) => s.id === id))
|
|
936
|
+
continue;
|
|
937
|
+
const job = await getJobDetail(root, id, tail);
|
|
938
|
+
if (job) {
|
|
939
|
+
sendJson(res, 200, { job });
|
|
940
|
+
return;
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
sendJson(res, 404, { error: 'Job not found' });
|
|
944
|
+
}
|
|
945
|
+
catch (err) {
|
|
946
|
+
sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) });
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
async function handleListSubtasks(_req, res, url) {
|
|
950
|
+
try {
|
|
951
|
+
const { sessionManager } = await import('../core/session.js');
|
|
952
|
+
const agent = url.searchParams.get('agent') || undefined;
|
|
953
|
+
const subtasks = await sessionManager.listAllSubtasks(agent ? { agent } : {});
|
|
954
|
+
sendJson(res, 200, { subtasks });
|
|
955
|
+
}
|
|
956
|
+
catch (err) {
|
|
957
|
+
sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) });
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
async function handleAudit(_req, res, url) {
|
|
961
|
+
try {
|
|
962
|
+
const { queryInvocations, getStats } = await import('../core/audit-log.js');
|
|
963
|
+
const limit = Math.min(Math.max(parseInt(url.searchParams.get('limit') || '100', 10) || 100, 1), 1000);
|
|
964
|
+
const days = parseInt(url.searchParams.get('days') || '7', 10) || 7;
|
|
965
|
+
const agent = url.searchParams.get('agent') || undefined;
|
|
966
|
+
const platform = url.searchParams.get('platform') || undefined;
|
|
967
|
+
const userId = url.searchParams.get('user') || undefined;
|
|
968
|
+
const intent = url.searchParams.get('intent') || undefined;
|
|
969
|
+
const rows = queryInvocations({ limit, days, agent, platform, userId, intent });
|
|
970
|
+
const stats = getStats();
|
|
971
|
+
sendJson(res, 200, { invocations: rows, stats });
|
|
972
|
+
}
|
|
973
|
+
catch (err) {
|
|
974
|
+
sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) });
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
/**
|
|
978
|
+
* Per-agent operational health snapshot. Drives the Health tab in /tasks.
|
|
979
|
+
*
|
|
980
|
+
* Combines three independent live data sources:
|
|
981
|
+
* - circuit breaker phase / cooldown remaining (core/circuit-breaker.ts)
|
|
982
|
+
* - rate-limiter remaining tokens & config (core/rate-limiter.ts agentLimiter)
|
|
983
|
+
* - latency p50 / p95 / p99 + invocation totals (core/metrics.ts snapshot)
|
|
984
|
+
*
|
|
985
|
+
* No persistence — pure read of in-memory state. Cheap to call (<1 ms for
|
|
986
|
+
* a typical agent fleet) so the page is happy to poll on a 5 s tick.
|
|
987
|
+
*/
|
|
988
|
+
async function handleAgentHealth(_req, res) {
|
|
989
|
+
try {
|
|
990
|
+
const { circuitBreaker } = await import('../core/circuit-breaker.js');
|
|
991
|
+
const { agentLimiter } = await import('../core/rate-limiter.js');
|
|
992
|
+
const { snapshot } = await import('../core/metrics.js');
|
|
993
|
+
const snap = snapshot();
|
|
994
|
+
const now = Date.now();
|
|
995
|
+
const agents = registry.listAgents().map((name) => {
|
|
996
|
+
const breaker = circuitBreaker.getStatus(name);
|
|
997
|
+
const cooldownRemainingMs = breaker.openedAt && breaker.phase !== 'closed'
|
|
998
|
+
? Math.max(0, breaker.cooldownMs - (now - breaker.openedAt))
|
|
999
|
+
: 0;
|
|
1000
|
+
const rate = agentLimiter.status(name);
|
|
1001
|
+
const m = snap.agents.find((a) => a.agent === name);
|
|
1002
|
+
return {
|
|
1003
|
+
agent: name,
|
|
1004
|
+
breaker: {
|
|
1005
|
+
phase: breaker.phase,
|
|
1006
|
+
failures: breaker.failures,
|
|
1007
|
+
cooldownMs: breaker.cooldownMs,
|
|
1008
|
+
cooldownRemainingMs,
|
|
1009
|
+
},
|
|
1010
|
+
rate: {
|
|
1011
|
+
remaining: rate.remaining,
|
|
1012
|
+
rate: rate.rate,
|
|
1013
|
+
intervalSec: rate.intervalSec,
|
|
1014
|
+
},
|
|
1015
|
+
invocations: m
|
|
1016
|
+
? {
|
|
1017
|
+
total: m.total,
|
|
1018
|
+
success: m.success,
|
|
1019
|
+
failure: m.failure,
|
|
1020
|
+
successRate: m.successRate,
|
|
1021
|
+
costSum: m.costSum,
|
|
1022
|
+
sampleCount: m.sampleCount,
|
|
1023
|
+
p50Ms: m.p50Ms,
|
|
1024
|
+
p95Ms: m.p95Ms,
|
|
1025
|
+
p99Ms: m.p99Ms,
|
|
1026
|
+
}
|
|
1027
|
+
: null,
|
|
1028
|
+
};
|
|
1029
|
+
});
|
|
1030
|
+
sendJson(res, 200, { agents, uptimeSec: snap.uptimeSec });
|
|
1031
|
+
}
|
|
1032
|
+
catch (err) {
|
|
1033
|
+
sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) });
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
/**
|
|
1037
|
+
* List every currently-pending HITL approval across all sessions /
|
|
1038
|
+
* platforms. Used by the global Approvals tab in /tasks so the operator
|
|
1039
|
+
* can see at a glance whether something is waiting on a y/n that nobody
|
|
1040
|
+
* is around to give.
|
|
1041
|
+
*/
|
|
1042
|
+
async function handleListApprovals(_req, res) {
|
|
1043
|
+
try {
|
|
1044
|
+
const { approvalBus } = await import('../core/approval-bus.js');
|
|
1045
|
+
const pending = approvalBus.listPending();
|
|
1046
|
+
const metrics = approvalBus.getMetrics();
|
|
1047
|
+
sendJson(res, 200, { pending, metrics });
|
|
1048
|
+
}
|
|
1049
|
+
catch (err) {
|
|
1050
|
+
sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) });
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
/**
|
|
1054
|
+
* Resolve an approval by reqId (admin / dashboard path). Body:
|
|
1055
|
+
* { behavior: 'allow' | 'deny', autoAllowFurther?: boolean, message?: string }
|
|
1056
|
+
*
|
|
1057
|
+
* resolvePending operates on threadId, not reqId, so we walk listPending
|
|
1058
|
+
* to find the matching pending and forward to the bus. The web token is
|
|
1059
|
+
* the access gate — anyone with it can resolve any pending. Multi-tenant
|
|
1060
|
+
* scoping is tracked separately with the rest of cross-cutting #3.1.
|
|
1061
|
+
*/
|
|
1062
|
+
async function handleResolveApproval(req, res, reqId) {
|
|
1063
|
+
try {
|
|
1064
|
+
const body = await readBody(req, res);
|
|
1065
|
+
let parsed;
|
|
1066
|
+
try {
|
|
1067
|
+
parsed = JSON.parse(body);
|
|
1068
|
+
}
|
|
1069
|
+
catch {
|
|
1070
|
+
sendJson(res, 400, { ok: false, error: 'Invalid JSON body' });
|
|
1071
|
+
return;
|
|
1072
|
+
}
|
|
1073
|
+
if (parsed.behavior !== 'allow' && parsed.behavior !== 'deny') {
|
|
1074
|
+
sendJson(res, 400, { ok: false, error: 'behavior must be "allow" or "deny"' });
|
|
1075
|
+
return;
|
|
1076
|
+
}
|
|
1077
|
+
const { approvalBus } = await import('../core/approval-bus.js');
|
|
1078
|
+
const target = approvalBus.listPending().find((p) => p.reqId === reqId);
|
|
1079
|
+
if (!target) {
|
|
1080
|
+
sendJson(res, 404, { ok: false, error: 'Approval not pending (may have already resolved or timed out)' });
|
|
1081
|
+
return;
|
|
1082
|
+
}
|
|
1083
|
+
const decision = parsed.behavior === 'allow'
|
|
1084
|
+
? { behavior: 'allow', autoAllowFurther: parsed.autoAllowFurther === true }
|
|
1085
|
+
: { behavior: 'deny', message: parsed.message || 'denied via dashboard' };
|
|
1086
|
+
const resolved = approvalBus.resolvePending(target.threadId, decision);
|
|
1087
|
+
if (!resolved) {
|
|
1088
|
+
sendJson(res, 409, { ok: false, error: 'Race: pending vanished between list and resolve' });
|
|
1089
|
+
return;
|
|
1090
|
+
}
|
|
1091
|
+
sendJson(res, 200, { ok: true, resolved });
|
|
1092
|
+
}
|
|
1093
|
+
catch (err) {
|
|
1094
|
+
sendJson(res, 500, { ok: false, error: err instanceof Error ? err.message : String(err) });
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
/** Hard cap on file content we'll ship over the wire — avoids OOM and keeps
|
|
1098
|
+
* the browser responsive. Matches the soft limit our log-tail endpoints use. */
|
|
1099
|
+
const WORKSPACE_FILE_MAX_BYTES = 1 * 1024 * 1024;
|
|
1100
|
+
/** Bytes scanned for a binary heuristic. Null byte in this window → binary. */
|
|
1101
|
+
const BINARY_PROBE_BYTES = 8 * 1024;
|
|
1102
|
+
/**
|
|
1103
|
+
* Read-only view of `~/.im-hub-workspaces/<agent>/`. The Files tab in /tasks
|
|
1104
|
+
* uses it to inspect what an IM-context agent is reading and writing into its
|
|
1105
|
+
* pinned workspace (CLAUDE.md, AGENTS.md, scratch notes, etc.).
|
|
1106
|
+
*
|
|
1107
|
+
* Query params:
|
|
1108
|
+
* agent — required, MUST match a registered agent name. We reject anything
|
|
1109
|
+
* else to keep `agent` from sneaking traversal segments past the
|
|
1110
|
+
* join (e.g. `?agent=../../etc`).
|
|
1111
|
+
* path — optional relative path under the agent's workspace. Defaults to ''.
|
|
1112
|
+
*
|
|
1113
|
+
* Response shape:
|
|
1114
|
+
* { type:'dir', entries:[{name,isDir,size,mtime}] }
|
|
1115
|
+
* { type:'file', content, size, encoding:'utf-8'|'base64', truncated }
|
|
1116
|
+
*
|
|
1117
|
+
* Path-traversal defense: after `resolvePath(base, userPath)` we verify the
|
|
1118
|
+
* result is exactly `base` or starts with `base + sep`. A `..`-laden path
|
|
1119
|
+
* collapses outside the base and gets rejected before any read.
|
|
1120
|
+
*
|
|
1121
|
+
* Edits / writes intentionally not exposed; ops use plain ssh.
|
|
1122
|
+
*/
|
|
1123
|
+
async function handleWorkspaceFiles(_req, res, url) {
|
|
1124
|
+
try {
|
|
1125
|
+
const agent = url.searchParams.get('agent') || '';
|
|
1126
|
+
const userPath = url.searchParams.get('path') || '';
|
|
1127
|
+
if (!agent) {
|
|
1128
|
+
sendJson(res, 400, { error: 'Missing required ?agent=' });
|
|
1129
|
+
return;
|
|
1130
|
+
}
|
|
1131
|
+
// Whitelist agent against the registry. Even an empty registry won't
|
|
1132
|
+
// expose anything because a non-registered name fails this check.
|
|
1133
|
+
const known = new Set(registry.listAgents());
|
|
1134
|
+
if (!known.has(agent)) {
|
|
1135
|
+
sendJson(res, 404, { error: `Unknown agent "${agent}"` });
|
|
1136
|
+
return;
|
|
1137
|
+
}
|
|
1138
|
+
const { defaultAgentCwd } = await import('../core/agent-cwd.js');
|
|
1139
|
+
const base = resolvePath(defaultAgentCwd(agent));
|
|
1140
|
+
const target = resolvePath(base, userPath);
|
|
1141
|
+
// The base itself is allowed; anything else must live strictly below it.
|
|
1142
|
+
if (target !== base && !target.startsWith(base + pathSep)) {
|
|
1143
|
+
sendJson(res, 400, { error: 'Path escapes workspace root' });
|
|
1144
|
+
return;
|
|
1145
|
+
}
|
|
1146
|
+
let st;
|
|
1147
|
+
try {
|
|
1148
|
+
st = await stat(target);
|
|
1149
|
+
}
|
|
1150
|
+
catch (err) {
|
|
1151
|
+
const e = err;
|
|
1152
|
+
if (e.code === 'ENOENT') {
|
|
1153
|
+
sendJson(res, 404, { error: 'Not found', path: relativePath(base, target) });
|
|
1154
|
+
return;
|
|
1155
|
+
}
|
|
1156
|
+
throw err;
|
|
1157
|
+
}
|
|
1158
|
+
if (st.isDirectory()) {
|
|
1159
|
+
const names = await readdir(target);
|
|
1160
|
+
const entries = await Promise.all(names.map(async (name) => {
|
|
1161
|
+
try {
|
|
1162
|
+
const sub = await stat(join(target, name));
|
|
1163
|
+
return {
|
|
1164
|
+
name,
|
|
1165
|
+
isDir: sub.isDirectory(),
|
|
1166
|
+
size: sub.isDirectory() ? null : sub.size,
|
|
1167
|
+
mtime: sub.mtime.toISOString(),
|
|
1168
|
+
};
|
|
1169
|
+
}
|
|
1170
|
+
catch {
|
|
1171
|
+
// Broken symlink or race-deleted entry — surface but mark unknown.
|
|
1172
|
+
return { name, isDir: false, size: null, mtime: null, broken: true };
|
|
1173
|
+
}
|
|
1174
|
+
}));
|
|
1175
|
+
// Dirs first, then case-insensitive name sort — matches the convention
|
|
1176
|
+
// most file-managers use. Stable in practice (Intl.Collator sort).
|
|
1177
|
+
entries.sort((a, b) => {
|
|
1178
|
+
if (a.isDir !== b.isDir)
|
|
1179
|
+
return a.isDir ? -1 : 1;
|
|
1180
|
+
return a.name.localeCompare(b.name, undefined, { sensitivity: 'base' });
|
|
1181
|
+
});
|
|
1182
|
+
sendJson(res, 200, {
|
|
1183
|
+
type: 'dir',
|
|
1184
|
+
agent,
|
|
1185
|
+
path: relativePath(base, target),
|
|
1186
|
+
base,
|
|
1187
|
+
entries,
|
|
1188
|
+
});
|
|
1189
|
+
return;
|
|
1190
|
+
}
|
|
1191
|
+
if (!st.isFile()) {
|
|
1192
|
+
sendJson(res, 400, { error: 'Not a regular file' });
|
|
1193
|
+
return;
|
|
1194
|
+
}
|
|
1195
|
+
// For files larger than the cap, we still return metadata + a note —
|
|
1196
|
+
// user can ssh in for the rest. Avoids surprising the browser with
|
|
1197
|
+
// a 50 MB log dump.
|
|
1198
|
+
const truncated = st.size > WORKSPACE_FILE_MAX_BYTES;
|
|
1199
|
+
const buf = await readFile(target);
|
|
1200
|
+
const slice = truncated ? buf.subarray(0, WORKSPACE_FILE_MAX_BYTES) : buf;
|
|
1201
|
+
// Binary detection: a NUL byte in the first 8 KB is a strong signal.
|
|
1202
|
+
// Cheaper than full UTF-8 validation and matches grep's heuristic.
|
|
1203
|
+
const probe = slice.subarray(0, Math.min(slice.length, BINARY_PROBE_BYTES));
|
|
1204
|
+
const isBinary = probe.includes(0);
|
|
1205
|
+
sendJson(res, 200, {
|
|
1206
|
+
type: 'file',
|
|
1207
|
+
agent,
|
|
1208
|
+
path: relativePath(base, target),
|
|
1209
|
+
size: st.size,
|
|
1210
|
+
mtime: st.mtime.toISOString(),
|
|
1211
|
+
encoding: isBinary ? 'base64' : 'utf-8',
|
|
1212
|
+
content: isBinary ? slice.toString('base64') : slice.toString('utf-8'),
|
|
1213
|
+
truncated,
|
|
1214
|
+
});
|
|
1215
|
+
}
|
|
1216
|
+
catch (err) {
|
|
1217
|
+
sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) });
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
/**
|
|
1221
|
+
* Inline edit of a workspace file. UI use-case: annotate the agent's
|
|
1222
|
+
* CLAUDE.md / AGENTS.md / scratch notes from the dashboard without
|
|
1223
|
+
* shelling into the host.
|
|
1224
|
+
*
|
|
1225
|
+
* Body: { content: string } — UTF-8 text only (binary writes refused).
|
|
1226
|
+
* Response: { ok: true, size, mtime } on success.
|
|
1227
|
+
*
|
|
1228
|
+
* Safety:
|
|
1229
|
+
* - Same agent + path traversal guards as the GET handler.
|
|
1230
|
+
* - 1 MiB hard cap on `content` (matches the read cap so a roundtrip
|
|
1231
|
+
* edit can't grow a file beyond what the read can show).
|
|
1232
|
+
* - Atomic write: stage to `<target>.tmp.<rand>` then `rename` so a
|
|
1233
|
+
* crash mid-write can't leave a half-truncated file. The .tmp file
|
|
1234
|
+
* is unlinked on any error path.
|
|
1235
|
+
* - Refuses to overwrite a directory; refuses to create a parent dir
|
|
1236
|
+
* that doesn't exist (no implicit mkdir-p — keeps surprises out).
|
|
1237
|
+
*/
|
|
1238
|
+
async function handleWorkspaceFileWrite(req, res, url) {
|
|
1239
|
+
try {
|
|
1240
|
+
const agent = url.searchParams.get('agent') || '';
|
|
1241
|
+
const userPath = url.searchParams.get('path') || '';
|
|
1242
|
+
if (!agent) {
|
|
1243
|
+
sendJson(res, 400, { error: 'Missing required ?agent=' });
|
|
1244
|
+
return;
|
|
1245
|
+
}
|
|
1246
|
+
if (!userPath) {
|
|
1247
|
+
sendJson(res, 400, { error: 'Missing required ?path=' });
|
|
1248
|
+
return;
|
|
1249
|
+
}
|
|
1250
|
+
const known = new Set(registry.listAgents());
|
|
1251
|
+
if (!known.has(agent)) {
|
|
1252
|
+
sendJson(res, 404, { error: `Unknown agent "${agent}"` });
|
|
1253
|
+
return;
|
|
1254
|
+
}
|
|
1255
|
+
const { defaultAgentCwd } = await import('../core/agent-cwd.js');
|
|
1256
|
+
const base = resolvePath(defaultAgentCwd(agent));
|
|
1257
|
+
const target = resolvePath(base, userPath);
|
|
1258
|
+
if (target !== base && !target.startsWith(base + pathSep)) {
|
|
1259
|
+
sendJson(res, 400, { error: 'Path escapes workspace root' });
|
|
1260
|
+
return;
|
|
1261
|
+
}
|
|
1262
|
+
if (target === base) {
|
|
1263
|
+
sendJson(res, 400, { error: 'Cannot overwrite workspace root' });
|
|
1264
|
+
return;
|
|
1265
|
+
}
|
|
1266
|
+
const body = await readBody(req, res);
|
|
1267
|
+
let parsed;
|
|
1268
|
+
try {
|
|
1269
|
+
parsed = JSON.parse(body);
|
|
1270
|
+
}
|
|
1271
|
+
catch {
|
|
1272
|
+
sendJson(res, 400, { error: 'Invalid JSON body' });
|
|
1273
|
+
return;
|
|
1274
|
+
}
|
|
1275
|
+
if (typeof parsed.content !== 'string') {
|
|
1276
|
+
sendJson(res, 400, { error: 'content must be a string' });
|
|
1277
|
+
return;
|
|
1278
|
+
}
|
|
1279
|
+
const content = parsed.content;
|
|
1280
|
+
// Encode early so the size check is on bytes, not chars (a single
|
|
1281
|
+
// CJK char is 3 bytes UTF-8 — char-count would lie about file size).
|
|
1282
|
+
const buf = Buffer.from(content, 'utf-8');
|
|
1283
|
+
if (buf.length > WORKSPACE_FILE_MAX_BYTES) {
|
|
1284
|
+
sendJson(res, 413, { error: 'Content exceeds 1 MiB cap' });
|
|
1285
|
+
return;
|
|
1286
|
+
}
|
|
1287
|
+
// Reject content that contains a NUL byte. UTF-8 text never has one;
|
|
1288
|
+
// accidental binary upload here would corrupt the editor on next read.
|
|
1289
|
+
if (buf.includes(0)) {
|
|
1290
|
+
sendJson(res, 400, { error: 'NUL byte in content — only UTF-8 text accepted' });
|
|
1291
|
+
return;
|
|
1292
|
+
}
|
|
1293
|
+
// Existing-target guards: must not be a directory; parent dir must
|
|
1294
|
+
// exist (no implicit mkdir-p — too easy to typo a deep path and
|
|
1295
|
+
// create a hidden mess).
|
|
1296
|
+
try {
|
|
1297
|
+
const st = await stat(target);
|
|
1298
|
+
if (st.isDirectory()) {
|
|
1299
|
+
sendJson(res, 400, { error: 'Target is a directory' });
|
|
1300
|
+
return;
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
catch (err) {
|
|
1304
|
+
const e = err;
|
|
1305
|
+
if (e.code !== 'ENOENT')
|
|
1306
|
+
throw err;
|
|
1307
|
+
// Doesn't exist yet — that's fine for new-file writes, but the
|
|
1308
|
+
// parent directory must be present.
|
|
1309
|
+
const parent = dirname(target);
|
|
1310
|
+
try {
|
|
1311
|
+
const ps = await stat(parent);
|
|
1312
|
+
if (!ps.isDirectory()) {
|
|
1313
|
+
sendJson(res, 400, { error: 'Parent path is not a directory' });
|
|
1314
|
+
return;
|
|
1315
|
+
}
|
|
1316
|
+
}
|
|
1317
|
+
catch {
|
|
1318
|
+
sendJson(res, 400, { error: 'Parent directory does not exist' });
|
|
1319
|
+
return;
|
|
1320
|
+
}
|
|
1321
|
+
}
|
|
1322
|
+
// Atomic write. crypto.randomBytes is the cheapest unique suffix and
|
|
1323
|
+
// already imported up top.
|
|
1324
|
+
const tmp = `${target}.tmp.${randomBytes(6).toString('hex')}`;
|
|
1325
|
+
try {
|
|
1326
|
+
await writeFile(tmp, buf, { mode: 0o600 });
|
|
1327
|
+
await rename(tmp, target);
|
|
1328
|
+
}
|
|
1329
|
+
catch (err) {
|
|
1330
|
+
try {
|
|
1331
|
+
await unlink(tmp);
|
|
1332
|
+
}
|
|
1333
|
+
catch { /* tmp may not have been created */ }
|
|
1334
|
+
throw err;
|
|
1335
|
+
}
|
|
1336
|
+
const finalSt = await stat(target);
|
|
1337
|
+
sendJson(res, 200, {
|
|
1338
|
+
ok: true,
|
|
1339
|
+
agent,
|
|
1340
|
+
path: relativePath(base, target),
|
|
1341
|
+
size: finalSt.size,
|
|
1342
|
+
mtime: finalSt.mtime.toISOString(),
|
|
1343
|
+
});
|
|
1344
|
+
}
|
|
1345
|
+
catch (err) {
|
|
1346
|
+
const e = err;
|
|
1347
|
+
if (e?.handled)
|
|
1348
|
+
return;
|
|
1349
|
+
if (!res.headersSent)
|
|
1350
|
+
sendJson(res, e?.statusCode || 500, { error: e instanceof Error ? e.message : String(err) });
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1353
|
+
/**
|
|
1354
|
+
* Run cancel/run across an array of job ids in one request. Saves N round
|
|
1355
|
+
* trips when the user multi-selects a long list in the Jobs tab.
|
|
1356
|
+
*
|
|
1357
|
+
* Body: { ids: number[] }
|
|
1358
|
+
* Response: { results: Array<{ id, ok, error?, traceId? }> }
|
|
1359
|
+
*
|
|
1360
|
+
* Per-id failures don't fail the whole request — each entry carries its own
|
|
1361
|
+
* status so the UI can mark partial success.
|
|
1362
|
+
*/
|
|
1363
|
+
async function handleBatchJob(req, res, action, defaultAgent) {
|
|
1364
|
+
try {
|
|
1365
|
+
const body = await readBody(req, res);
|
|
1366
|
+
let parsed;
|
|
1367
|
+
try {
|
|
1368
|
+
parsed = JSON.parse(body);
|
|
1369
|
+
}
|
|
1370
|
+
catch {
|
|
1371
|
+
sendJson(res, 400, { error: 'Invalid JSON body' });
|
|
1372
|
+
return;
|
|
1373
|
+
}
|
|
1374
|
+
if (!Array.isArray(parsed.ids) || parsed.ids.length === 0) {
|
|
1375
|
+
sendJson(res, 400, { error: 'ids must be a non-empty array of numbers' });
|
|
1376
|
+
return;
|
|
1377
|
+
}
|
|
1378
|
+
// Cap so a runaway client can't queue thousands of spawns at once. Same
|
|
1379
|
+
// ceiling we use elsewhere for batched ops.
|
|
1380
|
+
if (parsed.ids.length > 100) {
|
|
1381
|
+
sendJson(res, 400, { error: 'Maximum 100 ids per batch' });
|
|
1382
|
+
return;
|
|
1383
|
+
}
|
|
1384
|
+
const ids = parsed.ids
|
|
1385
|
+
.map((x) => (typeof x === 'number' ? x : parseInt(String(x), 10)))
|
|
1386
|
+
.filter((n) => Number.isFinite(n) && n > 0);
|
|
1387
|
+
if (ids.length === 0) {
|
|
1388
|
+
sendJson(res, 400, { error: 'No valid ids in array' });
|
|
1389
|
+
return;
|
|
1390
|
+
}
|
|
1391
|
+
const { getJob, cancelJob, runJob } = await import('../core/job-board.js');
|
|
1392
|
+
const { AgentBase } = await import('../core/agent-base.js');
|
|
1393
|
+
const results = await Promise.all(ids.map(async (id) => {
|
|
1394
|
+
try {
|
|
1395
|
+
if (action === 'cancel') {
|
|
1396
|
+
return { id, ok: cancelJob(id) };
|
|
1397
|
+
}
|
|
1398
|
+
const job = getJob(id);
|
|
1399
|
+
if (!job)
|
|
1400
|
+
return { id, ok: false, error: 'Job not found' };
|
|
1401
|
+
const agent = registry.findAgent(job.agent);
|
|
1402
|
+
if (!agent)
|
|
1403
|
+
return { id, ok: false, error: `Agent "${job.agent}" not registered` };
|
|
1404
|
+
const traceId = generateTraceId();
|
|
1405
|
+
const log = createLogger({ traceId, platform: 'web', component: 'job-run-batch' });
|
|
1406
|
+
// Same fire-and-forget pattern as handleRunJob — the dashboard
|
|
1407
|
+
// streams status from /events / /api/jobs.
|
|
1408
|
+
void runJob(id, async function* (j, _logger, signal) {
|
|
1409
|
+
if (agent instanceof AgentBase) {
|
|
1410
|
+
const text = await agent.spawnAndCollect(j.prompt, signal);
|
|
1411
|
+
if (text)
|
|
1412
|
+
yield text;
|
|
1413
|
+
}
|
|
1414
|
+
else {
|
|
1415
|
+
for await (const chunk of agent.sendPrompt(`web-job-${j.id}`, j.prompt, [])) {
|
|
1416
|
+
if (signal.aborted)
|
|
1417
|
+
break;
|
|
1418
|
+
yield chunk;
|
|
1419
|
+
}
|
|
1420
|
+
}
|
|
1421
|
+
}, log).catch(() => { });
|
|
1422
|
+
return { id, ok: true, traceId };
|
|
1423
|
+
}
|
|
1424
|
+
catch (err) {
|
|
1425
|
+
return { id, ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
1426
|
+
}
|
|
1427
|
+
}));
|
|
1428
|
+
// defaultAgent isn't used directly — runJob reads job.agent — but we
|
|
1429
|
+
// accept it to keep the call-site symmetric with handleInvoke.
|
|
1430
|
+
void defaultAgent;
|
|
1431
|
+
sendJson(res, 200, { results });
|
|
1432
|
+
}
|
|
1433
|
+
catch (err) {
|
|
1434
|
+
const e = err;
|
|
1435
|
+
if (e?.handled)
|
|
1436
|
+
return;
|
|
1437
|
+
if (!res.headersSent)
|
|
1438
|
+
sendJson(res, e?.statusCode || 500, { error: e instanceof Error ? e.message : String(err) });
|
|
1439
|
+
}
|
|
1440
|
+
}
|
|
1441
|
+
/**
|
|
1442
|
+
* Server-Sent Events stream for real-time dashboard updates. Subscribes
|
|
1443
|
+
* to the in-process event-bus and forwards every event as an SSE frame.
|
|
1444
|
+
*
|
|
1445
|
+
* On connect we replay the last ~200 events from the bus's recent ring
|
|
1446
|
+
* so a freshly-opened tab doesn't have to wait for the next event to
|
|
1447
|
+
* have any context.
|
|
1448
|
+
*
|
|
1449
|
+
* Heartbeats every 25 s — Node's default keepalive isn't enough for some
|
|
1450
|
+
* proxies (nginx default is 60s idle close, browsers reconnect EventSource
|
|
1451
|
+
* automatically but we'd rather avoid the churn).
|
|
1452
|
+
*
|
|
1453
|
+
* Token-gated like every other /api endpoint via the upstream guard.
|
|
1454
|
+
*/
|
|
1455
|
+
async function handleEventsSSE(req, res) {
|
|
1456
|
+
const { eventBus } = await import('../core/event-bus.js');
|
|
1457
|
+
res.writeHead(200, {
|
|
1458
|
+
'Content-Type': 'text/event-stream; charset=utf-8',
|
|
1459
|
+
'Cache-Control': 'no-cache, no-transform',
|
|
1460
|
+
'Connection': 'keep-alive',
|
|
1461
|
+
'X-Accel-Buffering': 'no',
|
|
1462
|
+
});
|
|
1463
|
+
// Tell the client what we count as "now" so it can disambiguate replay
|
|
1464
|
+
// from live events.
|
|
1465
|
+
res.write(`event: hello\ndata: ${JSON.stringify({ ts: new Date().toISOString() })}\n\n`);
|
|
1466
|
+
// Replay recent buffer.
|
|
1467
|
+
for (const e of eventBus.getRecent()) {
|
|
1468
|
+
res.write(`event: ${e.type}\ndata: ${JSON.stringify(e)}\n\n`);
|
|
1469
|
+
}
|
|
1470
|
+
const onEvent = (e) => {
|
|
1471
|
+
try {
|
|
1472
|
+
res.write(`event: ${e.type}\ndata: ${JSON.stringify(e)}\n\n`);
|
|
1473
|
+
}
|
|
1474
|
+
catch {
|
|
1475
|
+
// Likely socket closed. The 'close' handler below will clean up;
|
|
1476
|
+
// swallow here so a downstream listener error doesn't propagate.
|
|
1477
|
+
}
|
|
1478
|
+
};
|
|
1479
|
+
eventBus.on('event', onEvent);
|
|
1480
|
+
// Periodic keepalive comment so proxies don't close idle connections.
|
|
1481
|
+
// SSE comments start with ':' and are ignored by EventSource clients.
|
|
1482
|
+
const heartbeat = setInterval(() => {
|
|
1483
|
+
try {
|
|
1484
|
+
res.write(': keepalive\n\n');
|
|
1485
|
+
}
|
|
1486
|
+
catch { /* socket closed */ }
|
|
1487
|
+
}, 25_000);
|
|
1488
|
+
if (typeof heartbeat === 'object' && heartbeat && 'unref' in heartbeat) {
|
|
1489
|
+
heartbeat.unref();
|
|
1490
|
+
}
|
|
1491
|
+
const cleanup = () => {
|
|
1492
|
+
clearInterval(heartbeat);
|
|
1493
|
+
eventBus.off('event', onEvent);
|
|
1494
|
+
};
|
|
1495
|
+
req.on('close', cleanup);
|
|
1496
|
+
req.on('error', cleanup);
|
|
1497
|
+
}
|
|
1498
|
+
async function handleAcpDiscover(req, res) {
|
|
1499
|
+
try {
|
|
1500
|
+
const body = await readBody(req, res);
|
|
1501
|
+
const { baseUrl, register } = JSON.parse(body);
|
|
1502
|
+
if (!baseUrl) {
|
|
1503
|
+
sendJson(res, 400, { error: 'Missing baseUrl' });
|
|
1504
|
+
return;
|
|
1505
|
+
}
|
|
1506
|
+
const { discoverAgents } = await import('../plugins/agents/acp/discovery.js');
|
|
1507
|
+
const result = await discoverAgents(baseUrl);
|
|
1508
|
+
if (register) {
|
|
1509
|
+
await registry.loadACPAgents(result.agents);
|
|
1510
|
+
}
|
|
1511
|
+
sendJson(res, 200, { ok: true, baseUrl: result.baseUrl, agents: result.agents });
|
|
1512
|
+
}
|
|
1513
|
+
catch (err) {
|
|
1514
|
+
const e = err;
|
|
1515
|
+
if (e?.handled)
|
|
1516
|
+
return;
|
|
1517
|
+
const status = e?.statusCode || 500;
|
|
1518
|
+
const msg = e instanceof Error ? e.message : String(err);
|
|
1519
|
+
if (!res.headersSent)
|
|
1520
|
+
sendJson(res, status, { ok: false, error: msg });
|
|
1521
|
+
}
|
|
1522
|
+
}
|
|
1523
|
+
async function handleAcpTest(req, res) {
|
|
1524
|
+
try {
|
|
1525
|
+
const body = await readBody(req, res);
|
|
1526
|
+
// M11: bare JSON.parse threw a SyntaxError that bubbled out into the
|
|
1527
|
+
// outer catch and surfaced as a "500-ish 400" with the parser's raw
|
|
1528
|
+
// message. Validate explicitly so malformed bodies get a clean 400.
|
|
1529
|
+
let parsed;
|
|
1530
|
+
try {
|
|
1531
|
+
parsed = JSON.parse(body);
|
|
1532
|
+
}
|
|
1533
|
+
catch {
|
|
1534
|
+
sendJson(res, 400, { ok: false, error: 'Invalid JSON body' });
|
|
1535
|
+
return;
|
|
1536
|
+
}
|
|
1537
|
+
const { endpoint, auth } = parsed;
|
|
1538
|
+
if (!endpoint || typeof endpoint !== 'string') {
|
|
1539
|
+
sendJson(res, 400, { ok: false, error: 'Missing or invalid "endpoint"' });
|
|
1540
|
+
return;
|
|
1541
|
+
}
|
|
1542
|
+
// Dynamic import to avoid circular deps
|
|
1543
|
+
const { ACPClient } = await import('../plugins/agents/acp/acp-client.js');
|
|
1544
|
+
const client = new ACPClient({ name: 'test', endpoint, auth: auth });
|
|
1545
|
+
const manifest = await client.fetchManifest();
|
|
1546
|
+
sendJson(res, 200, {
|
|
1547
|
+
ok: true,
|
|
1548
|
+
name: manifest.name,
|
|
1549
|
+
description: manifest.description,
|
|
1550
|
+
});
|
|
1551
|
+
}
|
|
1552
|
+
catch (err) {
|
|
1553
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1554
|
+
sendJson(res, 400, { ok: false, error: msg });
|
|
1555
|
+
}
|
|
1556
|
+
}
|
|
1557
|
+
// ============================================
|
|
1558
|
+
// Helpers
|
|
1559
|
+
// ============================================
|
|
1560
|
+
async function getAgentStatuses() {
|
|
1561
|
+
const agents = registry.listAgents();
|
|
1562
|
+
const status = {};
|
|
1563
|
+
await Promise.all(agents.map(async (name) => {
|
|
1564
|
+
const agent = registry.findAgent(name);
|
|
1565
|
+
if (agent) {
|
|
1566
|
+
try {
|
|
1567
|
+
status[name] = await agent.isAvailable();
|
|
1568
|
+
}
|
|
1569
|
+
catch {
|
|
1570
|
+
status[name] = false;
|
|
1571
|
+
}
|
|
1572
|
+
}
|
|
1573
|
+
}));
|
|
1574
|
+
return status;
|
|
1575
|
+
}
|
|
1576
|
+
function mask(value) {
|
|
1577
|
+
if (!value)
|
|
1578
|
+
return '';
|
|
1579
|
+
if (value.length <= 4)
|
|
1580
|
+
return '****';
|
|
1581
|
+
return value.slice(0, 2) + '****' + value.slice(-2);
|
|
1582
|
+
}
|
|
1583
|
+
/** Hard cap on inbound JSON bodies for the Web REST API. */
|
|
1584
|
+
const MAX_API_BODY_BYTES = 1 * 1024 * 1024; // 1 MiB
|
|
1585
|
+
function readBody(req, res) {
|
|
1586
|
+
return new Promise((resolve, reject) => {
|
|
1587
|
+
const chunks = [];
|
|
1588
|
+
let total = 0;
|
|
1589
|
+
let aborted = false;
|
|
1590
|
+
req.on('data', (chunk) => {
|
|
1591
|
+
if (aborted)
|
|
1592
|
+
return;
|
|
1593
|
+
total += chunk.length;
|
|
1594
|
+
if (total > MAX_API_BODY_BYTES) {
|
|
1595
|
+
aborted = true;
|
|
1596
|
+
if (res && !res.headersSent) {
|
|
1597
|
+
sendJson(res, 413, { error: 'Request body too large' });
|
|
1598
|
+
}
|
|
1599
|
+
const err = new Error('Request body too large');
|
|
1600
|
+
err.statusCode = 413;
|
|
1601
|
+
err.handled = !!res;
|
|
1602
|
+
reject(err);
|
|
1603
|
+
return;
|
|
1604
|
+
}
|
|
1605
|
+
chunks.push(chunk);
|
|
1606
|
+
});
|
|
1607
|
+
req.on('end', () => {
|
|
1608
|
+
if (aborted)
|
|
1609
|
+
return;
|
|
1610
|
+
resolve(Buffer.concat(chunks).toString('utf-8'));
|
|
1611
|
+
});
|
|
1612
|
+
req.on('error', (err) => {
|
|
1613
|
+
if (!aborted)
|
|
1614
|
+
reject(err);
|
|
1615
|
+
});
|
|
1616
|
+
});
|
|
1617
|
+
}
|
|
1618
|
+
function sendJson(res, status, data) {
|
|
1619
|
+
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
1620
|
+
res.end(JSON.stringify(data));
|
|
1621
|
+
}
|
|
1622
|
+
// ============================================
|
|
1623
|
+
// WebSocket chat handlers
|
|
1624
|
+
// ============================================
|
|
1625
|
+
/**
|
|
1626
|
+
* Handle a message from a web client
|
|
1627
|
+
*/
|
|
1628
|
+
async function handleClientMessage(client, msg, defaultAgent) {
|
|
1629
|
+
const { ws, id: clientId } = client;
|
|
1630
|
+
switch (msg.type) {
|
|
1631
|
+
case 'message': {
|
|
1632
|
+
if (!msg.text?.trim())
|
|
1633
|
+
return;
|
|
1634
|
+
const text = msg.text.trim();
|
|
1635
|
+
const traceId = generateTraceId();
|
|
1636
|
+
const logger = createLogger({ traceId, platform: 'web', component: 'web' });
|
|
1637
|
+
if (msg.agent && msg.agent !== client.agent) {
|
|
1638
|
+
client.agent = msg.agent;
|
|
1639
|
+
}
|
|
1640
|
+
const parsed = parseMessage(text);
|
|
1641
|
+
try {
|
|
1642
|
+
const routeCtx = {
|
|
1643
|
+
threadId: clientId,
|
|
1644
|
+
channelId: 'web',
|
|
1645
|
+
platform: 'web',
|
|
1646
|
+
defaultAgent: client.agent,
|
|
1647
|
+
traceId,
|
|
1648
|
+
logger,
|
|
1649
|
+
userId: `web:${clientId}`,
|
|
1650
|
+
};
|
|
1651
|
+
logger.info({ event: 'message.received', text: text.substring(0, 120) });
|
|
1652
|
+
const result = await routeMessage(parsed, routeCtx);
|
|
1653
|
+
// String response (built-in commands, errors)
|
|
1654
|
+
if (typeof result === 'string') {
|
|
1655
|
+
sendToClient(ws, { type: 'done', text: result });
|
|
1656
|
+
return;
|
|
1657
|
+
}
|
|
1658
|
+
// Streaming response (agent responses)
|
|
1659
|
+
let fullText = '';
|
|
1660
|
+
for await (const chunk of result) {
|
|
1661
|
+
fullText += chunk;
|
|
1662
|
+
// L1: defer when the per-socket send buffer is full. Without
|
|
1663
|
+
// this, a slow client lets the chunk producer keep allocating
|
|
1664
|
+
// frames into the kernel + ws-internal queue, which can grow
|
|
1665
|
+
// to GBs for a long agent response.
|
|
1666
|
+
await awaitWsDrain(ws);
|
|
1667
|
+
if (ws.readyState !== ws.OPEN)
|
|
1668
|
+
break;
|
|
1669
|
+
sendToClient(ws, { type: 'chunk', text: chunk });
|
|
1670
|
+
}
|
|
1671
|
+
sendToClient(ws, { type: 'done', text: fullText });
|
|
1672
|
+
}
|
|
1673
|
+
catch (err) {
|
|
1674
|
+
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
1675
|
+
const stack = err instanceof Error ? err.stack : undefined;
|
|
1676
|
+
logger.error({ event: 'web.handle.error', err: errorMsg, stack }, 'Error handling client message');
|
|
1677
|
+
sendToClient(ws, { type: 'error', message: `Agent error: ${errorMsg}` });
|
|
1678
|
+
}
|
|
1679
|
+
break;
|
|
1680
|
+
}
|
|
1681
|
+
case 'switch-agent': {
|
|
1682
|
+
if (!msg.agent)
|
|
1683
|
+
return;
|
|
1684
|
+
const agent = registry.findAgent(msg.agent);
|
|
1685
|
+
if (!agent) {
|
|
1686
|
+
sendToClient(ws, { type: 'error', message: `Agent "${msg.agent}" not found` });
|
|
1687
|
+
return;
|
|
1688
|
+
}
|
|
1689
|
+
if (!(await isAgentAvailableCached(agent.name))) {
|
|
1690
|
+
sendToClient(ws, { type: 'error', message: `Agent "${agent.name}" is not available` });
|
|
1691
|
+
return;
|
|
1692
|
+
}
|
|
1693
|
+
client.agent = agent.name;
|
|
1694
|
+
await sessionManager.switchAgent('web', 'web', clientId, agent.name);
|
|
1695
|
+
sendToClient(ws, { type: 'agent-switched', agent: agent.name });
|
|
1696
|
+
break;
|
|
1697
|
+
}
|
|
1698
|
+
case 'get-agents': {
|
|
1699
|
+
const agents = registry.listAgents();
|
|
1700
|
+
sendToClient(ws, { type: 'agents', agents });
|
|
1701
|
+
break;
|
|
1702
|
+
}
|
|
1703
|
+
case 'get-history': {
|
|
1704
|
+
await sendSessionHistory(ws, clientId, defaultAgent);
|
|
1705
|
+
break;
|
|
1706
|
+
}
|
|
1707
|
+
}
|
|
1708
|
+
}
|
|
1709
|
+
/**
|
|
1710
|
+
* Send session history to a client
|
|
1711
|
+
*/
|
|
1712
|
+
async function sendSessionHistory(ws, clientId, defaultAgent) {
|
|
1713
|
+
const history = await sessionManager.getSessionWithHistory('web', 'web', clientId);
|
|
1714
|
+
if (history && history.messages.length > 0) {
|
|
1715
|
+
sendToClient(ws, {
|
|
1716
|
+
type: 'history',
|
|
1717
|
+
messages: history.messages,
|
|
1718
|
+
agent: history.session.agent,
|
|
1719
|
+
});
|
|
1720
|
+
}
|
|
1721
|
+
}
|
|
1722
|
+
/** L1: backpressure threshold for the WS send path. When `ws.bufferedAmount`
|
|
1723
|
+
* exceeds this, the streaming chunk loop awaits a tick instead of piling up
|
|
1724
|
+
* more frames. 4 MiB tolerates a few seconds of slow client without
|
|
1725
|
+
* unbounded memory growth — Node's WebSocket impl honors the kernel
|
|
1726
|
+
* send buffer behind this number. */
|
|
1727
|
+
const WS_BACKPRESSURE_HIGHWATER_BYTES = 4 * 1024 * 1024;
|
|
1728
|
+
/**
|
|
1729
|
+
* Send a JSON message to a WebSocket client
|
|
1730
|
+
*/
|
|
1731
|
+
function sendToClient(ws, data) {
|
|
1732
|
+
if (ws.readyState === ws.OPEN) {
|
|
1733
|
+
ws.send(JSON.stringify(data));
|
|
1734
|
+
}
|
|
1735
|
+
}
|
|
1736
|
+
/**
|
|
1737
|
+
* Wait until `ws.bufferedAmount` drops below the highwater mark, or the
|
|
1738
|
+
* socket closes, or the timeout fires. Used by the streaming chunk loop to
|
|
1739
|
+
* stop piling up frames at slow clients.
|
|
1740
|
+
*
|
|
1741
|
+
* Polls every 50 ms — node's `ws` doesn't emit a `drain` event we can hook,
|
|
1742
|
+
* but the buffered amount drops monotonically once the kernel ACKs flush.
|
|
1743
|
+
* Bounded by IMHUB_WS_BACKPRESSURE_TIMEOUT_MS (default 5 s) so a frozen
|
|
1744
|
+
* client can't wedge the agent's chunk producer indefinitely.
|
|
1745
|
+
*/
|
|
1746
|
+
async function awaitWsDrain(ws) {
|
|
1747
|
+
if (ws.bufferedAmount < WS_BACKPRESSURE_HIGHWATER_BYTES)
|
|
1748
|
+
return;
|
|
1749
|
+
const timeoutMs = (() => {
|
|
1750
|
+
const raw = process.env.IMHUB_WS_BACKPRESSURE_TIMEOUT_MS;
|
|
1751
|
+
if (raw) {
|
|
1752
|
+
const n = parseInt(raw, 10);
|
|
1753
|
+
if (Number.isFinite(n) && n > 0)
|
|
1754
|
+
return n;
|
|
1755
|
+
}
|
|
1756
|
+
return 5_000;
|
|
1757
|
+
})();
|
|
1758
|
+
const startedAt = Date.now();
|
|
1759
|
+
while (ws.readyState === ws.OPEN
|
|
1760
|
+
&& ws.bufferedAmount >= WS_BACKPRESSURE_HIGHWATER_BYTES
|
|
1761
|
+
&& Date.now() - startedAt < timeoutMs) {
|
|
1762
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
1763
|
+
}
|
|
1764
|
+
}
|
|
1765
|
+
/**
|
|
1766
|
+
* Serve a static file (no token injection needed)
|
|
1767
|
+
*/
|
|
1768
|
+
function serveStatic(res, filePath, contentType) {
|
|
1769
|
+
if (!existsSync(filePath)) {
|
|
1770
|
+
res.writeHead(404);
|
|
1771
|
+
res.end('Not found');
|
|
1772
|
+
return;
|
|
1773
|
+
}
|
|
1774
|
+
const content = readFileSync(filePath);
|
|
1775
|
+
res.writeHead(200, { 'Content-Type': contentType });
|
|
1776
|
+
res.end(content);
|
|
1777
|
+
}
|
|
1778
|
+
/**
|
|
1779
|
+
* Serve index/settings HTML with injected web token for API auth
|
|
1780
|
+
*/
|
|
1781
|
+
function serveIndexHtml(res, filePath, token) {
|
|
1782
|
+
if (!existsSync(filePath)) {
|
|
1783
|
+
res.writeHead(404);
|
|
1784
|
+
res.end('Not found');
|
|
1785
|
+
return;
|
|
1786
|
+
}
|
|
1787
|
+
let html = readFileSync(filePath, 'utf-8');
|
|
1788
|
+
// JSON.stringify produces a safely quoted JS string literal — prevents
|
|
1789
|
+
// breakout if the token ever contains ' or </script>.
|
|
1790
|
+
html = html.replace('</head>', `<script>window.IMHUB_TOKEN=${JSON.stringify(token)};</script></head>`);
|
|
1791
|
+
// No-cache so dashboard updates land for users without forcing a hard refresh.
|
|
1792
|
+
// The HTML is small (<30KB) and the round-trip is cheap; correctness wins.
|
|
1793
|
+
//
|
|
1794
|
+
// M1: defense-in-depth response headers. CSP keeps 'unsafe-inline' on
|
|
1795
|
+
// script/style because the IMHUB_TOKEN bootstrap and a few inline event
|
|
1796
|
+
// handlers in the static pages still rely on it; tightening to a nonce
|
|
1797
|
+
// is tracked separately. Outside that, lock everything down: no framing,
|
|
1798
|
+
// no MIME sniffing, no Referer leak to third parties, no third-party
|
|
1799
|
+
// resources at all.
|
|
1800
|
+
res.writeHead(200, {
|
|
1801
|
+
'Content-Type': 'text/html; charset=utf-8',
|
|
1802
|
+
'Cache-Control': 'no-cache, must-revalidate',
|
|
1803
|
+
'X-Frame-Options': 'DENY',
|
|
1804
|
+
'X-Content-Type-Options': 'nosniff',
|
|
1805
|
+
'Referrer-Policy': 'no-referrer',
|
|
1806
|
+
'Content-Security-Policy': [
|
|
1807
|
+
"default-src 'self'",
|
|
1808
|
+
"connect-src 'self' ws: wss:",
|
|
1809
|
+
"script-src 'self' 'unsafe-inline'",
|
|
1810
|
+
"style-src 'self' 'unsafe-inline'",
|
|
1811
|
+
"img-src 'self' data:",
|
|
1812
|
+
"font-src 'self' data:",
|
|
1813
|
+
"frame-ancestors 'none'",
|
|
1814
|
+
"base-uri 'self'",
|
|
1815
|
+
"form-action 'self'",
|
|
1816
|
+
].join('; '),
|
|
1817
|
+
});
|
|
1818
|
+
res.end(html);
|
|
1819
|
+
}
|
|
1820
|
+
//# sourceMappingURL=server.js.map
|