import { CrewlyAgentExternalRuntimeService } from './crewly-agent/crewly-agent-external-runtime.service.js'; import { StorageService } from '../core/storage.service.js'; import { CREWLY_CONSTANTS, RuntimeType } from '../../constants.js'; import { type RegistrationFlowCancelReason } from './registration-flow-registry.js'; export interface OrchestratorConfig { sessionName: string; projectPath: string; windowName?: string; /** Runtime the orchestrator will run (adds runtime-scoped env such as Antigravity's key, and selects the settings API keys passed in its spawn env) */ runtimeType?: RuntimeType; } /** * Error thrown when an agent role cannot be resolved to a valid value. * * Indicates a member with no `role` field in storage, or a caller passing * `undefined`/empty/literal-"undefined". Surfaces the failure loudly so it * cannot manifest as the silent "empty Claude prompt" symptom (Bug #1). */ export declare class InvalidAgentRoleError extends Error { readonly received: unknown; constructor(received: unknown, context?: string); } /** * Validate that an agent role is a usable string before it flows into session * creation, env-var injection, or prompt-template path resolution. * * Rejects: undefined, null, non-strings, empty/whitespace strings, and the * literal string "undefined" (which results from JS coercion when an undefined * is interpolated into a template literal — the exact failure mode behind * `CREWLY_ROLE="undefined"` in tmux env). * * @param role - The candidate role value * @param context - Optional caller context for the error message (e.g. "startTeamMember") * @throws {InvalidAgentRoleError} when role is unusable */ export declare function validateAgentRole(role: unknown, context?: string): asserts role is string; /** * Service responsible for the complex, multi-step process of agent initialization and registration. * Isolates the complex state management of agent startup with progressive escalation. * * Key capabilities: * - Agent session creation and management * - Runtime initialization and registration * - Reliable message delivery to Claude Code with retry logic * - Health checking and status management */ /** * Key for the duplicate-write guard (#128): a hash of the whole message. * * @param message - Text written to the agent * @returns sha1 hex of the full text */ export declare function messageDedupKey(message: string): string; export declare class AgentRegistrationService { private logger; private _sessionHelper; private storageService; private projectRoot; private promptCache; private sessionCreationLocks; private registrationFlows; private stuckMessageDetectorTimer; private tuiSessionRegistry; private sentMessageTracker; private sessionDeliveryMutex; private lastSentMessageHash; private lastGuardRefusal; /** * Sessions whose last delivery attempt found a busy Claude Code (its * "esc to interrupt" bar on screen) and did not paste: the message is * queued and delivered when the agent goes idle (2026-10-03 Ella: a paste * into a busy box rendered late, got no Enter and blocked the box). */ private busyHold; /** When messages to a session started being held for a busy agent (first hold of the episode) */ private busyHoldSince; /** Re-check timers for messages held for a busy agent */ private busyHoldTimers; /** Timers that write out messages held for a usage-limited agent */ private usageLimitFlushTimers; private guardRefusalCount; private inProcessRuntimes; /** #167: Optional scheduler service for DLQ drain on agent activation. */ private schedulerService; /** Architecture Upgrade Phase 6: EventBusService for standing task subscriptions. */ private eventBusService; private static get CLAUDE_PROMPT_INDICATORS(); constructor(_legacyTmuxService: unknown, // Legacy parameter for backwards compatibility projectRoot: string | null, storageService: StorageService); /** * #167: Inject scheduler service for dead-letter queue drain on agent activation. * * @param scheduler - Scheduler service with drainDeadLetterQueue method */ setSchedulerService(scheduler: { drainDeadLetterQueue(sessionName: string): Promise; }): void; /** * Inject EventBusService for standing task workflow subscriptions at agent registration. * * @param eventBus - EventBusService instance with subscribe method */ setEventBusService(eventBus: { subscribe(input: import('../../types/event-bus.types.js').CreateSubscriptionInput): import('../../types/event-bus.types.js').EventSubscription; }): void; /** * Cancel a pending registration prompt for a session. * Called by RuntimeExitMonitorService when a runtime exit is detected, and * before every kill of the session's PTY. * * @param sessionName - The session whose registration to cancel * @param reason - Why the flow is cancelled (defaults to a runtime exit) */ cancelPendingRegistration(sessionName: string, reason?: RegistrationFlowCancelReason): void; /** * The PTY currently registered under a session name, used as the identity * a registration flow is bound to. Undefined when there is none (or the * session helper is not initialised yet). * * @param sessionName - Session name * @returns The live session object, compared by reference */ private currentPtySession; /** * Whether a registration flow must stop writing to its session: its * signal was aborted, a newer flow took over, or the session's PTY was * killed/replaced since the flow started. Checked before every send. * * @param sessionName - Session the flow writes to * @param abortSignal - The flow's signal (undefined = unguarded caller) * @returns True when the caller must not send anything */ private isFlowCancelled; /** * Whether a session is already up or being brought up: a creation is in * flight, a PTY exists under the name, or an in-process runtime runs it. * The boot restore uses this to leave alone an agent another launcher * (reconciler wake, team start) already started this boot, instead of * killing it and resuming the same conversation a second time. * * @param sessionName - Session name * @returns True when the session is live or launching */ isSessionLiveOrLaunching(sessionName: string): Promise; /** * Get or create the session helper with lazy initialization */ private getSessionHelper; /** * Get session helper synchronously (may throw if not initialized) */ private getSessionHelperSync; /** * Resolve the runtime type for a session from storage. * Checks orchestrator status first, then team member data. * Falls back to CLAUDE_CODE if nothing is found. * * IMPORTANT: Callers should prefer passing runtimeType explicitly. * This method exists as a safety net so that Ctrl+C (Claude Code * cleanup) is never sent to a Gemini CLI session, which would * trigger /quit and kill the agent. */ private resolveSessionRuntimeType; /** * Route a crewly-agent in-process response to the originating chat conversation. * * PTY-based runtimes emit [NOTIFY] markers that the terminal gateway parses. * In-process crewly-agents have no terminal output, so this method provides * the equivalent routing for their text responses. * * @param sessionName - Agent session that produced the response * @param text - Response text from the agent * @param conversationId - Chat conversation to route the response to * @param slackThreadKey - Slack thread the delivered message came from, when it named exactly one */ private routeInProcessResponseToChat; /** * Auto-route an in-process agent's text response back to Slack. * * Mirrors the post-send side effects of POST /slack/send so that the * Slack reply, thread-status bookkeeping, and V3 SLA cascade all stay in * sync. Used as a finish-event hook for the in-process Crewly Agent * runtime when: * 1. The inbound message carried a `[SLACK:channelId:threadTs]` marker * 2. The agent produced text output (`result.text` non-empty) * 3. The agent did NOT explicitly call the `reply_slack` tool * * Without this hook, AI SDK agents that finish with `toolCalls=0, * finishReason=stop` would drop their reply on the floor — unlike the * claude-code PTY path, the in-process runtime has no stdout stream the * SlackBridge can tail. * * Best-effort: a Slack-not-connected condition or a downstream * bookkeeping failure is logged and swallowed; never throws. The chat * route (`routeInProcessResponseToChat`) handles persistence to the chat * service separately. * * @param sessionName - Agent session that produced the response * @param text - Response text from the agent * @param slackContext - Slack channel + thread metadata captured from the * inbound `[SLACK:channelId:threadTs]` marker */ private routeInProcessResponseToSlack; /** * Find the project root by looking for package.json */ private findProjectRoot; /** * Turn a member's model / reasoning-effort choice into launch flags for * its PTY runtime (`--model` for Claude Code, `-m` for Codex / Gemini, * `--model provider/model` for OpenCode). The in-process Crewly Agent * runtime reads `modelId` directly and gets no flags here. * * @param sessionName - For logging * @param runtimeType - The member's runtime * @param modelId - Model name as the harness expects it * @param reasoningEffort - Optional effort level (Claude Code / Codex) * @returns Flags to inject after the binary, or `[]` */ private resolveModelFlags; /** * Resolve runtime flags from the agent's effective skills. * Reads role config and skill configs to collect flags compatible with the runtime. * * @param role - The agent's role name * @param runtimeType - The agent's runtime type (e.g. 'claude-code') * @param skillOverrides - Additional skills assigned to the member * @param excludedRoleSkills - Skills excluded from the role's default set * @returns Array of CLI flags to inject (e.g. ['--chrome']) */ private resolveRuntimeFlags; /** * Search known skill directories for a skill's skill.json file. * Skills live under `config/skills/agent/core/` or `config/skills/agent/marketplace/`. * * @param skillId - The skill identifier (directory name) * @returns Absolute path to skill.json, or null if not found */ private findSkillJsonPath; /** * Create a runtime service instance for the given runtime type. * Centralizes RuntimeServiceFactory creation to reduce code duplication. */ private createRuntimeService; /** * Get an in-process Crewly Agent runtime by session name. * Used by the terminal controller for force-delivery to in-process agents. * * @param sessionName - Session name to look up * @returns The CrewlyAgentExternalRuntimeService if it exists, undefined otherwise */ getInProcessRuntime(sessionName: string): CrewlyAgentExternalRuntimeService | undefined; /** * Check if an in-process Crewly Agent runtime is active and ready for a given session. * * Used by team status resolution to correctly report active status for * crewly-agent runtimes that have no PTY session. * * @param sessionName - The session name to check * @returns True if an in-process runtime exists and is ready */ isInProcessRuntimeActive(sessionName: string): boolean; /** * Decide how the runtime's conversation id is handled for this launch and * apply the Claude Code half (flags + persisting a preset id). Codex's * resume id is returned for the command rewrite; a fresh Codex id is * discovered after launch by {@link recordRuntimeSessionAfterLaunch}. * * @param sessionName - Agent session * @param runtimeType - Runtime * @param effectiveFlags - Flag list to extend in place * @returns The plan */ private planSessionRecovery; /** * For a fresh Claude Code conversation, hand over the most recent previous * one (written within the last week) the way a runtime switch does: a * handover file plus a note on the kickoff. Skipped when a handover for the * session is already newer than that conversation (the oversized, per-task * and runtime-switch paths write their own). Never throws. * * @param sessionName - Session being launched * @param cwd - Its working directory * @param persistence - Session persistence, when available */ private prepareRestartHandover; /** * Decide whether a Claude Code agent's stored conversation is too big to * resume, and if so write the handover file its fresh one will read. * * The orchestrator's threshold is {@link orcFreshContextTokens} (300k); * team members use {@link memberFreshContextTokens} (400k, above Claude * Code's own 350k auto-compact so compaction runs first and this is only a * safety net — crewly#1344). * * The transcript is looked for under the Claude home the session runs on * NOW (`claudeHomeOverride`: another account's config dir after a runtime * fallback moved it there), which is also where `conversationExists` looks * before `--resume`. A switch between accounts copies the transcript there * first (runtime-fallback.wiring `carryClaudeConversation`), so the * conversation is found and resumed instead of starting fresh. * Only Claude Code, and only here — at a launch — so a conversation is * never cut in the middle of anything. Real state (tasks, teams, OKRs, * wiki) lives in Crewly and is read back at startup; the handover keeps * the tail of what was said. * * @param sessionName - Session being launched * @param runtimeType - Its runtime * @param storedSessionId - The conversation it would resume * @param cwd - Its working directory * @returns True when it should start fresh instead */ private closeOversizedConversation; /** * For a freshly launched Codex agent, learn its conversation id from the * rollout file it writes right after start and persist it. Runs in the * background; never throws. * * @param sessionName - Agent session * @param runtimeType - Runtime * @param cwd - The agent's working directory (matched against the rollout's cwd) * @param launchedAtMs - When the launch command was sent * @param plan - The launch plan (skips when the id is already known) */ /** * Remember (or forget) that a session came up by resuming a conversation. * * @param sessionName - The session * @param role - Role it runs as (for the register-self kickoff) * @param plan - The launch plan */ private noteResumedLaunch; /** * Kickoff text for a resumed runtime conversation (any runtime): the * backend restarted, so its registration is gone even though the * conversation remembers doing it. Tell it plainly that this is the same * conversation, hand it the exact register-self command its prompt uses * (orchestrator skill for the orchestrator), and tell it to carry on. * * @param sessionName - The session * @param role - Its role * @param promptFilePath - The init prompt, for a context refresh; omitted * for Claude Code, whose prompt is loaded as its agent definition * @returns The message to type into the runtime */ private resumedKickoff; private recordRuntimeSessionAfterLaunch; /** * For a freshly launched Antigravity agent, learn its conversation id and * persist it so a restart can resume with `agy --conversation=`. * agy creates the conversation when the first prompt (the registration * kickoff) arrives; the conversation whose logs mention this agent's init * prompt file wins, else agy's latest conversation for the workspace. * Runs in the background; never throws. * * @param sessionName - Agent session * @param cwd - The agent's working directory (agy's workspace) * @param launchedAtMs - When the launch command was sent * @param plan - The launch plan (skips when resuming a known id) */ private recordAntigravityConversationAfterLaunch; /** * Get the check interval based on environment. * Uses shorter intervals in test environment for faster tests. */ private getCheckInterval; /** * Use the short test-environment cadence under Jest, otherwise the given * production interval. * * @param productionMs - Interval to use outside the test environment * @returns Interval in milliseconds */ private testAwareInterval; /** * Update agent status with safe error handling (non-blocking). * Returns true if successful, false if failed. */ private updateAgentStatusSafe; /** * Role aliases — common synonyms users naturally type in team configs * mapped to the canonical directory name under `config/roles/`. Without * these, a team-config role like `"tech-lead"` would fall through to a * non-existent directory and trigger the ENOENT fallback path on every * agent registration (Steve 2026-05-15 dogfood: CE team config used * `role: "tech-lead"` while the canonical directory is `team-leader`, * producing a steady drip of "Could not load prompt from config" warns * for every member-start with no actionable resolution short of editing * user data). * * Keep keys lowercased + dash-normalized (matches the post-normalization * shape used by `getPromptFileForRole`). */ private static readonly ROLE_DIR_ALIASES; /** * Role directory used when a team's custom role string has no * `config/roles//prompt.md`. Falling back to this generic prompt * keeps the full modular assembly (identity, soul, member systemPrompt) * — the bare inline fallback left agents introducing themselves as * "Codex" instead of by their name. */ private static readonly FALLBACK_ROLE_DIR; /** Custom roles already reported as falling back (warn once each). */ private readonly roleFallbackWarned; /** * Sessions launched by resuming an earlier runtime conversation (Codex * `resume`, Claude `--resume`) → the role they run as. A resumed agent has * already read its init prompt once and answers "Ready." when told to * read it again, without re-registering — the orchestrator on * steamfun-ops sat unregistered for 11 restarts that way (2026-09-19). * Such sessions get an explicit register-self kickoff instead. */ private readonly resumedSessions; /** Handover file for an orchestrator that was just given a fresh conversation, by session. */ private readonly pendingHandovers; /** Kickoff note for a session that restarted into a fresh conversation, with the handover to read (given the way a runtime-switch note is). */ private readonly pendingRestartNotes; /** * Assemble the orchestrator prompt at the *other* profile and log the * module count + token estimate of both tiers side by side, so the * operator can judge what `CREWLY_ORC_PROMPT_PROFILE=lite` would save (or * did save) without diffing prompts by hand. Read-only and best-effort: * a failure here never affects the prompt that was already assembled. * * @param assembler - The assembler used for the live prompt * @param moduleConfig - Config the live prompt was assembled from * @param liveReport - Report of the live (selected-profile) assembly */ private logPromptProfileComparison; /** * Get prompt file path for a specific role * Uses the unified config/roles/{role}/prompt.md structure */ private getPromptFileForRole; /** * Initialize agent with optimized 2-step escalation process * Reduced from 4-step to 2-step with shorter timeouts for better concurrency */ initializeAgentWithRegistration(sessionName: string, role: string, projectPath?: string, timeout?: number, memberId?: string, runtimeType?: RuntimeType, runtimeFlags?: string[], additionalAllowlistPaths?: string[]): Promise<{ success: boolean; message?: string; error?: string; /** Set to RUNTIME_STARTUP_BLOCKED when retrying cannot help. */ errorCode?: string; }>; /** * Step 1: Try direct registration prompt * Assumes Claude is already running and just needs a registration prompt */ private tryDirectRegistration; /** * Step 2: Cleanup with Ctrl+C and reinitialize * Tries to reset the runtime session and start fresh */ private tryCleanupAndReinit; /** * Detect and store the Claude Code session ID by scanning the filesystem. * Claude Code stores conversations as .jsonl files in * ~/.claude/projects// where path-slug is the absolute * project path with / replaced by -. * * Runs in background after agent init to capture the session ID * for resume-on-restart support. * * @param sessionName - PTY session name * @param projectPath - Absolute path to the agent's project directory */ private detectAndStoreSessionId; /** * Provision the runtime-specific project config file. * * Each agent CLI reads a different file for project configuration: * - Claude Code → .crewly/CLAUDE.md * - Gemini CLI → GEMINI.md (project root) * - Codex → AGENTS.md (project root) * - OpenCode → AGENTS.md (project root, same convention as Codex — #306) * - Antigravity → AGENTS.md (project root; agy reads GEMINI.md and AGENTS.md) * * Uses 'wx' flag to avoid overwriting existing files. * * @param projectPath - Project directory path * @param runtimeType - Agent runtime type */ private provisionRuntimeConfigFile; /** * Send registration prompt asynchronously (non-blocking). * Uses an AbortController so the operation can be cancelled if the * runtime exits before registration completes. */ private sendRegistrationPromptAsync; /** * Whether the runtime is sitting on its sign-in screen. Typing the kickoff * there after the input-ready timeout put the instruction text into * Codex's "Provide your own API key" box: it became the "API key", every * call 401'd, and the 401 set off a re-login that wiped the owner's fresh * ChatGPT sign-in (2026-09-26, Nova). Nothing is typed; the login monitor * sees the screen and asks the owner to sign in. * * @param sessionName - Session * @returns True when the screen is a sign-in / login prompt */ private isOnSignInScreen; /** * Poll the PTY screen until the runtime is idle at its input prompt * (`RuntimeAgentService.isReadyForInput`) or the timeout elapses. * * On timeout we log a warning and return `false` but the caller still * sends the instruction — a late instruction is recoverable (re-delivery, * `POST /api/terminal/:s/deliver`), a never-sent one is not. * * @param sessionName - PTY session name * @param runtimeType - Runtime whose prompt heuristics to use * @param abortSignal - Aborts the wait when the runtime exits * @param timeoutMs - Maximum time to wait (defaults to RUNTIME_INPUT_READY_TIMEOUT_MS) * @returns true when the idle prompt was observed, false on timeout/abort */ private waitForRuntimeInputReady; /** * Resolve how long an agent gets to register after its prompt is delivered. * Mirrors the timeout selection used when the session is created. * * @param role - Agent role * @param runtimeType - Agent runtime * @returns Registration timeout in milliseconds */ private resolveRegistrationTimeout; /** * After the registration instruction was delivered, wait the registration * timeout for the agent to flip to `active`. If it does not, log an error * (this used to be completely silent) and re-deliver the instruction once, * gated again on the runtime being idle at its prompt. * * @param sessionName - PTY session name * @param role - Agent role * @param prompt - The full registration prompt (re-written to the prompt file) * @param runtimeType - Agent runtime * @param abortSignal - Aborts when the runtime exits */ private redeliverIfUnregistered; /** * Step 3: Kill session and recreate completely * Most aggressive approach - completely recreates the session from scratch */ private tryFullRecreation; /** * Register a crewly-agent member as active by updating its team config directly. * In-process agents cannot run bash scripts, so this performs the registration * that the register-self skill would normally do for PTY-based runtimes. * * @param sessionName - Agent session name * @param role - Agent role * @param memberId - Optional member UUID * @returns True if registration succeeded */ private registerMemberActive; /** * Set up standing event subscriptions for task workflow events based on org role. * Called once at agent registration. Subscriptions are long-lived (24h TTL) * and NOT oneShot — they persist for the agent's session lifetime. * * Orchestrator: task:verified, task:blocked, task:failed (all teams) * Team Lead: task:done, task:blocked, task:failed, task:needs_clarification (own team) * Executor: task:assigned (self scope) * * @param sessionName - Agent session name * @param orgRole - Organizational role (orchestrator, team-lead, executor) * @param teamId - Team ID for scoping (optional for orchestrator) */ private setupStandingSubscriptions; /** * Load registration prompt from config files (with caching to prevent file I/O contention) */ /** * The `{{MEMBER_SKILLS_JSON}}` value for a session: the member's declared * skills, else its reported capabilities, else `[]`. Storage failures * degrade to `[]` so registration never blocks on it. * * @param sessionName - Session whose member to look up * @returns A JSON array literal */ private resolveMemberSkillsJson; private loadRegistrationPrompt; /** * Write the registration prompt to a file on disk. * * For Claude Code agents (#207): writes to `{projectPath}/.claude/agents/{sessionName}.md` * with YAML frontmatter so the `--agent` flag can load it as a custom agent definition. * For other runtimes: writes to `~/.crewly/prompts/{sessionName}-init.md`. * * @param sessionName - Session name (used for filename) * @param prompt - The full prompt content * @param options - Optional: projectPath, runtimeType, and role for claude-code agent mode * @returns The absolute path to the written file, or undefined on error */ private writePromptFile; /** * Build the unified init prompt path used by non-Claude-Code runtimes. * * Anchored on {@link getCrewlyHomePath} so test profiles, KR3 livewalk * dry-runs, and ESTestNode acceptance harnesses that export * `CREWLY_HOME=/tmp/...` write into the test profile, not the * developer's real `~/.crewly/prompts`. WI-F (2026-05-07): the * previous `path.join(os.homedir(), CREWLY_CONSTANTS.PATHS.CREWLY_HOME, * ...)` form ignored the env var and let Mia's KR3 livewalk overwrite * 27 occurrences of `~/.crewly/prompts/crewly-orc-init.md` and 26 of * `crewly-auditor-init.md` in Steve's user-prod tree at 02:37Z. */ private getInitPromptFilePath; /** * Wait for agent registration to complete */ private waitForRegistration; /** * Check if agent is properly registered */ private checkAgentRegistration; /** * Fast registration with verification and retry mechanism * Sends system prompt to runtime which triggers MCP registration via teams.json * @param skipInitialCleanup If true, skips Ctrl+C on first attempt (when runtime was just initialized) */ private attemptRegistrationWithVerification; /** * Fast registration polling with shorter intervals */ private waitForRegistrationFast; /** * Resolve the orchestrator's working directory deterministically: * `CREWLY_ORC_CWD` env → first stored project whose path exists → CREWLY_HOME. * * Never the package install dir or the server's `process.cwd()`: under * systemd that is `/usr/lib/node_modules/crewly`, and the orchestrator * would see a different filesystem depending on which code path started it. * * @returns Absolute directory to spawn the orchestrator PTY in */ resolveOrchestratorCwd(): Promise; /** * Create orchestrator session - extracted from the original tmux service */ private createOrchestratorSession; /** * Telemetry env for a PTY runtime: `CLAUDE_CODE_ENABLE_TELEMETRY=1` for * Claude Code when token tracking is on in settings. Passed at spawn (it * used to be typed in as an `export` after the shell started). * * @param runtimeType - Runtime the session will run * @returns Env map to merge into the spawn env (empty when off) */ private buildTelemetryEnv; /** * The directory an agent last ran in, for a restart that does not say * where. Claude Code keeps conversations per directory, so launching in * the backend's own cwd instead made the stored conversation "not exist" * and handed over another agent's transcript. * * @param sessionName - Agent session * @returns The registered session's cwd, else the last-conversations entry's, if it is a directory */ private lastKnownCwd; /** * Where a Claude Code session keeps its conversations when it runs on * another of the owner's Claude Code accounts (issue #942). * * @param sessionName - PTY session name * @param runtimeType - Runtime the session runs * @returns `{ claudeHome }` for an account session, else `{}` (the default `~/.claude`) */ private claudeHomeOverride; /** * The identity environment every agent PTY is spawned with. * * One source of truth for the primary spawn path, the Step-2 full * recreation path and the orchestrator session: `CREWLY_SESSION_NAME` * is what every skill sends as `X-Agent-Session`, so a session spawned * without it produces unattributed heartbeats and channel replies. Passing * the env at spawn (rather than only typing `export`s afterwards) is what * makes it survive a shell that is still initialising. * * It also carries the harness env (harnessEnvForAgents): PATH with the * user npm prefix (`~/.crewly/npm-global/bin`, where a harness lands when * `npm install -g` hit EACCES) and the Claude credential Crewly holds from * onboarding (`CLAUDE_CODE_OAUTH_TOKEN` or `ANTHROPIC_API_KEY`). For an * Antigravity session it adds the saved Gemini key as `GEMINI_API_KEY` * (agy reads the key only from its environment) — in the spawn env, so it * is never typed into the terminal. * * A Claude Code session the runtime fallback moved to another of the * owner's Claude Code accounts (issue #942) gets that account's config * dir (`CLAUDE_CONFIG_DIR`) and token instead of the default login. * * @param sessionName - PTY session name (also the agent's identity) * @param role - Agent role (orchestrator, developer, …) * @param cwd - Working directory the PTY is spawned in (exposed as CREWLY_PROJECT_PATH) * @param runtimeType - Runtime the session will run, when known * @returns Env map to pass as `createSession(..., { env })` */ private buildAgentIdentityEnv; /** * The API-key environment for a PTY runtime, resolved from settings (with * the per-runtime override chain). * * These are secrets, so they are only ever passed as the PTY's spawn * environment (`createSession(..., { env })`), never typed in as `export` * — a typed export is echoed by the shell into scrollback, the persistent * session log and the terminal-output API. * * Used by the PTY runtimes (claude-code, gemini-cli, codex-cli, * antigravity-cli, opencode-cli). crewly-agent gets its keys via * CrewlyAgentExternalRuntimeService.buildChildEnv instead. * * Two runtimes get an override on top of the plain settings lookup, both * carried over from the primary spawn path's inline key resolution: * - Antigravity: a key already saved in Settings → Runtimes is applied at * spawn via buildAgentIdentityEnv (harnessEnvForAgents); a settings * Gemini key must not override it here. * - Codex: it prefers OPENAI_API_KEY over its own login, so a stale * settings key overrides a working ChatGPT sign-in ("Incorrect API * key", Nova 2026-09-26). When Codex already has its own login, the * settings key is withheld. * * @param runtimeType - Runtime the session will run (selects key overrides) * @returns Env entries for every key that is configured; empty when none are */ private buildApiKeyEnv; /** * Wait — bounded — for a freshly spawned shell to print its prompt before * anything is written to it. * * D3 (2026-09-21): Step 2 full recreation used to write the runtime init * sequence ~8 ms after spawn. That sequence leads with Ctrl-C, and a zsh that * young has not installed its interactive SIGINT handling, so the TTY-delivered * SIGINT ends the shell (exit 0, 11 ms after spawn). The primary path never * hits this only because it types five `export`s first (~0.5 s). * * Checks the buffer first (the prompt may have landed before we subscribe), * then waits for the first non-whitespace output up to * `SESSION_RECREATION_CONSTANTS.SHELL_READY_TIMEOUT_MS`. Never throws: on * timeout it logs a warning and lets the caller proceed, so a silent shell * degrades to today's behaviour rather than blocking recreation. * * @param sessionName - The just-created PTY session * @returns True when a prompt was seen, false when the window elapsed */ private waitForShellReady; /** * Unified session creation that handles both orchestrator and team members * @param config Session configuration * @returns Promise with success/error information */ createAgentSession(config: { sessionName: string; role: string; projectPath?: string; windowName?: string; memberId?: string; runtimeType?: RuntimeType; teamId?: string; /** * Skip intelligent recovery and force immediate session kill + recreation. * Use during server startup to avoid expensive recovery on stale sessions. * @default false */ forceRecreate?: boolean; /** * Additional paths to add to Gemini CLI's /directory allowlist during * postInitialize, before the registration prompt is sent. Used by the * orchestrator to include all existing project paths so they don't * race with the "Read the file..." prompt. */ additionalAllowlistPaths?: string[]; }): Promise<{ success: boolean; sessionName?: string; message?: string; error?: string; /** Set to RUNTIME_STARTUP_BLOCKED when retrying cannot help. */ errorCode?: string; }>; /** * Internal implementation of createAgentSession, guarded by sessionCreationLocks. * @param config Session configuration * @returns Promise with success/error information */ private _createAgentSessionImpl; /** * Stop a session so it can be launched again right away (runtime switch): * the runtime goes (PTY or in-process) but the session stays registered, * its conversation id and its member status are kept. * * @param sessionName - Session to stop * @returns True when nothing of it is left running */ stopSessionForRelaunch(sessionName: string): Promise; /** * Unified session termination that handles both orchestrator and team members * @param sessionName The session to terminate * @param role The role for proper status updates * @returns Promise with success/error information */ terminateAgentSession(sessionName: string, role?: string): Promise<{ success: boolean; message?: string; error?: string; }>; /** * Whether a member's registration is still pending: its runtime is being * started or is running but has not called register-self yet. Unknown * sessions (orchestrator, ad-hoc) report 'unknown' and are not gated. * * @param sessionName - Session name * @returns 'pending' | 'ready' | 'unknown' */ private registrationStateOf; /** * Send a message to any agent session with reliable delivery. * Uses robust delivery mechanism with retry logic to ensure messages * are properly delivered to Claude Code's input. * * @param sessionName - The agent session name * @param message - The message to send * @returns Promise with success status and optional error message * * @example * ```typescript * const result = await agentRegistrationService.sendMessageToAgent( * 'crewly-orc', * '[CHAT:123] Hello, orchestrator!' * ); * if (result.success) { * console.log('Message delivered'); * } else { * console.error('Delivery failed:', result.error); * } * ``` */ sendMessageToAgent(sessionName: string, message: string, runtimeType?: RuntimeType): Promise<{ success: boolean; message?: string; error?: string; queued?: boolean; }>; /** * Mark a delivery to a session with the fresh-conversation service so no * `/clear` starts during it. Never throws. * * @param sessionName - Session being written to * @returns Function ending the mark */ private beginFreshDelivery; /** * Wait (bounded) for a running fresh-conversation / context-cap clear of * the session. Never throws. * * @param sessionName - Session about to be written to */ private waitIfFreshClearing; /** * Wait for an agent session to be at a ready prompt before sending messages. * * Subscribes to terminal output and polls capturePane to detect when the * agent returns to an input prompt. This is critical for sequential message * processing: after the orchestrator responds to a message it may continue * working (managing agents, running commands) before returning to prompt. * * @param sessionName - The session name to wait on * @param timeoutMs - Maximum time to wait (default 120s) * @returns true if agent is ready, false if timed out */ waitForAgentReady(sessionName: string, timeoutMs?: number, runtimeType?: RuntimeType): Promise; /** * Queue a message for an agent that is busy, without waiting for it. The * message is delivered when the agent goes idle (agent:idle drains the * queue) or by the busy-hold re-check timer, exactly like a message * {@link sendMessageToAgent} holds for a mid-turn agent. For agent-to-agent * traffic whose sender must not wait (2026-10-05: the orchestrator sat in * `send-message` for minutes while the recipient was busy). * * @param sessionName - The busy agent * @param message - The message * @returns Its 1-based position in the agent's queue (1 = next) and the queue size */ holdForBusyAgent(sessionName: string, message: string): { position: number; queueSize: number; }; /** * Make a session's runtime redraw its screen (SIGWINCH): one column and * row larger, then back to its current size. Uses the backend's resize * (PTY and screen buffer together) at the session's real size, never a * fixed 80x24 — that would shrink an agent's terminal back to the size * whose too-short window hid the input box (2026-10-08 Ella). * * @param sessionHelper - The session helper * @param sessionName - The session */ private repaintBySigwinch; /** * Whether an agent is mid-turn, for the unreadable-input restart: a live * turn on screen (busy bar / spinner that is repainting), or agent output * within `quietMs` (Crewly's own resize redraws are not counted, see * PtyActivityTrackerService.ignoreOutputFor). Unsure counts as mid-turn. * * @param sessionName - The agent * @param quietMs - How long without output counts as not mid-turn * @returns True when the agent may be working */ isMidTurnForRecovery(sessionName: string, quietMs: number): Promise; /** * Whether an existing session shows a live turn or agent output within * `quietMs`. Unsure (any error) counts as working. * * @param sessionName - The agent * @param quietMs - How long without output counts as not working * @returns True when the agent may be working */ private isAgentWorkingNow; /** * For a Claude Code agent that is mid-turn, queue the message instead of * pasting it into the running turn (see {@link holdForBusyAgent}). Other * runtimes, and an agent that is not busy, are left to the caller. * * Why not let Claude Code queue it natively: checked on 2.1.289 * (2026-10-05), a message submitted mid-turn is injected into the running * turn at its next tool call, and the agent may finish that turn without * ever answering it. * * @param sessionName - The recipient * @param message - The message * @returns Queue position and size when it was queued, null when the caller should deliver */ holdIfMidTurn(sessionName: string, message: string): Promise<{ position: number; queueSize: number; } | null>; /** * Write out the messages held for a usage-limited agent once its limit * has reset. One timer per agent; the flush re-checks (still limited * means held again and a new timer). * * @param sessionName - The agent session * @param delayMs - Time until the limit's retry time */ private scheduleUsageLimitFlush; /** * Messages held for a busy agent are normally sent when it goes idle * (agent:idle drains the queue). A busy screen the activity monitor has * not yet registered produces no idle transition, so the queue is also * retried on a timer until it is empty. Each retry re-checks: still busy * means held again. * * @param sessionName - The agent session */ private scheduleBusyHoldRecheck; /** * Send message with retry logic for reliable delivery to Claude Code. * Uses SessionCommandHelper.sendMessage() (proven two-step write pattern) * with stuck-message detection and retry on failure. * * @param sessionName - The session name * @param message - The message to send * @param maxAttempts - Maximum number of delivery attempts * @returns true if message was delivered successfully */ private sendMessageWithRetry; /** * The delivery attempts behind {@link sendMessageWithRetry}. * * @param sessionName - Target session * @param message - Message to deliver * @param maxAttempts - Attempts before giving up * @param runtimeType - The session's runtime * @returns Whether the checks saw the message accepted */ private sendMessageWithRetryAttempts; /** * Check if a sent message is stuck at the terminal prompt (Enter was not accepted). * * Captures the current terminal pane and looks for the message text still visible * on the last few lines. If the text is found, Enter was not processed and the * message is stuck. * * @param sessionName - The session to check * @param message - The original message that was sent * @returns true if the message text is still visible at the prompt (stuck) */ private isMessageStuckAtPrompt; /** * Check if message text is stuck at the TUI prompt line (Enter was not pressed). * * Unlike `isMessageStuckAtPrompt` which checks the last 20 lines for the message * text ANYWHERE, this method specifically checks whether the message text appears * ON the prompt line itself (the line starting with `> ` or `│ > `). * * After Enter is successfully pressed, the text moves from the prompt line to the * chat history area. So if the text is still on the prompt line, Enter was dropped. * * This avoids the false positive problem where `isMessageStuckAtPrompt` matches * the message text in the TUI's chat history (which echoes submitted messages). * * @param sessionName - The session to check * @param message - The original message that was sent * @returns true if the message text is visible on the TUI prompt line (stuck) */ private isTextStuckAtTuiPrompt; /** * Attempt to recover from text stuck at TUI prompt by pressing Enter. * Checks if text is stuck, sends Enter, waits, then verifies. * * @param sessionName - The session to recover * @param message - The original message * @returns true if recovery succeeded (text is no longer at prompt) */ private recoverStuckTuiMessage; /** * Start a background scanner that periodically checks all registered TUI * sessions for text stuck at the prompt (Enter not pressed). * * This is a safety net that catches messages missed by the per-send * verification in `sendMessageWithRetry`. It scans every 30 seconds. * * When stuck text is detected, the scanner presses Enter to recover. * If text has been sitting at the prompt for multiple consecutive scans, * it escalates to Tab + Enter (TUI focus recovery). */ startStuckMessageDetector(): void; /** * Stop the background stuck-message detector. */ stopStuckMessageDetector(): void; /** * Scan all sessions for stuck messages. Two complementary strategies: * * 1. TUI prompt-line scanning (existing): checks registered TUI sessions * for text sitting on the `> ` prompt line. * 2. Tracked-message scanning (new, all runtimes): checks if any recently * sent message text is still visible at the bottom of the terminal * after 15+ seconds — runtime-agnostic Enter-drop detection. */ private scanForStuckMessages; /** * Unregister a TUI session from the stuck-message detector. * Call this when a session is destroyed or agent is stopped. * * @param sessionName - The session to unregister */ unregisterTuiSession(sessionName: string): void; /** * Register a message that was sent to a session for background * stuck-detection. The scanner will periodically check if the * message text is still visible at the bottom of the terminal. * * @param sessionName - The session the message was sent to * @param message - The full message text */ trackSentMessage(sessionName: string, message: string): void; /** * Check if the agent appears to be at an input prompt. * Delegates to the regex-free isAgentAtPrompt() from terminal-string-ops, * with additional logging and per-line prompt detection using isPromptLine(). * * @param terminalOutput - The terminal output to check * @param runtimeType - The runtime type for pattern selection * @returns true if the agent appears to be at a prompt */ /** * Claude Code's idle prompt read from the faint-free input box rather * than from screen text. The text check ({@link isClaudeAtPrompt}) wants * `❯` alone on its line, but an idle box shows a faint placeholder or * prompt suggestion (`❯ Try "edit to..."`) that plain capture * includes — so idle read as "not at prompt" and deliveries went through * only on the final attempt — while a busy box with nothing typed reads * as a bare `❯`. Here: idle at the prompt = a readable Claude Code box * (bare or labelled top rule) that is empty or holds our own pending * paste, and no turn on screen (busy bar or spinner line). * * @param sessionHelper - Session helper (needs `readInputBox`) * @param sessionName - The session * @param screen - Captured screen text (for the turn-in-progress check) * @param runtimeType - The session's runtime, if known * @returns true/false when decided from the box; null when it cannot be * (another runtime, no styled capture, unreadable screen) */ private claudeIdlePromptFromBox; /** * Whether the agent is idle at its input prompt, for deciding when to * write: the input box for Claude Code ({@link claudeIdlePromptFromBox}), * the screen-text check otherwise. * * @param sessionHelper - Session helper * @param sessionName - The session * @param screen - Captured screen text * @param runtimeType - The session's runtime, if known * @returns True when idle at the prompt */ private isAtIdlePrompt; /** * Whether ActivityMonitor says this agent is mid-task. * * Used to qualify "at the prompt": Claude Code paints one while waiting * on a tool, and a message written then is absorbed into the running * turn and never answered. Only a positive answer is trusted — the * monitor polls on a 30s cadence, so `idle` may merely be stale, while * `in_progress` is a fact. * * Bounded: `getWorkingStatusForSession` has hung before (the 2026-05-14 * incident), and delivery must not wedge behind it. A timeout answers * "not busy", which is the old behaviour. * * @param sessionName - The agent session * @returns True only when the monitor positively reports in_progress */ private isBusyByWorkingStatus; private isClaudeAtPrompt; /** * Detect if Gemini CLI is currently in shell mode. * * In shell mode, Gemini CLI changes its prompt from `>` to `!`. Any input * sent in this mode is executed as a shell command instead of being passed * to the model. This method examines the last few lines of terminal output * for shell mode prompt indicators. * * @param terminalOutput - Captured terminal pane content * @returns true if the terminal shows a shell mode prompt */ private isGeminiInShellMode; /** * Escape from Gemini CLI shell mode by sending Escape key. * * Sends Escape and waits for the prompt to change from `!` back to `>`. * Retries up to MAX_ESCAPE_ATTEMPTS times. * * @param sessionName - The session running Gemini CLI * @param sessionHelper - SessionCommandHelper instance * @returns true if successfully escaped shell mode, false if still in shell mode */ private escapeGeminiShellMode; /** * Generic key sending to any agent session * @param sessionName The agent session name * @param key The key to send (e.g., 'Enter', 'Ctrl+C') * @returns Promise with success/error information */ sendKeyToAgent(sessionName: string, key: string): Promise<{ success: boolean; message?: string; error?: string; }>; /** * Generic health check for any agent session * @param sessionName The agent session name * @param role The agent role for additional context * @param timeout Timeout for health check in milliseconds * @returns Promise with health status information */ checkAgentHealth(sessionName: string, role?: string, timeout?: number): Promise<{ success: boolean; data?: { agent: { sessionName: string; role?: string; running: boolean; status: (typeof CREWLY_CONSTANTS.AGENT_STATUSES)[keyof typeof CREWLY_CONSTANTS.AGENT_STATUSES]; }; timestamp: string; }; error?: string; }>; /** * Write the prompt to a file and send a short instruction to the agent to read it. * * Instead of pasting large multi-line prompts directly into the terminal (which * causes bracketed paste issues, shell interpretation errors, and truncation), * we write the prompt to ~/.crewly/prompts/{sessionName}-init.md and send a * single-line instruction telling the agent to read that file. * * @param sessionName The session name * @param prompt The full system prompt to deliver * @param runtimeType The agent runtime type * @param abortSignal Optional signal to cancel the operation (e.g. on runtime exit) * @returns true if the instruction was delivered successfully */ private sendPromptRobustly; /** * Capture the current PTY output of a running agent session. * * Used by the queue processor to verify that a force-delivered message * was actually seen by the agent (pending-ack verification). * * @param sessionName - The agent session name * @param lines - Number of lines to capture (default 200) * @returns The captured terminal output, or empty string if session not found */ captureAgentOutput(sessionName: string, lines?: number): Promise; } /** * Whether Codex has its own stored login (`$CODEX_HOME/auth.json`, written by * `codex login` for ChatGPT or an API key). * * @param env - Environment (tests) * @returns True when the auth file exists */ export declare function codexHasOwnLogin(env?: NodeJS.ProcessEnv): boolean; /** * Whether a captured screen is a runtime's sign-in / login prompt. * * @param screen - Captured terminal text * @returns True for a sign-in screen */ export declare function isSignInScreen(screen: string): boolean; //# sourceMappingURL=agent-registration.service.d.ts.map