/** * Crewly Agent External Runtime Service * * Runs the Crewly Agent as a dedicated executable process and communicates * with it over a newline-delimited JSON protocol on stdin/stdout. * * This keeps OSS integration points intact while moving the agent runtime * behind an external process boundary so it can be versioned separately. * * @module services/agent/crewly-agent/crewly-agent-external-runtime.service */ import { RuntimeAgentService } from '../runtime-agent.service.abstract.js'; import type { AgentRunResult, CrewlyAgentConfig } from './types.js'; import { SessionCommandHelper } from '../../session/index.js'; import { type RuntimeType } from '../../../constants.js'; /** * How an in-process agent ends a turn. * * The turn's final text is posted to whoever wrote (Slack DM, chat) when no * reply tool sent the answer. This used to demand "a text summary of * findings, results, and issues encountered, then call report-status" — so * the orchestrator ended every owner conversation with an English status * report ("I've sent the reply. Here's my status: *What the user asked* … * *What I did* … *Next step*"), posted into the owner's DM several times in a * row (2026-09-26). */ export declare const CREWLY_AGENT_OUTPUT_REQUIREMENTS: string; export declare class CrewlyAgentExternalRuntimeService extends RuntimeAgentService { private initialized; private ready; private currentSessionName; private currentMemberId; private currentRoleName; private currentModelString; private child; /** * Pid of the running agent child process, if any. The caller-identity * check maps it to this session (a call from the child or its tools is * this agent's; #1024). * * @returns Pid or undefined */ get childPid(): number | undefined; private logBuffer; private heartbeatTimer; private stdoutBuffer; /** * In-flight runs keyed by correlation id, in dispatch order. * * A single resolve/reject pair used to live here instead. Delivery is * fire-and-forget, so two messages dispatched milliseconds apart both wrote * that pair: the first run's handlers were overwritten and its promise was * orphaned forever, while the second promise got settled with the FIRST * run's result. Observed live — a reply meant for one Slack thread was * booked against another, the orphan later tripped the IPC deadline and * recycled a perfectly healthy child, and the second message's real answer * was swallowed by the `?.` on an already-cleared slot. */ private readonly pendingRuns; /** Monotonic source for run correlation ids. */ private runCounter; /** * Tail of the child's stderr. A failed model call reaches the parent only * as "No output generated. Check the stream for errors." — the provider's * real error (DeepSeek's HTTP 402 "Insufficient Balance") is printed to * stderr, so usage-limit detection reads both. */ private recentStderr; private pendingInitResolve; private pendingInitReject; private storedConfig; /** Guard against overlapping {@link recycleChild} calls. */ private recycling; /** * Bound signal forwarder so we can register it on parent SIGINT/ * SIGTERM/SIGHUP and detach the same identity on shutdown. Null * when no child is currently attached. */ private signalForwarder; constructor(sessionHelper: SessionCommandHelper, projectRoot: string); protected getRuntimeType(): RuntimeType; protected detectRuntimeSpecific(_sessionName: string): Promise; protected getRuntimeReadyPatterns(): string[]; protected getRuntimeErrorPatterns(): string[]; protected getRuntimeExitPatterns(): RegExp[]; initializeInProcess(sessionName: string, config?: Partial & { memberId?: string; }, roleName?: string): Promise; handleMessage(message: string, metadata?: Record): Promise; /** * Settle the pending run a worker message belongs to. * * Correlates on `runId` when the child echoes one. Falls back to the OLDEST * pending run when it does not — the child processes runs strictly serially * (its own queue is FIFO), so the oldest outstanding request is the one being * answered. The fallback keeps an older `crewly-agent` binary working. * * @param runId - Correlation id echoed by the child, if any * @param settle - Applied to the matched run's handlers * @returns True when a pending run was matched and settled */ private settlePendingRun; /** * Resolve how long the parent waits for the child's IPC reply. * * Deliberately the child's own run budget plus a grace window, so the child * always gets to report its own timeout with proper context first; the parent * timer only fires when the child has stopped reporting altogether. * * @returns Deadline in milliseconds */ private resolveIpcTimeoutMs; /** * Replace a wedged child process with a fresh one. * * Without this, a single unresponsive child left the session permanently * dead: `handleExit` nulls the child and clears `initialized`, and nothing * ever spawned a replacement, so every later message failed the `isReady()` * check for the lifetime of the backend process. * * Concurrent calls are collapsed — the delivery mutex serializes sends, but * a process 'error' event can race the IPC deadline. * * @returns Resolves once the replacement is ready, or after logging a failure */ private recycleChild; isReady(): boolean; shutdown(): void; /** * Fail every in-flight run with the same cause. * * Used on teardown paths where the child is gone: leaving any pending run * unsettled would strand its caller forever, which is precisely the silent * hang this runtime has been hardened against. * * @param error - Cause reported to every waiting caller */ private rejectAllPendingRuns; /** * Build the environment the agent child process is spawned with. * * The crewly-agent runtime resolves API keys from its own environment only * (see model-manager.ts) — by design it does not reach into OSS settings. * The bridging it expects instead is that the process spawning it sets the * keys, so this is where Settings → API Keys reaches the runtime. Until it * did, a key entered in the UI was written to settings.json and went no * further: the child inherited whatever the engine's own shell happened to * export, and an agent on a DeepSeek/OpenAI/Gemini model only worked on the * machine where the operator had exported that key by hand. * * Keys are resolved through the settings override chain (skill → runtime → * global → env) with this runtime as context, so a per-runtime override for * crewly-agent applies. They are layered after the inherited environment so * a configured key wins over an exported one, and a provider with no key * leaves its variable untouched rather than exporting an empty value that * would shadow anything the child could otherwise have inherited. * * @param config - The agent configuration being spawned * @returns The complete child environment */ private buildChildEnv; private spawnAgentProcess; /** * Resolve the runtime command. Returns the bare binary name (no shell) * by default, or, if the user has configured a custom shell command in * settings, returns it with `useShell=true` after validating it against * a strict allow-list. * * Allow-list rationale: a shell-ready command is anything matching the * shape `[ ]*` — no shell metacharacters like * `;` `|` `&` `$` backticks. Anything richer is suspicious and is * silently dropped (we fall back to the default binary). Settings are * user-controlled in principle but flow through HTTP APIs, so we * treat the value as semi-trusted input. */ private resolveRuntimeCommand; /** * Resolve the managed `crewly-agent` runtime to an executable path. * * Prefers the binary VENDORED inside crewly itself at * `packages/crewly-agent/bin/crewly-agent` (relative to the install dir, * {@link this.projectRoot}). This is what makes the in-process runtime work * out of the box now that crewly-agent lives in the OSS monorepo — it * resolves identically whether the engine was started via an npm script * (which puts `node_modules/.bin` on PATH) or directly via `node dist/...` * (which does not), and whether running from a checkout or an installed * npm package (the tarball ships `packages/crewly-agent/`). * * Falls back to the bare {@link CREWLY_AGENT_MANAGED_COMMAND} name (PATH * lookup) for legacy global installs where the vendored copy is absent. * * @returns Absolute path to the vendored binary, or the bare command name */ private resolveManagedBinary; /** * Allow-list for custom runtime commands. Matches an executable path or * name followed by space-separated flags/args, where each token is a * combination of letters, digits, and the path-safe punctuation * (`. _ / - = :`). Rejects anything containing shell control characters * (`; | & $ \` < > ( ) { } [ ]`), newlines, or quotes. */ private static readonly SAFE_SHELL_COMMAND_RE; /** * Find an executable on the engine's PATH. Returns the absolute path * when found, or null when not. Async so we never block the event * loop on slow disks. Exported behavior is "is `name` runnable?"; * we don't care which exact entry won the search. */ private lookupOnPath; /** * Forward parent SIGINT/SIGTERM/SIGHUP to the spawned child so it gets * a chance to flush + exit instead of being orphaned. Idempotent — * calling more than once just replaces the previous listeners. * * Removes its own listeners on `handleExit` so old callbacks don't * fire against a recycled `this.child` reference after a restart. */ private attachSignalForwarders; private detachSignalForwarders; private sendMessage; private handleStdout; private handleWorkerMessage; private handleStderr; /** * Handle child process exit. * * @param child - The process that exited; ignored when it is not the current * one (a superseded child from a recycle) * @param code - Exit code, if any * @param signal - Terminating signal, if any */ private handleExit; /** * Handle a child process-level error (spawn failure, EPIPE, …). * * @param child - The process that errored; ignored when superseded * @param error - The emitted error */ private handleProcessError; /** * Record one run's usage in the shared token ledger (`token-usage.json`). * * Not gated on `settings.general.tokenTracking`: that flag opts a PTY * runtime into telemetry it would otherwise not emit (claude-code needs an * env var). The in-process runtime already has exact counts in hand for * every run, and until 2026-09-18 they were thrown away by default — a * DeepSeek orchestrator ran for months with no record of what it spent. * * @param session - Session the run belongs to * @param result - The completed run */ private recordTokenUsage; private startHeartbeat; private stopHeartbeat; private loadSystemPrompt; buildEnhancedSystemPrompt(roleName?: string): Promise; private loadSkillsCatalogSummary; private loadInstalledAddons; } //# sourceMappingURL=crewly-agent-external-runtime.service.d.ts.map