@aion0/forge 0.5.26 → 0.5.28
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/.forge/worktrees/pipeline-4dd8dc2d/CLAUDE.md +86 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/README.md +136 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/RELEASE_NOTES.md +36 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/agents/route.ts +17 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/auth/[...nextauth]/route.ts +3 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/auth/verify/route.ts +46 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/claude/[id]/route.ts +31 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/claude/[id]/stream/route.ts +63 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/claude/route.ts +28 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/claude-sessions/[projectName]/entries/route.ts +23 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/claude-sessions/[projectName]/live/route.ts +72 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/claude-sessions/[projectName]/route.ts +37 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/claude-sessions/sync/route.ts +17 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/claude-templates/route.ts +145 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/code/route.ts +299 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/delivery/[id]/route.ts +62 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/delivery/route.ts +40 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/detect-cli/route.ts +46 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/docs/route.ts +176 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/docs/sessions/route.ts +54 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/favorites/route.ts +26 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/flows/route.ts +6 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/flows/run/route.ts +19 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/git/route.ts +149 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/help/route.ts +84 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/issue-scanner/route.ts +116 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/logs/route.ts +100 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/mobile-chat/route.ts +115 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/monitor/route.ts +74 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/notifications/route.ts +42 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/notify/test/route.ts +33 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/online/route.ts +40 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/pipelines/[id]/route.ts +41 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/pipelines/route.ts +90 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/plugins/route.ts +75 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/preview/[...path]/route.ts +64 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/preview/route.ts +156 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/project-pipelines/route.ts +91 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/project-sessions/route.ts +61 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/projects/route.ts +26 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/sessions/[id]/chat/route.ts +64 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/sessions/[id]/messages/route.ts +9 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/sessions/[id]/route.ts +17 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/sessions/route.ts +20 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/settings/route.ts +64 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/skills/local/route.ts +228 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/skills/route.ts +182 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/smith-templates/route.ts +81 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/status/route.ts +12 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/tabs/route.ts +25 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/tasks/[id]/route.ts +51 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/tasks/[id]/stream/route.ts +77 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/tasks/link/route.ts +37 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/tasks/route.ts +44 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/tasks/session/route.ts +14 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/telegram/route.ts +23 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/templates/route.ts +6 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/terminal-bell/route.ts +39 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/terminal-cwd/route.ts +19 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/terminal-state/route.ts +15 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/tunnel/route.ts +26 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/upgrade/route.ts +43 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/usage/route.ts +20 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/version/route.ts +78 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/watchers/route.ts +33 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/workspace/[id]/agents/route.ts +35 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/workspace/[id]/memory/route.ts +23 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/workspace/[id]/smith/route.ts +22 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/workspace/[id]/stream/route.ts +31 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/workspace/route.ts +79 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/global-error.tsx +21 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/globals.css +52 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/icon.ico +0 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/icon.png +0 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/icon.svg +106 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/layout.tsx +17 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/login/LoginForm.tsx +96 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/login/page.tsx +10 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/mobile/page.tsx +10 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/page.tsx +22 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/bin/forge-server.mjs +484 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/check-forge-status.sh +71 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/cli/mw.ts +579 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/BrowserPanel.tsx +175 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/ChatPanel.tsx +191 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/ClaudeTerminal.tsx +267 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/CodeViewer.tsx +787 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/ConversationEditor.tsx +411 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/ConversationGraphView.tsx +347 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/ConversationTerminalView.tsx +303 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/Dashboard.tsx +807 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/DashboardWrapper.tsx +9 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/DeliveryFlowEditor.tsx +491 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/DeliveryList.tsx +230 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/DeliveryWorkspace.tsx +589 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/DocTerminal.tsx +187 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/DocsViewer.tsx +574 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/HelpDialog.tsx +169 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/HelpTerminal.tsx +141 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/InlinePipelineView.tsx +111 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/LogViewer.tsx +194 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/MarkdownContent.tsx +73 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/MobileView.tsx +385 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/MonitorPanel.tsx +122 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/NewSessionModal.tsx +93 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/NewTaskModal.tsx +492 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/PipelineEditor.tsx +570 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/PipelineView.tsx +1018 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/PluginsPanel.tsx +472 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/ProjectDetail.tsx +1618 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/ProjectList.tsx +108 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/ProjectManager.tsx +401 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/SessionList.tsx +74 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/SessionView.tsx +726 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/SettingsModal.tsx +1647 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/SkillsPanel.tsx +969 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/StatusBar.tsx +99 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/TabBar.tsx +46 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/TaskBoard.tsx +113 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/TaskDetail.tsx +372 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/TerminalLauncher.tsx +398 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/TunnelToggle.tsx +206 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/UsagePanel.tsx +207 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/WebTerminal.tsx +1743 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/WorkspaceTree.tsx +221 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/WorkspaceView.tsx +4048 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/dev-test.sh +5 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/docs/Forge_Memory_Layer_Design.docx +0 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/docs/Forge_Strategy_Research_2026.docx +0 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/docs/LOCAL-DEPLOY.md +144 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/docs/roadmap-multi-agent-workflow.md +330 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/forge-logo.png +0 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/forge-logo.svg +106 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/hooks/useSidebarResize.ts +52 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/install.sh +29 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/instrumentation.ts +35 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/agents/claude-adapter.ts +104 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/agents/generic-adapter.ts +64 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/agents/index.ts +245 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/agents/types.ts +70 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/artifacts.ts +106 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/auth.ts +62 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/builtin-plugins/docker.yaml +70 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/builtin-plugins/http.yaml +66 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/builtin-plugins/jenkins.yaml +92 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/builtin-plugins/llm-vision.yaml +85 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/builtin-plugins/playwright.yaml +111 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/builtin-plugins/shell-command.yaml +60 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/builtin-plugins/slack.yaml +48 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/builtin-plugins/webhook.yaml +56 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/claude-process.ts +361 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/claude-sessions.ts +266 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/claude-templates.ts +227 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/cloudflared.ts +424 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/crypto.ts +67 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/delivery.ts +787 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/dirs.ts +99 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/flows.ts +86 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/forge-mcp-server.ts +732 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/forge-skills/forge-inbox.md +38 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/forge-skills/forge-send.md +47 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/forge-skills/forge-status.md +32 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/forge-skills/forge-workspace-sync.md +37 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/help-docs/00-overview.md +40 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/help-docs/01-settings.md +194 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/help-docs/02-telegram.md +41 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/help-docs/03-tunnel.md +31 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/help-docs/04-tasks.md +52 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/help-docs/05-pipelines.md +460 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/help-docs/06-skills.md +43 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/help-docs/07-projects.md +73 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/help-docs/08-rules.md +53 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/help-docs/09-issue-autofix.md +55 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/help-docs/10-troubleshooting.md +89 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/help-docs/11-workspace.md +810 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/help-docs/CLAUDE.md +62 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/init.ts +266 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/issue-scanner.ts +298 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/logger.ts +79 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/notifications.ts +75 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/notify.ts +108 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/password.ts +97 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/pipeline-scheduler.ts +373 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/pipeline.ts +1565 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/plugins/executor.ts +347 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/plugins/registry.ts +228 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/plugins/types.ts +103 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/project-sessions.ts +53 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/projects.ts +86 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/session-manager.ts +156 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/session-utils.ts +53 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/session-watcher.ts +345 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/settings.ts +195 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/skills.ts +458 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/task-manager.ts +951 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/telegram-bot.ts +1477 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/telegram-standalone.ts +83 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/terminal-server.ts +70 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/terminal-standalone.ts +438 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/usage-scanner.ts +249 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/workspace/__tests__/state-machine.test.ts +388 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/workspace/__tests__/workspace.test.ts +311 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/workspace/agent-bus.ts +416 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/workspace/agent-worker.ts +655 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/workspace/backends/api-backend.ts +262 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/workspace/backends/cli-backend.ts +491 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/workspace/index.ts +84 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/workspace/manager.ts +136 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/workspace/orchestrator.ts +3415 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/workspace/persistence.ts +309 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/workspace/presets.ts +649 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/workspace/requests.ts +287 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/workspace/session-monitor.ts +240 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/workspace/skill-installer.ts +275 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/workspace/smith-memory.ts +498 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/workspace/types.ts +241 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/workspace/watch-manager.ts +560 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/workspace-standalone.ts +978 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/middleware.ts +51 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/next.config.ts +26 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/package.json +74 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/pnpm-lock.yaml +3719 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/pnpm-workspace.yaml +1 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/postcss.config.mjs +7 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/publish.sh +133 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/scripts/bench/README.md +66 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/scripts/bench/results/.gitignore +2 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/scripts/bench/run.ts +635 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/scripts/bench/tasks/01-text-utils/task.md +26 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/scripts/bench/tasks/01-text-utils/validator.sh +46 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/scripts/bench/tasks/02-pagination/setup.sh +19 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/scripts/bench/tasks/02-pagination/task.md +48 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/scripts/bench/tasks/02-pagination/validator.sh +69 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/scripts/bench/tasks/03-bug-fix/setup.sh +82 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/scripts/bench/tasks/03-bug-fix/task.md +30 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/scripts/bench/tasks/03-bug-fix/validator.sh +29 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/scripts/verify-usage.ts +178 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/src/config/index.ts +129 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/src/core/db/database.ts +259 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/src/core/memory/strategy.ts +32 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/src/core/providers/chat.ts +65 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/src/core/providers/registry.ts +60 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/src/core/session/manager.ts +190 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/src/types/index.ts +129 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/start.sh +32 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/templates/smith-lead.json +45 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/tsconfig.json +42 -0
- package/RELEASE_NOTES.md +10 -29
- package/app/api/terminal-bell/route.ts +6 -2
- package/app/api/terminal-cwd/route.ts +7 -4
- package/components/CodeViewer.tsx +3 -31
- package/components/Dashboard.tsx +34 -20
- package/components/WebTerminal.tsx +36 -2
- package/lib/terminal-standalone.ts +19 -2
- package/package.json +1 -1
|
@@ -0,0 +1,3415 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WorkspaceOrchestrator — manages a group of agents within a workspace.
|
|
3
|
+
*
|
|
4
|
+
* Responsibilities:
|
|
5
|
+
* - Create/remove agents
|
|
6
|
+
* - Run agents (auto-select backend, inject upstream context)
|
|
7
|
+
* - Listen to agent events → trigger downstream agents
|
|
8
|
+
* - Approval gating
|
|
9
|
+
* - Parallel execution (independent agents run concurrently)
|
|
10
|
+
* - Error recovery (restart from lastCheckpoint)
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { EventEmitter } from 'node:events';
|
|
14
|
+
import { readFileSync, existsSync, writeFileSync, unlinkSync, mkdirSync, statSync, readdirSync } from 'node:fs';
|
|
15
|
+
import { execSync } from 'node:child_process';
|
|
16
|
+
import { resolve, join } from 'node:path';
|
|
17
|
+
import { homedir } from 'node:os';
|
|
18
|
+
import type {
|
|
19
|
+
WorkspaceAgentConfig,
|
|
20
|
+
AgentState,
|
|
21
|
+
SmithStatus,
|
|
22
|
+
TaskStatus,
|
|
23
|
+
WorkerEvent,
|
|
24
|
+
BusMessage,
|
|
25
|
+
Artifact,
|
|
26
|
+
WorkspaceState,
|
|
27
|
+
DaemonWakeReason,
|
|
28
|
+
} from './types';
|
|
29
|
+
import { AgentWorker } from './agent-worker';
|
|
30
|
+
import { AgentBus } from './agent-bus';
|
|
31
|
+
import { WatchManager } from './watch-manager';
|
|
32
|
+
// ApiBackend loaded dynamically — its dependency chain uses @/src path aliases
|
|
33
|
+
// that only work in Next.js context, not in standalone tsx process
|
|
34
|
+
// import { ApiBackend } from './backends/api-backend';
|
|
35
|
+
import { CliBackend } from './backends/cli-backend';
|
|
36
|
+
import { appendAgentLog, saveWorkspace, saveWorkspaceSync, startAutoSave, stopAutoSave } from './persistence';
|
|
37
|
+
import { hasForgeSkills, installForgeSkills } from './skill-installer';
|
|
38
|
+
import {
|
|
39
|
+
loadMemory, saveMemory, createMemory, formatMemoryForPrompt,
|
|
40
|
+
addObservation, addSessionSummary, parseStepToObservations, buildSessionSummary,
|
|
41
|
+
} from './smith-memory';
|
|
42
|
+
import { getFixedSession } from '../project-sessions';
|
|
43
|
+
|
|
44
|
+
// ─── Workspace Topology Cache ────────────────────────────
|
|
45
|
+
|
|
46
|
+
export interface TopoAgent {
|
|
47
|
+
id: string;
|
|
48
|
+
label: string;
|
|
49
|
+
icon: string;
|
|
50
|
+
role: string; // full role text
|
|
51
|
+
roleSummary: string; // first line, ≤150 chars
|
|
52
|
+
primary: boolean;
|
|
53
|
+
dependsOn: string[]; // agent labels (not IDs)
|
|
54
|
+
dependsOnIds: string[];
|
|
55
|
+
workDir: string;
|
|
56
|
+
outputs: string[];
|
|
57
|
+
steps: string[]; // step labels
|
|
58
|
+
smithStatus: string;
|
|
59
|
+
taskStatus: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface WorkspaceTopo {
|
|
63
|
+
agents: TopoAgent[];
|
|
64
|
+
flow: string; // "Lead → Engineer → QA → Reviewer"
|
|
65
|
+
updatedAt: number;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ─── Orchestrator Events ─────────────────────────────────
|
|
69
|
+
|
|
70
|
+
export type OrchestratorEvent =
|
|
71
|
+
| WorkerEvent
|
|
72
|
+
| { type: 'bus_message'; message: BusMessage }
|
|
73
|
+
| { type: 'approval_required'; agentId: string; upstreamId: string }
|
|
74
|
+
| { type: 'user_input_request'; agentId: string; fromAgent: string; question: string }
|
|
75
|
+
| { type: 'workspace_status'; running: number; done: number; total: number }
|
|
76
|
+
| { type: 'workspace_complete' }
|
|
77
|
+
| { type: 'watch_alert'; agentId: string; changes: any[]; summary: string; timestamp: number };
|
|
78
|
+
|
|
79
|
+
// ─── Orchestrator class ──────────────────────────────────
|
|
80
|
+
|
|
81
|
+
export class WorkspaceOrchestrator extends EventEmitter {
|
|
82
|
+
readonly workspaceId: string;
|
|
83
|
+
readonly projectPath: string;
|
|
84
|
+
readonly projectName: string;
|
|
85
|
+
|
|
86
|
+
private agents = new Map<string, { config: WorkspaceAgentConfig; worker: AgentWorker | null; state: AgentState }>();
|
|
87
|
+
private bus: AgentBus;
|
|
88
|
+
private watchManager: WatchManager;
|
|
89
|
+
private sessionMonitor: import('./session-monitor').SessionFileMonitor | null = null;
|
|
90
|
+
private approvalQueue = new Set<string>();
|
|
91
|
+
private daemonActive = false;
|
|
92
|
+
private createdAt = Date.now();
|
|
93
|
+
private healthCheckTimer: NodeJS.Timeout | null = null;
|
|
94
|
+
private settingsValidCache = new Map<string, number>(); // filePath → mtime (validated ok)
|
|
95
|
+
private agentRunningMsg = new Map<string, string>(); // agentId → messageId currently being processed
|
|
96
|
+
private reconcileTick = 0; // counts health check ticks for 60s reconcile
|
|
97
|
+
private _topoCache: WorkspaceTopo | null = null; // cached workspace topology
|
|
98
|
+
private roleInjectState = new Map<string, { lastInjectAt: number; msgsSinceInject: number }>(); // per-agent role reminder tracking
|
|
99
|
+
|
|
100
|
+
/** Emit a log event (auto-persisted via constructor listener) */
|
|
101
|
+
emitLog(agentId: string, entry: any): void {
|
|
102
|
+
this.emit('event', { type: 'log', agentId, entry } as any);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
constructor(workspaceId: string, projectPath: string, projectName: string) {
|
|
106
|
+
super();
|
|
107
|
+
this.workspaceId = workspaceId;
|
|
108
|
+
this.projectPath = projectPath;
|
|
109
|
+
this.projectName = projectName;
|
|
110
|
+
this.bus = new AgentBus();
|
|
111
|
+
this.watchManager = new WatchManager(workspaceId, projectPath, () => this.agents as any);
|
|
112
|
+
|
|
113
|
+
// Auto-persist all log events to disk (so LogPanel can read them)
|
|
114
|
+
this.on('event', (event: any) => {
|
|
115
|
+
if (event.type === 'log' && event.agentId && event.entry) {
|
|
116
|
+
appendAgentLog(this.workspaceId, event.agentId, event.entry).catch(() => {});
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
// Handle watch events
|
|
120
|
+
this.watchManager.on('watch_alert', (event) => {
|
|
121
|
+
this.emit('event', event);
|
|
122
|
+
// Push alert to agent history so Log panel shows it
|
|
123
|
+
const alertEntry = this.agents.get(event.agentId);
|
|
124
|
+
if (alertEntry && event.entry) {
|
|
125
|
+
alertEntry.state.history.push(event.entry);
|
|
126
|
+
this.emit('event', { type: 'log', agentId: event.agentId, entry: event.entry } as any);
|
|
127
|
+
}
|
|
128
|
+
this.handleWatchAlert(event.agentId, event.summary);
|
|
129
|
+
});
|
|
130
|
+
// Note: watch_heartbeat (no changes) only logs to console, not to agent history/logs.jsonl
|
|
131
|
+
|
|
132
|
+
// Forward bus messages as orchestrator events (after dedup, skip ACKs)
|
|
133
|
+
this.bus.on('message', (msg: BusMessage) => {
|
|
134
|
+
if (msg.type === 'ack') return; // ACKs are internal, don't emit to UI
|
|
135
|
+
if (msg.to === '_system') {
|
|
136
|
+
this.emit('event', { type: 'bus_message', message: msg } satisfies OrchestratorEvent);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
this.handleBusMessage(msg);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
// Start auto-save (every 10 seconds)
|
|
143
|
+
startAutoSave(workspaceId, () => this.getFullState());
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ─── Agent Management ──────────────────────────────────
|
|
147
|
+
|
|
148
|
+
/** Check if agent outputs or workDir conflict with existing agents */
|
|
149
|
+
private validateOutputs(config: WorkspaceAgentConfig, excludeId?: string): string | null {
|
|
150
|
+
if (config.type === 'input') return null;
|
|
151
|
+
|
|
152
|
+
const normalize = (p: string) => p.replace(/^\.?\//, '').replace(/\/$/, '') || '.';
|
|
153
|
+
|
|
154
|
+
// Validate workDir is within project (no ../ escape)
|
|
155
|
+
if (config.workDir) {
|
|
156
|
+
const relativeDir = config.workDir.replace(/^\.?\//, '');
|
|
157
|
+
if (relativeDir.includes('..')) {
|
|
158
|
+
return `Work directory "${config.workDir}" contains "..". Must be a subdirectory of the project.`;
|
|
159
|
+
}
|
|
160
|
+
const projectRoot = this.projectPath.endsWith('/') ? this.projectPath : this.projectPath + '/';
|
|
161
|
+
const resolved = resolve(this.projectPath, relativeDir);
|
|
162
|
+
if (resolved !== this.projectPath && !resolved.startsWith(projectRoot)) {
|
|
163
|
+
return `Work directory "${config.workDir}" is outside the project. Must be a subdirectory.`;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Every non-input smith must have a unique workDir
|
|
168
|
+
const newDir = normalize(config.workDir || '.');
|
|
169
|
+
|
|
170
|
+
for (const [id, entry] of this.agents) {
|
|
171
|
+
if (id === excludeId || entry.config.type === 'input') continue;
|
|
172
|
+
|
|
173
|
+
const existingDir = normalize(entry.config.workDir || '.');
|
|
174
|
+
|
|
175
|
+
// Same workDir → conflict
|
|
176
|
+
if (newDir === existingDir) {
|
|
177
|
+
return `Work directory conflict: "${config.label}" and "${entry.config.label}" both use "${newDir === '.' ? 'project root' : newDir}/". Each smith must have a unique work directory.`;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// One is parent of the other → conflict (e.g., "src" and "src/components")
|
|
181
|
+
if (newDir.startsWith(existingDir + '/') || existingDir.startsWith(newDir + '/')) {
|
|
182
|
+
return `Work directory conflict: "${config.label}" (${newDir}/) overlaps with "${entry.config.label}" (${existingDir}/). Nested directories not allowed.`;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Output path overlap is allowed — multiple agents can write to the same
|
|
186
|
+
// output directory (e.g., Engineer and UI Designer both output to src/).
|
|
187
|
+
// Work directory uniqueness already prevents file-level conflicts.
|
|
188
|
+
}
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Check for non-blocking output overlaps — returns a warning message if any */
|
|
193
|
+
private getOutputWarnings(config: WorkspaceAgentConfig, excludeId?: string): string | null {
|
|
194
|
+
if (config.type === 'input' || !config.outputs?.length) return null;
|
|
195
|
+
const normalize = (p: string) => p.replace(/^\.?\//, '').replace(/\/$/, '') || '.';
|
|
196
|
+
const overlaps: string[] = [];
|
|
197
|
+
for (const [id, entry] of this.agents) {
|
|
198
|
+
if (id === excludeId || entry.config.type === 'input') continue;
|
|
199
|
+
for (const out of config.outputs) {
|
|
200
|
+
for (const existing of entry.config.outputs || []) {
|
|
201
|
+
if (normalize(out) === normalize(existing)) {
|
|
202
|
+
overlaps.push(`${entry.config.label} also outputs to "${out}"`);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return overlaps.length ? `Output overlap (non-blocking): ${overlaps.join('; ')}` : null;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Detect if adding dependsOn edges would create a cycle in the DAG */
|
|
211
|
+
private detectCycle(agentId: string, dependsOn: string[]): string | null {
|
|
212
|
+
// Build adjacency: agent → agents it depends on
|
|
213
|
+
const deps = new Map<string, string[]>();
|
|
214
|
+
for (const [id, entry] of this.agents) {
|
|
215
|
+
if (id !== agentId) deps.set(id, [...entry.config.dependsOn]);
|
|
216
|
+
}
|
|
217
|
+
deps.set(agentId, [...dependsOn]);
|
|
218
|
+
|
|
219
|
+
// DFS cycle detection
|
|
220
|
+
const visited = new Set<string>();
|
|
221
|
+
const inStack = new Set<string>();
|
|
222
|
+
|
|
223
|
+
const dfs = (node: string): string | null => {
|
|
224
|
+
if (inStack.has(node)) return node; // cycle found
|
|
225
|
+
if (visited.has(node)) return null;
|
|
226
|
+
visited.add(node);
|
|
227
|
+
inStack.add(node);
|
|
228
|
+
for (const dep of deps.get(node) || []) {
|
|
229
|
+
const cycle = dfs(dep);
|
|
230
|
+
if (cycle) return cycle;
|
|
231
|
+
}
|
|
232
|
+
inStack.delete(node);
|
|
233
|
+
return null;
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
for (const id of deps.keys()) {
|
|
237
|
+
const cycle = dfs(id);
|
|
238
|
+
if (cycle) {
|
|
239
|
+
const cycleName = this.agents.get(cycle)?.config.label || cycle;
|
|
240
|
+
return `Circular dependency detected involving "${cycleName}". Dependencies must form a DAG (no cycles).`;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
return null;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Check if agentA is upstream of agentB (A is in B's dependency chain) */
|
|
247
|
+
isUpstream(agentA: string, agentB: string): boolean {
|
|
248
|
+
const visited = new Set<string>();
|
|
249
|
+
const check = (current: string): boolean => {
|
|
250
|
+
if (current === agentA) return true;
|
|
251
|
+
if (visited.has(current)) return false;
|
|
252
|
+
visited.add(current);
|
|
253
|
+
const entry = this.agents.get(current);
|
|
254
|
+
if (!entry) return false;
|
|
255
|
+
return entry.config.dependsOn.some(dep => check(dep));
|
|
256
|
+
};
|
|
257
|
+
return check(agentB);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Get the primary agent for this workspace (if any) */
|
|
261
|
+
getPrimaryAgent(): { config: WorkspaceAgentConfig; state: AgentState } | null {
|
|
262
|
+
for (const [, entry] of this.agents) {
|
|
263
|
+
if (entry.config.primary) return entry;
|
|
264
|
+
}
|
|
265
|
+
return null;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
addAgent(config: WorkspaceAgentConfig): void {
|
|
269
|
+
const conflict = this.validateOutputs(config);
|
|
270
|
+
if (conflict) throw new Error(conflict);
|
|
271
|
+
const warning = this.getOutputWarnings(config);
|
|
272
|
+
if (warning) console.warn(`[workspace] ${warning}`);
|
|
273
|
+
|
|
274
|
+
// Check DAG cycle before adding
|
|
275
|
+
const cycleErr = this.detectCycle(config.id, config.dependsOn);
|
|
276
|
+
if (cycleErr) throw new Error(cycleErr);
|
|
277
|
+
|
|
278
|
+
// Primary agent validation
|
|
279
|
+
this.validatePrimaryRules(config);
|
|
280
|
+
|
|
281
|
+
const state: AgentState = {
|
|
282
|
+
smithStatus: 'down',
|
|
283
|
+
taskStatus: 'idle',
|
|
284
|
+
history: [],
|
|
285
|
+
artifacts: [],
|
|
286
|
+
};
|
|
287
|
+
// Primary agent: force terminal-only, root dir
|
|
288
|
+
if (config.primary) {
|
|
289
|
+
config.persistentSession = true;
|
|
290
|
+
config.workDir = './';
|
|
291
|
+
}
|
|
292
|
+
this.agents.set(config.id, { config, worker: null, state });
|
|
293
|
+
// If daemon active, start persistent session + worker
|
|
294
|
+
if (this.daemonActive && config.type !== 'input' && config.persistentSession) {
|
|
295
|
+
this.enterDaemonListening(config.id);
|
|
296
|
+
const entry = this.agents.get(config.id)!;
|
|
297
|
+
entry.state.smithStatus = 'active';
|
|
298
|
+
this.ensurePersistentSession(config.id, config).then(() => {
|
|
299
|
+
this.startMessageLoop(config.id);
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
this.saveNow();
|
|
303
|
+
this.emitAgentsChanged();
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
removeAgent(id: string): void {
|
|
307
|
+
const entry = this.agents.get(id);
|
|
308
|
+
if (entry?.config.primary) throw new Error('Cannot remove the primary agent');
|
|
309
|
+
if (entry?.worker) {
|
|
310
|
+
entry.worker.stop();
|
|
311
|
+
}
|
|
312
|
+
this.agents.delete(id);
|
|
313
|
+
this.approvalQueue.delete(id);
|
|
314
|
+
|
|
315
|
+
// Clean up dangling dependsOn references in other agents
|
|
316
|
+
for (const [, other] of this.agents) {
|
|
317
|
+
const idx = other.config.dependsOn.indexOf(id);
|
|
318
|
+
if (idx !== -1) {
|
|
319
|
+
other.config.dependsOn.splice(idx, 1);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
this.saveNow();
|
|
324
|
+
this.emitAgentsChanged();
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** Validate primary agent rules */
|
|
328
|
+
private validatePrimaryRules(config: WorkspaceAgentConfig, excludeId?: string): void {
|
|
329
|
+
if (config.primary) {
|
|
330
|
+
// Only one primary allowed
|
|
331
|
+
for (const [id, entry] of this.agents) {
|
|
332
|
+
if (id !== excludeId && entry.config.primary) {
|
|
333
|
+
throw new Error(`Only one primary agent allowed. "${entry.config.label}" is already primary.`);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
// Non-primary agents cannot use root directory if a primary exists
|
|
338
|
+
if (!config.primary && config.type !== 'input') {
|
|
339
|
+
const workDir = config.workDir?.replace(/\/+$/, '') || '';
|
|
340
|
+
if (!workDir || workDir === '.' || workDir === './') {
|
|
341
|
+
const primary = this.getPrimaryAgent();
|
|
342
|
+
if (primary && primary.config.id !== excludeId) {
|
|
343
|
+
throw new Error(`Root directory is reserved for primary agent "${primary.config.label}". Choose a subdirectory.`);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
updateAgentConfig(id: string, config: WorkspaceAgentConfig): void {
|
|
350
|
+
const entry = this.agents.get(id);
|
|
351
|
+
if (!entry) return;
|
|
352
|
+
// Validate agentId exists — fallback to default if deleted from Settings
|
|
353
|
+
if (config.agentId) {
|
|
354
|
+
try {
|
|
355
|
+
const { listAgents, getDefaultAgentId } = require('../agents/index');
|
|
356
|
+
const validAgents = new Set((listAgents() as any[]).map((a: any) => a.id));
|
|
357
|
+
if (!validAgents.has(config.agentId)) {
|
|
358
|
+
const fallback = getDefaultAgentId() || 'claude';
|
|
359
|
+
console.log(`[workspace] ${config.label}: agent "${config.agentId}" not found, falling back to "${fallback}"`);
|
|
360
|
+
config.agentId = fallback;
|
|
361
|
+
}
|
|
362
|
+
} catch {}
|
|
363
|
+
}
|
|
364
|
+
const conflict = this.validateOutputs(config, id);
|
|
365
|
+
if (conflict) throw new Error(conflict);
|
|
366
|
+
const warning = this.getOutputWarnings(config, id);
|
|
367
|
+
if (warning) console.warn(`[workspace] ${warning}`);
|
|
368
|
+
const cycleErr = this.detectCycle(id, config.dependsOn);
|
|
369
|
+
if (cycleErr) throw new Error(cycleErr);
|
|
370
|
+
this.validatePrimaryRules(config, id);
|
|
371
|
+
// Primary agent: force terminal-only, root dir
|
|
372
|
+
if (config.primary) {
|
|
373
|
+
config.persistentSession = true;
|
|
374
|
+
config.workDir = './';
|
|
375
|
+
}
|
|
376
|
+
if (entry.worker && entry.state.taskStatus === 'running') {
|
|
377
|
+
entry.worker.stop();
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// If agent CLI changed (claude→codex, etc.), kill old terminal and clear bound session
|
|
381
|
+
const agentChanged = entry.config.agentId !== config.agentId;
|
|
382
|
+
if (agentChanged) {
|
|
383
|
+
console.log(`[workspace] ${config.label}: agent changed ${entry.config.agentId} → ${config.agentId}`);
|
|
384
|
+
if (entry.state.tmuxSession) {
|
|
385
|
+
try { execSync(`tmux kill-session -t "${entry.state.tmuxSession}" 2>/dev/null`, { timeout: 3000 }); } catch {}
|
|
386
|
+
console.log(`[workspace] ${config.label}: killed tmux session ${entry.state.tmuxSession}`);
|
|
387
|
+
}
|
|
388
|
+
entry.state.tmuxSession = undefined;
|
|
389
|
+
config.boundSessionId = undefined;
|
|
390
|
+
} else {
|
|
391
|
+
// Preserve server-managed fields the client doesn't track
|
|
392
|
+
if (!config.boundSessionId && entry.config.boundSessionId) {
|
|
393
|
+
config.boundSessionId = entry.config.boundSessionId;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
entry.config = config;
|
|
398
|
+
// Reset status but keep history/artifacts (don't wipe logs)
|
|
399
|
+
entry.state.taskStatus = 'idle';
|
|
400
|
+
entry.state.error = undefined;
|
|
401
|
+
if (entry.worker) {
|
|
402
|
+
entry.worker.removeAllListeners();
|
|
403
|
+
entry.worker.stop();
|
|
404
|
+
}
|
|
405
|
+
entry.worker = null;
|
|
406
|
+
|
|
407
|
+
if (this.daemonActive) {
|
|
408
|
+
// Set 'starting' BEFORE creating worker — worker.executeDaemon emits 'active' synchronously
|
|
409
|
+
// which would cause a race: frontend sees active before boundSessionId is ready
|
|
410
|
+
entry.state.smithStatus = 'starting';
|
|
411
|
+
this.emit('event', { type: 'smith_status', agentId: id, smithStatus: 'starting' } as any);
|
|
412
|
+
// Restart watch if config changed
|
|
413
|
+
this.watchManager.startWatch(id, config);
|
|
414
|
+
this.ensurePersistentSession(id, config).then(() => {
|
|
415
|
+
const e = this.agents.get(id);
|
|
416
|
+
if (e) {
|
|
417
|
+
// Rebuild worker + message loop AFTER session is ready (boundSessionId set)
|
|
418
|
+
this.enterDaemonListening(id);
|
|
419
|
+
e.state.smithStatus = 'active';
|
|
420
|
+
this.emit('event', { type: 'smith_status', agentId: id, smithStatus: 'active' } as any);
|
|
421
|
+
this.emitAgentsChanged();
|
|
422
|
+
}
|
|
423
|
+
this.startMessageLoop(id);
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
this.saveNow();
|
|
427
|
+
this.emitAgentsChanged();
|
|
428
|
+
this.emit('event', { type: 'task_status', agentId: id, taskStatus: 'idle' } satisfies WorkerEvent);
|
|
429
|
+
this.emit('event', { type: 'smith_status', agentId: id, smithStatus: entry.state.smithStatus } as any);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
getAgentState(id: string): Readonly<AgentState> | undefined {
|
|
433
|
+
return this.agents.get(id)?.state;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
getAllAgentStates(): Record<string, AgentState> {
|
|
437
|
+
const result: Record<string, AgentState> = {};
|
|
438
|
+
for (const [id, entry] of this.agents) {
|
|
439
|
+
const workerState = entry.worker?.getState();
|
|
440
|
+
// Merge: worker state for task/smith, entry.state for mode (orchestrator controls mode)
|
|
441
|
+
result[id] = workerState
|
|
442
|
+
? { ...workerState, taskStatus: entry.state.taskStatus, tmuxSession: entry.state.tmuxSession, currentMessageId: entry.state.currentMessageId }
|
|
443
|
+
: entry.state;
|
|
444
|
+
}
|
|
445
|
+
return result;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// ─── Execution ─────────────────────────────────────────
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* Complete an Input node — set its content and mark as done.
|
|
452
|
+
* If re-submitted, resets downstream agents so they can re-run.
|
|
453
|
+
*/
|
|
454
|
+
completeInput(agentId: string, content: string): void {
|
|
455
|
+
const entry = this.agents.get(agentId);
|
|
456
|
+
if (!entry || entry.config.type !== 'input') return;
|
|
457
|
+
|
|
458
|
+
const isUpdate = entry.state.taskStatus === 'done';
|
|
459
|
+
|
|
460
|
+
// Append to entries (incremental, not overwrite)
|
|
461
|
+
if (!entry.config.entries) entry.config.entries = [];
|
|
462
|
+
entry.config.entries.push({ content, timestamp: Date.now() });
|
|
463
|
+
// Keep bounded — max 100 entries, oldest removed
|
|
464
|
+
if (entry.config.entries.length > 100) {
|
|
465
|
+
entry.config.entries = entry.config.entries.slice(-100);
|
|
466
|
+
}
|
|
467
|
+
// Also set content to latest for backward compat
|
|
468
|
+
entry.config.content = content;
|
|
469
|
+
|
|
470
|
+
entry.state.taskStatus = 'done';
|
|
471
|
+
entry.state.completedAt = Date.now();
|
|
472
|
+
entry.state.artifacts = [{ type: 'text', summary: content.slice(0, 200) }];
|
|
473
|
+
|
|
474
|
+
this.emit('event', { type: 'task_status', agentId, taskStatus: 'done' } satisfies WorkerEvent);
|
|
475
|
+
this.emit('event', { type: 'done', agentId, summary: 'Input provided' } satisfies WorkerEvent);
|
|
476
|
+
this.emitAgentsChanged(); // push updated entries to frontend
|
|
477
|
+
this.bus.notifyTaskComplete(agentId, [], content.slice(0, 200));
|
|
478
|
+
|
|
479
|
+
// Send input_updated messages to downstream agents via bus
|
|
480
|
+
// routeMessageToAgent handles auto-execution for active smiths
|
|
481
|
+
for (const [id, downstream] of this.agents) {
|
|
482
|
+
if (downstream.config.type === 'input') continue;
|
|
483
|
+
if (!downstream.config.dependsOn.includes(agentId)) continue;
|
|
484
|
+
this.bus.send(agentId, id, 'notify', {
|
|
485
|
+
action: 'input_updated',
|
|
486
|
+
content: content.slice(0, 500),
|
|
487
|
+
});
|
|
488
|
+
console.log(`[bus] Input → ${downstream.config.label}: input_updated`);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
this.saveNow();
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/** Reset an agent and all its downstream to idle (for re-run) */
|
|
495
|
+
resetAgent(agentId: string): void {
|
|
496
|
+
const entry = this.agents.get(agentId);
|
|
497
|
+
if (!entry) return;
|
|
498
|
+
if (entry.worker) entry.worker.stop();
|
|
499
|
+
entry.worker = null;
|
|
500
|
+
// Kill orphaned tmux session if manual agent
|
|
501
|
+
if (entry.state.tmuxSession) {
|
|
502
|
+
try {
|
|
503
|
+
const { execSync } = require('node:child_process');
|
|
504
|
+
execSync(`tmux kill-session -t "${entry.state.tmuxSession}" 2>/dev/null`, { timeout: 3000 });
|
|
505
|
+
console.log(`[workspace] Killed tmux session ${entry.state.tmuxSession}`);
|
|
506
|
+
} catch {} // session might already be dead
|
|
507
|
+
}
|
|
508
|
+
entry.state = { smithStatus: 'down', taskStatus: 'idle', history: entry.state.history, artifacts: [] };
|
|
509
|
+
this.emit('event', { type: 'task_status', agentId, taskStatus: 'idle' } satisfies WorkerEvent);
|
|
510
|
+
this.emitAgentsChanged();
|
|
511
|
+
this.saveNow();
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/** Reset all agents that depend on the given agent (recursively) */
|
|
515
|
+
private resetDownstream(agentId: string, visited = new Set<string>()): void {
|
|
516
|
+
if (visited.has(agentId)) return; // cycle protection
|
|
517
|
+
visited.add(agentId);
|
|
518
|
+
|
|
519
|
+
for (const [id, entry] of this.agents) {
|
|
520
|
+
if (id === agentId) continue;
|
|
521
|
+
if (!entry.config.dependsOn.includes(agentId)) continue;
|
|
522
|
+
if (entry.state.taskStatus === 'idle') continue;
|
|
523
|
+
console.log(`[workspace] Resetting ${entry.config.label} (${id}) to idle (upstream ${agentId} changed)`);
|
|
524
|
+
if (entry.worker) entry.worker.stop();
|
|
525
|
+
entry.worker = null;
|
|
526
|
+
entry.state = { smithStatus: entry.state.smithStatus, taskStatus: 'idle', history: entry.state.history, artifacts: [], cliSessionId: entry.state.cliSessionId };
|
|
527
|
+
this.emit('event', { type: 'task_status', agentId: id, taskStatus: 'idle' } satisfies WorkerEvent);
|
|
528
|
+
this.resetDownstream(id, visited);
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/** Validate that an agent can run (sync check). Throws on error. */
|
|
533
|
+
validateCanRun(agentId: string): void {
|
|
534
|
+
const entry = this.agents.get(agentId);
|
|
535
|
+
if (!entry) throw new Error(`Agent "${agentId}" not found`);
|
|
536
|
+
if (entry.config.type === 'input') return;
|
|
537
|
+
if (entry.state.taskStatus === 'running') throw new Error(`Agent "${entry.config.label}" is already running`);
|
|
538
|
+
for (const depId of entry.config.dependsOn) {
|
|
539
|
+
const dep = this.agents.get(depId);
|
|
540
|
+
if (!dep) throw new Error(`Dependency "${depId}" not found (deleted?). Edit the agent to fix.`);
|
|
541
|
+
if (dep.state.taskStatus !== 'done') {
|
|
542
|
+
const hint = dep.state.taskStatus === 'idle' ? ' (never executed — run it first)'
|
|
543
|
+
: dep.state.taskStatus === 'failed' ? ' (failed — retry it first)'
|
|
544
|
+
: dep.state.taskStatus === 'running' ? ' (still running — wait for it to finish)'
|
|
545
|
+
: '';
|
|
546
|
+
throw new Error(`Dependency "${dep.config.label}" not completed yet${hint}`);
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/** Run a specific agent. Requires daemon mode. force=true bypasses status checks (for retry). */
|
|
552
|
+
async runAgent(agentId: string, userInput?: string, force = false): Promise<void> {
|
|
553
|
+
if (!this.daemonActive) {
|
|
554
|
+
throw new Error('Start daemon first before running agents');
|
|
555
|
+
}
|
|
556
|
+
const label = this.agents.get(agentId)?.config.label || agentId;
|
|
557
|
+
console.log(`[workspace] runAgent(${label}, force=${force})`, new Error().stack?.split('\n').slice(2, 5).join(' <- '));
|
|
558
|
+
return this.runAgentDaemon(agentId, userInput, force);
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/** @deprecated Use runAgent (which now delegates to daemon mode) */
|
|
562
|
+
private async runAgentLegacy(agentId: string, userInput?: string): Promise<void> {
|
|
563
|
+
const entry = this.agents.get(agentId);
|
|
564
|
+
if (!entry) throw new Error(`Agent "${agentId}" not found`);
|
|
565
|
+
|
|
566
|
+
// Input nodes are completed via completeInput(), not run
|
|
567
|
+
if (entry.config.type === 'input') {
|
|
568
|
+
if (userInput) this.completeInput(agentId, userInput);
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
if (entry.state.taskStatus === 'running') return;
|
|
573
|
+
|
|
574
|
+
// Allow re-running done/failed/idle(was interrupted)/waiting_approval agents — reset them first
|
|
575
|
+
let resumeFromCheckpoint = false;
|
|
576
|
+
if (entry.state.taskStatus === 'done' || entry.state.taskStatus === 'failed' || entry.state.taskStatus === 'idle' || this.approvalQueue.has(agentId)) {
|
|
577
|
+
this.approvalQueue.delete(agentId);
|
|
578
|
+
console.log(`[workspace] Re-running ${entry.config.label} (was taskStatus=${entry.state.taskStatus})`);
|
|
579
|
+
// For failed: keep lastCheckpoint for resume
|
|
580
|
+
resumeFromCheckpoint = (entry.state.taskStatus === 'failed')
|
|
581
|
+
&& entry.state.lastCheckpoint !== undefined;
|
|
582
|
+
if (entry.worker) entry.worker.stop();
|
|
583
|
+
entry.worker = null;
|
|
584
|
+
if (!resumeFromCheckpoint) {
|
|
585
|
+
entry.state = { smithStatus: entry.state.smithStatus, taskStatus: 'idle', history: entry.state.history, artifacts: [], cliSessionId: entry.state.cliSessionId };
|
|
586
|
+
} else {
|
|
587
|
+
entry.state.taskStatus = 'idle';
|
|
588
|
+
entry.state.error = undefined;
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
const { config } = entry;
|
|
593
|
+
|
|
594
|
+
// Check if all dependencies are done
|
|
595
|
+
for (const depId of config.dependsOn) {
|
|
596
|
+
const dep = this.agents.get(depId);
|
|
597
|
+
if (!dep || dep.state.taskStatus !== 'done') {
|
|
598
|
+
throw new Error(`Dependency "${dep?.config.label || depId}" not completed yet`);
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
// Build upstream context from dependencies (includes Input node content)
|
|
603
|
+
let upstreamContext = this.buildUpstreamContext(config);
|
|
604
|
+
if (userInput) {
|
|
605
|
+
const prefix = '## Additional Instructions:\n' + userInput;
|
|
606
|
+
upstreamContext = upstreamContext ? prefix + '\n\n---\n\n' + upstreamContext : prefix;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
// Create backend
|
|
610
|
+
const backend = this.createBackend(config, agentId);
|
|
611
|
+
|
|
612
|
+
// Create worker with bus callbacks for inter-agent communication
|
|
613
|
+
// Load agent memory
|
|
614
|
+
const memory = loadMemory(this.workspaceId, agentId);
|
|
615
|
+
const memoryContext = formatMemoryForPrompt(memory);
|
|
616
|
+
|
|
617
|
+
const peerAgentIds = Array.from(this.agents.keys()).filter(id => id !== agentId);
|
|
618
|
+
const worker = new AgentWorker({
|
|
619
|
+
config,
|
|
620
|
+
backend,
|
|
621
|
+
projectPath: this.projectPath, workspaceId: this.workspaceId,
|
|
622
|
+
peerAgentIds,
|
|
623
|
+
memoryContext: memoryContext || undefined,
|
|
624
|
+
onBusSend: (to, content) => {
|
|
625
|
+
this.bus.send(agentId, to, 'notify', { action: 'agent_message', content });
|
|
626
|
+
},
|
|
627
|
+
onBusRequest: async (to, question) => {
|
|
628
|
+
const response = await this.bus.request(agentId, to, { action: 'question', content: question });
|
|
629
|
+
return response.payload.content || '(no response)';
|
|
630
|
+
},
|
|
631
|
+
onMemoryUpdate: (stepResults) => {
|
|
632
|
+
this.updateAgentMemory(agentId, config, stepResults);
|
|
633
|
+
},
|
|
634
|
+
});
|
|
635
|
+
entry.worker = worker;
|
|
636
|
+
|
|
637
|
+
// Forward worker events
|
|
638
|
+
worker.on('event', (event: WorkerEvent) => {
|
|
639
|
+
// Sync state
|
|
640
|
+
entry.state = worker.getState() as AgentState;
|
|
641
|
+
|
|
642
|
+
// Persist log entries to disk
|
|
643
|
+
if (event.type === 'log') {
|
|
644
|
+
appendAgentLog(this.workspaceId, agentId, event.entry).catch(() => {});
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
this.emit('event', event);
|
|
648
|
+
|
|
649
|
+
// Update liveness
|
|
650
|
+
if (event.type === 'task_status' || event.type === 'smith_status') {
|
|
651
|
+
this.updateAgentLiveness(agentId);
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
// On step complete → capture observation + notify bus
|
|
655
|
+
if (event.type === 'step') {
|
|
656
|
+
const step = config.steps[event.stepIndex];
|
|
657
|
+
if (step) {
|
|
658
|
+
this.bus.notifyStepComplete(agentId, step.label);
|
|
659
|
+
|
|
660
|
+
// Capture memory observation from the previous step's result
|
|
661
|
+
const prevStepIdx = event.stepIndex - 1;
|
|
662
|
+
if (prevStepIdx >= 0) {
|
|
663
|
+
const prevStep = config.steps[prevStepIdx];
|
|
664
|
+
const prevResult = entry.state.history
|
|
665
|
+
.filter(h => h.type === 'result' && h.subtype === 'step_complete')
|
|
666
|
+
.slice(-1)[0];
|
|
667
|
+
if (prevResult && prevStep) {
|
|
668
|
+
const obs = parseStepToObservations(prevStep.label, prevResult.content, entry.state.artifacts);
|
|
669
|
+
for (const o of obs) {
|
|
670
|
+
addObservation(this.workspaceId, agentId, config.label, config.role, o).catch(() => {});
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
// On done → notify + trigger downstream (or reply to sender if from downstream)
|
|
678
|
+
if (event.type === 'done') {
|
|
679
|
+
this.handleAgentDone(agentId, entry, event.summary);
|
|
680
|
+
|
|
681
|
+
this.emitWorkspaceStatus();
|
|
682
|
+
this.checkWorkspaceComplete();
|
|
683
|
+
|
|
684
|
+
// Note: no auto-rerun. Bus messages that need re-run go through user approval.
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
// On error → notify bus
|
|
688
|
+
if (event.type === 'error') {
|
|
689
|
+
this.bus.notifyError(agentId, event.error);
|
|
690
|
+
this.emitWorkspaceStatus();
|
|
691
|
+
}
|
|
692
|
+
});
|
|
693
|
+
|
|
694
|
+
// Inject only undelivered (pending) bus messages addressed to this agent
|
|
695
|
+
const pendingMsgs = this.bus.getPendingMessagesFor(agentId)
|
|
696
|
+
.filter(m => m.from !== agentId); // don't inject own messages
|
|
697
|
+
for (const msg of pendingMsgs) {
|
|
698
|
+
const fromLabel = this.agents.get(msg.from)?.config.label || msg.from;
|
|
699
|
+
worker.injectMessage({
|
|
700
|
+
type: 'system',
|
|
701
|
+
subtype: 'bus_message',
|
|
702
|
+
content: `[From ${fromLabel}]: ${msg.payload.content || msg.payload.action}`,
|
|
703
|
+
timestamp: new Date(msg.timestamp).toISOString(),
|
|
704
|
+
});
|
|
705
|
+
// Mark as delivered + ACK so sender knows it was received
|
|
706
|
+
msg.status = 'done';
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
// Start from checkpoint if recovering from failure
|
|
710
|
+
const startStep = resumeFromCheckpoint && entry.state.lastCheckpoint !== undefined
|
|
711
|
+
? entry.state.lastCheckpoint + 1
|
|
712
|
+
: 0;
|
|
713
|
+
|
|
714
|
+
this.emitWorkspaceStatus();
|
|
715
|
+
|
|
716
|
+
// Execute (non-blocking — fire and forget, events handle the rest)
|
|
717
|
+
worker.execute(startStep, upstreamContext).catch(err => {
|
|
718
|
+
// Only set failed if worker didn't already handle it (avoid duplicate error events)
|
|
719
|
+
if (entry.state.taskStatus !== 'failed') {
|
|
720
|
+
entry.state.taskStatus = 'failed';
|
|
721
|
+
entry.state.error = err?.message || String(err);
|
|
722
|
+
this.emit('event', { type: 'error', agentId, error: entry.state.error! } satisfies WorkerEvent);
|
|
723
|
+
}
|
|
724
|
+
});
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
/** Run all agents — starts daemon if not active, then runs all ready agents */
|
|
728
|
+
async runAll(): Promise<void> {
|
|
729
|
+
if (!this.daemonActive) {
|
|
730
|
+
return this.startDaemon();
|
|
731
|
+
}
|
|
732
|
+
const ready = this.getDaemonReadyAgents();
|
|
733
|
+
await Promise.all(ready.map(id => this.runAgentDaemon(id)));
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
/** Run a single agent in daemon mode. force=true resets failed/interrupted agents. triggerMessageId tracks which bus message started this. */
|
|
737
|
+
async runAgentDaemon(agentId: string, userInput?: string, force = false, triggerMessageId?: string): Promise<void> {
|
|
738
|
+
const entry = this.agents.get(agentId);
|
|
739
|
+
if (!entry) throw new Error(`Agent "${agentId}" not found`);
|
|
740
|
+
|
|
741
|
+
if (entry.config.type === 'input') {
|
|
742
|
+
if (userInput) this.completeInput(agentId, userInput);
|
|
743
|
+
return;
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
if (entry.state.taskStatus === 'running' && !force) return;
|
|
747
|
+
// Already has a daemon worker running → skip (unless force retry)
|
|
748
|
+
if (entry.worker && entry.state.smithStatus === 'active' && !force) return;
|
|
749
|
+
|
|
750
|
+
// Already done → enter daemon listening directly (don't re-run steps)
|
|
751
|
+
if (entry.state.taskStatus === 'done' && !force) {
|
|
752
|
+
return this.enterDaemonListening(agentId);
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
if (!force) {
|
|
756
|
+
// Failed → leave as-is, user must retry explicitly
|
|
757
|
+
if (entry.state.taskStatus === 'failed') return;
|
|
758
|
+
// waiting_approval → leave as-is
|
|
759
|
+
if (this.approvalQueue.has(agentId)) return;
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
// Reset state for fresh start — preserve smithStatus and mode
|
|
763
|
+
if (entry.state.taskStatus !== 'idle') {
|
|
764
|
+
this.approvalQueue.delete(agentId);
|
|
765
|
+
if (entry.worker) entry.worker.stop();
|
|
766
|
+
entry.worker = null;
|
|
767
|
+
entry.state = {
|
|
768
|
+
smithStatus: entry.state.smithStatus,
|
|
769
|
+
taskStatus: 'idle',
|
|
770
|
+
history: [],
|
|
771
|
+
artifacts: [],
|
|
772
|
+
cliSessionId: entry.state.cliSessionId, // preserve session for --resume
|
|
773
|
+
};
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
// Ensure smith is active when daemon starts this agent
|
|
777
|
+
// Skip if 'starting': ensurePersistentSession is in progress and will set 'active' when done.
|
|
778
|
+
if (this.daemonActive && entry.state.smithStatus !== 'active' && entry.state.smithStatus !== 'starting') {
|
|
779
|
+
entry.state.smithStatus = 'active';
|
|
780
|
+
this.emit('event', { type: 'smith_status', agentId, smithStatus: 'active' } satisfies WorkerEvent);
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
const { config } = entry;
|
|
784
|
+
|
|
785
|
+
// Check dependencies
|
|
786
|
+
for (const depId of config.dependsOn) {
|
|
787
|
+
const dep = this.agents.get(depId);
|
|
788
|
+
if (!dep) throw new Error(`Dependency "${depId}" not found`);
|
|
789
|
+
if (force) {
|
|
790
|
+
// Manual trigger: only require upstream smith to be active (online)
|
|
791
|
+
if (dep.config.type !== 'input' && dep.state.smithStatus !== 'active') {
|
|
792
|
+
throw new Error(`Dependency "${dep.config.label}" smith is not active — start daemon first`);
|
|
793
|
+
}
|
|
794
|
+
} else {
|
|
795
|
+
// Auto trigger: require upstream task completed
|
|
796
|
+
if (dep.state.taskStatus !== 'done') {
|
|
797
|
+
throw new Error(`Dependency "${dep.config.label}" not completed yet`);
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
// Role is now injected via buildUpstreamContext (headless) and persistent session preamble (terminal).
|
|
803
|
+
// No longer writes to CLAUDE.md — project files stay clean when daemon stops.
|
|
804
|
+
|
|
805
|
+
let upstreamContext = this.buildUpstreamContext(config);
|
|
806
|
+
if (userInput) {
|
|
807
|
+
const prefix = '## Additional Instructions:\n' + userInput;
|
|
808
|
+
upstreamContext = upstreamContext ? prefix + '\n\n---\n\n' + upstreamContext : prefix;
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
const backend = this.createBackend(config, agentId);
|
|
812
|
+
const memory = loadMemory(this.workspaceId, agentId);
|
|
813
|
+
const memoryContext = formatMemoryForPrompt(memory);
|
|
814
|
+
const peerAgentIds = Array.from(this.agents.keys()).filter(id => id !== agentId);
|
|
815
|
+
|
|
816
|
+
const worker = new AgentWorker({
|
|
817
|
+
config, backend,
|
|
818
|
+
projectPath: this.projectPath, workspaceId: this.workspaceId,
|
|
819
|
+
peerAgentIds,
|
|
820
|
+
memoryContext: memoryContext || undefined,
|
|
821
|
+
onBusSend: (to, content) => {
|
|
822
|
+
this.bus.send(agentId, to, 'notify', { action: 'agent_message', content });
|
|
823
|
+
},
|
|
824
|
+
onBusRequest: async (to, question) => {
|
|
825
|
+
const response = await this.bus.request(agentId, to, { action: 'question', content: question });
|
|
826
|
+
return response.payload.content || '(no response)';
|
|
827
|
+
},
|
|
828
|
+
onMessageDone: (messageId) => {
|
|
829
|
+
const busMsg = this.bus.getLog().find(m => m.id === messageId);
|
|
830
|
+
if (busMsg) {
|
|
831
|
+
busMsg.status = 'done';
|
|
832
|
+
this.emit('event', { type: 'bus_message_status', messageId, status: 'done' } as any);
|
|
833
|
+
this.emitAgentsChanged();
|
|
834
|
+
}
|
|
835
|
+
},
|
|
836
|
+
onMessageFailed: (messageId) => {
|
|
837
|
+
const busMsg = this.bus.getLog().find(m => m.id === messageId);
|
|
838
|
+
if (busMsg) {
|
|
839
|
+
busMsg.status = 'failed';
|
|
840
|
+
this.emit('event', { type: 'bus_message_status', messageId, status: 'failed' } as any);
|
|
841
|
+
this.emitAgentsChanged();
|
|
842
|
+
}
|
|
843
|
+
},
|
|
844
|
+
onMemoryUpdate: (stepResults) => {
|
|
845
|
+
try {
|
|
846
|
+
const observations = stepResults.flatMap((r, i) =>
|
|
847
|
+
parseStepToObservations(config.steps[i]?.label || `Step ${i}`, r, entry.state.artifacts)
|
|
848
|
+
);
|
|
849
|
+
for (const obs of observations) addObservation(this.workspaceId, agentId, config.label, config.role, obs);
|
|
850
|
+
const stepLabels = config.steps.map(s => s.label);
|
|
851
|
+
const summary = buildSessionSummary(stepLabels, stepResults, entry.state.artifacts);
|
|
852
|
+
addSessionSummary(this.workspaceId, agentId, summary);
|
|
853
|
+
} catch {}
|
|
854
|
+
},
|
|
855
|
+
});
|
|
856
|
+
|
|
857
|
+
entry.worker = worker;
|
|
858
|
+
|
|
859
|
+
// Track trigger message so smith can mark it done/failed on completion
|
|
860
|
+
if (triggerMessageId) {
|
|
861
|
+
worker.setProcessingMessage(triggerMessageId);
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
// Forward events (same as runAgent)
|
|
865
|
+
worker.on('event', (event: WorkerEvent) => {
|
|
866
|
+
if (event.type === 'task_status') {
|
|
867
|
+
entry.state.taskStatus = event.taskStatus;
|
|
868
|
+
entry.state.error = event.error;
|
|
869
|
+
if (event.taskStatus === 'running') entry.state.startedAt = Date.now();
|
|
870
|
+
const workerState = worker.getState();
|
|
871
|
+
entry.state.daemonIteration = workerState.daemonIteration;
|
|
872
|
+
}
|
|
873
|
+
if (event.type === 'smith_status') {
|
|
874
|
+
entry.state.smithStatus = event.smithStatus;
|
|
875
|
+
}
|
|
876
|
+
if (event.type === 'log') {
|
|
877
|
+
appendAgentLog(this.workspaceId, agentId, event.entry).catch(() => {});
|
|
878
|
+
}
|
|
879
|
+
this.emit('event', event);
|
|
880
|
+
if (event.type === 'task_status' || event.type === 'smith_status') {
|
|
881
|
+
this.updateAgentLiveness(agentId);
|
|
882
|
+
}
|
|
883
|
+
if (event.type === 'step' && event.stepIndex >= 0) {
|
|
884
|
+
const step = config.steps[event.stepIndex];
|
|
885
|
+
if (step) this.bus.notifyStepComplete(agentId, step.label);
|
|
886
|
+
}
|
|
887
|
+
if (event.type === 'done') {
|
|
888
|
+
this.handleAgentDone(agentId, entry, event.summary);
|
|
889
|
+
}
|
|
890
|
+
if (event.type === 'error') {
|
|
891
|
+
this.agentRunningMsg.delete(agentId);
|
|
892
|
+
this.bus.notifyError(agentId, event.error);
|
|
893
|
+
this.emitWorkspaceStatus();
|
|
894
|
+
}
|
|
895
|
+
});
|
|
896
|
+
|
|
897
|
+
// Inject pending messages
|
|
898
|
+
const pendingMsgs = this.bus.getPendingMessagesFor(agentId)
|
|
899
|
+
.filter(m => m.from !== agentId);
|
|
900
|
+
for (const msg of pendingMsgs) {
|
|
901
|
+
const fromLabel = this.agents.get(msg.from)?.config.label || msg.from;
|
|
902
|
+
worker.injectMessage({
|
|
903
|
+
type: 'system', subtype: 'bus_message',
|
|
904
|
+
content: `[From ${fromLabel}]: ${msg.payload.content || msg.payload.action}`,
|
|
905
|
+
timestamp: new Date(msg.timestamp).toISOString(),
|
|
906
|
+
});
|
|
907
|
+
msg.status = 'done';
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
this.emitWorkspaceStatus();
|
|
911
|
+
|
|
912
|
+
// Execute in daemon mode (non-blocking)
|
|
913
|
+
worker.executeDaemon(0, upstreamContext).catch(err => {
|
|
914
|
+
if (entry.state.taskStatus !== 'failed') {
|
|
915
|
+
this.agentRunningMsg.delete(agentId);
|
|
916
|
+
entry.state.taskStatus = 'failed';
|
|
917
|
+
entry.state.error = err?.message || String(err);
|
|
918
|
+
this.emit('event', { type: 'error', agentId, error: entry.state.error! } satisfies WorkerEvent);
|
|
919
|
+
}
|
|
920
|
+
});
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
/** Start all agents in daemon mode — orchestrator manages each smith's lifecycle */
|
|
924
|
+
async startDaemon(): Promise<void> {
|
|
925
|
+
if (this.daemonActive) return;
|
|
926
|
+
this.daemonActive = true;
|
|
927
|
+
console.log(`[workspace] Starting daemon mode...`);
|
|
928
|
+
|
|
929
|
+
// Clean up stale state from previous run
|
|
930
|
+
this.bus.markAllRunningAsFailed();
|
|
931
|
+
|
|
932
|
+
// Install forge skills globally (once per daemon start)
|
|
933
|
+
try {
|
|
934
|
+
installForgeSkills(this.projectPath, this.workspaceId, '', Number(process.env.PORT) || 8403);
|
|
935
|
+
} catch {}
|
|
936
|
+
|
|
937
|
+
// Validate agent IDs — fallback to default if configured agent was deleted from Settings
|
|
938
|
+
let defaultAgentId = 'claude';
|
|
939
|
+
try {
|
|
940
|
+
const { listAgents, getDefaultAgentId } = await import('../agents/index') as any;
|
|
941
|
+
const validAgents = new Set((listAgents() as any[]).map(a => a.id));
|
|
942
|
+
defaultAgentId = getDefaultAgentId() || 'claude';
|
|
943
|
+
for (const [id, entry] of this.agents) {
|
|
944
|
+
if (entry.config.type === 'input') continue;
|
|
945
|
+
if (entry.config.agentId && !validAgents.has(entry.config.agentId)) {
|
|
946
|
+
console.log(`[daemon] ${entry.config.label}: agent "${entry.config.agentId}" not found, falling back to "${defaultAgentId}"`);
|
|
947
|
+
entry.config.agentId = defaultAgentId;
|
|
948
|
+
this.emit('event', { type: 'log', agentId: id, entry: { type: 'system', subtype: 'warning', content: `Agent "${entry.config.agentId}" not found in Settings — using default "${defaultAgentId}"`, timestamp: new Date().toISOString() } } as any);
|
|
949
|
+
this.saveNow();
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
} catch {}
|
|
953
|
+
|
|
954
|
+
// Start each smith one by one, verify each starts correctly
|
|
955
|
+
let started = 0;
|
|
956
|
+
let failed = 0;
|
|
957
|
+
for (const [id, entry] of this.agents) {
|
|
958
|
+
if (entry.config.type === 'input') continue;
|
|
959
|
+
|
|
960
|
+
// Kill any stale worker from previous run
|
|
961
|
+
if (entry.worker) {
|
|
962
|
+
entry.worker.stop();
|
|
963
|
+
entry.worker = null;
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
// Stop any existing message loop
|
|
967
|
+
this.stopMessageLoop(id);
|
|
968
|
+
|
|
969
|
+
try {
|
|
970
|
+
// 1. Start daemon listening loop (creates worker)
|
|
971
|
+
this.enterDaemonListening(id);
|
|
972
|
+
|
|
973
|
+
// 2. Verify worker was created
|
|
974
|
+
if (!entry.worker) {
|
|
975
|
+
throw new Error('Worker not created');
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
// 3. Set smith status — persistent session agents stay 'starting' until ensurePersistentSession completes
|
|
979
|
+
entry.state.smithStatus = entry.config.persistentSession ? 'starting' : 'active';
|
|
980
|
+
entry.state.error = undefined;
|
|
981
|
+
|
|
982
|
+
// 4. Start message loop (delayed for persistent session agents — session must exist first)
|
|
983
|
+
if (!entry.config.persistentSession) {
|
|
984
|
+
this.startMessageLoop(id);
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
// 5. Update liveness for bus routing
|
|
988
|
+
this.updateAgentLiveness(id);
|
|
989
|
+
|
|
990
|
+
// 6. Notify frontend
|
|
991
|
+
this.emit('event', { type: 'smith_status', agentId: id, smithStatus: entry.state.smithStatus } as any);
|
|
992
|
+
|
|
993
|
+
started++;
|
|
994
|
+
console.log(`[daemon] ✓ ${entry.config.label}: ${entry.state.smithStatus} (task=${entry.state.taskStatus})`);
|
|
995
|
+
} catch (err: any) {
|
|
996
|
+
entry.state.smithStatus = 'down';
|
|
997
|
+
entry.state.error = `Failed to start: ${err.message}`;
|
|
998
|
+
this.emit('event', { type: 'smith_status', agentId: id, smithStatus: 'down' } satisfies WorkerEvent);
|
|
999
|
+
failed++;
|
|
1000
|
+
console.error(`[daemon] ✗ ${entry.config.label}: failed — ${err.message}`);
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
// Migration: clean any legacy Forge blocks from CLAUDE.md files (role/topo no longer written to disk)
|
|
1005
|
+
this.cleanLegacyClaudeMdBlocks();
|
|
1006
|
+
|
|
1007
|
+
// Create persistent terminal sessions, then start their message loops
|
|
1008
|
+
for (const [id, entry] of this.agents) {
|
|
1009
|
+
if (entry.config.type === 'input' || !entry.config.persistentSession) continue;
|
|
1010
|
+
await this.ensurePersistentSession(id, entry.config);
|
|
1011
|
+
// Set active now that session + boundSessionId are ready
|
|
1012
|
+
if (entry.state.smithStatus === 'starting') {
|
|
1013
|
+
entry.state.smithStatus = 'active';
|
|
1014
|
+
this.emit('event', { type: 'smith_status', agentId: id, smithStatus: 'active' } as any);
|
|
1015
|
+
}
|
|
1016
|
+
if (entry.state.smithStatus === 'active') {
|
|
1017
|
+
// Inject role preamble so Claude knows its role (replaces CLAUDE.md writing)
|
|
1018
|
+
if (entry.config.role?.trim()) {
|
|
1019
|
+
const preamble = this.buildRolePreamble(entry.config);
|
|
1020
|
+
if (this.injectIntoSession(id, preamble)) {
|
|
1021
|
+
this.markRoleInjected(id);
|
|
1022
|
+
console.log(`[daemon] ${entry.config.label}: injected role preamble`);
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
this.startMessageLoop(id);
|
|
1026
|
+
} else {
|
|
1027
|
+
console.log(`[daemon] ${entry.config.label}: skipped message loop (smith=${entry.state.smithStatus})`);
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
// Build workspace topology cache (all smiths can query via MCP get_agents)
|
|
1032
|
+
this.rebuildTopo();
|
|
1033
|
+
|
|
1034
|
+
// Start watch loops for agents with watch config
|
|
1035
|
+
this.watchManager.start();
|
|
1036
|
+
|
|
1037
|
+
// Start session file monitors for agents with known session IDs
|
|
1038
|
+
this.startSessionMonitors().catch(err => console.error('[session-monitor] Failed to start:', err.message));
|
|
1039
|
+
|
|
1040
|
+
// Start health check — monitor all agents every 10s, auto-heal
|
|
1041
|
+
this.startHealthCheck();
|
|
1042
|
+
|
|
1043
|
+
console.log(`[workspace] Daemon started: ${started} smiths active, ${failed} failed`);
|
|
1044
|
+
this.emitAgentsChanged();
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
/** Get agents that can start in daemon mode (idle, done — with deps met) */
|
|
1048
|
+
private getDaemonReadyAgents(): string[] {
|
|
1049
|
+
const ready: string[] = [];
|
|
1050
|
+
for (const [id, entry] of this.agents) {
|
|
1051
|
+
if (entry.config.type === 'input') continue;
|
|
1052
|
+
if (entry.state.taskStatus === 'running' || entry.state.smithStatus === 'active') {
|
|
1053
|
+
console.log(`[daemon] ${entry.config.label}: already smithStatus=${entry.state.smithStatus} taskStatus=${entry.state.taskStatus}`);
|
|
1054
|
+
continue;
|
|
1055
|
+
}
|
|
1056
|
+
const allDepsDone = entry.config.dependsOn.every(depId => {
|
|
1057
|
+
const dep = this.agents.get(depId);
|
|
1058
|
+
return dep && (dep.state.taskStatus === 'done');
|
|
1059
|
+
});
|
|
1060
|
+
if (allDepsDone) {
|
|
1061
|
+
console.log(`[daemon] ${entry.config.label}: ready (taskStatus=${entry.state.taskStatus})`);
|
|
1062
|
+
ready.push(id);
|
|
1063
|
+
} else {
|
|
1064
|
+
const unmet = entry.config.dependsOn.filter(d => {
|
|
1065
|
+
const dep = this.agents.get(d);
|
|
1066
|
+
return !dep || (dep.state.taskStatus !== 'done');
|
|
1067
|
+
}).map(d => this.agents.get(d)?.config.label || d);
|
|
1068
|
+
console.log(`[daemon] ${entry.config.label}: not ready — deps unmet: ${unmet.join(', ')} (taskStatus=${entry.state.taskStatus})`);
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
return ready;
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
/** Put a done agent into daemon listening mode without re-running steps */
|
|
1075
|
+
private enterDaemonListening(agentId: string): void {
|
|
1076
|
+
const entry = this.agents.get(agentId);
|
|
1077
|
+
if (!entry) return;
|
|
1078
|
+
|
|
1079
|
+
// Stop existing worker first to prevent duplicate execution
|
|
1080
|
+
if (entry.worker) {
|
|
1081
|
+
entry.worker.removeAllListeners();
|
|
1082
|
+
entry.worker.stop();
|
|
1083
|
+
entry.worker = null;
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
const { config } = entry;
|
|
1087
|
+
|
|
1088
|
+
const backend = this.createBackend(config, agentId);
|
|
1089
|
+
const peerAgentIds = Array.from(this.agents.keys()).filter(id => id !== agentId);
|
|
1090
|
+
|
|
1091
|
+
const worker = new AgentWorker({
|
|
1092
|
+
config, backend,
|
|
1093
|
+
projectPath: this.projectPath, workspaceId: this.workspaceId,
|
|
1094
|
+
peerAgentIds,
|
|
1095
|
+
initialTaskStatus: entry.state.taskStatus, // preserve current task status
|
|
1096
|
+
onBusSend: (to, content) => {
|
|
1097
|
+
this.bus.send(agentId, to, 'notify', { action: 'agent_message', content });
|
|
1098
|
+
},
|
|
1099
|
+
onBusRequest: async (to, question) => {
|
|
1100
|
+
const response = await this.bus.request(agentId, to, { action: 'question', content: question });
|
|
1101
|
+
return response.payload.content || '(no response)';
|
|
1102
|
+
},
|
|
1103
|
+
onMessageDone: (messageId) => {
|
|
1104
|
+
const busMsg = this.bus.getLog().find(m => m.id === messageId);
|
|
1105
|
+
if (busMsg) {
|
|
1106
|
+
busMsg.status = 'done';
|
|
1107
|
+
this.emit('event', { type: 'bus_message_status', messageId, status: 'done' } as any);
|
|
1108
|
+
this.emitAgentsChanged();
|
|
1109
|
+
}
|
|
1110
|
+
},
|
|
1111
|
+
onMessageFailed: (messageId) => {
|
|
1112
|
+
const busMsg = this.bus.getLog().find(m => m.id === messageId);
|
|
1113
|
+
if (busMsg) {
|
|
1114
|
+
busMsg.status = 'failed';
|
|
1115
|
+
this.emit('event', { type: 'bus_message_status', messageId, status: 'failed' } as any);
|
|
1116
|
+
this.emitAgentsChanged();
|
|
1117
|
+
}
|
|
1118
|
+
},
|
|
1119
|
+
});
|
|
1120
|
+
|
|
1121
|
+
entry.worker = worker;
|
|
1122
|
+
|
|
1123
|
+
// Forward events (same handler as runAgentDaemon)
|
|
1124
|
+
worker.on('event', (event: WorkerEvent) => {
|
|
1125
|
+
if (event.type === 'task_status') {
|
|
1126
|
+
entry.state.taskStatus = event.taskStatus;
|
|
1127
|
+
entry.state.error = event.error;
|
|
1128
|
+
const workerState = worker.getState();
|
|
1129
|
+
entry.state.daemonIteration = workerState.daemonIteration;
|
|
1130
|
+
}
|
|
1131
|
+
if (event.type === 'smith_status') {
|
|
1132
|
+
entry.state.smithStatus = event.smithStatus;
|
|
1133
|
+
}
|
|
1134
|
+
if (event.type === 'log') {
|
|
1135
|
+
appendAgentLog(this.workspaceId, agentId, event.entry).catch(() => {});
|
|
1136
|
+
}
|
|
1137
|
+
this.emit('event', event);
|
|
1138
|
+
if (event.type === 'task_status' || event.type === 'smith_status') {
|
|
1139
|
+
this.updateAgentLiveness(agentId);
|
|
1140
|
+
}
|
|
1141
|
+
if (event.type === 'done') {
|
|
1142
|
+
this.handleAgentDone(agentId, entry, event.summary);
|
|
1143
|
+
}
|
|
1144
|
+
if (event.type === 'error') {
|
|
1145
|
+
this.agentRunningMsg.delete(agentId);
|
|
1146
|
+
this.bus.notifyError(agentId, event.error);
|
|
1147
|
+
}
|
|
1148
|
+
});
|
|
1149
|
+
|
|
1150
|
+
// Message loop (startMessageLoop) handles auto-consumption of pending messages
|
|
1151
|
+
|
|
1152
|
+
console.log(`[workspace] Agent "${config.label}" entering daemon listening (task=${entry.state.taskStatus})`);
|
|
1153
|
+
|
|
1154
|
+
// executeDaemon with skipSteps=true → goes directly to listening loop
|
|
1155
|
+
worker.executeDaemon(0, undefined, true).catch(err => {
|
|
1156
|
+
console.error(`[workspace] enterDaemonListening error for ${config.label}:`, err.message);
|
|
1157
|
+
});
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
/** Stop all agents (exit daemon mode) */
|
|
1161
|
+
/** Stop all agents — orchestrator shuts down each smith */
|
|
1162
|
+
stopDaemon(): void {
|
|
1163
|
+
this.daemonActive = false;
|
|
1164
|
+
console.log('[workspace] Stopping daemon...');
|
|
1165
|
+
|
|
1166
|
+
for (const [id, entry] of this.agents) {
|
|
1167
|
+
if (entry.config.type === 'input') continue;
|
|
1168
|
+
|
|
1169
|
+
// 1. Stop message loop
|
|
1170
|
+
this.stopMessageLoop(id);
|
|
1171
|
+
|
|
1172
|
+
// 2. Stop worker
|
|
1173
|
+
if (entry.worker) {
|
|
1174
|
+
entry.worker.stop();
|
|
1175
|
+
entry.worker = null;
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
// 2b. For persistent sessions: send /clear to reset Claude's context if user is attached.
|
|
1179
|
+
// This is a Claude Code slash command — no LLM call, just a local context reset.
|
|
1180
|
+
if (entry.state.tmuxSession && entry.config.role?.trim()) {
|
|
1181
|
+
let isAttached = false;
|
|
1182
|
+
try {
|
|
1183
|
+
const info = execSync(`tmux display-message -t "${entry.state.tmuxSession}" -p "#{session_attached}" 2>/dev/null`, { timeout: 3000, encoding: 'utf-8' }).trim();
|
|
1184
|
+
isAttached = info !== '0';
|
|
1185
|
+
} catch {}
|
|
1186
|
+
if (isAttached) {
|
|
1187
|
+
try { this.injectIntoSession(id, '/clear'); } catch {}
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
this.roleInjectState.delete(id);
|
|
1191
|
+
|
|
1192
|
+
// 3. Kill tmux session (skip if user is attached to it)
|
|
1193
|
+
if (entry.state.tmuxSession) {
|
|
1194
|
+
let isAttached = false;
|
|
1195
|
+
try {
|
|
1196
|
+
const info = execSync(`tmux display-message -t "${entry.state.tmuxSession}" -p "#{session_attached}" 2>/dev/null`, { timeout: 3000, encoding: 'utf-8' }).trim();
|
|
1197
|
+
isAttached = info !== '0';
|
|
1198
|
+
} catch {}
|
|
1199
|
+
if (isAttached) {
|
|
1200
|
+
console.log(`[daemon] ${entry.config.label}: tmux session attached by user, not killing`);
|
|
1201
|
+
} else {
|
|
1202
|
+
try { execSync(`tmux kill-session -t "${entry.state.tmuxSession}" 2>/dev/null`, { timeout: 3000 }); } catch {}
|
|
1203
|
+
}
|
|
1204
|
+
entry.state.tmuxSession = undefined;
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
// 4. Set smith down, reset running tasks
|
|
1208
|
+
entry.state.smithStatus = 'down';
|
|
1209
|
+
if (entry.state.taskStatus === 'running') {
|
|
1210
|
+
entry.state.taskStatus = 'idle';
|
|
1211
|
+
}
|
|
1212
|
+
entry.state.error = undefined;
|
|
1213
|
+
this.updateAgentLiveness(id);
|
|
1214
|
+
this.emit('event', { type: 'smith_status', agentId: id, smithStatus: 'down' } satisfies WorkerEvent);
|
|
1215
|
+
|
|
1216
|
+
console.log(`[daemon] ■ ${entry.config.label}: stopped`);
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
// Mark running messages as failed
|
|
1220
|
+
this.bus.markAllRunningAsFailed();
|
|
1221
|
+
this.emitAgentsChanged();
|
|
1222
|
+
this.watchManager.stop();
|
|
1223
|
+
this.stopAllTerminalMonitors();
|
|
1224
|
+
if (this.sessionMonitor) { this.sessionMonitor.stopAll(); this.sessionMonitor = null; }
|
|
1225
|
+
this.stopHealthCheck();
|
|
1226
|
+
this.forgeActedMessages.clear();
|
|
1227
|
+
this.busMarkerScanned.clear();
|
|
1228
|
+
this.forgeAgentStartTime = 0;
|
|
1229
|
+
this.agentRunningMsg.clear();
|
|
1230
|
+
this.reconcileTick = 0;
|
|
1231
|
+
console.log('[workspace] Daemon stopped');
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
// ─── Hook-based completion ─────────────────────────────
|
|
1235
|
+
|
|
1236
|
+
/** Called by Claude Code Stop hook via HTTP — agent finished a turn */
|
|
1237
|
+
handleHookDone(agentId: string): void {
|
|
1238
|
+
const entry = this.agents.get(agentId);
|
|
1239
|
+
if (!entry) return;
|
|
1240
|
+
if (!this.daemonActive) return;
|
|
1241
|
+
|
|
1242
|
+
console.log(`[hook] ${entry.config.label}: Stop hook → done (was ${entry.state.taskStatus})`);
|
|
1243
|
+
entry.state.taskStatus = 'done';
|
|
1244
|
+
entry.state.completedAt = Date.now();
|
|
1245
|
+
this.emit('event', { type: 'task_status', agentId, taskStatus: 'done' } as any);
|
|
1246
|
+
this.emit('event', { type: 'log', agentId, entry: { type: 'system', subtype: 'hook_done', content: 'Claude Code Stop hook: turn completed', timestamp: new Date().toISOString() } } as any);
|
|
1247
|
+
this.handleAgentDone(agentId, entry, 'Stop hook');
|
|
1248
|
+
this.sessionMonitor?.resetState(agentId);
|
|
1249
|
+
this.saveNow();
|
|
1250
|
+
this.emitAgentsChanged();
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
// ─── Session File Monitor ──────────────────────────────
|
|
1254
|
+
|
|
1255
|
+
private async startSessionMonitors(): Promise<void> {
|
|
1256
|
+
console.log('[session-monitor] Initializing...');
|
|
1257
|
+
const { SessionFileMonitor } = await import('./session-monitor');
|
|
1258
|
+
this.sessionMonitor = new SessionFileMonitor();
|
|
1259
|
+
|
|
1260
|
+
// Listen for state changes from session file monitor
|
|
1261
|
+
this.sessionMonitor.on('stateChange', (event: any) => {
|
|
1262
|
+
const entry = this.agents.get(event.agentId);
|
|
1263
|
+
if (!entry) {
|
|
1264
|
+
console.log(`[session-monitor] stateChange: agent ${event.agentId} not found in map`);
|
|
1265
|
+
return;
|
|
1266
|
+
}
|
|
1267
|
+
console.log(`[session-monitor] stateChange: ${entry.config.label} ${event.state} (current taskStatus=${entry.state.taskStatus})`);
|
|
1268
|
+
|
|
1269
|
+
if (event.state === 'running' && entry.state.taskStatus !== 'running') {
|
|
1270
|
+
entry.state.taskStatus = 'running';
|
|
1271
|
+
console.log(`[session-monitor] → emitting task_status=running for ${entry.config.label}`);
|
|
1272
|
+
this.emit('event', { type: 'task_status', agentId: event.agentId, taskStatus: 'running' } as any);
|
|
1273
|
+
this.emitAgentsChanged();
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
if (event.state === 'done' && entry.state.taskStatus === 'running') {
|
|
1277
|
+
entry.state.taskStatus = 'done';
|
|
1278
|
+
this.emit('event', { type: 'task_status', agentId: event.agentId, taskStatus: 'done' } as any);
|
|
1279
|
+
console.log(`[session-monitor] ${event.agentId}: done — ${event.detail || 'turn completed'}`);
|
|
1280
|
+
this.handleAgentDone(event.agentId, entry, event.detail);
|
|
1281
|
+
this.emitAgentsChanged();
|
|
1282
|
+
}
|
|
1283
|
+
});
|
|
1284
|
+
|
|
1285
|
+
// Start monitors for all agents with known session IDs
|
|
1286
|
+
for (const [id, entry] of this.agents) {
|
|
1287
|
+
if (entry.config.type === 'input') continue;
|
|
1288
|
+
await this.startAgentSessionMonitor(id, entry.config);
|
|
1289
|
+
}
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
private async startAgentSessionMonitor(agentId: string, config: WorkspaceAgentConfig): Promise<void> {
|
|
1293
|
+
if (!this.sessionMonitor) return;
|
|
1294
|
+
|
|
1295
|
+
// Determine session file path
|
|
1296
|
+
let sessionId: string | undefined;
|
|
1297
|
+
|
|
1298
|
+
if (config.primary) {
|
|
1299
|
+
sessionId = getFixedSession(this.projectPath);
|
|
1300
|
+
console.log(`[session-monitor] ${config.label}: primary fixedSession=${sessionId || 'NONE'}`);
|
|
1301
|
+
} else {
|
|
1302
|
+
sessionId = config.boundSessionId;
|
|
1303
|
+
console.log(`[session-monitor] ${config.label}: boundSession=${sessionId || 'NONE'}`);
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
if (!sessionId) {
|
|
1307
|
+
// Try to auto-bind from session files on disk
|
|
1308
|
+
sessionId = this.getLatestSessionId(config.workDir);
|
|
1309
|
+
if (sessionId && !config.primary) {
|
|
1310
|
+
config.boundSessionId = sessionId;
|
|
1311
|
+
this.saveNow();
|
|
1312
|
+
console.log(`[session-monitor] ${config.label}: auto-bound to ${sessionId}`);
|
|
1313
|
+
}
|
|
1314
|
+
if (!sessionId) {
|
|
1315
|
+
console.log(`[session-monitor] ${config.label}: no sessionId, skipping`);
|
|
1316
|
+
return;
|
|
1317
|
+
}
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
const { SessionFileMonitor } = await import('./session-monitor');
|
|
1321
|
+
const filePath = SessionFileMonitor.resolveSessionPath(this.projectPath, config.workDir, sessionId);
|
|
1322
|
+
this.sessionMonitor.startMonitoring(agentId, filePath);
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
// ─── Health Check — auto-heal agents ─────────────────
|
|
1326
|
+
|
|
1327
|
+
private startHealthCheck(): void {
|
|
1328
|
+
if (this.healthCheckTimer) return;
|
|
1329
|
+
this.healthCheckTimer = setInterval(() => this.runHealthCheck(), 10_000);
|
|
1330
|
+
this.healthCheckTimer.unref();
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
private stopHealthCheck(): void {
|
|
1334
|
+
if (this.healthCheckTimer) {
|
|
1335
|
+
clearInterval(this.healthCheckTimer);
|
|
1336
|
+
this.healthCheckTimer = null;
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
private runHealthCheck(): void {
|
|
1341
|
+
if (!this.daemonActive) return;
|
|
1342
|
+
|
|
1343
|
+
// Every 60s (6 ticks × 10s): reconcile agentRunningMsg cache with actual bus log
|
|
1344
|
+
this.reconcileTick++;
|
|
1345
|
+
if (this.reconcileTick >= 6) {
|
|
1346
|
+
this.reconcileTick = 0;
|
|
1347
|
+
const log = this.bus.getLog();
|
|
1348
|
+
for (const [agentId, messageId] of this.agentRunningMsg) {
|
|
1349
|
+
const msg = log.find(m => m.id === messageId);
|
|
1350
|
+
if (!msg || msg.status !== 'running') {
|
|
1351
|
+
console.log(`[health] reconcile: clearing stale agentRunningMsg for ${agentId} (msg=${messageId.slice(0, 8)}, status=${msg?.status || 'not found'})`);
|
|
1352
|
+
this.agentRunningMsg.delete(agentId);
|
|
1353
|
+
}
|
|
1354
|
+
}
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
for (const [id, entry] of this.agents) {
|
|
1358
|
+
if (entry.config.type === 'input') continue;
|
|
1359
|
+
|
|
1360
|
+
// Check 1: Worker should exist for all active agents
|
|
1361
|
+
if (!entry.worker) {
|
|
1362
|
+
console.log(`[health] ${entry.config.label}: no worker — recreating`);
|
|
1363
|
+
this.enterDaemonListening(id);
|
|
1364
|
+
entry.state.smithStatus = 'active';
|
|
1365
|
+
this.emit('event', { type: 'smith_status', agentId: id, smithStatus: 'active' } as any);
|
|
1366
|
+
continue;
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
// Check 2: SmithStatus should be active
|
|
1370
|
+
// Skip: 'starting' means ensurePersistentSession is in progress — overriding would race with it.
|
|
1371
|
+
if (entry.state.smithStatus !== 'active' && entry.state.smithStatus !== 'starting') {
|
|
1372
|
+
console.log(`[health] ${entry.config.label}: smith=${entry.state.smithStatus} — setting active`);
|
|
1373
|
+
entry.state.smithStatus = 'active';
|
|
1374
|
+
this.emit('event', { type: 'smith_status', agentId: id, smithStatus: 'active' } as any);
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
// Check 3: Message loop should be running
|
|
1378
|
+
if (!this.messageLoopTimers.has(id)) {
|
|
1379
|
+
console.log(`[health] ${entry.config.label}: message loop stopped — restarting`);
|
|
1380
|
+
this.startMessageLoop(id);
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
// Check 4: Stale running messages (agent not actually running) → mark failed
|
|
1384
|
+
if (entry.state.taskStatus !== 'running') {
|
|
1385
|
+
const staleRunning = this.bus.getLog().filter(m => m.to === id && m.status === 'running' && m.type !== 'ack');
|
|
1386
|
+
for (const m of staleRunning) {
|
|
1387
|
+
const age = Date.now() - m.timestamp;
|
|
1388
|
+
if (age > 60_000) { // running for 60s+ but agent is idle = stale
|
|
1389
|
+
console.log(`[health] ${entry.config.label}: stale running message ${m.id.slice(0, 8)} (${Math.round(age/1000)}s) — marking failed`);
|
|
1390
|
+
m.status = 'failed';
|
|
1391
|
+
this.emit('event', { type: 'bus_message_status', messageId: m.id, status: 'failed' } as any);
|
|
1392
|
+
}
|
|
1393
|
+
}
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
// Check 5: Pending messages but agent idle — try wake
|
|
1397
|
+
if (entry.state.taskStatus !== 'running') {
|
|
1398
|
+
const pending = this.bus.getPendingMessagesFor(id).filter(m => m.from !== id && m.type !== 'ack');
|
|
1399
|
+
if (pending.length > 0 && entry.worker?.isListening()) {
|
|
1400
|
+
// Message loop should handle this, but if it didn't, log it
|
|
1401
|
+
const age = Date.now() - pending[0].timestamp;
|
|
1402
|
+
if (age > 30_000) { // stuck for 30+ seconds
|
|
1403
|
+
console.log(`[health] ${entry.config.label}: ${pending.length} pending msg(s) stuck for ${Math.round(age/1000)}s — message loop should pick up`);
|
|
1404
|
+
}
|
|
1405
|
+
}
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
// Check 6: persistentSession agent without tmux → auto-restart terminal
|
|
1409
|
+
// Skip if smithStatus='starting': ensurePersistentSession is already in progress.
|
|
1410
|
+
if (entry.config.persistentSession && !entry.state.tmuxSession && entry.state.smithStatus === 'active') {
|
|
1411
|
+
console.log(`[health] ${entry.config.label}: persistentSession but no tmux — restarting terminal`);
|
|
1412
|
+
this.ensurePersistentSession(id, entry.config).catch(err => {
|
|
1413
|
+
console.error(`[health] ${entry.config.label}: failed to restart terminal: ${err.message}`);
|
|
1414
|
+
});
|
|
1415
|
+
}
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
// ── Forge Agent: autonomous bus monitor ──
|
|
1419
|
+
this.runForgeAgentCheck();
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
// Track which messages Forge agent already acted on (avoid duplicate nudges)
|
|
1423
|
+
private forgeActedMessages = new Set<string>();
|
|
1424
|
+
private forgeAgentStartTime = 0;
|
|
1425
|
+
|
|
1426
|
+
/** Forge agent scans bus for actionable states (only recent messages) */
|
|
1427
|
+
private runForgeAgentCheck(): void {
|
|
1428
|
+
if (!this.forgeAgentStartTime) this.forgeAgentStartTime = Date.now();
|
|
1429
|
+
const log = this.bus.getLog();
|
|
1430
|
+
const now = Date.now();
|
|
1431
|
+
|
|
1432
|
+
// Pre-build reply index: "from→to" → latest non-ack message timestamp (only after daemon start)
|
|
1433
|
+
// Used for O(1) hasReply lookups instead of O(n) log.some() per message
|
|
1434
|
+
const replyIndex = new Map<string, number>();
|
|
1435
|
+
for (const r of log) {
|
|
1436
|
+
if (r.timestamp < this.forgeAgentStartTime) continue;
|
|
1437
|
+
if (r.type === 'ack') continue;
|
|
1438
|
+
const key = `${r.from}→${r.to}`;
|
|
1439
|
+
if ((replyIndex.get(key) || 0) < r.timestamp) replyIndex.set(key, r.timestamp);
|
|
1440
|
+
}
|
|
1441
|
+
|
|
1442
|
+
// Only scan messages from after daemon start (skip all history)
|
|
1443
|
+
for (const msg of log) {
|
|
1444
|
+
if (msg.timestamp < this.forgeAgentStartTime) continue;
|
|
1445
|
+
if (msg.type === 'ack' || msg.from === '_forge') continue;
|
|
1446
|
+
if (this.forgeActedMessages.has(msg.id)) continue;
|
|
1447
|
+
|
|
1448
|
+
// Case 1: Message done but no reply from target → ask target to send summary (once per pair)
|
|
1449
|
+
// Skip notification-only messages that don't need replies
|
|
1450
|
+
if (msg.status === 'done') {
|
|
1451
|
+
const action = msg.payload?.action;
|
|
1452
|
+
if (action === 'upstream_complete' || action === 'task_complete' || action === 'ack') { this.forgeActedMessages.add(msg.id); continue; }
|
|
1453
|
+
if (msg.from === '_system' || msg.from === '_watch') { this.forgeActedMessages.add(msg.id); continue; }
|
|
1454
|
+
const age = now - msg.timestamp;
|
|
1455
|
+
if (age < 30_000) continue;
|
|
1456
|
+
|
|
1457
|
+
// Dedup by target→sender pair (only nudge once per relationship)
|
|
1458
|
+
const nudgeKey = `nudge-${msg.to}->${msg.from}`;
|
|
1459
|
+
if (this.forgeActedMessages.has(nudgeKey)) { this.forgeActedMessages.add(msg.id); continue; }
|
|
1460
|
+
|
|
1461
|
+
const hasReply = (replyIndex.get(`${msg.to}→${msg.from}`) || 0) > msg.timestamp;
|
|
1462
|
+
if (!hasReply) {
|
|
1463
|
+
const senderLabel = this.agents.get(msg.from)?.config.label || msg.from;
|
|
1464
|
+
const targetEntry = this.agents.get(msg.to);
|
|
1465
|
+
if (targetEntry && targetEntry.state.smithStatus === 'active') {
|
|
1466
|
+
this.bus.send('_forge', msg.to, 'notify', {
|
|
1467
|
+
action: 'info_request',
|
|
1468
|
+
content: `[IMPORTANT] You finished a task requested by ${senderLabel} but did not send them the results. You MUST call the MCP tool "send_message" (NOT the forge-send skill) with to="${senderLabel}" and include a summary of what you did and the outcome. Do not do any other work until you have sent this reply.`,
|
|
1469
|
+
});
|
|
1470
|
+
this.forgeActedMessages.add(msg.id);
|
|
1471
|
+
this.forgeActedMessages.add(nudgeKey);
|
|
1472
|
+
console.log(`[forge-agent] Nudged ${targetEntry.config.label} to reply to ${senderLabel} (once)`);
|
|
1473
|
+
}
|
|
1474
|
+
}
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
// Case 2: Message running too long (>5min) → log warning
|
|
1478
|
+
if (msg.status === 'running') {
|
|
1479
|
+
const age = now - msg.timestamp;
|
|
1480
|
+
if (age > 300_000 && !this.forgeActedMessages.has(`running-${msg.id}`)) {
|
|
1481
|
+
const targetLabel = this.agents.get(msg.to)?.config.label || msg.to;
|
|
1482
|
+
console.log(`[forge-agent] Warning: ${targetLabel} has been running message ${msg.id.slice(0, 8)} for ${Math.round(age / 60000)}min`);
|
|
1483
|
+
this.emit('event', { type: 'log', agentId: msg.to, entry: { type: 'system', subtype: 'warning', content: `Message running for ${Math.round(age / 60000)}min — may be stuck`, timestamp: new Date().toISOString() } } as any);
|
|
1484
|
+
this.forgeActedMessages.add(`running-${msg.id}`);
|
|
1485
|
+
}
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1488
|
+
// Case 3: Pending too long (>2min) → try to restart message loop
|
|
1489
|
+
if (msg.status === 'pending') {
|
|
1490
|
+
const age = now - msg.timestamp;
|
|
1491
|
+
if (age > 120_000 && !this.forgeActedMessages.has(`pending-${msg.id}`)) {
|
|
1492
|
+
const targetEntry = this.agents.get(msg.to);
|
|
1493
|
+
const targetLabel = targetEntry?.config.label || msg.to;
|
|
1494
|
+
|
|
1495
|
+
// If agent is active but not running a task, restart message loop
|
|
1496
|
+
if (targetEntry && targetEntry.state.smithStatus === 'active' && targetEntry.state.taskStatus !== 'running') {
|
|
1497
|
+
if (!this.messageLoopTimers.has(msg.to)) {
|
|
1498
|
+
this.startMessageLoop(msg.to);
|
|
1499
|
+
console.log(`[forge-agent] Restarted message loop for ${targetLabel} (pending ${Math.round(age / 60000)}min)`);
|
|
1500
|
+
} else {
|
|
1501
|
+
console.log(`[forge-agent] ${targetLabel} has pending message ${msg.id.slice(0, 8)} for ${Math.round(age / 60000)}min — loop running but not consuming`);
|
|
1502
|
+
}
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1505
|
+
this.emit('event', { type: 'log', agentId: msg.to, entry: { type: 'system', subtype: 'warning', content: `Pending message from ${this.agents.get(msg.from)?.config.label || msg.from} waiting for ${Math.round(age / 60000)}min`, timestamp: new Date().toISOString() } } as any);
|
|
1506
|
+
this.forgeActedMessages.add(`pending-${msg.id}`);
|
|
1507
|
+
}
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1510
|
+
// Case 4: Failed → notify sender (once per sender→target pair)
|
|
1511
|
+
if (msg.status === 'failed') {
|
|
1512
|
+
const failKey = `failed-${msg.from}->${msg.to}`;
|
|
1513
|
+
if (!this.forgeActedMessages.has(failKey)) {
|
|
1514
|
+
const senderEntry = this.agents.get(msg.from);
|
|
1515
|
+
const targetLabel = this.agents.get(msg.to)?.config.label || msg.to;
|
|
1516
|
+
if (senderEntry && msg.from !== '_forge' && msg.from !== '_system') {
|
|
1517
|
+
this.bus.send('_forge', msg.from, 'notify', {
|
|
1518
|
+
action: 'update_notify',
|
|
1519
|
+
content: `Your message to ${targetLabel} has failed. You may want to retry or take a different approach.`,
|
|
1520
|
+
});
|
|
1521
|
+
console.log(`[forge-agent] Notified ${senderEntry.config.label} that message to ${targetLabel} failed (once)`);
|
|
1522
|
+
}
|
|
1523
|
+
this.forgeActedMessages.add(failKey);
|
|
1524
|
+
}
|
|
1525
|
+
this.forgeActedMessages.add(`failed-${msg.id}`);
|
|
1526
|
+
}
|
|
1527
|
+
|
|
1528
|
+
// Case 5: Pending approval too long (>5min) → log reminder
|
|
1529
|
+
if (msg.status === 'pending_approval') {
|
|
1530
|
+
const age = now - msg.timestamp;
|
|
1531
|
+
if (age > 300_000 && !this.forgeActedMessages.has(`approval-${msg.id}`)) {
|
|
1532
|
+
const targetLabel = this.agents.get(msg.to)?.config.label || msg.to;
|
|
1533
|
+
this.emit('event', { type: 'log', agentId: msg.to, entry: { type: 'system', subtype: 'warning', content: `Message awaiting approval for ${Math.round(age / 60000)}min — requires manual action`, timestamp: new Date().toISOString() } } as any);
|
|
1534
|
+
this.forgeActedMessages.add(`approval-${msg.id}`);
|
|
1535
|
+
}
|
|
1536
|
+
}
|
|
1537
|
+
}
|
|
1538
|
+
}
|
|
1539
|
+
|
|
1540
|
+
/** Handle watch alert based on agent's configured action */
|
|
1541
|
+
private handleWatchAlert(agentId: string, summary: string): void {
|
|
1542
|
+
const entry = this.agents.get(agentId);
|
|
1543
|
+
if (!entry) return;
|
|
1544
|
+
const action = entry.config.watch?.action || 'log';
|
|
1545
|
+
|
|
1546
|
+
if (action === 'log') {
|
|
1547
|
+
// Already logged by watch-manager, nothing more to do
|
|
1548
|
+
return;
|
|
1549
|
+
}
|
|
1550
|
+
|
|
1551
|
+
if (action === 'analyze') {
|
|
1552
|
+
// Auto-wake agent to analyze changes (skip if busy/manual)
|
|
1553
|
+
if (entry.state.taskStatus === 'running') {
|
|
1554
|
+
console.log(`[watch] ${entry.config.label}: skipped analyze (task=${entry.state.taskStatus})`);
|
|
1555
|
+
return;
|
|
1556
|
+
}
|
|
1557
|
+
if (!entry.worker?.isListening()) {
|
|
1558
|
+
console.log(`[watch] ${entry.config.label}: skipped analyze (worker=${!!entry.worker} listening=${entry.worker?.isListening()})`);
|
|
1559
|
+
return;
|
|
1560
|
+
}
|
|
1561
|
+
console.log(`[watch] ${entry.config.label}: triggering analyze`);
|
|
1562
|
+
|
|
1563
|
+
const prompt = entry.config.watch?.prompt || 'Analyze the following changes and produce a report:';
|
|
1564
|
+
const logEntry = {
|
|
1565
|
+
type: 'system' as const,
|
|
1566
|
+
subtype: 'watch_trigger',
|
|
1567
|
+
content: `[Watch] ${prompt}\n\n${summary}`,
|
|
1568
|
+
timestamp: new Date().toISOString(),
|
|
1569
|
+
};
|
|
1570
|
+
entry.worker.wake({ type: 'bus_message', messages: [logEntry] });
|
|
1571
|
+
console.log(`[watch] ${entry.config.label}: auto-analyzing detected changes`);
|
|
1572
|
+
return;
|
|
1573
|
+
}
|
|
1574
|
+
|
|
1575
|
+
if (action === 'approve') {
|
|
1576
|
+
// Create message with pending_approval status — user must approve to execute
|
|
1577
|
+
const msg = this.bus.send('_watch', agentId, 'notify', {
|
|
1578
|
+
action: 'watch_changes',
|
|
1579
|
+
content: `Watch detected changes (awaiting approval):\n${summary}`,
|
|
1580
|
+
});
|
|
1581
|
+
msg.status = 'pending_approval';
|
|
1582
|
+
this.emit('event', { type: 'bus_message_status', messageId: msg.id, status: 'pending_approval' } as any);
|
|
1583
|
+
console.log(`[watch] ${entry.config.label}: changes detected, awaiting approval`);
|
|
1584
|
+
}
|
|
1585
|
+
|
|
1586
|
+
if (action === 'send_message') {
|
|
1587
|
+
const targetId = entry.config.watch?.sendTo;
|
|
1588
|
+
if (!targetId) {
|
|
1589
|
+
console.log(`[watch] ${entry.config.label}: send_message but no sendTo configured`);
|
|
1590
|
+
return;
|
|
1591
|
+
}
|
|
1592
|
+
const targetEntry = this.agents.get(targetId);
|
|
1593
|
+
if (!targetEntry) {
|
|
1594
|
+
console.log(`[watch] ${entry.config.label}: sendTo agent ${targetId} not found`);
|
|
1595
|
+
return;
|
|
1596
|
+
}
|
|
1597
|
+
|
|
1598
|
+
const prompt = entry.config.watch?.prompt;
|
|
1599
|
+
// For terminal injection: send the configured prompt directly (pattern is the trigger, not the payload)
|
|
1600
|
+
// If no prompt configured, send the summary
|
|
1601
|
+
const message = prompt || summary;
|
|
1602
|
+
|
|
1603
|
+
// Try to inject directly into an open terminal session
|
|
1604
|
+
// Verify stored session is alive, clear if dead
|
|
1605
|
+
if (targetEntry.state.tmuxSession) {
|
|
1606
|
+
try { execSync(`tmux has-session -t "${targetEntry.state.tmuxSession}" 2>/dev/null`, { timeout: 3000 }); }
|
|
1607
|
+
catch { targetEntry.state.tmuxSession = undefined; }
|
|
1608
|
+
}
|
|
1609
|
+
const tmuxSession = targetEntry.state.tmuxSession || this.findTmuxSession(targetEntry.config.label);
|
|
1610
|
+
if (tmuxSession) {
|
|
1611
|
+
try {
|
|
1612
|
+
const tmpFile = `/tmp/forge-watch-${Date.now()}.txt`;
|
|
1613
|
+
writeFileSync(tmpFile, message);
|
|
1614
|
+
execSync(`tmux load-buffer ${tmpFile}`, { timeout: 5000 });
|
|
1615
|
+
execSync(`tmux paste-buffer -t "${tmuxSession}" && sleep 0.2 && tmux send-keys -t "${tmuxSession}" Enter`, { timeout: 5000 });
|
|
1616
|
+
try { unlinkSync(tmpFile); } catch {}
|
|
1617
|
+
console.log(`[watch] ${entry.config.label} → ${targetEntry.config.label}: injected into terminal (${tmuxSession})`);
|
|
1618
|
+
} catch (err: any) {
|
|
1619
|
+
console.error(`[watch] Terminal inject failed: ${err.message}, falling back to bus`);
|
|
1620
|
+
this.bus.send(agentId, targetId, 'notify', { action: 'watch_alert', content: message });
|
|
1621
|
+
}
|
|
1622
|
+
return;
|
|
1623
|
+
}
|
|
1624
|
+
|
|
1625
|
+
// No terminal open — send via bus (will start new session)
|
|
1626
|
+
const hasPendingFromWatch = this.bus.getLog().some(m =>
|
|
1627
|
+
m.from === agentId && m.to === targetId &&
|
|
1628
|
+
(m.status === 'pending' || m.status === 'running' || m.status === 'pending_approval') &&
|
|
1629
|
+
m.type !== 'ack'
|
|
1630
|
+
);
|
|
1631
|
+
if (hasPendingFromWatch) {
|
|
1632
|
+
console.log(`[watch] ${entry.config.label}: skipping bus send — target still processing`);
|
|
1633
|
+
return;
|
|
1634
|
+
}
|
|
1635
|
+
|
|
1636
|
+
this.bus.send(agentId, targetId, 'notify', { action: 'watch_alert', content: message });
|
|
1637
|
+
console.log(`[watch] ${entry.config.label} → ${targetEntry.config.label}: sent via bus`);
|
|
1638
|
+
}
|
|
1639
|
+
}
|
|
1640
|
+
|
|
1641
|
+
/** Check if daemon mode is active */
|
|
1642
|
+
isDaemonActive(): boolean {
|
|
1643
|
+
return this.daemonActive;
|
|
1644
|
+
}
|
|
1645
|
+
|
|
1646
|
+
/** Pause a running agent */
|
|
1647
|
+
pauseAgent(agentId: string): void {
|
|
1648
|
+
const entry = this.agents.get(agentId);
|
|
1649
|
+
entry?.worker?.pause();
|
|
1650
|
+
}
|
|
1651
|
+
|
|
1652
|
+
/** Resume a paused agent */
|
|
1653
|
+
resumeAgent(agentId: string): void {
|
|
1654
|
+
const entry = this.agents.get(agentId);
|
|
1655
|
+
entry?.worker?.resume();
|
|
1656
|
+
}
|
|
1657
|
+
|
|
1658
|
+
/** Stop a running agent */
|
|
1659
|
+
/** Mark agent task as done (user manually confirms completion) */
|
|
1660
|
+
markAgentDone(agentId: string, notify: boolean): void {
|
|
1661
|
+
const entry = this.agents.get(agentId);
|
|
1662
|
+
if (!entry) return;
|
|
1663
|
+
|
|
1664
|
+
// Mark running inbox messages as done
|
|
1665
|
+
const runningMsgs = this.bus.getLog().filter(m => m.to === agentId && m.status === 'running' && m.type !== 'ack');
|
|
1666
|
+
for (const m of runningMsgs) {
|
|
1667
|
+
m.status = 'done' as any;
|
|
1668
|
+
this.emit('event', { type: 'bus_message_status', messageId: m.id, status: 'done' } as any);
|
|
1669
|
+
}
|
|
1670
|
+
|
|
1671
|
+
entry.state.taskStatus = notify ? 'done' : 'idle';
|
|
1672
|
+
entry.state.completedAt = Date.now();
|
|
1673
|
+
this.emit('event', { type: 'task_status', agentId, taskStatus: entry.state.taskStatus } as any);
|
|
1674
|
+
if (notify) {
|
|
1675
|
+
this.handleAgentDone(agentId, entry, 'Manually marked done');
|
|
1676
|
+
}
|
|
1677
|
+
this.sessionMonitor?.resetState(agentId);
|
|
1678
|
+
this.saveNow();
|
|
1679
|
+
this.emitAgentsChanged();
|
|
1680
|
+
console.log(`[workspace] ${entry.config.label}: manually marked ${notify ? 'done' : 'idle'} (${runningMsgs.length} messages completed)`);
|
|
1681
|
+
}
|
|
1682
|
+
|
|
1683
|
+
/** Mark agent task as failed (user manually marks failure) */
|
|
1684
|
+
markAgentFailed(agentId: string, notify: boolean): void {
|
|
1685
|
+
const entry = this.agents.get(agentId);
|
|
1686
|
+
if (!entry) return;
|
|
1687
|
+
|
|
1688
|
+
// Mark running inbox messages as failed
|
|
1689
|
+
const runningMsgs = this.bus.getLog().filter(m => m.to === agentId && m.status === 'running' && m.type !== 'ack');
|
|
1690
|
+
for (const m of runningMsgs) {
|
|
1691
|
+
m.status = 'failed' as any;
|
|
1692
|
+
this.emit('event', { type: 'bus_message_status', messageId: m.id, status: 'failed' } as any);
|
|
1693
|
+
}
|
|
1694
|
+
|
|
1695
|
+
entry.state.taskStatus = 'failed';
|
|
1696
|
+
entry.state.error = 'Manually marked as failed';
|
|
1697
|
+
this.emit('event', { type: 'task_status', agentId, taskStatus: 'failed' } as any);
|
|
1698
|
+
if (notify) {
|
|
1699
|
+
this.bus.notifyTaskComplete(agentId, [], 'Task failed');
|
|
1700
|
+
}
|
|
1701
|
+
this.sessionMonitor?.resetState(agentId);
|
|
1702
|
+
this.saveNow();
|
|
1703
|
+
this.emitAgentsChanged();
|
|
1704
|
+
console.log(`[workspace] ${entry.config.label}: manually marked failed (${runningMsgs.length} messages failed)`);
|
|
1705
|
+
}
|
|
1706
|
+
|
|
1707
|
+
/** Legacy stop — for headless mode */
|
|
1708
|
+
stopAgent(agentId: string): void {
|
|
1709
|
+
const entry = this.agents.get(agentId);
|
|
1710
|
+
if (!entry) return;
|
|
1711
|
+
if (entry.config.persistentSession) {
|
|
1712
|
+
this.markAgentDone(agentId, false);
|
|
1713
|
+
} else {
|
|
1714
|
+
entry.worker?.stop();
|
|
1715
|
+
}
|
|
1716
|
+
}
|
|
1717
|
+
|
|
1718
|
+
/** Retry a failed agent from its last checkpoint */
|
|
1719
|
+
async retryAgent(agentId: string): Promise<void> {
|
|
1720
|
+
const entry = this.agents.get(agentId);
|
|
1721
|
+
if (!entry) throw new Error(`Agent "${agentId}" not found`);
|
|
1722
|
+
if (entry.state.taskStatus === 'running') {
|
|
1723
|
+
throw new Error(`Agent "${entry.config.label}" is already running`);
|
|
1724
|
+
}
|
|
1725
|
+
if (entry.state.taskStatus !== 'failed') {
|
|
1726
|
+
throw new Error(`Agent "${entry.config.label}" is ${entry.state.taskStatus}, not failed`);
|
|
1727
|
+
}
|
|
1728
|
+
// force=true: skip dep taskStatus check, only require upstream smith active
|
|
1729
|
+
await this.runAgent(agentId, undefined, true);
|
|
1730
|
+
}
|
|
1731
|
+
|
|
1732
|
+
/** Send a message to a running agent (human intervention) */
|
|
1733
|
+
/** Send a message to a smith — becomes a pending inbox message, processed by message loop */
|
|
1734
|
+
sendMessageToAgent(agentId: string, content: string): void {
|
|
1735
|
+
const entry = this.agents.get(agentId);
|
|
1736
|
+
if (!entry) return;
|
|
1737
|
+
|
|
1738
|
+
// Send via bus → becomes pending inbox message → message loop will consume it
|
|
1739
|
+
this.bus.send('user', agentId, 'notify', {
|
|
1740
|
+
action: 'user_message',
|
|
1741
|
+
content,
|
|
1742
|
+
});
|
|
1743
|
+
}
|
|
1744
|
+
|
|
1745
|
+
/** Approve a waiting agent to start execution */
|
|
1746
|
+
approveAgent(agentId: string): void {
|
|
1747
|
+
if (!this.approvalQueue.has(agentId)) return;
|
|
1748
|
+
this.approvalQueue.delete(agentId);
|
|
1749
|
+
this.runAgent(agentId).catch(() => {});
|
|
1750
|
+
}
|
|
1751
|
+
|
|
1752
|
+
/** Save tmux session name for an agent (for reattach after refresh) */
|
|
1753
|
+
setTmuxSession(agentId: string, sessionName: string): void {
|
|
1754
|
+
const entry = this.agents.get(agentId);
|
|
1755
|
+
if (!entry) return;
|
|
1756
|
+
entry.state.tmuxSession = sessionName;
|
|
1757
|
+
this.saveNow();
|
|
1758
|
+
this.emitAgentsChanged();
|
|
1759
|
+
}
|
|
1760
|
+
|
|
1761
|
+
clearTmuxSession(agentId: string): void {
|
|
1762
|
+
const entry = this.agents.get(agentId);
|
|
1763
|
+
if (!entry) return;
|
|
1764
|
+
entry.state.tmuxSession = undefined;
|
|
1765
|
+
// Reset session monitor warmup so it doesn't immediately trigger running on restart
|
|
1766
|
+
this.sessionMonitor?.resetState(agentId);
|
|
1767
|
+
this.saveNow();
|
|
1768
|
+
this.emitAgentsChanged();
|
|
1769
|
+
}
|
|
1770
|
+
|
|
1771
|
+
/** Record that an agent has an open terminal (tmux session tracking) */
|
|
1772
|
+
setManualMode(agentId: string): void {
|
|
1773
|
+
const entry = this.agents.get(agentId);
|
|
1774
|
+
if (!entry) return;
|
|
1775
|
+
// tmuxSession is set separately when terminal opens
|
|
1776
|
+
this.emitAgentsChanged();
|
|
1777
|
+
this.saveNow();
|
|
1778
|
+
console.log(`[workspace] Agent "${entry.config.label}" terminal opened`);
|
|
1779
|
+
}
|
|
1780
|
+
|
|
1781
|
+
/** Called when agent's terminal is closed */
|
|
1782
|
+
restartAgentDaemon(agentId: string): void {
|
|
1783
|
+
if (!this.daemonActive) return;
|
|
1784
|
+
const entry = this.agents.get(agentId);
|
|
1785
|
+
if (!entry || entry.config.type === 'input') return;
|
|
1786
|
+
|
|
1787
|
+
entry.state.error = undefined;
|
|
1788
|
+
// Don't clear tmuxSession here — it may still be alive (persistent session)
|
|
1789
|
+
// Terminal close just means the UI panel is closed, not necessarily tmux killed
|
|
1790
|
+
|
|
1791
|
+
// Recreate worker if needed
|
|
1792
|
+
if (!entry.worker) {
|
|
1793
|
+
this.enterDaemonListening(agentId);
|
|
1794
|
+
this.startMessageLoop(agentId);
|
|
1795
|
+
}
|
|
1796
|
+
|
|
1797
|
+
entry.state.smithStatus = 'active';
|
|
1798
|
+
this.emit('event', { type: 'smith_status', agentId, smithStatus: 'active' } satisfies WorkerEvent);
|
|
1799
|
+
this.emitAgentsChanged();
|
|
1800
|
+
}
|
|
1801
|
+
|
|
1802
|
+
/** Complete an agent from terminal — called by forge-done skill */
|
|
1803
|
+
completeManualAgent(agentId: string, changedFiles: string[]): void {
|
|
1804
|
+
const entry = this.agents.get(agentId);
|
|
1805
|
+
if (!entry) return;
|
|
1806
|
+
|
|
1807
|
+
entry.state.taskStatus = 'done';
|
|
1808
|
+
entry.state.completedAt = Date.now();
|
|
1809
|
+
entry.state.artifacts = changedFiles.map(f => ({ type: 'file' as const, path: f }));
|
|
1810
|
+
|
|
1811
|
+
console.log(`[workspace] Manual agent "${entry.config.label}" marked done. ${changedFiles.length} files changed.`);
|
|
1812
|
+
|
|
1813
|
+
this.emit('event', { type: 'task_status', agentId, taskStatus: 'done' } satisfies WorkerEvent);
|
|
1814
|
+
this.emit('event', { type: 'done', agentId, summary: `Manual: ${changedFiles.length} files changed` } satisfies WorkerEvent);
|
|
1815
|
+
this.emitAgentsChanged();
|
|
1816
|
+
|
|
1817
|
+
// Notify ALL agents that depend on this one (not just direct downstream)
|
|
1818
|
+
this.bus.notifyTaskComplete(agentId, changedFiles, `Manual work: ${changedFiles.length} files`);
|
|
1819
|
+
|
|
1820
|
+
// Send individual bus messages to all downstream agents so they know
|
|
1821
|
+
for (const [id, other] of this.agents) {
|
|
1822
|
+
if (id === agentId || other.config.type === 'input') continue;
|
|
1823
|
+
if (other.config.dependsOn.includes(agentId)) {
|
|
1824
|
+
this.bus.send(agentId, id, 'notify', {
|
|
1825
|
+
action: 'update_notify',
|
|
1826
|
+
content: `${entry.config.label} completed manual work: ${changedFiles.length} files changed`,
|
|
1827
|
+
files: changedFiles,
|
|
1828
|
+
});
|
|
1829
|
+
}
|
|
1830
|
+
}
|
|
1831
|
+
|
|
1832
|
+
if (this.daemonActive) {
|
|
1833
|
+
this.broadcastCompletion(agentId);
|
|
1834
|
+
}
|
|
1835
|
+
this.notifyDownstreamForRevalidation(agentId, changedFiles);
|
|
1836
|
+
this.emitWorkspaceStatus();
|
|
1837
|
+
this.checkWorkspaceComplete();
|
|
1838
|
+
this.saveNow();
|
|
1839
|
+
}
|
|
1840
|
+
|
|
1841
|
+
/** Reject an approval (set agent back to idle) */
|
|
1842
|
+
rejectApproval(agentId: string): void {
|
|
1843
|
+
this.approvalQueue.delete(agentId);
|
|
1844
|
+
const entry = this.agents.get(agentId);
|
|
1845
|
+
if (entry) {
|
|
1846
|
+
entry.state.taskStatus = 'idle';
|
|
1847
|
+
this.emit('event', { type: 'task_status', agentId, taskStatus: 'idle' } satisfies WorkerEvent);
|
|
1848
|
+
}
|
|
1849
|
+
}
|
|
1850
|
+
|
|
1851
|
+
// ─── Bus Access ────────────────────────────────────────
|
|
1852
|
+
|
|
1853
|
+
getBus(): AgentBus {
|
|
1854
|
+
return this.bus;
|
|
1855
|
+
}
|
|
1856
|
+
|
|
1857
|
+
getBusLog(): readonly BusMessage[] {
|
|
1858
|
+
return this.bus.getLog();
|
|
1859
|
+
}
|
|
1860
|
+
|
|
1861
|
+
// ─── State Snapshot (for persistence) ──────────────────
|
|
1862
|
+
|
|
1863
|
+
/** Get full workspace state for auto-save */
|
|
1864
|
+
getFullState(): WorkspaceState {
|
|
1865
|
+
return {
|
|
1866
|
+
id: this.workspaceId,
|
|
1867
|
+
projectPath: this.projectPath,
|
|
1868
|
+
projectName: this.projectName,
|
|
1869
|
+
agents: Array.from(this.agents.values()).map(e => e.config),
|
|
1870
|
+
agentStates: this.getAllAgentStates(),
|
|
1871
|
+
nodePositions: {},
|
|
1872
|
+
busLog: [...this.bus.getLog()],
|
|
1873
|
+
busOutbox: this.bus.getAllOutbox(),
|
|
1874
|
+
createdAt: this.createdAt,
|
|
1875
|
+
updatedAt: Date.now(),
|
|
1876
|
+
};
|
|
1877
|
+
}
|
|
1878
|
+
|
|
1879
|
+
getSnapshot(): {
|
|
1880
|
+
agents: WorkspaceAgentConfig[];
|
|
1881
|
+
agentStates: Record<string, AgentState>;
|
|
1882
|
+
busLog: BusMessage[];
|
|
1883
|
+
daemonActive: boolean;
|
|
1884
|
+
} {
|
|
1885
|
+
return {
|
|
1886
|
+
agents: Array.from(this.agents.values()).map(e => e.config),
|
|
1887
|
+
agentStates: this.getAllAgentStates(),
|
|
1888
|
+
busLog: [...this.bus.getLog()],
|
|
1889
|
+
daemonActive: this.daemonActive,
|
|
1890
|
+
};
|
|
1891
|
+
}
|
|
1892
|
+
|
|
1893
|
+
/** Restore from persisted state */
|
|
1894
|
+
loadSnapshot(data: {
|
|
1895
|
+
agents: WorkspaceAgentConfig[];
|
|
1896
|
+
agentStates: Record<string, AgentState>;
|
|
1897
|
+
busLog: BusMessage[];
|
|
1898
|
+
busOutbox?: Record<string, BusMessage[]>;
|
|
1899
|
+
}): void {
|
|
1900
|
+
this.agents.clear();
|
|
1901
|
+
this.daemonActive = false; // Reset daemon — user must click Start Daemon again after restart
|
|
1902
|
+
for (const config of data.agents) {
|
|
1903
|
+
const state = data.agentStates[config.id] || { smithStatus: 'down' as const, taskStatus: 'idle' as const, history: [], artifacts: [] };
|
|
1904
|
+
|
|
1905
|
+
// Migrate old format if loading from pre-two-layer state
|
|
1906
|
+
if ('status' in state && !('smithStatus' in state)) {
|
|
1907
|
+
const oldStatus = (state as any).status;
|
|
1908
|
+
(state as any).smithStatus = 'down';
|
|
1909
|
+
(state as any).taskStatus = (oldStatus === 'running' || oldStatus === 'listening') ? 'idle' :
|
|
1910
|
+
(oldStatus === 'interrupted') ? 'idle' :
|
|
1911
|
+
(oldStatus === 'waiting_approval') ? 'idle' :
|
|
1912
|
+
(oldStatus === 'paused') ? 'idle' :
|
|
1913
|
+
oldStatus;
|
|
1914
|
+
delete (state as any).status;
|
|
1915
|
+
delete (state as any).runMode;
|
|
1916
|
+
delete (state as any).daemonMode;
|
|
1917
|
+
}
|
|
1918
|
+
|
|
1919
|
+
// Mark running agents as failed (interrupted by restart)
|
|
1920
|
+
if (state.taskStatus === 'running') {
|
|
1921
|
+
state.taskStatus = 'failed';
|
|
1922
|
+
state.error = 'Interrupted by restart';
|
|
1923
|
+
}
|
|
1924
|
+
// Smith is down after restart (no daemon loop running)
|
|
1925
|
+
state.smithStatus = 'down';
|
|
1926
|
+
state.daemonIteration = undefined;
|
|
1927
|
+
this.agents.set(config.id, { config, worker: null, state });
|
|
1928
|
+
}
|
|
1929
|
+
this.bus.loadLog(data.busLog);
|
|
1930
|
+
if (data.busOutbox) {
|
|
1931
|
+
this.bus.loadOutbox(data.busOutbox);
|
|
1932
|
+
}
|
|
1933
|
+
|
|
1934
|
+
// Mark all pending messages as failed (they were lost on shutdown)
|
|
1935
|
+
// Users can retry agents manually if needed
|
|
1936
|
+
// Running messages from before crash → failed (pending stays pending for retry)
|
|
1937
|
+
this.bus.markAllRunningAsFailed();
|
|
1938
|
+
|
|
1939
|
+
// Initialize liveness for all loaded agents so bus delivery works
|
|
1940
|
+
for (const [agentId] of this.agents) {
|
|
1941
|
+
this.updateAgentLiveness(agentId);
|
|
1942
|
+
}
|
|
1943
|
+
}
|
|
1944
|
+
|
|
1945
|
+
/** Stop all agents, save final state, and clean up */
|
|
1946
|
+
shutdown(): void {
|
|
1947
|
+
this.daemonActive = false;
|
|
1948
|
+
this.stopHealthCheck();
|
|
1949
|
+
this.stopAllMessageLoops();
|
|
1950
|
+
stopAutoSave(this.workspaceId);
|
|
1951
|
+
// Sync save — must complete before process exits
|
|
1952
|
+
try { saveWorkspaceSync(this.getFullState()); } catch (err) {
|
|
1953
|
+
console.error(`[workspace] Failed to save on shutdown:`, err);
|
|
1954
|
+
}
|
|
1955
|
+
for (const [, entry] of this.agents) {
|
|
1956
|
+
entry.worker?.stop();
|
|
1957
|
+
}
|
|
1958
|
+
this.bus.clear();
|
|
1959
|
+
}
|
|
1960
|
+
|
|
1961
|
+
// ─── Private ───────────────────────────────────────────
|
|
1962
|
+
|
|
1963
|
+
/** Rebuild workspace topology cache — called on agent changes and daemon start */
|
|
1964
|
+
private rebuildTopo(): void {
|
|
1965
|
+
// Topological sort
|
|
1966
|
+
const sorted: WorkspaceAgentConfig[] = [];
|
|
1967
|
+
const visited = new Set<string>();
|
|
1968
|
+
const entries = Array.from(this.agents.values()).filter(e => e.config.type !== 'input');
|
|
1969
|
+
const configMap = new Map(entries.map(e => [e.config.id, e.config]));
|
|
1970
|
+
|
|
1971
|
+
const visit = (id: string) => {
|
|
1972
|
+
if (visited.has(id)) return;
|
|
1973
|
+
visited.add(id);
|
|
1974
|
+
const c = configMap.get(id);
|
|
1975
|
+
if (!c) return;
|
|
1976
|
+
for (const dep of c.dependsOn) visit(dep);
|
|
1977
|
+
sorted.push(c);
|
|
1978
|
+
};
|
|
1979
|
+
for (const e of entries) visit(e.config.id);
|
|
1980
|
+
|
|
1981
|
+
const agents: TopoAgent[] = sorted.map(c => {
|
|
1982
|
+
const state = this.agents.get(c.id)?.state;
|
|
1983
|
+
return {
|
|
1984
|
+
id: c.id,
|
|
1985
|
+
label: c.label,
|
|
1986
|
+
icon: c.icon,
|
|
1987
|
+
role: c.role || '',
|
|
1988
|
+
roleSummary: (c.role || '').split('\n')[0].slice(0, 150),
|
|
1989
|
+
primary: !!c.primary,
|
|
1990
|
+
dependsOn: c.dependsOn.map(d => this.agents.get(d)?.config.label || d),
|
|
1991
|
+
dependsOnIds: [...c.dependsOn],
|
|
1992
|
+
workDir: c.workDir || './',
|
|
1993
|
+
outputs: c.outputs || [],
|
|
1994
|
+
steps: (c.steps || []).map(s => s.label),
|
|
1995
|
+
smithStatus: state?.smithStatus || 'down',
|
|
1996
|
+
taskStatus: state?.taskStatus || 'idle',
|
|
1997
|
+
};
|
|
1998
|
+
});
|
|
1999
|
+
|
|
2000
|
+
this._topoCache = {
|
|
2001
|
+
agents,
|
|
2002
|
+
flow: sorted.map(c => c.label).join(' → '),
|
|
2003
|
+
updatedAt: Date.now(),
|
|
2004
|
+
};
|
|
2005
|
+
}
|
|
2006
|
+
|
|
2007
|
+
/** Get cached workspace topology (always fresh — rebuilt on every agent change) */
|
|
2008
|
+
getWorkspaceTopo(): WorkspaceTopo {
|
|
2009
|
+
if (!this._topoCache) this.rebuildTopo();
|
|
2010
|
+
return this._topoCache!;
|
|
2011
|
+
}
|
|
2012
|
+
|
|
2013
|
+
/** Remove stale Forge blocks from all agents' CLAUDE.md files (migration cleanup).
|
|
2014
|
+
* Role is no longer written to CLAUDE.md — this just removes legacy artifacts. */
|
|
2015
|
+
private cleanLegacyClaudeMdBlocks(): void {
|
|
2016
|
+
const roleRegex = /<!-- forge:agent-role -->[\s\S]*?<!-- \/forge:agent-role -->\n?/;
|
|
2017
|
+
const topoRegex = /<!-- forge:workspace-topo -->[\s\S]*?<!-- \/forge:workspace-topo -->\n?/;
|
|
2018
|
+
|
|
2019
|
+
for (const [, entry] of this.agents) {
|
|
2020
|
+
if (entry.config.type === 'input') continue;
|
|
2021
|
+
const workDir = join(this.projectPath, entry.config.workDir || '');
|
|
2022
|
+
const claudeMdPath = join(workDir, 'CLAUDE.md');
|
|
2023
|
+
if (!existsSync(claudeMdPath)) continue;
|
|
2024
|
+
|
|
2025
|
+
try {
|
|
2026
|
+
let content = readFileSync(claudeMdPath, 'utf-8');
|
|
2027
|
+
const before = content;
|
|
2028
|
+
if (roleRegex.test(content)) content = content.replace(roleRegex, '');
|
|
2029
|
+
if (topoRegex.test(content)) content = content.replace(topoRegex, '');
|
|
2030
|
+
if (content !== before) {
|
|
2031
|
+
content = content.trimEnd() + (content.trim() ? '\n' : '');
|
|
2032
|
+
if (content.trim()) writeFileSync(claudeMdPath, content);
|
|
2033
|
+
else unlinkSync(claudeMdPath); // was only forge content, delete empty file
|
|
2034
|
+
console.log(`[workspace] Cleaned legacy Forge blocks from ${claudeMdPath}`);
|
|
2035
|
+
}
|
|
2036
|
+
} catch (err: any) {
|
|
2037
|
+
console.warn(`[workspace] Failed to clean CLAUDE.md at ${claudeMdPath}: ${err.message}`);
|
|
2038
|
+
}
|
|
2039
|
+
}
|
|
2040
|
+
}
|
|
2041
|
+
|
|
2042
|
+
private createBackend(config: WorkspaceAgentConfig, agentId?: string) {
|
|
2043
|
+
switch (config.backend) {
|
|
2044
|
+
case 'api':
|
|
2045
|
+
// TODO: ApiBackend uses @/src path aliases that don't work in standalone tsx.
|
|
2046
|
+
// Need to refactor api-backend imports before enabling.
|
|
2047
|
+
throw new Error('API backend not yet supported in workspace daemon. Use CLI backend instead.');
|
|
2048
|
+
case 'cli':
|
|
2049
|
+
default: {
|
|
2050
|
+
// Resume existing claude session if available
|
|
2051
|
+
const existingSessionId = agentId ? this.agents.get(agentId)?.state.cliSessionId : undefined;
|
|
2052
|
+
const backend = new CliBackend(existingSessionId);
|
|
2053
|
+
// Persist new sessionId back to agent state
|
|
2054
|
+
if (agentId) {
|
|
2055
|
+
backend.onSessionId = (id) => {
|
|
2056
|
+
const entry = this.agents.get(agentId);
|
|
2057
|
+
if (entry) entry.state.cliSessionId = id;
|
|
2058
|
+
};
|
|
2059
|
+
}
|
|
2060
|
+
return backend;
|
|
2061
|
+
}
|
|
2062
|
+
}
|
|
2063
|
+
}
|
|
2064
|
+
|
|
2065
|
+
/** Build a compact topo summary injected into every agent task */
|
|
2066
|
+
private buildTopoSummary(selfId: string): string {
|
|
2067
|
+
const topo = this.getWorkspaceTopo();
|
|
2068
|
+
if (topo.agents.length === 0) return '';
|
|
2069
|
+
|
|
2070
|
+
const lines: string[] = ['## Workspace Team'];
|
|
2071
|
+
lines.push(`Flow: ${topo.flow}`);
|
|
2072
|
+
|
|
2073
|
+
// Missing roles hint
|
|
2074
|
+
const labels = new Set(topo.agents.map(a => a.label.toLowerCase()));
|
|
2075
|
+
const standard = ['architect', 'engineer', 'qa', 'reviewer', 'pm', 'lead'];
|
|
2076
|
+
const missing = standard.filter(r => !labels.has(r));
|
|
2077
|
+
if (missing.length > 0) lines.push(`Missing: ${missing.join(', ')}`);
|
|
2078
|
+
|
|
2079
|
+
for (const a of topo.agents) {
|
|
2080
|
+
const me = a.id === selfId ? ' ← you' : '';
|
|
2081
|
+
const status = `${a.smithStatus}/${a.taskStatus}`;
|
|
2082
|
+
lines.push(`- ${a.icon} ${a.label}${me} [${status}]: ${a.roleSummary}`);
|
|
2083
|
+
}
|
|
2084
|
+
return lines.join('\n');
|
|
2085
|
+
}
|
|
2086
|
+
|
|
2087
|
+
/** Build full role preamble — injected once when persistent session starts */
|
|
2088
|
+
private buildRolePreamble(config: WorkspaceAgentConfig): string {
|
|
2089
|
+
const roleText = (config.role || '').trim() || '(no role defined)';
|
|
2090
|
+
return `=== SMITH CONTEXT (managed by Forge) ===
|
|
2091
|
+
You are ${config.label}. Work dir: ${config.workDir || './'}.
|
|
2092
|
+
|
|
2093
|
+
Your role and responsibilities:
|
|
2094
|
+
${roleText}
|
|
2095
|
+
|
|
2096
|
+
---
|
|
2097
|
+
Silently ingest this context. Do NOT respond — await an actual task.`;
|
|
2098
|
+
}
|
|
2099
|
+
|
|
2100
|
+
/** Build short role reminder — injected periodically to combat auto-compaction */
|
|
2101
|
+
private buildRoleReminder(config: WorkspaceAgentConfig): string {
|
|
2102
|
+
const roleText = (config.role || '').trim();
|
|
2103
|
+
// First 300 chars of role, plus label
|
|
2104
|
+
const summary = roleText.length > 300 ? roleText.slice(0, 300) + '...' : roleText;
|
|
2105
|
+
return `[Role reminder — you are ${config.label}]\n${summary}\n---`;
|
|
2106
|
+
}
|
|
2107
|
+
|
|
2108
|
+
/** Check if a role reminder should be re-injected before next bus message */
|
|
2109
|
+
private needsRoleReminder(agentId: string): boolean {
|
|
2110
|
+
const st = this.roleInjectState.get(agentId);
|
|
2111
|
+
if (!st) return false; // no preamble sent yet — session is starting
|
|
2112
|
+
const now = Date.now();
|
|
2113
|
+
const REMIND_EVERY_MSGS = 10;
|
|
2114
|
+
const REMIND_EVERY_MS = 30 * 60 * 1000; // 30 min
|
|
2115
|
+
if (st.msgsSinceInject >= REMIND_EVERY_MSGS) return true;
|
|
2116
|
+
if (now - st.lastInjectAt >= REMIND_EVERY_MS) return true;
|
|
2117
|
+
return false;
|
|
2118
|
+
}
|
|
2119
|
+
|
|
2120
|
+
/** Mark that role was just injected for this agent */
|
|
2121
|
+
private markRoleInjected(agentId: string): void {
|
|
2122
|
+
this.roleInjectState.set(agentId, { lastInjectAt: Date.now(), msgsSinceInject: 0 });
|
|
2123
|
+
}
|
|
2124
|
+
|
|
2125
|
+
/** Increment message counter since last role injection */
|
|
2126
|
+
private incrementMsgCount(agentId: string): void {
|
|
2127
|
+
const st = this.roleInjectState.get(agentId);
|
|
2128
|
+
if (st) st.msgsSinceInject++;
|
|
2129
|
+
}
|
|
2130
|
+
|
|
2131
|
+
/** Build context string from upstream agents' outputs */
|
|
2132
|
+
private buildUpstreamContext(config: WorkspaceAgentConfig): string | undefined {
|
|
2133
|
+
// Always prepend: role + workspace team summary
|
|
2134
|
+
const roleBlock = config.role?.trim() ? `## Your Role (${config.label})\n${config.role.trim()}` : '';
|
|
2135
|
+
const topoSummary = this.buildTopoSummary(config.id);
|
|
2136
|
+
|
|
2137
|
+
const headerSections: string[] = [];
|
|
2138
|
+
if (roleBlock) headerSections.push(roleBlock);
|
|
2139
|
+
if (topoSummary) headerSections.push(topoSummary);
|
|
2140
|
+
const header = headerSections.join('\n\n');
|
|
2141
|
+
|
|
2142
|
+
if (config.dependsOn.length === 0) {
|
|
2143
|
+
return header || undefined;
|
|
2144
|
+
}
|
|
2145
|
+
|
|
2146
|
+
const sections: string[] = [];
|
|
2147
|
+
if (header) sections.push(header);
|
|
2148
|
+
|
|
2149
|
+
for (const depId of config.dependsOn) {
|
|
2150
|
+
const dep = this.agents.get(depId);
|
|
2151
|
+
if (!dep || (dep.state.taskStatus !== 'done')) continue;
|
|
2152
|
+
|
|
2153
|
+
const label = dep.config.label;
|
|
2154
|
+
|
|
2155
|
+
// Input nodes: only send latest entry (not full history)
|
|
2156
|
+
if (dep.config.type === 'input') {
|
|
2157
|
+
const entries = dep.config.entries;
|
|
2158
|
+
if (entries && entries.length > 0) {
|
|
2159
|
+
const latest = entries[entries.length - 1];
|
|
2160
|
+
sections.push(`### ${label} (latest input):\n${latest.content}`);
|
|
2161
|
+
} else if (dep.config.content) {
|
|
2162
|
+
// Legacy fallback
|
|
2163
|
+
sections.push(`### ${label}:\n${dep.config.content}`);
|
|
2164
|
+
}
|
|
2165
|
+
continue;
|
|
2166
|
+
}
|
|
2167
|
+
|
|
2168
|
+
const artifacts = dep.state.artifacts.filter(a => a.path);
|
|
2169
|
+
|
|
2170
|
+
if (artifacts.length === 0) {
|
|
2171
|
+
const lastResult = [...dep.state.history].reverse().find(h => h.type === 'result');
|
|
2172
|
+
if (lastResult) {
|
|
2173
|
+
sections.push(`### From ${label}:\n${lastResult.content}`);
|
|
2174
|
+
}
|
|
2175
|
+
continue;
|
|
2176
|
+
}
|
|
2177
|
+
|
|
2178
|
+
// Read file artifacts
|
|
2179
|
+
for (const artifact of artifacts) {
|
|
2180
|
+
if (!artifact.path) continue;
|
|
2181
|
+
const fullPath = resolve(this.projectPath, artifact.path);
|
|
2182
|
+
try {
|
|
2183
|
+
if (existsSync(fullPath)) {
|
|
2184
|
+
const content = readFileSync(fullPath, 'utf-8');
|
|
2185
|
+
const truncated = content.length > 10000
|
|
2186
|
+
? content.slice(0, 10000) + '\n... (truncated)'
|
|
2187
|
+
: content;
|
|
2188
|
+
sections.push(`### From ${label} — ${artifact.path}:\n${truncated}`);
|
|
2189
|
+
}
|
|
2190
|
+
} catch {
|
|
2191
|
+
sections.push(`### From ${label} — ${artifact.path}: (could not read file)`);
|
|
2192
|
+
}
|
|
2193
|
+
}
|
|
2194
|
+
}
|
|
2195
|
+
|
|
2196
|
+
if (sections.length === 0) return undefined;
|
|
2197
|
+
|
|
2198
|
+
let combined = sections.join('\n\n---\n\n');
|
|
2199
|
+
|
|
2200
|
+
// Cap total upstream context to ~50K chars (~12K tokens) to prevent token explosion
|
|
2201
|
+
const MAX_UPSTREAM_CHARS = 50000;
|
|
2202
|
+
if (combined.length > MAX_UPSTREAM_CHARS) {
|
|
2203
|
+
combined = combined.slice(0, MAX_UPSTREAM_CHARS) + '\n\n... (upstream context truncated, ' + combined.length + ' chars total)';
|
|
2204
|
+
}
|
|
2205
|
+
|
|
2206
|
+
return combined;
|
|
2207
|
+
}
|
|
2208
|
+
|
|
2209
|
+
/** After an agent completes, check if any downstream agents should be triggered */
|
|
2210
|
+
/**
|
|
2211
|
+
* Broadcast completion to all downstream agents via bus messages.
|
|
2212
|
+
* Replaces direct triggerDownstream — all execution is now message-driven.
|
|
2213
|
+
* If no artifacts/changes, no message is sent → downstream stays idle.
|
|
2214
|
+
*/
|
|
2215
|
+
/** Build causedBy from the message currently being processed */
|
|
2216
|
+
private buildCausedBy(agentId: string, entry: { worker: AgentWorker | null }): BusMessage['causedBy'] | undefined {
|
|
2217
|
+
const msgId = entry.worker?.getCurrentMessageId?.();
|
|
2218
|
+
if (!msgId) return undefined;
|
|
2219
|
+
const msg = this.bus.getLog().find(m => m.id === msgId);
|
|
2220
|
+
if (!msg) return undefined;
|
|
2221
|
+
return { messageId: msg.id, from: msg.from, to: msg.to };
|
|
2222
|
+
}
|
|
2223
|
+
|
|
2224
|
+
/** Unified done handler: broadcast downstream or reply to sender based on message source */
|
|
2225
|
+
private handleAgentDone(agentId: string, entry: { config: WorkspaceAgentConfig; worker: AgentWorker | null; state: AgentState }, summary?: string): void {
|
|
2226
|
+
this.agentRunningMsg.delete(agentId);
|
|
2227
|
+
const files = entry.state.artifacts.filter(a => a.path).map(a => a.path!);
|
|
2228
|
+
console.log(`[workspace] Agent "${entry.config.label}" (${agentId}) completed. Artifacts: ${files.length}.`);
|
|
2229
|
+
|
|
2230
|
+
this.bus.notifyTaskComplete(agentId, files, summary);
|
|
2231
|
+
|
|
2232
|
+
// Check what message triggered this execution
|
|
2233
|
+
const causedBy = this.buildCausedBy(agentId, entry);
|
|
2234
|
+
const processedMsg = causedBy ? this.bus.getLog().find(m => m.id === causedBy.messageId) : null;
|
|
2235
|
+
|
|
2236
|
+
this.broadcastCompletion(agentId, causedBy);
|
|
2237
|
+
// Note: Forge agent (runForgeAgentCheck) monitors for missing replies
|
|
2238
|
+
// and nudges agents to send summaries. No action needed here.
|
|
2239
|
+
|
|
2240
|
+
this.emitWorkspaceStatus();
|
|
2241
|
+
this.checkWorkspaceComplete?.();
|
|
2242
|
+
}
|
|
2243
|
+
|
|
2244
|
+
private broadcastCompletion(completedAgentId: string, causedBy?: BusMessage['causedBy']): void {
|
|
2245
|
+
const completed = this.agents.get(completedAgentId);
|
|
2246
|
+
if (!completed) return;
|
|
2247
|
+
|
|
2248
|
+
const completedLabel = completed.config.label;
|
|
2249
|
+
const files = completed.state.artifacts.filter(a => a.path).map(a => a.path!);
|
|
2250
|
+
const summary = completed.state.history
|
|
2251
|
+
.filter(h => h.subtype === 'final_summary' || h.subtype === 'step_summary')
|
|
2252
|
+
.slice(-1)[0]?.content || '';
|
|
2253
|
+
|
|
2254
|
+
// Keep notification concise — agent can read files/git diff for details
|
|
2255
|
+
const shortSummary = summary.split('\n')[0]?.slice(0, 100) || '';
|
|
2256
|
+
const content = files.length > 0
|
|
2257
|
+
? `${completedLabel} completed: ${files.length} files changed.${shortSummary ? ' ' + shortSummary : ''} Run \`git diff --stat HEAD~1\` for details.`
|
|
2258
|
+
: `${completedLabel} completed.${shortSummary ? ' ' + shortSummary : ''}`;
|
|
2259
|
+
|
|
2260
|
+
// Find all downstream agents — skip if already sent upstream_complete recently (60s)
|
|
2261
|
+
const now = Date.now();
|
|
2262
|
+
|
|
2263
|
+
// Pre-scan log once: build dedup set and pending-to-complete list
|
|
2264
|
+
const recentSentTo = new Set<string>(); // agentIds that received upstream_complete within 60s
|
|
2265
|
+
const pendingToComplete: { m: any; to: string }[] = [];
|
|
2266
|
+
for (const m of this.bus.getLog()) {
|
|
2267
|
+
if (m.from !== completedAgentId || m.payload?.action !== 'upstream_complete') continue;
|
|
2268
|
+
if (now - m.timestamp < 60_000) recentSentTo.add(m.to);
|
|
2269
|
+
if (m.status === 'pending') pendingToComplete.push({ m, to: m.to });
|
|
2270
|
+
}
|
|
2271
|
+
|
|
2272
|
+
let sent = 0;
|
|
2273
|
+
for (const [id, entry] of this.agents) {
|
|
2274
|
+
if (id === completedAgentId) continue;
|
|
2275
|
+
if (entry.config.type === 'input') continue;
|
|
2276
|
+
if (!entry.config.dependsOn.includes(completedAgentId)) continue;
|
|
2277
|
+
|
|
2278
|
+
// Dedup: skip if upstream_complete was sent to this target within last 60s
|
|
2279
|
+
if (recentSentTo.has(id)) {
|
|
2280
|
+
console.log(`[bus] ${completedLabel} → ${entry.config.label}: upstream_complete skipped (sent <60s ago)`);
|
|
2281
|
+
continue;
|
|
2282
|
+
}
|
|
2283
|
+
|
|
2284
|
+
// Merge: auto-complete older pending upstream_complete from same sender to this target
|
|
2285
|
+
for (const { m, to } of pendingToComplete) {
|
|
2286
|
+
if (to === id) {
|
|
2287
|
+
m.status = 'done' as any;
|
|
2288
|
+
this.emit('event', { type: 'bus_message_status', messageId: m.id, status: 'done' } as any);
|
|
2289
|
+
}
|
|
2290
|
+
}
|
|
2291
|
+
|
|
2292
|
+
this.bus.send(completedAgentId, id, 'notify', {
|
|
2293
|
+
action: 'upstream_complete',
|
|
2294
|
+
content,
|
|
2295
|
+
files,
|
|
2296
|
+
}, { category: 'notification', causedBy });
|
|
2297
|
+
sent++;
|
|
2298
|
+
console.log(`[bus] ${completedLabel} → ${entry.config.label}: upstream_complete (${files.length} files)`);
|
|
2299
|
+
}
|
|
2300
|
+
|
|
2301
|
+
if (sent === 0) {
|
|
2302
|
+
console.log(`[bus] ${completedLabel} completed — no downstream agents`);
|
|
2303
|
+
}
|
|
2304
|
+
}
|
|
2305
|
+
|
|
2306
|
+
// ─── Agent liveness ─────────────────────────────────────
|
|
2307
|
+
|
|
2308
|
+
/** Find an active tmux session for an agent by checking naming conventions */
|
|
2309
|
+
// ─── Persistent Terminal Sessions ────────────────────────
|
|
2310
|
+
|
|
2311
|
+
/** Resolve the CLI session directory for a given project path */
|
|
2312
|
+
private getCliSessionDir(workDir?: string): string {
|
|
2313
|
+
const projectPath = workDir && workDir !== './' && workDir !== '.'
|
|
2314
|
+
? join(this.projectPath, workDir) : this.projectPath;
|
|
2315
|
+
// Claude Code encodes paths by replacing all non-alphanumeric chars with '-'
|
|
2316
|
+
const encoded = resolve(projectPath).replace(/[^a-zA-Z0-9]/g, '-');
|
|
2317
|
+
return join(homedir(), '.claude', 'projects', encoded);
|
|
2318
|
+
}
|
|
2319
|
+
|
|
2320
|
+
/** Return the latest session ID (by mtime) from the CLI session dir, or undefined if none. */
|
|
2321
|
+
private getLatestSessionId(workDir?: string): string | undefined {
|
|
2322
|
+
try {
|
|
2323
|
+
const sessionDir = this.getCliSessionDir(workDir);
|
|
2324
|
+
if (!existsSync(sessionDir)) return undefined;
|
|
2325
|
+
const files = readdirSync(sessionDir).filter(f => f.endsWith('.jsonl'));
|
|
2326
|
+
if (files.length === 0) return undefined;
|
|
2327
|
+
const latest = files
|
|
2328
|
+
.map(f => ({ name: f, mtime: statSync(join(sessionDir, f)).mtimeMs }))
|
|
2329
|
+
.sort((a, b) => b.mtime - a.mtime)[0];
|
|
2330
|
+
return latest.name.replace('.jsonl', '');
|
|
2331
|
+
} catch {
|
|
2332
|
+
return undefined;
|
|
2333
|
+
}
|
|
2334
|
+
}
|
|
2335
|
+
|
|
2336
|
+
/** Lightweight session bind for non-persistentSession agents.
|
|
2337
|
+
* Checks if the tmux session already exists and sets entry.state.tmuxSession.
|
|
2338
|
+
* Also auto-binds boundSessionId from the latest session file if not already set.
|
|
2339
|
+
* Does NOT create any session or run any launch script.
|
|
2340
|
+
*/
|
|
2341
|
+
private tryBindExistingSession(agentId: string, config: WorkspaceAgentConfig): void {
|
|
2342
|
+
const entry = this.agents.get(agentId);
|
|
2343
|
+
if (!entry) return;
|
|
2344
|
+
|
|
2345
|
+
const safeName = (s: string) => s.toLowerCase().replace(/[^a-z0-9]/g, '-').replace(/-+/g, '-').slice(0, 20);
|
|
2346
|
+
const sessionName = `mw-forge-${safeName(this.projectName)}-${safeName(config.label)}`;
|
|
2347
|
+
|
|
2348
|
+
// Check if tmux session is alive → set tmuxSession so open_terminal can return it
|
|
2349
|
+
try {
|
|
2350
|
+
execSync(`tmux has-session -t "${sessionName}" 2>/dev/null`, { timeout: 3000 });
|
|
2351
|
+
if (entry.state.tmuxSession !== sessionName) {
|
|
2352
|
+
entry.state.tmuxSession = sessionName;
|
|
2353
|
+
this.saveNow();
|
|
2354
|
+
console.log(`[daemon] ${config.label}: found existing tmux session, bound tmuxSession`);
|
|
2355
|
+
}
|
|
2356
|
+
} catch {
|
|
2357
|
+
// No existing session — leave tmuxSession undefined, open_terminal will let FloatingTerminal create one
|
|
2358
|
+
}
|
|
2359
|
+
|
|
2360
|
+
// Auto-bind boundSessionId from latest session file if not already set
|
|
2361
|
+
if (!config.boundSessionId) {
|
|
2362
|
+
const sessionId = this.getLatestSessionId(config.workDir);
|
|
2363
|
+
if (sessionId) {
|
|
2364
|
+
config.boundSessionId = sessionId;
|
|
2365
|
+
this.saveNow();
|
|
2366
|
+
console.log(`[daemon] ${config.label}: auto-bound boundSessionId=${sessionId}`);
|
|
2367
|
+
}
|
|
2368
|
+
}
|
|
2369
|
+
}
|
|
2370
|
+
|
|
2371
|
+
/**
|
|
2372
|
+
* Create or attach to the tmux session for an agent (terminal-open path).
|
|
2373
|
+
* Unlike ensurePersistentSession, skips the 3s startup verification so the
|
|
2374
|
+
* HTTP response is fast. Returns the session name, or null on failure.
|
|
2375
|
+
*/
|
|
2376
|
+
async openTerminalSession(agentId: string, forceRestart = false): Promise<string | null> {
|
|
2377
|
+
const entry = this.agents.get(agentId);
|
|
2378
|
+
if (!entry || entry.config.type === 'input') return null;
|
|
2379
|
+
const config = entry.config;
|
|
2380
|
+
|
|
2381
|
+
const safeName = (s: string) => s.toLowerCase().replace(/[^a-z0-9]/g, '-').replace(/-+/g, '-').slice(0, 20);
|
|
2382
|
+
const sessionName = `mw-forge-${safeName(this.projectName)}-${safeName(config.label)}`;
|
|
2383
|
+
|
|
2384
|
+
if (forceRestart) {
|
|
2385
|
+
// Kill existing tmux session so ensurePersistentSession rewrites the launch script
|
|
2386
|
+
// with the current boundSessionId (--resume flag). Safe — claude session data is in jsonl files.
|
|
2387
|
+
try { execSync(`tmux kill-session -t "${sessionName}" 2>/dev/null`, { timeout: 3000 }); } catch {}
|
|
2388
|
+
entry.state.tmuxSession = undefined;
|
|
2389
|
+
} else if (entry.state.tmuxSession) {
|
|
2390
|
+
// Attach to existing session if still alive
|
|
2391
|
+
try {
|
|
2392
|
+
execSync(`tmux has-session -t "${entry.state.tmuxSession}" 2>/dev/null`, { timeout: 3000 });
|
|
2393
|
+
return entry.state.tmuxSession;
|
|
2394
|
+
} catch {
|
|
2395
|
+
entry.state.tmuxSession = undefined;
|
|
2396
|
+
}
|
|
2397
|
+
}
|
|
2398
|
+
|
|
2399
|
+
// Create (or recreate) session without startup verification delay
|
|
2400
|
+
await this.ensurePersistentSession(agentId, config, true);
|
|
2401
|
+
return entry.state.tmuxSession || null;
|
|
2402
|
+
}
|
|
2403
|
+
|
|
2404
|
+
/** Create a persistent tmux session with the CLI agent */
|
|
2405
|
+
private async ensurePersistentSession(agentId: string, config: WorkspaceAgentConfig, skipStartupCheck = false): Promise<void> {
|
|
2406
|
+
const safeName = (s: string) => s.toLowerCase().replace(/[^a-z0-9]/g, '-').replace(/-+/g, '-').slice(0, 20);
|
|
2407
|
+
const sessionName = `mw-forge-${safeName(this.projectName)}-${safeName(config.label)}`;
|
|
2408
|
+
|
|
2409
|
+
// Pre-flight: check project's .claude/settings.json is valid (cached by mtime)
|
|
2410
|
+
const workDir = config.workDir && config.workDir !== './' && config.workDir !== '.'
|
|
2411
|
+
? `${this.projectPath}/${config.workDir}` : this.projectPath;
|
|
2412
|
+
const projectSettingsFile = join(workDir, '.claude', 'settings.json');
|
|
2413
|
+
if (existsSync(projectSettingsFile)) {
|
|
2414
|
+
try {
|
|
2415
|
+
const mtime = statSync(projectSettingsFile).mtimeMs;
|
|
2416
|
+
if (this.settingsValidCache.get(projectSettingsFile) !== mtime) {
|
|
2417
|
+
const raw = readFileSync(projectSettingsFile, 'utf-8');
|
|
2418
|
+
JSON.parse(raw);
|
|
2419
|
+
this.settingsValidCache.set(projectSettingsFile, mtime);
|
|
2420
|
+
}
|
|
2421
|
+
} catch (err: any) {
|
|
2422
|
+
this.settingsValidCache.delete(projectSettingsFile);
|
|
2423
|
+
const errorMsg = `Invalid .claude/settings.json: ${err.message}`;
|
|
2424
|
+
console.error(`[daemon] ${config.label}: ${errorMsg}`);
|
|
2425
|
+
const entry = this.agents.get(agentId);
|
|
2426
|
+
if (entry) {
|
|
2427
|
+
entry.state.error = errorMsg;
|
|
2428
|
+
entry.state.smithStatus = 'down';
|
|
2429
|
+
this.emit('event', { type: 'smith_status', agentId, smithStatus: 'down' } as any);
|
|
2430
|
+
this.emit('event', { type: 'log', agentId, entry: { type: 'system', subtype: 'error', content: `⚠️ ${errorMsg}`, timestamp: new Date().toISOString() } } as any);
|
|
2431
|
+
this.emitAgentsChanged();
|
|
2432
|
+
}
|
|
2433
|
+
return;
|
|
2434
|
+
}
|
|
2435
|
+
}
|
|
2436
|
+
|
|
2437
|
+
// Write agent context file for hooks to read (workDir/.forge/agent-context.json)
|
|
2438
|
+
try {
|
|
2439
|
+
const forgeDir = join(workDir, '.forge');
|
|
2440
|
+
mkdirSync(forgeDir, { recursive: true });
|
|
2441
|
+
const ctxPath = join(forgeDir, 'agent-context.json');
|
|
2442
|
+
writeFileSync(ctxPath, JSON.stringify({
|
|
2443
|
+
workspaceId: this.workspaceId,
|
|
2444
|
+
agentId: config.id,
|
|
2445
|
+
agentLabel: config.label,
|
|
2446
|
+
forgePort: Number(process.env.PORT) || 8403,
|
|
2447
|
+
}, null, 2));
|
|
2448
|
+
console.log(`[daemon] ${config.label}: wrote agent-context.json to ${ctxPath}`);
|
|
2449
|
+
} catch (err: any) {
|
|
2450
|
+
console.error(`[daemon] ${config.label}: failed to write agent-context.json: ${err.message}`);
|
|
2451
|
+
}
|
|
2452
|
+
|
|
2453
|
+
// Check if tmux session already exists and Claude is still alive inside
|
|
2454
|
+
let sessionAlreadyExists = false;
|
|
2455
|
+
let tmuxSessionExists = false;
|
|
2456
|
+
console.log(`[daemon] ${config.label}: ensurePersistentSession called — sessionName=${sessionName} boundSessionId=${config.boundSessionId || 'NONE'} skipStartupCheck=${skipStartupCheck}`);
|
|
2457
|
+
try {
|
|
2458
|
+
execSync(`tmux has-session -t "${sessionName}" 2>/dev/null`, { timeout: 3000 });
|
|
2459
|
+
tmuxSessionExists = true;
|
|
2460
|
+
} catch {}
|
|
2461
|
+
console.log(`[daemon] ${config.label}: tmuxSessionExists=${tmuxSessionExists}`);
|
|
2462
|
+
|
|
2463
|
+
if (tmuxSessionExists) {
|
|
2464
|
+
// Check if Claude process is still alive inside the tmux pane
|
|
2465
|
+
let claudeAlive = true;
|
|
2466
|
+
try {
|
|
2467
|
+
const paneContent = execSync(`tmux capture-pane -t "${sessionName}" -p -S -5`, { timeout: 3000, encoding: 'utf-8' });
|
|
2468
|
+
const exitedPatterns = [/^\$\s*$/, /\$ $/m, /Process exited/i, /command not found/i];
|
|
2469
|
+
if (exitedPatterns.some(p => p.test(paneContent))) {
|
|
2470
|
+
console.log(`[daemon] ${config.label}: Claude appears to have exited, recreating session`);
|
|
2471
|
+
try { execSync(`tmux kill-session -t "${sessionName}" 2>/dev/null`, { timeout: 3000 }); } catch {}
|
|
2472
|
+
claudeAlive = false;
|
|
2473
|
+
}
|
|
2474
|
+
} catch {
|
|
2475
|
+
// pane capture failed — assume alive
|
|
2476
|
+
}
|
|
2477
|
+
|
|
2478
|
+
if (claudeAlive) {
|
|
2479
|
+
sessionAlreadyExists = true;
|
|
2480
|
+
console.log(`[daemon] ${config.label}: persistent session alive (${sessionName}) — skipping script generation`);
|
|
2481
|
+
// Ensure FORGE env vars are set in the tmux session environment
|
|
2482
|
+
try {
|
|
2483
|
+
execSync(`tmux set-environment -t "${sessionName}" FORGE_WORKSPACE_ID "${this.workspaceId}" && tmux set-environment -t "${sessionName}" FORGE_AGENT_ID "${config.id}" && tmux set-environment -t "${sessionName}" FORGE_PORT "${Number(process.env.PORT) || 8403}"`, { timeout: 5000 });
|
|
2484
|
+
} catch {}
|
|
2485
|
+
}
|
|
2486
|
+
}
|
|
2487
|
+
|
|
2488
|
+
if (!sessionAlreadyExists) {
|
|
2489
|
+
// Pre-bind: find existing session file BEFORE starting CLI so --resume is available from the start.
|
|
2490
|
+
// This avoids starting a fresh CLI then restarting it after polling finds the session.
|
|
2491
|
+
if (!config.primary && !config.boundSessionId) {
|
|
2492
|
+
const existingSessionId = this.getLatestSessionId(config.workDir);
|
|
2493
|
+
if (existingSessionId) {
|
|
2494
|
+
config.boundSessionId = existingSessionId;
|
|
2495
|
+
this.saveNow();
|
|
2496
|
+
console.log(`[daemon] ${config.label}: pre-bound to existing session ${existingSessionId}`);
|
|
2497
|
+
}
|
|
2498
|
+
}
|
|
2499
|
+
|
|
2500
|
+
// Create new tmux session and start the CLI agent
|
|
2501
|
+
try {
|
|
2502
|
+
// Resolve agent launch info
|
|
2503
|
+
let cliCmd = 'claude';
|
|
2504
|
+
let cliType = 'claude-code';
|
|
2505
|
+
let supportsSession = true;
|
|
2506
|
+
let skipPermissionsFlag = '--dangerously-skip-permissions';
|
|
2507
|
+
let envExports = '';
|
|
2508
|
+
let modelFlag = '';
|
|
2509
|
+
try {
|
|
2510
|
+
const { resolveTerminalLaunch, listAgents } = await import('../agents/index') as any;
|
|
2511
|
+
const info = resolveTerminalLaunch(config.agentId);
|
|
2512
|
+
cliCmd = info.cliCmd || 'claude';
|
|
2513
|
+
cliType = info.cliType || 'claude-code';
|
|
2514
|
+
supportsSession = info.supportsSession ?? true;
|
|
2515
|
+
const agents = listAgents();
|
|
2516
|
+
const agentDef = agents.find((a: any) => a.id === config.agentId);
|
|
2517
|
+
if (agentDef?.skipPermissionsFlag) skipPermissionsFlag = agentDef.skipPermissionsFlag;
|
|
2518
|
+
if (info.env) {
|
|
2519
|
+
envExports = Object.entries(info.env)
|
|
2520
|
+
.filter(([k]) => k !== 'CLAUDE_MODEL')
|
|
2521
|
+
.map(([k, v]) => `export ${k}="${v}"`)
|
|
2522
|
+
.join(' && ');
|
|
2523
|
+
if (envExports) envExports += ' && ';
|
|
2524
|
+
}
|
|
2525
|
+
// Workspace agent model takes priority over profile/settings model
|
|
2526
|
+
const effectiveModel = config.model || info.model;
|
|
2527
|
+
if (effectiveModel) modelFlag = ` --model ${effectiveModel}`;
|
|
2528
|
+
} catch {}
|
|
2529
|
+
|
|
2530
|
+
// Generate MCP config for Claude Code agents
|
|
2531
|
+
let mcpConfigFlag = '';
|
|
2532
|
+
if (cliType === 'claude-code') {
|
|
2533
|
+
try {
|
|
2534
|
+
const mcpPort = Number(process.env.MCP_PORT) || 8406;
|
|
2535
|
+
const mcpConfigPath = join(workDir, '.forge', 'mcp.json');
|
|
2536
|
+
const mcpConfig = {
|
|
2537
|
+
mcpServers: {
|
|
2538
|
+
forge: {
|
|
2539
|
+
type: 'sse',
|
|
2540
|
+
url: `http://localhost:${mcpPort}/sse?workspaceId=${this.workspaceId}&agentId=${config.id}`,
|
|
2541
|
+
},
|
|
2542
|
+
},
|
|
2543
|
+
};
|
|
2544
|
+
mkdirSync(join(workDir, '.forge'), { recursive: true });
|
|
2545
|
+
writeFileSync(mcpConfigPath, JSON.stringify(mcpConfig, null, 2));
|
|
2546
|
+
mcpConfigFlag = ` --mcp-config "${mcpConfigPath}"`;
|
|
2547
|
+
} catch (err: any) {
|
|
2548
|
+
console.log(`[daemon] ${config.label}: MCP config generation failed: ${err.message}`);
|
|
2549
|
+
}
|
|
2550
|
+
}
|
|
2551
|
+
|
|
2552
|
+
execSync(`tmux new-session -d -s "${sessionName}" -c "${workDir}"`, { timeout: 5000 });
|
|
2553
|
+
|
|
2554
|
+
// Build launch script to avoid tmux send-keys truncation
|
|
2555
|
+
const scriptLines: string[] = ['#!/bin/bash', `cd "${workDir}"`];
|
|
2556
|
+
|
|
2557
|
+
// Unset old profile vars
|
|
2558
|
+
scriptLines.push('unset ANTHROPIC_AUTH_TOKEN ANTHROPIC_BASE_URL ANTHROPIC_SMALL_FAST_MODEL CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC DISABLE_TELEMETRY DISABLE_ERROR_REPORTING DISABLE_AUTOUPDATER DISABLE_NON_ESSENTIAL_MODEL_CALLS CLAUDE_MODEL');
|
|
2559
|
+
|
|
2560
|
+
// Set FORGE env vars
|
|
2561
|
+
scriptLines.push(`export FORGE_WORKSPACE_ID="${this.workspaceId}" FORGE_AGENT_ID="${config.id}" FORGE_PORT="${Number(process.env.PORT) || 8403}"`);
|
|
2562
|
+
|
|
2563
|
+
// Set profile env vars
|
|
2564
|
+
if (envExports) {
|
|
2565
|
+
scriptLines.push(envExports.replace(/ && /g, '\n').replace(/\n$/, ''));
|
|
2566
|
+
}
|
|
2567
|
+
|
|
2568
|
+
// Build CLI command
|
|
2569
|
+
let cmd = cliCmd;
|
|
2570
|
+
if (supportsSession) {
|
|
2571
|
+
let sessionId: string | undefined;
|
|
2572
|
+
if (config.primary) {
|
|
2573
|
+
sessionId = getFixedSession(this.projectPath);
|
|
2574
|
+
console.log(`[daemon] ${config.label}: fixedSession=${sessionId || 'NONE'} for ${this.projectPath}`);
|
|
2575
|
+
} else {
|
|
2576
|
+
sessionId = config.boundSessionId;
|
|
2577
|
+
console.log(`[daemon] ${config.label}: script-gen boundSessionId=${sessionId || 'NONE'}`);
|
|
2578
|
+
}
|
|
2579
|
+
if (sessionId) {
|
|
2580
|
+
const sessionFile = join(this.getCliSessionDir(config.workDir), `${sessionId}.jsonl`);
|
|
2581
|
+
if (existsSync(sessionFile)) {
|
|
2582
|
+
cmd += ` --resume ${sessionId}`;
|
|
2583
|
+
console.log(`[daemon] ${config.label}: script-gen adding --resume ${sessionId}`);
|
|
2584
|
+
} else {
|
|
2585
|
+
console.log(`[daemon] ${config.label}: bound session ${sessionId} missing, starting fresh`);
|
|
2586
|
+
}
|
|
2587
|
+
} else {
|
|
2588
|
+
console.log(`[daemon] ${config.label}: script-gen no boundSessionId → no --resume (skipStartupCheck=${skipStartupCheck})`);
|
|
2589
|
+
}
|
|
2590
|
+
}
|
|
2591
|
+
if (modelFlag) cmd += modelFlag;
|
|
2592
|
+
if (config.skipPermissions !== false && skipPermissionsFlag) cmd += ` ${skipPermissionsFlag}`;
|
|
2593
|
+
if (mcpConfigFlag) cmd += mcpConfigFlag;
|
|
2594
|
+
scriptLines.push(`exec ${cmd}`);
|
|
2595
|
+
|
|
2596
|
+
// Write script and execute in tmux
|
|
2597
|
+
const scriptPath = `/tmp/forge-launch-${config.id.replace(/[^a-z0-9-]/g, '')}.sh`;
|
|
2598
|
+
console.log(`[daemon] ${config.label}: writing launch script ${scriptPath}`);
|
|
2599
|
+
console.log(`[daemon] ${config.label}: exec line → ${cmd}`);
|
|
2600
|
+
writeFileSync(scriptPath, scriptLines.join('\n'), { mode: 0o755 });
|
|
2601
|
+
execSync(`tmux send-keys -t "${sessionName}" 'bash ${scriptPath}' Enter`, { timeout: 5000 });
|
|
2602
|
+
|
|
2603
|
+
console.log(`[daemon] ${config.label}: persistent session created (${sessionName}) [${cliType}: ${cliCmd}]`);
|
|
2604
|
+
|
|
2605
|
+
// Verify CLI started successfully (check after 3s if process is still alive)
|
|
2606
|
+
// Skip when called from openTerminalSession (terminal-open path) for fast response.
|
|
2607
|
+
if (skipStartupCheck) {
|
|
2608
|
+
// Set tmuxSession here only — normal path sets it after startup verification passes.
|
|
2609
|
+
const entrySkip = this.agents.get(agentId);
|
|
2610
|
+
if (entrySkip) {
|
|
2611
|
+
entrySkip.state.tmuxSession = sessionName;
|
|
2612
|
+
this.saveNow();
|
|
2613
|
+
this.emitAgentsChanged();
|
|
2614
|
+
}
|
|
2615
|
+
// Fire boundSessionId binding in background (no await — don't block terminal open)
|
|
2616
|
+
if (!config.primary && !config.boundSessionId) {
|
|
2617
|
+
const bgScriptPath = `/tmp/forge-launch-${config.id.replace(/[^a-z0-9-]/g, '')}.sh`;
|
|
2618
|
+
setTimeout(() => {
|
|
2619
|
+
const sid = this.getLatestSessionId(config.workDir);
|
|
2620
|
+
if (sid) {
|
|
2621
|
+
config.boundSessionId = sid;
|
|
2622
|
+
this.saveNow();
|
|
2623
|
+
console.log(`[daemon] ${config.label}: background bound to session ${sid}`);
|
|
2624
|
+
// Also update launch script for future restarts
|
|
2625
|
+
if (existsSync(bgScriptPath)) {
|
|
2626
|
+
try {
|
|
2627
|
+
const lines = readFileSync(bgScriptPath, 'utf-8').split('\n');
|
|
2628
|
+
const execIdx = lines.findIndex(l => l.startsWith('exec '));
|
|
2629
|
+
if (execIdx >= 0) {
|
|
2630
|
+
const withoutResume = lines[execIdx].replace(/\s--resume\s+\S+/, '');
|
|
2631
|
+
const afterExec = withoutResume.startsWith('exec ') ? 5 : 0;
|
|
2632
|
+
const cmdEnd = withoutResume.indexOf(' ', afterExec);
|
|
2633
|
+
lines[execIdx] = cmdEnd >= 0
|
|
2634
|
+
? withoutResume.slice(0, cmdEnd) + ` --resume ${sid}` + withoutResume.slice(cmdEnd)
|
|
2635
|
+
: withoutResume + ` --resume ${sid}`;
|
|
2636
|
+
writeFileSync(bgScriptPath, lines.join('\n'), { mode: 0o755 });
|
|
2637
|
+
console.log(`[daemon] ${config.label}: background updated launch script with --resume ${sid}`);
|
|
2638
|
+
}
|
|
2639
|
+
} catch {}
|
|
2640
|
+
}
|
|
2641
|
+
}
|
|
2642
|
+
}, 5000);
|
|
2643
|
+
}
|
|
2644
|
+
return;
|
|
2645
|
+
}
|
|
2646
|
+
await new Promise(resolve => setTimeout(resolve, 3000));
|
|
2647
|
+
try {
|
|
2648
|
+
const paneContent = execSync(`tmux capture-pane -t "${sessionName}" -p -S -20`, { timeout: 3000, encoding: 'utf-8' });
|
|
2649
|
+
// Check for common startup errors
|
|
2650
|
+
const errorPatterns = [
|
|
2651
|
+
/error.*settings\.json/i,
|
|
2652
|
+
/invalid.*json/i,
|
|
2653
|
+
/SyntaxError/i,
|
|
2654
|
+
/ENOENT.*settings/i,
|
|
2655
|
+
/failed to parse/i,
|
|
2656
|
+
/could not read/i,
|
|
2657
|
+
/fatal/i,
|
|
2658
|
+
/No conversation found/i,
|
|
2659
|
+
/could not connect/i,
|
|
2660
|
+
/ECONNREFUSED/i,
|
|
2661
|
+
];
|
|
2662
|
+
const hasError = errorPatterns.some(p => p.test(paneContent));
|
|
2663
|
+
if (hasError) {
|
|
2664
|
+
const errorLines = paneContent.split('\n').filter(l => /error|invalid|syntax|fatal|failed|No conversation|ECONNREFUSED/i.test(l)).slice(0, 3);
|
|
2665
|
+
const errorMsg = errorLines.join(' ').slice(0, 200) || 'CLI failed to start (check project settings)';
|
|
2666
|
+
console.error(`[daemon] ${config.label}: CLI startup error detected: ${errorMsg}`);
|
|
2667
|
+
|
|
2668
|
+
const entry = this.agents.get(agentId);
|
|
2669
|
+
if (entry) {
|
|
2670
|
+
entry.state.error = `Terminal failed: ${errorMsg}. Falling back to headless mode.`;
|
|
2671
|
+
entry.state.tmuxSession = undefined; // clear so message loop uses headless (claude -p)
|
|
2672
|
+
this.emit('event', { type: 'log', agentId, entry: { type: 'system', subtype: 'error', content: `Terminal startup failed: ${errorMsg}. Auto-fallback to headless.`, timestamp: new Date().toISOString() } } as any);
|
|
2673
|
+
this.emitAgentsChanged();
|
|
2674
|
+
}
|
|
2675
|
+
// Kill the failed tmux session
|
|
2676
|
+
try { execSync(`tmux kill-session -t "${sessionName}" 2>/dev/null`, { timeout: 3000 }); } catch {}
|
|
2677
|
+
return;
|
|
2678
|
+
}
|
|
2679
|
+
} catch {}
|
|
2680
|
+
} catch (err: any) {
|
|
2681
|
+
console.error(`[daemon] ${config.label}: failed to create persistent session: ${err.message}`);
|
|
2682
|
+
const entry = this.agents.get(agentId);
|
|
2683
|
+
if (entry) {
|
|
2684
|
+
entry.state.error = `Failed to create terminal: ${err.message}`;
|
|
2685
|
+
entry.state.smithStatus = 'down';
|
|
2686
|
+
this.emit('event', { type: 'smith_status', agentId, smithStatus: 'down' } as any);
|
|
2687
|
+
this.emitAgentsChanged();
|
|
2688
|
+
}
|
|
2689
|
+
return;
|
|
2690
|
+
}
|
|
2691
|
+
}
|
|
2692
|
+
|
|
2693
|
+
// Store tmux session name in agent state
|
|
2694
|
+
const entry = this.agents.get(agentId);
|
|
2695
|
+
if (entry) {
|
|
2696
|
+
entry.state.tmuxSession = sessionName;
|
|
2697
|
+
this.saveNow();
|
|
2698
|
+
this.emitAgentsChanged();
|
|
2699
|
+
}
|
|
2700
|
+
|
|
2701
|
+
// Ensure boundSessionId is set before returning (required for session monitor + --resume)
|
|
2702
|
+
// Also re-bind if existing boundSessionId points to a deleted session file
|
|
2703
|
+
if (!config.primary && config.boundSessionId) {
|
|
2704
|
+
const boundFile = join(this.getCliSessionDir(config.workDir), `${config.boundSessionId}.jsonl`);
|
|
2705
|
+
if (!existsSync(boundFile)) {
|
|
2706
|
+
console.log(`[daemon] ${config.label}: boundSession ${config.boundSessionId} file missing, re-binding`);
|
|
2707
|
+
config.boundSessionId = undefined;
|
|
2708
|
+
}
|
|
2709
|
+
}
|
|
2710
|
+
if (!config.primary && !config.boundSessionId) {
|
|
2711
|
+
const pollStart = Date.now();
|
|
2712
|
+
const pollDelay = sessionAlreadyExists ? 500 : 2000;
|
|
2713
|
+
await new Promise<void>(resolve => {
|
|
2714
|
+
const attempt = () => {
|
|
2715
|
+
if (!this.daemonActive) { resolve(); return; }
|
|
2716
|
+
const sessionId = this.getLatestSessionId(config.workDir);
|
|
2717
|
+
if (sessionId) {
|
|
2718
|
+
config.boundSessionId = sessionId;
|
|
2719
|
+
this.saveNow();
|
|
2720
|
+
console.log(`[daemon] ${config.label}: bound to session ${config.boundSessionId}`);
|
|
2721
|
+
// Update launch script with the now-known sessionId — script was written before
|
|
2722
|
+
// polling completed so it had no --resume flag. Fix it for future restarts.
|
|
2723
|
+
const scriptPath = `/tmp/forge-launch-${config.id.replace(/[^a-z0-9-]/g, '')}.sh`;
|
|
2724
|
+
if (existsSync(scriptPath)) {
|
|
2725
|
+
try {
|
|
2726
|
+
const lines = readFileSync(scriptPath, 'utf-8').split('\n');
|
|
2727
|
+
const execIdx = lines.findIndex(l => l.startsWith('exec '));
|
|
2728
|
+
if (execIdx >= 0) {
|
|
2729
|
+
// Remove any existing --resume flag, then re-insert after command name.
|
|
2730
|
+
// Line format: 'exec <cmd> [args...]'
|
|
2731
|
+
// Insert after cmd (skip 'exec ' prefix + cmd word) so bash doesn't
|
|
2732
|
+
// misinterpret --resume as an exec builtin option.
|
|
2733
|
+
const withoutResume = lines[execIdx].replace(/\s--resume\s+\S+/, '');
|
|
2734
|
+
const afterExec = withoutResume.startsWith('exec ') ? 5 : 0;
|
|
2735
|
+
const cmdEnd = withoutResume.indexOf(' ', afterExec);
|
|
2736
|
+
lines[execIdx] = cmdEnd >= 0
|
|
2737
|
+
? withoutResume.slice(0, cmdEnd) + ` --resume ${sessionId}` + withoutResume.slice(cmdEnd)
|
|
2738
|
+
: withoutResume + ` --resume ${sessionId}`;
|
|
2739
|
+
writeFileSync(scriptPath, lines.join('\n'), { mode: 0o755 });
|
|
2740
|
+
console.log(`[daemon] ${config.label}: updated launch script with --resume ${sessionId}`);
|
|
2741
|
+
}
|
|
2742
|
+
} catch {}
|
|
2743
|
+
}
|
|
2744
|
+
return resolve();
|
|
2745
|
+
}
|
|
2746
|
+
if (Date.now() - pollStart < 10_000) {
|
|
2747
|
+
setTimeout(attempt, 1000);
|
|
2748
|
+
} else {
|
|
2749
|
+
console.log(`[daemon] ${config.label}: no session file after 10s, skipping bind`);
|
|
2750
|
+
const entry = this.agents.get(agentId);
|
|
2751
|
+
if (entry) {
|
|
2752
|
+
entry.state.error = 'Terminal session not ready — click "Open Terminal" to start it manually';
|
|
2753
|
+
this.emit('event', { type: 'smith_status', agentId, smithStatus: entry.state.smithStatus } as any);
|
|
2754
|
+
this.emitAgentsChanged();
|
|
2755
|
+
}
|
|
2756
|
+
resolve();
|
|
2757
|
+
}
|
|
2758
|
+
};
|
|
2759
|
+
setTimeout(attempt, pollDelay);
|
|
2760
|
+
});
|
|
2761
|
+
}
|
|
2762
|
+
}
|
|
2763
|
+
|
|
2764
|
+
/** Inject text into an agent's persistent terminal session */
|
|
2765
|
+
injectIntoSession(agentId: string, text: string): boolean {
|
|
2766
|
+
const entry = this.agents.get(agentId);
|
|
2767
|
+
// Verify stored session is alive
|
|
2768
|
+
if (entry?.state.tmuxSession) {
|
|
2769
|
+
try { execSync(`tmux has-session -t "${entry.state.tmuxSession}" 2>/dev/null`, { timeout: 3000 }); }
|
|
2770
|
+
catch { entry.state.tmuxSession = undefined; }
|
|
2771
|
+
}
|
|
2772
|
+
const tmuxSession = entry?.state.tmuxSession || this.findTmuxSession(entry?.config.label || '');
|
|
2773
|
+
if (!tmuxSession) return false;
|
|
2774
|
+
// Cache found session for future use
|
|
2775
|
+
if (entry && !entry.state.tmuxSession) entry.state.tmuxSession = tmuxSession;
|
|
2776
|
+
|
|
2777
|
+
try {
|
|
2778
|
+
const tmpFile = `/tmp/forge-inject-${Date.now()}.txt`;
|
|
2779
|
+
writeFileSync(tmpFile, text);
|
|
2780
|
+
execSync(`tmux load-buffer ${tmpFile}`, { timeout: 5000 });
|
|
2781
|
+
execSync(`tmux paste-buffer -t "${tmuxSession}" && sleep 0.2 && tmux send-keys -t "${tmuxSession}" Enter`, { timeout: 5000 });
|
|
2782
|
+
try { unlinkSync(tmpFile); } catch {}
|
|
2783
|
+
return true;
|
|
2784
|
+
} catch (err: any) {
|
|
2785
|
+
console.error(`[inject] Failed for ${tmuxSession}: ${err.message}`);
|
|
2786
|
+
return false;
|
|
2787
|
+
}
|
|
2788
|
+
}
|
|
2789
|
+
|
|
2790
|
+
/** Check if agent has a persistent session available */
|
|
2791
|
+
hasPersistentSession(agentId: string): boolean {
|
|
2792
|
+
const entry = this.agents.get(agentId);
|
|
2793
|
+
if (!entry) return false;
|
|
2794
|
+
if (entry.state.tmuxSession) return true;
|
|
2795
|
+
return !!this.findTmuxSession(entry.config.label);
|
|
2796
|
+
}
|
|
2797
|
+
|
|
2798
|
+
private findTmuxSession(agentLabel: string): string | null {
|
|
2799
|
+
const safeName = (s: string) => s.toLowerCase().replace(/[^a-z0-9]/g, '-').replace(/-+/g, '-').slice(0, 20);
|
|
2800
|
+
const projectSafe = safeName(this.projectName);
|
|
2801
|
+
const agentSafe = safeName(agentLabel);
|
|
2802
|
+
|
|
2803
|
+
// Try workspace naming: mw-forge-{project}-{agent}
|
|
2804
|
+
const wsName = `mw-forge-${projectSafe}-${agentSafe}`;
|
|
2805
|
+
try { execSync(`tmux has-session -t "${wsName}" 2>/dev/null`, { timeout: 3000 }); return wsName; } catch {}
|
|
2806
|
+
|
|
2807
|
+
// Try VibeCoding naming: mw-{project}
|
|
2808
|
+
const vcName = `mw-${projectSafe}`;
|
|
2809
|
+
try { execSync(`tmux has-session -t "${vcName}" 2>/dev/null`, { timeout: 3000 }); return vcName; } catch {}
|
|
2810
|
+
|
|
2811
|
+
// Search terminal-state.json for project matching tmux session
|
|
2812
|
+
try {
|
|
2813
|
+
const statePath = join(homedir(), '.forge', 'data', 'terminal-state.json');
|
|
2814
|
+
if (existsSync(statePath)) {
|
|
2815
|
+
const termState = JSON.parse(readFileSync(statePath, 'utf-8'));
|
|
2816
|
+
for (const tab of termState.tabs || []) {
|
|
2817
|
+
if (tab.projectPath === this.projectPath) {
|
|
2818
|
+
const findSession = (tree: any): string | null => {
|
|
2819
|
+
if (tree?.type === 'terminal' && tree.sessionName) return tree.sessionName;
|
|
2820
|
+
for (const child of tree?.children || []) {
|
|
2821
|
+
const found = findSession(child);
|
|
2822
|
+
if (found) return found;
|
|
2823
|
+
}
|
|
2824
|
+
return null;
|
|
2825
|
+
};
|
|
2826
|
+
const sess = findSession(tab.tree);
|
|
2827
|
+
if (sess) {
|
|
2828
|
+
try { execSync(`tmux has-session -t "${sess}" 2>/dev/null`, { timeout: 3000 }); return sess; } catch {}
|
|
2829
|
+
}
|
|
2830
|
+
}
|
|
2831
|
+
}
|
|
2832
|
+
}
|
|
2833
|
+
} catch {}
|
|
2834
|
+
|
|
2835
|
+
return null;
|
|
2836
|
+
}
|
|
2837
|
+
|
|
2838
|
+
private updateAgentLiveness(agentId: string): void {
|
|
2839
|
+
const entry = this.agents.get(agentId);
|
|
2840
|
+
if (!entry) {
|
|
2841
|
+
this.bus.setAgentStatus(agentId, 'down');
|
|
2842
|
+
return;
|
|
2843
|
+
}
|
|
2844
|
+
if (entry.state.taskStatus === 'running') this.bus.setAgentStatus(agentId, 'busy');
|
|
2845
|
+
else if (entry.state.smithStatus === 'active') this.bus.setAgentStatus(agentId, 'alive');
|
|
2846
|
+
else this.bus.setAgentStatus(agentId, 'down');
|
|
2847
|
+
}
|
|
2848
|
+
|
|
2849
|
+
// ─── Bus message handling ──────────────────────────────
|
|
2850
|
+
|
|
2851
|
+
private handleBusMessage(msg: BusMessage): void {
|
|
2852
|
+
// Dedup
|
|
2853
|
+
if (this.bus.isDuplicate(msg.id)) return;
|
|
2854
|
+
|
|
2855
|
+
// Emit to UI after dedup (no duplicates, no ACKs)
|
|
2856
|
+
this.emit('event', { type: 'bus_message', message: msg } satisfies OrchestratorEvent);
|
|
2857
|
+
|
|
2858
|
+
// Route to target
|
|
2859
|
+
this.routeMessageToAgent(msg.to, msg);
|
|
2860
|
+
this.checkWorkspaceComplete();
|
|
2861
|
+
}
|
|
2862
|
+
|
|
2863
|
+
private routeMessageToAgent(targetId: string, msg: BusMessage): void {
|
|
2864
|
+
const target = this.agents.get(targetId);
|
|
2865
|
+
if (!target) return;
|
|
2866
|
+
|
|
2867
|
+
const fromLabel = this.agents.get(msg.from)?.config.label || msg.from;
|
|
2868
|
+
const action = msg.payload.action;
|
|
2869
|
+
const content = msg.payload.content || '';
|
|
2870
|
+
|
|
2871
|
+
console.log(`[bus] ${fromLabel} → ${target.config.label}: ${action} "${content.slice(0, 80)}"`);
|
|
2872
|
+
|
|
2873
|
+
const logEntry = {
|
|
2874
|
+
type: 'system' as const,
|
|
2875
|
+
subtype: 'bus_message',
|
|
2876
|
+
content: `[From ${fromLabel}]: ${content || action}`,
|
|
2877
|
+
timestamp: new Date(msg.timestamp).toISOString(),
|
|
2878
|
+
};
|
|
2879
|
+
|
|
2880
|
+
// Helper: mark message as processed when actually consumed
|
|
2881
|
+
const ackAndDeliver = () => {
|
|
2882
|
+
msg.status = 'done';
|
|
2883
|
+
};
|
|
2884
|
+
|
|
2885
|
+
// ── Input node: request user input ──
|
|
2886
|
+
if (target.config.type === 'input') {
|
|
2887
|
+
if (action === 'info_request' || action === 'question') {
|
|
2888
|
+
ackAndDeliver();
|
|
2889
|
+
this.emit('event', {
|
|
2890
|
+
type: 'user_input_request',
|
|
2891
|
+
agentId: targetId,
|
|
2892
|
+
fromAgent: msg.from,
|
|
2893
|
+
question: content,
|
|
2894
|
+
} satisfies OrchestratorEvent);
|
|
2895
|
+
}
|
|
2896
|
+
return;
|
|
2897
|
+
}
|
|
2898
|
+
|
|
2899
|
+
// ── Store message in agent history ──
|
|
2900
|
+
target.state.history.push(logEntry);
|
|
2901
|
+
|
|
2902
|
+
// ── requiresApproval → set pending_approval on arrival ──
|
|
2903
|
+
if (target.config.requiresApproval) {
|
|
2904
|
+
msg.status = 'pending_approval';
|
|
2905
|
+
this.emit('event', { type: 'bus_message_status', messageId: msg.id, status: 'pending_approval' } as any);
|
|
2906
|
+
console.log(`[bus] ${target.config.label}: received ${action} — pending approval`);
|
|
2907
|
+
return;
|
|
2908
|
+
}
|
|
2909
|
+
|
|
2910
|
+
// ── Message stays pending — message loop will consume it when smith is ready ──
|
|
2911
|
+
console.log(`[bus] ${target.config.label}: received ${action} — queued in inbox (${msg.status})`);
|
|
2912
|
+
}
|
|
2913
|
+
|
|
2914
|
+
// ─── Message consumption loop ─────────────────────────
|
|
2915
|
+
private messageLoopTimers = new Map<string, NodeJS.Timeout>();
|
|
2916
|
+
|
|
2917
|
+
/** Start the message consumption loop for a smith */
|
|
2918
|
+
private startMessageLoop(agentId: string): void {
|
|
2919
|
+
if (this.messageLoopTimers.has(agentId)) return; // already running
|
|
2920
|
+
|
|
2921
|
+
let debugTick = 0;
|
|
2922
|
+
const tick = () => {
|
|
2923
|
+
const entry = this.agents.get(agentId);
|
|
2924
|
+
if (!entry) {
|
|
2925
|
+
this.stopMessageLoop(agentId);
|
|
2926
|
+
return;
|
|
2927
|
+
}
|
|
2928
|
+
|
|
2929
|
+
// Don't stop loop if smith is down — just skip this tick
|
|
2930
|
+
// (loop stays alive so it works when smith comes back)
|
|
2931
|
+
if (entry.state.smithStatus !== 'active') return;
|
|
2932
|
+
|
|
2933
|
+
// Skip if already busy
|
|
2934
|
+
if (entry.state.taskStatus === 'running') return;
|
|
2935
|
+
|
|
2936
|
+
// Skip if any message is already running for this agent (O(1) cache lookup)
|
|
2937
|
+
if (this.agentRunningMsg.has(agentId)) return;
|
|
2938
|
+
|
|
2939
|
+
// Execution path determined by config, not runtime tmux state
|
|
2940
|
+
const isTerminalMode = entry.config.persistentSession;
|
|
2941
|
+
if (isTerminalMode) {
|
|
2942
|
+
// Terminal mode: need tmux session. If missing, skip this tick (health check will restart it)
|
|
2943
|
+
if (!entry.state.tmuxSession) {
|
|
2944
|
+
if (++debugTick % 15 === 0) {
|
|
2945
|
+
console.log(`[inbox] ${entry.config.label}: terminal mode but no tmux session — waiting for auto-restart`);
|
|
2946
|
+
}
|
|
2947
|
+
return;
|
|
2948
|
+
}
|
|
2949
|
+
} else {
|
|
2950
|
+
// Headless mode: need worker ready
|
|
2951
|
+
if (!entry.worker) {
|
|
2952
|
+
if (this.daemonActive) {
|
|
2953
|
+
console.log(`[inbox] ${entry.config.label}: no worker, recreating...`);
|
|
2954
|
+
this.enterDaemonListening(agentId);
|
|
2955
|
+
}
|
|
2956
|
+
return;
|
|
2957
|
+
}
|
|
2958
|
+
if (!entry.worker.isListening()) {
|
|
2959
|
+
if (++debugTick % 15 === 0) {
|
|
2960
|
+
console.log(`[inbox] ${entry.config.label}: not listening (smith=${entry.state.smithStatus} task=${entry.state.taskStatus})`);
|
|
2961
|
+
}
|
|
2962
|
+
return;
|
|
2963
|
+
}
|
|
2964
|
+
}
|
|
2965
|
+
|
|
2966
|
+
// requiresApproval is handled at message arrival time (routeMessageToAgent),
|
|
2967
|
+
// not in the message loop. Approved messages come through as normal 'pending'.
|
|
2968
|
+
|
|
2969
|
+
// Dedup: if multiple upstream_complete from same sender are pending, keep only latest
|
|
2970
|
+
const allRaw = this.bus.getPendingMessagesFor(agentId).filter(m => m.from !== agentId && m.type !== 'ack');
|
|
2971
|
+
const upstreamSeen = new Set<string>();
|
|
2972
|
+
for (let i = allRaw.length - 1; i >= 0; i--) {
|
|
2973
|
+
const m = allRaw[i];
|
|
2974
|
+
if (m.payload?.action === 'upstream_complete') {
|
|
2975
|
+
const key = `upstream-${m.from}`;
|
|
2976
|
+
if (upstreamSeen.has(key)) {
|
|
2977
|
+
m.status = 'done' as any;
|
|
2978
|
+
this.emit('event', { type: 'bus_message_status', messageId: m.id, status: 'done' } as any);
|
|
2979
|
+
}
|
|
2980
|
+
upstreamSeen.add(key);
|
|
2981
|
+
}
|
|
2982
|
+
}
|
|
2983
|
+
|
|
2984
|
+
// Find next pending message, applying causedBy rules
|
|
2985
|
+
const allPending = allRaw.filter(m => m.status === 'pending');
|
|
2986
|
+
const pending = allPending.filter(m => {
|
|
2987
|
+
// Tickets: accepted but check retry limit
|
|
2988
|
+
if (m.category === 'ticket') {
|
|
2989
|
+
const maxRetries = m.maxRetries ?? 3;
|
|
2990
|
+
if ((m.ticketRetries || 0) >= maxRetries) {
|
|
2991
|
+
console.log(`[inbox] ${entry.config.label}: ticket ${m.id.slice(0, 8)} exceeded max retries (${maxRetries}), marking failed`);
|
|
2992
|
+
m.status = 'failed' as any;
|
|
2993
|
+
m.ticketStatus = 'closed';
|
|
2994
|
+
this.emit('event', { type: 'bus_message_status', messageId: m.id, status: 'failed' } as any);
|
|
2995
|
+
return false;
|
|
2996
|
+
}
|
|
2997
|
+
return true;
|
|
2998
|
+
}
|
|
2999
|
+
|
|
3000
|
+
// System messages: _forge failure notifications are log-only (don't trigger agent execution)
|
|
3001
|
+
if (m.from === '_forge' && m.payload?.action === 'update_notify') {
|
|
3002
|
+
console.log(`[inbox] ${entry.config.label}: _forge notification logged, not executed`);
|
|
3003
|
+
m.status = 'done' as any;
|
|
3004
|
+
return false;
|
|
3005
|
+
}
|
|
3006
|
+
|
|
3007
|
+
// Other system messages (from _watch, _system, user) bypass causedBy rules
|
|
3008
|
+
if (m.from.startsWith('_') || m.from === 'user') return true;
|
|
3009
|
+
|
|
3010
|
+
// Notifications: check causedBy for loop prevention
|
|
3011
|
+
if (m.causedBy) {
|
|
3012
|
+
// Rule 1: Is this a response to something I sent? → accept (for verification)
|
|
3013
|
+
const myOutbox = this.bus.getOutboxFor(agentId);
|
|
3014
|
+
if (myOutbox.some(o => o.id === m.causedBy!.messageId)) return true;
|
|
3015
|
+
|
|
3016
|
+
// Rule 2: Notification from downstream → discard (prevents reverse flow)
|
|
3017
|
+
if (!this.isUpstream(m.from, agentId)) {
|
|
3018
|
+
console.log(`[inbox] ${entry.config.label}: discarding notification from downstream ${this.agents.get(m.from)?.config.label || m.from}`);
|
|
3019
|
+
m.status = 'done' as any; // silently consume
|
|
3020
|
+
return false;
|
|
3021
|
+
}
|
|
3022
|
+
}
|
|
3023
|
+
|
|
3024
|
+
// Default: accept (upstream notifications, no causedBy = initial trigger)
|
|
3025
|
+
return true;
|
|
3026
|
+
});
|
|
3027
|
+
if (pending.length === 0) return;
|
|
3028
|
+
|
|
3029
|
+
const nextMsg = pending[0];
|
|
3030
|
+
const fromLabel = this.agents.get(nextMsg.from)?.config.label || nextMsg.from;
|
|
3031
|
+
console.log(`[inbox] ${entry.config.label}: consuming message from ${fromLabel} (${nextMsg.payload.action})`);
|
|
3032
|
+
|
|
3033
|
+
// Mark message as running (being processed)
|
|
3034
|
+
nextMsg.status = 'running' as any;
|
|
3035
|
+
this.agentRunningMsg.set(agentId, nextMsg.id);
|
|
3036
|
+
this.emit('event', { type: 'bus_message_status', messageId: nextMsg.id, status: 'running' } as any);
|
|
3037
|
+
|
|
3038
|
+
const logEntry = {
|
|
3039
|
+
type: 'system' as const,
|
|
3040
|
+
subtype: 'bus_message',
|
|
3041
|
+
content: `[From ${fromLabel}]: ${nextMsg.payload.content || nextMsg.payload.action}`,
|
|
3042
|
+
timestamp: new Date(nextMsg.timestamp).toISOString(),
|
|
3043
|
+
};
|
|
3044
|
+
|
|
3045
|
+
// Terminal mode → notify (agent pulls via /forge-inbox); headless → worker (claude -p)
|
|
3046
|
+
if (isTerminalMode) {
|
|
3047
|
+
// "You have mail" approach: inject a short notification, not the message content.
|
|
3048
|
+
// The agent calls /forge-inbox to read the actual message when ready.
|
|
3049
|
+
const pendingCount = pending.length;
|
|
3050
|
+
let notification = pendingCount > 1
|
|
3051
|
+
? `You have ${pendingCount} new messages in your inbox (latest from ${fromLabel}). Use /forge-inbox to read them.`
|
|
3052
|
+
: `You have a new message from ${fromLabel}. Use /forge-inbox to read it.`;
|
|
3053
|
+
// Prepend role reminder if needed (combats auto-compaction over long sessions)
|
|
3054
|
+
if (this.needsRoleReminder(agentId) && entry.config.role?.trim()) {
|
|
3055
|
+
notification = this.buildRoleReminder(entry.config) + '\n\n' + notification;
|
|
3056
|
+
this.markRoleInjected(agentId);
|
|
3057
|
+
console.log(`[inbox] ${entry.config.label}: prepended role reminder`);
|
|
3058
|
+
} else {
|
|
3059
|
+
this.incrementMsgCount(agentId);
|
|
3060
|
+
}
|
|
3061
|
+
const injected = this.injectIntoSession(agentId, notification);
|
|
3062
|
+
if (injected) {
|
|
3063
|
+
entry.state.taskStatus = 'running';
|
|
3064
|
+
this.emit('event', { type: 'task_status', agentId, taskStatus: 'running' } as any);
|
|
3065
|
+
this.emit('event', { type: 'log', agentId, entry: { type: 'system', subtype: 'execution_method', content: `📬 Notified agent: ${notification}`, timestamp: new Date().toISOString() } } as any);
|
|
3066
|
+
console.log(`[inbox] ${entry.config.label}: notified of message from ${fromLabel}, monitoring for completion`);
|
|
3067
|
+
entry.state.currentMessageId = nextMsg.id;
|
|
3068
|
+
this.monitorTerminalCompletion(agentId, nextMsg.id, entry.state.tmuxSession!);
|
|
3069
|
+
} else {
|
|
3070
|
+
// Terminal inject failed — clear dead session, message stays pending
|
|
3071
|
+
// Health check will auto-restart the terminal session
|
|
3072
|
+
entry.state.tmuxSession = undefined;
|
|
3073
|
+
nextMsg.status = 'pending' as any; // revert to pending for retry
|
|
3074
|
+
this.agentRunningMsg.delete(agentId);
|
|
3075
|
+
this.emit('event', { type: 'bus_message_status', messageId: nextMsg.id, status: 'pending' } as any);
|
|
3076
|
+
this.emit('event', { type: 'log', agentId, entry: { type: 'system', subtype: 'warning', content: '⚠️ Terminal session down — waiting for auto-restart, message will retry', timestamp: new Date().toISOString() } } as any);
|
|
3077
|
+
console.log(`[inbox] ${entry.config.label}: terminal inject failed, cleared session — waiting for health check restart`);
|
|
3078
|
+
this.emitAgentsChanged();
|
|
3079
|
+
}
|
|
3080
|
+
} else {
|
|
3081
|
+
// Headless mode: mark running and push content directly
|
|
3082
|
+
nextMsg.status = 'running' as any;
|
|
3083
|
+
this.emit('event', { type: 'bus_message_status', messageId: nextMsg.id, status: 'running' } as any);
|
|
3084
|
+
entry.worker!.setProcessingMessage(nextMsg.id);
|
|
3085
|
+
entry.worker!.wake({ type: 'bus_message', messages: [logEntry] });
|
|
3086
|
+
this.emit('event', { type: 'log', agentId, entry: { type: 'system', subtype: 'execution_method', content: `⚡ Executed via headless (agent: ${entry.config.agentId || 'claude'})`, timestamp: new Date().toISOString() } } as any);
|
|
3087
|
+
}
|
|
3088
|
+
};
|
|
3089
|
+
|
|
3090
|
+
// Check every 2 seconds
|
|
3091
|
+
const timer = setInterval(tick, 2000);
|
|
3092
|
+
timer.unref(); // Don't prevent process exit in tests
|
|
3093
|
+
this.messageLoopTimers.set(agentId, timer);
|
|
3094
|
+
// Also run immediately
|
|
3095
|
+
tick();
|
|
3096
|
+
}
|
|
3097
|
+
|
|
3098
|
+
/** Stop the message consumption loop for a smith */
|
|
3099
|
+
private stopMessageLoop(agentId: string): void {
|
|
3100
|
+
const timer = this.messageLoopTimers.get(agentId);
|
|
3101
|
+
if (timer) {
|
|
3102
|
+
clearInterval(timer);
|
|
3103
|
+
this.messageLoopTimers.delete(agentId);
|
|
3104
|
+
}
|
|
3105
|
+
}
|
|
3106
|
+
|
|
3107
|
+
/** Stop all message loops */
|
|
3108
|
+
private stopAllMessageLoops(): void {
|
|
3109
|
+
for (const [id] of this.messageLoopTimers) {
|
|
3110
|
+
this.stopMessageLoop(id);
|
|
3111
|
+
}
|
|
3112
|
+
}
|
|
3113
|
+
|
|
3114
|
+
// ─── Terminal completion monitor ──────────────────────
|
|
3115
|
+
private terminalMonitors = new Map<string, NodeJS.Timeout>();
|
|
3116
|
+
|
|
3117
|
+
/**
|
|
3118
|
+
* Monitor a tmux session for completion after injecting a message.
|
|
3119
|
+
* Detects CLI prompt patterns (❯, $, >) indicating the agent is idle.
|
|
3120
|
+
* Requires 2 consecutive prompt detections (10s) to confirm completion.
|
|
3121
|
+
*/
|
|
3122
|
+
private monitorTerminalCompletion(agentId: string, messageId: string, tmuxSession: string): void {
|
|
3123
|
+
// Stop any existing monitor for this agent
|
|
3124
|
+
const existing = this.terminalMonitors.get(agentId);
|
|
3125
|
+
if (existing) clearInterval(existing);
|
|
3126
|
+
|
|
3127
|
+
// Prompt patterns that indicate the CLI is idle and waiting for input
|
|
3128
|
+
// Claude Code: ❯ Codex: > Aider: > Generic shell: $ #
|
|
3129
|
+
const PROMPT_PATTERNS = [
|
|
3130
|
+
/^❯\s*$/, // Claude Code idle prompt
|
|
3131
|
+
/^>\s*$/, // Codex / generic prompt
|
|
3132
|
+
/^\$\s*$/, // Shell prompt
|
|
3133
|
+
/^#\s*$/, // Root shell prompt
|
|
3134
|
+
/^aider>\s*$/, // Aider prompt
|
|
3135
|
+
];
|
|
3136
|
+
|
|
3137
|
+
let promptCount = 0;
|
|
3138
|
+
let started = false;
|
|
3139
|
+
const CONFIRM_CHECKS = 2; // 2 consecutive prompt detections = done
|
|
3140
|
+
const CHECK_INTERVAL = 5000; // 5s between checks
|
|
3141
|
+
|
|
3142
|
+
const timer = setInterval(() => {
|
|
3143
|
+
try {
|
|
3144
|
+
const output = execSync(`tmux capture-pane -t "${tmuxSession}" -p -S -30`, { timeout: 3000, encoding: 'utf-8' });
|
|
3145
|
+
|
|
3146
|
+
// Strip ANSI escape sequences for clean matching
|
|
3147
|
+
const clean = output.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '');
|
|
3148
|
+
// Get last few non-empty lines
|
|
3149
|
+
const lines = clean.split('\n').map(l => l.trim()).filter(Boolean);
|
|
3150
|
+
const tail = lines.slice(-5);
|
|
3151
|
+
|
|
3152
|
+
// First check: detect that agent started working (output changed from inject)
|
|
3153
|
+
if (!started && lines.length > 3) {
|
|
3154
|
+
started = true;
|
|
3155
|
+
}
|
|
3156
|
+
if (!started) return;
|
|
3157
|
+
|
|
3158
|
+
// Check if any of the last lines match a prompt pattern
|
|
3159
|
+
const hasPrompt = tail.some(line => PROMPT_PATTERNS.some(p => p.test(line)));
|
|
3160
|
+
|
|
3161
|
+
if (hasPrompt) {
|
|
3162
|
+
promptCount++;
|
|
3163
|
+
if (promptCount >= CONFIRM_CHECKS) {
|
|
3164
|
+
clearInterval(timer);
|
|
3165
|
+
this.terminalMonitors.delete(agentId);
|
|
3166
|
+
this.agentRunningMsg.delete(agentId);
|
|
3167
|
+
|
|
3168
|
+
// Extract output summary (skip prompt lines)
|
|
3169
|
+
const contentLines = lines.filter(l => !PROMPT_PATTERNS.some(p => p.test(l)));
|
|
3170
|
+
const summary = contentLines.slice(-15).join('\n');
|
|
3171
|
+
|
|
3172
|
+
// Mark message done
|
|
3173
|
+
const msg = this.bus.getLog().find(m => m.id === messageId);
|
|
3174
|
+
if (msg && msg.status !== 'done') {
|
|
3175
|
+
msg.status = 'done' as any;
|
|
3176
|
+
this.emit('event', { type: 'bus_message_status', messageId, status: 'done' } as any);
|
|
3177
|
+
}
|
|
3178
|
+
|
|
3179
|
+
// Emit output to log panel
|
|
3180
|
+
this.emit('event', { type: 'log', agentId, entry: { type: 'assistant', subtype: 'terminal_output', content: `📺 Terminal completed:\n${summary.slice(0, 500)}`, timestamp: new Date().toISOString() } } as any);
|
|
3181
|
+
|
|
3182
|
+
// Trigger downstream notifications
|
|
3183
|
+
const entry = this.agents.get(agentId);
|
|
3184
|
+
if (entry) {
|
|
3185
|
+
entry.state.currentMessageId = undefined;
|
|
3186
|
+
this.handleAgentDone(agentId, entry, summary.slice(0, 300));
|
|
3187
|
+
}
|
|
3188
|
+
console.log(`[terminal-monitor] ${agentId}: prompt detected, completed`);
|
|
3189
|
+
}
|
|
3190
|
+
} else {
|
|
3191
|
+
promptCount = 0; // reset — still working
|
|
3192
|
+
}
|
|
3193
|
+
} catch {
|
|
3194
|
+
// Session died
|
|
3195
|
+
clearInterval(timer);
|
|
3196
|
+
this.terminalMonitors.delete(agentId);
|
|
3197
|
+
this.agentRunningMsg.delete(agentId);
|
|
3198
|
+
const msg = this.bus.getLog().find(m => m.id === messageId);
|
|
3199
|
+
if (msg && msg.status !== 'done' && msg.status !== 'failed') {
|
|
3200
|
+
msg.status = 'failed' as any;
|
|
3201
|
+
this.emit('event', { type: 'bus_message_status', messageId, status: 'failed' } as any);
|
|
3202
|
+
}
|
|
3203
|
+
const entry = this.agents.get(agentId);
|
|
3204
|
+
if (entry) entry.state.currentMessageId = undefined;
|
|
3205
|
+
console.error(`[terminal-monitor] ${agentId}: session died, marked message failed`);
|
|
3206
|
+
}
|
|
3207
|
+
}, CHECK_INTERVAL);
|
|
3208
|
+
timer.unref();
|
|
3209
|
+
this.terminalMonitors.set(agentId, timer);
|
|
3210
|
+
}
|
|
3211
|
+
|
|
3212
|
+
/** Stop all terminal monitors (on daemon stop) */
|
|
3213
|
+
private stopAllTerminalMonitors(): void {
|
|
3214
|
+
for (const [, timer] of this.terminalMonitors) clearInterval(timer);
|
|
3215
|
+
this.terminalMonitors.clear();
|
|
3216
|
+
}
|
|
3217
|
+
|
|
3218
|
+
/** Check if all agents are done and no pending work remains */
|
|
3219
|
+
private checkWorkspaceComplete(): void {
|
|
3220
|
+
let allDone = true;
|
|
3221
|
+
for (const [id, entry] of this.agents) {
|
|
3222
|
+
const ws = entry.worker?.getState();
|
|
3223
|
+
const taskSt = ws?.taskStatus ?? entry.state.taskStatus;
|
|
3224
|
+
if (taskSt === 'running' || this.approvalQueue.has(id)) {
|
|
3225
|
+
allDone = false;
|
|
3226
|
+
break;
|
|
3227
|
+
}
|
|
3228
|
+
// idle agents with unmet deps don't block completion
|
|
3229
|
+
if (taskSt === 'idle' && entry.config.dependsOn.length > 0) {
|
|
3230
|
+
const allDepsDone = entry.config.dependsOn.every(depId => {
|
|
3231
|
+
const dep = this.agents.get(depId);
|
|
3232
|
+
return dep && (dep.state.taskStatus === 'done');
|
|
3233
|
+
});
|
|
3234
|
+
if (allDepsDone) {
|
|
3235
|
+
allDone = false; // idle but ready to run = not complete
|
|
3236
|
+
break;
|
|
3237
|
+
}
|
|
3238
|
+
}
|
|
3239
|
+
}
|
|
3240
|
+
|
|
3241
|
+
if (allDone && this.agents.size > 0) {
|
|
3242
|
+
const hasPendingRequests = this.bus.getLog().some(m =>
|
|
3243
|
+
m.type === 'request' && !this.bus.getLog().some(r =>
|
|
3244
|
+
r.type === 'response' && r.payload.replyTo === m.id
|
|
3245
|
+
)
|
|
3246
|
+
);
|
|
3247
|
+
// Also check request documents are all done
|
|
3248
|
+
let requestsComplete = true;
|
|
3249
|
+
try {
|
|
3250
|
+
const { listRequests } = require('./requests');
|
|
3251
|
+
const allReqs = listRequests(this.projectPath);
|
|
3252
|
+
if (allReqs.length > 0) {
|
|
3253
|
+
requestsComplete = allReqs.every((r: any) => r.status === 'done' || r.status === 'rejected');
|
|
3254
|
+
}
|
|
3255
|
+
} catch {}
|
|
3256
|
+
|
|
3257
|
+
if (!hasPendingRequests && requestsComplete) {
|
|
3258
|
+
console.log('[workspace] All agents complete, no pending requests, all request docs done. Workspace done.');
|
|
3259
|
+
this.emit('event', { type: 'workspace_complete' } satisfies OrchestratorEvent);
|
|
3260
|
+
}
|
|
3261
|
+
}
|
|
3262
|
+
}
|
|
3263
|
+
|
|
3264
|
+
/** Get agents that are idle and have all dependencies met */
|
|
3265
|
+
private getReadyAgents(): string[] {
|
|
3266
|
+
const ready: string[] = [];
|
|
3267
|
+
for (const [id, entry] of this.agents) {
|
|
3268
|
+
if (entry.state.taskStatus !== 'idle') continue;
|
|
3269
|
+
const allDepsDone = entry.config.dependsOn.every(depId => {
|
|
3270
|
+
const dep = this.agents.get(depId);
|
|
3271
|
+
return dep && dep.state.taskStatus === 'done';
|
|
3272
|
+
});
|
|
3273
|
+
if (allDepsDone) ready.push(id);
|
|
3274
|
+
}
|
|
3275
|
+
return ready;
|
|
3276
|
+
}
|
|
3277
|
+
|
|
3278
|
+
/**
|
|
3279
|
+
* Parse CLI agent output for bus message markers.
|
|
3280
|
+
* Format: [SEND:TargetLabel:action] content
|
|
3281
|
+
* Example: [SEND:Engineer:fix_request] SQL injection found in auth module
|
|
3282
|
+
*/
|
|
3283
|
+
/**
|
|
3284
|
+
* After an agent completes, notify downstream agents that already ran (done/failed)
|
|
3285
|
+
* to re-validate their work. Sets them to waiting_approval so user decides.
|
|
3286
|
+
*/
|
|
3287
|
+
private notifyDownstreamForRevalidation(completedAgentId: string, files: string[]): void {
|
|
3288
|
+
const completedLabel = this.agents.get(completedAgentId)?.config.label || completedAgentId;
|
|
3289
|
+
|
|
3290
|
+
for (const [id, entry] of this.agents) {
|
|
3291
|
+
if (id === completedAgentId) continue;
|
|
3292
|
+
if (!entry.config.dependsOn.includes(completedAgentId)) continue;
|
|
3293
|
+
|
|
3294
|
+
// Only notify agents that already completed — they need to re-validate
|
|
3295
|
+
if (entry.state.taskStatus !== 'done' && entry.state.taskStatus !== 'failed') continue;
|
|
3296
|
+
|
|
3297
|
+
console.log(`[workspace] ${completedLabel} changed → ${entry.config.label} needs re-validation`);
|
|
3298
|
+
|
|
3299
|
+
// Send bus message
|
|
3300
|
+
this.bus.send(completedAgentId, id, 'notify', {
|
|
3301
|
+
action: 'update_notify',
|
|
3302
|
+
content: `${completedLabel} completed with changes. Please re-validate.`,
|
|
3303
|
+
files,
|
|
3304
|
+
});
|
|
3305
|
+
|
|
3306
|
+
// Set to waiting_approval so user confirms re-run
|
|
3307
|
+
entry.state.taskStatus = 'idle';
|
|
3308
|
+
entry.state.history.push({
|
|
3309
|
+
type: 'system',
|
|
3310
|
+
subtype: 'revalidation_request',
|
|
3311
|
+
content: `[${completedLabel}] completed with changes — approve to re-run validation`,
|
|
3312
|
+
timestamp: new Date().toISOString(),
|
|
3313
|
+
});
|
|
3314
|
+
this.approvalQueue.add(id);
|
|
3315
|
+
this.emit('event', { type: 'task_status', agentId: id, taskStatus: 'idle' } satisfies WorkerEvent);
|
|
3316
|
+
this.emit('event', {
|
|
3317
|
+
type: 'approval_required',
|
|
3318
|
+
agentId: id,
|
|
3319
|
+
upstreamId: completedAgentId,
|
|
3320
|
+
} satisfies OrchestratorEvent);
|
|
3321
|
+
}
|
|
3322
|
+
}
|
|
3323
|
+
|
|
3324
|
+
/** Track how many history entries have been scanned per agent to avoid re-parsing */
|
|
3325
|
+
private busMarkerScanned = new Map<string, number>();
|
|
3326
|
+
|
|
3327
|
+
private parseBusMarkers(fromAgentId: string, history: { type: string; content: string }[]): void {
|
|
3328
|
+
const markerRegex = /\[SEND:([^:]+):([^\]]+)\]\s*(.+)/g;
|
|
3329
|
+
const labelToId = new Map<string, string>();
|
|
3330
|
+
for (const [id, e] of this.agents) {
|
|
3331
|
+
labelToId.set(e.config.label.toLowerCase(), id);
|
|
3332
|
+
}
|
|
3333
|
+
|
|
3334
|
+
// Only scan new entries since last parse (avoid re-sending from old history)
|
|
3335
|
+
const lastScanned = this.busMarkerScanned.get(fromAgentId) || 0;
|
|
3336
|
+
const newEntries = history.slice(lastScanned);
|
|
3337
|
+
this.busMarkerScanned.set(fromAgentId, history.length);
|
|
3338
|
+
|
|
3339
|
+
for (const entry of newEntries) {
|
|
3340
|
+
let match;
|
|
3341
|
+
while ((match = markerRegex.exec(entry.content)) !== null) {
|
|
3342
|
+
const targetLabel = match[1].trim();
|
|
3343
|
+
const action = match[2].trim();
|
|
3344
|
+
const content = match[3].trim();
|
|
3345
|
+
const targetId = labelToId.get(targetLabel.toLowerCase());
|
|
3346
|
+
|
|
3347
|
+
if (targetId && targetId !== fromAgentId) {
|
|
3348
|
+
console.log(`[bus] Parsed marker from ${fromAgentId}: → ${targetLabel} (${action}): ${content.slice(0, 60)}`);
|
|
3349
|
+
this.bus.send(fromAgentId, targetId, 'notify', { action, content });
|
|
3350
|
+
}
|
|
3351
|
+
}
|
|
3352
|
+
}
|
|
3353
|
+
}
|
|
3354
|
+
|
|
3355
|
+
private saveNow(): void {
|
|
3356
|
+
saveWorkspace(this.getFullState()).catch(() => {});
|
|
3357
|
+
}
|
|
3358
|
+
|
|
3359
|
+
/** Emit agents_changed so SSE pushes the updated list to frontend */
|
|
3360
|
+
private emitAgentsChanged(): void {
|
|
3361
|
+
// Refresh topology cache so MCP queries always return current state
|
|
3362
|
+
this.rebuildTopo();
|
|
3363
|
+
const agents = Array.from(this.agents.values()).map(e => e.config);
|
|
3364
|
+
const agentStates = this.getAllAgentStates();
|
|
3365
|
+
this.emit('event', { type: 'agents_changed', agents, agentStates } satisfies WorkerEvent);
|
|
3366
|
+
}
|
|
3367
|
+
|
|
3368
|
+
private emitWorkspaceStatus(): void {
|
|
3369
|
+
let running = 0, done = 0;
|
|
3370
|
+
for (const [, entry] of this.agents) {
|
|
3371
|
+
const ws = entry.worker?.getState();
|
|
3372
|
+
const taskSt = ws?.taskStatus ?? entry.state.taskStatus;
|
|
3373
|
+
if (taskSt === 'running') running++;
|
|
3374
|
+
if (taskSt === 'done') done++;
|
|
3375
|
+
}
|
|
3376
|
+
this.emit('event', {
|
|
3377
|
+
type: 'workspace_status',
|
|
3378
|
+
running,
|
|
3379
|
+
done,
|
|
3380
|
+
total: this.agents.size,
|
|
3381
|
+
} satisfies OrchestratorEvent);
|
|
3382
|
+
}
|
|
3383
|
+
|
|
3384
|
+
/**
|
|
3385
|
+
* Update agent memory after execution completes.
|
|
3386
|
+
* Parses step results into structured memory entries.
|
|
3387
|
+
*/
|
|
3388
|
+
private async updateAgentMemory(agentId: string, config: WorkspaceAgentConfig, stepResults: string[]): Promise<void> {
|
|
3389
|
+
try {
|
|
3390
|
+
const entry = this.agents.get(agentId);
|
|
3391
|
+
|
|
3392
|
+
// Capture observation from the last step (previous steps captured in 'step' event handler)
|
|
3393
|
+
const lastStep = config.steps[config.steps.length - 1];
|
|
3394
|
+
const lastResult = stepResults[stepResults.length - 1];
|
|
3395
|
+
if (lastStep && lastResult) {
|
|
3396
|
+
const obs = parseStepToObservations(lastStep.label, lastResult, entry?.state.artifacts || []);
|
|
3397
|
+
for (const o of obs) {
|
|
3398
|
+
await addObservation(this.workspaceId, agentId, config.label, config.role, o);
|
|
3399
|
+
}
|
|
3400
|
+
}
|
|
3401
|
+
|
|
3402
|
+
// Add session summary
|
|
3403
|
+
const summary = buildSessionSummary(
|
|
3404
|
+
config.steps.map(s => s.label),
|
|
3405
|
+
stepResults,
|
|
3406
|
+
entry?.state.artifacts || [],
|
|
3407
|
+
);
|
|
3408
|
+
await addSessionSummary(this.workspaceId, agentId, summary);
|
|
3409
|
+
|
|
3410
|
+
console.log(`[workspace] Updated memory for ${config.label}`);
|
|
3411
|
+
} catch (err: any) {
|
|
3412
|
+
console.error(`[workspace] Failed to update memory for ${config.label}:`, err.message);
|
|
3413
|
+
}
|
|
3414
|
+
}
|
|
3415
|
+
}
|