crewly 1.12.1 → 1.13.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/config/skills/_common/complete-body-shape.test.sh +113 -0
- package/config/skills/_common/no-dangling-symlinks.test.sh +63 -0
- package/config/skills/_common/task-pool-body-shape.test.sh +471 -0
- package/config/skills/agent/core/accept-task/execute.sh +30 -5
- package/config/skills/agent/core/block-task/execute.sh +59 -6
- package/config/skills/agent/core/complete-task/SKILL.md +37 -5
- package/config/skills/agent/core/complete-task/execute.sh +40 -9
- package/config/skills/agent/core/complete-task/execute.test.sh +213 -0
- package/config/skills/agent/core/create-task/SKILL.md +33 -0
- package/config/skills/agent/core/create-task/execute.sh +82 -23
- package/config/skills/agent/core/delegate-task/execute.sh +46 -0
- package/config/skills/agent/core/get-team-norms/SKILL.md +1 -1
- package/config/skills/agent/core/get-team-norms/execute.sh +73 -2
- package/config/skills/agent/core/handoff-task/execute.sh +8 -1
- package/config/skills/agent/core/report-status/SKILL.md +26 -0
- package/config/skills/agent/core/report-status/execute.sh +40 -5
- package/config/skills/orchestrator/delegate-task/SKILL.md +1 -1
- package/config/skills/orchestrator/delegate-task/execute.sh +45 -11
- package/config/skills/orchestrator/delegate-task/execute.test.sh +153 -1
- package/config/skills/team-leader/aggregate-results/SKILL.md +1 -1
- package/config/skills/team-leader/decompose-goal/execute.sh +11 -16
- package/config/skills/team-leader/delegate-task/SKILL.md +14 -1
- package/config/skills/team-leader/delegate-task/execute.sh +132 -43
- package/config/skills/team-leader/delegate-task/execute.test.sh +180 -27
- package/config/vitest-exclude.constants.ts +56 -0
- package/dist/backend/backend/src/controllers/chat/chat.controller.d.ts.map +1 -1
- package/dist/backend/backend/src/controllers/chat/chat.controller.js +29 -7
- package/dist/backend/backend/src/controllers/chat/chat.controller.js.map +1 -1
- package/dist/backend/backend/src/controllers/cloud/cloud.controller.d.ts.map +1 -1
- package/dist/backend/backend/src/controllers/cloud/cloud.controller.js +1 -1
- package/dist/backend/backend/src/controllers/cloud/cloud.controller.js.map +1 -1
- package/dist/backend/backend/src/controllers/monitoring/terminal.controller.d.ts.map +1 -1
- package/dist/backend/backend/src/controllers/monitoring/terminal.controller.js +18 -3
- package/dist/backend/backend/src/controllers/monitoring/terminal.controller.js.map +1 -1
- package/dist/backend/backend/src/controllers/slack/slack.controller.d.ts.map +1 -1
- package/dist/backend/backend/src/controllers/slack/slack.controller.js +18 -15
- package/dist/backend/backend/src/controllers/slack/slack.controller.js.map +1 -1
- package/dist/backend/backend/src/controllers/task-pool/task-pool.controller.d.ts +6 -1
- package/dist/backend/backend/src/controllers/task-pool/task-pool.controller.d.ts.map +1 -1
- package/dist/backend/backend/src/controllers/task-pool/task-pool.controller.js +19 -10
- package/dist/backend/backend/src/controllers/task-pool/task-pool.controller.js.map +1 -1
- package/dist/backend/backend/src/index.d.ts.map +1 -1
- package/dist/backend/backend/src/index.js +20 -0
- package/dist/backend/backend/src/index.js.map +1 -1
- package/dist/backend/backend/src/scripts/stamp-build.d.ts +25 -0
- package/dist/backend/backend/src/scripts/stamp-build.d.ts.map +1 -0
- package/dist/backend/backend/src/scripts/stamp-build.js +48 -0
- package/dist/backend/backend/src/scripts/stamp-build.js.map +1 -0
- package/dist/backend/backend/src/services/agent/agent-registration.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/agent/agent-registration.service.js +33 -1
- package/dist/backend/backend/src/services/agent/agent-registration.service.js.map +1 -1
- package/dist/backend/backend/src/services/agent/crewly-agent/crewly-agent-external-runtime.service.d.ts +78 -2
- package/dist/backend/backend/src/services/agent/crewly-agent/crewly-agent-external-runtime.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/agent/crewly-agent/crewly-agent-external-runtime.service.js +248 -44
- package/dist/backend/backend/src/services/agent/crewly-agent/crewly-agent-external-runtime.service.js.map +1 -1
- package/dist/backend/backend/src/services/agent/crewly-agent/types.d.ts +51 -3
- package/dist/backend/backend/src/services/agent/crewly-agent/types.d.ts.map +1 -1
- package/dist/backend/backend/src/services/agent/crewly-agent/types.js +31 -1
- package/dist/backend/backend/src/services/agent/crewly-agent/types.js.map +1 -1
- package/dist/backend/backend/src/services/cloud/cloud-client.service.d.ts +5 -0
- package/dist/backend/backend/src/services/cloud/cloud-client.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/cloud/cloud-client.service.js +7 -0
- package/dist/backend/backend/src/services/cloud/cloud-client.service.js.map +1 -1
- package/dist/backend/backend/src/services/event-bus/event-to-workitem-bridge.service.d.ts +15 -0
- package/dist/backend/backend/src/services/event-bus/event-to-workitem-bridge.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/event-bus/event-to-workitem-bridge.service.js +40 -0
- package/dist/backend/backend/src/services/event-bus/event-to-workitem-bridge.service.js.map +1 -1
- package/dist/backend/backend/src/services/orchestrator/commitment-approval-guard.d.ts.map +1 -1
- package/dist/backend/backend/src/services/orchestrator/commitment-approval-guard.js +26 -0
- package/dist/backend/backend/src/services/orchestrator/commitment-approval-guard.js.map +1 -1
- package/dist/backend/backend/src/services/orchestrator/onboarding/materialize-team.d.ts.map +1 -1
- package/dist/backend/backend/src/services/orchestrator/onboarding/materialize-team.js +19 -3
- package/dist/backend/backend/src/services/orchestrator/onboarding/materialize-team.js.map +1 -1
- package/dist/backend/backend/src/services/orchestrator/orchestrator-status.service.d.ts +28 -3
- package/dist/backend/backend/src/services/orchestrator/orchestrator-status.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/orchestrator/orchestrator-status.service.js +65 -8
- package/dist/backend/backend/src/services/orchestrator/orchestrator-status.service.js.map +1 -1
- package/dist/backend/backend/src/services/reconciler/reconcile-rules.d.ts +214 -4
- package/dist/backend/backend/src/services/reconciler/reconcile-rules.d.ts.map +1 -1
- package/dist/backend/backend/src/services/reconciler/reconcile-rules.js +330 -48
- package/dist/backend/backend/src/services/reconciler/reconcile-rules.js.map +1 -1
- package/dist/backend/backend/src/services/reconciler/reconciler-data-provider.d.ts +13 -1
- package/dist/backend/backend/src/services/reconciler/reconciler-data-provider.d.ts.map +1 -1
- package/dist/backend/backend/src/services/reconciler/reconciler-data-provider.js +84 -5
- package/dist/backend/backend/src/services/reconciler/reconciler-data-provider.js.map +1 -1
- package/dist/backend/backend/src/services/reconciler/reconciler.service.d.ts +14 -0
- package/dist/backend/backend/src/services/reconciler/reconciler.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/reconciler/reconciler.service.js +77 -3
- package/dist/backend/backend/src/services/reconciler/reconciler.service.js.map +1 -1
- package/dist/backend/backend/src/services/task-pool/task-pool.service.d.ts +152 -7
- package/dist/backend/backend/src/services/task-pool/task-pool.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/task-pool/task-pool.service.js +268 -36
- package/dist/backend/backend/src/services/task-pool/task-pool.service.js.map +1 -1
- package/dist/backend/backend/src/services/v3/escalation-router.service.js +2 -2
- package/dist/backend/backend/src/services/v3/escalation-router.service.js.map +1 -1
- package/dist/backend/backend/src/services/v3/request-sla.subscriber.d.ts.map +1 -1
- package/dist/backend/backend/src/services/v3/request-sla.subscriber.js +39 -2
- package/dist/backend/backend/src/services/v3/request-sla.subscriber.js.map +1 -1
- package/dist/backend/backend/src/services/wiki/wiki-migrate.service.d.ts +3 -0
- package/dist/backend/backend/src/services/wiki/wiki-migrate.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/wiki/wiki-migrate.service.js +21 -10
- package/dist/backend/backend/src/services/wiki/wiki-migrate.service.js.map +1 -1
- package/dist/backend/backend/src/types/v2/index.d.ts +1 -1
- package/dist/backend/backend/src/types/v2/index.d.ts.map +1 -1
- package/dist/backend/backend/src/types/v2/index.js +1 -1
- package/dist/backend/backend/src/types/v2/index.js.map +1 -1
- package/dist/backend/backend/src/types/v2/work-item.types.d.ts +181 -1
- package/dist/backend/backend/src/types/v2/work-item.types.d.ts.map +1 -1
- package/dist/backend/backend/src/types/v2/work-item.types.js +104 -17
- package/dist/backend/backend/src/types/v2/work-item.types.js.map +1 -1
- package/dist/backend/backend/src/utils/build-provenance.d.ts +150 -0
- package/dist/backend/backend/src/utils/build-provenance.d.ts.map +1 -0
- package/dist/backend/backend/src/utils/build-provenance.js +211 -0
- package/dist/backend/backend/src/utils/build-provenance.js.map +1 -0
- package/dist/backend/backend/src/websocket/chat.gateway.d.ts.map +1 -1
- package/dist/backend/backend/src/websocket/chat.gateway.js +7 -1
- package/dist/backend/backend/src/websocket/chat.gateway.js.map +1 -1
- package/dist/backend/build-info.json +4 -0
- package/dist/backend/config/vitest-exclude.constants.d.ts +53 -0
- package/dist/backend/config/vitest-exclude.constants.d.ts.map +1 -0
- package/dist/backend/config/vitest-exclude.constants.js +55 -0
- package/dist/backend/config/vitest-exclude.constants.js.map +1 -0
- package/dist/cli/backend/src/services/agent/active-work-briefing.service.d.ts +538 -0
- package/dist/cli/backend/src/services/agent/active-work-briefing.service.d.ts.map +1 -0
- package/dist/cli/backend/src/services/agent/active-work-briefing.service.js +802 -0
- package/dist/cli/backend/src/services/agent/active-work-briefing.service.js.map +1 -0
- package/dist/cli/backend/src/services/agent/agent-heartbeat.service.d.ts +240 -0
- package/dist/cli/backend/src/services/agent/agent-heartbeat.service.d.ts.map +1 -0
- package/dist/cli/backend/src/services/agent/agent-heartbeat.service.js +524 -0
- package/dist/cli/backend/src/services/agent/agent-heartbeat.service.js.map +1 -0
- package/dist/cli/backend/src/services/agent/agent-registration.service.d.ts +629 -0
- package/dist/cli/backend/src/services/agent/agent-registration.service.d.ts.map +1 -0
- package/dist/cli/backend/src/services/agent/agent-registration.service.js +4548 -0
- package/dist/cli/backend/src/services/agent/agent-registration.service.js.map +1 -0
- package/dist/cli/backend/src/services/agent/agent-suspend.service.d.ts +118 -0
- package/dist/cli/backend/src/services/agent/agent-suspend.service.d.ts.map +1 -0
- package/dist/cli/backend/src/services/agent/agent-suspend.service.js +304 -0
- package/dist/cli/backend/src/services/agent/agent-suspend.service.js.map +1 -0
- package/dist/cli/backend/src/services/agent/auditor-scheduler.service.d.ts +196 -0
- package/dist/cli/backend/src/services/agent/auditor-scheduler.service.d.ts.map +1 -0
- package/dist/cli/backend/src/services/agent/auditor-scheduler.service.js +504 -0
- package/dist/cli/backend/src/services/agent/auditor-scheduler.service.js.map +1 -0
- package/dist/cli/backend/src/services/agent/claude-runtime.service.d.ts +97 -0
- package/dist/cli/backend/src/services/agent/claude-runtime.service.d.ts.map +1 -0
- package/dist/cli/backend/src/services/agent/claude-runtime.service.js +334 -0
- package/dist/cli/backend/src/services/agent/claude-runtime.service.js.map +1 -0
- package/dist/cli/backend/src/services/agent/codex-runtime.service.d.ts +47 -0
- package/dist/cli/backend/src/services/agent/codex-runtime.service.d.ts.map +1 -0
- package/dist/cli/backend/src/services/agent/codex-runtime.service.js +112 -0
- package/dist/cli/backend/src/services/agent/codex-runtime.service.js.map +1 -0
- package/dist/cli/backend/src/services/agent/context-window-monitor.service.d.ts +323 -0
- package/dist/cli/backend/src/services/agent/context-window-monitor.service.d.ts.map +1 -0
- package/dist/cli/backend/src/services/agent/context-window-monitor.service.js +1056 -0
- package/dist/cli/backend/src/services/agent/context-window-monitor.service.js.map +1 -0
- package/dist/cli/backend/src/services/agent/crewly-agent/crewly-agent-external-runtime.service.d.ts +198 -0
- package/dist/cli/backend/src/services/agent/crewly-agent/crewly-agent-external-runtime.service.d.ts.map +1 -0
- package/dist/cli/backend/src/services/agent/crewly-agent/crewly-agent-external-runtime.service.js +816 -0
- package/dist/cli/backend/src/services/agent/crewly-agent/crewly-agent-external-runtime.service.js.map +1 -0
- package/dist/cli/backend/src/services/agent/crewly-agent/in-process-log-buffer.d.ts +155 -0
- package/dist/cli/backend/src/services/agent/crewly-agent/in-process-log-buffer.d.ts.map +1 -0
- package/dist/cli/backend/src/services/agent/crewly-agent/in-process-log-buffer.js +272 -0
- package/dist/cli/backend/src/services/agent/crewly-agent/in-process-log-buffer.js.map +1 -0
- package/dist/cli/backend/src/services/agent/crewly-agent/in-process-runtime-registry.d.ts +91 -0
- package/dist/cli/backend/src/services/agent/crewly-agent/in-process-runtime-registry.d.ts.map +1 -0
- package/dist/cli/backend/src/services/agent/crewly-agent/in-process-runtime-registry.js +120 -0
- package/dist/cli/backend/src/services/agent/crewly-agent/in-process-runtime-registry.js.map +1 -0
- package/dist/cli/backend/src/services/agent/crewly-agent/types.d.ts +536 -0
- package/dist/cli/backend/src/services/agent/crewly-agent/types.d.ts.map +1 -0
- package/dist/cli/backend/src/services/agent/crewly-agent/types.js +282 -0
- package/dist/cli/backend/src/services/agent/crewly-agent/types.js.map +1 -0
- package/dist/cli/backend/src/services/agent/disk-cleanup.service.d.ts +100 -0
- package/dist/cli/backend/src/services/agent/disk-cleanup.service.d.ts.map +1 -0
- package/dist/cli/backend/src/services/agent/disk-cleanup.service.js +149 -0
- package/dist/cli/backend/src/services/agent/disk-cleanup.service.js.map +1 -0
- package/dist/cli/backend/src/services/agent/gemini-runtime.service.d.ts +130 -0
- package/dist/cli/backend/src/services/agent/gemini-runtime.service.d.ts.map +1 -0
- package/dist/cli/backend/src/services/agent/gemini-runtime.service.js +758 -0
- package/dist/cli/backend/src/services/agent/gemini-runtime.service.js.map +1 -0
- package/dist/cli/backend/src/services/agent/oauth-relogin-monitor.service.d.ts +216 -0
- package/dist/cli/backend/src/services/agent/oauth-relogin-monitor.service.d.ts.map +1 -0
- package/dist/cli/backend/src/services/agent/oauth-relogin-monitor.service.js +508 -0
- package/dist/cli/backend/src/services/agent/oauth-relogin-monitor.service.js.map +1 -0
- package/dist/cli/backend/src/services/agent/pty-activity-tracker.service.d.ts +113 -0
- package/dist/cli/backend/src/services/agent/pty-activity-tracker.service.d.ts.map +1 -0
- package/dist/cli/backend/src/services/agent/pty-activity-tracker.service.js +161 -0
- package/dist/cli/backend/src/services/agent/pty-activity-tracker.service.js.map +1 -0
- package/dist/cli/backend/src/services/agent/runtime-agent.service.abstract.d.ts +156 -0
- package/dist/cli/backend/src/services/agent/runtime-agent.service.abstract.d.ts.map +1 -0
- package/dist/cli/backend/src/services/agent/runtime-agent.service.abstract.js +641 -0
- package/dist/cli/backend/src/services/agent/runtime-agent.service.abstract.js.map +1 -0
- package/dist/cli/backend/src/services/agent/runtime-exit-monitor.service.d.ts +295 -0
- package/dist/cli/backend/src/services/agent/runtime-exit-monitor.service.d.ts.map +1 -0
- package/dist/cli/backend/src/services/agent/runtime-exit-monitor.service.js +1166 -0
- package/dist/cli/backend/src/services/agent/runtime-exit-monitor.service.js.map +1 -0
- package/dist/cli/backend/src/services/agent/runtime-service.factory.d.ts +71 -0
- package/dist/cli/backend/src/services/agent/runtime-service.factory.d.ts.map +1 -0
- package/dist/cli/backend/src/services/agent/runtime-service.factory.js +174 -0
- package/dist/cli/backend/src/services/agent/runtime-service.factory.js.map +1 -0
- package/dist/cli/backend/src/services/ai/prompt-builder.service.d.ts +435 -0
- package/dist/cli/backend/src/services/ai/prompt-builder.service.d.ts.map +1 -0
- package/dist/cli/backend/src/services/ai/prompt-builder.service.js +1331 -0
- package/dist/cli/backend/src/services/ai/prompt-builder.service.js.map +1 -0
- package/dist/cli/backend/src/services/ai/prompt-modules/capability-overlay.module.d.ts +33 -0
- package/dist/cli/backend/src/services/ai/prompt-modules/capability-overlay.module.d.ts.map +1 -0
- package/dist/cli/backend/src/services/ai/prompt-modules/capability-overlay.module.js +70 -0
- package/dist/cli/backend/src/services/ai/prompt-modules/capability-overlay.module.js.map +1 -0
- package/dist/cli/backend/src/services/ai/prompt-modules/communication.module.d.ts +100 -0
- package/dist/cli/backend/src/services/ai/prompt-modules/communication.module.d.ts.map +1 -0
- package/dist/cli/backend/src/services/ai/prompt-modules/communication.module.js +296 -0
- package/dist/cli/backend/src/services/ai/prompt-modules/communication.module.js.map +1 -0
- package/dist/cli/backend/src/services/ai/prompt-modules/decision-rights.module.d.ts +59 -0
- package/dist/cli/backend/src/services/ai/prompt-modules/decision-rights.module.d.ts.map +1 -0
- package/dist/cli/backend/src/services/ai/prompt-modules/decision-rights.module.js +87 -0
- package/dist/cli/backend/src/services/ai/prompt-modules/decision-rights.module.js.map +1 -0
- package/dist/cli/backend/src/services/ai/prompt-modules/default-execution-loop.module.d.ts +67 -0
- package/dist/cli/backend/src/services/ai/prompt-modules/default-execution-loop.module.d.ts.map +1 -0
- package/dist/cli/backend/src/services/ai/prompt-modules/default-execution-loop.module.js +84 -0
- package/dist/cli/backend/src/services/ai/prompt-modules/default-execution-loop.module.js.map +1 -0
- package/dist/cli/backend/src/services/ai/prompt-modules/domain-sop.module.d.ts +35 -0
- package/dist/cli/backend/src/services/ai/prompt-modules/domain-sop.module.d.ts.map +1 -0
- package/dist/cli/backend/src/services/ai/prompt-modules/domain-sop.module.js +50 -0
- package/dist/cli/backend/src/services/ai/prompt-modules/domain-sop.module.js.map +1 -0
- package/dist/cli/backend/src/services/ai/prompt-modules/expert-profile.module.d.ts +41 -0
- package/dist/cli/backend/src/services/ai/prompt-modules/expert-profile.module.d.ts.map +1 -0
- package/dist/cli/backend/src/services/ai/prompt-modules/expert-profile.module.js +95 -0
- package/dist/cli/backend/src/services/ai/prompt-modules/expert-profile.module.js.map +1 -0
- package/dist/cli/backend/src/services/ai/prompt-modules/identity.module.d.ts +28 -0
- package/dist/cli/backend/src/services/ai/prompt-modules/identity.module.d.ts.map +1 -0
- package/dist/cli/backend/src/services/ai/prompt-modules/identity.module.js +47 -0
- package/dist/cli/backend/src/services/ai/prompt-modules/identity.module.js.map +1 -0
- package/dist/cli/backend/src/services/ai/prompt-modules/lazy-anti-patterns.module.d.ts +65 -0
- package/dist/cli/backend/src/services/ai/prompt-modules/lazy-anti-patterns.module.d.ts.map +1 -0
- package/dist/cli/backend/src/services/ai/prompt-modules/lazy-anti-patterns.module.js +79 -0
- package/dist/cli/backend/src/services/ai/prompt-modules/lazy-anti-patterns.module.js.map +1 -0
- package/dist/cli/backend/src/services/ai/prompt-modules/learning-reference.module.d.ts +28 -0
- package/dist/cli/backend/src/services/ai/prompt-modules/learning-reference.module.d.ts.map +1 -0
- package/dist/cli/backend/src/services/ai/prompt-modules/learning-reference.module.js +55 -0
- package/dist/cli/backend/src/services/ai/prompt-modules/learning-reference.module.js.map +1 -0
- package/dist/cli/backend/src/services/ai/prompt-modules/lifecycle.module.d.ts +44 -0
- package/dist/cli/backend/src/services/ai/prompt-modules/lifecycle.module.d.ts.map +1 -0
- package/dist/cli/backend/src/services/ai/prompt-modules/lifecycle.module.js +120 -0
- package/dist/cli/backend/src/services/ai/prompt-modules/lifecycle.module.js.map +1 -0
- package/dist/cli/backend/src/services/ai/prompt-modules/markdown-file-module.d.ts +46 -0
- package/dist/cli/backend/src/services/ai/prompt-modules/markdown-file-module.d.ts.map +1 -0
- package/dist/cli/backend/src/services/ai/prompt-modules/markdown-file-module.js +44 -0
- package/dist/cli/backend/src/services/ai/prompt-modules/markdown-file-module.js.map +1 -0
- package/dist/cli/backend/src/services/ai/prompt-modules/memory-reference.module.d.ts +28 -0
- package/dist/cli/backend/src/services/ai/prompt-modules/memory-reference.module.d.ts.map +1 -0
- package/dist/cli/backend/src/services/ai/prompt-modules/memory-reference.module.js +46 -0
- package/dist/cli/backend/src/services/ai/prompt-modules/memory-reference.module.js.map +1 -0
- package/dist/cli/backend/src/services/ai/prompt-modules/mission-context.module.d.ts +60 -0
- package/dist/cli/backend/src/services/ai/prompt-modules/mission-context.module.d.ts.map +1 -0
- package/dist/cli/backend/src/services/ai/prompt-modules/mission-context.module.js +104 -0
- package/dist/cli/backend/src/services/ai/prompt-modules/mission-context.module.js.map +1 -0
- package/dist/cli/backend/src/services/ai/prompt-modules/project-reference.module.d.ts +29 -0
- package/dist/cli/backend/src/services/ai/prompt-modules/project-reference.module.d.ts.map +1 -0
- package/dist/cli/backend/src/services/ai/prompt-modules/project-reference.module.js +50 -0
- package/dist/cli/backend/src/services/ai/prompt-modules/project-reference.module.js.map +1 -0
- package/dist/cli/backend/src/services/ai/prompt-modules/prompt-assembly.service.d.ts +103 -0
- package/dist/cli/backend/src/services/ai/prompt-modules/prompt-assembly.service.d.ts.map +1 -0
- package/dist/cli/backend/src/services/ai/prompt-modules/prompt-assembly.service.js +383 -0
- package/dist/cli/backend/src/services/ai/prompt-modules/prompt-assembly.service.js.map +1 -0
- package/dist/cli/backend/src/services/ai/prompt-modules/recovery.module.d.ts +34 -0
- package/dist/cli/backend/src/services/ai/prompt-modules/recovery.module.d.ts.map +1 -0
- package/dist/cli/backend/src/services/ai/prompt-modules/recovery.module.js +96 -0
- package/dist/cli/backend/src/services/ai/prompt-modules/recovery.module.js.map +1 -0
- package/dist/cli/backend/src/services/ai/prompt-modules/request-contract.module.d.ts +106 -0
- package/dist/cli/backend/src/services/ai/prompt-modules/request-contract.module.d.ts.map +1 -0
- package/dist/cli/backend/src/services/ai/prompt-modules/request-contract.module.js +167 -0
- package/dist/cli/backend/src/services/ai/prompt-modules/request-contract.module.js.map +1 -0
- package/dist/cli/backend/src/services/ai/prompt-modules/risk-policy.module.d.ts +35 -0
- package/dist/cli/backend/src/services/ai/prompt-modules/risk-policy.module.d.ts.map +1 -0
- package/dist/cli/backend/src/services/ai/prompt-modules/risk-policy.module.js +50 -0
- package/dist/cli/backend/src/services/ai/prompt-modules/risk-policy.module.js.map +1 -0
- package/dist/cli/backend/src/services/ai/prompt-modules/role-boundary.module.d.ts +91 -0
- package/dist/cli/backend/src/services/ai/prompt-modules/role-boundary.module.d.ts.map +1 -0
- package/dist/cli/backend/src/services/ai/prompt-modules/role-boundary.module.js +411 -0
- package/dist/cli/backend/src/services/ai/prompt-modules/role-boundary.module.js.map +1 -0
- package/dist/cli/backend/src/services/ai/prompt-modules/skills-reference.module.d.ts +62 -0
- package/dist/cli/backend/src/services/ai/prompt-modules/skills-reference.module.d.ts.map +1 -0
- package/dist/cli/backend/src/services/ai/prompt-modules/skills-reference.module.js +199 -0
- package/dist/cli/backend/src/services/ai/prompt-modules/skills-reference.module.js.map +1 -0
- package/dist/cli/backend/src/services/ai/prompt-modules/sop-norm-distinction.module.d.ts +79 -0
- package/dist/cli/backend/src/services/ai/prompt-modules/sop-norm-distinction.module.d.ts.map +1 -0
- package/dist/cli/backend/src/services/ai/prompt-modules/sop-norm-distinction.module.js +118 -0
- package/dist/cli/backend/src/services/ai/prompt-modules/sop-norm-distinction.module.js.map +1 -0
- package/dist/cli/backend/src/services/ai/prompt-modules/soul.module.d.ts +102 -0
- package/dist/cli/backend/src/services/ai/prompt-modules/soul.module.d.ts.map +1 -0
- package/dist/cli/backend/src/services/ai/prompt-modules/soul.module.js +229 -0
- package/dist/cli/backend/src/services/ai/prompt-modules/soul.module.js.map +1 -0
- package/dist/cli/backend/src/services/ai/prompt-modules/team-norms.module.d.ts +61 -0
- package/dist/cli/backend/src/services/ai/prompt-modules/team-norms.module.d.ts.map +1 -0
- package/dist/cli/backend/src/services/ai/prompt-modules/team-norms.module.js +197 -0
- package/dist/cli/backend/src/services/ai/prompt-modules/team-norms.module.js.map +1 -0
- package/dist/cli/backend/src/services/ai/prompt-modules/team-reference.module.d.ts +54 -0
- package/dist/cli/backend/src/services/ai/prompt-modules/team-reference.module.d.ts.map +1 -0
- package/dist/cli/backend/src/services/ai/prompt-modules/team-reference.module.js +149 -0
- package/dist/cli/backend/src/services/ai/prompt-modules/team-reference.module.js.map +1 -0
- package/dist/cli/backend/src/services/ai/prompt-modules/user-profile-reference.module.d.ts +32 -0
- package/dist/cli/backend/src/services/ai/prompt-modules/user-profile-reference.module.d.ts.map +1 -0
- package/dist/cli/backend/src/services/ai/prompt-modules/user-profile-reference.module.js +69 -0
- package/dist/cli/backend/src/services/ai/prompt-modules/user-profile-reference.module.js.map +1 -0
- package/dist/cli/backend/src/services/ai/prompt-modules/working-memory.module.d.ts +91 -0
- package/dist/cli/backend/src/services/ai/prompt-modules/working-memory.module.d.ts.map +1 -0
- package/dist/cli/backend/src/services/ai/prompt-modules/working-memory.module.js +136 -0
- package/dist/cli/backend/src/services/ai/prompt-modules/working-memory.module.js.map +1 -0
- package/dist/cli/backend/src/services/ai/self-improvement/growth-areas.service.d.ts +81 -0
- package/dist/cli/backend/src/services/ai/self-improvement/growth-areas.service.d.ts.map +1 -0
- package/dist/cli/backend/src/services/ai/self-improvement/growth-areas.service.js +135 -0
- package/dist/cli/backend/src/services/ai/self-improvement/growth-areas.service.js.map +1 -0
- package/dist/cli/backend/src/services/chat-v2/chat-v2.service.d.ts +737 -0
- package/dist/cli/backend/src/services/chat-v2/chat-v2.service.d.ts.map +1 -0
- package/dist/cli/backend/src/services/chat-v2/chat-v2.service.js +1213 -0
- package/dist/cli/backend/src/services/chat-v2/chat-v2.service.js.map +1 -0
- package/dist/cli/backend/src/services/chat-v2/chat-v2.singleton.d.ts +27 -0
- package/dist/cli/backend/src/services/chat-v2/chat-v2.singleton.d.ts.map +1 -0
- package/dist/cli/backend/src/services/chat-v2/chat-v2.singleton.js +57 -0
- package/dist/cli/backend/src/services/chat-v2/chat-v2.singleton.js.map +1 -0
- package/dist/cli/backend/src/services/chat-v2/config.d.ts +100 -0
- package/dist/cli/backend/src/services/chat-v2/config.d.ts.map +1 -0
- package/dist/cli/backend/src/services/chat-v2/config.js +174 -0
- package/dist/cli/backend/src/services/chat-v2/config.js.map +1 -0
- package/dist/cli/backend/src/services/chat-v2/legacy-dto.utils.d.ts +118 -0
- package/dist/cli/backend/src/services/chat-v2/legacy-dto.utils.d.ts.map +1 -0
- package/dist/cli/backend/src/services/chat-v2/legacy-dto.utils.js +171 -0
- package/dist/cli/backend/src/services/chat-v2/legacy-dto.utils.js.map +1 -0
- package/dist/cli/backend/src/services/chat-v2/sqlite/channel.store.d.ts +188 -0
- package/dist/cli/backend/src/services/chat-v2/sqlite/channel.store.d.ts.map +1 -0
- package/dist/cli/backend/src/services/chat-v2/sqlite/channel.store.js +317 -0
- package/dist/cli/backend/src/services/chat-v2/sqlite/channel.store.js.map +1 -0
- package/dist/cli/backend/src/services/chat-v2/sqlite/chat-db.d.ts +119 -0
- package/dist/cli/backend/src/services/chat-v2/sqlite/chat-db.d.ts.map +1 -0
- package/dist/cli/backend/src/services/chat-v2/sqlite/chat-db.js +551 -0
- package/dist/cli/backend/src/services/chat-v2/sqlite/chat-db.js.map +1 -0
- package/dist/cli/backend/src/services/chat-v2/sqlite/message.store.d.ts +208 -0
- package/dist/cli/backend/src/services/chat-v2/sqlite/message.store.d.ts.map +1 -0
- package/dist/cli/backend/src/services/chat-v2/sqlite/message.store.js +405 -0
- package/dist/cli/backend/src/services/chat-v2/sqlite/message.store.js.map +1 -0
- package/dist/cli/backend/src/services/chat-v2/types.d.ts +341 -0
- package/dist/cli/backend/src/services/chat-v2/types.d.ts.map +1 -0
- package/dist/cli/backend/src/services/chat-v2/types.js +61 -0
- package/dist/cli/backend/src/services/chat-v2/types.js.map +1 -0
- package/dist/cli/backend/src/services/cloud/cloud-client.service.d.ts +5 -0
- package/dist/cli/backend/src/services/cloud/cloud-client.service.d.ts.map +1 -1
- package/dist/cli/backend/src/services/cloud/cloud-client.service.js +7 -0
- package/dist/cli/backend/src/services/cloud/cloud-client.service.js.map +1 -1
- package/dist/cli/backend/src/services/continuation/patterns/idle-patterns.d.ts +37 -0
- package/dist/cli/backend/src/services/continuation/patterns/idle-patterns.d.ts.map +1 -0
- package/dist/cli/backend/src/services/continuation/patterns/idle-patterns.js +63 -0
- package/dist/cli/backend/src/services/continuation/patterns/idle-patterns.js.map +1 -0
- package/dist/cli/backend/src/services/intent-task/intent-classifier.rules.d.ts +335 -0
- package/dist/cli/backend/src/services/intent-task/intent-classifier.rules.d.ts.map +1 -0
- package/dist/cli/backend/src/services/intent-task/intent-classifier.rules.js +421 -0
- package/dist/cli/backend/src/services/intent-task/intent-classifier.rules.js.map +1 -0
- package/dist/cli/backend/src/services/mcp-client.d.ts +233 -0
- package/dist/cli/backend/src/services/mcp-client.d.ts.map +1 -0
- package/dist/cli/backend/src/services/mcp-client.js +302 -0
- package/dist/cli/backend/src/services/mcp-client.js.map +1 -0
- package/dist/cli/backend/src/services/memory/mission-context.service.d.ts +168 -0
- package/dist/cli/backend/src/services/memory/mission-context.service.d.ts.map +1 -0
- package/dist/cli/backend/src/services/memory/mission-context.service.js +365 -0
- package/dist/cli/backend/src/services/memory/mission-context.service.js.map +1 -0
- package/dist/cli/backend/src/services/memory/session-memory.service.d.ts +197 -0
- package/dist/cli/backend/src/services/memory/session-memory.service.d.ts.map +1 -0
- package/dist/cli/backend/src/services/memory/session-memory.service.js +369 -0
- package/dist/cli/backend/src/services/memory/session-memory.service.js.map +1 -0
- package/dist/cli/backend/src/services/memory/user-profile.service.d.ts +77 -0
- package/dist/cli/backend/src/services/memory/user-profile.service.d.ts.map +1 -0
- package/dist/cli/backend/src/services/memory/user-profile.service.js +171 -0
- package/dist/cli/backend/src/services/memory/user-profile.service.js.map +1 -0
- package/dist/cli/backend/src/services/memory/working-memory.service.d.ts +232 -0
- package/dist/cli/backend/src/services/memory/working-memory.service.d.ts.map +1 -0
- package/dist/cli/backend/src/services/memory/working-memory.service.js +417 -0
- package/dist/cli/backend/src/services/memory/working-memory.service.js.map +1 -0
- package/dist/cli/backend/src/services/messaging/sub-agent-message-queue.service.d.ts +84 -0
- package/dist/cli/backend/src/services/messaging/sub-agent-message-queue.service.d.ts.map +1 -0
- package/dist/cli/backend/src/services/messaging/sub-agent-message-queue.service.js +136 -0
- package/dist/cli/backend/src/services/messaging/sub-agent-message-queue.service.js.map +1 -0
- package/dist/cli/backend/src/services/messaging/thread-status-queue.service.d.ts +358 -0
- package/dist/cli/backend/src/services/messaging/thread-status-queue.service.d.ts.map +1 -0
- package/dist/cli/backend/src/services/messaging/thread-status-queue.service.js +755 -0
- package/dist/cli/backend/src/services/messaging/thread-status-queue.service.js.map +1 -0
- package/dist/cli/backend/src/services/monitoring/token-usage.service.d.ts +269 -0
- package/dist/cli/backend/src/services/monitoring/token-usage.service.d.ts.map +1 -0
- package/dist/cli/backend/src/services/monitoring/token-usage.service.js +391 -0
- package/dist/cli/backend/src/services/monitoring/token-usage.service.js.map +1 -0
- package/dist/cli/backend/src/services/observability/agent-behavior-log.service.d.ts +132 -0
- package/dist/cli/backend/src/services/observability/agent-behavior-log.service.d.ts.map +1 -0
- package/dist/cli/backend/src/services/observability/agent-behavior-log.service.js +284 -0
- package/dist/cli/backend/src/services/observability/agent-behavior-log.service.js.map +1 -0
- package/dist/cli/backend/src/services/observability/agent-behavior-log.singleton.d.ts +70 -0
- package/dist/cli/backend/src/services/observability/agent-behavior-log.singleton.d.ts.map +1 -0
- package/dist/cli/backend/src/services/observability/agent-behavior-log.singleton.js +121 -0
- package/dist/cli/backend/src/services/observability/agent-behavior-log.singleton.js.map +1 -0
- package/dist/cli/backend/src/services/observability/agent-behavior-log.types.d.ts +130 -0
- package/dist/cli/backend/src/services/observability/agent-behavior-log.types.d.ts.map +1 -0
- package/dist/cli/backend/src/services/observability/agent-behavior-log.types.js +48 -0
- package/dist/cli/backend/src/services/observability/agent-behavior-log.types.js.map +1 -0
- package/dist/cli/backend/src/services/observability/observability-db.d.ts +84 -0
- package/dist/cli/backend/src/services/observability/observability-db.d.ts.map +1 -0
- package/dist/cli/backend/src/services/observability/observability-db.js +165 -0
- package/dist/cli/backend/src/services/observability/observability-db.js.map +1 -0
- package/dist/cli/backend/src/services/onboarding/content-approval.service.d.ts +124 -0
- package/dist/cli/backend/src/services/onboarding/content-approval.service.d.ts.map +1 -0
- package/dist/cli/backend/src/services/onboarding/content-approval.service.js +256 -0
- package/dist/cli/backend/src/services/onboarding/content-approval.service.js.map +1 -0
- package/dist/cli/backend/src/services/onboarding/content-approval.types.d.ts +80 -0
- package/dist/cli/backend/src/services/onboarding/content-approval.types.d.ts.map +1 -0
- package/dist/cli/backend/src/services/onboarding/content-approval.types.js +16 -0
- package/dist/cli/backend/src/services/onboarding/content-approval.types.js.map +1 -0
- package/dist/cli/backend/src/services/orc/orc-delivery-enforcer.service.d.ts +142 -0
- package/dist/cli/backend/src/services/orc/orc-delivery-enforcer.service.d.ts.map +1 -0
- package/dist/cli/backend/src/services/orc/orc-delivery-enforcer.service.js +265 -0
- package/dist/cli/backend/src/services/orc/orc-delivery-enforcer.service.js.map +1 -0
- package/dist/cli/backend/src/services/orchestrator/improvement-marker.service.d.ts +231 -0
- package/dist/cli/backend/src/services/orchestrator/improvement-marker.service.d.ts.map +1 -0
- package/dist/cli/backend/src/services/orchestrator/improvement-marker.service.js +353 -0
- package/dist/cli/backend/src/services/orchestrator/improvement-marker.service.js.map +1 -0
- package/dist/cli/backend/src/services/orchestrator/improvement-startup.service.d.ts +157 -0
- package/dist/cli/backend/src/services/orchestrator/improvement-startup.service.d.ts.map +1 -0
- package/dist/cli/backend/src/services/orchestrator/improvement-startup.service.js +483 -0
- package/dist/cli/backend/src/services/orchestrator/improvement-startup.service.js.map +1 -0
- package/dist/cli/backend/src/services/orchestrator/index.d.ts +16 -0
- package/dist/cli/backend/src/services/orchestrator/index.d.ts.map +1 -0
- package/dist/cli/backend/src/services/orchestrator/index.js +16 -0
- package/dist/cli/backend/src/services/orchestrator/index.js.map +1 -0
- package/dist/cli/backend/src/services/orchestrator/orchestrator-restart.service.d.ts +117 -0
- package/dist/cli/backend/src/services/orchestrator/orchestrator-restart.service.d.ts.map +1 -0
- package/dist/cli/backend/src/services/orchestrator/orchestrator-restart.service.js +300 -0
- package/dist/cli/backend/src/services/orchestrator/orchestrator-restart.service.js.map +1 -0
- package/dist/cli/backend/src/services/orchestrator/orchestrator-setup.service.d.ts +114 -0
- package/dist/cli/backend/src/services/orchestrator/orchestrator-setup.service.d.ts.map +1 -0
- package/dist/cli/backend/src/services/orchestrator/orchestrator-setup.service.js +195 -0
- package/dist/cli/backend/src/services/orchestrator/orchestrator-setup.service.js.map +1 -0
- package/dist/cli/backend/src/services/orchestrator/orchestrator-status.service.d.ts +94 -0
- package/dist/cli/backend/src/services/orchestrator/orchestrator-status.service.d.ts.map +1 -0
- package/dist/cli/backend/src/services/orchestrator/orchestrator-status.service.js +279 -0
- package/dist/cli/backend/src/services/orchestrator/orchestrator-status.service.js.map +1 -0
- package/dist/cli/backend/src/services/orchestrator/safe-restart.service.d.ts +192 -0
- package/dist/cli/backend/src/services/orchestrator/safe-restart.service.d.ts.map +1 -0
- package/dist/cli/backend/src/services/orchestrator/safe-restart.service.js +395 -0
- package/dist/cli/backend/src/services/orchestrator/safe-restart.service.js.map +1 -0
- package/dist/cli/backend/src/services/orchestrator/self-improvement.service.d.ts +201 -0
- package/dist/cli/backend/src/services/orchestrator/self-improvement.service.d.ts.map +1 -0
- package/dist/cli/backend/src/services/orchestrator/self-improvement.service.js +396 -0
- package/dist/cli/backend/src/services/orchestrator/self-improvement.service.js.map +1 -0
- package/dist/cli/backend/src/services/orchestrator/slack-bridge-lazy.d.ts +17 -0
- package/dist/cli/backend/src/services/orchestrator/slack-bridge-lazy.d.ts.map +1 -0
- package/dist/cli/backend/src/services/orchestrator/slack-bridge-lazy.js +30 -0
- package/dist/cli/backend/src/services/orchestrator/slack-bridge-lazy.js.map +1 -0
- package/dist/cli/backend/src/services/orchestrator/state-persistence.service.d.ts +249 -0
- package/dist/cli/backend/src/services/orchestrator/state-persistence.service.d.ts.map +1 -0
- package/dist/cli/backend/src/services/orchestrator/state-persistence.service.js +569 -0
- package/dist/cli/backend/src/services/orchestrator/state-persistence.service.js.map +1 -0
- package/dist/cli/backend/src/services/session/index.d.ts +47 -0
- package/dist/cli/backend/src/services/session/index.d.ts.map +1 -0
- package/dist/cli/backend/src/services/session/index.js +50 -0
- package/dist/cli/backend/src/services/session/index.js.map +1 -0
- package/dist/cli/backend/src/services/session/log-rotation.service.d.ts +163 -0
- package/dist/cli/backend/src/services/session/log-rotation.service.d.ts.map +1 -0
- package/dist/cli/backend/src/services/session/log-rotation.service.js +362 -0
- package/dist/cli/backend/src/services/session/log-rotation.service.js.map +1 -0
- package/dist/cli/backend/src/services/session/pty/index.d.ts +11 -0
- package/dist/cli/backend/src/services/session/pty/index.d.ts.map +1 -0
- package/dist/cli/backend/src/services/session/pty/index.js +11 -0
- package/dist/cli/backend/src/services/session/pty/index.js.map +1 -0
- package/dist/cli/backend/src/services/session/pty/pty-session-backend.d.ts +445 -0
- package/dist/cli/backend/src/services/session/pty/pty-session-backend.d.ts.map +1 -0
- package/dist/cli/backend/src/services/session/pty/pty-session-backend.js +804 -0
- package/dist/cli/backend/src/services/session/pty/pty-session-backend.js.map +1 -0
- package/dist/cli/backend/src/services/session/pty/pty-session.d.ts +252 -0
- package/dist/cli/backend/src/services/session/pty/pty-session.d.ts.map +1 -0
- package/dist/cli/backend/src/services/session/pty/pty-session.js +611 -0
- package/dist/cli/backend/src/services/session/pty/pty-session.js.map +1 -0
- package/dist/cli/backend/src/services/session/pty/pty-terminal-buffer.d.ts +275 -0
- package/dist/cli/backend/src/services/session/pty/pty-terminal-buffer.d.ts.map +1 -0
- package/dist/cli/backend/src/services/session/pty/pty-terminal-buffer.js +403 -0
- package/dist/cli/backend/src/services/session/pty/pty-terminal-buffer.js.map +1 -0
- package/dist/cli/backend/src/services/session/runtime-pid-registry.service.d.ts +86 -0
- package/dist/cli/backend/src/services/session/runtime-pid-registry.service.d.ts.map +1 -0
- package/dist/cli/backend/src/services/session/runtime-pid-registry.service.js +214 -0
- package/dist/cli/backend/src/services/session/runtime-pid-registry.service.js.map +1 -0
- package/dist/cli/backend/src/services/session/session-backend.factory.d.ts +127 -0
- package/dist/cli/backend/src/services/session/session-backend.factory.d.ts.map +1 -0
- package/dist/cli/backend/src/services/session/session-backend.factory.js +226 -0
- package/dist/cli/backend/src/services/session/session-backend.factory.js.map +1 -0
- package/dist/cli/backend/src/services/session/session-backend.interface.d.ts +390 -0
- package/dist/cli/backend/src/services/session/session-backend.interface.d.ts.map +1 -0
- package/dist/cli/backend/src/services/session/session-backend.interface.js +19 -0
- package/dist/cli/backend/src/services/session/session-backend.interface.js.map +1 -0
- package/dist/cli/backend/src/services/session/session-command-helper.d.ts +350 -0
- package/dist/cli/backend/src/services/session/session-command-helper.d.ts.map +1 -0
- package/dist/cli/backend/src/services/session/session-command-helper.js +810 -0
- package/dist/cli/backend/src/services/session/session-command-helper.js.map +1 -0
- package/dist/cli/backend/src/services/session/session-handoff.service.d.ts +307 -0
- package/dist/cli/backend/src/services/session/session-handoff.service.d.ts.map +1 -0
- package/dist/cli/backend/src/services/session/session-handoff.service.js +742 -0
- package/dist/cli/backend/src/services/session/session-handoff.service.js.map +1 -0
- package/dist/cli/backend/src/services/session/session-state-persistence.d.ts +231 -0
- package/dist/cli/backend/src/services/session/session-state-persistence.d.ts.map +1 -0
- package/dist/cli/backend/src/services/session/session-state-persistence.js +407 -0
- package/dist/cli/backend/src/services/session/session-state-persistence.js.map +1 -0
- package/dist/cli/backend/src/services/settings/role.service.d.ts +277 -0
- package/dist/cli/backend/src/services/settings/role.service.d.ts.map +1 -0
- package/dist/cli/backend/src/services/settings/role.service.js +728 -0
- package/dist/cli/backend/src/services/settings/role.service.js.map +1 -0
- package/dist/cli/backend/src/services/slack/cross-machine-message.service.d.ts +152 -0
- package/dist/cli/backend/src/services/slack/cross-machine-message.service.d.ts.map +1 -0
- package/dist/cli/backend/src/services/slack/cross-machine-message.service.js +354 -0
- package/dist/cli/backend/src/services/slack/cross-machine-message.service.js.map +1 -0
- package/dist/cli/backend/src/services/slack/slack-image.service.d.ts +114 -0
- package/dist/cli/backend/src/services/slack/slack-image.service.d.ts.map +1 -0
- package/dist/cli/backend/src/services/slack/slack-image.service.js +331 -0
- package/dist/cli/backend/src/services/slack/slack-image.service.js.map +1 -0
- package/dist/cli/backend/src/services/slack/slack-orchestrator-bridge.d.ts +499 -0
- package/dist/cli/backend/src/services/slack/slack-orchestrator-bridge.d.ts.map +1 -0
- package/dist/cli/backend/src/services/slack/slack-orchestrator-bridge.js +1928 -0
- package/dist/cli/backend/src/services/slack/slack-orchestrator-bridge.js.map +1 -0
- package/dist/cli/backend/src/services/slack/slack.service.d.ts +315 -0
- package/dist/cli/backend/src/services/slack/slack.service.d.ts.map +1 -0
- package/dist/cli/backend/src/services/slack/slack.service.js +1094 -0
- package/dist/cli/backend/src/services/slack/slack.service.js.map +1 -0
- package/dist/cli/backend/src/services/sop/sop.service.d.ts +364 -0
- package/dist/cli/backend/src/services/sop/sop.service.d.ts.map +1 -0
- package/dist/cli/backend/src/services/sop/sop.service.js +734 -0
- package/dist/cli/backend/src/services/sop/sop.service.js.map +1 -0
- package/dist/cli/backend/src/services/task-pool/task-pool.service.d.ts +152 -7
- package/dist/cli/backend/src/services/task-pool/task-pool.service.d.ts.map +1 -1
- package/dist/cli/backend/src/services/task-pool/task-pool.service.js +268 -36
- package/dist/cli/backend/src/services/task-pool/task-pool.service.js.map +1 -1
- package/dist/cli/backend/src/services/v3/escalation-router.service.d.ts +178 -0
- package/dist/cli/backend/src/services/v3/escalation-router.service.d.ts.map +1 -0
- package/dist/cli/backend/src/services/v3/escalation-router.service.js +692 -0
- package/dist/cli/backend/src/services/v3/escalation-router.service.js.map +1 -0
- package/dist/cli/backend/src/services/v3/kr-tracking.service.d.ts +200 -0
- package/dist/cli/backend/src/services/v3/kr-tracking.service.d.ts.map +1 -0
- package/dist/cli/backend/src/services/v3/kr-tracking.service.js +482 -0
- package/dist/cli/backend/src/services/v3/kr-tracking.service.js.map +1 -0
- package/dist/cli/backend/src/services/v3/mission-executor.service.d.ts +117 -0
- package/dist/cli/backend/src/services/v3/mission-executor.service.d.ts.map +1 -0
- package/dist/cli/backend/src/services/v3/mission-executor.service.js +321 -0
- package/dist/cli/backend/src/services/v3/mission-executor.service.js.map +1 -0
- package/dist/cli/backend/src/services/v3/request-sla.subscriber.d.ts +610 -0
- package/dist/cli/backend/src/services/v3/request-sla.subscriber.d.ts.map +1 -0
- package/dist/cli/backend/src/services/v3/request-sla.subscriber.js +1529 -0
- package/dist/cli/backend/src/services/v3/request-sla.subscriber.js.map +1 -0
- package/dist/cli/backend/src/services/v3/request-tracker.service.d.ts +78 -0
- package/dist/cli/backend/src/services/v3/request-tracker.service.d.ts.map +1 -0
- package/dist/cli/backend/src/services/v3/request-tracker.service.js +108 -0
- package/dist/cli/backend/src/services/v3/request-tracker.service.js.map +1 -0
- package/dist/cli/backend/src/services/v3/request.service.d.ts +295 -0
- package/dist/cli/backend/src/services/v3/request.service.d.ts.map +1 -0
- package/dist/cli/backend/src/services/v3/request.service.js +534 -0
- package/dist/cli/backend/src/services/v3/request.service.js.map +1 -0
- package/dist/cli/backend/src/services/v3/v3-data.service.d.ts +340 -0
- package/dist/cli/backend/src/services/v3/v3-data.service.d.ts.map +1 -0
- package/dist/cli/backend/src/services/v3/v3-data.service.js +1113 -0
- package/dist/cli/backend/src/services/v3/v3-data.service.js.map +1 -0
- package/dist/cli/backend/src/types/cross-machine.types.d.ts +103 -0
- package/dist/cli/backend/src/types/cross-machine.types.d.ts.map +1 -0
- package/dist/cli/backend/src/types/cross-machine.types.js +47 -0
- package/dist/cli/backend/src/types/cross-machine.types.js.map +1 -0
- package/dist/cli/backend/src/types/intent-task.types.d.ts +402 -0
- package/dist/cli/backend/src/types/intent-task.types.d.ts.map +1 -0
- package/dist/cli/backend/src/types/intent-task.types.js +403 -0
- package/dist/cli/backend/src/types/intent-task.types.js.map +1 -0
- package/dist/cli/backend/src/types/orchestrator-state.types.d.ts +531 -0
- package/dist/cli/backend/src/types/orchestrator-state.types.d.ts.map +1 -0
- package/dist/cli/backend/src/types/orchestrator-state.types.js +110 -0
- package/dist/cli/backend/src/types/orchestrator-state.types.js.map +1 -0
- package/dist/cli/backend/src/types/v2/index.d.ts +30 -0
- package/dist/cli/backend/src/types/v2/index.d.ts.map +1 -0
- package/dist/cli/backend/src/types/v2/index.js +22 -0
- package/dist/cli/backend/src/types/v2/index.js.map +1 -0
- package/dist/cli/backend/src/types/v2/key-result.types.d.ts +279 -0
- package/dist/cli/backend/src/types/v2/key-result.types.d.ts.map +1 -0
- package/dist/cli/backend/src/types/v2/key-result.types.js +264 -0
- package/dist/cli/backend/src/types/v2/key-result.types.js.map +1 -0
- package/dist/cli/backend/src/types/v2/mission.types.d.ts +661 -0
- package/dist/cli/backend/src/types/v2/mission.types.d.ts.map +1 -0
- package/dist/cli/backend/src/types/v2/mission.types.js +713 -0
- package/dist/cli/backend/src/types/v2/mission.types.js.map +1 -0
- package/dist/cli/backend/src/types/v2/reconcile.types.d.ts +204 -0
- package/dist/cli/backend/src/types/v2/reconcile.types.d.ts.map +1 -0
- package/dist/cli/backend/src/types/v2/reconcile.types.js +121 -0
- package/dist/cli/backend/src/types/v2/reconcile.types.js.map +1 -0
- package/dist/cli/backend/src/types/v2/request.types.d.ts +192 -0
- package/dist/cli/backend/src/types/v2/request.types.d.ts.map +1 -0
- package/dist/cli/backend/src/types/v2/request.types.js +179 -0
- package/dist/cli/backend/src/types/v2/request.types.js.map +1 -0
- package/dist/cli/backend/src/types/v2/work-item.types.d.ts +181 -1
- package/dist/cli/backend/src/types/v2/work-item.types.d.ts.map +1 -1
- package/dist/cli/backend/src/types/v2/work-item.types.js +104 -17
- package/dist/cli/backend/src/types/v2/work-item.types.js.map +1 -1
- package/dist/cli/backend/src/types/v2/workspace.types.d.ts +157 -0
- package/dist/cli/backend/src/types/v2/workspace.types.d.ts.map +1 -0
- package/dist/cli/backend/src/types/v2/workspace.types.js +173 -0
- package/dist/cli/backend/src/types/v2/workspace.types.js.map +1 -0
- package/dist/cli/backend/src/utils/async.utils.d.ts +68 -0
- package/dist/cli/backend/src/utils/async.utils.d.ts.map +1 -0
- package/dist/cli/backend/src/utils/async.utils.js +80 -0
- package/dist/cli/backend/src/utils/async.utils.js.map +1 -0
- package/dist/cli/backend/src/utils/native-binding.utils.d.ts +128 -0
- package/dist/cli/backend/src/utils/native-binding.utils.d.ts.map +1 -0
- package/dist/cli/backend/src/utils/native-binding.utils.js +206 -0
- package/dist/cli/backend/src/utils/native-binding.utils.js.map +1 -0
- package/dist/cli/backend/src/websocket/chat.gateway.d.ts +142 -0
- package/dist/cli/backend/src/websocket/chat.gateway.d.ts.map +1 -0
- package/dist/cli/backend/src/websocket/chat.gateway.js +327 -0
- package/dist/cli/backend/src/websocket/chat.gateway.js.map +1 -0
- package/dist/cli/backend/src/websocket/terminal.gateway.d.ts +341 -0
- package/dist/cli/backend/src/websocket/terminal.gateway.d.ts.map +1 -0
- package/dist/cli/backend/src/websocket/terminal.gateway.js +933 -0
- package/dist/cli/backend/src/websocket/terminal.gateway.js.map +1 -0
- package/dist/cli/config/vitest-exclude.constants.d.ts +53 -0
- package/dist/cli/config/vitest-exclude.constants.d.ts.map +1 -0
- package/dist/cli/config/vitest-exclude.constants.js +55 -0
- package/dist/cli/config/vitest-exclude.constants.js.map +1 -0
- package/package.json +2 -2
- package/packages/crewly-agent/src/cli.ts +16 -6
- package/packages/crewly-agent/src/runtime/agent-runner.service.test.ts +311 -33
- package/packages/crewly-agent/src/runtime/agent-runner.service.ts +221 -12
- package/packages/crewly-agent/src/runtime/tool-registry.test.ts +19 -0
- package/packages/crewly-agent/src/runtime/tool-registry.ts +4 -1
- package/packages/crewly-agent/src/runtime/types.ts +65 -5
|
@@ -0,0 +1,4548 @@
|
|
|
1
|
+
import * as path from 'path';
|
|
2
|
+
import * as os from 'os';
|
|
3
|
+
import { readFile, readdir, stat, mkdir, writeFile, access } from 'fs/promises';
|
|
4
|
+
import { existsSync } from 'fs';
|
|
5
|
+
import { LoggerService } from '../core/logger.service.js';
|
|
6
|
+
import { createSessionCommandHelper, getSessionBackendSync, createSessionBackend, getSessionStatePersistence, } from '../session/index.js';
|
|
7
|
+
import { RuntimeServiceFactory } from './runtime-service.factory.js';
|
|
8
|
+
import { registerInProcessRuntime, unregisterInProcessRuntime, } from './crewly-agent/in-process-runtime-registry.js';
|
|
9
|
+
import { getCrewlyHomePath } from '../core/crewly-home.utils.js';
|
|
10
|
+
import { CREWLY_CONSTANTS, ENV_CONSTANTS, AGENT_TIMEOUTS, ORCHESTRATOR_SESSION_NAME, ORCHESTRATOR_ROLE, RUNTIME_TYPES, SESSION_COMMAND_DELAYS, EVENT_DELIVERY_CONSTANTS, TERMINAL_PATTERNS, GEMINI_SHELL_MODE_CONSTANTS, GEMINI_STUCK_CONNECTIVITY_PATTERN, GEMINI_ERROR_STATE_CONSTANTS, } from '../../constants.js';
|
|
11
|
+
import { WEB_CONSTANTS } from '../../../../config/constants.js';
|
|
12
|
+
import { delay } from '../../utils/async.utils.js';
|
|
13
|
+
import { getSettingsService } from '../settings/settings.service.js';
|
|
14
|
+
import { SessionMemoryService } from '../memory/session-memory.service.js';
|
|
15
|
+
import { ActiveWorkBriefingService } from './active-work-briefing.service.js';
|
|
16
|
+
import { RuntimeExitMonitorService } from './runtime-exit-monitor.service.js';
|
|
17
|
+
import { ContextWindowMonitorService } from './context-window-monitor.service.js';
|
|
18
|
+
import { OAuthReloginMonitorService } from './oauth-relogin-monitor.service.js';
|
|
19
|
+
import { SubAgentMessageQueue } from '../messaging/sub-agent-message-queue.service.js';
|
|
20
|
+
import { AgentSuspendService } from './agent-suspend.service.js';
|
|
21
|
+
import { PromptBuilderService, buildModuleConfigFromTeamMember, } from '../ai/prompt-builder.service.js';
|
|
22
|
+
import { PromptAssemblyService } from '../ai/prompt-modules/prompt-assembly.service.js';
|
|
23
|
+
import { stripAnsiCodes } from '../../utils/terminal-output.utils.js';
|
|
24
|
+
import { isPromptLine, containsSpinnerOrWorkingIndicator, containsProcessingIndicator, containsBusyStatusBar, containsRewindMode, extractChatPrefix, stripTuiLineBorders, matchTuiPromptLine, } from '../../utils/terminal-string-ops.js';
|
|
25
|
+
import { PtyActivityTrackerService } from './pty-activity-tracker.service.js';
|
|
26
|
+
import { synthesizeSlackConversationId } from '../chat-v2/legacy-dto.utils.js';
|
|
27
|
+
/**
|
|
28
|
+
* Error thrown when an agent role cannot be resolved to a valid value.
|
|
29
|
+
*
|
|
30
|
+
* Indicates a member with no `role` field in storage, or a caller passing
|
|
31
|
+
* `undefined`/empty/literal-"undefined". Surfaces the failure loudly so it
|
|
32
|
+
* cannot manifest as the silent "empty Claude prompt" symptom (Bug #1).
|
|
33
|
+
*/
|
|
34
|
+
export class InvalidAgentRoleError extends Error {
|
|
35
|
+
received;
|
|
36
|
+
constructor(received, context) {
|
|
37
|
+
const ctx = context ? ` (${context})` : '';
|
|
38
|
+
const display = received === undefined ? 'undefined'
|
|
39
|
+
: received === null ? 'null'
|
|
40
|
+
: typeof received === 'string' ? `"${received}"`
|
|
41
|
+
: String(received);
|
|
42
|
+
super(`Invalid agent role${ctx}: received ${display}. Role must be a non-empty string ` +
|
|
43
|
+
`matching a directory under config/roles/. ` +
|
|
44
|
+
`Common cause: team member.role field missing or unset in storage.`);
|
|
45
|
+
this.received = received;
|
|
46
|
+
this.name = 'InvalidAgentRoleError';
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Validate that an agent role is a usable string before it flows into session
|
|
51
|
+
* creation, env-var injection, or prompt-template path resolution.
|
|
52
|
+
*
|
|
53
|
+
* Rejects: undefined, null, non-strings, empty/whitespace strings, and the
|
|
54
|
+
* literal string "undefined" (which results from JS coercion when an undefined
|
|
55
|
+
* is interpolated into a template literal — the exact failure mode behind
|
|
56
|
+
* `CREWLY_ROLE="undefined"` in tmux env).
|
|
57
|
+
*
|
|
58
|
+
* @param role - The candidate role value
|
|
59
|
+
* @param context - Optional caller context for the error message (e.g. "startTeamMember")
|
|
60
|
+
* @throws {InvalidAgentRoleError} when role is unusable
|
|
61
|
+
*/
|
|
62
|
+
export function validateAgentRole(role, context) {
|
|
63
|
+
if (typeof role !== 'string') {
|
|
64
|
+
throw new InvalidAgentRoleError(role, context);
|
|
65
|
+
}
|
|
66
|
+
const trimmed = role.trim();
|
|
67
|
+
if (trimmed.length === 0 || trimmed.toLowerCase() === 'undefined' || trimmed.toLowerCase() === 'null') {
|
|
68
|
+
throw new InvalidAgentRoleError(role, context);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Service responsible for the complex, multi-step process of agent initialization and registration.
|
|
73
|
+
* Isolates the complex state management of agent startup with progressive escalation.
|
|
74
|
+
*
|
|
75
|
+
* Key capabilities:
|
|
76
|
+
* - Agent session creation and management
|
|
77
|
+
* - Runtime initialization and registration
|
|
78
|
+
* - Reliable message delivery to Claude Code with retry logic
|
|
79
|
+
* - Health checking and status management
|
|
80
|
+
*/
|
|
81
|
+
export class AgentRegistrationService {
|
|
82
|
+
logger;
|
|
83
|
+
_sessionHelper = null;
|
|
84
|
+
storageService;
|
|
85
|
+
projectRoot;
|
|
86
|
+
// Prompt file caching to eliminate file I/O contention during concurrent session creation
|
|
87
|
+
promptCache = new Map();
|
|
88
|
+
// Session creation locks to prevent concurrent createAgentSession calls for the same session
|
|
89
|
+
sessionCreationLocks = new Map();
|
|
90
|
+
// AbortControllers for pending registration prompts (keyed by session name)
|
|
91
|
+
registrationAbortControllers = new Map();
|
|
92
|
+
// Background stuck-message detector timer
|
|
93
|
+
stuckMessageDetectorTimer = null;
|
|
94
|
+
// Track TUI sessions for the stuck-message detector (sessionName → runtimeType)
|
|
95
|
+
tuiSessionRegistry = new Map();
|
|
96
|
+
// Track recently-sent messages for background stuck-detection (all runtimes).
|
|
97
|
+
// Key: sessionName, Value: array of tracked message entries
|
|
98
|
+
sentMessageTracker = new Map();
|
|
99
|
+
// Per-session delivery mutex to serialize message delivery.
|
|
100
|
+
// Prevents concurrent sendMessageWithRetry calls to the same session,
|
|
101
|
+
// which causes multiple Ctrl+C presses that can crash the runtime.
|
|
102
|
+
sessionDeliveryMutex = new Map();
|
|
103
|
+
// Per-session hash of last sent message to prevent duplicate writes (#128).
|
|
104
|
+
// Key: sessionName, Value: { hash, sentAt }
|
|
105
|
+
lastSentMessageHash = new Map();
|
|
106
|
+
// In-process Crewly Agent runtimes (sessionName → runtime instance).
|
|
107
|
+
// Used for crewly-agent runtimeType agents that run without PTY sessions.
|
|
108
|
+
inProcessRuntimes = new Map();
|
|
109
|
+
/** #167: Optional scheduler service for DLQ drain on agent activation. */
|
|
110
|
+
schedulerService = null;
|
|
111
|
+
/** Architecture Upgrade Phase 6: EventBusService for standing task subscriptions. */
|
|
112
|
+
eventBusService = null;
|
|
113
|
+
// Terminal patterns are now centralized in TERMINAL_PATTERNS constant
|
|
114
|
+
// Keeping prompt chars as static getter for backwards compatibility within the class
|
|
115
|
+
static get CLAUDE_PROMPT_INDICATORS() {
|
|
116
|
+
return TERMINAL_PATTERNS.PROMPT_CHARS;
|
|
117
|
+
}
|
|
118
|
+
constructor(_legacyTmuxService, // Legacy parameter for backwards compatibility
|
|
119
|
+
projectRoot, storageService) {
|
|
120
|
+
this.logger = LoggerService.getInstance().createComponentLogger('AgentRegistrationService');
|
|
121
|
+
this.storageService = storageService;
|
|
122
|
+
this.projectRoot = projectRoot || this.findProjectRoot();
|
|
123
|
+
// Wire up the exit monitor to cancel pending registrations on runtime exit
|
|
124
|
+
RuntimeExitMonitorService.getInstance().setOnExitDetectedCallback((sessionName) => {
|
|
125
|
+
this.cancelPendingRegistration(sessionName);
|
|
126
|
+
this.unregisterTuiSession(sessionName);
|
|
127
|
+
// Preserve queued messages for suspended agents (they'll be delivered on rehydrate)
|
|
128
|
+
if (!AgentSuspendService.getInstance().isSuspended(sessionName)) {
|
|
129
|
+
SubAgentMessageQueue.getInstance().clear(sessionName);
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* #167: Inject scheduler service for dead-letter queue drain on agent activation.
|
|
135
|
+
*
|
|
136
|
+
* @param scheduler - Scheduler service with drainDeadLetterQueue method
|
|
137
|
+
*/
|
|
138
|
+
setSchedulerService(scheduler) {
|
|
139
|
+
this.schedulerService = scheduler;
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Inject EventBusService for standing task workflow subscriptions at agent registration.
|
|
143
|
+
*
|
|
144
|
+
* @param eventBus - EventBusService instance with subscribe method
|
|
145
|
+
*/
|
|
146
|
+
setEventBusService(eventBus) {
|
|
147
|
+
this.eventBusService = eventBus;
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Cancel a pending registration prompt for a session.
|
|
151
|
+
* Called by RuntimeExitMonitorService when a runtime exit is detected.
|
|
152
|
+
*
|
|
153
|
+
* @param sessionName - The session whose registration to cancel
|
|
154
|
+
*/
|
|
155
|
+
cancelPendingRegistration(sessionName) {
|
|
156
|
+
const controller = this.registrationAbortControllers.get(sessionName);
|
|
157
|
+
if (controller) {
|
|
158
|
+
controller.abort();
|
|
159
|
+
this.registrationAbortControllers.delete(sessionName);
|
|
160
|
+
this.logger.info('Cancelled pending registration', { sessionName });
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Get or create the session helper with lazy initialization
|
|
165
|
+
*/
|
|
166
|
+
async getSessionHelper() {
|
|
167
|
+
this.logger.debug('Getting session helper', {
|
|
168
|
+
hasExistingHelper: !!this._sessionHelper,
|
|
169
|
+
});
|
|
170
|
+
if (this._sessionHelper) {
|
|
171
|
+
return this._sessionHelper;
|
|
172
|
+
}
|
|
173
|
+
let backend = getSessionBackendSync();
|
|
174
|
+
this.logger.debug('Backend sync check', {
|
|
175
|
+
hasBackend: !!backend,
|
|
176
|
+
});
|
|
177
|
+
if (!backend) {
|
|
178
|
+
this.logger.info('Creating new PTY session backend');
|
|
179
|
+
backend = await createSessionBackend('pty');
|
|
180
|
+
this.logger.info('PTY session backend created', {
|
|
181
|
+
hasBackend: !!backend,
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
this._sessionHelper = createSessionCommandHelper(backend);
|
|
185
|
+
this.logger.debug('Session helper created');
|
|
186
|
+
return this._sessionHelper;
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Get session helper synchronously (may throw if not initialized)
|
|
190
|
+
*/
|
|
191
|
+
getSessionHelperSync() {
|
|
192
|
+
if (!this._sessionHelper) {
|
|
193
|
+
const backend = getSessionBackendSync();
|
|
194
|
+
if (!backend) {
|
|
195
|
+
throw new Error('Session backend not initialized');
|
|
196
|
+
}
|
|
197
|
+
this._sessionHelper = createSessionCommandHelper(backend);
|
|
198
|
+
}
|
|
199
|
+
return this._sessionHelper;
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Resolve the runtime type for a session from storage.
|
|
203
|
+
* Checks orchestrator status first, then team member data.
|
|
204
|
+
* Falls back to CLAUDE_CODE if nothing is found.
|
|
205
|
+
*
|
|
206
|
+
* IMPORTANT: Callers should prefer passing runtimeType explicitly.
|
|
207
|
+
* This method exists as a safety net so that Ctrl+C (Claude Code
|
|
208
|
+
* cleanup) is never sent to a Gemini CLI session, which would
|
|
209
|
+
* trigger /quit and kill the agent.
|
|
210
|
+
*/
|
|
211
|
+
async resolveSessionRuntimeType(sessionName) {
|
|
212
|
+
try {
|
|
213
|
+
// Check if this is the orchestrator session
|
|
214
|
+
if (sessionName === ORCHESTRATOR_SESSION_NAME) {
|
|
215
|
+
const orchestratorStatus = await this.storageService.getOrchestratorStatus();
|
|
216
|
+
if (orchestratorStatus?.runtimeType) {
|
|
217
|
+
return orchestratorStatus.runtimeType;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
// Check team member data
|
|
221
|
+
const memberInfo = await this.storageService.findMemberBySessionName(sessionName);
|
|
222
|
+
if (memberInfo?.member?.runtimeType) {
|
|
223
|
+
return memberInfo.member.runtimeType;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
catch (error) {
|
|
227
|
+
this.logger.debug('Could not resolve runtime type from storage, using default', {
|
|
228
|
+
sessionName,
|
|
229
|
+
error: error instanceof Error ? error.message : String(error),
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
// Check in-process runtimes before defaulting to CLAUDE_CODE.
|
|
233
|
+
// crewly-agent runtimes may not have a team config entry yet.
|
|
234
|
+
if (this.inProcessRuntimes.has(sessionName)) {
|
|
235
|
+
return RUNTIME_TYPES.CREWLY_AGENT;
|
|
236
|
+
}
|
|
237
|
+
return RUNTIME_TYPES.CLAUDE_CODE;
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* Route a crewly-agent in-process response to the originating chat conversation.
|
|
241
|
+
*
|
|
242
|
+
* PTY-based runtimes emit [NOTIFY] markers that the terminal gateway parses.
|
|
243
|
+
* In-process crewly-agents have no terminal output, so this method provides
|
|
244
|
+
* the equivalent routing for their text responses.
|
|
245
|
+
*
|
|
246
|
+
* @param sessionName - Agent session that produced the response
|
|
247
|
+
* @param text - Response text from the agent
|
|
248
|
+
* @param conversationId - Chat conversation to route the response to
|
|
249
|
+
*/
|
|
250
|
+
routeInProcessResponseToChat(sessionName, text, conversationId) {
|
|
251
|
+
// LEGACY PATH — chatGateway.processNotifyMessage → ChatService
|
|
252
|
+
// (JSON files in ~/.crewly/chat/). Kept until Phase 5 data
|
|
253
|
+
// migration retires the JSON store; until then the legacy file
|
|
254
|
+
// is what the frontend chat-v1 readers consume.
|
|
255
|
+
import('../../websocket/chat.gateway.js')
|
|
256
|
+
.then(({ getChatGateway }) => {
|
|
257
|
+
const chatGateway = getChatGateway();
|
|
258
|
+
if (!chatGateway) {
|
|
259
|
+
this.logger.debug('ChatGateway not available, skipping response routing', {
|
|
260
|
+
sessionName, conversationId,
|
|
261
|
+
});
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
// No metadata override here: processNotifyMessage already tags the
|
|
265
|
+
// turn `source: 'in-process-runtime'` — the canonical member of the
|
|
266
|
+
// closed RECORD_TURN_SOURCES enum for exactly this path. The old
|
|
267
|
+
// `'crewly-agent'` value was spread OVER that correct default and
|
|
268
|
+
// then failed enum validation, so every in-process reply to a web
|
|
269
|
+
// conversation was rejected with "unknown metadata.source" and
|
|
270
|
+
// silently dropped after the agent had already done the work.
|
|
271
|
+
return chatGateway.processNotifyMessage(sessionName, text, conversationId);
|
|
272
|
+
})
|
|
273
|
+
.then((chatMessage) => {
|
|
274
|
+
if (chatMessage) {
|
|
275
|
+
this.logger.debug('Routed in-process agent response to chat (legacy)', {
|
|
276
|
+
sessionName, conversationId,
|
|
277
|
+
messageId: typeof chatMessage === 'object' ? chatMessage.id : undefined,
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
})
|
|
281
|
+
.catch((err) => {
|
|
282
|
+
this.logger.warn('Failed to route in-process agent response to chat', {
|
|
283
|
+
sessionName, conversationId,
|
|
284
|
+
error: err instanceof Error ? err.message : String(err),
|
|
285
|
+
});
|
|
286
|
+
});
|
|
287
|
+
// Phase 6β of unified-chat-message-store spec: the explicit
|
|
288
|
+
// chat-v2 recordTurn call that lived here during Phases 2-3
|
|
289
|
+
// was removed because `ChatService` is now a thin façade over
|
|
290
|
+
// `ChatV2Service` — the `processNotifyMessage` call above
|
|
291
|
+
// already writes through to chat-v2 internally. Keeping both
|
|
292
|
+
// paths would produce duplicate rows.
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* Auto-route an in-process agent's text response back to Slack.
|
|
296
|
+
*
|
|
297
|
+
* Mirrors the post-send side effects of POST /slack/send so that the
|
|
298
|
+
* Slack reply, thread-status bookkeeping, and V3 SLA cascade all stay in
|
|
299
|
+
* sync. Used as a finish-event hook for the in-process Crewly Agent
|
|
300
|
+
* runtime when:
|
|
301
|
+
* 1. The inbound message carried a `[SLACK:channelId:threadTs]` marker
|
|
302
|
+
* 2. The agent produced text output (`result.text` non-empty)
|
|
303
|
+
* 3. The agent did NOT explicitly call the `reply_slack` tool
|
|
304
|
+
*
|
|
305
|
+
* Without this hook, AI SDK agents that finish with `toolCalls=0,
|
|
306
|
+
* finishReason=stop` would drop their reply on the floor — unlike the
|
|
307
|
+
* claude-code PTY path, the in-process runtime has no stdout stream the
|
|
308
|
+
* SlackBridge can tail.
|
|
309
|
+
*
|
|
310
|
+
* Best-effort: a Slack-not-connected condition or a downstream
|
|
311
|
+
* bookkeeping failure is logged and swallowed; never throws. The chat
|
|
312
|
+
* route (`routeInProcessResponseToChat`) handles persistence to the chat
|
|
313
|
+
* service separately.
|
|
314
|
+
*
|
|
315
|
+
* @param sessionName - Agent session that produced the response
|
|
316
|
+
* @param text - Response text from the agent
|
|
317
|
+
* @param slackContext - Slack channel + thread metadata captured from the
|
|
318
|
+
* inbound `[SLACK:channelId:threadTs]` marker
|
|
319
|
+
*/
|
|
320
|
+
routeInProcessResponseToSlack(sessionName, text, slackContext) {
|
|
321
|
+
// Lazy import to avoid module-load circular dependency between
|
|
322
|
+
// agent-registration ↔ slack ↔ messaging ↔ v3.
|
|
323
|
+
import('../slack/slack.service.js')
|
|
324
|
+
.then(async ({ getSlackService }) => {
|
|
325
|
+
const slack = getSlackService();
|
|
326
|
+
if (!slack.isConnected()) {
|
|
327
|
+
this.logger.warn('Slack not connected — cannot auto-route in-process agent response', {
|
|
328
|
+
sessionName,
|
|
329
|
+
channelId: slackContext.channelId,
|
|
330
|
+
threadTs: slackContext.threadTs,
|
|
331
|
+
textLength: text.length,
|
|
332
|
+
});
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
// Auto-prefix with the agent display name (mirrors reply_slack tool
|
|
336
|
+
// behavior) so the message is attributable in the Slack thread.
|
|
337
|
+
// Skip if the text already starts with `[` — the agent supplied its
|
|
338
|
+
// own prefix.
|
|
339
|
+
let cleanText = text;
|
|
340
|
+
if (sessionName && !cleanText.startsWith('[')) {
|
|
341
|
+
const parts = sessionName.split('-');
|
|
342
|
+
const namePart = parts.length >= 3 ? parts[2] : parts[parts.length - 1];
|
|
343
|
+
if (namePart) {
|
|
344
|
+
const capitalized = namePart.charAt(0).toUpperCase() + namePart.slice(1);
|
|
345
|
+
cleanText = `[${capitalized}] ${cleanText}`;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
await slack.sendMessage({
|
|
349
|
+
channelId: slackContext.channelId,
|
|
350
|
+
text: cleanText,
|
|
351
|
+
threadTs: slackContext.threadTs,
|
|
352
|
+
});
|
|
353
|
+
this.logger.info('Auto-routed in-process agent response to Slack', {
|
|
354
|
+
sessionName,
|
|
355
|
+
channelId: slackContext.channelId,
|
|
356
|
+
threadTs: slackContext.threadTs,
|
|
357
|
+
textLength: cleanText.length,
|
|
358
|
+
});
|
|
359
|
+
// Mirror /slack/send bookkeeping #4 — clear the pending-delivery
|
|
360
|
+
// reminder OrcDeliveryEnforcer holds for this thread. This is the one
|
|
361
|
+
// side-effect the auto-route never mirrored, and the gap is
|
|
362
|
+
// user-visible: when the agent answers with plain text instead of
|
|
363
|
+
// calling the reply_slack tool, the reply reaches Slack but the
|
|
364
|
+
// ledger entry survives, so the enforcer nudges again at 10 min and
|
|
365
|
+
// again at 30 min — and the agent posts the same answer each time.
|
|
366
|
+
// SlackService's dedup window is 30s, far too short to absorb
|
|
367
|
+
// reminders spaced minutes apart, so every nudge lands in the thread
|
|
368
|
+
// as a visible duplicate.
|
|
369
|
+
if (slackContext.threadTs) {
|
|
370
|
+
try {
|
|
371
|
+
const { OrcDeliveryEnforcerService } = await import('../orc/orc-delivery-enforcer.service.js');
|
|
372
|
+
OrcDeliveryEnforcerService.getInstance()?.markDelivered({
|
|
373
|
+
channelId: slackContext.channelId,
|
|
374
|
+
threadTs: slackContext.threadTs,
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
catch (err) {
|
|
378
|
+
this.logger.debug('Failed to clear pending delivery after auto-route (non-fatal)', {
|
|
379
|
+
sessionName,
|
|
380
|
+
error: err instanceof Error ? err.message : String(err),
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
// Mirror /slack/send bookkeeping #3 — record `agent.action`
|
|
385
|
+
// with actionType='send_slack' so the auditor view and any
|
|
386
|
+
// V3 downstream analysis see the auto-routed reply. Without
|
|
387
|
+
// this entry the in-process auto-route is invisible in the
|
|
388
|
+
// behavior log (only `/slack/send`-originated replies show
|
|
389
|
+
// up). Follow-up #9 from PR #543 review.
|
|
390
|
+
try {
|
|
391
|
+
const { getAgentBehaviorLogService } = await import('../observability/agent-behavior-log.singleton.js');
|
|
392
|
+
getAgentBehaviorLogService()?.record({
|
|
393
|
+
type: 'agent.action',
|
|
394
|
+
agent: sessionName,
|
|
395
|
+
actionType: 'send_slack',
|
|
396
|
+
details: {
|
|
397
|
+
channelId: slackContext.channelId,
|
|
398
|
+
threadTs: slackContext.threadTs ?? null,
|
|
399
|
+
textLength: cleanText.length,
|
|
400
|
+
source: 'in_process_auto_route',
|
|
401
|
+
},
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
catch (err) {
|
|
405
|
+
this.logger.debug('Failed to record send_slack agent.action (non-fatal)', {
|
|
406
|
+
sessionName,
|
|
407
|
+
error: err instanceof Error ? err.message : String(err),
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
// Mirror /slack/send bookkeeping #1 — mark thread as
|
|
411
|
+
// replied_completed so the recovery loop on the next restart
|
|
412
|
+
// does not re-enqueue this inbound message. Without this, every
|
|
413
|
+
// backend restart would cause orc to re-reply.
|
|
414
|
+
//
|
|
415
|
+
// Race-protection: build the thread-status entry on the fly if
|
|
416
|
+
// it isn't tracked yet. The Slack listener's `trackInbound` is
|
|
417
|
+
// what normally populates the entry, but in a startup-race
|
|
418
|
+
// scenario the agent finish event can land before the listener
|
|
419
|
+
// has persisted the inbound. Without this fallback,
|
|
420
|
+
// `markReplied` short-circuits and the recovery loop on the
|
|
421
|
+
// next boot still re-fires the inbound (the exact bug
|
|
422
|
+
// /slack/send already guards against — keep parity).
|
|
423
|
+
if (slackContext.threadTs) {
|
|
424
|
+
try {
|
|
425
|
+
const { ThreadStatusQueueService } = await import('../messaging/thread-status-queue.service.js');
|
|
426
|
+
const tsq = ThreadStatusQueueService.getInstance();
|
|
427
|
+
const threadKey = `${slackContext.channelId}:${slackContext.threadTs}`;
|
|
428
|
+
if (!tsq.get(threadKey)) {
|
|
429
|
+
tsq.trackInbound({
|
|
430
|
+
threadKey,
|
|
431
|
+
conversationId: synthesizeSlackConversationId(slackContext.channelId, slackContext.threadTs),
|
|
432
|
+
source: 'slack',
|
|
433
|
+
messagePreview: '[in-process auto-route — no inbound recorded]',
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
tsq.markReplied(threadKey, 'replied_completed');
|
|
437
|
+
}
|
|
438
|
+
catch (err) {
|
|
439
|
+
this.logger.debug('Failed to update thread-status after auto-route (non-fatal)', {
|
|
440
|
+
sessionName,
|
|
441
|
+
error: err instanceof Error ? err.message : String(err),
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
// Mirror /slack/send bookkeeping #2 — fire the V3 SLA cascade so
|
|
446
|
+
// the matching Request transitions out of `queued`. Without
|
|
447
|
+
// this, requests whose acknowledgement came via this auto-route
|
|
448
|
+
// would never reach a terminal state.
|
|
449
|
+
if (slackContext.threadTs) {
|
|
450
|
+
try {
|
|
451
|
+
const { getRequestSlaSubscriber } = await import('../v3/request-sla.subscriber.js');
|
|
452
|
+
const sub = getRequestSlaSubscriber();
|
|
453
|
+
if (sub) {
|
|
454
|
+
await sub.markResolvedByThread(slackContext.threadTs);
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
catch (err) {
|
|
458
|
+
this.logger.debug('Failed to fire SLA cascade after auto-route (non-fatal)', {
|
|
459
|
+
sessionName,
|
|
460
|
+
error: err instanceof Error ? err.message : String(err),
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
})
|
|
465
|
+
.catch((err) => {
|
|
466
|
+
this.logger.warn('Failed to auto-route in-process agent response to Slack', {
|
|
467
|
+
sessionName,
|
|
468
|
+
channelId: slackContext.channelId,
|
|
469
|
+
threadTs: slackContext.threadTs,
|
|
470
|
+
error: err instanceof Error ? err.message : String(err),
|
|
471
|
+
});
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
/**
|
|
475
|
+
* Find the project root by looking for package.json
|
|
476
|
+
*/
|
|
477
|
+
findProjectRoot() {
|
|
478
|
+
// Resolve from the compiled dist/ location to the package root (#209)
|
|
479
|
+
// dist/backend/src/services/agent/ → root (5 levels up)
|
|
480
|
+
// This works for both local dev and npm global installs
|
|
481
|
+
try {
|
|
482
|
+
// Use __dirname equivalent: path.dirname(fileURLToPath(import.meta.url))
|
|
483
|
+
// But since Jest doesn't support import.meta, we use the module path directly
|
|
484
|
+
const candidates = [
|
|
485
|
+
// From source: backend/src/services/agent/ → root
|
|
486
|
+
path.resolve(__dirname, '..', '..', '..', '..'),
|
|
487
|
+
// From compiled dist: dist/backend/src/services/agent/ → root
|
|
488
|
+
path.resolve(__dirname, '..', '..', '..', '..', '..'),
|
|
489
|
+
];
|
|
490
|
+
for (const candidate of candidates) {
|
|
491
|
+
if (existsSync(path.join(candidate, 'config', 'skills'))) {
|
|
492
|
+
return candidate;
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
catch {
|
|
497
|
+
// Path resolution failed — fall back
|
|
498
|
+
}
|
|
499
|
+
return process.cwd();
|
|
500
|
+
}
|
|
501
|
+
/**
|
|
502
|
+
* Resolve runtime flags from the agent's effective skills.
|
|
503
|
+
* Reads role config and skill configs to collect flags compatible with the runtime.
|
|
504
|
+
*
|
|
505
|
+
* @param role - The agent's role name
|
|
506
|
+
* @param runtimeType - The agent's runtime type (e.g. 'claude-code')
|
|
507
|
+
* @param skillOverrides - Additional skills assigned to the member
|
|
508
|
+
* @param excludedRoleSkills - Skills excluded from the role's default set
|
|
509
|
+
* @returns Array of CLI flags to inject (e.g. ['--chrome'])
|
|
510
|
+
*/
|
|
511
|
+
async resolveRuntimeFlags(role, runtimeType, skillOverrides, excludedRoleSkills) {
|
|
512
|
+
const flags = new Set();
|
|
513
|
+
try {
|
|
514
|
+
// 1. Get role's assigned skills from config/roles/{role}/role.json
|
|
515
|
+
const roleName = role.toLowerCase().replace(/\s+/g, '-');
|
|
516
|
+
const rolePath = path.join(this.projectRoot, 'config', 'roles', roleName, 'role.json');
|
|
517
|
+
const roleContent = await readFile(rolePath, 'utf8');
|
|
518
|
+
const roleConfig = JSON.parse(roleContent);
|
|
519
|
+
const roleSkills = roleConfig.assignedSkills || [];
|
|
520
|
+
// 2. Apply skill overrides and exclusions
|
|
521
|
+
const effectiveSkills = new Set([...roleSkills, ...(skillOverrides || [])]);
|
|
522
|
+
for (const excluded of (excludedRoleSkills || [])) {
|
|
523
|
+
effectiveSkills.delete(excluded);
|
|
524
|
+
}
|
|
525
|
+
// 3. For each skill, read skill.json and collect flags matching runtime.
|
|
526
|
+
// Skills live under agent/core/ or agent/marketplace/ subdirectories,
|
|
527
|
+
// so search both paths when looking up skill.json.
|
|
528
|
+
for (const skillId of effectiveSkills) {
|
|
529
|
+
try {
|
|
530
|
+
const skillJsonPath = await this.findSkillJsonPath(skillId);
|
|
531
|
+
if (!skillJsonPath) {
|
|
532
|
+
this.logger.debug('Skill config not found in any known directory (no runtime flags)', { skillId });
|
|
533
|
+
continue;
|
|
534
|
+
}
|
|
535
|
+
const skillContent = await readFile(skillJsonPath, 'utf8');
|
|
536
|
+
const skillConfig = JSON.parse(skillContent);
|
|
537
|
+
if (skillConfig.runtime?.runtime === runtimeType && Array.isArray(skillConfig.runtime?.flags)) {
|
|
538
|
+
for (const flag of skillConfig.runtime.flags) {
|
|
539
|
+
flags.add(flag);
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
catch (skillErr) {
|
|
544
|
+
this.logger.warn('Failed to read skill config', {
|
|
545
|
+
skillId,
|
|
546
|
+
error: skillErr instanceof Error ? skillErr.message : String(skillErr),
|
|
547
|
+
});
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
if (flags.size > 0) {
|
|
551
|
+
this.logger.info('Resolved runtime flags from skills', {
|
|
552
|
+
role, runtimeType, flags: Array.from(flags),
|
|
553
|
+
});
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
catch (error) {
|
|
557
|
+
this.logger.debug('Could not resolve runtime flags (no role config or read error)', {
|
|
558
|
+
role, runtimeType, error: error instanceof Error ? error.message : String(error),
|
|
559
|
+
});
|
|
560
|
+
}
|
|
561
|
+
return Array.from(flags);
|
|
562
|
+
}
|
|
563
|
+
/**
|
|
564
|
+
* Search known skill directories for a skill's skill.json file.
|
|
565
|
+
* Skills live under `config/skills/agent/core/` or `config/skills/agent/marketplace/`.
|
|
566
|
+
*
|
|
567
|
+
* @param skillId - The skill identifier (directory name)
|
|
568
|
+
* @returns Absolute path to skill.json, or null if not found
|
|
569
|
+
*/
|
|
570
|
+
async findSkillJsonPath(skillId) {
|
|
571
|
+
const searchDirs = [
|
|
572
|
+
path.join(this.projectRoot, 'config', 'skills', 'agent', 'core', skillId, 'skill.json'),
|
|
573
|
+
path.join(this.projectRoot, 'config', 'skills', 'agent', 'marketplace', skillId, 'skill.json'),
|
|
574
|
+
];
|
|
575
|
+
for (const candidate of searchDirs) {
|
|
576
|
+
try {
|
|
577
|
+
await access(candidate);
|
|
578
|
+
return candidate;
|
|
579
|
+
}
|
|
580
|
+
catch {
|
|
581
|
+
// Not found in this directory, try next
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
return null;
|
|
585
|
+
}
|
|
586
|
+
/**
|
|
587
|
+
* Create a runtime service instance for the given runtime type.
|
|
588
|
+
* Centralizes RuntimeServiceFactory creation to reduce code duplication.
|
|
589
|
+
*/
|
|
590
|
+
createRuntimeService(runtimeType) {
|
|
591
|
+
return RuntimeServiceFactory.create(runtimeType, null, this.projectRoot);
|
|
592
|
+
}
|
|
593
|
+
/**
|
|
594
|
+
* Get an in-process Crewly Agent runtime by session name.
|
|
595
|
+
* Used by the terminal controller for force-delivery to in-process agents.
|
|
596
|
+
*
|
|
597
|
+
* @param sessionName - Session name to look up
|
|
598
|
+
* @returns The CrewlyAgentExternalRuntimeService if it exists, undefined otherwise
|
|
599
|
+
*/
|
|
600
|
+
getInProcessRuntime(sessionName) {
|
|
601
|
+
return this.inProcessRuntimes.get(sessionName);
|
|
602
|
+
}
|
|
603
|
+
/**
|
|
604
|
+
* Check if an in-process Crewly Agent runtime is active and ready for a given session.
|
|
605
|
+
*
|
|
606
|
+
* Used by team status resolution to correctly report active status for
|
|
607
|
+
* crewly-agent runtimes that have no PTY session.
|
|
608
|
+
*
|
|
609
|
+
* @param sessionName - The session name to check
|
|
610
|
+
* @returns True if an in-process runtime exists and is ready
|
|
611
|
+
*/
|
|
612
|
+
isInProcessRuntimeActive(sessionName) {
|
|
613
|
+
const runtime = this.inProcessRuntimes.get(sessionName);
|
|
614
|
+
return runtime !== undefined && runtime.isReady();
|
|
615
|
+
}
|
|
616
|
+
/**
|
|
617
|
+
* Get the check interval based on environment.
|
|
618
|
+
* Uses shorter intervals in test environment for faster tests.
|
|
619
|
+
*/
|
|
620
|
+
getCheckInterval() {
|
|
621
|
+
return process.env.NODE_ENV === 'test' ? 1000 : 2000;
|
|
622
|
+
}
|
|
623
|
+
/**
|
|
624
|
+
* Update agent status with safe error handling (non-blocking).
|
|
625
|
+
* Returns true if successful, false if failed.
|
|
626
|
+
*/
|
|
627
|
+
async updateAgentStatusSafe(sessionName, status, context) {
|
|
628
|
+
try {
|
|
629
|
+
await this.storageService.updateAgentStatus(sessionName, status);
|
|
630
|
+
this.logger.info(`Agent status updated to ${status}`, { sessionName, ...context });
|
|
631
|
+
return true;
|
|
632
|
+
}
|
|
633
|
+
catch (error) {
|
|
634
|
+
this.logger.warn('Failed to update agent status (non-critical)', {
|
|
635
|
+
sessionName,
|
|
636
|
+
error: error instanceof Error ? error.message : String(error),
|
|
637
|
+
});
|
|
638
|
+
return false;
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
/**
|
|
642
|
+
* Role aliases — common synonyms users naturally type in team configs
|
|
643
|
+
* mapped to the canonical directory name under `config/roles/`. Without
|
|
644
|
+
* these, a team-config role like `"tech-lead"` would fall through to a
|
|
645
|
+
* non-existent directory and trigger the ENOENT fallback path on every
|
|
646
|
+
* agent registration (Steve 2026-05-15 dogfood: CE team config used
|
|
647
|
+
* `role: "tech-lead"` while the canonical directory is `team-leader`,
|
|
648
|
+
* producing a steady drip of "Could not load prompt from config" warns
|
|
649
|
+
* for every member-start with no actionable resolution short of editing
|
|
650
|
+
* user data).
|
|
651
|
+
*
|
|
652
|
+
* Keep keys lowercased + dash-normalized (matches the post-normalization
|
|
653
|
+
* shape used by `getPromptFileForRole`).
|
|
654
|
+
*/
|
|
655
|
+
static ROLE_DIR_ALIASES = new Map([
|
|
656
|
+
['tech-lead', 'team-leader'],
|
|
657
|
+
['techlead', 'team-leader'],
|
|
658
|
+
['tl', 'team-leader'],
|
|
659
|
+
['team-lead', 'team-leader'],
|
|
660
|
+
]);
|
|
661
|
+
/**
|
|
662
|
+
* Get prompt file path for a specific role
|
|
663
|
+
* Uses the unified config/roles/{role}/prompt.md structure
|
|
664
|
+
*/
|
|
665
|
+
async getPromptFileForRole(role) {
|
|
666
|
+
// Defensive: reject empty/undefined/literal-"undefined" before path concat,
|
|
667
|
+
// which would otherwise resolve to a non-existent `config/roles/undefined/prompt.md`
|
|
668
|
+
// and trigger the silent fallback path that left agents with empty prompts (Bug #1).
|
|
669
|
+
validateAgentRole(role, 'getPromptFileForRole');
|
|
670
|
+
// Normalize role name to directory name format
|
|
671
|
+
const normalized = role.toLowerCase().replace(/\s+/g, '-');
|
|
672
|
+
// Resolve aliases (e.g. tech-lead → team-leader). Falls through for
|
|
673
|
+
// any role that already matches its directory name.
|
|
674
|
+
const roleName = AgentRegistrationService.ROLE_DIR_ALIASES.get(normalized) ?? normalized;
|
|
675
|
+
return path.join(process.cwd(), 'config', 'roles', roleName, 'prompt.md');
|
|
676
|
+
}
|
|
677
|
+
/**
|
|
678
|
+
* Initialize agent with optimized 2-step escalation process
|
|
679
|
+
* Reduced from 4-step to 2-step with shorter timeouts for better concurrency
|
|
680
|
+
*/
|
|
681
|
+
async initializeAgentWithRegistration(sessionName, role, projectPath, timeout = AGENT_TIMEOUTS.REGULAR_AGENT_INITIALIZATION, memberId, runtimeType = RUNTIME_TYPES.CLAUDE_CODE, runtimeFlags, additionalAllowlistPaths) {
|
|
682
|
+
const startTime = Date.now();
|
|
683
|
+
this.logger.info('Starting optimized agent initialization with registration', {
|
|
684
|
+
sessionName,
|
|
685
|
+
role,
|
|
686
|
+
timeout,
|
|
687
|
+
runtimeType,
|
|
688
|
+
});
|
|
689
|
+
// PHASE 4: agentStatus is now set immediately in API endpoints (startTeam/startTeamMember)
|
|
690
|
+
// No longer need to set it here - focus only on session creation
|
|
691
|
+
this.logger.info('Starting agent session initialization', { sessionName, role });
|
|
692
|
+
// Clear detection cache to ensure fresh runtime detection
|
|
693
|
+
const runtimeService = this.createRuntimeService(runtimeType);
|
|
694
|
+
runtimeService.clearDetectionCache(sessionName);
|
|
695
|
+
// Skip Step 1 (direct registration) as it often fails in concurrent scenarios
|
|
696
|
+
// Go directly to Step 2: Cleanup + reinit (more reliable)
|
|
697
|
+
try {
|
|
698
|
+
this.logger.info('Step 1: Attempting cleanup and reinitialization', {
|
|
699
|
+
sessionName,
|
|
700
|
+
});
|
|
701
|
+
const step1Success = await this.tryCleanupAndReinit(sessionName, role, 70000, // 70 seconds for cleanup and reinit (allows 60s for runtime ready)
|
|
702
|
+
projectPath, memberId, runtimeType, runtimeFlags, additionalAllowlistPaths);
|
|
703
|
+
if (step1Success) {
|
|
704
|
+
return {
|
|
705
|
+
success: true,
|
|
706
|
+
message: 'Agent registered successfully after cleanup and reinit',
|
|
707
|
+
};
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
catch (error) {
|
|
711
|
+
this.logger.warn('Step 1 (cleanup + reinit) failed', {
|
|
712
|
+
sessionName,
|
|
713
|
+
error: error instanceof Error ? error.message : String(error),
|
|
714
|
+
});
|
|
715
|
+
}
|
|
716
|
+
// Step 2: Full session recreation (30 seconds) - only if time remaining
|
|
717
|
+
if (Date.now() - startTime < timeout - 35000) {
|
|
718
|
+
try {
|
|
719
|
+
this.logger.info('Step 2: Attempting full session recreation', { sessionName });
|
|
720
|
+
const step2Success = await this.tryFullRecreation(sessionName, role, 30000, // Reduced from 45000 to 30000
|
|
721
|
+
projectPath, memberId, runtimeType, runtimeFlags);
|
|
722
|
+
if (step2Success) {
|
|
723
|
+
return {
|
|
724
|
+
success: true,
|
|
725
|
+
message: 'Agent registered successfully after full recreation',
|
|
726
|
+
};
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
catch (error) {
|
|
730
|
+
this.logger.warn('Step 2 (full recreation) failed', {
|
|
731
|
+
sessionName,
|
|
732
|
+
error: error instanceof Error ? error.message : String(error),
|
|
733
|
+
});
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
// Give up after 2 steps
|
|
737
|
+
const errorMsg = `Failed to initialize agent after optimized escalation attempts (${Math.round((Date.now() - startTime) / 1000)}s)`;
|
|
738
|
+
this.logger.error(errorMsg, { sessionName, role });
|
|
739
|
+
return { success: false, error: errorMsg };
|
|
740
|
+
}
|
|
741
|
+
/**
|
|
742
|
+
* Step 1: Try direct registration prompt
|
|
743
|
+
* Assumes Claude is already running and just needs a registration prompt
|
|
744
|
+
*/
|
|
745
|
+
async tryDirectRegistration(sessionName, role, timeout, memberId, runtimeType = RUNTIME_TYPES.CLAUDE_CODE) {
|
|
746
|
+
// Clear any existing input. Claude already performs Ctrl+C cleanup during
|
|
747
|
+
// runtime detection, so avoid sending additional Ctrl+C sequences.
|
|
748
|
+
// Gemini CLI: Skip cleanup — Ctrl+C at an empty prompt triggers /quit.
|
|
749
|
+
// Escape defocuses the TUI. Ctrl+U is ignored by the TUI.
|
|
750
|
+
const helper = await this.getSessionHelper();
|
|
751
|
+
// First check if runtime is running before sending the prompt
|
|
752
|
+
// runtimeService2: Separate instance for pre-registration runtime detection
|
|
753
|
+
// This instance is isolated from main runtimeService to avoid cache interference
|
|
754
|
+
const runtimeService2 = this.createRuntimeService(runtimeType);
|
|
755
|
+
const runtimeRunning = await runtimeService2.detectRuntimeWithCommand(sessionName);
|
|
756
|
+
if (!runtimeRunning) {
|
|
757
|
+
this.logger.debug('Runtime not detected in Step 1, skipping direct registration', {
|
|
758
|
+
sessionName,
|
|
759
|
+
runtimeType,
|
|
760
|
+
});
|
|
761
|
+
return false;
|
|
762
|
+
}
|
|
763
|
+
this.logger.debug('Runtime detected, sending registration prompt', {
|
|
764
|
+
sessionName,
|
|
765
|
+
runtimeType,
|
|
766
|
+
});
|
|
767
|
+
// Clear any pending slash command from detection. Claude detection already
|
|
768
|
+
// exits slash mode. Gemini CLI: skip — Ctrl+C at empty prompt exits CLI,
|
|
769
|
+
// Escape defocuses TUI, Ctrl+U is ignored. The prompt should be clean.
|
|
770
|
+
const prompt = await this.loadRegistrationPrompt(role, sessionName, memberId, runtimeType);
|
|
771
|
+
const promptDelivered = await this.sendPromptRobustly(sessionName, prompt, runtimeType);
|
|
772
|
+
if (!promptDelivered) {
|
|
773
|
+
this.logger.warn('Failed to deliver registration prompt', { sessionName, role });
|
|
774
|
+
return false;
|
|
775
|
+
}
|
|
776
|
+
return await this.waitForRegistration(sessionName, role, timeout);
|
|
777
|
+
}
|
|
778
|
+
/**
|
|
779
|
+
* Step 2: Cleanup with Ctrl+C and reinitialize
|
|
780
|
+
* Tries to reset the runtime session and start fresh
|
|
781
|
+
*/
|
|
782
|
+
async tryCleanupAndReinit(sessionName, role, timeout, projectPath, memberId, runtimeType = RUNTIME_TYPES.CLAUDE_CODE, runtimeFlags, additionalAllowlistPaths) {
|
|
783
|
+
// Clear Commandline
|
|
784
|
+
await (await this.getSessionHelper()).clearCurrentCommandLine(sessionName);
|
|
785
|
+
// Inject --resume flag if this was a previously running Claude Code session
|
|
786
|
+
const effectiveFlags = runtimeFlags ? [...runtimeFlags] : [];
|
|
787
|
+
if (runtimeType === RUNTIME_TYPES.CLAUDE_CODE) {
|
|
788
|
+
try {
|
|
789
|
+
const settings = await getSettingsService().getSettings();
|
|
790
|
+
if (settings.general.autoResumeOnRestart) {
|
|
791
|
+
const persistence = getSessionStatePersistence();
|
|
792
|
+
if (persistence.isRestoredSession(sessionName)) {
|
|
793
|
+
const storedSessionId = persistence.getSessionId(sessionName);
|
|
794
|
+
if (storedSessionId) {
|
|
795
|
+
effectiveFlags.push('--resume', storedSessionId);
|
|
796
|
+
this.logger.info('Injecting --resume flag for session restore', {
|
|
797
|
+
sessionName, sessionId: storedSessionId,
|
|
798
|
+
});
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
else {
|
|
803
|
+
this.logger.info('Auto-resume disabled by settings, skipping session resume', { sessionName });
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
catch (resumeError) {
|
|
807
|
+
this.logger.debug('Could not resolve resume flag (non-fatal)', {
|
|
808
|
+
sessionName, error: resumeError instanceof Error ? resumeError.message : String(resumeError),
|
|
809
|
+
});
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
// Write prompt file before launching runtime so --agent (Claude Code) or --append-system-prompt-file works
|
|
813
|
+
let promptFilePath;
|
|
814
|
+
let agentName;
|
|
815
|
+
if (runtimeType === RUNTIME_TYPES.CLAUDE_CODE) {
|
|
816
|
+
try {
|
|
817
|
+
const prompt = await this.loadRegistrationPrompt(role, sessionName, memberId, runtimeType);
|
|
818
|
+
promptFilePath = await this.writePromptFile(sessionName, prompt, {
|
|
819
|
+
runtimeType,
|
|
820
|
+
projectPath,
|
|
821
|
+
role,
|
|
822
|
+
});
|
|
823
|
+
agentName = sessionName;
|
|
824
|
+
}
|
|
825
|
+
catch (promptError) {
|
|
826
|
+
this.logger.warn('Failed to pre-write prompt file (non-fatal, will fall back to direct delivery)', {
|
|
827
|
+
sessionName,
|
|
828
|
+
error: promptError instanceof Error ? promptError.message : String(promptError),
|
|
829
|
+
});
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
// Reinitialize runtime using the appropriate initialization script (always fresh start)
|
|
833
|
+
const runtimeService2 = this.createRuntimeService(runtimeType);
|
|
834
|
+
await runtimeService2.executeRuntimeInitScript(sessionName, projectPath, effectiveFlags, promptFilePath, agentName);
|
|
835
|
+
// Wait for runtime to be ready (simplified detection)
|
|
836
|
+
// Use shorter check interval in test environment, and reasonable interval in production
|
|
837
|
+
const checkInterval = this.getCheckInterval();
|
|
838
|
+
const isReady = await runtimeService2.waitForRuntimeReady(sessionName, 60000, checkInterval); // 60s timeout — Claude Code cold starts can take 30-60s
|
|
839
|
+
if (!isReady) {
|
|
840
|
+
throw new Error(`Failed to reinitialize ${runtimeType} within timeout`);
|
|
841
|
+
}
|
|
842
|
+
// Start runtime exit monitoring IMMEDIATELY after runtime is ready.
|
|
843
|
+
// Must be before postInitialize and sendRegistrationPromptAsync so exits
|
|
844
|
+
// during those phases are detected and the abort signal fires in time.
|
|
845
|
+
RuntimeExitMonitorService.getInstance().startMonitoring(sessionName, runtimeType, role);
|
|
846
|
+
// Start OAuth relogin monitoring for automatic re-authentication
|
|
847
|
+
OAuthReloginMonitorService.getInstance().startMonitoring(sessionName, runtimeType);
|
|
848
|
+
// Look up per-agent browser automation override from member config
|
|
849
|
+
let browserAutomationOverride;
|
|
850
|
+
if (memberId) {
|
|
851
|
+
try {
|
|
852
|
+
const teams = await this.storageService.getTeams();
|
|
853
|
+
for (const team of teams) {
|
|
854
|
+
const member = team.members?.find((m) => m.id === memberId || m.sessionName === sessionName);
|
|
855
|
+
if (member?.enableBrowserAutomation !== undefined) {
|
|
856
|
+
browserAutomationOverride = member.enableBrowserAutomation;
|
|
857
|
+
break;
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
catch {
|
|
862
|
+
// Non-fatal — will use global setting
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
// Run post-initialization hook (e.g., Gemini CLI directory allowlist)
|
|
866
|
+
try {
|
|
867
|
+
await runtimeService2.postInitialize(sessionName, projectPath, additionalAllowlistPaths, browserAutomationOverride);
|
|
868
|
+
// Drain stale terminal escape sequences (e.g. DA1 [?1;2c) that may have
|
|
869
|
+
// arrived during postInitialize commands, so they don't leak into the prompt input
|
|
870
|
+
await delay(500);
|
|
871
|
+
// Clear any pending input after post-initialization.
|
|
872
|
+
// Claude Code: Ctrl+C + Ctrl+U (clearCurrentCommandLine) — standard cleanup.
|
|
873
|
+
// Gemini CLI: Skip cleanup entirely — the TUI just started with a clean
|
|
874
|
+
// prompt. Ctrl+C at an empty Gemini CLI prompt triggers /quit and exits
|
|
875
|
+
// the CLI. Escape defocuses the TUI. Ctrl+U is ignored. The delay(500)
|
|
876
|
+
// above is sufficient to drain stale escape sequences.
|
|
877
|
+
if (runtimeType === RUNTIME_TYPES.CLAUDE_CODE) {
|
|
878
|
+
await (await this.getSessionHelper()).clearCurrentCommandLine(sessionName);
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
catch (postInitError) {
|
|
882
|
+
this.logger.warn('Post-initialization hook failed (non-fatal)', {
|
|
883
|
+
sessionName,
|
|
884
|
+
runtimeType,
|
|
885
|
+
error: postInitError instanceof Error ? postInitError.message : String(postInitError),
|
|
886
|
+
});
|
|
887
|
+
}
|
|
888
|
+
// For PTY sessions, once runtime is detected as ready, consider initialization successful
|
|
889
|
+
// MCP registration will happen async when the agent processes its first prompt
|
|
890
|
+
this.logger.info('Runtime detected as ready, session initialization successful', {
|
|
891
|
+
sessionName,
|
|
892
|
+
role,
|
|
893
|
+
runtimeType,
|
|
894
|
+
});
|
|
895
|
+
// Background: detect and store session ID for resume-on-restart
|
|
896
|
+
if (runtimeType === RUNTIME_TYPES.CLAUDE_CODE) {
|
|
897
|
+
const detectPath = projectPath || process.cwd();
|
|
898
|
+
setTimeout(() => {
|
|
899
|
+
this.detectAndStoreSessionId(sessionName, detectPath).catch(() => { });
|
|
900
|
+
}, 10000);
|
|
901
|
+
}
|
|
902
|
+
// Send the registration prompt in background (don't block on it)
|
|
903
|
+
this.sendRegistrationPromptAsync(sessionName, role, memberId, runtimeType).catch((err) => {
|
|
904
|
+
this.logger.warn('Background registration prompt failed (non-blocking)', {
|
|
905
|
+
sessionName,
|
|
906
|
+
error: err instanceof Error ? err.message : String(err),
|
|
907
|
+
});
|
|
908
|
+
});
|
|
909
|
+
// Update agent status to 'started' since the runtime is running
|
|
910
|
+
// The agent will become 'active' only after it registers via the API endpoint
|
|
911
|
+
try {
|
|
912
|
+
await this.storageService.updateAgentStatus(sessionName, CREWLY_CONSTANTS.AGENT_STATUSES.STARTED);
|
|
913
|
+
this.logger.info('Agent status updated to started (runtime running, awaiting registration)', { sessionName, role });
|
|
914
|
+
}
|
|
915
|
+
catch (statusError) {
|
|
916
|
+
this.logger.warn('Failed to update agent status (non-critical)', {
|
|
917
|
+
sessionName,
|
|
918
|
+
error: statusError instanceof Error ? statusError.message : String(statusError),
|
|
919
|
+
});
|
|
920
|
+
}
|
|
921
|
+
return true;
|
|
922
|
+
}
|
|
923
|
+
/**
|
|
924
|
+
* Detect and store the Claude Code session ID by scanning the filesystem.
|
|
925
|
+
* Claude Code stores conversations as <UUID>.jsonl files in
|
|
926
|
+
* ~/.claude/projects/<path-slug>/ where path-slug is the absolute
|
|
927
|
+
* project path with / replaced by -.
|
|
928
|
+
*
|
|
929
|
+
* Runs in background after agent init to capture the session ID
|
|
930
|
+
* for resume-on-restart support.
|
|
931
|
+
*
|
|
932
|
+
* @param sessionName - PTY session name
|
|
933
|
+
* @param projectPath - Absolute path to the agent's project directory
|
|
934
|
+
*/
|
|
935
|
+
async detectAndStoreSessionId(sessionName, projectPath) {
|
|
936
|
+
try {
|
|
937
|
+
const slug = projectPath.replace(/\//g, '-');
|
|
938
|
+
const claudeProjectDir = path.join(os.homedir(), '.claude', 'projects', slug);
|
|
939
|
+
const files = await readdir(claudeProjectDir);
|
|
940
|
+
const jsonlFiles = files.filter(f => f.endsWith('.jsonl'));
|
|
941
|
+
if (jsonlFiles.length === 0)
|
|
942
|
+
return;
|
|
943
|
+
// Find most recent .jsonl by mtime
|
|
944
|
+
let latestFile = '';
|
|
945
|
+
let latestMtime = 0;
|
|
946
|
+
for (const file of jsonlFiles) {
|
|
947
|
+
const fileStat = await stat(path.join(claudeProjectDir, file));
|
|
948
|
+
if (fileStat.mtimeMs > latestMtime) {
|
|
949
|
+
latestMtime = fileStat.mtimeMs;
|
|
950
|
+
latestFile = file;
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
if (!latestFile)
|
|
954
|
+
return;
|
|
955
|
+
// Extract UUID from filename (remove .jsonl extension)
|
|
956
|
+
const sessionId = latestFile.replace('.jsonl', '');
|
|
957
|
+
const persistence = getSessionStatePersistence();
|
|
958
|
+
persistence.updateSessionId(sessionName, sessionId);
|
|
959
|
+
this.logger.info('Detected and stored Claude session ID from filesystem', {
|
|
960
|
+
sessionName,
|
|
961
|
+
sessionId,
|
|
962
|
+
claudeProjectDir,
|
|
963
|
+
});
|
|
964
|
+
}
|
|
965
|
+
catch (error) {
|
|
966
|
+
this.logger.debug('Could not detect Claude session ID from filesystem (non-fatal)', {
|
|
967
|
+
sessionName,
|
|
968
|
+
error: error instanceof Error ? error.message : String(error),
|
|
969
|
+
});
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
/**
|
|
973
|
+
* Provision the runtime-specific project config file.
|
|
974
|
+
*
|
|
975
|
+
* Each agent CLI reads a different file for project configuration:
|
|
976
|
+
* - Claude Code → .crewly/CLAUDE.md
|
|
977
|
+
* - Gemini CLI → GEMINI.md (project root)
|
|
978
|
+
* - Codex → AGENTS.md (project root)
|
|
979
|
+
*
|
|
980
|
+
* Uses 'wx' flag to avoid overwriting existing files.
|
|
981
|
+
*
|
|
982
|
+
* @param projectPath - Project directory path
|
|
983
|
+
* @param runtimeType - Agent runtime type
|
|
984
|
+
*/
|
|
985
|
+
async provisionRuntimeConfigFile(projectPath, runtimeType) {
|
|
986
|
+
// Map runtime type to template file and output path
|
|
987
|
+
const configMap = {
|
|
988
|
+
[RUNTIME_TYPES.CLAUDE_CODE]: {
|
|
989
|
+
template: 'agent-claude-md.md',
|
|
990
|
+
outputPath: path.join(projectPath, '.crewly', 'CLAUDE.md'),
|
|
991
|
+
},
|
|
992
|
+
[RUNTIME_TYPES.GEMINI_CLI]: {
|
|
993
|
+
template: 'agent-gemini-md.md',
|
|
994
|
+
outputPath: path.join(projectPath, 'GEMINI.md'),
|
|
995
|
+
},
|
|
996
|
+
[RUNTIME_TYPES.CODEX_CLI]: {
|
|
997
|
+
template: 'agent-agents-md.md',
|
|
998
|
+
outputPath: path.join(projectPath, 'AGENTS.md'),
|
|
999
|
+
},
|
|
1000
|
+
};
|
|
1001
|
+
const config = configMap[runtimeType];
|
|
1002
|
+
if (!config)
|
|
1003
|
+
return; // Unknown runtime — skip
|
|
1004
|
+
try {
|
|
1005
|
+
// Ensure parent directory exists
|
|
1006
|
+
const dir = path.dirname(config.outputPath);
|
|
1007
|
+
await mkdir(dir, { recursive: true });
|
|
1008
|
+
const templatePath = path.join(this.projectRoot, 'config', 'templates', config.template);
|
|
1009
|
+
let content = this.promptCache.get(templatePath);
|
|
1010
|
+
if (!content) {
|
|
1011
|
+
content = await readFile(templatePath, 'utf8');
|
|
1012
|
+
this.promptCache.set(templatePath, content);
|
|
1013
|
+
}
|
|
1014
|
+
// Substitute path placeholders before writing (#209). The cache holds
|
|
1015
|
+
// the RAW template; resolve into a per-project copy so skill-script and
|
|
1016
|
+
// project paths in the provisioned config file point at real locations
|
|
1017
|
+
// (mirrors the placeholder substitution done for the registration prompt).
|
|
1018
|
+
const agentSkillsPath = path.join(this.projectRoot, 'config', 'skills', 'agent');
|
|
1019
|
+
const orchestratorSkillsPath = path.join(this.projectRoot, 'config', 'skills', 'orchestrator');
|
|
1020
|
+
const marketplaceSkillsPath = path.join(os.homedir(), '.crewly', 'marketplace', 'skills');
|
|
1021
|
+
const resolved = content
|
|
1022
|
+
.replace(/\{\{PROJECT_PATH\}\}/g, projectPath)
|
|
1023
|
+
.replace(/\{\{AGENT_SKILLS_PATH\}\}/g, agentSkillsPath)
|
|
1024
|
+
.replace(/\{\{ORCHESTRATOR_SKILLS_PATH\}\}/g, orchestratorSkillsPath)
|
|
1025
|
+
.replace(/\{\{MARKETPLACE_SKILLS_PATH\}\}/g, marketplaceSkillsPath);
|
|
1026
|
+
await writeFile(config.outputPath, resolved, { flag: 'wx' }).catch(() => {
|
|
1027
|
+
// File already exists — no action needed
|
|
1028
|
+
});
|
|
1029
|
+
}
|
|
1030
|
+
catch (err) {
|
|
1031
|
+
this.logger.warn('Could not provision runtime config file (non-critical)', {
|
|
1032
|
+
runtimeType,
|
|
1033
|
+
outputPath: config.outputPath,
|
|
1034
|
+
error: err instanceof Error ? err.message : String(err),
|
|
1035
|
+
});
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
/**
|
|
1039
|
+
* Send registration prompt asynchronously (non-blocking).
|
|
1040
|
+
* Uses an AbortController so the operation can be cancelled if the
|
|
1041
|
+
* runtime exits before registration completes.
|
|
1042
|
+
*/
|
|
1043
|
+
async sendRegistrationPromptAsync(sessionName, role, memberId, runtimeType = RUNTIME_TYPES.CLAUDE_CODE) {
|
|
1044
|
+
// Create AbortController for this registration
|
|
1045
|
+
const controller = new AbortController();
|
|
1046
|
+
this.registrationAbortControllers.set(sessionName, controller);
|
|
1047
|
+
try {
|
|
1048
|
+
this.logger.info('Loading registration prompt', { sessionName, role, runtimeType });
|
|
1049
|
+
if (controller.signal.aborted)
|
|
1050
|
+
return;
|
|
1051
|
+
const prompt = await this.loadRegistrationPrompt(role, sessionName, memberId, runtimeType);
|
|
1052
|
+
this.logger.info('Registration prompt loaded, sending to agent', {
|
|
1053
|
+
sessionName, role, runtimeType, promptLength: prompt.length,
|
|
1054
|
+
});
|
|
1055
|
+
if (controller.signal.aborted)
|
|
1056
|
+
return;
|
|
1057
|
+
const sent = await this.sendPromptRobustly(sessionName, prompt, runtimeType, controller.signal);
|
|
1058
|
+
if (sent) {
|
|
1059
|
+
this.logger.info('Registration prompt sent successfully', { sessionName, role });
|
|
1060
|
+
}
|
|
1061
|
+
else {
|
|
1062
|
+
this.logger.warn('Registration prompt delivery returned false', { sessionName, role, runtimeType });
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
catch (error) {
|
|
1066
|
+
if (controller.signal.aborted) {
|
|
1067
|
+
this.logger.info('Registration prompt cancelled (runtime exited)', { sessionName });
|
|
1068
|
+
return;
|
|
1069
|
+
}
|
|
1070
|
+
this.logger.warn('Failed to send registration prompt asynchronously', {
|
|
1071
|
+
sessionName,
|
|
1072
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1073
|
+
stack: error instanceof Error ? error.stack : undefined,
|
|
1074
|
+
});
|
|
1075
|
+
}
|
|
1076
|
+
finally {
|
|
1077
|
+
this.registrationAbortControllers.delete(sessionName);
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
/**
|
|
1081
|
+
* Step 3: Kill session and recreate completely
|
|
1082
|
+
* Most aggressive approach - completely recreates the session from scratch
|
|
1083
|
+
*/
|
|
1084
|
+
async tryFullRecreation(sessionName, role, timeout, projectPath, memberId, runtimeType = RUNTIME_TYPES.CLAUDE_CODE, runtimeFlags) {
|
|
1085
|
+
// Kill existing session
|
|
1086
|
+
await (await this.getSessionHelper()).killSession(sessionName);
|
|
1087
|
+
// Wait for cleanup
|
|
1088
|
+
await delay(1000);
|
|
1089
|
+
// Inject --resume flag if this was a previously running Claude Code session
|
|
1090
|
+
const effectiveFlags = runtimeFlags ? [...runtimeFlags] : [];
|
|
1091
|
+
if (runtimeType === RUNTIME_TYPES.CLAUDE_CODE) {
|
|
1092
|
+
try {
|
|
1093
|
+
const settings = await getSettingsService().getSettings();
|
|
1094
|
+
if (settings.general.autoResumeOnRestart) {
|
|
1095
|
+
const persistence = getSessionStatePersistence();
|
|
1096
|
+
if (persistence.isRestoredSession(sessionName)) {
|
|
1097
|
+
const storedSessionId = persistence.getSessionId(sessionName);
|
|
1098
|
+
if (storedSessionId) {
|
|
1099
|
+
effectiveFlags.push('--resume', storedSessionId);
|
|
1100
|
+
this.logger.info('Injecting --resume flag for session restore (full recreation)', {
|
|
1101
|
+
sessionName, sessionId: storedSessionId,
|
|
1102
|
+
});
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
else {
|
|
1107
|
+
this.logger.info('Auto-resume disabled by settings, skipping session resume after recreation', { sessionName });
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
catch (resumeError) {
|
|
1111
|
+
this.logger.debug('Could not resolve resume flag (non-fatal)', {
|
|
1112
|
+
sessionName, error: resumeError instanceof Error ? resumeError.message : String(resumeError),
|
|
1113
|
+
});
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
// Write prompt file before launching runtime so --agent (Claude Code) or --append-system-prompt-file works
|
|
1117
|
+
let promptFilePath;
|
|
1118
|
+
let agentName;
|
|
1119
|
+
if (runtimeType === RUNTIME_TYPES.CLAUDE_CODE) {
|
|
1120
|
+
try {
|
|
1121
|
+
const prompt = await this.loadRegistrationPrompt(role, sessionName, memberId, runtimeType);
|
|
1122
|
+
promptFilePath = await this.writePromptFile(sessionName, prompt, {
|
|
1123
|
+
runtimeType,
|
|
1124
|
+
projectPath,
|
|
1125
|
+
role,
|
|
1126
|
+
});
|
|
1127
|
+
agentName = sessionName;
|
|
1128
|
+
}
|
|
1129
|
+
catch (promptError) {
|
|
1130
|
+
this.logger.warn('Failed to pre-write prompt file in full recreation (non-fatal)', {
|
|
1131
|
+
sessionName,
|
|
1132
|
+
error: promptError instanceof Error ? promptError.message : String(promptError),
|
|
1133
|
+
});
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
// Recreate session based on role
|
|
1137
|
+
if (role === ORCHESTRATOR_ROLE) {
|
|
1138
|
+
await this.createOrchestratorSession({
|
|
1139
|
+
sessionName,
|
|
1140
|
+
projectPath: projectPath || process.cwd(),
|
|
1141
|
+
});
|
|
1142
|
+
// Initialize runtime for orchestrator using script (always fresh start)
|
|
1143
|
+
const runtimeService = this.createRuntimeService(runtimeType);
|
|
1144
|
+
await runtimeService.executeRuntimeInitScript(sessionName, process.cwd(), effectiveFlags, promptFilePath, agentName);
|
|
1145
|
+
// Wait for runtime to be ready
|
|
1146
|
+
const checkInterval = this.getCheckInterval();
|
|
1147
|
+
// runtimeService3: Separate instance for orchestrator ready-state detection
|
|
1148
|
+
// Isolated from runtimeService to prevent interference between init and ready checks
|
|
1149
|
+
const runtimeService3 = this.createRuntimeService(runtimeType);
|
|
1150
|
+
const isReady = await runtimeService3.waitForRuntimeReady(sessionName, 45000, checkInterval);
|
|
1151
|
+
if (!isReady) {
|
|
1152
|
+
throw new Error(`Failed to initialize ${runtimeType} in recreated orchestrator session within timeout`);
|
|
1153
|
+
}
|
|
1154
|
+
// Start runtime exit monitoring immediately after runtime is ready
|
|
1155
|
+
RuntimeExitMonitorService.getInstance().startMonitoring(sessionName, runtimeType, role);
|
|
1156
|
+
// Start OAuth relogin monitoring for automatic re-authentication
|
|
1157
|
+
OAuthReloginMonitorService.getInstance().startMonitoring(sessionName, runtimeType);
|
|
1158
|
+
// Additional verification: Use runtime detection to confirm runtime is responding
|
|
1159
|
+
// Wait a bit longer for runtime to fully load after showing welcome message
|
|
1160
|
+
this.logger.debug('Runtime ready detected for orchestrator, waiting for full startup before verification', { sessionName, runtimeType });
|
|
1161
|
+
await delay(5000);
|
|
1162
|
+
// Codex CLI is sensitive to active key-probe verification (it may drop to
|
|
1163
|
+
// the shell when probe keys are interpreted outside the TUI). We already
|
|
1164
|
+
// passed waitForRuntimeReady above, so skip the extra command probe here.
|
|
1165
|
+
if (runtimeType !== RUNTIME_TYPES.CODEX_CLI) {
|
|
1166
|
+
this.logger.debug('Verifying orchestrator runtime responsiveness', {
|
|
1167
|
+
sessionName,
|
|
1168
|
+
runtimeType,
|
|
1169
|
+
});
|
|
1170
|
+
// runtimeService4: Final verification instance for orchestrator responsiveness
|
|
1171
|
+
// Clean instance for post-initialization responsiveness testing
|
|
1172
|
+
const runtimeService4 = this.createRuntimeService(runtimeType);
|
|
1173
|
+
const runtimeResponding = await runtimeService4.detectRuntimeWithCommand(sessionName);
|
|
1174
|
+
if (!runtimeResponding) {
|
|
1175
|
+
throw new Error(`${runtimeType} not responding to commands after orchestrator recreation`);
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
else {
|
|
1179
|
+
this.logger.debug('Skipping active runtime probe for Codex orchestrator recreation', {
|
|
1180
|
+
sessionName,
|
|
1181
|
+
runtimeType,
|
|
1182
|
+
});
|
|
1183
|
+
}
|
|
1184
|
+
this.logger.debug('Runtime confirmed ready for orchestrator in Step 3', { sessionName, runtimeType });
|
|
1185
|
+
}
|
|
1186
|
+
else {
|
|
1187
|
+
// For other roles, create basic session and initialize Claude (always fresh start)
|
|
1188
|
+
await (await this.getSessionHelper()).createSession(sessionName, projectPath || process.cwd());
|
|
1189
|
+
const runtimeService = this.createRuntimeService(runtimeType);
|
|
1190
|
+
await runtimeService.executeRuntimeInitScript(sessionName, projectPath, effectiveFlags, promptFilePath, agentName);
|
|
1191
|
+
// Wait for runtime to be ready (simplified detection)
|
|
1192
|
+
const checkInterval = this.getCheckInterval();
|
|
1193
|
+
const isReady = await this.createRuntimeService(runtimeType)
|
|
1194
|
+
.waitForRuntimeReady(sessionName, 25000, checkInterval); // 25s timeout
|
|
1195
|
+
if (!isReady) {
|
|
1196
|
+
throw new Error(`Failed to initialize ${runtimeType} in recreated session within timeout`);
|
|
1197
|
+
}
|
|
1198
|
+
// Start runtime exit monitoring immediately after runtime is ready
|
|
1199
|
+
RuntimeExitMonitorService.getInstance().startMonitoring(sessionName, runtimeType, role);
|
|
1200
|
+
// Start OAuth relogin monitoring for automatic re-authentication
|
|
1201
|
+
OAuthReloginMonitorService.getInstance().startMonitoring(sessionName, runtimeType);
|
|
1202
|
+
}
|
|
1203
|
+
// Look up per-agent browser automation override from member config
|
|
1204
|
+
let browserOverrideForRecreation;
|
|
1205
|
+
if (memberId) {
|
|
1206
|
+
try {
|
|
1207
|
+
const teams = await this.storageService.getTeams();
|
|
1208
|
+
for (const team of teams) {
|
|
1209
|
+
const member = team.members?.find((m) => m.id === memberId || m.sessionName === sessionName);
|
|
1210
|
+
if (member?.enableBrowserAutomation !== undefined) {
|
|
1211
|
+
browserOverrideForRecreation = member.enableBrowserAutomation;
|
|
1212
|
+
break;
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1215
|
+
}
|
|
1216
|
+
catch {
|
|
1217
|
+
// Non-fatal — will use global setting
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
// Run post-initialization hook (e.g., Gemini CLI directory allowlist)
|
|
1221
|
+
try {
|
|
1222
|
+
const postInitService = this.createRuntimeService(runtimeType);
|
|
1223
|
+
await postInitService.postInitialize(sessionName, projectPath, undefined, browserOverrideForRecreation);
|
|
1224
|
+
// Drain stale terminal escape sequences (e.g. DA1 [?1;2c) that may have
|
|
1225
|
+
// arrived during postInitialize commands, so they don't leak into the prompt input
|
|
1226
|
+
await delay(500);
|
|
1227
|
+
// Claude Code: Ctrl+C + Ctrl+U to clear any stale input.
|
|
1228
|
+
// Gemini CLI (Ink TUI): Do NOT send any cleanup keystrokes.
|
|
1229
|
+
// Escape defocuses the Ink TUI input permanently. Ctrl+C at empty
|
|
1230
|
+
// prompt triggers /quit. Ctrl+U is ignored. The delay above is
|
|
1231
|
+
// sufficient to drain stale escape sequences.
|
|
1232
|
+
if (runtimeType === RUNTIME_TYPES.CLAUDE_CODE) {
|
|
1233
|
+
await (await this.getSessionHelper()).clearCurrentCommandLine(sessionName);
|
|
1234
|
+
}
|
|
1235
|
+
}
|
|
1236
|
+
catch (postInitError) {
|
|
1237
|
+
this.logger.warn('Post-initialization hook failed after recreation (non-fatal)', {
|
|
1238
|
+
sessionName,
|
|
1239
|
+
runtimeType,
|
|
1240
|
+
error: postInitError instanceof Error ? postInitError.message : String(postInitError),
|
|
1241
|
+
});
|
|
1242
|
+
}
|
|
1243
|
+
// For PTY sessions, once runtime is detected as ready, consider initialization successful
|
|
1244
|
+
this.logger.info('Runtime detected as ready after full recreation, session initialization successful', {
|
|
1245
|
+
sessionName,
|
|
1246
|
+
role,
|
|
1247
|
+
runtimeType,
|
|
1248
|
+
});
|
|
1249
|
+
// Background: detect and store session ID for resume-on-restart
|
|
1250
|
+
if (runtimeType === RUNTIME_TYPES.CLAUDE_CODE) {
|
|
1251
|
+
const detectPath = projectPath || process.cwd();
|
|
1252
|
+
setTimeout(() => {
|
|
1253
|
+
this.detectAndStoreSessionId(sessionName, detectPath).catch(() => { });
|
|
1254
|
+
}, 10000);
|
|
1255
|
+
}
|
|
1256
|
+
// Send the registration prompt in background (don't block on it)
|
|
1257
|
+
this.sendRegistrationPromptAsync(sessionName, role, memberId, runtimeType).catch((err) => {
|
|
1258
|
+
this.logger.warn('Background registration prompt failed after recreation (non-blocking)', {
|
|
1259
|
+
sessionName,
|
|
1260
|
+
error: err instanceof Error ? err.message : String(err),
|
|
1261
|
+
});
|
|
1262
|
+
});
|
|
1263
|
+
// Update agent status to 'started' since the runtime is running
|
|
1264
|
+
// The agent will become 'active' only after it registers via the API endpoint
|
|
1265
|
+
try {
|
|
1266
|
+
await this.storageService.updateAgentStatus(sessionName, CREWLY_CONSTANTS.AGENT_STATUSES.STARTED);
|
|
1267
|
+
this.logger.info('Agent status updated to started after recreation (awaiting registration)', { sessionName, role });
|
|
1268
|
+
}
|
|
1269
|
+
catch (statusError) {
|
|
1270
|
+
this.logger.warn('Failed to update agent status after recreation (non-critical)', {
|
|
1271
|
+
sessionName,
|
|
1272
|
+
error: statusError instanceof Error ? statusError.message : String(statusError),
|
|
1273
|
+
});
|
|
1274
|
+
}
|
|
1275
|
+
return true;
|
|
1276
|
+
}
|
|
1277
|
+
/**
|
|
1278
|
+
* Register a crewly-agent member as active by updating its team config directly.
|
|
1279
|
+
* In-process agents cannot run bash scripts, so this performs the registration
|
|
1280
|
+
* that the register-self skill would normally do for PTY-based runtimes.
|
|
1281
|
+
*
|
|
1282
|
+
* @param sessionName - Agent session name
|
|
1283
|
+
* @param role - Agent role
|
|
1284
|
+
* @param memberId - Optional member UUID
|
|
1285
|
+
* @returns True if registration succeeded
|
|
1286
|
+
*/
|
|
1287
|
+
async registerMemberActive(sessionName, role, memberId) {
|
|
1288
|
+
try {
|
|
1289
|
+
// Handle orchestrator separately — its config is stored outside getTeams()
|
|
1290
|
+
if (sessionName === ORCHESTRATOR_SESSION_NAME) {
|
|
1291
|
+
await this.storageService.updateOrchestratorStatus(CREWLY_CONSTANTS.AGENT_STATUSES.ACTIVE);
|
|
1292
|
+
this.logger.info('Orchestrator registered as active via config', { sessionName });
|
|
1293
|
+
return true;
|
|
1294
|
+
}
|
|
1295
|
+
const teams = await this.storageService.getTeams();
|
|
1296
|
+
for (const team of teams) {
|
|
1297
|
+
const member = team.members.find((m) => (m.sessionName === sessionName) ||
|
|
1298
|
+
(memberId !== undefined && m.id === memberId));
|
|
1299
|
+
if (member) {
|
|
1300
|
+
member.agentStatus = 'active';
|
|
1301
|
+
member.workingStatus = 'idle';
|
|
1302
|
+
member.updatedAt = new Date().toISOString();
|
|
1303
|
+
// Pre-populate sessionName so register_self tool can find this member
|
|
1304
|
+
if (!member.sessionName && sessionName) {
|
|
1305
|
+
member.sessionName = sessionName;
|
|
1306
|
+
}
|
|
1307
|
+
await this.storageService.saveTeam(team);
|
|
1308
|
+
this.logger.info('Member registered as active', { sessionName, teamId: team.id });
|
|
1309
|
+
// #167: Drain dead-letter queue for newly active agent
|
|
1310
|
+
if (this.schedulerService) {
|
|
1311
|
+
this.schedulerService.drainDeadLetterQueue(sessionName).catch((err) => {
|
|
1312
|
+
this.logger.warn('Failed to drain DLQ on activation (non-critical)', {
|
|
1313
|
+
sessionName,
|
|
1314
|
+
error: err.message,
|
|
1315
|
+
});
|
|
1316
|
+
});
|
|
1317
|
+
}
|
|
1318
|
+
// Architecture Upgrade Phase 6: set up standing task event subscriptions
|
|
1319
|
+
this.setupStandingSubscriptions(sessionName, member.canDelegate ? 'team-lead' : 'executor', team.id);
|
|
1320
|
+
return true;
|
|
1321
|
+
}
|
|
1322
|
+
}
|
|
1323
|
+
this.logger.warn('Could not find member to register as active', { sessionName, memberId });
|
|
1324
|
+
return false;
|
|
1325
|
+
}
|
|
1326
|
+
catch (error) {
|
|
1327
|
+
this.logger.error('Failed to register member as active', {
|
|
1328
|
+
sessionName,
|
|
1329
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1330
|
+
});
|
|
1331
|
+
return false;
|
|
1332
|
+
}
|
|
1333
|
+
}
|
|
1334
|
+
/**
|
|
1335
|
+
* Set up standing event subscriptions for task workflow events based on org role.
|
|
1336
|
+
* Called once at agent registration. Subscriptions are long-lived (24h TTL)
|
|
1337
|
+
* and NOT oneShot — they persist for the agent's session lifetime.
|
|
1338
|
+
*
|
|
1339
|
+
* Orchestrator: task:verified, task:blocked, task:failed (all teams)
|
|
1340
|
+
* Team Lead: task:done, task:blocked, task:failed, task:needs_clarification (own team)
|
|
1341
|
+
* Executor: task:assigned (self scope)
|
|
1342
|
+
*
|
|
1343
|
+
* @param sessionName - Agent session name
|
|
1344
|
+
* @param orgRole - Organizational role (orchestrator, team-lead, executor)
|
|
1345
|
+
* @param teamId - Team ID for scoping (optional for orchestrator)
|
|
1346
|
+
*/
|
|
1347
|
+
setupStandingSubscriptions(sessionName, orgRole, teamId) {
|
|
1348
|
+
if (!this.eventBusService) {
|
|
1349
|
+
this.logger.debug('EventBusService not available, skipping standing subscriptions', { sessionName });
|
|
1350
|
+
return;
|
|
1351
|
+
}
|
|
1352
|
+
const TTL_MINUTES = 60 * 24; // 24 hours
|
|
1353
|
+
try {
|
|
1354
|
+
if (orgRole === 'orchestrator') {
|
|
1355
|
+
// Orchestrator: awareness-only subscriptions for all teams
|
|
1356
|
+
for (const eventType of ['task:verified', 'task:blocked', 'task:failed', 'team:all_tasks_done']) {
|
|
1357
|
+
this.eventBusService.subscribe({
|
|
1358
|
+
eventType,
|
|
1359
|
+
filter: {},
|
|
1360
|
+
subscriberSession: sessionName,
|
|
1361
|
+
oneShot: false,
|
|
1362
|
+
ttlMinutes: TTL_MINUTES,
|
|
1363
|
+
});
|
|
1364
|
+
}
|
|
1365
|
+
this.logger.info('Standing subscriptions set up for orchestrator', { sessionName });
|
|
1366
|
+
}
|
|
1367
|
+
else if (orgRole === 'team-lead') {
|
|
1368
|
+
// TL: own team scope
|
|
1369
|
+
for (const eventType of ['task:done', 'task:blocked', 'task:failed', 'task:needs_clarification']) {
|
|
1370
|
+
this.eventBusService.subscribe({
|
|
1371
|
+
eventType,
|
|
1372
|
+
filter: teamId ? { teamId } : {},
|
|
1373
|
+
subscriberSession: sessionName,
|
|
1374
|
+
oneShot: false,
|
|
1375
|
+
ttlMinutes: TTL_MINUTES,
|
|
1376
|
+
});
|
|
1377
|
+
}
|
|
1378
|
+
this.logger.info('Standing subscriptions set up for team-lead', { sessionName, teamId });
|
|
1379
|
+
}
|
|
1380
|
+
else {
|
|
1381
|
+
// Executor: self scope only
|
|
1382
|
+
this.eventBusService.subscribe({
|
|
1383
|
+
eventType: 'task:assigned',
|
|
1384
|
+
filter: { sessionName },
|
|
1385
|
+
subscriberSession: sessionName,
|
|
1386
|
+
oneShot: false,
|
|
1387
|
+
ttlMinutes: TTL_MINUTES,
|
|
1388
|
+
});
|
|
1389
|
+
this.logger.info('Standing subscription set up for executor', { sessionName });
|
|
1390
|
+
}
|
|
1391
|
+
}
|
|
1392
|
+
catch (err) {
|
|
1393
|
+
this.logger.warn('Failed to set up standing subscriptions (non-fatal)', {
|
|
1394
|
+
sessionName,
|
|
1395
|
+
orgRole,
|
|
1396
|
+
error: err instanceof Error ? err.message : String(err),
|
|
1397
|
+
});
|
|
1398
|
+
}
|
|
1399
|
+
}
|
|
1400
|
+
/**
|
|
1401
|
+
* Load registration prompt from config files (with caching to prevent file I/O contention)
|
|
1402
|
+
*/
|
|
1403
|
+
async loadRegistrationPrompt(role, sessionName, memberId, runtimeType = RUNTIME_TYPES.CLAUDE_CODE) {
|
|
1404
|
+
try {
|
|
1405
|
+
// Cache key is role only - the template file is the same regardless of memberId
|
|
1406
|
+
const cacheKey = role;
|
|
1407
|
+
// Check cache first to avoid file I/O during concurrent operations
|
|
1408
|
+
if (!this.promptCache.has(cacheKey)) {
|
|
1409
|
+
this.logger.debug('Loading prompt template from file', { role, cacheKey });
|
|
1410
|
+
// Get the prompt file path from team roles configuration
|
|
1411
|
+
const promptPath = await this.getPromptFileForRole(role);
|
|
1412
|
+
const promptTemplate = await readFile(promptPath, 'utf8');
|
|
1413
|
+
this.promptCache.set(cacheKey, promptTemplate);
|
|
1414
|
+
this.logger.debug('Prompt template cached', { role, cacheKey, promptPath });
|
|
1415
|
+
}
|
|
1416
|
+
else {
|
|
1417
|
+
this.logger.debug('Using cached prompt template', { role, cacheKey });
|
|
1418
|
+
}
|
|
1419
|
+
// Get cached template and apply variable replacements
|
|
1420
|
+
let prompt = this.promptCache.get(cacheKey);
|
|
1421
|
+
// Replace all template placeholders
|
|
1422
|
+
prompt = prompt.replace(/\{\{SESSION_ID\}\}/g, sessionName);
|
|
1423
|
+
prompt = prompt.replace(/\{\{SESSION_NAME\}\}/g, sessionName);
|
|
1424
|
+
prompt = prompt.replace(/\{\{ROLE\}\}/g, role);
|
|
1425
|
+
if (memberId) {
|
|
1426
|
+
prompt = prompt.replace(/\{\{MEMBER_ID\}\}/g, memberId);
|
|
1427
|
+
}
|
|
1428
|
+
else {
|
|
1429
|
+
// For orchestrator or cases without member ID, remove the memberId parameter
|
|
1430
|
+
prompt = prompt.replace(/,\s*"memberId":\s*"\{\{MEMBER_ID\}\}"/g, '');
|
|
1431
|
+
}
|
|
1432
|
+
// Look up project path and TL hierarchy for team members
|
|
1433
|
+
let projectPath = process.cwd();
|
|
1434
|
+
let foundTeam = null;
|
|
1435
|
+
let foundMember = null;
|
|
1436
|
+
try {
|
|
1437
|
+
const teams = await this.storageService.getTeams();
|
|
1438
|
+
for (const team of teams) {
|
|
1439
|
+
const member = team.members?.find((m) => m.sessionName === sessionName);
|
|
1440
|
+
if (member) {
|
|
1441
|
+
foundTeam = team;
|
|
1442
|
+
foundMember = member;
|
|
1443
|
+
if (team.projectIds[0]) {
|
|
1444
|
+
const projects = await this.storageService.getProjects();
|
|
1445
|
+
const project = projects.find((p) => p.id === team.projectIds[0]);
|
|
1446
|
+
if (project?.path) {
|
|
1447
|
+
projectPath = project.path;
|
|
1448
|
+
}
|
|
1449
|
+
}
|
|
1450
|
+
break;
|
|
1451
|
+
}
|
|
1452
|
+
}
|
|
1453
|
+
}
|
|
1454
|
+
catch {
|
|
1455
|
+
// Non-critical - use default project path
|
|
1456
|
+
}
|
|
1457
|
+
// Write runtime-specific project config file so the agent CLI
|
|
1458
|
+
// recognizes Crewly as a legitimate project configuration (fixes #33)
|
|
1459
|
+
// Claude Code → .crewly/CLAUDE.md, Gemini CLI → GEMINI.md, Codex → AGENTS.md
|
|
1460
|
+
if (role !== ORCHESTRATOR_ROLE) {
|
|
1461
|
+
await this.provisionRuntimeConfigFile(projectPath, runtimeType);
|
|
1462
|
+
}
|
|
1463
|
+
// Replace project path placeholder (must happen after project path lookup above)
|
|
1464
|
+
prompt = prompt.replace(/\{\{PROJECT_PATH\}\}/g, projectPath);
|
|
1465
|
+
// Replace agent skills path placeholder
|
|
1466
|
+
const agentSkillsPath = path.join(this.projectRoot, 'config', 'skills', 'agent');
|
|
1467
|
+
prompt = prompt.replace(/\{\{AGENT_SKILLS_PATH\}\}/g, agentSkillsPath);
|
|
1468
|
+
// Replace orchestrator skills path placeholder — ensures skill scripts
|
|
1469
|
+
// resolve correctly regardless of the orchestrator's working directory
|
|
1470
|
+
const orchestratorSkillsPath = path.join(this.projectRoot, 'config', 'skills', 'orchestrator');
|
|
1471
|
+
prompt = prompt.replace(/\{\{ORCHESTRATOR_SKILLS_PATH\}\}/g, orchestratorSkillsPath);
|
|
1472
|
+
// Replace marketplace skills path placeholder
|
|
1473
|
+
const marketplaceSkillsPath = path.join(os.homedir(), '.crewly', 'marketplace', 'skills');
|
|
1474
|
+
prompt = prompt.replace(/\{\{MARKETPLACE_SKILLS_PATH\}\}/g, marketplaceSkillsPath);
|
|
1475
|
+
// #210: Inject member's systemPrompt if available
|
|
1476
|
+
// This contains role context and authorization info that the agent needs at startup
|
|
1477
|
+
if (foundTeam && foundMember?.id) {
|
|
1478
|
+
try {
|
|
1479
|
+
const memberPrompt = await this.storageService.getMemberPrompt(foundTeam.id, foundMember.id);
|
|
1480
|
+
if (memberPrompt && memberPrompt.trim()) {
|
|
1481
|
+
prompt = memberPrompt.trim() + '\n\n' + prompt;
|
|
1482
|
+
this.logger.info('Member systemPrompt prepended to init prompt', {
|
|
1483
|
+
sessionName,
|
|
1484
|
+
memberId: foundMember.id,
|
|
1485
|
+
systemPromptLength: memberPrompt.length,
|
|
1486
|
+
});
|
|
1487
|
+
}
|
|
1488
|
+
}
|
|
1489
|
+
catch (err) {
|
|
1490
|
+
this.logger.warn('Failed to load member systemPrompt (non-critical)', {
|
|
1491
|
+
sessionName,
|
|
1492
|
+
memberId: foundMember.id,
|
|
1493
|
+
error: err instanceof Error ? err.message : String(err),
|
|
1494
|
+
});
|
|
1495
|
+
}
|
|
1496
|
+
}
|
|
1497
|
+
// Inject skill authorization section — tells agents about available capabilities
|
|
1498
|
+
prompt += '\n\n## Available Capabilities\n\n'
|
|
1499
|
+
+ 'This session has browser automation enabled via the Playwright MCP server. '
|
|
1500
|
+
+ 'Browser skills (e.g. remote-browser) are the preferred way to perform browser tasks. '
|
|
1501
|
+
+ 'Bash skills in the Crewly skills directory are available for team communication and status reporting.';
|
|
1502
|
+
// Inject Team Lead addon for members with canDelegate=true and subordinates
|
|
1503
|
+
if (foundMember?.canDelegate && foundMember.subordinateIds && foundMember.subordinateIds.length > 0 && foundTeam) {
|
|
1504
|
+
try {
|
|
1505
|
+
// Resolve subordinateIds to SubordinateInfo[]
|
|
1506
|
+
const subordinates = foundMember.subordinateIds
|
|
1507
|
+
.map((subId) => {
|
|
1508
|
+
const subMember = foundTeam.members?.find((m) => m.id === subId);
|
|
1509
|
+
if (!subMember)
|
|
1510
|
+
return null;
|
|
1511
|
+
return {
|
|
1512
|
+
name: subMember.name,
|
|
1513
|
+
sessionName: subMember.sessionName || '',
|
|
1514
|
+
role: subMember.role || 'developer',
|
|
1515
|
+
memberId: subMember.id || subId,
|
|
1516
|
+
};
|
|
1517
|
+
})
|
|
1518
|
+
.filter((s) => s !== null);
|
|
1519
|
+
if (subordinates.length > 0) {
|
|
1520
|
+
const tlConfig = {
|
|
1521
|
+
name: sessionName,
|
|
1522
|
+
role: role,
|
|
1523
|
+
systemPrompt: '',
|
|
1524
|
+
projectPath,
|
|
1525
|
+
memberId,
|
|
1526
|
+
teamId: foundTeam.id,
|
|
1527
|
+
canDelegate: true,
|
|
1528
|
+
subordinates,
|
|
1529
|
+
};
|
|
1530
|
+
const promptBuilder = new PromptBuilderService(this.projectRoot);
|
|
1531
|
+
const tlSection = await promptBuilder.buildTeamLeadSection(tlConfig);
|
|
1532
|
+
if (tlSection) {
|
|
1533
|
+
prompt += `\n\n---\n\n${tlSection}`;
|
|
1534
|
+
this.logger.info('TL addon injected into init prompt', {
|
|
1535
|
+
sessionName,
|
|
1536
|
+
subordinateCount: subordinates.length,
|
|
1537
|
+
subordinateNames: subordinates.map((s) => s.name),
|
|
1538
|
+
tlSectionLength: tlSection.length,
|
|
1539
|
+
});
|
|
1540
|
+
}
|
|
1541
|
+
}
|
|
1542
|
+
}
|
|
1543
|
+
catch (tlError) {
|
|
1544
|
+
this.logger.warn('Failed to inject TL addon (non-critical)', {
|
|
1545
|
+
sessionName,
|
|
1546
|
+
error: tlError instanceof Error ? tlError.message : String(tlError),
|
|
1547
|
+
});
|
|
1548
|
+
}
|
|
1549
|
+
}
|
|
1550
|
+
// Issue #395: Inject the authoritative active-work briefing FIRST,
|
|
1551
|
+
// so the agent's reading order is "what am I on the hook for RIGHT
|
|
1552
|
+
// NOW" before the supplementary memory context. Soft-fail with WARN
|
|
1553
|
+
// — TaskPool/Request hiccups must not block agent boot.
|
|
1554
|
+
try {
|
|
1555
|
+
// ActiveWorkBriefingService.getInstance() is async — see service
|
|
1556
|
+
// JSDoc. Dynamic-import migration was the build-fix for PR #494's
|
|
1557
|
+
// broken relative-path resolution under entry-script anchoring.
|
|
1558
|
+
const activeWorkService = await ActiveWorkBriefingService.getInstance();
|
|
1559
|
+
const activeWorkBriefing = await activeWorkService.generateActiveWorkBriefing(sessionName, role);
|
|
1560
|
+
const activeWorkMd = activeWorkService.formatBriefingAsMarkdown(activeWorkBriefing);
|
|
1561
|
+
if (activeWorkMd && activeWorkMd.length > 30) {
|
|
1562
|
+
prompt += `\n\n---\n\n${activeWorkMd}`;
|
|
1563
|
+
this.logger.info('Active-work briefing injected into prompt', {
|
|
1564
|
+
sessionName,
|
|
1565
|
+
role,
|
|
1566
|
+
openRequests: activeWorkBriefing.openRequests.length,
|
|
1567
|
+
activeWorkItems: activeWorkBriefing.activeWorkItems.length,
|
|
1568
|
+
pendingReviews: activeWorkBriefing.pendingReviews.length,
|
|
1569
|
+
outboundDelegations: activeWorkBriefing.outboundDelegations.length,
|
|
1570
|
+
recentlyAutoResolved: activeWorkBriefing.recentlyAutoResolved.length,
|
|
1571
|
+
truncated: activeWorkBriefing.truncated,
|
|
1572
|
+
briefingLength: activeWorkMd.length,
|
|
1573
|
+
});
|
|
1574
|
+
}
|
|
1575
|
+
}
|
|
1576
|
+
catch (activeWorkError) {
|
|
1577
|
+
this.logger.warn('Failed to generate active-work briefing (non-critical)', {
|
|
1578
|
+
sessionName,
|
|
1579
|
+
error: activeWorkError instanceof Error ? activeWorkError.message : String(activeWorkError),
|
|
1580
|
+
});
|
|
1581
|
+
}
|
|
1582
|
+
// Generate and inject startup briefing from session memory
|
|
1583
|
+
try {
|
|
1584
|
+
const sessionMemoryService = SessionMemoryService.getInstance();
|
|
1585
|
+
await sessionMemoryService.onSessionStart(sessionName, role, projectPath);
|
|
1586
|
+
const briefing = await sessionMemoryService.generateStartupBriefing(sessionName, role, projectPath);
|
|
1587
|
+
const briefingMd = sessionMemoryService.formatBriefingAsMarkdown(briefing);
|
|
1588
|
+
if (briefingMd && briefingMd.length > 30) {
|
|
1589
|
+
prompt += `\n\n---\n\n${briefingMd}`;
|
|
1590
|
+
this.logger.info('Startup briefing injected into prompt', { sessionName, role, briefingLength: briefingMd.length });
|
|
1591
|
+
}
|
|
1592
|
+
}
|
|
1593
|
+
catch (briefingError) {
|
|
1594
|
+
this.logger.warn('Failed to generate startup briefing (non-critical)', {
|
|
1595
|
+
sessionName,
|
|
1596
|
+
error: briefingError instanceof Error ? briefingError.message : String(briefingError),
|
|
1597
|
+
});
|
|
1598
|
+
}
|
|
1599
|
+
// Inject mandatory session recovery protocol (recall + get-my-context)
|
|
1600
|
+
try {
|
|
1601
|
+
const recoveryBuilder = new PromptBuilderService(this.projectRoot);
|
|
1602
|
+
const recoverySection = recoveryBuilder.buildSessionRecoverySection(sessionName, role, projectPath);
|
|
1603
|
+
prompt += `\n\n---\n\n${recoverySection}`;
|
|
1604
|
+
this.logger.info('Session recovery protocol injected into prompt', { sessionName, role });
|
|
1605
|
+
}
|
|
1606
|
+
catch (recoveryError) {
|
|
1607
|
+
this.logger.warn('Failed to inject session recovery protocol (non-critical)', {
|
|
1608
|
+
sessionName,
|
|
1609
|
+
error: recoveryError instanceof Error ? recoveryError.message : String(recoveryError),
|
|
1610
|
+
});
|
|
1611
|
+
}
|
|
1612
|
+
// Append identity section so the agent knows its session name and project path
|
|
1613
|
+
prompt += `\n\n---\n\n## Your Identity\n- **Session Name:** ${sessionName}\n- **Project Path:** ${projectPath}`;
|
|
1614
|
+
if (memberId) {
|
|
1615
|
+
prompt += `\n- **Member ID:** ${memberId}`;
|
|
1616
|
+
}
|
|
1617
|
+
// Conditionally append Self Evolution instructions for the orchestrator
|
|
1618
|
+
if (role === ORCHESTRATOR_ROLE) {
|
|
1619
|
+
try {
|
|
1620
|
+
const settings = await getSettingsService().getSettings();
|
|
1621
|
+
if (settings.general.enableSelfEvolution) {
|
|
1622
|
+
const selfEvoPath = path.join(this.projectRoot, 'config', 'roles', 'orchestrator', 'self-evolution.md');
|
|
1623
|
+
const selfEvoPrompt = await readFile(selfEvoPath, 'utf8');
|
|
1624
|
+
prompt += `\n\n---\n\n${selfEvoPrompt}`;
|
|
1625
|
+
this.logger.info('Self Evolution prompt injected', { sessionName });
|
|
1626
|
+
}
|
|
1627
|
+
}
|
|
1628
|
+
catch (selfEvoError) {
|
|
1629
|
+
this.logger.warn('Failed to load self-evolution prompt (non-critical)', {
|
|
1630
|
+
sessionName,
|
|
1631
|
+
error: selfEvoError instanceof Error ? selfEvoError.message : String(selfEvoError),
|
|
1632
|
+
});
|
|
1633
|
+
}
|
|
1634
|
+
}
|
|
1635
|
+
// Phase 5: Use modular prompt assembly (enabled by default, disable with CREWLY_USE_MODULAR_PROMPTS=false).
|
|
1636
|
+
// Replaces the entire monolithic template with PromptAssemblyService — handling identity,
|
|
1637
|
+
// skills, communication, recovery, lifecycle, memory, soul, and team-reference modules
|
|
1638
|
+
// with token budget enforcement.
|
|
1639
|
+
if (process.env.CREWLY_USE_MODULAR_PROMPTS !== 'false') {
|
|
1640
|
+
this.logger.info('Using modular prompt assembly (Phase 5)', { sessionName, role });
|
|
1641
|
+
const agentSkillsPath = path.join(this.projectRoot, 'config', 'skills', 'agent');
|
|
1642
|
+
const tlSkillsPath = path.join(this.projectRoot, 'config', 'skills', 'team-leader');
|
|
1643
|
+
// Pre-compute the subordinate roster — used by both the helper
|
|
1644
|
+
// path (passed via runtime.subordinates) and the orchestrator
|
|
1645
|
+
// fallback path below.
|
|
1646
|
+
const subordinates = foundMember?.subordinateIds
|
|
1647
|
+
?.map((subId) => {
|
|
1648
|
+
const subMember = foundTeam?.members?.find((m) => m.id === subId);
|
|
1649
|
+
if (!subMember)
|
|
1650
|
+
return null;
|
|
1651
|
+
return {
|
|
1652
|
+
name: subMember.name,
|
|
1653
|
+
sessionName: subMember.sessionName || '',
|
|
1654
|
+
role: subMember.role || 'developer',
|
|
1655
|
+
memberId: subMember.id || subId,
|
|
1656
|
+
};
|
|
1657
|
+
})
|
|
1658
|
+
.filter((s) => s !== null);
|
|
1659
|
+
const teamNormsPath = foundTeam?.id
|
|
1660
|
+
? path.join(os.homedir(), CREWLY_CONSTANTS.PATHS.CREWLY_HOME, 'teams', foundTeam.id, 'norms')
|
|
1661
|
+
: undefined;
|
|
1662
|
+
let moduleConfig;
|
|
1663
|
+
if (foundMember && foundTeam) {
|
|
1664
|
+
// WIRE-2: When the agent is a TeamMember of a Team, build the
|
|
1665
|
+
// canonical ModuleConfig via the shared helper so every
|
|
1666
|
+
// autonomy / org / team-level field reaches the assembler.
|
|
1667
|
+
// Caller-side overlay covers the two registration-specific
|
|
1668
|
+
// fields the helper doesn't know about (teamNormsPath +
|
|
1669
|
+
// the registration's `memberId` override which may differ
|
|
1670
|
+
// from member.id when the caller passed an explicit id).
|
|
1671
|
+
const runtime = {
|
|
1672
|
+
sessionName,
|
|
1673
|
+
projectPath,
|
|
1674
|
+
runtimeType: runtimeType,
|
|
1675
|
+
subordinates,
|
|
1676
|
+
agentSkillsPath,
|
|
1677
|
+
tlSkillsPath,
|
|
1678
|
+
projectRoot: this.projectRoot,
|
|
1679
|
+
};
|
|
1680
|
+
moduleConfig = {
|
|
1681
|
+
...buildModuleConfigFromTeamMember(foundMember, foundTeam, runtime),
|
|
1682
|
+
memberId: memberId ?? foundMember.id ?? '',
|
|
1683
|
+
teamNormsPath,
|
|
1684
|
+
};
|
|
1685
|
+
}
|
|
1686
|
+
else {
|
|
1687
|
+
// Orchestrator + legacy paths: foundMember/foundTeam not
|
|
1688
|
+
// present (orchestrator config lives outside getTeams()).
|
|
1689
|
+
// Keep the inline fallback so registration still works for
|
|
1690
|
+
// the orchestrator and any caller without team context.
|
|
1691
|
+
moduleConfig = {
|
|
1692
|
+
sessionName,
|
|
1693
|
+
memberId: memberId ?? '',
|
|
1694
|
+
role,
|
|
1695
|
+
teamId: foundTeam?.id,
|
|
1696
|
+
projectPath,
|
|
1697
|
+
runtimeType: runtimeType,
|
|
1698
|
+
canDelegate: foundMember?.canDelegate,
|
|
1699
|
+
subordinates,
|
|
1700
|
+
agentSkillsPath,
|
|
1701
|
+
tlSkillsPath,
|
|
1702
|
+
projectRoot: this.projectRoot,
|
|
1703
|
+
orgRole: role === 'orchestrator' ? 'orchestrator'
|
|
1704
|
+
: foundMember?.canDelegate ? 'team-lead'
|
|
1705
|
+
: 'executor',
|
|
1706
|
+
autonomyLevel: foundMember?.autonomyLevel ?? 'directed',
|
|
1707
|
+
teamNormsPath,
|
|
1708
|
+
};
|
|
1709
|
+
}
|
|
1710
|
+
const assembler = new PromptAssemblyService();
|
|
1711
|
+
const { prompt: modularPrompt, report } = await assembler.assemble(moduleConfig);
|
|
1712
|
+
this.logger.info('Modular prompt assembled for registration', {
|
|
1713
|
+
sessionName,
|
|
1714
|
+
role,
|
|
1715
|
+
totalTokens: report.totalTokens,
|
|
1716
|
+
moduleCount: report.moduleBreakdown.length,
|
|
1717
|
+
truncatedCount: report.truncated.length,
|
|
1718
|
+
modules: report.moduleBreakdown.map(m => m.name),
|
|
1719
|
+
});
|
|
1720
|
+
return modularPrompt;
|
|
1721
|
+
}
|
|
1722
|
+
return prompt;
|
|
1723
|
+
}
|
|
1724
|
+
catch (error) {
|
|
1725
|
+
// Fallback to inline prompt if file doesn't exist
|
|
1726
|
+
const attemptedPath = await this.getPromptFileForRole(role);
|
|
1727
|
+
// Distinguish "role prompt simply absent" (team-config uses a
|
|
1728
|
+
// custom role string, fallback covers it) from genuine read
|
|
1729
|
+
// failures (permission denied, corrupt file). ENOENT is a
|
|
1730
|
+
// common case — e.g. CE team config currently uses
|
|
1731
|
+
// `role: "tech-lead"` while the filesystem canonical is
|
|
1732
|
+
// `team-leader`. The fallback inline prompt is functionally
|
|
1733
|
+
// sufficient; ERROR-level noise just buries real issues.
|
|
1734
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
1735
|
+
const isMissingFile = /ENOENT/.test(msg);
|
|
1736
|
+
this.logger[isMissingFile ? 'warn' : 'error']('Could not load prompt from config, using fallback', {
|
|
1737
|
+
role,
|
|
1738
|
+
promptPath: attemptedPath,
|
|
1739
|
+
error: msg,
|
|
1740
|
+
...(isMissingFile ? {} : { stack: error instanceof Error ? error.stack : undefined }),
|
|
1741
|
+
});
|
|
1742
|
+
const memberIdJson = memberId ? `,"teamMemberId":"${memberId}"` : '';
|
|
1743
|
+
const skillsPath = path.join(this.projectRoot, 'config', 'skills', 'agent');
|
|
1744
|
+
return `Hey! I need your help with some tasks on this project.
|
|
1745
|
+
|
|
1746
|
+
## Quick context about this setup
|
|
1747
|
+
|
|
1748
|
+
This project uses Crewly for team coordination. You have bash skills available at \`${skillsPath}/\` that communicate with the Crewly backend running locally. Read the skills catalog at \`~/.crewly/skills/AGENT_SKILLS_CATALOG.md\` for a full reference of available skills.
|
|
1749
|
+
|
|
1750
|
+
## First thing - please check in
|
|
1751
|
+
|
|
1752
|
+
Please run the register-self skill to let the team dashboard know you're available:
|
|
1753
|
+
\`\`\`bash
|
|
1754
|
+
bash ${skillsPath}/core/register-self/execute.sh '{"role":"${role}","sessionName":"${sessionName}"${memberIdJson}}'
|
|
1755
|
+
\`\`\`
|
|
1756
|
+
All it does is update a local status flag so the web UI shows you as online - nothing more.
|
|
1757
|
+
|
|
1758
|
+
## Default Execution Loop
|
|
1759
|
+
|
|
1760
|
+
When assigned a task, do not wait passively.
|
|
1761
|
+
|
|
1762
|
+
Loop until done, blocked, or explicitly reassigned:
|
|
1763
|
+
1. Restate the expected outcome in one sentence.
|
|
1764
|
+
2. Identify the fastest safe path to produce a usable result.
|
|
1765
|
+
3. Execute immediately.
|
|
1766
|
+
4. Run cheapest meaningful validation.
|
|
1767
|
+
5. If validation fails, fix and retry.
|
|
1768
|
+
6. If blocked by missing goal/outcome/eval, escalate to your TL.
|
|
1769
|
+
7. If blocked by implementation detail, decide reasonably and continue.
|
|
1770
|
+
8. Report only when you have a result, blocker, or decision exceeding your authority.`;
|
|
1771
|
+
}
|
|
1772
|
+
}
|
|
1773
|
+
/**
|
|
1774
|
+
* Write the registration prompt to a file on disk.
|
|
1775
|
+
*
|
|
1776
|
+
* For Claude Code agents (#207): writes to `{projectPath}/.claude/agents/{sessionName}.md`
|
|
1777
|
+
* with YAML frontmatter so the `--agent` flag can load it as a custom agent definition.
|
|
1778
|
+
* For other runtimes: writes to `~/.crewly/prompts/{sessionName}-init.md`.
|
|
1779
|
+
*
|
|
1780
|
+
* @param sessionName - Session name (used for filename)
|
|
1781
|
+
* @param prompt - The full prompt content
|
|
1782
|
+
* @param options - Optional: projectPath, runtimeType, and role for claude-code agent mode
|
|
1783
|
+
* @returns The absolute path to the written file, or undefined on error
|
|
1784
|
+
*/
|
|
1785
|
+
async writePromptFile(sessionName, prompt, options) {
|
|
1786
|
+
const isClaudeAgent = options?.runtimeType === RUNTIME_TYPES.CLAUDE_CODE && options?.projectPath;
|
|
1787
|
+
const promptFilePath = isClaudeAgent
|
|
1788
|
+
? path.join(options.projectPath, '.claude', 'agents', `${sessionName}.md`)
|
|
1789
|
+
: this.getInitPromptFilePath(sessionName);
|
|
1790
|
+
const promptsDir = path.dirname(promptFilePath);
|
|
1791
|
+
// Quote YAML values to prevent injection via sessionName or role containing special chars
|
|
1792
|
+
const yamlSafeName = sessionName.replace(/"/g, '\\"');
|
|
1793
|
+
const yamlSafeRole = (options?.role || 'developer').replace(/"/g, '\\"');
|
|
1794
|
+
const fileContent = isClaudeAgent
|
|
1795
|
+
? `---\nname: "${yamlSafeName}"\ndescription: "${yamlSafeRole} agent for Crewly orchestration"\n---\n\n${prompt}`
|
|
1796
|
+
: prompt;
|
|
1797
|
+
try {
|
|
1798
|
+
await mkdir(promptsDir, { recursive: true });
|
|
1799
|
+
await writeFile(promptFilePath, fileContent, 'utf8');
|
|
1800
|
+
this.logger.debug('Wrote init prompt to file', {
|
|
1801
|
+
sessionName,
|
|
1802
|
+
promptFilePath,
|
|
1803
|
+
promptLength: fileContent.length,
|
|
1804
|
+
mode: isClaudeAgent ? 'claude-agent' : 'legacy',
|
|
1805
|
+
});
|
|
1806
|
+
return promptFilePath;
|
|
1807
|
+
}
|
|
1808
|
+
catch (error) {
|
|
1809
|
+
this.logger.warn('Failed to write init prompt file', {
|
|
1810
|
+
sessionName,
|
|
1811
|
+
promptFilePath,
|
|
1812
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1813
|
+
});
|
|
1814
|
+
return undefined;
|
|
1815
|
+
}
|
|
1816
|
+
}
|
|
1817
|
+
/**
|
|
1818
|
+
* Build the unified init prompt path used by non-Claude-Code runtimes.
|
|
1819
|
+
*
|
|
1820
|
+
* Anchored on {@link getCrewlyHomePath} so test profiles, KR3 livewalk
|
|
1821
|
+
* dry-runs, and ESTestNode acceptance harnesses that export
|
|
1822
|
+
* `CREWLY_HOME=/tmp/...` write into the test profile, not the
|
|
1823
|
+
* developer's real `~/.crewly/prompts`. WI-F (2026-05-07): the
|
|
1824
|
+
* previous `path.join(os.homedir(), CREWLY_CONSTANTS.PATHS.CREWLY_HOME,
|
|
1825
|
+
* ...)` form ignored the env var and let Mia's KR3 livewalk overwrite
|
|
1826
|
+
* 27 occurrences of `~/.crewly/prompts/crewly-orc-init.md` and 26 of
|
|
1827
|
+
* `crewly-auditor-init.md` in Steve's user-prod tree at 02:37Z.
|
|
1828
|
+
*/
|
|
1829
|
+
getInitPromptFilePath(sessionName) {
|
|
1830
|
+
return path.join(getCrewlyHomePath(), 'prompts', `${sessionName}-init.md`);
|
|
1831
|
+
}
|
|
1832
|
+
/**
|
|
1833
|
+
* Wait for agent registration to complete
|
|
1834
|
+
*/
|
|
1835
|
+
async waitForRegistration(sessionName, role, timeout) {
|
|
1836
|
+
const startTime = Date.now();
|
|
1837
|
+
const checkInterval = 5000; // Check every 5 seconds to prevent overlapping with `/` detection
|
|
1838
|
+
while (Date.now() - startTime < timeout) {
|
|
1839
|
+
try {
|
|
1840
|
+
if (await this.checkAgentRegistration(sessionName, role)) {
|
|
1841
|
+
this.logger.info('Agent registration confirmed', { sessionName, role });
|
|
1842
|
+
return true;
|
|
1843
|
+
}
|
|
1844
|
+
await delay(checkInterval);
|
|
1845
|
+
}
|
|
1846
|
+
catch (error) {
|
|
1847
|
+
this.logger.warn('Error checking registration', {
|
|
1848
|
+
sessionName,
|
|
1849
|
+
role,
|
|
1850
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1851
|
+
});
|
|
1852
|
+
await delay(checkInterval);
|
|
1853
|
+
}
|
|
1854
|
+
}
|
|
1855
|
+
this.logger.warn('Timeout waiting for agent registration', { sessionName, role, timeout });
|
|
1856
|
+
return false;
|
|
1857
|
+
}
|
|
1858
|
+
/**
|
|
1859
|
+
* Check if agent is properly registered
|
|
1860
|
+
*/
|
|
1861
|
+
async checkAgentRegistration(sessionName, role) {
|
|
1862
|
+
try {
|
|
1863
|
+
if (role === ORCHESTRATOR_ROLE) {
|
|
1864
|
+
// For orchestrator, check agentStatus is active
|
|
1865
|
+
const orchestratorStatus = await this.storageService.getOrchestratorStatus();
|
|
1866
|
+
return orchestratorStatus?.agentStatus === CREWLY_CONSTANTS.AGENT_STATUSES.ACTIVE;
|
|
1867
|
+
}
|
|
1868
|
+
// For team members, check teams data
|
|
1869
|
+
const teams = await this.storageService.getTeams();
|
|
1870
|
+
// Find team member with matching sessionName and check agentStatus
|
|
1871
|
+
for (const team of teams) {
|
|
1872
|
+
if (team.members) {
|
|
1873
|
+
for (const member of team.members) {
|
|
1874
|
+
if (member.sessionName === sessionName && member.role === role) {
|
|
1875
|
+
return member.agentStatus === CREWLY_CONSTANTS.AGENT_STATUSES.ACTIVE;
|
|
1876
|
+
}
|
|
1877
|
+
}
|
|
1878
|
+
}
|
|
1879
|
+
}
|
|
1880
|
+
return false;
|
|
1881
|
+
}
|
|
1882
|
+
catch (error) {
|
|
1883
|
+
this.logger.debug('Error checking agent registration', {
|
|
1884
|
+
sessionName,
|
|
1885
|
+
role,
|
|
1886
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1887
|
+
});
|
|
1888
|
+
return false;
|
|
1889
|
+
}
|
|
1890
|
+
}
|
|
1891
|
+
/**
|
|
1892
|
+
* Fast registration with verification and retry mechanism
|
|
1893
|
+
* Sends system prompt to runtime which triggers MCP registration via teams.json
|
|
1894
|
+
* @param skipInitialCleanup If true, skips Ctrl+C on first attempt (when runtime was just initialized)
|
|
1895
|
+
*/
|
|
1896
|
+
async attemptRegistrationWithVerification(sessionName, role, timeout, memberId, maxRetries = 3, skipInitialCleanup = false, runtimeType = RUNTIME_TYPES.CLAUDE_CODE) {
|
|
1897
|
+
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
1898
|
+
this.logger.info('Attempting system prompt registration with MCP flow', {
|
|
1899
|
+
sessionName,
|
|
1900
|
+
role,
|
|
1901
|
+
runtimeType,
|
|
1902
|
+
attempt,
|
|
1903
|
+
maxRetries,
|
|
1904
|
+
});
|
|
1905
|
+
try {
|
|
1906
|
+
// Step 1: Send Ctrl+C to clear any pending commands (skip on first attempt if Claude was just initialized)
|
|
1907
|
+
if (!skipInitialCleanup || attempt > 1) {
|
|
1908
|
+
await (await this.getSessionHelper()).clearCurrentCommandLine(sessionName);
|
|
1909
|
+
await delay(500);
|
|
1910
|
+
this.logger.debug('Sent Ctrl+C to clear terminal state', {
|
|
1911
|
+
sessionName,
|
|
1912
|
+
attempt,
|
|
1913
|
+
});
|
|
1914
|
+
}
|
|
1915
|
+
else {
|
|
1916
|
+
this.logger.debug('Skipping Ctrl+C on first attempt (Claude was just initialized)', { sessionName, attempt });
|
|
1917
|
+
}
|
|
1918
|
+
// Step 2: Verify runtime is running (skip detection if runtime was just initialized and verified)
|
|
1919
|
+
let runtimeRunning = true; // Assume true if we skip detection
|
|
1920
|
+
if (!skipInitialCleanup || attempt > 1) {
|
|
1921
|
+
// Only do runtime detection on retries or when runtime wasn't just initialized
|
|
1922
|
+
const forceRefresh = attempt > 1; // Force refresh on retry attempts
|
|
1923
|
+
const runtimeService5 = this.createRuntimeService(runtimeType);
|
|
1924
|
+
runtimeRunning = await runtimeService5.detectRuntimeWithCommand(sessionName, forceRefresh);
|
|
1925
|
+
if (!runtimeRunning) {
|
|
1926
|
+
this.logger.warn('Runtime not detected, cannot send system prompt', {
|
|
1927
|
+
sessionName,
|
|
1928
|
+
runtimeType,
|
|
1929
|
+
attempt,
|
|
1930
|
+
forceRefresh,
|
|
1931
|
+
});
|
|
1932
|
+
// Clear detection cache before continuing to retry
|
|
1933
|
+
const runtimeService6 = this.createRuntimeService(runtimeType);
|
|
1934
|
+
runtimeService6.clearDetectionCache(sessionName);
|
|
1935
|
+
// Add longer delay between failed detection attempts
|
|
1936
|
+
if (attempt < maxRetries) {
|
|
1937
|
+
await delay(2000);
|
|
1938
|
+
}
|
|
1939
|
+
continue; // Try again
|
|
1940
|
+
}
|
|
1941
|
+
}
|
|
1942
|
+
else {
|
|
1943
|
+
this.logger.debug('Skipping runtime detection (runtime was just initialized and verified)', {
|
|
1944
|
+
sessionName,
|
|
1945
|
+
runtimeType,
|
|
1946
|
+
attempt,
|
|
1947
|
+
});
|
|
1948
|
+
}
|
|
1949
|
+
// Step 3: Send system prompt with robust delivery mechanism
|
|
1950
|
+
const prompt = await this.loadRegistrationPrompt(role, sessionName, memberId, runtimeType);
|
|
1951
|
+
const promptDelivered = await this.sendPromptRobustly(sessionName, prompt, runtimeType);
|
|
1952
|
+
if (!promptDelivered) {
|
|
1953
|
+
this.logger.warn('Failed to deliver system prompt reliably', {
|
|
1954
|
+
sessionName,
|
|
1955
|
+
attempt,
|
|
1956
|
+
promptLength: prompt.length,
|
|
1957
|
+
});
|
|
1958
|
+
continue; // Try again
|
|
1959
|
+
}
|
|
1960
|
+
this.logger.debug('System prompt delivered successfully', {
|
|
1961
|
+
sessionName,
|
|
1962
|
+
promptLength: prompt.length,
|
|
1963
|
+
});
|
|
1964
|
+
this.logger.debug('Terminal activity detected, waiting for MCP registration', {
|
|
1965
|
+
sessionName,
|
|
1966
|
+
});
|
|
1967
|
+
// Step 5: Wait for registration API call to update teams.json (agentStatus: activating -> active)
|
|
1968
|
+
const registrationTimeout = Math.min(timeout, 25000); // Max 25s per attempt
|
|
1969
|
+
const registered = await this.waitForRegistrationFast(sessionName, role, registrationTimeout);
|
|
1970
|
+
if (registered) {
|
|
1971
|
+
this.logger.info('MCP registration successful (teams.json updated)', {
|
|
1972
|
+
sessionName,
|
|
1973
|
+
role,
|
|
1974
|
+
attempt,
|
|
1975
|
+
});
|
|
1976
|
+
return true;
|
|
1977
|
+
}
|
|
1978
|
+
this.logger.warn('MCP registration timeout, retrying', {
|
|
1979
|
+
sessionName,
|
|
1980
|
+
role,
|
|
1981
|
+
attempt,
|
|
1982
|
+
nextAttempt: attempt < maxRetries,
|
|
1983
|
+
});
|
|
1984
|
+
}
|
|
1985
|
+
catch (error) {
|
|
1986
|
+
this.logger.error('System prompt registration attempt failed', {
|
|
1987
|
+
sessionName,
|
|
1988
|
+
role,
|
|
1989
|
+
attempt,
|
|
1990
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1991
|
+
});
|
|
1992
|
+
}
|
|
1993
|
+
// Short delay before retry
|
|
1994
|
+
if (attempt < maxRetries) {
|
|
1995
|
+
await delay(1000);
|
|
1996
|
+
}
|
|
1997
|
+
}
|
|
1998
|
+
this.logger.warn('Background MCP registration did not complete within timeout (agent still running)', {
|
|
1999
|
+
sessionName,
|
|
2000
|
+
role,
|
|
2001
|
+
runtimeType,
|
|
2002
|
+
maxRetries,
|
|
2003
|
+
});
|
|
2004
|
+
return false;
|
|
2005
|
+
}
|
|
2006
|
+
/**
|
|
2007
|
+
* Fast registration polling with shorter intervals
|
|
2008
|
+
*/
|
|
2009
|
+
async waitForRegistrationFast(sessionName, role, timeout) {
|
|
2010
|
+
const startTime = Date.now();
|
|
2011
|
+
const fastCheckInterval = 2000; // Check every 2 seconds (faster than original 5s)
|
|
2012
|
+
while (Date.now() - startTime < timeout) {
|
|
2013
|
+
try {
|
|
2014
|
+
if (await this.checkAgentRegistration(sessionName, role)) {
|
|
2015
|
+
this.logger.info('Fast registration confirmation', { sessionName, role });
|
|
2016
|
+
return true;
|
|
2017
|
+
}
|
|
2018
|
+
await delay(fastCheckInterval);
|
|
2019
|
+
}
|
|
2020
|
+
catch (error) {
|
|
2021
|
+
this.logger.debug('Error in fast registration check', {
|
|
2022
|
+
sessionName,
|
|
2023
|
+
role,
|
|
2024
|
+
error: error instanceof Error ? error.message : String(error),
|
|
2025
|
+
});
|
|
2026
|
+
await delay(1000); // Shorter error delay
|
|
2027
|
+
}
|
|
2028
|
+
}
|
|
2029
|
+
this.logger.warn('Fast registration timeout', { sessionName, role, timeout });
|
|
2030
|
+
return false;
|
|
2031
|
+
}
|
|
2032
|
+
/**
|
|
2033
|
+
* Create orchestrator session - extracted from the original tmux service
|
|
2034
|
+
*/
|
|
2035
|
+
async createOrchestratorSession(config) {
|
|
2036
|
+
this.logger.info('Creating orchestrator session', {
|
|
2037
|
+
sessionName: config.sessionName,
|
|
2038
|
+
projectPath: config.projectPath,
|
|
2039
|
+
});
|
|
2040
|
+
// Check if session already exists
|
|
2041
|
+
if (await (await this.getSessionHelper()).sessionExists(config.sessionName)) {
|
|
2042
|
+
this.logger.info('Orchestrator session already exists', {
|
|
2043
|
+
sessionName: config.sessionName,
|
|
2044
|
+
});
|
|
2045
|
+
return;
|
|
2046
|
+
}
|
|
2047
|
+
// Create new session for orchestrator
|
|
2048
|
+
await (await this.getSessionHelper()).createSession(config.sessionName, config.projectPath
|
|
2049
|
+
// windowName not used in PTY backend
|
|
2050
|
+
);
|
|
2051
|
+
this.logger.info('Orchestrator session created successfully', {
|
|
2052
|
+
sessionName: config.sessionName,
|
|
2053
|
+
});
|
|
2054
|
+
}
|
|
2055
|
+
/**
|
|
2056
|
+
* Unified session creation that handles both orchestrator and team members
|
|
2057
|
+
* @param config Session configuration
|
|
2058
|
+
* @returns Promise with success/error information
|
|
2059
|
+
*/
|
|
2060
|
+
async createAgentSession(config) {
|
|
2061
|
+
// Fail fast if role is unusable. Without this guard, an undefined role
|
|
2062
|
+
// flows through the entire pipeline, gets stringified to "undefined" in
|
|
2063
|
+
// the CREWLY_ROLE env var, and silently breaks prompt resolution. We
|
|
2064
|
+
// surface the misconfiguration here instead of spawning a dead session.
|
|
2065
|
+
try {
|
|
2066
|
+
validateAgentRole(config.role, 'createAgentSession');
|
|
2067
|
+
}
|
|
2068
|
+
catch (err) {
|
|
2069
|
+
const error = err instanceof Error ? err.message : String(err);
|
|
2070
|
+
this.logger.error('Refusing to create agent session with invalid role', {
|
|
2071
|
+
sessionName: config.sessionName,
|
|
2072
|
+
memberId: config.memberId,
|
|
2073
|
+
teamId: config.teamId,
|
|
2074
|
+
receivedRole: config.role,
|
|
2075
|
+
error,
|
|
2076
|
+
});
|
|
2077
|
+
return {
|
|
2078
|
+
success: false,
|
|
2079
|
+
sessionName: config.sessionName,
|
|
2080
|
+
error,
|
|
2081
|
+
};
|
|
2082
|
+
}
|
|
2083
|
+
// If another creation is in progress for this session, wait for it
|
|
2084
|
+
const existingLock = this.sessionCreationLocks.get(config.sessionName);
|
|
2085
|
+
if (existingLock) {
|
|
2086
|
+
this.logger.info('Waiting for existing session creation to complete', { sessionName: config.sessionName });
|
|
2087
|
+
try {
|
|
2088
|
+
return await existingLock;
|
|
2089
|
+
}
|
|
2090
|
+
catch {
|
|
2091
|
+
// Previous creation failed, proceed with our own attempt
|
|
2092
|
+
this.logger.info('Previous session creation failed, proceeding with new attempt', { sessionName: config.sessionName });
|
|
2093
|
+
}
|
|
2094
|
+
}
|
|
2095
|
+
// Create and store the lock promise
|
|
2096
|
+
const creationPromise = this._createAgentSessionImpl(config);
|
|
2097
|
+
this.sessionCreationLocks.set(config.sessionName, creationPromise);
|
|
2098
|
+
try {
|
|
2099
|
+
const result = await creationPromise;
|
|
2100
|
+
return result;
|
|
2101
|
+
}
|
|
2102
|
+
finally {
|
|
2103
|
+
// Only delete lock if it's still ours — a concurrent caller may have
|
|
2104
|
+
// replaced it with a new promise after our catch path above.
|
|
2105
|
+
if (this.sessionCreationLocks.get(config.sessionName) === creationPromise) {
|
|
2106
|
+
this.sessionCreationLocks.delete(config.sessionName);
|
|
2107
|
+
}
|
|
2108
|
+
}
|
|
2109
|
+
}
|
|
2110
|
+
/**
|
|
2111
|
+
* Internal implementation of createAgentSession, guarded by sessionCreationLocks.
|
|
2112
|
+
* @param config Session configuration
|
|
2113
|
+
* @returns Promise with success/error information
|
|
2114
|
+
*/
|
|
2115
|
+
async _createAgentSessionImpl(config) {
|
|
2116
|
+
const { sessionName, role, projectPath = process.cwd(), windowName, memberId } = config;
|
|
2117
|
+
const forceRecreate = config.forceRecreate ?? false;
|
|
2118
|
+
// Get runtime type from config or default to claude-code
|
|
2119
|
+
let runtimeType = config.runtimeType || RUNTIME_TYPES.CLAUDE_CODE;
|
|
2120
|
+
// Resolve runtime flags from the agent's effective skills
|
|
2121
|
+
let runtimeFlags = [];
|
|
2122
|
+
// For team members, try to get runtime type from storage and resolve skill flags
|
|
2123
|
+
if (!config.runtimeType && role !== ORCHESTRATOR_ROLE) {
|
|
2124
|
+
try {
|
|
2125
|
+
const teams = await this.storageService.getTeams();
|
|
2126
|
+
for (const team of teams) {
|
|
2127
|
+
const member = team.members?.find((m) => m.sessionName === sessionName);
|
|
2128
|
+
if (member) {
|
|
2129
|
+
if (member.runtimeType) {
|
|
2130
|
+
runtimeType = member.runtimeType;
|
|
2131
|
+
}
|
|
2132
|
+
// Resolve runtime flags from role skills + member overrides
|
|
2133
|
+
runtimeFlags = await this.resolveRuntimeFlags(role, runtimeType, member.skillOverrides, member.excludedRoleSkills);
|
|
2134
|
+
break;
|
|
2135
|
+
}
|
|
2136
|
+
}
|
|
2137
|
+
}
|
|
2138
|
+
catch (error) {
|
|
2139
|
+
this.logger.warn('Failed to get runtime type or flags from storage, using defaults', {
|
|
2140
|
+
sessionName,
|
|
2141
|
+
role,
|
|
2142
|
+
error: error instanceof Error ? error.message : String(error),
|
|
2143
|
+
});
|
|
2144
|
+
}
|
|
2145
|
+
}
|
|
2146
|
+
else if (role !== ORCHESTRATOR_ROLE) {
|
|
2147
|
+
// Config-provided runtimeType, still resolve flags
|
|
2148
|
+
try {
|
|
2149
|
+
const teams = await this.storageService.getTeams();
|
|
2150
|
+
for (const team of teams) {
|
|
2151
|
+
const member = team.members?.find((m) => m.sessionName === sessionName);
|
|
2152
|
+
if (member) {
|
|
2153
|
+
runtimeFlags = await this.resolveRuntimeFlags(role, runtimeType, member.skillOverrides, member.excludedRoleSkills);
|
|
2154
|
+
break;
|
|
2155
|
+
}
|
|
2156
|
+
}
|
|
2157
|
+
}
|
|
2158
|
+
catch (error) {
|
|
2159
|
+
this.logger.warn('Failed to resolve runtime flags', {
|
|
2160
|
+
sessionName, role,
|
|
2161
|
+
error: error instanceof Error ? error.message : String(error),
|
|
2162
|
+
});
|
|
2163
|
+
}
|
|
2164
|
+
}
|
|
2165
|
+
// For orchestrator, try to get runtime type from orchestrator status
|
|
2166
|
+
if (role === ORCHESTRATOR_ROLE && !config.runtimeType) {
|
|
2167
|
+
try {
|
|
2168
|
+
const orchestratorStatus = await this.storageService.getOrchestratorStatus();
|
|
2169
|
+
if (orchestratorStatus?.runtimeType) {
|
|
2170
|
+
runtimeType = orchestratorStatus.runtimeType;
|
|
2171
|
+
}
|
|
2172
|
+
}
|
|
2173
|
+
catch (error) {
|
|
2174
|
+
this.logger.warn('Failed to get orchestrator runtime type from storage, using default', {
|
|
2175
|
+
sessionName,
|
|
2176
|
+
role,
|
|
2177
|
+
error: error instanceof Error ? error.message : String(error),
|
|
2178
|
+
});
|
|
2179
|
+
}
|
|
2180
|
+
}
|
|
2181
|
+
try {
|
|
2182
|
+
this.logger.info('Creating agent session (unified approach)', {
|
|
2183
|
+
sessionName,
|
|
2184
|
+
role,
|
|
2185
|
+
runtimeType,
|
|
2186
|
+
projectPath,
|
|
2187
|
+
windowName,
|
|
2188
|
+
memberId,
|
|
2189
|
+
});
|
|
2190
|
+
// Get session helper first
|
|
2191
|
+
this.logger.debug('Getting session helper for session creation');
|
|
2192
|
+
const sessionHelper = await this.getSessionHelper();
|
|
2193
|
+
this.logger.debug('Session helper obtained', {
|
|
2194
|
+
hasHelper: !!sessionHelper,
|
|
2195
|
+
});
|
|
2196
|
+
// Check if session already exists
|
|
2197
|
+
const sessionExists = sessionHelper.sessionExists(sessionName);
|
|
2198
|
+
this.logger.debug('Checked if session exists', {
|
|
2199
|
+
sessionName,
|
|
2200
|
+
sessionExists,
|
|
2201
|
+
});
|
|
2202
|
+
if (!sessionExists) {
|
|
2203
|
+
// Session doesn't exist, go directly to creating a new session
|
|
2204
|
+
this.logger.info('Session does not exist, will create new session', { sessionName });
|
|
2205
|
+
}
|
|
2206
|
+
else if (forceRecreate) {
|
|
2207
|
+
// Skip recovery, kill immediately (used during server startup for stale sessions)
|
|
2208
|
+
this.logger.info('Session exists but forceRecreate is set, killing for clean restart', { sessionName });
|
|
2209
|
+
try {
|
|
2210
|
+
const runtimeService = this.createRuntimeService(runtimeType);
|
|
2211
|
+
runtimeService.clearDetectionCache(sessionName);
|
|
2212
|
+
await (await this.getSessionHelper()).killSession(sessionName);
|
|
2213
|
+
await delay(1000);
|
|
2214
|
+
}
|
|
2215
|
+
catch (killError) {
|
|
2216
|
+
this.logger.warn('Failed to kill session during forceRecreate, continuing with recreation', {
|
|
2217
|
+
sessionName,
|
|
2218
|
+
error: killError instanceof Error ? killError.message : String(killError),
|
|
2219
|
+
});
|
|
2220
|
+
}
|
|
2221
|
+
}
|
|
2222
|
+
else {
|
|
2223
|
+
this.logger.info('Session already exists, attempting intelligent recovery instead of killing', {
|
|
2224
|
+
sessionName,
|
|
2225
|
+
});
|
|
2226
|
+
// Step 1: Try to detect if runtime is already running using slash command
|
|
2227
|
+
const runtimeService = this.createRuntimeService(runtimeType);
|
|
2228
|
+
let recoverySuccess = false;
|
|
2229
|
+
try {
|
|
2230
|
+
const runtimeRunning = await runtimeService.detectRuntimeWithCommand(sessionName);
|
|
2231
|
+
if (runtimeRunning) {
|
|
2232
|
+
this.logger.info('Runtime detected in existing session, attempting direct registration', {
|
|
2233
|
+
sessionName,
|
|
2234
|
+
});
|
|
2235
|
+
// Try to send registration prompt directly
|
|
2236
|
+
const registrationResult = await this.attemptRegistrationWithVerification(sessionName, role, 25000, // 25 second timeout
|
|
2237
|
+
memberId, 1, // single attempt for now
|
|
2238
|
+
true, // skip initial cleanup since runtime is confirmed running
|
|
2239
|
+
runtimeType);
|
|
2240
|
+
if (registrationResult) {
|
|
2241
|
+
recoverySuccess = true;
|
|
2242
|
+
this.logger.info('Successfully registered existing session with runtime', { sessionName });
|
|
2243
|
+
}
|
|
2244
|
+
}
|
|
2245
|
+
// Step 2: If runtime not detected or registration failed, try Ctrl+C restart
|
|
2246
|
+
// only for Claude. Gemini/Codex treat Ctrl+C as destructive at prompt
|
|
2247
|
+
// and can exit the runtime instead of recovering.
|
|
2248
|
+
if (!recoverySuccess) {
|
|
2249
|
+
if (runtimeType !== RUNTIME_TYPES.CLAUDE_CODE) {
|
|
2250
|
+
this.logger.info('Skipping Ctrl+C recovery for non-Claude runtime; falling back to recreation', { sessionName, runtimeType });
|
|
2251
|
+
throw new Error(`${runtimeType} recovery requires full recreation`);
|
|
2252
|
+
}
|
|
2253
|
+
this.logger.info('Runtime not detected or registration failed, attempting Ctrl+C restart', {
|
|
2254
|
+
sessionName,
|
|
2255
|
+
});
|
|
2256
|
+
// Send Ctrl+C twice to try to restart Claude
|
|
2257
|
+
await (await this.getSessionHelper()).sendCtrlC(sessionName);
|
|
2258
|
+
await delay(1000);
|
|
2259
|
+
await (await this.getSessionHelper()).sendCtrlC(sessionName);
|
|
2260
|
+
await delay(2000);
|
|
2261
|
+
// Clear runtime detection cache after Ctrl+C restart
|
|
2262
|
+
runtimeService.clearDetectionCache(sessionName);
|
|
2263
|
+
// Try registration after Ctrl+C restart
|
|
2264
|
+
const postCtrlCResult = await this.attemptRegistrationWithVerification(sessionName, role, 25000, memberId, 1, // single attempt
|
|
2265
|
+
false, // don't skip cleanup after Ctrl+C
|
|
2266
|
+
runtimeType);
|
|
2267
|
+
if (postCtrlCResult) {
|
|
2268
|
+
recoverySuccess = true;
|
|
2269
|
+
this.logger.info('Successfully recovered session after Ctrl+C restart', { sessionName });
|
|
2270
|
+
}
|
|
2271
|
+
}
|
|
2272
|
+
}
|
|
2273
|
+
catch (error) {
|
|
2274
|
+
this.logger.warn('Error during intelligent session recovery', {
|
|
2275
|
+
sessionName,
|
|
2276
|
+
error: error instanceof Error ? error.message : String(error),
|
|
2277
|
+
});
|
|
2278
|
+
}
|
|
2279
|
+
// If recovery succeeded, register for persistence, start monitoring, and return early
|
|
2280
|
+
if (recoverySuccess) {
|
|
2281
|
+
// Register session for state persistence so it survives restarts
|
|
2282
|
+
try {
|
|
2283
|
+
const persistence = getSessionStatePersistence();
|
|
2284
|
+
persistence.registerSession(sessionName, {
|
|
2285
|
+
cwd: projectPath || process.cwd(),
|
|
2286
|
+
command: process.env.SHELL || '/bin/bash',
|
|
2287
|
+
args: [],
|
|
2288
|
+
}, runtimeType, role, config.teamId, config.memberId);
|
|
2289
|
+
}
|
|
2290
|
+
catch (persistError) {
|
|
2291
|
+
this.logger.warn('Failed to register recovered session for persistence (non-critical)', {
|
|
2292
|
+
sessionName,
|
|
2293
|
+
error: persistError instanceof Error ? persistError.message : String(persistError),
|
|
2294
|
+
});
|
|
2295
|
+
}
|
|
2296
|
+
// Start context window monitoring for recovered non-orchestrator session
|
|
2297
|
+
if (role !== ORCHESTRATOR_ROLE && config.teamId && config.memberId) {
|
|
2298
|
+
ContextWindowMonitorService.getInstance().startSessionMonitoring(sessionName, config.memberId, config.teamId, role, runtimeType);
|
|
2299
|
+
}
|
|
2300
|
+
// Start OAuth relogin monitoring for recovered session
|
|
2301
|
+
OAuthReloginMonitorService.getInstance().startMonitoring(sessionName, runtimeType);
|
|
2302
|
+
return {
|
|
2303
|
+
success: true,
|
|
2304
|
+
sessionName,
|
|
2305
|
+
message: 'Agent session recovered and registered successfully',
|
|
2306
|
+
};
|
|
2307
|
+
}
|
|
2308
|
+
// Step 3: Last resort - fall back to killing session for clean restart
|
|
2309
|
+
this.logger.warn('All recovery attempts failed, falling back to session recreation', {
|
|
2310
|
+
sessionName,
|
|
2311
|
+
});
|
|
2312
|
+
await (await this.getSessionHelper()).killSession(sessionName);
|
|
2313
|
+
await delay(1000); // Wait for cleanup
|
|
2314
|
+
}
|
|
2315
|
+
// ===== In-process Crewly Agent branch =====
|
|
2316
|
+
// Crewly Agent runs inside the Node.js process — no PTY session needed.
|
|
2317
|
+
if (runtimeType === RUNTIME_TYPES.CREWLY_AGENT) {
|
|
2318
|
+
this.logger.info('Starting in-process Crewly Agent runtime', { sessionName, role });
|
|
2319
|
+
const crewlyRuntime = this.createRuntimeService(runtimeType);
|
|
2320
|
+
// Look up modelId for this session.
|
|
2321
|
+
//
|
|
2322
|
+
// Resolution order:
|
|
2323
|
+
// 1. Team-member lookup (storage.service.getTeams iterates persisted
|
|
2324
|
+
// team configs, matched by config.memberId).
|
|
2325
|
+
// 2. Orchestrator fallback — storage.service.getTeams() explicitly
|
|
2326
|
+
// EXCLUDES the orchestrator directory (storage.service.ts:413),
|
|
2327
|
+
// so the orchestrator's modelId lives in teams/orchestrator/config.json
|
|
2328
|
+
// and must be read separately via getOrchestratorStatus().
|
|
2329
|
+
//
|
|
2330
|
+
// Without this orchestrator branch, the orchestrator's modelId would
|
|
2331
|
+
// silently default to DEFAULT_MODEL even when configured.
|
|
2332
|
+
let memberModelId;
|
|
2333
|
+
if (config.memberId) {
|
|
2334
|
+
try {
|
|
2335
|
+
const teams = await this.storageService.getTeams();
|
|
2336
|
+
for (const team of teams) {
|
|
2337
|
+
const member = team.members?.find(m => m.id === config.memberId);
|
|
2338
|
+
if (member?.modelId) {
|
|
2339
|
+
memberModelId = member.modelId;
|
|
2340
|
+
break;
|
|
2341
|
+
}
|
|
2342
|
+
}
|
|
2343
|
+
}
|
|
2344
|
+
catch {
|
|
2345
|
+
// Non-critical — fall back to orchestrator/default model
|
|
2346
|
+
}
|
|
2347
|
+
}
|
|
2348
|
+
// Orchestrator fallback: getTeams() doesn't surface the orchestrator,
|
|
2349
|
+
// so look it up via getOrchestratorStatus when this is the orchestrator
|
|
2350
|
+
// session (or its virtual member id) and no team-level modelId was found.
|
|
2351
|
+
const isOrchestrator = sessionName === ORCHESTRATOR_SESSION_NAME
|
|
2352
|
+
|| config.memberId === 'orchestrator-member';
|
|
2353
|
+
if (!memberModelId && isOrchestrator) {
|
|
2354
|
+
try {
|
|
2355
|
+
const orchestratorStatus = await this.storageService.getOrchestratorStatus();
|
|
2356
|
+
if (orchestratorStatus?.modelId) {
|
|
2357
|
+
memberModelId = orchestratorStatus.modelId;
|
|
2358
|
+
}
|
|
2359
|
+
}
|
|
2360
|
+
catch {
|
|
2361
|
+
// Non-critical — fall back to default model
|
|
2362
|
+
}
|
|
2363
|
+
}
|
|
2364
|
+
// Parse modelId into model config (falls back to DEFAULT_MODEL)
|
|
2365
|
+
const { parseModelId } = await import('./crewly-agent/types.js');
|
|
2366
|
+
const modelConfig = memberModelId ? { model: parseModelId(memberModelId) } : {};
|
|
2367
|
+
// Determine role name for system prompt lookup
|
|
2368
|
+
const roleName = role === ORCHESTRATOR_ROLE ? 'orchestrator' : role.toLowerCase().replace(/\s+/g, '-');
|
|
2369
|
+
const memberId = config.memberId;
|
|
2370
|
+
await crewlyRuntime.initializeInProcess(sessionName, { projectPath, memberId, ...modelConfig }, roleName);
|
|
2371
|
+
// Track the in-process runtime for message routing.
|
|
2372
|
+
// Mirror to the module-level registry so callers without an
|
|
2373
|
+
// AgentRegistrationService reference (e.g. orchestrator-status
|
|
2374
|
+
// service) can probe runtime liveness — fixes B0 isActive bug.
|
|
2375
|
+
this.inProcessRuntimes.set(sessionName, crewlyRuntime);
|
|
2376
|
+
registerInProcessRuntime(sessionName, crewlyRuntime);
|
|
2377
|
+
// Auto-register agent as active — crewly-agent cannot run bash scripts,
|
|
2378
|
+
// so we register on its behalf instead of relying on the register_self tool.
|
|
2379
|
+
try {
|
|
2380
|
+
const registerResult = await this.registerMemberActive(sessionName, role, memberId);
|
|
2381
|
+
this.logger.info('Auto-registered crewly-agent as active', {
|
|
2382
|
+
sessionName, role, success: registerResult,
|
|
2383
|
+
});
|
|
2384
|
+
}
|
|
2385
|
+
catch (registerErr) {
|
|
2386
|
+
this.logger.warn('Auto-registration failed (non-fatal)', {
|
|
2387
|
+
sessionName,
|
|
2388
|
+
error: registerErr instanceof Error ? registerErr.message : String(registerErr),
|
|
2389
|
+
});
|
|
2390
|
+
}
|
|
2391
|
+
// For crewly-agent, the system prompt is already loaded during initializeInProcess().
|
|
2392
|
+
// Do NOT deliver the full registration prompt as a message — that would trigger
|
|
2393
|
+
// a redundant generateText call (60K chars → rate limits). Instead, send a
|
|
2394
|
+
// lightweight activation message that tells the agent to start working.
|
|
2395
|
+
//
|
|
2396
|
+
// B0/B1 cold-start invariant: the orchestrator's `register_self` is performed
|
|
2397
|
+
// by registerMemberActive() above (which writes directly to storage), so the
|
|
2398
|
+
// orchestrator does NOT need an activation message. Skipping it here keeps
|
|
2399
|
+
// `conversationHistory.messageCount === 0` after a fresh setup, which B1's
|
|
2400
|
+
// fresh-install detector relies on. Subordinate agents still receive the
|
|
2401
|
+
// activation kickoff because they are not auto-registered via storage.
|
|
2402
|
+
if (sessionName !== ORCHESTRATOR_SESSION_NAME) {
|
|
2403
|
+
crewlyRuntime.handleMessage(`You are now active as "${role}" (session: ${sessionName}). ` +
|
|
2404
|
+
'Your system prompt is already loaded. Begin by calling register_self, ' +
|
|
2405
|
+
'then wait for tasks.').catch(promptError => {
|
|
2406
|
+
this.logger.warn('Initial activation message failed (non-fatal for crewly-agent)', {
|
|
2407
|
+
sessionName,
|
|
2408
|
+
error: promptError instanceof Error ? promptError.message : String(promptError),
|
|
2409
|
+
});
|
|
2410
|
+
});
|
|
2411
|
+
}
|
|
2412
|
+
else {
|
|
2413
|
+
this.logger.debug('Skipping initial activation message for orchestrator (cold-start invariant)', {
|
|
2414
|
+
sessionName,
|
|
2415
|
+
});
|
|
2416
|
+
}
|
|
2417
|
+
// Start context window monitoring if applicable
|
|
2418
|
+
if (role !== ORCHESTRATOR_ROLE && config.teamId && config.memberId) {
|
|
2419
|
+
ContextWindowMonitorService.getInstance().startSessionMonitoring(sessionName, config.memberId, config.teamId, role, runtimeType);
|
|
2420
|
+
}
|
|
2421
|
+
return {
|
|
2422
|
+
success: true,
|
|
2423
|
+
sessionName,
|
|
2424
|
+
message: 'Crewly Agent in-process runtime initialized and registered',
|
|
2425
|
+
};
|
|
2426
|
+
}
|
|
2427
|
+
// Create new session (same approach for both orchestrator and team members)
|
|
2428
|
+
// #222: Use projectRoot (crewly install dir) as fallback instead of process.cwd()
|
|
2429
|
+
const cwdToUse = projectPath || this.projectRoot || process.cwd();
|
|
2430
|
+
this.logger.info('Creating PTY session', {
|
|
2431
|
+
sessionName,
|
|
2432
|
+
cwd: cwdToUse,
|
|
2433
|
+
});
|
|
2434
|
+
try {
|
|
2435
|
+
const createdSession = await sessionHelper.createSession(sessionName, cwdToUse);
|
|
2436
|
+
this.logger.info('PTY session created successfully', {
|
|
2437
|
+
sessionName,
|
|
2438
|
+
pid: createdSession.pid,
|
|
2439
|
+
cwd: createdSession.cwd,
|
|
2440
|
+
});
|
|
2441
|
+
}
|
|
2442
|
+
catch (createError) {
|
|
2443
|
+
this.logger.error('Failed to create PTY session', {
|
|
2444
|
+
sessionName,
|
|
2445
|
+
cwd: cwdToUse,
|
|
2446
|
+
error: createError instanceof Error ? createError.message : String(createError),
|
|
2447
|
+
stack: createError instanceof Error ? createError.stack : undefined,
|
|
2448
|
+
});
|
|
2449
|
+
throw createError;
|
|
2450
|
+
}
|
|
2451
|
+
// Verify session was created
|
|
2452
|
+
const sessionCreatedCheck = sessionHelper.sessionExists(sessionName);
|
|
2453
|
+
this.logger.info('Session creation verification', {
|
|
2454
|
+
sessionName,
|
|
2455
|
+
exists: sessionCreatedCheck,
|
|
2456
|
+
});
|
|
2457
|
+
if (!sessionCreatedCheck) {
|
|
2458
|
+
throw new Error(`Session '${sessionName}' was not created successfully`);
|
|
2459
|
+
}
|
|
2460
|
+
// Register session for state persistence so it survives restarts
|
|
2461
|
+
try {
|
|
2462
|
+
const persistence = getSessionStatePersistence();
|
|
2463
|
+
persistence.registerSession(sessionName, {
|
|
2464
|
+
cwd: cwdToUse,
|
|
2465
|
+
command: process.env.SHELL || '/bin/bash',
|
|
2466
|
+
args: [],
|
|
2467
|
+
}, runtimeType, role, config.teamId, config.memberId);
|
|
2468
|
+
}
|
|
2469
|
+
catch (persistError) {
|
|
2470
|
+
this.logger.warn('Failed to register session for persistence (non-critical)', {
|
|
2471
|
+
sessionName,
|
|
2472
|
+
error: persistError instanceof Error ? persistError.message : String(persistError),
|
|
2473
|
+
});
|
|
2474
|
+
}
|
|
2475
|
+
// Set environment variables for MCP connection
|
|
2476
|
+
await sessionHelper.setEnvironmentVariable(sessionName, ENV_CONSTANTS.CREWLY_SESSION_NAME, sessionName);
|
|
2477
|
+
await sessionHelper.setEnvironmentVariable(sessionName, ENV_CONSTANTS.CREWLY_ROLE, role);
|
|
2478
|
+
await sessionHelper.setEnvironmentVariable(sessionName, ENV_CONSTANTS.CREWLY_API_URL, `http://localhost:${WEB_CONSTANTS.PORTS.BACKEND}`);
|
|
2479
|
+
// #187: Set project path so memory skills can auto-inject it
|
|
2480
|
+
await sessionHelper.setEnvironmentVariable(sessionName, ENV_CONSTANTS.CREWLY_PROJECT_PATH, cwdToUse);
|
|
2481
|
+
// #222: Set install dir so agents can resolve skill paths regardless of CWD
|
|
2482
|
+
await sessionHelper.setEnvironmentVariable(sessionName, ENV_CONSTANTS.CREWLY_INSTALL_DIR, this.projectRoot);
|
|
2483
|
+
// Inject API keys from settings (with override chain) for all runtimes
|
|
2484
|
+
const settingsService = getSettingsService();
|
|
2485
|
+
const runtimeContext = { runtime: runtimeType };
|
|
2486
|
+
// Gemini key — needed by gemini-cli and crewly-agent
|
|
2487
|
+
const geminiKey = await settingsService.getApiKey('gemini', runtimeContext);
|
|
2488
|
+
if (geminiKey) {
|
|
2489
|
+
await sessionHelper.setEnvironmentVariable(sessionName, 'GOOGLE_GENERATIVE_AI_API_KEY', geminiKey);
|
|
2490
|
+
await sessionHelper.setEnvironmentVariable(sessionName, ENV_CONSTANTS.GEMINI_API_KEY, geminiKey);
|
|
2491
|
+
}
|
|
2492
|
+
// Anthropic key — needed by claude-code and crewly-agent
|
|
2493
|
+
const anthropicKey = await settingsService.getApiKey('anthropic', runtimeContext);
|
|
2494
|
+
if (anthropicKey) {
|
|
2495
|
+
await sessionHelper.setEnvironmentVariable(sessionName, 'ANTHROPIC_API_KEY', anthropicKey);
|
|
2496
|
+
}
|
|
2497
|
+
// OpenAI key — needed by codex-cli and crewly-agent
|
|
2498
|
+
const openaiKey = await settingsService.getApiKey('openai', runtimeContext);
|
|
2499
|
+
if (openaiKey) {
|
|
2500
|
+
await sessionHelper.setEnvironmentVariable(sessionName, 'OPENAI_API_KEY', openaiKey);
|
|
2501
|
+
}
|
|
2502
|
+
// Token tracking telemetry — inject env vars for runtimes that need them
|
|
2503
|
+
const settings = await settingsService.getSettings();
|
|
2504
|
+
if (settings.general?.tokenTracking && runtimeType === RUNTIME_TYPES.CLAUDE_CODE) {
|
|
2505
|
+
await sessionHelper.setEnvironmentVariable(sessionName, ENV_CONSTANTS.CLAUDE_CODE_ENABLE_TELEMETRY, '1');
|
|
2506
|
+
this.logger.debug('Token tracking enabled: set CLAUDE_CODE_ENABLE_TELEMETRY=1', { sessionName });
|
|
2507
|
+
}
|
|
2508
|
+
this.logger.info('Agent session created and environment variables set, initializing with registration', {
|
|
2509
|
+
sessionName,
|
|
2510
|
+
role,
|
|
2511
|
+
runtimeType,
|
|
2512
|
+
});
|
|
2513
|
+
// Use the existing unified registration system
|
|
2514
|
+
// #227: Claude Code gets extended timeout for PI protection evaluation
|
|
2515
|
+
const timeout = role === ORCHESTRATOR_ROLE
|
|
2516
|
+
? AGENT_TIMEOUTS.ORCHESTRATOR_INITIALIZATION
|
|
2517
|
+
: runtimeType === RUNTIME_TYPES.CLAUDE_CODE
|
|
2518
|
+
? AGENT_TIMEOUTS.CLAUDE_CODE_INITIALIZATION
|
|
2519
|
+
: AGENT_TIMEOUTS.REGULAR_AGENT_INITIALIZATION;
|
|
2520
|
+
const initResult = await this.initializeAgentWithRegistration(sessionName, role, projectPath, timeout, memberId, runtimeType, runtimeFlags, config.additionalAllowlistPaths);
|
|
2521
|
+
if (!initResult.success) {
|
|
2522
|
+
return {
|
|
2523
|
+
success: false,
|
|
2524
|
+
sessionName,
|
|
2525
|
+
error: initResult.error || 'Failed to initialize and register agent',
|
|
2526
|
+
};
|
|
2527
|
+
}
|
|
2528
|
+
// Start context window monitoring for newly created non-orchestrator session
|
|
2529
|
+
if (role !== ORCHESTRATOR_ROLE && config.teamId && config.memberId) {
|
|
2530
|
+
ContextWindowMonitorService.getInstance().startSessionMonitoring(sessionName, config.memberId, config.teamId, role, runtimeType);
|
|
2531
|
+
}
|
|
2532
|
+
// Start OAuth relogin monitoring for newly created session
|
|
2533
|
+
OAuthReloginMonitorService.getInstance().startMonitoring(sessionName, runtimeType);
|
|
2534
|
+
return {
|
|
2535
|
+
success: true,
|
|
2536
|
+
sessionName,
|
|
2537
|
+
message: initResult.message || 'Agent session created and registered successfully',
|
|
2538
|
+
};
|
|
2539
|
+
}
|
|
2540
|
+
catch (error) {
|
|
2541
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
2542
|
+
this.logger.error('Failed to create agent session', {
|
|
2543
|
+
sessionName,
|
|
2544
|
+
role,
|
|
2545
|
+
error: errorMessage,
|
|
2546
|
+
});
|
|
2547
|
+
return {
|
|
2548
|
+
success: false,
|
|
2549
|
+
sessionName,
|
|
2550
|
+
error: errorMessage,
|
|
2551
|
+
};
|
|
2552
|
+
}
|
|
2553
|
+
}
|
|
2554
|
+
/**
|
|
2555
|
+
* Unified session termination that handles both orchestrator and team members
|
|
2556
|
+
* @param sessionName The session to terminate
|
|
2557
|
+
* @param role The role for proper status updates
|
|
2558
|
+
* @returns Promise with success/error information
|
|
2559
|
+
*/
|
|
2560
|
+
async terminateAgentSession(sessionName, role = 'unknown') {
|
|
2561
|
+
try {
|
|
2562
|
+
this.logger.info('Terminating agent session (unified approach)', { sessionName, role });
|
|
2563
|
+
// Stop runtime exit monitoring before killing the session
|
|
2564
|
+
RuntimeExitMonitorService.getInstance().stopMonitoring(sessionName);
|
|
2565
|
+
// Stop OAuth relogin monitoring before killing the session
|
|
2566
|
+
OAuthReloginMonitorService.getInstance().stopMonitoring(sessionName);
|
|
2567
|
+
// Stop context window monitoring before killing the session
|
|
2568
|
+
ContextWindowMonitorService.getInstance().stopSessionMonitoring(sessionName);
|
|
2569
|
+
// Shut down in-process Crewly Agent runtime if present.
|
|
2570
|
+
// Both maps must stay in sync: the local instance map and the
|
|
2571
|
+
// module-level registry consumed by orchestrator-status service.
|
|
2572
|
+
const inProcessRuntime = this.inProcessRuntimes.get(sessionName);
|
|
2573
|
+
if (inProcessRuntime) {
|
|
2574
|
+
inProcessRuntime.shutdown();
|
|
2575
|
+
this.inProcessRuntimes.delete(sessionName);
|
|
2576
|
+
unregisterInProcessRuntime(sessionName);
|
|
2577
|
+
this.logger.info('In-process Crewly Agent runtime shut down', { sessionName });
|
|
2578
|
+
}
|
|
2579
|
+
// Get session helper once to avoid repeated async calls
|
|
2580
|
+
const sessionHelper = await this.getSessionHelper();
|
|
2581
|
+
const sessionExists = sessionHelper.sessionExists(sessionName);
|
|
2582
|
+
if (sessionExists) {
|
|
2583
|
+
// Kill the tmux session
|
|
2584
|
+
await sessionHelper.killSession(sessionName);
|
|
2585
|
+
this.logger.info('Session terminated successfully', { sessionName });
|
|
2586
|
+
}
|
|
2587
|
+
else if (!inProcessRuntime) {
|
|
2588
|
+
this.logger.info('Session already terminated or does not exist', { sessionName });
|
|
2589
|
+
}
|
|
2590
|
+
// Capture session end for memory persistence
|
|
2591
|
+
try {
|
|
2592
|
+
const sessionMemoryService = SessionMemoryService.getInstance();
|
|
2593
|
+
await sessionMemoryService.onSessionEnd(sessionName, role, process.cwd());
|
|
2594
|
+
this.logger.info('Session memory captured on termination', { sessionName, role });
|
|
2595
|
+
}
|
|
2596
|
+
catch (memoryError) {
|
|
2597
|
+
this.logger.warn('Failed to capture session memory on termination (non-critical)', {
|
|
2598
|
+
sessionName,
|
|
2599
|
+
error: memoryError instanceof Error ? memoryError.message : String(memoryError),
|
|
2600
|
+
});
|
|
2601
|
+
}
|
|
2602
|
+
// Update agent status to inactive (works for both orchestrator and team members)
|
|
2603
|
+
await this.storageService.updateAgentStatus(sessionName, CREWLY_CONSTANTS.AGENT_STATUSES.INACTIVE);
|
|
2604
|
+
this.logger.info('Agent status updated to inactive', { sessionName, role });
|
|
2605
|
+
// Unregister from persistence so explicitly stopped agents don't reappear in resume popup
|
|
2606
|
+
try {
|
|
2607
|
+
const persistence = getSessionStatePersistence();
|
|
2608
|
+
persistence.unregisterSession(sessionName);
|
|
2609
|
+
}
|
|
2610
|
+
catch (persistError) {
|
|
2611
|
+
this.logger.warn('Failed to unregister session from persistence (non-critical)', {
|
|
2612
|
+
sessionName,
|
|
2613
|
+
error: persistError instanceof Error ? persistError.message : String(persistError),
|
|
2614
|
+
});
|
|
2615
|
+
}
|
|
2616
|
+
return {
|
|
2617
|
+
success: true,
|
|
2618
|
+
message: sessionExists
|
|
2619
|
+
? 'Agent session terminated successfully'
|
|
2620
|
+
: 'Agent session was already terminated',
|
|
2621
|
+
};
|
|
2622
|
+
}
|
|
2623
|
+
catch (error) {
|
|
2624
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
2625
|
+
this.logger.error('Failed to terminate agent session', {
|
|
2626
|
+
sessionName,
|
|
2627
|
+
role,
|
|
2628
|
+
error: errorMessage,
|
|
2629
|
+
});
|
|
2630
|
+
return {
|
|
2631
|
+
success: false,
|
|
2632
|
+
error: errorMessage,
|
|
2633
|
+
};
|
|
2634
|
+
}
|
|
2635
|
+
}
|
|
2636
|
+
/**
|
|
2637
|
+
* Send a message to any agent session with reliable delivery.
|
|
2638
|
+
* Uses robust delivery mechanism with retry logic to ensure messages
|
|
2639
|
+
* are properly delivered to Claude Code's input.
|
|
2640
|
+
*
|
|
2641
|
+
* @param sessionName - The agent session name
|
|
2642
|
+
* @param message - The message to send
|
|
2643
|
+
* @returns Promise with success status and optional error message
|
|
2644
|
+
*
|
|
2645
|
+
* @example
|
|
2646
|
+
* ```typescript
|
|
2647
|
+
* const result = await agentRegistrationService.sendMessageToAgent(
|
|
2648
|
+
* 'crewly-orc',
|
|
2649
|
+
* '[CHAT:123] Hello, orchestrator!'
|
|
2650
|
+
* );
|
|
2651
|
+
* if (result.success) {
|
|
2652
|
+
* console.log('Message delivered');
|
|
2653
|
+
* } else {
|
|
2654
|
+
* console.error('Delivery failed:', result.error);
|
|
2655
|
+
* }
|
|
2656
|
+
* ```
|
|
2657
|
+
*/
|
|
2658
|
+
async sendMessageToAgent(sessionName, message, runtimeType) {
|
|
2659
|
+
// Per-session delivery serialization: chain this delivery after any
|
|
2660
|
+
// in-flight delivery to the same session. This prevents concurrent
|
|
2661
|
+
// sendMessageWithRetry calls that each send Ctrl+C on retry attempts,
|
|
2662
|
+
// which can kill the runtime when 25+ scheduled checks fire at once.
|
|
2663
|
+
const previousDelivery = this.sessionDeliveryMutex.get(sessionName);
|
|
2664
|
+
let releaseMutex;
|
|
2665
|
+
const currentDelivery = new Promise((r) => { releaseMutex = r; });
|
|
2666
|
+
this.sessionDeliveryMutex.set(sessionName, currentDelivery);
|
|
2667
|
+
if (previousDelivery) {
|
|
2668
|
+
this.logger.info('Waiting for in-flight delivery to complete before sending', {
|
|
2669
|
+
sessionName,
|
|
2670
|
+
messageLength: message.length,
|
|
2671
|
+
});
|
|
2672
|
+
await previousDelivery.catch(() => { });
|
|
2673
|
+
}
|
|
2674
|
+
try {
|
|
2675
|
+
if (!message || typeof message !== 'string') {
|
|
2676
|
+
return {
|
|
2677
|
+
success: false,
|
|
2678
|
+
error: 'Message is required and must be a string',
|
|
2679
|
+
};
|
|
2680
|
+
}
|
|
2681
|
+
// Auto-resolve runtime type if not provided.
|
|
2682
|
+
// CRITICAL: defaulting to CLAUDE_CODE is dangerous because
|
|
2683
|
+
// Ctrl+C cleanup (Claude Code behavior) triggers /quit on Gemini CLI.
|
|
2684
|
+
if (!runtimeType) {
|
|
2685
|
+
runtimeType = await this.resolveSessionRuntimeType(sessionName);
|
|
2686
|
+
}
|
|
2687
|
+
// ===== In-process Crewly Agent delivery =====
|
|
2688
|
+
// Route directly to the in-process runtime, bypassing PTY entirely.
|
|
2689
|
+
// Fire-and-forget: mirrors PTY runtimes where the write returns immediately.
|
|
2690
|
+
// The LLM processes the message asynchronously in the background.
|
|
2691
|
+
//
|
|
2692
|
+
// IMPORTANT: Always check inProcessRuntimes map first, regardless of
|
|
2693
|
+
// the runtimeType parameter. This makes delivery robust against runtime
|
|
2694
|
+
// type misresolution — e.g., when storage lookup fails and defaults to
|
|
2695
|
+
// claude-code for what is actually a crewly-agent session. Without this
|
|
2696
|
+
// fallback, messages to crewly-agent sessions silently fail when routed
|
|
2697
|
+
// through the PTY path (no PTY session exists).
|
|
2698
|
+
const crewlyRuntime = this.inProcessRuntimes.get(sessionName);
|
|
2699
|
+
if (crewlyRuntime || runtimeType === RUNTIME_TYPES.CREWLY_AGENT) {
|
|
2700
|
+
if (!crewlyRuntime || !crewlyRuntime.isReady()) {
|
|
2701
|
+
return {
|
|
2702
|
+
success: false,
|
|
2703
|
+
error: `Crewly Agent runtime for '${sessionName}' is not initialized`,
|
|
2704
|
+
};
|
|
2705
|
+
}
|
|
2706
|
+
// Extract conversationId from [CHAT:xxx] or [GCHAT:xxx ...] prefix for response routing
|
|
2707
|
+
const chatPrefixMatch = message.match(/^\[(?:G?CHAT):([^\]\s]+)[^\]]*\]\s*/);
|
|
2708
|
+
const incomingConversationId = chatPrefixMatch?.[1];
|
|
2709
|
+
// Extract Slack context from [SLACK:channelId:threadTs] marker if present (Bug 5).
|
|
2710
|
+
// This allows crewly-agent to auto-fill reply_slack with the correct thread.
|
|
2711
|
+
let slackMetadata;
|
|
2712
|
+
const slackPrefixMatch = message.match(/\[SLACK:([^:\]]+)(?::([^\]]+))?\]/);
|
|
2713
|
+
if (slackPrefixMatch) {
|
|
2714
|
+
slackMetadata = { channelId: slackPrefixMatch[1] };
|
|
2715
|
+
if (slackPrefixMatch[2])
|
|
2716
|
+
slackMetadata.threadTs = slackPrefixMatch[2];
|
|
2717
|
+
}
|
|
2718
|
+
// Process message asynchronously — don't block the caller
|
|
2719
|
+
crewlyRuntime.handleMessage(message, slackMetadata)
|
|
2720
|
+
.then((result) => {
|
|
2721
|
+
this.logger.info('Crewly Agent finished processing message', {
|
|
2722
|
+
sessionName, messageLength: message.length,
|
|
2723
|
+
responseLength: result.text?.length ?? 0,
|
|
2724
|
+
});
|
|
2725
|
+
// Route response text back to the originating chat conversation.
|
|
2726
|
+
// Without this, crewly-agent responses are discarded — the agent
|
|
2727
|
+
// has no terminal output for the NOTIFY pathway used by PTY runtimes.
|
|
2728
|
+
//
|
|
2729
|
+
// For Slack-sourced messages: check if the agent already sent a
|
|
2730
|
+
// reply via reply_slack tool during processing. If so, skip routing
|
|
2731
|
+
// to avoid duplicates. If not, route normally to ensure delivery.
|
|
2732
|
+
const agentAlreadyReplied = result.toolCalls?.some(tc => tc.toolName === 'reply_slack' || tc.toolName === 'reply-slack') ?? false;
|
|
2733
|
+
if (result.text && incomingConversationId && !agentAlreadyReplied) {
|
|
2734
|
+
this.routeInProcessResponseToChat(sessionName, result.text, incomingConversationId);
|
|
2735
|
+
}
|
|
2736
|
+
else if (agentAlreadyReplied) {
|
|
2737
|
+
this.logger.debug('Skipping chat routing — agent already replied via reply_slack', {
|
|
2738
|
+
sessionName, conversationId: incomingConversationId,
|
|
2739
|
+
});
|
|
2740
|
+
}
|
|
2741
|
+
// Auto-route to Slack when the message originated from Slack and the
|
|
2742
|
+
// agent finished with text-only output (toolCalls=0, finishReason=stop)
|
|
2743
|
+
// without explicitly invoking the reply_slack tool. Without this hook,
|
|
2744
|
+
// the in-process AI SDK runtime drops Slack replies silently — unlike
|
|
2745
|
+
// claude-code PTY where TerminalGateway tails stdout into SlackBridge,
|
|
2746
|
+
// the in-process runtime has no stream the bridge can listen to.
|
|
2747
|
+
//
|
|
2748
|
+
// Skipped when the agent already called reply_slack (which posts via
|
|
2749
|
+
// /slack/send) to prevent double-posting on the same thread.
|
|
2750
|
+
if (slackMetadata?.channelId && result.text && !agentAlreadyReplied) {
|
|
2751
|
+
this.routeInProcessResponseToSlack(sessionName, result.text, {
|
|
2752
|
+
channelId: slackMetadata.channelId,
|
|
2753
|
+
threadTs: slackMetadata.threadTs,
|
|
2754
|
+
});
|
|
2755
|
+
}
|
|
2756
|
+
})
|
|
2757
|
+
.catch(async (agentError) => {
|
|
2758
|
+
const errMsg = agentError instanceof Error ? agentError.message : String(agentError);
|
|
2759
|
+
this.logger.error('Crewly Agent message handling failed', {
|
|
2760
|
+
sessionName, error: errMsg,
|
|
2761
|
+
messageLength: message.length,
|
|
2762
|
+
messagePreview: message.substring(0, 200),
|
|
2763
|
+
});
|
|
2764
|
+
// Log to InProcessLogBuffer so orchestrator can see the error via get-agent-logs
|
|
2765
|
+
try {
|
|
2766
|
+
const { InProcessLogBuffer } = await import('./crewly-agent/in-process-log-buffer.js');
|
|
2767
|
+
InProcessLogBuffer.getInstance().append(sessionName, 'error', `Message handling failed: ${errMsg}`);
|
|
2768
|
+
}
|
|
2769
|
+
catch { /* non-critical */ }
|
|
2770
|
+
// Update workingStatus to idle so the agent is not stuck as in_progress
|
|
2771
|
+
try {
|
|
2772
|
+
const memberInfo = await this.storageService.findMemberBySessionName(sessionName);
|
|
2773
|
+
if (memberInfo) {
|
|
2774
|
+
memberInfo.member.workingStatus = 'idle';
|
|
2775
|
+
memberInfo.member.updatedAt = new Date().toISOString();
|
|
2776
|
+
await this.storageService.saveTeam(memberInfo.team);
|
|
2777
|
+
this.logger.info('Reset workingStatus to idle after crewly-agent error', { sessionName });
|
|
2778
|
+
}
|
|
2779
|
+
}
|
|
2780
|
+
catch (statusError) {
|
|
2781
|
+
this.logger.warn('Failed to reset workingStatus after error', {
|
|
2782
|
+
sessionName,
|
|
2783
|
+
error: statusError instanceof Error ? statusError.message : String(statusError),
|
|
2784
|
+
});
|
|
2785
|
+
}
|
|
2786
|
+
});
|
|
2787
|
+
this.logger.info('Message dispatched to in-process Crewly Agent', {
|
|
2788
|
+
sessionName, messageLength: message.length,
|
|
2789
|
+
});
|
|
2790
|
+
return { success: true, message: 'Message delivered to Crewly Agent' };
|
|
2791
|
+
}
|
|
2792
|
+
// Get session helper once for this method
|
|
2793
|
+
const sessionHelper = await this.getSessionHelper();
|
|
2794
|
+
// Check if session exists
|
|
2795
|
+
if (!sessionHelper.sessionExists(sessionName)) {
|
|
2796
|
+
return {
|
|
2797
|
+
success: false,
|
|
2798
|
+
error: `Session '${sessionName}' does not exist`,
|
|
2799
|
+
};
|
|
2800
|
+
}
|
|
2801
|
+
// Guard: refuse to deliver if the runtime process has exited.
|
|
2802
|
+
// When Claude Code exits (e.g. context exhaustion), the PTY shell
|
|
2803
|
+
// stays alive. Writing to it would execute garbage as shell commands.
|
|
2804
|
+
const backend = typeof sessionHelper.getBackend === 'function'
|
|
2805
|
+
? sessionHelper.getBackend()
|
|
2806
|
+
: getSessionBackendSync();
|
|
2807
|
+
const childAlive = backend?.isChildProcessAlive?.(sessionName);
|
|
2808
|
+
if (childAlive === false) {
|
|
2809
|
+
this.logger.error('Runtime process not alive, refusing to deliver message', {
|
|
2810
|
+
sessionName,
|
|
2811
|
+
messageLength: message.length,
|
|
2812
|
+
});
|
|
2813
|
+
return {
|
|
2814
|
+
success: false,
|
|
2815
|
+
error: 'Runtime has exited — no child process in session',
|
|
2816
|
+
};
|
|
2817
|
+
}
|
|
2818
|
+
// Use robust message delivery with proper waiting mechanism.
|
|
2819
|
+
// Gemini CLI: use 2 attempts — one retry is needed for TUI focus
|
|
2820
|
+
// issues (Tab+Enter recovery). Previously set to 1 which caused
|
|
2821
|
+
// Google Chat messages to fail permanently on first TUI hiccup.
|
|
2822
|
+
const maxDeliveryAttempts = runtimeType === RUNTIME_TYPES.GEMINI_CLI ? 2 : 3;
|
|
2823
|
+
const delivered = await this.sendMessageWithRetry(sessionName, message, maxDeliveryAttempts, runtimeType);
|
|
2824
|
+
if (!delivered) {
|
|
2825
|
+
// Check if the agent is actively processing (busy) — queue for later delivery.
|
|
2826
|
+
// Use PTY idle time instead of regex patterns for robust cross-runtime detection.
|
|
2827
|
+
const idleMs = PtyActivityTrackerService.getInstance().getIdleTimeMs(sessionName);
|
|
2828
|
+
const isBusy = idleMs < SESSION_COMMAND_DELAYS.AGENT_BUSY_IDLE_THRESHOLD_MS;
|
|
2829
|
+
if (isBusy) {
|
|
2830
|
+
// #236: Queue message for delivery when agent becomes idle
|
|
2831
|
+
SubAgentMessageQueue.getInstance().enqueue(sessionName, message);
|
|
2832
|
+
this.logger.info('Agent busy — message queued for later delivery', {
|
|
2833
|
+
sessionName,
|
|
2834
|
+
messageLength: message.length,
|
|
2835
|
+
});
|
|
2836
|
+
return {
|
|
2837
|
+
success: true,
|
|
2838
|
+
queued: true,
|
|
2839
|
+
message: '[AGENT_BUSY] Message queued for delivery when agent becomes idle',
|
|
2840
|
+
};
|
|
2841
|
+
}
|
|
2842
|
+
return {
|
|
2843
|
+
success: false,
|
|
2844
|
+
error: 'Failed to deliver message after multiple attempts',
|
|
2845
|
+
};
|
|
2846
|
+
}
|
|
2847
|
+
this.logger.info('Message sent to agent successfully', {
|
|
2848
|
+
sessionName,
|
|
2849
|
+
messageLength: message.length,
|
|
2850
|
+
});
|
|
2851
|
+
return {
|
|
2852
|
+
success: true,
|
|
2853
|
+
message: 'Message sent to agent successfully',
|
|
2854
|
+
};
|
|
2855
|
+
}
|
|
2856
|
+
catch (error) {
|
|
2857
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
2858
|
+
this.logger.error('Failed to send message to agent', {
|
|
2859
|
+
sessionName,
|
|
2860
|
+
error: errorMessage,
|
|
2861
|
+
});
|
|
2862
|
+
return {
|
|
2863
|
+
success: false,
|
|
2864
|
+
error: errorMessage,
|
|
2865
|
+
};
|
|
2866
|
+
}
|
|
2867
|
+
finally {
|
|
2868
|
+
releaseMutex();
|
|
2869
|
+
if (this.sessionDeliveryMutex.get(sessionName) === currentDelivery) {
|
|
2870
|
+
this.sessionDeliveryMutex.delete(sessionName);
|
|
2871
|
+
}
|
|
2872
|
+
}
|
|
2873
|
+
}
|
|
2874
|
+
/**
|
|
2875
|
+
* Wait for an agent session to be at a ready prompt before sending messages.
|
|
2876
|
+
*
|
|
2877
|
+
* Subscribes to terminal output and polls capturePane to detect when the
|
|
2878
|
+
* agent returns to an input prompt. This is critical for sequential message
|
|
2879
|
+
* processing: after the orchestrator responds to a message it may continue
|
|
2880
|
+
* working (managing agents, running commands) before returning to prompt.
|
|
2881
|
+
*
|
|
2882
|
+
* @param sessionName - The session name to wait on
|
|
2883
|
+
* @param timeoutMs - Maximum time to wait (default 120s)
|
|
2884
|
+
* @returns true if agent is ready, false if timed out
|
|
2885
|
+
*/
|
|
2886
|
+
async waitForAgentReady(sessionName, timeoutMs = EVENT_DELIVERY_CONSTANTS.AGENT_READY_TIMEOUT, runtimeType) {
|
|
2887
|
+
// In-process Crewly Agent is always "ready" (no prompt to wait for)
|
|
2888
|
+
const inProcessRuntime = this.inProcessRuntimes.get(sessionName);
|
|
2889
|
+
if (inProcessRuntime) {
|
|
2890
|
+
return inProcessRuntime.isReady();
|
|
2891
|
+
}
|
|
2892
|
+
const sessionHelper = await this.getSessionHelper();
|
|
2893
|
+
// Check if session exists
|
|
2894
|
+
if (!sessionHelper.sessionExists(sessionName)) {
|
|
2895
|
+
this.logger.warn('Session does not exist for waitForAgentReady', { sessionName });
|
|
2896
|
+
return false;
|
|
2897
|
+
}
|
|
2898
|
+
// Quick check - already at prompt?
|
|
2899
|
+
const currentOutput = sessionHelper.capturePane(sessionName);
|
|
2900
|
+
if (this.isClaudeAtPrompt(currentOutput, runtimeType)) {
|
|
2901
|
+
this.logger.debug('Agent already at prompt', { sessionName });
|
|
2902
|
+
return true;
|
|
2903
|
+
}
|
|
2904
|
+
this.logger.info('Waiting for agent to return to prompt', { sessionName, timeoutMs });
|
|
2905
|
+
const session = sessionHelper.getSession(sessionName);
|
|
2906
|
+
if (!session) {
|
|
2907
|
+
return false;
|
|
2908
|
+
}
|
|
2909
|
+
const waitStartMs = Date.now();
|
|
2910
|
+
return new Promise((resolve) => {
|
|
2911
|
+
let resolved = false;
|
|
2912
|
+
let pollCount = 0;
|
|
2913
|
+
const pollInterval = EVENT_DELIVERY_CONSTANTS.AGENT_READY_POLL_INTERVAL;
|
|
2914
|
+
const cleanup = () => {
|
|
2915
|
+
if (resolved)
|
|
2916
|
+
return;
|
|
2917
|
+
resolved = true;
|
|
2918
|
+
clearTimeout(timeoutId);
|
|
2919
|
+
clearInterval(pollId);
|
|
2920
|
+
clearInterval(deepScanId);
|
|
2921
|
+
unsubscribe();
|
|
2922
|
+
};
|
|
2923
|
+
// Timeout - give up waiting
|
|
2924
|
+
const timeoutId = setTimeout(() => {
|
|
2925
|
+
this.logger.warn('Timed out waiting for agent to be ready', { sessionName, timeoutMs });
|
|
2926
|
+
cleanup();
|
|
2927
|
+
resolve(false);
|
|
2928
|
+
}, timeoutMs);
|
|
2929
|
+
// Poll capturePane periodically as a fallback
|
|
2930
|
+
const pollId = setInterval(() => {
|
|
2931
|
+
if (resolved)
|
|
2932
|
+
return;
|
|
2933
|
+
pollCount++;
|
|
2934
|
+
const output = sessionHelper.capturePane(sessionName);
|
|
2935
|
+
if (this.isClaudeAtPrompt(output, runtimeType)) {
|
|
2936
|
+
const elapsedMs = Date.now() - waitStartMs;
|
|
2937
|
+
this.logger.info('Agent ready after polling', { sessionName, pollCount, elapsedMs });
|
|
2938
|
+
cleanup();
|
|
2939
|
+
resolve(true);
|
|
2940
|
+
}
|
|
2941
|
+
}, pollInterval);
|
|
2942
|
+
// Deep scan with larger buffer every DEEP_SCAN_INTERVAL to catch prompts
|
|
2943
|
+
// that the fast poll misses (e.g. when prompt is beyond the default 200 lines)
|
|
2944
|
+
const deepScanId = setInterval(() => {
|
|
2945
|
+
if (resolved)
|
|
2946
|
+
return;
|
|
2947
|
+
pollCount++;
|
|
2948
|
+
const output = sessionHelper.capturePane(sessionName, EVENT_DELIVERY_CONSTANTS.DEEP_SCAN_LINES);
|
|
2949
|
+
if (this.isClaudeAtPrompt(output, runtimeType)) {
|
|
2950
|
+
const elapsedMs = Date.now() - waitStartMs;
|
|
2951
|
+
this.logger.info('Agent ready after polling', { sessionName, pollCount, elapsedMs });
|
|
2952
|
+
cleanup();
|
|
2953
|
+
resolve(true);
|
|
2954
|
+
}
|
|
2955
|
+
}, EVENT_DELIVERY_CONSTANTS.DEEP_SCAN_INTERVAL);
|
|
2956
|
+
// Also subscribe to terminal data for faster detection
|
|
2957
|
+
const unsubscribe = session.onData((data) => {
|
|
2958
|
+
if (resolved)
|
|
2959
|
+
return;
|
|
2960
|
+
// Strip ANSI escape sequences before testing — raw PTY data contains
|
|
2961
|
+
// cursor positioning, color codes, etc. that break pattern matching (#106)
|
|
2962
|
+
const cleanData = stripAnsiCodes(data);
|
|
2963
|
+
// Check each line for prompt pattern (string-based, no regex)
|
|
2964
|
+
const hasPromptInStream = cleanData.split('\n').some(line => line.trim().length > 0 && isPromptLine(line, runtimeType));
|
|
2965
|
+
if (hasPromptInStream) {
|
|
2966
|
+
// Double-check with capturePane to avoid false positives from partial data
|
|
2967
|
+
const output = sessionHelper.capturePane(sessionName);
|
|
2968
|
+
if (this.isClaudeAtPrompt(output, runtimeType)) {
|
|
2969
|
+
this.logger.debug('Agent at prompt (detected via stream)', { sessionName });
|
|
2970
|
+
cleanup();
|
|
2971
|
+
resolve(true);
|
|
2972
|
+
}
|
|
2973
|
+
}
|
|
2974
|
+
});
|
|
2975
|
+
});
|
|
2976
|
+
}
|
|
2977
|
+
/**
|
|
2978
|
+
* Send message with retry logic for reliable delivery to Claude Code.
|
|
2979
|
+
* Uses SessionCommandHelper.sendMessage() (proven two-step write pattern)
|
|
2980
|
+
* with stuck-message detection and retry on failure.
|
|
2981
|
+
*
|
|
2982
|
+
* @param sessionName - The session name
|
|
2983
|
+
* @param message - The message to send
|
|
2984
|
+
* @param maxAttempts - Maximum number of delivery attempts
|
|
2985
|
+
* @returns true if message was delivered successfully
|
|
2986
|
+
*/
|
|
2987
|
+
async sendMessageWithRetry(sessionName, message, maxAttempts = 3, runtimeType = RUNTIME_TYPES.CLAUDE_CODE) {
|
|
2988
|
+
const sessionHelper = await this.getSessionHelper();
|
|
2989
|
+
const isClaudeCode = runtimeType === RUNTIME_TYPES.CLAUDE_CODE;
|
|
2990
|
+
const isCodexCli = runtimeType === RUNTIME_TYPES.CODEX_CLI;
|
|
2991
|
+
const isGeminiCli = runtimeType === RUNTIME_TYPES.GEMINI_CLI;
|
|
2992
|
+
// Register TUI sessions for TUI prompt-line scanning (Part 1 of scanner).
|
|
2993
|
+
// The scanner itself is started lazily by trackSentMessage() which
|
|
2994
|
+
// runs after every message send, covering all runtimes.
|
|
2995
|
+
if (!isClaudeCode) {
|
|
2996
|
+
this.tuiSessionRegistry.set(sessionName, runtimeType);
|
|
2997
|
+
}
|
|
2998
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
2999
|
+
try {
|
|
3000
|
+
this.logger.debug('Attempting message delivery', {
|
|
3001
|
+
sessionName,
|
|
3002
|
+
attempt,
|
|
3003
|
+
maxAttempts,
|
|
3004
|
+
messageLength: message.length,
|
|
3005
|
+
runtimeType,
|
|
3006
|
+
});
|
|
3007
|
+
// Verify agent is at prompt before sending
|
|
3008
|
+
const output = sessionHelper.capturePane(sessionName);
|
|
3009
|
+
// Gemini CLI stuck-connectivity guard (#128): If the CLI is in a
|
|
3010
|
+
// "Trying to reach <model>" retry loop, it will never process
|
|
3011
|
+
// new messages. Bail out immediately so the caller gets a clear
|
|
3012
|
+
// failure signal and the runtime-exit-monitor can trigger recovery.
|
|
3013
|
+
if (runtimeType === RUNTIME_TYPES.GEMINI_CLI && GEMINI_STUCK_CONNECTIVITY_PATTERN.test(output)) {
|
|
3014
|
+
this.logger.warn('Gemini CLI stuck in connectivity retry loop, aborting message delivery (#128)', {
|
|
3015
|
+
sessionName,
|
|
3016
|
+
attempt,
|
|
3017
|
+
});
|
|
3018
|
+
return false;
|
|
3019
|
+
}
|
|
3020
|
+
if (!this.isClaudeAtPrompt(output, runtimeType)) {
|
|
3021
|
+
if (attempt === maxAttempts) {
|
|
3022
|
+
// On the final attempt, check if the agent is DEFINITELY busy
|
|
3023
|
+
// before force-delivering. Use PTY idle time for robust
|
|
3024
|
+
// cross-runtime detection instead of fragile regex patterns.
|
|
3025
|
+
const idleMs = PtyActivityTrackerService.getInstance().getIdleTimeMs(sessionName);
|
|
3026
|
+
const isBusy = idleMs < SESSION_COMMAND_DELAYS.AGENT_BUSY_IDLE_THRESHOLD_MS;
|
|
3027
|
+
if (isBusy) {
|
|
3028
|
+
this.logger.warn('Agent is busy (processing indicators detected), skipping force delivery', {
|
|
3029
|
+
sessionName,
|
|
3030
|
+
attempt,
|
|
3031
|
+
runtimeType,
|
|
3032
|
+
});
|
|
3033
|
+
return false;
|
|
3034
|
+
}
|
|
3035
|
+
// Not clearly busy — fall back to direct delivery. The agent
|
|
3036
|
+
// may be at a prompt that doesn't match our detection patterns,
|
|
3037
|
+
// or the terminal buffer may be stale.
|
|
3038
|
+
this.logger.warn('Prompt detection failed on final attempt, delivering message directly', {
|
|
3039
|
+
sessionName,
|
|
3040
|
+
attempt,
|
|
3041
|
+
runtimeType,
|
|
3042
|
+
});
|
|
3043
|
+
// Fall through to message delivery below
|
|
3044
|
+
}
|
|
3045
|
+
else {
|
|
3046
|
+
this.logger.debug('Not at prompt, waiting before retry', { sessionName, attempt });
|
|
3047
|
+
await delay(SESSION_COMMAND_DELAYS.CLAUDE_RECOVERY_DELAY);
|
|
3048
|
+
continue;
|
|
3049
|
+
}
|
|
3050
|
+
}
|
|
3051
|
+
// Gemini CLI shell mode guard: if the prompt shows `!` instead of `>`,
|
|
3052
|
+
// input will be executed as a shell command. Send Escape to exit first.
|
|
3053
|
+
if (runtimeType === RUNTIME_TYPES.GEMINI_CLI && this.isGeminiInShellMode(output)) {
|
|
3054
|
+
const escaped = await this.escapeGeminiShellMode(sessionName, sessionHelper);
|
|
3055
|
+
if (!escaped) {
|
|
3056
|
+
this.logger.warn('Could not exit Gemini shell mode, skipping attempt', {
|
|
3057
|
+
sessionName,
|
|
3058
|
+
attempt,
|
|
3059
|
+
});
|
|
3060
|
+
await delay(SESSION_COMMAND_DELAYS.MESSAGE_RETRY_DELAY);
|
|
3061
|
+
continue;
|
|
3062
|
+
}
|
|
3063
|
+
}
|
|
3064
|
+
// Clear any stale text before sending.
|
|
3065
|
+
// Claude Code: Ctrl+C to cancel any pending input — but ONLY on
|
|
3066
|
+
// retry attempts. On the first attempt, waitForAgentReady already
|
|
3067
|
+
// confirmed the agent is idle at the prompt, so Ctrl+C is
|
|
3068
|
+
// unnecessary and can disrupt Claude Code's input handler if the
|
|
3069
|
+
// 300ms settling delay isn't enough.
|
|
3070
|
+
// Gemini CLI (Ink TUI): escalating recovery to refocus input box.
|
|
3071
|
+
// The TUI input can lose focus after idle periods, auto-update
|
|
3072
|
+
// notifications, or dialog overlays. When defocused, writes are
|
|
3073
|
+
// silently consumed by the Ink framework but NOT routed to the
|
|
3074
|
+
// InputPrompt.
|
|
3075
|
+
// Do NOT send Escape (defocuses permanently), Ctrl+C (triggers
|
|
3076
|
+
// /quit on empty prompt), or Ctrl+U (ignored by TUI).
|
|
3077
|
+
//
|
|
3078
|
+
// Recovery strategies (escalating per attempt):
|
|
3079
|
+
// 1. Tab + Enter: Tab triggers Ink's focusNext() which cycles
|
|
3080
|
+
// focus through focusable components. If InputPrompt is the
|
|
3081
|
+
// only/next focusable element, it gets refocused. Enter then
|
|
3082
|
+
// dismisses any overlay or acts as safe no-op on empty prompt.
|
|
3083
|
+
// 2-3. PTY resize + Tab + Enter: Resize sends SIGWINCH to the
|
|
3084
|
+
// child process, forcing Ink to re-render the entire TUI.
|
|
3085
|
+
// This may restore the internal focus state. Then Tab + Enter
|
|
3086
|
+
// for focus cycling and overlay dismissal.
|
|
3087
|
+
if (isClaudeCode) {
|
|
3088
|
+
if (attempt > 1) {
|
|
3089
|
+
// On retry: Ctrl+C to cancel stale input, then PTY resize to force
|
|
3090
|
+
// TUI re-render (SIGWINCH), then Tab to cycle Ink focus.
|
|
3091
|
+
await sessionHelper.sendCtrlC(sessionName);
|
|
3092
|
+
await delay(300);
|
|
3093
|
+
}
|
|
3094
|
+
// PTY resize on ALL attempts to force SIGWINCH → Ink TUI re-render.
|
|
3095
|
+
// Claude Code's Ink TUI can lose internal input focus after stop hooks
|
|
3096
|
+
// (especially ones that error), state transitions, or idle periods.
|
|
3097
|
+
// When defocused, the `❯` prompt is visible in the terminal buffer
|
|
3098
|
+
// but writes are silently consumed by the framework — NOT routed to
|
|
3099
|
+
// the InputPrompt. Tab alone was insufficient; PTY resize forces a
|
|
3100
|
+
// full TUI re-render that reliably restores focus state.
|
|
3101
|
+
// This matches the manual workaround where pressing a key in the
|
|
3102
|
+
// frontend terminal "wakes up" the input handler.
|
|
3103
|
+
try {
|
|
3104
|
+
const session = sessionHelper.getSession(sessionName);
|
|
3105
|
+
if (session) {
|
|
3106
|
+
session.resize(81, 25);
|
|
3107
|
+
await delay(200);
|
|
3108
|
+
session.resize(80, 24);
|
|
3109
|
+
await delay(300);
|
|
3110
|
+
}
|
|
3111
|
+
}
|
|
3112
|
+
catch { /* non-fatal */ }
|
|
3113
|
+
await sessionHelper.sendKey(sessionName, 'Tab');
|
|
3114
|
+
await delay(300);
|
|
3115
|
+
}
|
|
3116
|
+
else if (isCodexCli) {
|
|
3117
|
+
// #246: Codex CLI TUI recovery — simpler than Gemini.
|
|
3118
|
+
// Codex uses Ink-based TUI with › prompt. Avoid Gemini-specific
|
|
3119
|
+
// F12/compress/error-state logic which is irrelevant for Codex.
|
|
3120
|
+
if (attempt > 1) {
|
|
3121
|
+
// PTY resize to trigger SIGWINCH → Ink re-render
|
|
3122
|
+
try {
|
|
3123
|
+
const session = sessionHelper.getSession(sessionName);
|
|
3124
|
+
if (session) {
|
|
3125
|
+
session.resize(81, 25);
|
|
3126
|
+
await delay(200);
|
|
3127
|
+
session.resize(80, 24);
|
|
3128
|
+
await delay(300);
|
|
3129
|
+
}
|
|
3130
|
+
}
|
|
3131
|
+
catch { /* non-fatal */ }
|
|
3132
|
+
// Ctrl+U to clear any stale input text
|
|
3133
|
+
await sessionHelper.sendKey(sessionName, 'C-u');
|
|
3134
|
+
await delay(300);
|
|
3135
|
+
}
|
|
3136
|
+
}
|
|
3137
|
+
else {
|
|
3138
|
+
// Gemini CLI TUI recovery
|
|
3139
|
+
// Detect recent /compress — Ink TUI loses internal focus after
|
|
3140
|
+
// /compress re-renders, causing subsequent messages to be silently
|
|
3141
|
+
// dropped even though the prompt `>` is visible (#114).
|
|
3142
|
+
// Always force PTY resize on attempt 1 if /compress detected.
|
|
3143
|
+
// Also detect ✖ error state (#130) — MCP connection errors
|
|
3144
|
+
// cause a persistent error indicator that steals TUI focus.
|
|
3145
|
+
const recentOutput = sessionHelper.capturePane(sessionName, 40);
|
|
3146
|
+
const compressDetected = recentOutput.includes('/compress') ||
|
|
3147
|
+
recentOutput.includes('Context compressed') ||
|
|
3148
|
+
recentOutput.includes('Compressing context');
|
|
3149
|
+
// Check for Gemini CLI error indicators ONLY in the status bar area (#130).
|
|
3150
|
+
// The ✖ marker must be in the bottom status lines, not in content area —
|
|
3151
|
+
// checking the full 40-line output causes false positives when ✖ appears
|
|
3152
|
+
// in normal response content (e.g., diffs, lists), triggering spurious
|
|
3153
|
+
// F12 keypresses that open DevTools or disrupt the TUI.
|
|
3154
|
+
const statusArea = recentOutput.split('\n').slice(-GEMINI_ERROR_STATE_CONSTANTS.STATUS_AREA_LINES).join('\n');
|
|
3155
|
+
const errorStateDetected = statusArea.includes(GEMINI_ERROR_STATE_CONSTANTS.ERROR_MARKER) ||
|
|
3156
|
+
GEMINI_ERROR_STATE_CONSTANTS.ERROR_COUNT_PATTERN.test(statusArea);
|
|
3157
|
+
const needsResize = attempt > 1 || compressDetected || errorStateDetected;
|
|
3158
|
+
// Force a PTY resize to trigger SIGWINCH, making Ink
|
|
3159
|
+
// re-render the TUI and potentially restore focus state.
|
|
3160
|
+
if (needsResize) {
|
|
3161
|
+
try {
|
|
3162
|
+
const session = sessionHelper.getSession(sessionName);
|
|
3163
|
+
if (session) {
|
|
3164
|
+
// Resize slightly then back to trigger SIGWINCH
|
|
3165
|
+
session.resize(81, 25);
|
|
3166
|
+
await delay(200);
|
|
3167
|
+
session.resize(80, 24);
|
|
3168
|
+
await delay(500);
|
|
3169
|
+
this.logger.debug('PTY resize sent to trigger TUI re-render', {
|
|
3170
|
+
sessionName,
|
|
3171
|
+
attempt,
|
|
3172
|
+
compressDetected,
|
|
3173
|
+
});
|
|
3174
|
+
}
|
|
3175
|
+
}
|
|
3176
|
+
catch (resizeErr) {
|
|
3177
|
+
this.logger.debug('PTY resize failed (non-fatal)', {
|
|
3178
|
+
sessionName,
|
|
3179
|
+
error: resizeErr instanceof Error ? resizeErr.message : String(resizeErr),
|
|
3180
|
+
});
|
|
3181
|
+
}
|
|
3182
|
+
}
|
|
3183
|
+
// If error state detected (✖), dismiss the error overlay first (#130).
|
|
3184
|
+
// F12 toggles the error details panel in Gemini CLI, Enter dismisses
|
|
3185
|
+
// any overlay/notification, and the combination restores the TUI to a
|
|
3186
|
+
// state where the InputPrompt can accept input.
|
|
3187
|
+
if (errorStateDetected) {
|
|
3188
|
+
this.logger.info('Gemini CLI error state detected (✖), dismissing before delivery (#130)', {
|
|
3189
|
+
sessionName,
|
|
3190
|
+
attempt,
|
|
3191
|
+
});
|
|
3192
|
+
// F12 to close error details panel if open
|
|
3193
|
+
await sessionHelper.sendKey(sessionName, 'F12');
|
|
3194
|
+
await delay(300);
|
|
3195
|
+
// Enter to dismiss any remaining overlay/notification
|
|
3196
|
+
await sessionHelper.sendEnter(sessionName);
|
|
3197
|
+
await delay(500);
|
|
3198
|
+
}
|
|
3199
|
+
// Send Tab to cycle Ink focus. In Ink v6, Tab triggers
|
|
3200
|
+
// focusNext() in FocusContext, which moves focus to the next
|
|
3201
|
+
// focusable component (InputPrompt). This works even when the
|
|
3202
|
+
// input is defocused because the Tab handler runs at the Ink
|
|
3203
|
+
// framework level, not the component level.
|
|
3204
|
+
// Send two Tabs to cycle through error/notification components
|
|
3205
|
+
// that may be in the focus chain (#130).
|
|
3206
|
+
await sessionHelper.sendKey(sessionName, 'Tab');
|
|
3207
|
+
await delay(200);
|
|
3208
|
+
await sessionHelper.sendKey(sessionName, 'Tab');
|
|
3209
|
+
await delay(300);
|
|
3210
|
+
// Then Enter to dismiss any notification overlay and ensure
|
|
3211
|
+
// the input is engaged. Enter on an empty `> ` prompt is a
|
|
3212
|
+
// safe no-op (just shows a new blank prompt line).
|
|
3213
|
+
await sessionHelper.sendEnter(sessionName);
|
|
3214
|
+
// Extra settling time after /compress or error state to let Ink TUI stabilize
|
|
3215
|
+
await delay((compressDetected || errorStateDetected) ? 1000 : 500);
|
|
3216
|
+
}
|
|
3217
|
+
// For Gemini CLI: detect and gently escape interactive modes before
|
|
3218
|
+
// sending the message. Avoid Ctrl-C here — Gemini interprets it as
|
|
3219
|
+
// destructive cancellation/quit in some states.
|
|
3220
|
+
if (isGeminiCli) {
|
|
3221
|
+
const preOutput = sessionHelper.capturePane(sessionName, 10);
|
|
3222
|
+
const interactiveModePatterns = [
|
|
3223
|
+
'Select a file', // /resume file picker
|
|
3224
|
+
'❯', // Interactive list selector
|
|
3225
|
+
'Choose an option', // Generic interactive prompt
|
|
3226
|
+
'/resume', // Resume command active
|
|
3227
|
+
];
|
|
3228
|
+
const inInteractiveMode = interactiveModePatterns.some(p => preOutput.includes(p));
|
|
3229
|
+
if (inInteractiveMode) {
|
|
3230
|
+
this.logger.warn('Gemini CLI in interactive mode, using non-destructive recovery', {
|
|
3231
|
+
sessionName,
|
|
3232
|
+
detectedPattern: interactiveModePatterns.find(p => preOutput.includes(p)),
|
|
3233
|
+
});
|
|
3234
|
+
await sessionHelper.sendKey(sessionName, 'Tab');
|
|
3235
|
+
await delay(250);
|
|
3236
|
+
await sessionHelper.sendEnter(sessionName);
|
|
3237
|
+
await delay(1000);
|
|
3238
|
+
}
|
|
3239
|
+
}
|
|
3240
|
+
// Capture output BEFORE sending to detect changes for ALL runtimes.
|
|
3241
|
+
// Claude Code: progressive output-change detection prevents false-positive
|
|
3242
|
+
// Ctrl+C by giving Claude up to 6.5s to start rendering output.
|
|
3243
|
+
// TUI runtimes: same output-change detection as before.
|
|
3244
|
+
// Use a small pane capture (20 lines) to reduce noise from TUI
|
|
3245
|
+
// border redraws that can cause length changes unrelated to delivery.
|
|
3246
|
+
const beforeOutput = sessionHelper.capturePane(sessionName, 20);
|
|
3247
|
+
const beforeLength = beforeOutput.length;
|
|
3248
|
+
// Deduplication guard (#128): Check if the agent is already
|
|
3249
|
+
// processing on ALL attempts (not just retries). A previous
|
|
3250
|
+
// write may have succeeded but verification failed. Re-writing
|
|
3251
|
+
// the same message creates a duplicate in the input buffer.
|
|
3252
|
+
//
|
|
3253
|
+
// IMPORTANT: Only skip the write when we have a positive hash
|
|
3254
|
+
// match (isRecentDuplicate). The `attempt > 1` condition was
|
|
3255
|
+
// removed because the spinner could be from a DIFFERENT message
|
|
3256
|
+
// that the agent is still processing — skipping the write for
|
|
3257
|
+
// a new message causes silent message loss.
|
|
3258
|
+
{
|
|
3259
|
+
const preWriteCheck = sessionHelper.capturePane(sessionName);
|
|
3260
|
+
const hasSpinner = containsSpinnerOrWorkingIndicator(preWriteCheck);
|
|
3261
|
+
if (hasSpinner) {
|
|
3262
|
+
// Hash-based dedup: if the same message was recently sent
|
|
3263
|
+
// and the agent is processing, it's very likely our message.
|
|
3264
|
+
const msgHash = message.substring(0, 200);
|
|
3265
|
+
const lastSent = this.lastSentMessageHash.get(sessionName);
|
|
3266
|
+
const isRecentDuplicate = lastSent
|
|
3267
|
+
&& lastSent.hash === msgHash
|
|
3268
|
+
&& (Date.now() - lastSent.sentAt) < 60000;
|
|
3269
|
+
if (isRecentDuplicate) {
|
|
3270
|
+
this.logger.info('Agent already processing — skipping write to prevent duplicate (#128)', {
|
|
3271
|
+
sessionName,
|
|
3272
|
+
attempt,
|
|
3273
|
+
isRecentDuplicate: true,
|
|
3274
|
+
});
|
|
3275
|
+
return true;
|
|
3276
|
+
}
|
|
3277
|
+
// Agent is busy with a different message. On retries,
|
|
3278
|
+
// log but still proceed to write — the message will be
|
|
3279
|
+
// queued in the PTY input buffer for when the agent
|
|
3280
|
+
// finishes its current work.
|
|
3281
|
+
if (attempt > 1) {
|
|
3282
|
+
this.logger.info('Agent busy with different message, proceeding with write on retry', {
|
|
3283
|
+
sessionName,
|
|
3284
|
+
attempt,
|
|
3285
|
+
});
|
|
3286
|
+
}
|
|
3287
|
+
}
|
|
3288
|
+
// On retries: log informationally if the agent is mid-task and
|
|
3289
|
+
// our message text isn't visible at the bottom of the pane.
|
|
3290
|
+
// Historically this branch returned `true` (silent skip) on
|
|
3291
|
+
// `notAtPrompt && !textStuck`, which silently dropped Slack
|
|
3292
|
+
// messages whenever the orc was busy on a prior task and the
|
|
3293
|
+
// force-deliver fallback's first internal write had been
|
|
3294
|
+
// suppressed by the spinner-dedup guard above. Steve's
|
|
3295
|
+
// 2026-04-30 UTC 17:28 / 18:37 incident: messages acked by
|
|
3296
|
+
// the queue, never reached the orc PTY.
|
|
3297
|
+
//
|
|
3298
|
+
// Correct semantics: the only safe condition for skipping is
|
|
3299
|
+
// a positive recent-duplicate hash match (handled in the
|
|
3300
|
+
// `hasSpinner` block above via `isRecentDuplicate`). If we
|
|
3301
|
+
// reach this point we KNOW the new message has not been
|
|
3302
|
+
// confirmed-written this round, so proceed to the actual
|
|
3303
|
+
// `session.write` below — the PTY input buffer will queue
|
|
3304
|
+
// the bytes until the agent is ready to consume them. A
|
|
3305
|
+
// duplicate is recoverable on the agent side; a silent loss
|
|
3306
|
+
// is not.
|
|
3307
|
+
if (attempt > 1) {
|
|
3308
|
+
const notAtPrompt = !this.isClaudeAtPrompt(preWriteCheck, runtimeType);
|
|
3309
|
+
if (notAtPrompt) {
|
|
3310
|
+
const msgSnippet = (message.length > 20
|
|
3311
|
+
? message.substring(0, 80)
|
|
3312
|
+
: message).replace(/\s+/g, ' ').trim();
|
|
3313
|
+
const bottomLines = preWriteCheck.split('\n').slice(-10).join(' ').replace(/\s+/g, ' ');
|
|
3314
|
+
const textStuck = bottomLines.includes(msgSnippet);
|
|
3315
|
+
this.logger.info('Agent busy on retry — proceeding to write (no silent skip)', {
|
|
3316
|
+
sessionName,
|
|
3317
|
+
attempt,
|
|
3318
|
+
textStuck,
|
|
3319
|
+
});
|
|
3320
|
+
// No early return — fall through to session.write.
|
|
3321
|
+
}
|
|
3322
|
+
}
|
|
3323
|
+
}
|
|
3324
|
+
// Use SessionCommandHelper.sendMessage() — proven two-step write:
|
|
3325
|
+
// 1. session.write(message) — triggers bracketed paste
|
|
3326
|
+
// 2. await delay(scaled) — waits for paste processing
|
|
3327
|
+
// 3. session.write('\r') — sends Enter separately
|
|
3328
|
+
// 4. await delay(KEY_DELAY) — waits for key processing
|
|
3329
|
+
await sessionHelper.sendMessage(sessionName, message);
|
|
3330
|
+
// Register for background stuck-detection safety net (all runtimes).
|
|
3331
|
+
// If progressive verification below misses an Enter drop, the
|
|
3332
|
+
// background scanner will catch it within 30s.
|
|
3333
|
+
this.trackSentMessage(sessionName, message);
|
|
3334
|
+
// Track message hash for deduplication on retry (#128)
|
|
3335
|
+
this.lastSentMessageHash.set(sessionName, {
|
|
3336
|
+
hash: message.substring(0, 200),
|
|
3337
|
+
sentAt: Date.now(),
|
|
3338
|
+
});
|
|
3339
|
+
// Wait for agent to start processing, then verify delivery.
|
|
3340
|
+
// TUI runtimes need a longer delay (3s) for the TUI to redraw
|
|
3341
|
+
// and show processing indicators after accepting input.
|
|
3342
|
+
const processingDelay = isClaudeCode
|
|
3343
|
+
? SESSION_COMMAND_DELAYS.MESSAGE_PROCESSING_DELAY
|
|
3344
|
+
: 3000;
|
|
3345
|
+
await delay(processingDelay);
|
|
3346
|
+
// === Delivery verification ===
|
|
3347
|
+
// Claude Code: prompt disappears during processing, so check
|
|
3348
|
+
// isMessageStuckAtPrompt (text still at prompt = stuck).
|
|
3349
|
+
// TUI runtimes (Gemini CLI): use TWO-PHASE verification:
|
|
3350
|
+
// Phase 1: Direct check — is the message text sitting ON the
|
|
3351
|
+
// `> ` prompt line? If yes, Enter was dropped. Try
|
|
3352
|
+
// pressing Enter to recover before falling through to retry.
|
|
3353
|
+
// Phase 2: Output-change detection — compare before/after captures
|
|
3354
|
+
// to see if the agent started processing.
|
|
3355
|
+
if (isClaudeCode) {
|
|
3356
|
+
// Progressive verification for Claude Code.
|
|
3357
|
+
// Claude Code routinely takes 3-8s to start rendering with large
|
|
3358
|
+
// context. The old 2.5s window caused false-positive Ctrl+C that
|
|
3359
|
+
// cancelled valid in-progress prompts. Now we progressively check
|
|
3360
|
+
// at [1s, 2s, 3s] intervals (total ~6.5s including processing delay).
|
|
3361
|
+
//
|
|
3362
|
+
// Progressive verification: check at [1s, 2s, 3s] intervals whether
|
|
3363
|
+
// the agent accepted the message and started processing it.
|
|
3364
|
+
//
|
|
3365
|
+
// Uses runtime-agnostic detection — no hardcoded prompt characters.
|
|
3366
|
+
// This stays correct when the agent runtime upgrades its prompt.
|
|
3367
|
+
//
|
|
3368
|
+
// Three signals checked per interval:
|
|
3369
|
+
// 1. Processing indicators (⠋ ⏺ spinners) — definitive success
|
|
3370
|
+
// 2. isClaudeAtPrompt() — if still true, agent is idle, wait more
|
|
3371
|
+
// 3. Message text in bottom lines — if found, Enter was dropped
|
|
3372
|
+
// (message pasted at prompt but not submitted)
|
|
3373
|
+
// If prompt gone AND message text NOT at bottom → success
|
|
3374
|
+
let claudeDelivered = false;
|
|
3375
|
+
for (const intervalMs of SESSION_COMMAND_DELAYS.CLAUDE_VERIFICATION_INTERVALS) {
|
|
3376
|
+
const currentOutput = sessionHelper.capturePane(sessionName);
|
|
3377
|
+
// 1. Processing indicators (spinners, working marker) are
|
|
3378
|
+
// definitive proof the agent accepted and is working.
|
|
3379
|
+
// Only check spinner/⏺ chars — NOT text words like
|
|
3380
|
+
// "thinking" which appear in historical response text.
|
|
3381
|
+
if (containsSpinnerOrWorkingIndicator(currentOutput)) {
|
|
3382
|
+
this.logger.debug('Processing indicators detected — message accepted', {
|
|
3383
|
+
sessionName,
|
|
3384
|
+
attempt,
|
|
3385
|
+
});
|
|
3386
|
+
claudeDelivered = true;
|
|
3387
|
+
break;
|
|
3388
|
+
}
|
|
3389
|
+
// 2. Is the agent still at its standard idle prompt?
|
|
3390
|
+
// Reuse isClaudeAtPrompt() — the single source of truth
|
|
3391
|
+
// for prompt detection. This avoids hardcoding prompt
|
|
3392
|
+
// characters and stays correct when the runtime upgrades.
|
|
3393
|
+
if (this.isClaudeAtPrompt(currentOutput, runtimeType)) {
|
|
3394
|
+
// Prompt is visible. Before just waiting, check if our
|
|
3395
|
+
// message text is also at the bottom — this means the
|
|
3396
|
+
// text was pasted but Enter was dropped. Press Enter.
|
|
3397
|
+
// Normalize whitespace: enhanced messages contain literal \n from
|
|
3398
|
+
// addContinuationInstructions(). join(' ') replaces line breaks with
|
|
3399
|
+
// spaces, but the snippet still has \n chars, so includes() fails.
|
|
3400
|
+
const promptMsgSnippet = (message.length > 20
|
|
3401
|
+
? message.substring(0, 80)
|
|
3402
|
+
: message).replace(/\s+/g, ' ').trim();
|
|
3403
|
+
const promptBottomLines = currentOutput.split('\n').slice(-10).join(' ').replace(/\s+/g, ' ');
|
|
3404
|
+
if (promptBottomLines.includes(promptMsgSnippet)) {
|
|
3405
|
+
this.logger.warn('At prompt with message text at bottom — pressing Tab+Enter', {
|
|
3406
|
+
sessionName,
|
|
3407
|
+
attempt,
|
|
3408
|
+
intervalMs,
|
|
3409
|
+
});
|
|
3410
|
+
// Tab restores Ink TUI focus before Enter — without this,
|
|
3411
|
+
// Enter may be consumed by the framework but not routed to
|
|
3412
|
+
// the input component if focus was lost during a re-render
|
|
3413
|
+
// (e.g., after stop hooks, state transitions).
|
|
3414
|
+
await sessionHelper.sendKey(sessionName, 'Tab');
|
|
3415
|
+
await delay(200);
|
|
3416
|
+
await sessionHelper.sendEnter(sessionName);
|
|
3417
|
+
await delay(500);
|
|
3418
|
+
await sessionHelper.sendEnter(sessionName); // backup
|
|
3419
|
+
await delay(SESSION_COMMAND_DELAYS.MESSAGE_PROCESSING_DELAY);
|
|
3420
|
+
// Verify recovery
|
|
3421
|
+
const postEnterOutput = sessionHelper.capturePane(sessionName);
|
|
3422
|
+
if (containsSpinnerOrWorkingIndicator(postEnterOutput) ||
|
|
3423
|
+
!this.isClaudeAtPrompt(postEnterOutput, runtimeType)) {
|
|
3424
|
+
this.logger.info('Enter recovery from prompt successful', {
|
|
3425
|
+
sessionName,
|
|
3426
|
+
attempt,
|
|
3427
|
+
});
|
|
3428
|
+
claudeDelivered = true;
|
|
3429
|
+
break;
|
|
3430
|
+
}
|
|
3431
|
+
}
|
|
3432
|
+
// Fast-response detection: if the terminal output changed since
|
|
3433
|
+
// we sent the message, Claude processed it and returned to prompt
|
|
3434
|
+
// before our first check. Without this, fast responses (e.g.,
|
|
3435
|
+
// simple IP queries) cause false "delivery unconfirmed" verdicts
|
|
3436
|
+
// and trigger duplicate sends (#retry-storm).
|
|
3437
|
+
const outputChanged = currentOutput !== beforeOutput;
|
|
3438
|
+
if (outputChanged) {
|
|
3439
|
+
this.logger.info('Agent at prompt but output changed — fast response detected, delivery confirmed', {
|
|
3440
|
+
sessionName,
|
|
3441
|
+
attempt,
|
|
3442
|
+
intervalMs,
|
|
3443
|
+
beforeLength: beforeOutput.length,
|
|
3444
|
+
currentLength: currentOutput.length,
|
|
3445
|
+
});
|
|
3446
|
+
claudeDelivered = true;
|
|
3447
|
+
break;
|
|
3448
|
+
}
|
|
3449
|
+
this.logger.debug('Agent still at prompt, waiting before re-check', {
|
|
3450
|
+
sessionName,
|
|
3451
|
+
attempt,
|
|
3452
|
+
intervalMs,
|
|
3453
|
+
});
|
|
3454
|
+
await delay(intervalMs);
|
|
3455
|
+
continue;
|
|
3456
|
+
}
|
|
3457
|
+
// 3. Prompt not in its standard idle form. Before declaring
|
|
3458
|
+
// success, check if our message text is stuck at the
|
|
3459
|
+
// bottom of the terminal. When Enter is dropped, the
|
|
3460
|
+
// pasted text appears on the prompt line — the terminal
|
|
3461
|
+
// shows the prompt char followed by our message text.
|
|
3462
|
+
// This check is runtime-agnostic: it works regardless
|
|
3463
|
+
// of what prompt character the runtime uses.
|
|
3464
|
+
// Normalize whitespace in snippet (see Step 2 comment above).
|
|
3465
|
+
const msgSnippet = (message.length > 20
|
|
3466
|
+
? message.substring(0, 80)
|
|
3467
|
+
: message).replace(/\s+/g, ' ').trim();
|
|
3468
|
+
const bottomLines = currentOutput.split('\n').slice(-10).join(' ').replace(/\s+/g, ' ');
|
|
3469
|
+
if (bottomLines.includes(msgSnippet)) {
|
|
3470
|
+
// Message text is at the bottom of the terminal but the
|
|
3471
|
+
// prompt is no longer in its idle form — Enter was dropped.
|
|
3472
|
+
// Instead of waiting and doing a full Ctrl+C + resend retry,
|
|
3473
|
+
// use Tab to restore TUI focus, then Enter to submit.
|
|
3474
|
+
this.logger.warn('Message text stuck at bottom — pressing Tab+Enter to recover', {
|
|
3475
|
+
sessionName,
|
|
3476
|
+
attempt,
|
|
3477
|
+
intervalMs,
|
|
3478
|
+
});
|
|
3479
|
+
await sessionHelper.sendKey(sessionName, 'Tab');
|
|
3480
|
+
await delay(200);
|
|
3481
|
+
await sessionHelper.sendEnter(sessionName);
|
|
3482
|
+
await delay(500);
|
|
3483
|
+
await sessionHelper.sendEnter(sessionName); // backup Enter
|
|
3484
|
+
// Give the agent a moment to start processing after Enter
|
|
3485
|
+
await delay(SESSION_COMMAND_DELAYS.MESSAGE_PROCESSING_DELAY);
|
|
3486
|
+
// Verify recovery: check if processing started
|
|
3487
|
+
const recoveryOutput = sessionHelper.capturePane(sessionName);
|
|
3488
|
+
if (containsSpinnerOrWorkingIndicator(recoveryOutput)) {
|
|
3489
|
+
this.logger.info('Enter recovery successful — processing started', {
|
|
3490
|
+
sessionName,
|
|
3491
|
+
attempt,
|
|
3492
|
+
});
|
|
3493
|
+
claudeDelivered = true;
|
|
3494
|
+
break;
|
|
3495
|
+
}
|
|
3496
|
+
// Check if prompt reappeared (Enter submitted but processed instantly)
|
|
3497
|
+
// or if output changed from the stuck state
|
|
3498
|
+
const recoveryBottom = recoveryOutput.split('\n').slice(-10).join(' ').replace(/\s+/g, ' ');
|
|
3499
|
+
if (!recoveryBottom.includes(msgSnippet)) {
|
|
3500
|
+
this.logger.info('Enter recovery successful — stuck text cleared', {
|
|
3501
|
+
sessionName,
|
|
3502
|
+
attempt,
|
|
3503
|
+
});
|
|
3504
|
+
claudeDelivered = true;
|
|
3505
|
+
break;
|
|
3506
|
+
}
|
|
3507
|
+
// Still stuck after Enter recovery — continue to next interval
|
|
3508
|
+
this.logger.debug('Still stuck after Enter recovery, continuing verification', {
|
|
3509
|
+
sessionName,
|
|
3510
|
+
attempt,
|
|
3511
|
+
});
|
|
3512
|
+
await delay(intervalMs);
|
|
3513
|
+
continue;
|
|
3514
|
+
}
|
|
3515
|
+
// Prompt gone AND no stuck text → agent started processing
|
|
3516
|
+
this.logger.debug('Prompt gone, no stuck text — message delivered', {
|
|
3517
|
+
sessionName,
|
|
3518
|
+
attempt,
|
|
3519
|
+
});
|
|
3520
|
+
claudeDelivered = true;
|
|
3521
|
+
break;
|
|
3522
|
+
}
|
|
3523
|
+
// --- Extended confirmation loop ---
|
|
3524
|
+
// If progressive verification didn't confirm delivery, run an
|
|
3525
|
+
// extended check loop (every 3s, up to 30s total). Each iteration
|
|
3526
|
+
// captures the pane, checks for spinner/working indicators, and
|
|
3527
|
+
// presses Tab+Enter if message text is still stuck at the bottom.
|
|
3528
|
+
if (!claudeDelivered) {
|
|
3529
|
+
const CONFIRM_INTERVAL_MS = 3000;
|
|
3530
|
+
const CONFIRM_MAX_MS = 30000;
|
|
3531
|
+
const confirmStart = Date.now();
|
|
3532
|
+
let confirmAttempt = 0;
|
|
3533
|
+
// Normalize snippet once for reuse
|
|
3534
|
+
const confirmSnippet = (message.length > 20
|
|
3535
|
+
? message.substring(0, 80)
|
|
3536
|
+
: message).replace(/\s+/g, ' ').trim();
|
|
3537
|
+
while (Date.now() - confirmStart < CONFIRM_MAX_MS) {
|
|
3538
|
+
await delay(CONFIRM_INTERVAL_MS);
|
|
3539
|
+
confirmAttempt++;
|
|
3540
|
+
const loopOutput = sessionHelper.capturePane(sessionName);
|
|
3541
|
+
const loopBottom = loopOutput.split('\n').slice(-10).join(' ').replace(/\s+/g, ' ');
|
|
3542
|
+
const textStuck = loopBottom.includes(confirmSnippet);
|
|
3543
|
+
// Spinner or working indicator → delivered
|
|
3544
|
+
if (containsSpinnerOrWorkingIndicator(loopOutput)) {
|
|
3545
|
+
this.logger.info('Confirmation loop: processing detected', {
|
|
3546
|
+
sessionName, attempt, confirmAttempt,
|
|
3547
|
+
});
|
|
3548
|
+
claudeDelivered = true;
|
|
3549
|
+
break;
|
|
3550
|
+
}
|
|
3551
|
+
// Text still stuck → Enter was dropped, NOT delivered. Try
|
|
3552
|
+
// Tab+Enter recovery and re-check next iteration. This is
|
|
3553
|
+
// checked BEFORE the output-change shortcut below: when our
|
|
3554
|
+
// own pasted message is echoed at the prompt the output DID
|
|
3555
|
+
// change vs the pre-send baseline, but that change is the
|
|
3556
|
+
// stuck echo, not a response — treating it as "delivered"
|
|
3557
|
+
// is the false-positive that masked the orc 假死 (#686
|
|
3558
|
+
// follow-up: a hung session looked "delivered" forever).
|
|
3559
|
+
if (textStuck) {
|
|
3560
|
+
this.logger.warn('Confirmation loop: text still stuck, pressing Tab+Enter', {
|
|
3561
|
+
sessionName, attempt, confirmAttempt,
|
|
3562
|
+
});
|
|
3563
|
+
await sessionHelper.sendKey(sessionName, 'Tab');
|
|
3564
|
+
await delay(200);
|
|
3565
|
+
await sessionHelper.sendEnter(sessionName);
|
|
3566
|
+
await delay(500);
|
|
3567
|
+
await sessionHelper.sendEnter(sessionName); // backup
|
|
3568
|
+
continue;
|
|
3569
|
+
}
|
|
3570
|
+
// Fast-response detection: output changed since send AND our
|
|
3571
|
+
// message text is NOT stuck at the prompt → a real response
|
|
3572
|
+
// landed and returned to prompt before we looked. Delivered.
|
|
3573
|
+
if (loopOutput !== beforeOutput) {
|
|
3574
|
+
this.logger.info('Confirmation loop: output changed from pre-send — fast response confirmed', {
|
|
3575
|
+
sessionName, attempt, confirmAttempt,
|
|
3576
|
+
});
|
|
3577
|
+
claudeDelivered = true;
|
|
3578
|
+
break;
|
|
3579
|
+
}
|
|
3580
|
+
// Not at prompt and text not stuck → delivered
|
|
3581
|
+
if (!this.isClaudeAtPrompt(loopOutput, runtimeType)) {
|
|
3582
|
+
this.logger.info('Confirmation loop: prompt gone, text cleared', {
|
|
3583
|
+
sessionName, attempt, confirmAttempt,
|
|
3584
|
+
});
|
|
3585
|
+
claudeDelivered = true;
|
|
3586
|
+
break;
|
|
3587
|
+
}
|
|
3588
|
+
}
|
|
3589
|
+
if (!claudeDelivered) {
|
|
3590
|
+
this.logger.error('Confirmation loop exhausted — delivery unconfirmed', {
|
|
3591
|
+
sessionName, attempt, confirmAttempts: confirmAttempt,
|
|
3592
|
+
});
|
|
3593
|
+
}
|
|
3594
|
+
}
|
|
3595
|
+
if (claudeDelivered) {
|
|
3596
|
+
return true;
|
|
3597
|
+
}
|
|
3598
|
+
}
|
|
3599
|
+
else {
|
|
3600
|
+
// --- Gemini CLI: lightweight verification ---
|
|
3601
|
+
// Gemini CLI's PTY write is reliable. The aggressive verification
|
|
3602
|
+
// (stuck detection + Tab+Enter recovery + output-change diff) causes
|
|
3603
|
+
// false "stuck" judgments that trigger visible retry error spam even
|
|
3604
|
+
// when the message was successfully delivered (#retry-storm fix).
|
|
3605
|
+
//
|
|
3606
|
+
// Strategy: only check for the definitive stuck signal (message text
|
|
3607
|
+
// literally sitting on the `> ` prompt line). If not stuck, trust the
|
|
3608
|
+
// write. Skip the output-change heuristics that cause false negatives.
|
|
3609
|
+
const isGemini = runtimeType === RUNTIME_TYPES.GEMINI_CLI;
|
|
3610
|
+
if (isGemini) {
|
|
3611
|
+
// Single stuck-at-prompt check — the only reliable failure signal
|
|
3612
|
+
const stuckAtPrompt = this.isTextStuckAtTuiPrompt(sessionName, message);
|
|
3613
|
+
if (stuckAtPrompt) {
|
|
3614
|
+
const recovered = await this.recoverStuckTuiMessage(sessionName, message);
|
|
3615
|
+
if (recovered) {
|
|
3616
|
+
this.logger.info('Gemini CLI: message recovered via Enter re-press', {
|
|
3617
|
+
sessionName,
|
|
3618
|
+
attempt,
|
|
3619
|
+
});
|
|
3620
|
+
return true;
|
|
3621
|
+
}
|
|
3622
|
+
this.logger.warn('Gemini CLI: message still stuck after Enter recovery', {
|
|
3623
|
+
sessionName,
|
|
3624
|
+
attempt,
|
|
3625
|
+
});
|
|
3626
|
+
// Fall through to retry cleanup below
|
|
3627
|
+
}
|
|
3628
|
+
else {
|
|
3629
|
+
// Not stuck at prompt — PTY write succeeded, trust delivery.
|
|
3630
|
+
// The old code did output-change detection here, but TUI redraws
|
|
3631
|
+
// and minimal length diffs caused false "not delivered" verdicts.
|
|
3632
|
+
this.logger.debug('Gemini CLI: message not stuck at prompt — delivery trusted', {
|
|
3633
|
+
sessionName,
|
|
3634
|
+
attempt,
|
|
3635
|
+
});
|
|
3636
|
+
return true;
|
|
3637
|
+
}
|
|
3638
|
+
}
|
|
3639
|
+
else {
|
|
3640
|
+
// --- Non-Gemini TUI runtimes: full verification ---
|
|
3641
|
+
// Phase 1: Direct stuck-at-prompt detection
|
|
3642
|
+
const stuckAtPrompt = this.isTextStuckAtTuiPrompt(sessionName, message);
|
|
3643
|
+
if (stuckAtPrompt) {
|
|
3644
|
+
const recovered = await this.recoverStuckTuiMessage(sessionName, message);
|
|
3645
|
+
if (recovered) {
|
|
3646
|
+
this.logger.info('Message recovered via Enter re-press', {
|
|
3647
|
+
sessionName,
|
|
3648
|
+
attempt,
|
|
3649
|
+
});
|
|
3650
|
+
return true;
|
|
3651
|
+
}
|
|
3652
|
+
this.logger.warn('Message still stuck after Enter recovery, will retry', {
|
|
3653
|
+
sessionName,
|
|
3654
|
+
attempt,
|
|
3655
|
+
});
|
|
3656
|
+
}
|
|
3657
|
+
else {
|
|
3658
|
+
// Phase 2: Output-change detection
|
|
3659
|
+
const afterOutput = sessionHelper.capturePane(sessionName, 20);
|
|
3660
|
+
const lengthDiff = afterOutput.length - beforeLength;
|
|
3661
|
+
const contentChanged = beforeOutput !== afterOutput;
|
|
3662
|
+
let newContent = '';
|
|
3663
|
+
if (contentChanged && afterOutput.length > beforeOutput.length) {
|
|
3664
|
+
newContent = afterOutput.slice(beforeLength);
|
|
3665
|
+
}
|
|
3666
|
+
else if (contentChanged) {
|
|
3667
|
+
const beforeLines = new Set(beforeOutput.split('\n'));
|
|
3668
|
+
newContent = afterOutput
|
|
3669
|
+
.split('\n')
|
|
3670
|
+
.filter((line) => !beforeLines.has(line))
|
|
3671
|
+
.join('\n');
|
|
3672
|
+
}
|
|
3673
|
+
const hasProcessingIndicators = containsProcessingIndicator(newContent || afterOutput.slice(-500));
|
|
3674
|
+
const significantLengthChange = Math.abs(lengthDiff) > 10;
|
|
3675
|
+
const delivered = (lengthDiff > 20)
|
|
3676
|
+
|| (contentChanged && significantLengthChange)
|
|
3677
|
+
|| hasProcessingIndicators;
|
|
3678
|
+
if (delivered) {
|
|
3679
|
+
const hasStrongSignal = hasProcessingIndicators;
|
|
3680
|
+
const logLevel = hasStrongSignal ? 'debug' : 'warn';
|
|
3681
|
+
this.logger[logLevel](`Message delivery verified (${hasStrongSignal ? 'strong' : 'weak'} signal)`, {
|
|
3682
|
+
sessionName,
|
|
3683
|
+
attempt,
|
|
3684
|
+
lengthDiff,
|
|
3685
|
+
contentChanged,
|
|
3686
|
+
hasProcessingIndicators,
|
|
3687
|
+
newContentLength: newContent.length,
|
|
3688
|
+
signal: hasStrongSignal ? 'processing-indicators' : 'output-length-change-only',
|
|
3689
|
+
});
|
|
3690
|
+
return true;
|
|
3691
|
+
}
|
|
3692
|
+
this.logger.warn('TUI output did not change after send — message may not have been accepted', {
|
|
3693
|
+
sessionName,
|
|
3694
|
+
attempt,
|
|
3695
|
+
lengthDiff,
|
|
3696
|
+
contentChanged,
|
|
3697
|
+
hasProcessingIndicators,
|
|
3698
|
+
newContentLength: newContent.length,
|
|
3699
|
+
});
|
|
3700
|
+
}
|
|
3701
|
+
}
|
|
3702
|
+
}
|
|
3703
|
+
// Message stuck at prompt — clear line and retry.
|
|
3704
|
+
this.logger.warn('Message stuck at prompt after send, clearing for retry', {
|
|
3705
|
+
sessionName,
|
|
3706
|
+
attempt,
|
|
3707
|
+
});
|
|
3708
|
+
if (isClaudeCode) {
|
|
3709
|
+
await sessionHelper.clearCurrentCommandLine(sessionName);
|
|
3710
|
+
}
|
|
3711
|
+
else {
|
|
3712
|
+
// Gemini CLI retry cleanup: NEVER send Ctrl+C — it triggers /quit
|
|
3713
|
+
// and kills the CLI entirely, regardless of whether text is in the
|
|
3714
|
+
// input box or not. Use Tab to cycle Ink focus back to the input
|
|
3715
|
+
// prompt, then Enter to submit any pending text.
|
|
3716
|
+
this.logger.warn('Gemini CLI message stuck — using Tab focus recovery (no Ctrl+C)', {
|
|
3717
|
+
sessionName,
|
|
3718
|
+
attempt,
|
|
3719
|
+
});
|
|
3720
|
+
await sessionHelper.sendKey(sessionName, 'Tab');
|
|
3721
|
+
await delay(300);
|
|
3722
|
+
await sessionHelper.sendEnter(sessionName);
|
|
3723
|
+
await delay(300);
|
|
3724
|
+
}
|
|
3725
|
+
await delay(SESSION_COMMAND_DELAYS.CLEAR_COMMAND_DELAY);
|
|
3726
|
+
if (attempt < maxAttempts) {
|
|
3727
|
+
await delay(SESSION_COMMAND_DELAYS.MESSAGE_RETRY_DELAY);
|
|
3728
|
+
}
|
|
3729
|
+
}
|
|
3730
|
+
catch (error) {
|
|
3731
|
+
this.logger.error('Error during message delivery', {
|
|
3732
|
+
sessionName,
|
|
3733
|
+
attempt,
|
|
3734
|
+
error: error instanceof Error ? error.message : String(error),
|
|
3735
|
+
});
|
|
3736
|
+
if (attempt < maxAttempts) {
|
|
3737
|
+
await delay(SESSION_COMMAND_DELAYS.MESSAGE_RETRY_DELAY);
|
|
3738
|
+
}
|
|
3739
|
+
}
|
|
3740
|
+
}
|
|
3741
|
+
this.logger.warn('Message delivery verification failed — all attempts exhausted', {
|
|
3742
|
+
sessionName,
|
|
3743
|
+
maxAttempts,
|
|
3744
|
+
messageLength: message.length,
|
|
3745
|
+
});
|
|
3746
|
+
this.logger.error('Message delivery failed after all retry attempts', {
|
|
3747
|
+
sessionName,
|
|
3748
|
+
maxAttempts,
|
|
3749
|
+
messageLength: message.length,
|
|
3750
|
+
});
|
|
3751
|
+
return false;
|
|
3752
|
+
}
|
|
3753
|
+
/**
|
|
3754
|
+
* Check if a sent message is stuck at the terminal prompt (Enter was not accepted).
|
|
3755
|
+
*
|
|
3756
|
+
* Captures the current terminal pane and looks for the message text still visible
|
|
3757
|
+
* on the last few lines. If the text is found, Enter was not processed and the
|
|
3758
|
+
* message is stuck.
|
|
3759
|
+
*
|
|
3760
|
+
* @param sessionName - The session to check
|
|
3761
|
+
* @param message - The original message that was sent
|
|
3762
|
+
* @returns true if the message text is still visible at the prompt (stuck)
|
|
3763
|
+
*/
|
|
3764
|
+
async isMessageStuckAtPrompt(sessionName, message) {
|
|
3765
|
+
try {
|
|
3766
|
+
const sessionHelper = await this.getSessionHelper();
|
|
3767
|
+
const output = sessionHelper.capturePane(sessionName);
|
|
3768
|
+
if (!output || output.trim().length === 0) {
|
|
3769
|
+
return false;
|
|
3770
|
+
}
|
|
3771
|
+
// Extract a search token from the message:
|
|
3772
|
+
// Strip [CHAT:uuid] prefix if present, then take the first 40 chars
|
|
3773
|
+
const { prefixLength } = extractChatPrefix(message);
|
|
3774
|
+
const contentAfterPrefix = prefixLength > 0
|
|
3775
|
+
? message.slice(prefixLength)
|
|
3776
|
+
: message;
|
|
3777
|
+
const searchToken = contentAfterPrefix.slice(0, 40).trim();
|
|
3778
|
+
// Also use [CHAT: as a secondary token if message has a CHAT prefix
|
|
3779
|
+
const chatToken = prefixLength > 0 ? '[CHAT:' : null;
|
|
3780
|
+
// Check last 20 non-empty lines for either token.
|
|
3781
|
+
// Gemini CLI TUI has status bars at the bottom (branch, sandbox, model info)
|
|
3782
|
+
// that push input content further up. 5 lines was insufficient.
|
|
3783
|
+
const lines = output.split('\n').filter((line) => line.trim().length > 0);
|
|
3784
|
+
const linesToCheck = lines.slice(-20);
|
|
3785
|
+
const isStuck = linesToCheck.some((line) => {
|
|
3786
|
+
// Strip TUI box-drawing borders before checking (Gemini CLI wraps content in │...│)
|
|
3787
|
+
const stripped = stripTuiLineBorders(line);
|
|
3788
|
+
if (searchToken && (line.includes(searchToken) || stripped.includes(searchToken)))
|
|
3789
|
+
return true;
|
|
3790
|
+
if (chatToken && (line.includes(chatToken) || stripped.includes(chatToken)))
|
|
3791
|
+
return true;
|
|
3792
|
+
return false;
|
|
3793
|
+
});
|
|
3794
|
+
this.logger.debug('isMessageStuckAtPrompt result', {
|
|
3795
|
+
sessionName,
|
|
3796
|
+
isStuck,
|
|
3797
|
+
searchToken: searchToken.slice(0, 20),
|
|
3798
|
+
linesChecked: linesToCheck.length,
|
|
3799
|
+
});
|
|
3800
|
+
return isStuck;
|
|
3801
|
+
}
|
|
3802
|
+
catch (error) {
|
|
3803
|
+
this.logger.warn('Error checking if message stuck at prompt', {
|
|
3804
|
+
sessionName,
|
|
3805
|
+
error: error instanceof Error ? error.message : String(error),
|
|
3806
|
+
});
|
|
3807
|
+
return false;
|
|
3808
|
+
}
|
|
3809
|
+
}
|
|
3810
|
+
/**
|
|
3811
|
+
* Check if message text is stuck at the TUI prompt line (Enter was not pressed).
|
|
3812
|
+
*
|
|
3813
|
+
* Unlike `isMessageStuckAtPrompt` which checks the last 20 lines for the message
|
|
3814
|
+
* text ANYWHERE, this method specifically checks whether the message text appears
|
|
3815
|
+
* ON the prompt line itself (the line starting with `> ` or `│ > `).
|
|
3816
|
+
*
|
|
3817
|
+
* After Enter is successfully pressed, the text moves from the prompt line to the
|
|
3818
|
+
* chat history area. So if the text is still on the prompt line, Enter was dropped.
|
|
3819
|
+
*
|
|
3820
|
+
* This avoids the false positive problem where `isMessageStuckAtPrompt` matches
|
|
3821
|
+
* the message text in the TUI's chat history (which echoes submitted messages).
|
|
3822
|
+
*
|
|
3823
|
+
* @param sessionName - The session to check
|
|
3824
|
+
* @param message - The original message that was sent
|
|
3825
|
+
* @returns true if the message text is visible on the TUI prompt line (stuck)
|
|
3826
|
+
*/
|
|
3827
|
+
isTextStuckAtTuiPrompt(sessionName, message) {
|
|
3828
|
+
try {
|
|
3829
|
+
const sessionHelper = this._sessionHelper;
|
|
3830
|
+
if (!sessionHelper)
|
|
3831
|
+
return false;
|
|
3832
|
+
const output = sessionHelper.capturePane(sessionName);
|
|
3833
|
+
if (!output || output.trim().length === 0)
|
|
3834
|
+
return false;
|
|
3835
|
+
// Extract search token: first 30 chars of the message content
|
|
3836
|
+
// (after stripping any [CHAT:uuid] prefix)
|
|
3837
|
+
const { prefixLength } = extractChatPrefix(message);
|
|
3838
|
+
const contentAfterPrefix = prefixLength > 0
|
|
3839
|
+
? message.slice(prefixLength)
|
|
3840
|
+
: message;
|
|
3841
|
+
const searchToken = contentAfterPrefix.slice(0, 30).trim();
|
|
3842
|
+
if (!searchToken)
|
|
3843
|
+
return false;
|
|
3844
|
+
// Find lines that look like a TUI prompt with text after it.
|
|
3845
|
+
// Gemini CLI TUI wraps content in box-drawing borders: │ > text │
|
|
3846
|
+
// Or without borders: > text
|
|
3847
|
+
// The prompt line is the line with `> ` followed by actual content.
|
|
3848
|
+
const lines = output.split('\n');
|
|
3849
|
+
for (let i = lines.length - 1; i >= Math.max(0, lines.length - 25); i--) {
|
|
3850
|
+
const line = lines[i];
|
|
3851
|
+
const promptContent = matchTuiPromptLine(line);
|
|
3852
|
+
if (promptContent !== null) {
|
|
3853
|
+
const trimmedContent = stripTuiLineBorders(promptContent).trim();
|
|
3854
|
+
// Check if the prompt line content contains our message text.
|
|
3855
|
+
// Only check the BOTTOMMOST prompt line — historical echoes of
|
|
3856
|
+
// submitted messages also appear as `> text` further up in the
|
|
3857
|
+
// TUI conversation history. Without this break, those echoes
|
|
3858
|
+
// cause false "stuck" detections even after the message was
|
|
3859
|
+
// successfully delivered.
|
|
3860
|
+
if (trimmedContent.length > 5 && trimmedContent.includes(searchToken)) {
|
|
3861
|
+
this.logger.warn('Text stuck at TUI prompt — Enter was not pressed', {
|
|
3862
|
+
sessionName,
|
|
3863
|
+
searchToken: searchToken.slice(0, 20),
|
|
3864
|
+
promptContent: promptContent.slice(0, 60),
|
|
3865
|
+
});
|
|
3866
|
+
return true;
|
|
3867
|
+
}
|
|
3868
|
+
break; // Only check the first (bottommost) prompt line
|
|
3869
|
+
}
|
|
3870
|
+
}
|
|
3871
|
+
return false;
|
|
3872
|
+
}
|
|
3873
|
+
catch (error) {
|
|
3874
|
+
this.logger.debug('Error in isTextStuckAtTuiPrompt', {
|
|
3875
|
+
sessionName,
|
|
3876
|
+
error: error instanceof Error ? error.message : String(error),
|
|
3877
|
+
});
|
|
3878
|
+
return false;
|
|
3879
|
+
}
|
|
3880
|
+
}
|
|
3881
|
+
/**
|
|
3882
|
+
* Attempt to recover from text stuck at TUI prompt by pressing Enter.
|
|
3883
|
+
* Checks if text is stuck, sends Enter, waits, then verifies.
|
|
3884
|
+
*
|
|
3885
|
+
* @param sessionName - The session to recover
|
|
3886
|
+
* @param message - The original message
|
|
3887
|
+
* @returns true if recovery succeeded (text is no longer at prompt)
|
|
3888
|
+
*/
|
|
3889
|
+
async recoverStuckTuiMessage(sessionName, message) {
|
|
3890
|
+
const sessionHelper = await this.getSessionHelper();
|
|
3891
|
+
// Tab restores Ink TUI focus, then Enter to submit the stuck text.
|
|
3892
|
+
// Without Tab, Enter may be consumed by the framework but not routed
|
|
3893
|
+
// to the input component if focus was lost during a re-render.
|
|
3894
|
+
this.logger.info('Pressing Tab+Enter to recover stuck TUI message', { sessionName });
|
|
3895
|
+
await sessionHelper.sendKey(sessionName, 'Tab');
|
|
3896
|
+
await delay(200);
|
|
3897
|
+
await sessionHelper.sendEnter(sessionName);
|
|
3898
|
+
await delay(500);
|
|
3899
|
+
// Double-tap: send a backup Enter in case the first was consumed
|
|
3900
|
+
await sessionHelper.sendEnter(sessionName);
|
|
3901
|
+
await delay(2000);
|
|
3902
|
+
// Verify text is no longer at prompt
|
|
3903
|
+
const stillStuck = this.isTextStuckAtTuiPrompt(sessionName, message);
|
|
3904
|
+
if (!stillStuck) {
|
|
3905
|
+
this.logger.info('TUI message recovery succeeded — Enter accepted', { sessionName });
|
|
3906
|
+
return true;
|
|
3907
|
+
}
|
|
3908
|
+
this.logger.warn('TUI message still stuck after Enter recovery', { sessionName });
|
|
3909
|
+
return false;
|
|
3910
|
+
}
|
|
3911
|
+
/**
|
|
3912
|
+
* Start a background scanner that periodically checks all registered TUI
|
|
3913
|
+
* sessions for text stuck at the prompt (Enter not pressed).
|
|
3914
|
+
*
|
|
3915
|
+
* This is a safety net that catches messages missed by the per-send
|
|
3916
|
+
* verification in `sendMessageWithRetry`. It scans every 30 seconds.
|
|
3917
|
+
*
|
|
3918
|
+
* When stuck text is detected, the scanner presses Enter to recover.
|
|
3919
|
+
* If text has been sitting at the prompt for multiple consecutive scans,
|
|
3920
|
+
* it escalates to Tab + Enter (TUI focus recovery).
|
|
3921
|
+
*/
|
|
3922
|
+
startStuckMessageDetector() {
|
|
3923
|
+
if (this.stuckMessageDetectorTimer) {
|
|
3924
|
+
this.logger.debug('Stuck message detector already running');
|
|
3925
|
+
return;
|
|
3926
|
+
}
|
|
3927
|
+
const SCAN_INTERVAL_MS = 30000; // 30 seconds
|
|
3928
|
+
this.logger.info('Starting background stuck-message detector', {
|
|
3929
|
+
intervalMs: SCAN_INTERVAL_MS,
|
|
3930
|
+
});
|
|
3931
|
+
this.stuckMessageDetectorTimer = setInterval(async () => {
|
|
3932
|
+
try {
|
|
3933
|
+
await this.scanForStuckMessages();
|
|
3934
|
+
}
|
|
3935
|
+
catch (error) {
|
|
3936
|
+
this.logger.debug('Stuck message scan error (non-fatal)', {
|
|
3937
|
+
error: error instanceof Error ? error.message : String(error),
|
|
3938
|
+
});
|
|
3939
|
+
}
|
|
3940
|
+
}, SCAN_INTERVAL_MS);
|
|
3941
|
+
}
|
|
3942
|
+
/**
|
|
3943
|
+
* Stop the background stuck-message detector.
|
|
3944
|
+
*/
|
|
3945
|
+
stopStuckMessageDetector() {
|
|
3946
|
+
if (this.stuckMessageDetectorTimer) {
|
|
3947
|
+
clearInterval(this.stuckMessageDetectorTimer);
|
|
3948
|
+
this.stuckMessageDetectorTimer = null;
|
|
3949
|
+
this.logger.info('Stopped background stuck-message detector');
|
|
3950
|
+
}
|
|
3951
|
+
}
|
|
3952
|
+
/**
|
|
3953
|
+
* Scan all sessions for stuck messages. Two complementary strategies:
|
|
3954
|
+
*
|
|
3955
|
+
* 1. TUI prompt-line scanning (existing): checks registered TUI sessions
|
|
3956
|
+
* for text sitting on the `> ` prompt line.
|
|
3957
|
+
* 2. Tracked-message scanning (new, all runtimes): checks if any recently
|
|
3958
|
+
* sent message text is still visible at the bottom of the terminal
|
|
3959
|
+
* after 15+ seconds — runtime-agnostic Enter-drop detection.
|
|
3960
|
+
*/
|
|
3961
|
+
async scanForStuckMessages() {
|
|
3962
|
+
const sessionHelper = this._sessionHelper;
|
|
3963
|
+
if (!sessionHelper)
|
|
3964
|
+
return;
|
|
3965
|
+
// --- Part 0: Rewind mode detection (all sessions) ---
|
|
3966
|
+
// If Claude Code is stuck in Rewind mode (triggered by ESC during
|
|
3967
|
+
// processing), send 'q' to exit before any other recovery attempts.
|
|
3968
|
+
for (const sessionName of sessionHelper.listSessions()) {
|
|
3969
|
+
try {
|
|
3970
|
+
const output = sessionHelper.capturePane(sessionName);
|
|
3971
|
+
if (containsRewindMode(output)) {
|
|
3972
|
+
this.logger.warn('Rewind mode detected, sending q to exit', { sessionName });
|
|
3973
|
+
sessionHelper.writeRaw(sessionName, 'q');
|
|
3974
|
+
await delay(500);
|
|
3975
|
+
}
|
|
3976
|
+
}
|
|
3977
|
+
catch {
|
|
3978
|
+
// Non-fatal: session may have been destroyed
|
|
3979
|
+
}
|
|
3980
|
+
}
|
|
3981
|
+
// --- Part 1: Existing TUI prompt-line scanning ---
|
|
3982
|
+
for (const [sessionName, runtimeType] of this.tuiSessionRegistry) {
|
|
3983
|
+
try {
|
|
3984
|
+
// Skip Gemini CLI sessions — their TUI prompt behaves differently
|
|
3985
|
+
// and false-positive stuck detection causes Tab+Enter spam.
|
|
3986
|
+
if (runtimeType === RUNTIME_TYPES.GEMINI_CLI) {
|
|
3987
|
+
continue;
|
|
3988
|
+
}
|
|
3989
|
+
// #213: Skip orchestrator — it frequently waits for long-running
|
|
3990
|
+
// agent tasks (30+ min), and false stuck detection causes disruptive
|
|
3991
|
+
// Tab+Enter key injections that interrupt its workflow.
|
|
3992
|
+
if (sessionName === ORCHESTRATOR_SESSION_NAME) {
|
|
3993
|
+
continue;
|
|
3994
|
+
}
|
|
3995
|
+
// Skip sessions that no longer exist
|
|
3996
|
+
if (!sessionHelper.sessionExists(sessionName)) {
|
|
3997
|
+
this.tuiSessionRegistry.delete(sessionName);
|
|
3998
|
+
continue;
|
|
3999
|
+
}
|
|
4000
|
+
const output = sessionHelper.capturePane(sessionName);
|
|
4001
|
+
if (!output || output.trim().length === 0)
|
|
4002
|
+
continue;
|
|
4003
|
+
// Look for any text sitting on the prompt line
|
|
4004
|
+
const lines = output.split('\n');
|
|
4005
|
+
for (let i = lines.length - 1; i >= Math.max(0, lines.length - 25); i--) {
|
|
4006
|
+
const promptContent = matchTuiPromptLine(lines[i]);
|
|
4007
|
+
if (promptContent !== null) {
|
|
4008
|
+
const trimmedContent = stripTuiLineBorders(promptContent).trim();
|
|
4009
|
+
// Only act on substantial text (> 10 chars) to avoid false positives
|
|
4010
|
+
// from TUI rendering artifacts or short status text
|
|
4011
|
+
if (trimmedContent.length > 10) {
|
|
4012
|
+
// Skip known idle placeholder text that sits at
|
|
4013
|
+
// the `> ` prompt when no user input is present. These are
|
|
4014
|
+
// NOT stuck messages — they are TUI decoration.
|
|
4015
|
+
const lowerContent = trimmedContent.toLowerCase();
|
|
4016
|
+
const isPlaceholder = lowerContent.startsWith('type your message') ||
|
|
4017
|
+
trimmedContent.startsWith('@'); // e.g., "@path/to/file"
|
|
4018
|
+
if (isPlaceholder) {
|
|
4019
|
+
break;
|
|
4020
|
+
}
|
|
4021
|
+
this.logger.warn('Background scan: text stuck at TUI prompt, pressing Tab+Enter', {
|
|
4022
|
+
sessionName,
|
|
4023
|
+
promptContent: promptContent.slice(0, 80),
|
|
4024
|
+
});
|
|
4025
|
+
// Tab restores Ink TUI focus before Enter
|
|
4026
|
+
await sessionHelper.sendKey(sessionName, 'Tab');
|
|
4027
|
+
await delay(200);
|
|
4028
|
+
await sessionHelper.sendEnter(sessionName);
|
|
4029
|
+
await delay(500);
|
|
4030
|
+
// Backup Enter
|
|
4031
|
+
await sessionHelper.sendEnter(sessionName);
|
|
4032
|
+
}
|
|
4033
|
+
break; // Only check the first prompt line found from the bottom
|
|
4034
|
+
}
|
|
4035
|
+
}
|
|
4036
|
+
}
|
|
4037
|
+
catch (error) {
|
|
4038
|
+
this.logger.debug('Error scanning session for stuck messages', {
|
|
4039
|
+
sessionName,
|
|
4040
|
+
error: error instanceof Error ? error.message : String(error),
|
|
4041
|
+
});
|
|
4042
|
+
}
|
|
4043
|
+
}
|
|
4044
|
+
// --- Part 2: Tracked-message scanning (non-Gemini runtimes) ---
|
|
4045
|
+
const now = Date.now();
|
|
4046
|
+
const MIN_AGE_MS = 10000; // Check messages older than 10s
|
|
4047
|
+
const MAX_RECOVERY_ATTEMPTS = 5; // Give up after 5 Tab+Enter attempts
|
|
4048
|
+
for (const [sessionName, entries] of this.sentMessageTracker) {
|
|
4049
|
+
if (!sessionHelper.sessionExists(sessionName)) {
|
|
4050
|
+
this.sentMessageTracker.delete(sessionName);
|
|
4051
|
+
continue;
|
|
4052
|
+
}
|
|
4053
|
+
// Skip Gemini CLI sessions — Tab+Enter recovery causes more harm
|
|
4054
|
+
// than good because the Gemini TUI handles input differently.
|
|
4055
|
+
const trackedRuntime = this.tuiSessionRegistry.get(sessionName);
|
|
4056
|
+
if (trackedRuntime === RUNTIME_TYPES.GEMINI_CLI) {
|
|
4057
|
+
continue;
|
|
4058
|
+
}
|
|
4059
|
+
try {
|
|
4060
|
+
const output = sessionHelper.capturePane(sessionName);
|
|
4061
|
+
if (!output || output.trim().length === 0)
|
|
4062
|
+
continue;
|
|
4063
|
+
const bottomText = output.split('\n').slice(-15).join(' ').replace(/\s+/g, ' ');
|
|
4064
|
+
for (const entry of entries) {
|
|
4065
|
+
if (entry.recovered)
|
|
4066
|
+
continue;
|
|
4067
|
+
if (now - entry.sentAt < MIN_AGE_MS)
|
|
4068
|
+
continue;
|
|
4069
|
+
if (entry.recoveryAttempts >= MAX_RECOVERY_ATTEMPTS) {
|
|
4070
|
+
// Exhausted recovery attempts — mark as recovered to stop retrying
|
|
4071
|
+
entry.recovered = true;
|
|
4072
|
+
this.logger.error('Background scan: max recovery attempts exhausted', {
|
|
4073
|
+
sessionName,
|
|
4074
|
+
snippet: entry.snippet.slice(0, 50),
|
|
4075
|
+
attempts: entry.recoveryAttempts,
|
|
4076
|
+
});
|
|
4077
|
+
continue;
|
|
4078
|
+
}
|
|
4079
|
+
if (bottomText.includes(entry.snippet)) {
|
|
4080
|
+
entry.recoveryAttempts++;
|
|
4081
|
+
this.logger.warn('Background scan: tracked message stuck, pressing Tab+Enter', {
|
|
4082
|
+
sessionName,
|
|
4083
|
+
snippet: entry.snippet.slice(0, 50),
|
|
4084
|
+
ageMs: now - entry.sentAt,
|
|
4085
|
+
attempt: entry.recoveryAttempts,
|
|
4086
|
+
maxAttempts: MAX_RECOVERY_ATTEMPTS,
|
|
4087
|
+
});
|
|
4088
|
+
// Tab restores Ink TUI focus before Enter
|
|
4089
|
+
await sessionHelper.sendKey(sessionName, 'Tab');
|
|
4090
|
+
await delay(200);
|
|
4091
|
+
await sessionHelper.sendEnter(sessionName);
|
|
4092
|
+
await delay(500);
|
|
4093
|
+
await sessionHelper.sendEnter(sessionName); // backup
|
|
4094
|
+
break; // One recovery per session per scan cycle
|
|
4095
|
+
}
|
|
4096
|
+
}
|
|
4097
|
+
}
|
|
4098
|
+
catch (error) {
|
|
4099
|
+
this.logger.debug('Error scanning tracked messages for session', {
|
|
4100
|
+
sessionName,
|
|
4101
|
+
error: error instanceof Error ? error.message : String(error),
|
|
4102
|
+
});
|
|
4103
|
+
}
|
|
4104
|
+
}
|
|
4105
|
+
// Clean up old tracked-message entries
|
|
4106
|
+
for (const [sessionName, entries] of this.sentMessageTracker) {
|
|
4107
|
+
const fresh = entries.filter(e => now - e.sentAt < 5 * 60 * 1000);
|
|
4108
|
+
if (fresh.length === 0) {
|
|
4109
|
+
this.sentMessageTracker.delete(sessionName);
|
|
4110
|
+
}
|
|
4111
|
+
else {
|
|
4112
|
+
this.sentMessageTracker.set(sessionName, fresh);
|
|
4113
|
+
}
|
|
4114
|
+
}
|
|
4115
|
+
}
|
|
4116
|
+
/**
|
|
4117
|
+
* Unregister a TUI session from the stuck-message detector.
|
|
4118
|
+
* Call this when a session is destroyed or agent is stopped.
|
|
4119
|
+
*
|
|
4120
|
+
* @param sessionName - The session to unregister
|
|
4121
|
+
*/
|
|
4122
|
+
unregisterTuiSession(sessionName) {
|
|
4123
|
+
this.tuiSessionRegistry.delete(sessionName);
|
|
4124
|
+
this.sentMessageTracker.delete(sessionName);
|
|
4125
|
+
}
|
|
4126
|
+
/**
|
|
4127
|
+
* Register a message that was sent to a session for background
|
|
4128
|
+
* stuck-detection. The scanner will periodically check if the
|
|
4129
|
+
* message text is still visible at the bottom of the terminal.
|
|
4130
|
+
*
|
|
4131
|
+
* @param sessionName - The session the message was sent to
|
|
4132
|
+
* @param message - The full message text
|
|
4133
|
+
*/
|
|
4134
|
+
trackSentMessage(sessionName, message) {
|
|
4135
|
+
// Extract a search snippet: skip [CHAT:uuid] prefix, take first 80 chars
|
|
4136
|
+
const { prefixLength } = extractChatPrefix(message);
|
|
4137
|
+
const contentStart = prefixLength;
|
|
4138
|
+
// Normalize whitespace: messages may contain \n from enhanced templates.
|
|
4139
|
+
// Terminal bottom text is join(' '), so \n in snippet would never match.
|
|
4140
|
+
const snippet = message.slice(contentStart, contentStart + 80).replace(/\s+/g, ' ').trim();
|
|
4141
|
+
if (snippet.length < 10)
|
|
4142
|
+
return; // Too short to reliably match
|
|
4143
|
+
const entries = this.sentMessageTracker.get(sessionName) || [];
|
|
4144
|
+
entries.push({ snippet, sentAt: Date.now(), recovered: false, recoveryAttempts: 0 });
|
|
4145
|
+
// Keep only last 5 minutes of entries
|
|
4146
|
+
const cutoff = Date.now() - 5 * 60 * 1000;
|
|
4147
|
+
this.sentMessageTracker.set(sessionName, entries.filter(e => e.sentAt > cutoff));
|
|
4148
|
+
// Ensure the background scanner is running
|
|
4149
|
+
this.startStuckMessageDetector();
|
|
4150
|
+
}
|
|
4151
|
+
/**
|
|
4152
|
+
* Check if the agent appears to be at an input prompt.
|
|
4153
|
+
* Delegates to the regex-free isAgentAtPrompt() from terminal-string-ops,
|
|
4154
|
+
* with additional logging and per-line prompt detection using isPromptLine().
|
|
4155
|
+
*
|
|
4156
|
+
* @param terminalOutput - The terminal output to check
|
|
4157
|
+
* @param runtimeType - The runtime type for pattern selection
|
|
4158
|
+
* @returns true if the agent appears to be at a prompt
|
|
4159
|
+
*/
|
|
4160
|
+
isClaudeAtPrompt(terminalOutput, runtimeType) {
|
|
4161
|
+
// Handle null/undefined/empty input — return false since an empty buffer
|
|
4162
|
+
// may indicate a crashed session or one that hasn't started yet
|
|
4163
|
+
if (!terminalOutput || typeof terminalOutput !== 'string') {
|
|
4164
|
+
this.logger.debug('Terminal output is empty or invalid, cannot detect prompt', { runtimeType });
|
|
4165
|
+
return false;
|
|
4166
|
+
}
|
|
4167
|
+
// Only analyze the tail of the buffer to avoid matching historical prompts.
|
|
4168
|
+
// Use 5000 chars to accommodate large tool outputs that push the prompt
|
|
4169
|
+
// further back in the buffer (#106).
|
|
4170
|
+
const tailSection = terminalOutput.slice(-5000);
|
|
4171
|
+
// Check for prompt FIRST. Processing indicators like "thinking" or "analyzing"
|
|
4172
|
+
// can appear in the agent's previous response text and persist in the terminal
|
|
4173
|
+
// scroll buffer, causing false negatives if checked before the prompt.
|
|
4174
|
+
const lines = tailSection.split('\n').filter((line) => line.trim().length > 0);
|
|
4175
|
+
const linesToCheck = lines.slice(-10);
|
|
4176
|
+
const hasPrompt = linesToCheck.some(line => isPromptLine(line, runtimeType));
|
|
4177
|
+
if (hasPrompt) {
|
|
4178
|
+
return true;
|
|
4179
|
+
}
|
|
4180
|
+
// No prompt found — check if still processing.
|
|
4181
|
+
// Check last 10 lines (not just 5) because tool output can push processing
|
|
4182
|
+
// indicators further up while the status bar stays at the bottom.
|
|
4183
|
+
const recentLines = linesToCheck.join('\n');
|
|
4184
|
+
if (containsProcessingIndicator(recentLines)) {
|
|
4185
|
+
this.logger.debug('Processing indicators present near bottom of output');
|
|
4186
|
+
return false;
|
|
4187
|
+
}
|
|
4188
|
+
// Check for "esc to interrupt" in the status bar — this is a definitive
|
|
4189
|
+
// busy signal. Claude Code only shows this text while actively processing.
|
|
4190
|
+
// It disappears when the agent returns to idle at the prompt.
|
|
4191
|
+
if (containsBusyStatusBar(recentLines)) {
|
|
4192
|
+
this.logger.debug('Busy status bar detected (esc to interrupt)');
|
|
4193
|
+
return false;
|
|
4194
|
+
}
|
|
4195
|
+
this.logger.debug('No prompt detected in terminal output', {
|
|
4196
|
+
tailLength: tailSection.length,
|
|
4197
|
+
lastLines: linesToCheck.slice(-3).map(l => l.substring(0, 80)),
|
|
4198
|
+
runtimeType,
|
|
4199
|
+
});
|
|
4200
|
+
return false;
|
|
4201
|
+
}
|
|
4202
|
+
/**
|
|
4203
|
+
* Detect if Gemini CLI is currently in shell mode.
|
|
4204
|
+
*
|
|
4205
|
+
* In shell mode, Gemini CLI changes its prompt from `>` to `!`. Any input
|
|
4206
|
+
* sent in this mode is executed as a shell command instead of being passed
|
|
4207
|
+
* to the model. This method examines the last few lines of terminal output
|
|
4208
|
+
* for shell mode prompt indicators.
|
|
4209
|
+
*
|
|
4210
|
+
* @param terminalOutput - Captured terminal pane content
|
|
4211
|
+
* @returns true if the terminal shows a shell mode prompt
|
|
4212
|
+
*/
|
|
4213
|
+
isGeminiInShellMode(terminalOutput) {
|
|
4214
|
+
if (!terminalOutput || typeof terminalOutput !== 'string') {
|
|
4215
|
+
return false;
|
|
4216
|
+
}
|
|
4217
|
+
const lines = terminalOutput.split('\n').filter((line) => line.trim().length > 0);
|
|
4218
|
+
const linesToCheck = lines.slice(-10);
|
|
4219
|
+
return linesToCheck.some((line) => {
|
|
4220
|
+
const trimmed = line.trim();
|
|
4221
|
+
// Strip TUI box-drawing borders
|
|
4222
|
+
const stripped = trimmed.replace(/^[│┃|]+\s*/, '').replace(/\s*[│┃|]+$/, '');
|
|
4223
|
+
// Shell mode prompt: `!` alone or `! ` with text (not `> ` which is normal mode)
|
|
4224
|
+
// Check stripped line — after removing box-drawing, if it starts with `! ` or equals `!`
|
|
4225
|
+
if (stripped === '!' || stripped.startsWith('! ')) {
|
|
4226
|
+
return true;
|
|
4227
|
+
}
|
|
4228
|
+
// Also check pattern-based detection for bordered prompts
|
|
4229
|
+
return GEMINI_SHELL_MODE_CONSTANTS.SHELL_MODE_PROMPT_PATTERNS.some((pattern) => pattern.test(trimmed));
|
|
4230
|
+
});
|
|
4231
|
+
}
|
|
4232
|
+
/**
|
|
4233
|
+
* Escape from Gemini CLI shell mode by sending Escape key.
|
|
4234
|
+
*
|
|
4235
|
+
* Sends Escape and waits for the prompt to change from `!` back to `>`.
|
|
4236
|
+
* Retries up to MAX_ESCAPE_ATTEMPTS times.
|
|
4237
|
+
*
|
|
4238
|
+
* @param sessionName - The session running Gemini CLI
|
|
4239
|
+
* @param sessionHelper - SessionCommandHelper instance
|
|
4240
|
+
* @returns true if successfully escaped shell mode, false if still in shell mode
|
|
4241
|
+
*/
|
|
4242
|
+
async escapeGeminiShellMode(sessionName, sessionHelper) {
|
|
4243
|
+
for (let attempt = 1; attempt <= GEMINI_SHELL_MODE_CONSTANTS.MAX_ESCAPE_ATTEMPTS; attempt++) {
|
|
4244
|
+
this.logger.info('Gemini CLI in shell mode, sending Escape to exit', {
|
|
4245
|
+
sessionName,
|
|
4246
|
+
attempt,
|
|
4247
|
+
});
|
|
4248
|
+
await sessionHelper.sendEscape(sessionName);
|
|
4249
|
+
await delay(GEMINI_SHELL_MODE_CONSTANTS.ESCAPE_DELAY_MS);
|
|
4250
|
+
// Check if we're back to normal mode
|
|
4251
|
+
const output = sessionHelper.capturePane(sessionName);
|
|
4252
|
+
if (!this.isGeminiInShellMode(output)) {
|
|
4253
|
+
this.logger.info('Successfully exited Gemini CLI shell mode', {
|
|
4254
|
+
sessionName,
|
|
4255
|
+
attempt,
|
|
4256
|
+
});
|
|
4257
|
+
return true;
|
|
4258
|
+
}
|
|
4259
|
+
}
|
|
4260
|
+
this.logger.warn('Failed to exit Gemini CLI shell mode after max attempts', {
|
|
4261
|
+
sessionName,
|
|
4262
|
+
maxAttempts: GEMINI_SHELL_MODE_CONSTANTS.MAX_ESCAPE_ATTEMPTS,
|
|
4263
|
+
});
|
|
4264
|
+
return false;
|
|
4265
|
+
}
|
|
4266
|
+
/**
|
|
4267
|
+
* Generic key sending to any agent session
|
|
4268
|
+
* @param sessionName The agent session name
|
|
4269
|
+
* @param key The key to send (e.g., 'Enter', 'Ctrl+C')
|
|
4270
|
+
* @returns Promise with success/error information
|
|
4271
|
+
*/
|
|
4272
|
+
async sendKeyToAgent(sessionName, key) {
|
|
4273
|
+
try {
|
|
4274
|
+
// Get session helper once to avoid repeated async calls
|
|
4275
|
+
const sessionHelper = await this.getSessionHelper();
|
|
4276
|
+
// Check if session exists
|
|
4277
|
+
const sessionExists = sessionHelper.sessionExists(sessionName);
|
|
4278
|
+
if (!sessionExists) {
|
|
4279
|
+
return {
|
|
4280
|
+
success: false,
|
|
4281
|
+
error: `Session '${sessionName}' does not exist`,
|
|
4282
|
+
};
|
|
4283
|
+
}
|
|
4284
|
+
// Send key using session command helper
|
|
4285
|
+
await sessionHelper.sendKey(sessionName, key);
|
|
4286
|
+
this.logger.info('Key sent to agent successfully', {
|
|
4287
|
+
sessionName,
|
|
4288
|
+
key,
|
|
4289
|
+
});
|
|
4290
|
+
return {
|
|
4291
|
+
success: true,
|
|
4292
|
+
message: `${key} key sent to agent successfully`,
|
|
4293
|
+
};
|
|
4294
|
+
}
|
|
4295
|
+
catch (error) {
|
|
4296
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
4297
|
+
this.logger.error('Failed to send key to agent', {
|
|
4298
|
+
sessionName,
|
|
4299
|
+
key,
|
|
4300
|
+
error: errorMessage,
|
|
4301
|
+
});
|
|
4302
|
+
return {
|
|
4303
|
+
success: false,
|
|
4304
|
+
error: errorMessage,
|
|
4305
|
+
};
|
|
4306
|
+
}
|
|
4307
|
+
}
|
|
4308
|
+
/**
|
|
4309
|
+
* Generic health check for any agent session
|
|
4310
|
+
* @param sessionName The agent session name
|
|
4311
|
+
* @param role The agent role for additional context
|
|
4312
|
+
* @param timeout Timeout for health check in milliseconds
|
|
4313
|
+
* @returns Promise with health status information
|
|
4314
|
+
*/
|
|
4315
|
+
async checkAgentHealth(sessionName, role, timeout = 1000) {
|
|
4316
|
+
try {
|
|
4317
|
+
// Lightweight health check with timeout
|
|
4318
|
+
const agentRunning = await Promise.race([
|
|
4319
|
+
(await this.getSessionHelper()).sessionExists(sessionName),
|
|
4320
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error('Health check timeout')), timeout)),
|
|
4321
|
+
]).catch(() => false);
|
|
4322
|
+
return {
|
|
4323
|
+
success: true,
|
|
4324
|
+
data: {
|
|
4325
|
+
agent: {
|
|
4326
|
+
sessionName,
|
|
4327
|
+
role,
|
|
4328
|
+
running: agentRunning,
|
|
4329
|
+
status: agentRunning
|
|
4330
|
+
? CREWLY_CONSTANTS.AGENT_STATUSES.ACTIVE
|
|
4331
|
+
: CREWLY_CONSTANTS.AGENT_STATUSES.INACTIVE,
|
|
4332
|
+
},
|
|
4333
|
+
timestamp: new Date().toISOString(),
|
|
4334
|
+
},
|
|
4335
|
+
};
|
|
4336
|
+
}
|
|
4337
|
+
catch (error) {
|
|
4338
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
4339
|
+
this.logger.error('Failed to check agent health', {
|
|
4340
|
+
sessionName,
|
|
4341
|
+
role,
|
|
4342
|
+
error: errorMessage,
|
|
4343
|
+
});
|
|
4344
|
+
return {
|
|
4345
|
+
success: false,
|
|
4346
|
+
error: errorMessage,
|
|
4347
|
+
};
|
|
4348
|
+
}
|
|
4349
|
+
}
|
|
4350
|
+
/**
|
|
4351
|
+
* Write the prompt to a file and send a short instruction to the agent to read it.
|
|
4352
|
+
*
|
|
4353
|
+
* Instead of pasting large multi-line prompts directly into the terminal (which
|
|
4354
|
+
* causes bracketed paste issues, shell interpretation errors, and truncation),
|
|
4355
|
+
* we write the prompt to ~/.crewly/prompts/{sessionName}-init.md and send a
|
|
4356
|
+
* single-line instruction telling the agent to read that file.
|
|
4357
|
+
*
|
|
4358
|
+
* @param sessionName The session name
|
|
4359
|
+
* @param prompt The full system prompt to deliver
|
|
4360
|
+
* @param runtimeType The agent runtime type
|
|
4361
|
+
* @param abortSignal Optional signal to cancel the operation (e.g. on runtime exit)
|
|
4362
|
+
* @returns true if the instruction was delivered successfully
|
|
4363
|
+
*/
|
|
4364
|
+
async sendPromptRobustly(sessionName, prompt, runtimeType = RUNTIME_TYPES.CLAUDE_CODE, abortSignal) {
|
|
4365
|
+
const isClaudeCode = runtimeType === RUNTIME_TYPES.CLAUDE_CODE;
|
|
4366
|
+
const sessionHelper = await this.getSessionHelper();
|
|
4367
|
+
// Step 1: Write prompt to a file (idempotent — may already exist from pre-launch write).
|
|
4368
|
+
const promptFilePath = await this.writePromptFile(sessionName, prompt);
|
|
4369
|
+
if (!promptFilePath) {
|
|
4370
|
+
// File write failure is fatal — all runtimes use the file-read approach.
|
|
4371
|
+
return false;
|
|
4372
|
+
}
|
|
4373
|
+
// Step 2: Build the message to send.
|
|
4374
|
+
// Claude Code: prompt was already loaded via --agent flag at launch (#207),
|
|
4375
|
+
// so the agent already has all instructions as its agent definition. Just send a short
|
|
4376
|
+
// kickoff trigger — no need to ask it to read the file again.
|
|
4377
|
+
// Gemini CLI / other runtimes: need the file-read instruction since the prompt
|
|
4378
|
+
// was NOT loaded via system prompt.
|
|
4379
|
+
const messageToSend = isClaudeCode
|
|
4380
|
+
? 'Begin your work now. Follow the step-by-step instructions in your agent definition EXACTLY — start with Step 1, then Step 2, then Step 3 (register-self). Do NOT skip or reorder steps. Registration is required before the system will deliver messages to you.'
|
|
4381
|
+
: `Read the file at ${promptFilePath} and follow all instructions in it.`;
|
|
4382
|
+
// Note: kickoff message is intentionally imperative for reliable agent bootstrapping.
|
|
4383
|
+
// The --agent flag (Claude Code) loads the prompt as trusted system context, so this
|
|
4384
|
+
// short trigger is not subject to PI detection.
|
|
4385
|
+
// Single attempt for all runtimes. Retrying causes duplicate messages because:
|
|
4386
|
+
// - Claude Code: returns to its prompt between tool calls, making retry
|
|
4387
|
+
// checks think the first message wasn't received.
|
|
4388
|
+
// - Gemini CLI: the echoed message "Read the file at..." starts with `> `
|
|
4389
|
+
// which isClaudeAtPrompt misdetects as an idle prompt, and the TUI's
|
|
4390
|
+
// input box shows `> ` even during processing. Both cause false-negative
|
|
4391
|
+
// delivery verification and unnecessary retries.
|
|
4392
|
+
// The sendMessage helper reliably writes to the PTY — if it succeeds,
|
|
4393
|
+
// the message was delivered.
|
|
4394
|
+
const maxAttempts = 1;
|
|
4395
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
4396
|
+
// Check abort before each attempt
|
|
4397
|
+
if (abortSignal?.aborted) {
|
|
4398
|
+
this.logger.info('Prompt delivery aborted (runtime exited)', { sessionName, attempt });
|
|
4399
|
+
return false;
|
|
4400
|
+
}
|
|
4401
|
+
try {
|
|
4402
|
+
this.logger.info('Sending prompt to agent', {
|
|
4403
|
+
sessionName,
|
|
4404
|
+
attempt,
|
|
4405
|
+
runtimeType,
|
|
4406
|
+
promptFilePath,
|
|
4407
|
+
deliveryMethod: isClaudeCode ? 'system-prompt-kickoff' : 'file-read',
|
|
4408
|
+
});
|
|
4409
|
+
// Check abort before sending to terminal
|
|
4410
|
+
if (abortSignal?.aborted) {
|
|
4411
|
+
this.logger.info('Prompt delivery aborted before send (runtime exited)', { sessionName });
|
|
4412
|
+
return false;
|
|
4413
|
+
}
|
|
4414
|
+
// Clear any pending input before sending the instruction (first attempt only).
|
|
4415
|
+
if (attempt === 1) {
|
|
4416
|
+
if (isClaudeCode) {
|
|
4417
|
+
// Claude Code: Escape closes slash menus + Ctrl+U clears line.
|
|
4418
|
+
await sessionHelper.sendEscape(sessionName);
|
|
4419
|
+
await delay(200);
|
|
4420
|
+
await sessionHelper.sendKey(sessionName, 'C-u');
|
|
4421
|
+
await delay(300);
|
|
4422
|
+
}
|
|
4423
|
+
else if (runtimeType === RUNTIME_TYPES.CODEX_CLI) {
|
|
4424
|
+
// #246: Codex CLI — Ctrl+U to clear any stale input text.
|
|
4425
|
+
// Unlike Gemini, Codex doesn't need Enter flush; its Ink TUI
|
|
4426
|
+
// handles Ctrl+U for line clear similar to Claude Code.
|
|
4427
|
+
await sessionHelper.sendKey(sessionName, 'C-u');
|
|
4428
|
+
await delay(300);
|
|
4429
|
+
}
|
|
4430
|
+
else {
|
|
4431
|
+
// Gemini CLI (Ink TUI): After /directory add processing, the TUI
|
|
4432
|
+
// input may lose focus or have stale invisible characters.
|
|
4433
|
+
// Send Enter to flush any residual input, then wait for the
|
|
4434
|
+
// prompt to settle before sending the real instruction.
|
|
4435
|
+
this.logger.debug('Gemini CLI pre-send: flushing input with Enter', { sessionName });
|
|
4436
|
+
await sessionHelper.sendEnter(sessionName);
|
|
4437
|
+
await delay(1000);
|
|
4438
|
+
}
|
|
4439
|
+
}
|
|
4440
|
+
// Check abort right before writing instruction to terminal
|
|
4441
|
+
if (abortSignal?.aborted) {
|
|
4442
|
+
this.logger.info('Prompt delivery aborted before instruction send (runtime exited)', { sessionName });
|
|
4443
|
+
return false;
|
|
4444
|
+
}
|
|
4445
|
+
// Send the kickoff / file-read instruction
|
|
4446
|
+
await sessionHelper.sendMessage(sessionName, messageToSend);
|
|
4447
|
+
// Claude Code: rapid-check verification.
|
|
4448
|
+
// Check every 1s for up to 24s (Claude v2 first-token can be slow: MCP/skill load) whether Claude started processing.
|
|
4449
|
+
// If Claude leaves the prompt at any point, the message was received.
|
|
4450
|
+
// This avoids the old length-comparison approach which was unreliable
|
|
4451
|
+
// (Claude's TUI redraws change output length unpredictably).
|
|
4452
|
+
if (isClaudeCode) {
|
|
4453
|
+
for (let i = 0; i < 24; i++) {
|
|
4454
|
+
await delay(1000);
|
|
4455
|
+
if (abortSignal?.aborted)
|
|
4456
|
+
return false;
|
|
4457
|
+
const currentOutput = sessionHelper.capturePane(sessionName);
|
|
4458
|
+
// Processing indicators (spinners) = definitive success
|
|
4459
|
+
if (containsSpinnerOrWorkingIndicator(currentOutput)) {
|
|
4460
|
+
this.logger.debug('Kickoff delivered — processing indicators detected', {
|
|
4461
|
+
sessionName, checkIndex: i,
|
|
4462
|
+
});
|
|
4463
|
+
return true;
|
|
4464
|
+
}
|
|
4465
|
+
// Claude left the prompt = started working on the message
|
|
4466
|
+
if (!this.isClaudeAtPrompt(currentOutput, RUNTIME_TYPES.CLAUDE_CODE)) {
|
|
4467
|
+
this.logger.debug('Kickoff delivered — Claude left prompt', {
|
|
4468
|
+
sessionName, checkIndex: i,
|
|
4469
|
+
});
|
|
4470
|
+
return true;
|
|
4471
|
+
}
|
|
4472
|
+
}
|
|
4473
|
+
// Claude still at prompt after 8s — message likely not received.
|
|
4474
|
+
// For Claude Code with --append-system-prompt-file, don't retry
|
|
4475
|
+
// (would cause duplicate). Log warning and return false.
|
|
4476
|
+
// Still at prompt — the kickoff was likely typed but not submitted (PTY submit
|
|
4477
|
+
// can be dropped). A missing agent costs far more than a duplicate kickoff
|
|
4478
|
+
// (idempotent: it just re-reads the prompt file), so resend ONCE and re-poll.
|
|
4479
|
+
this.logger.warn('Kickoff unconfirmed after 24s — resending once', { sessionName, runtimeType });
|
|
4480
|
+
await sessionHelper.sendMessage(sessionName, messageToSend);
|
|
4481
|
+
for (let j = 0; j < 12; j++) {
|
|
4482
|
+
await delay(1000);
|
|
4483
|
+
if (abortSignal?.aborted)
|
|
4484
|
+
return false;
|
|
4485
|
+
const out2 = sessionHelper.capturePane(sessionName);
|
|
4486
|
+
if (containsSpinnerOrWorkingIndicator(out2) || !this.isClaudeAtPrompt(out2, RUNTIME_TYPES.CLAUDE_CODE)) {
|
|
4487
|
+
this.logger.debug('Kickoff delivered on resend', { sessionName, checkIndex: j });
|
|
4488
|
+
return true;
|
|
4489
|
+
}
|
|
4490
|
+
}
|
|
4491
|
+
this.logger.warn('Kickoff delivery unconfirmed after resend — giving up', { sessionName, runtimeType });
|
|
4492
|
+
return false;
|
|
4493
|
+
}
|
|
4494
|
+
// Gemini CLI / other runtimes: trust the PTY delivery.
|
|
4495
|
+
// sendMessage writes directly to the PTY — if it didn't throw,
|
|
4496
|
+
// the message was delivered. Terminal-based verification is unreliable
|
|
4497
|
+
// because Gemini's TUI always shows `> ` (even during processing)
|
|
4498
|
+
// and our echoed message starts with `> `, both causing false
|
|
4499
|
+
// "at prompt" detection.
|
|
4500
|
+
this.logger.info('Prompt instruction sent to PTY (trusting delivery)', {
|
|
4501
|
+
sessionName, attempt, runtimeType,
|
|
4502
|
+
messageLength: messageToSend.length,
|
|
4503
|
+
});
|
|
4504
|
+
return true;
|
|
4505
|
+
}
|
|
4506
|
+
catch (error) {
|
|
4507
|
+
this.logger.error('Error during prompt instruction delivery', {
|
|
4508
|
+
sessionName,
|
|
4509
|
+
attempt,
|
|
4510
|
+
runtimeType,
|
|
4511
|
+
error: error instanceof Error ? error.message : String(error),
|
|
4512
|
+
});
|
|
4513
|
+
if (attempt === maxAttempts) {
|
|
4514
|
+
return false;
|
|
4515
|
+
}
|
|
4516
|
+
}
|
|
4517
|
+
}
|
|
4518
|
+
this.logger.warn('Prompt instruction delivery unconfirmed after all attempts (agent may still process it)', {
|
|
4519
|
+
sessionName,
|
|
4520
|
+
maxAttempts,
|
|
4521
|
+
runtimeType,
|
|
4522
|
+
});
|
|
4523
|
+
return false;
|
|
4524
|
+
}
|
|
4525
|
+
/**
|
|
4526
|
+
* Capture the current PTY output of a running agent session.
|
|
4527
|
+
*
|
|
4528
|
+
* Used by the queue processor to verify that a force-delivered message
|
|
4529
|
+
* was actually seen by the agent (pending-ack verification).
|
|
4530
|
+
*
|
|
4531
|
+
* @param sessionName - The agent session name
|
|
4532
|
+
* @param lines - Number of lines to capture (default 200)
|
|
4533
|
+
* @returns The captured terminal output, or empty string if session not found
|
|
4534
|
+
*/
|
|
4535
|
+
async captureAgentOutput(sessionName, lines = 200) {
|
|
4536
|
+
try {
|
|
4537
|
+
const sessionHelper = await this.getSessionHelper();
|
|
4538
|
+
if (!sessionHelper.sessionExists(sessionName)) {
|
|
4539
|
+
return '';
|
|
4540
|
+
}
|
|
4541
|
+
return sessionHelper.capturePane(sessionName, lines);
|
|
4542
|
+
}
|
|
4543
|
+
catch {
|
|
4544
|
+
return '';
|
|
4545
|
+
}
|
|
4546
|
+
}
|
|
4547
|
+
}
|
|
4548
|
+
//# sourceMappingURL=agent-registration.service.js.map
|