import type { Logger, PtyHandle, Role, Channel } from './types.js'; import { type SystemctlRunner } from './systemd-scope.js'; import type { FsWatcher } from './fs-watcher.js'; import { type McpProbeResult, type McpProbeServerDescriptor } from './mcp-tools-probe.js'; import { type OwnerProfileBlock, type DormantPlugin, type ActivePlugin, type SpecialistDomain } from './system-prompt.js'; import type { HostResolution } from './config.js'; import type { ToolSurface } from './tool-surface.js'; import type { JsonlTail } from './jsonl-tail.js'; import type { PermissionModeTail } from './permission-mode-tail.js'; import { type SpawnPermissionMode } from '../../../lib/models/dist/index.js'; /** The per-spawn `permissions.deny` set for a public session (any channel): * every CC native tool by bare name, a `(//**)` path form for every file tool, * every harness tool by bare name, and the memory MCP server wildcard. Sorted * and deduped so the emitted argv is stable and the audit test can compare. */ export declare function buildPublicDeny(): string[]; export interface PtyTracker { /** Intrinsic Claude session id. Map key, duplicated here so * `forEachLive` callbacks don't need a closure over the key. */ sessionId: string; /** "session_" form from the PID file. Stable after the * watcher's PID-file event resolves. */ bridgeSessionId: string; /** Suffix-only convenience field (no "session_" prefix). */ bridgeSuffix: string; pid: number; /** Live PtyHandle — the only place this reference lives. */ pty: PtyHandle; /** Flipped true on PTY exit (natural or operator-requested). Read by * /input (refuse 410) and the operator-request kill path (skip * redundant destroy that would throw on a closed socket). */ exited: boolean; /** Task 176 — true once `releasePtyMasterFd` has returned * `master-fd=closed`. Defense in depth across the two exit branches * (natural exit and operator request) — whichever loses the race * skips the second destroy that would throw on real node-pty. */ fdReleased: boolean; /** Hidden-spawn flag from /spawn body. Used by the LRU eviction * hot path that does not want to round-trip through the sidecar. */ hidden: boolean; /** Specialist agent name (or null for operator spawns). Same * rationale as `hidden`. */ specialist: string | null; /** Task 250 — systemd scope unit token for this spawn. The unit name * is `claude-session-.scope`; `stopSession` and the * reaper issue `systemctl --user stop` against that unit instead of * `pty.kill`. Equals the intrinsic sessionId on `/resume` spawns * (sessionId known pre-spawn) and a manager-minted UUID on fresh * spawns. */ scopeUnitToken: string; } /** Task 1915 — the wall-clock (ms) when this manager registered the owned * PTY tracker for `sessionId`, recorded just after that `claude` process was * spawned (the process loads its skills moments later). The skill-staleness * audit uses this as the process-start baseline it compares against each * invokable SKILL.md's mtime. Undefined when no owned tracker exists (the * session is a detached survivor or already gone). */ export declare function trackerRegisteredAtMs(sessionId: string): number | undefined; /** Returns true when the manager has a live PtyHandle for `sessionId`. * False for exited rows (the tracker is removed) and for sessions the * manager never spawned (e.g. a JSONL-only archived row). */ export declare function isLive(sessionId: string): boolean; /** Task 271 — read-only PID accessor for the live PTY at `sessionId`. * Returns the PID when a tracker exists and has not exited, null * otherwise. Used by the /promote refusal path so the 409 payload can * identify the live process the operator must end first. Mirrors * `isLive`'s encapsulation rule: returns a primitive, not the handle. */ export declare function livePidForSession(sessionId: string): number | null; /** Task-962 — classify an already-live child as busy vs wedged from signals the * watcher already tracks, so a recovery respawn never kills a child that is * merely slow. Busy = ANY recent sign of progress within `staleMs`: the PID * file reports status 'busy', the PID file was refreshed (`updatedAt`), or the * session JSONL was written (`jsonlMtimeMs`). Wedged = all of those are stale. * Pure: the http-server recover branch reads the signals and calls this. */ export declare function classifyLiveness(input: { jsonlMtimeMs: number | null; status: string | undefined; updatedAt: number; now: number; staleMs: number; }): 'busy' | 'wedged'; /** Task 455 — count of prior PTYs that exited for this sessionId within * this manager-process lifetime. Read by the /stop route to surface a * one-line observability hit when the count is > 0, so operators can * see respawn history without changing the success-path log shape. */ export declare function priorExitedCountForSession(sessionId: string): number; /** Test seam — read-only access to the tracker for a given session. * Production routes never call this; they use the dedicated mutation * exports (`killSession`, `onPtyExit`, etc.) so the PtyHandle stays * encapsulated inside the module. */ export declare function getPtyTrackerForTests(sessionId: string): PtyTracker | undefined; /** Register a callback to fire when the PTY for `sessionId` exits. * Returns true when the tracker exists (callback was wired); false * when no live PTY is tracked for that id (the caller's contract * fires regardless — e.g. `attachPublicAudit` skips on no-tracker). * Routes call this instead of reaching into the tracker for `.pty`. */ export declare function onPtyExit(sessionId: string, cb: () => void): boolean; /** Iterate every live tracker. Used by index.ts's shutdown loop to * SIGTERM each PTY synchronously, and by checkSlotsAndMaybeEvict's * LRU search to walk specialists when the cap is hit. */ export declare function forEachLivePty(cb: (tracker: PtyTracker) => void): void; /** Count of live trackers. Surfaced by /healthz so the operator can * see process-side liveness without scanning the watcher's row index. */ export declare function livePtyCount(): number; /** Task 702 — the sessionIds of every owned PTY that has not exited. The * truthful "this process is running" set for sessions this manager spawned * or adopted. Reattached survivor scopes (status=detached) are NOT here — * the /live-sessions endpoint unions them in from the watcher. */ export declare function liveSessionIds(): Array<{ sessionId: string; pid: number; }>; /** Test-only — clear the tracker map between vitest cases. Production * never invokes this; the only legitimate eviction is PTY exit. * Task 455 — also clears `priorExitedBySessionId` so tests that * predate the exit-history counter (and only call this reset) do * not leak counter state across vitest file boundaries. */ export declare function __resetPtyTrackerForTests(): void; /** Test-only — clear the exit-history counter between vitest cases. * Production never invokes this; the counter resets naturally on * manager restart. `__resetPtyTrackerForTests` clears this map too; * this narrower export is retained for tests that want to reset * only the counter without touching the live tracker map. */ export declare function __resetExitHistoryForTests(): void; /** Test-only — directly register a tracker without going through * `spawnClaudeSession`. Used by FD-release / natural-exit tests that * need to construct a known PtyHandle + tracker pair and exercise * `killSession` / `handlePtyNaturalExit` against it. */ export declare function __registerPtyTrackerForTests(tracker: PtyTracker): void; export type SpawnFailureReason = 'which-claude-not-found' | 'pty-spawn-failed' | 'pid-file-timeout' | 'pid-file-session-mismatch' | 'host-context-unresolved' | 'identity-unresolved' | 'soul-empty' | 'knowledge-missing' | 'knowledge-empty' | 'mcp-config-write-failed' | 'sidecar-write-failed' | 'dontask-empty-allowlist' | 'consent-waiver-missing' | 'startup-prompt-block'; export type SpawnRejectionReason = 'specialist-cap-reached' | 'operator-slots-reserved' | 'low-memory'; export interface SpecialistCapRejection { kind: 'specialist-cap-reached'; currentCount: number; cap: number; } export interface OperatorReserveRejection { kind: 'operator-slots-reserved'; total: number; reserve: number; totalCap: number; } /** Task 600 — aggregate-RSS spawn gate. Returned when MemAvailable on the * host drops below MIN_ADMISSION_MB, preventing a new specialist from * driving the system into memory exhaustion. */ export interface LowMemoryRejection { kind: 'low-memory'; memAvailableMb: number; thresholdMb: number; } /** Task 260 — what /spawn and /resume return to the caller. The * metadata-rich `StoredSession` shape is gone; everything the wire * payload needs (senderId, role, channel, url, startedAt, etc.) is * read by the route from the sidecar (which `spawnClaudeSession` * wrote before launching the PTY) and the watcher row. The success * payload carries the in-process facts: the canonical sessionId * (which may equal `args.resumeClaudeSessionId` on /resume), the * pid, and the tracker reference for callers that need to attach * exit-time handlers (`attachPublicAudit`, * `attachSpecialistEndTurnAutoArchive`). */ export interface SpawnedSession { sessionId: string; pid: number; startedAt: string; /** URL captured by the time /spawn returns, or null when the * `/remote-control` banner has not landed yet (the common case for * fast spawns) or when the spawn is headless. The sidecar's `url` * field is mutated when the banner does land; the route's SSE push * re-emits the row from sidecar then. */ url: string | null; tracker: PtyTracker; } export type SpawnResult = { ok: true; session: SpawnedSession; } | { ok: false; reason: SpawnFailureReason; stderrTail: string; } | { ok: false; rejected: SpecialistCapRejection | OperatorReserveRejection | LowMemoryRejection; }; export interface SpawnDeps { /** Task 250 — spawn adapter takes a stable per-spawn unit token so the * systemd-run --user --scope --unit=claude-session-.scope can be * composed at the adapter boundary. The adapter wraps node-pty around * systemd-run; from the caller's perspective the contract is unchanged * apart from the extra `unitToken` argument. */ spawnPty: (cmd: string, args: string[], env: NodeJS.ProcessEnv, cwd: string, unitToken: string) => PtyHandle; /** Task 1643 — launch a passive one-shot in claude print mode as a piped * child (inside the same systemd scope) instead of an interactive pty. Print * mode blocks on MCP init before its turn, so mcp__work__work-create is in the * model surface; the interactive pty spawn fixed its surface before the async * work server registered, leaving the intake unable to file its task. Piped * stdout is appended to `streamLogPath` for the surface census. Passive only. * Optional so the many test deps-builders need not supply it; production * (index.ts) always wires it, and a passive spawn without it falls back to the * interactive pty launcher with a loud log rather than silently. */ spawnPrintChild?: (cmd: string, args: string[], env: NodeJS.ProcessEnv, cwd: string, unitToken: string, streamLogPath: string) => PtyHandle; spawnCwd: string; claudeBin: string; /** Absolute node binary for spawned channel-MCP children (Task 1402). On * darwin the launchd server PATH excludes an nvm node, so a bare `node` * descriptor command fails; falls back to bare `node` when NODE_BIN is unset. */ nodeBin: string; /** Max wait for the PID file to land after PTY spawn. Replaces the * url-capture timeout as the readiness gate. */ pidFileTimeoutMs: number; /** URL capture is best-effort and never blocks /spawn; this timeout is * used purely for the parallel capture loop's bound. */ urlCaptureTimeoutMs: number; logger: Logger; watcher: FsWatcher; host: HostResolution; accountDir: string; accountId: string; platformRoot: string; logDir: string; agentsDirs: Readonly>; perAccountSpecialistsDir: string; /** Per-brand Claude Code config dir — `$HOME/./.claude`. The * manager process inherits `CLAUDE_CONFIG_DIR` from its systemd unit; * this field is the same value, plumbed through the deps surface so * unit tests can substitute a tmp path. Claude Code resolves * `--agent ` by reading `/agents/.md` * (Task 165). */ claudeConfigDir: string; mcpConfigDir?: string; toolSurface: ToolSurface; /** Fires once after the watcher reports the PID-file CREATE event for * the spawn AND the sidecar has been written to disk. http-server * uses this to attach the public-tool audit watcher and the * end-turn auto-archive subscriber. The callback receives the * PtyTracker so callers can register `pty.onExit` handlers; route * code reads spawn metadata from the sidecar (or via the watcher * row that the sidecar populated). */ onSessionReady?: (tracker: PtyTracker) => void; /** Task 189 — fires after the `/remote-control is active · ` banner * is matched and the sidecar's `url` field has been written. http-server * uses this to push a `row-updated` SSE so the client's hidden * Open-in-new-tab arrow appears in step with the URL landing. Headless * spawns never invoke this (the capture handler is not attached); * url-capture-timeout never invokes this (no URL to push). */ onUrlCaptured?: (sessionId: string) => void; tunnelUrlOverride?: string | null; /** Concurrent-specialist cap (per-account). See ManagerConfig. */ specialistCap: number; /** Operator-slot reservation. See ManagerConfig. */ operatorReserve: number; /** Total PTY ceiling (min of kernel pty/max and env). See ManagerConfig. */ totalPtyCap: number; /** Task 600 — injectable for testing on non-Linux. Default implementation * reads MemAvailable from /proc/meminfo and returns MB. Returns null when * /proc/meminfo is absent (non-Linux dev machine). */ readMemAvailableMb?: () => number | null; /** Task 173 — per-specialist set of tool names to strip from the * `--allowed-tools` argv at spawn time. Sourced from the boot-time * drift assertion's `brand-excluded-plugin` defects: every tool whose * `mcp____*` namespace appears in the brand's * `plugins.excluded` set lands in this map. A spawn whose specialist * name has no entry is unaffected. Undefined means the manager booted * before the strip-list plumbing landed; treat as empty. */ specialistToolStripList?: ReadonlyMap>; /** Task 207 — brand-scoped Neo4j endpoint, stamped on the PTY env block * for specialist spawns so plugin-system MCP children inherit the * connection without the per-spawn `.mcp.json` (dropped for specialists * in favour of plugin manifests). Resolved at manager startup from * `process.env.NEO4J_URI`; manager refuses to boot if absent (loud-fail * replaces the silent regression where the recorder produced zero * writes against a missing connection). Optional on the deps surface * so existing test fixtures stay terse — tests that don't spawn a * specialist never read this value; tests that do override it via the * fixture's `deps()` factory. Production sites get the loud-fail at * manager startup. */ brandedNeo4jUri?: string; /** Task 207 — brand-scoped Neo4j password, source-of-truth read at * manager startup from `process.env.NEO4J_PASSWORD` or from * `/config/.neo4j-password` (same file the UI and the * memory plugin both read). Stamped on the PTY env so plugin-system * MCP children inherit it without the per-spawn `.mcp.json`. Manager * refuses to boot if neither source resolves. Optional on deps for the * same reason as `brandedNeo4jUri`. */ brandedNeo4jPassword?: string; /** Task 178 — shadow-probe each MCP server in the spawn's mcp.json to * learn what each server offers, then emit [pty-spawn-tool-inventory]. * Production default delegates to `probeMcpServerTools` from * `mcp-tools-probe.ts`; tests inject a fake. Per-server probe timeout * defaults to 5 s — see `toolInventoryProbeTimeoutMs`. */ readMcpToolInventory?: (req: { servers: ReadonlyMap; timeoutMs: number; }) => Promise; /** Task 178 — per-server timeout for the tool-inventory probe. Default * 5_000 ms. On timeout the server's tool list is empty in the * aggregated [pty-spawn-tool-inventory] line. */ toolInventoryProbeTimeoutMs?: number; /** Task 250 — systemctl runner for scope-stop/list. Default uses * spawnSync; tests inject a recording stub. Optional for older test * fixtures that never reach the stop path. */ runSystemctl?: SystemctlRunner; /** Task 1399 — host platform, defaulted to `process.platform` at read. * Only the capacity-eviction path consults it: on darwin the evicted * session has no systemd scope, so it kills the pty pid directly rather * than issuing a (no-op) scope stop. Injectable so tests can drive the * darwin branch on a Linux CI host. */ platform?: NodeJS.Platform; /** Task 183 — JSONL tail for the database-operator recorder spawn. * pty-spawner calls `register(sessionId, { specialist, argvTools })` * immediately after argv composition, BEFORE the PTY actually spawns, * so the tail's state exists by the time the first JSONL modify event * fires. Operator spawns pass through untouched — the tail filters by * specialist on its caller side (only the database-operator spawn site * registers). Optional so existing test fixtures stay terse. */ jsonlTail?: JsonlTail; /** Task 278 — consumer of the per-session `.permission-mode.log` * written by the binary's `--debug=auto-mode` capture. Optional so * existing test fixtures stay terse. When present, http-server's * jsonl-event subscriber calls `deriveFromFile` to compute the * effective mode and update the sidecar on mismatch. */ permissionModeTail?: PermissionModeTail; } /** Task 171 — specialists whose `--agent ` spawn has no human in the * loop: no operator browser tab, no chat channel, no `claude.ai/code/session_` * URL to open. For these, `--remote-control` is dead weight (one outbound * WebSocket to Anthropic per spawn plus a PTY-byte regex that never matches) * and the URL-capture handler is not attached. Membership is by specialist * name — the values that `SpawnArgs.specialist` carries — sourced from * archive Task 085 (specialist plumbing) and this task. New headless * specialists register their name here; channel-facing specialists * (content-producer, librarian, personal-assistant, project-manager, * research-assistant) stay out. */ export declare const HEADLESS_ROLES: ReadonlySet; export interface SpawnArgs { senderId: string; /** Task 205 — admin operator's userId (subject of the cookie session). * Stamped onto the pty child env as `USER_ID` and into the per-spawn * mcp.json placeholder map alongside `ACCOUNT_ID`. Absence is legal * (silent-compaction MCP path, recorder specialist spawns); it lands * as the empty string so MCP module init stays boot-tolerant per the * Task 202 contract and the per-call refusal fires at first use. */ userId?: string; role: Role; channel: Channel; resumeClaudeSessionId?: string; channels?: string[]; permissionMode?: PermissionMode; attachmentDir?: string; aboutOwner?: OwnerProfileBlock; dormantPlugins?: readonly DormantPlugin[]; activePlugins?: readonly ActivePlugin[]; specialistDomains?: readonly SpecialistDomain[]; hidden?: boolean; specialist?: string; /** Optional per-spawn model override. When set, the manager appends * `--model ` to claude's argv. Sidebar spawns leave this unset; * a spawn that requests a specific model carries it here. Out of * scope: a system-wide default. */ model?: string; /** Optional initial user message. When set, the manager appends the * string as the trailing positional argv to `claude`, which the CLI * processes as the first user turn at session start. This makes the * database-operator session's JSONL first `role=user` line equal the * passed string verbatim — the turn is submitted by argv, not by writing * into the PTY. Task 153. */ initialMessage?: string; /** Task 346 — public agent slug under `/agents//`. * Threaded onto the spawn env as the provenance / read-scope slug. */ agentSlug?: string; /** Task 382 — `sessionId` UUID of the active `:AdminConversation` (or * `:PublicConversation`), threaded onto both admin and specialist spawn * envs as `SESSION_NODE_ID`. The memory/contacts MCP wrappers read * this var and call `injectConversationProvenance`, which MATCHes * `(c:Conversation {sessionId, accountId})` and prepends an inbound * `:PRODUCED` edge to gated writes (`:Person`, `:Organization`, etc.). * * Skipped when null/empty (autonomous spawns with no parent conversation — * cron, scheduled-task — legitimately have no anchor and must thread an * explicit `producedByTaskId` via `work-create` instead). */ conversationNodeId?: string; /** Task 485 — per-visitor ringfence key. On a `public-session-reviewer` * spawn it is stamped as `RECORDER_SLICE_TOKEN` so the dispatched * database-operator's memory-write stamps `sliceToken` + `scope:"user"`. * Also carried on the bridge entry for a gated `role='public'` visitor so * the reaper can dispatch the reviewer. No longer stamped as a read-scope * env (`ACCESS_SLICE_TOKEN`) — the public read path was retired (Task 654). */ sliceToken?: string; /** Task 485 — `elementId(:Person)` for the visitor whose grant owns the * slice. Threaded to the bridge entry / sidecar so the session-end reviewer * dispatch can target the visitor; the reviewer receives it via the prompt's * ``. No longer stamped on the child env (Task 654). */ personId?: string; /** Task 884 — the `maxy_visitor` cookie UUID for an open-mode public spawn. * Persisted to the sidecar as the open-mode ownership anchor. Unset for * gated/admin spawns. */ visitorId?: string; /** Task 756 — attach the per-conversation webchat native channel to this * public spawn. When present, the per-spawn strict mcp-config carries the * webchat-channel server as its ONLY server, the dev-channels flag activates * it, and `mcp__webchat-channel__reply` becomes the sole `--allowed-tools` * entry (so the zero-tool ringfence still holds: deny-all + reply-only). * The webchat twin of the rc-spawn admin `webchatChannel` attach (Task 772), * but composed under the public deny + dontAsk fail-closed surface. */ webchatChannel?: { key: string; gatewayUrl: string; serverPath: string; }; /** The telegram twin of `webchatChannel`. When present on a public spawn, * `mcp__telegram-channel__reply` becomes the sole `--allowed-tools` entry so * the zero-tool ringfence still holds (deny-all + reply-only). The binding * field is senderId (not key). */ telegramChannel?: { senderId: string; gatewayUrl: string; serverPath: string; }; /** Task 939 — gated-visitor read-back. The rendered body * computed UI-side from the visitor's slice writes. Fed to * composeAppendSystemPrompt as an extra so the toolless public agent reads * its prior-visit context without any tool. Gated public spawns only. */ previousContext?: string; /** Task 1483 — a WhatsApp passive account-manager spawn: a fresh, one-shot * process structurally confined to only `work-create`, no reply channel, no * HOUSE_ADMIN_SCOPE. Rides `role:'public'` (so it inherits the public * restricted `--strict-mcp-config` + dontAsk posture) but registers the `work` * server (not zero servers) and allows only `mcp__work__work-create`. */ passive?: boolean; /** Task 1483 — the sub-account a passive spawn is scoped to. Passed as the * per-spawn mcp-config `accountId`, so the `work` MCP server's env block carries * `ACCOUNT_ID` = this sub-account and `work-create` stamps the sub-account on * the `:Task`. Ignored unless `passive` is set. */ targetAccountId?: string; /** Task 1500 — the userId of the sub-account's managing house admin, read * from the target sub-account's account.json at spawn time. Injected into the * work server's env block as `INTAKE_ASSIGNED_ADMIN_USER_ID`, which * work-create resolves to the :AdminUser and links ASSIGNED_TO. Passive-only; * undefined when the sub-account has no managing admin. */ assignedAdminUserId?: string; } export type PermissionMode = SpawnPermissionMode; export declare const PERMISSION_MODES: readonly PermissionMode[]; interface McpConfigWriteParams { role: Role; toolSurface: ToolSurface; accountId: string; /** Task 205 — admin operator's userId. Threaded through to the * placeholder map so `${USER_ID}` substitutes in `mcp.json` env * blocks. Empty string when absent (silent-compaction / recorder * paths). */ userId?: string; platformRoot: string; logDir: string; /** Task 476 — sessionId minted by the spawner up front. Becomes both * the `${SESSION_ID}` placeholder value substituted into the * per-spawn mcp.json env blocks AND the disambiguating suffix on the * mcp.json filename. One UUID end-to-end: also passed to claude via * `--session-id` (fresh) or `--resume` (restored), used as the * sidecar key, and returned to the caller. */ sessionId: string; /** Task 476/487 — provenance slug substituted into `${AGENT_SLUG}` * placeholders in PLUGIN.md env blocks. The admin operator gets the * `'admin'` sentinel (so writes stamp `createdByAgent="admin"` instead * of `"unknown"`); the specialist slug on subagent spawns; the public * agent slug on public spawns. Every memory/work/contacts/scheduling/ * workflows/aeo plugin reads `process.env.AGENT_SLUG` purely as the * `createdBy.agent` provenance stamp. Read-scope lives in AGENT_SLUG_SCOPE * below — the two were one var until Task 487 split them. */ agentSlug: string; /** Task 487 — read-scope slug substituted into `${AGENT_SLUG_SCOPE}`. * Consumed ONLY by the memory plugin's memory-search to add * `AND $agentSlug IN node.agents`. Empty string for the admin operator * (search stays wide-open); the specialist slug on subagent spawns; * the public agent slug on public spawns. This reproduces the pre-487 * AGENT_SLUG semantics exactly, so read-scope is unchanged for every * role — only provenance (AGENT_SLUG) gained the 'admin' default. */ agentScopeSlug: string; targetDir: string; logger: Logger; /** Task 165 — when the spawn is a specialist subagent, narrow the * rendered mcp.json to only the MCP servers whose plugin namespace * appears in the specialist's `tools:` frontmatter. `null` means the * spawn is not a specialist; the role-based admin/public projection * applies. */ specialistTools: readonly string[] | null; /** Task 1483 — a WhatsApp passive spawn. Registers ONLY the `work` server * (overriding the public zero-server projection), so under * `--strict-mcp-config` its sole tool is `mcp__work__work-create`. `accountId` * is the sub-account, so the work server's env block carries `ACCOUNT_ID` = * sub-account and `work-create` stamps it on the `:Task`. */ passive?: boolean; /** Task 1493 — the elementId of the :Person who sent the passive intake * (the sub-account manager). Injected into the work server's env block as * `RAISED_BY_PERSON_ID`, which `work-create` reads to default `raisedBy` so * the intake :Task links RAISED_BY the manager without the one-tool agent * resolving anything. Passive-only; ignored on every other spawn. */ raisedByPersonId?: string; /** Task 1500 — the managing house admin's userId for a passive intake spawn. * Injected into the work server's env block as `INTAKE_ASSIGNED_ADMIN_USER_ID` * so work-create links the intake :Task ASSIGNED_TO that admin. Passive-only; * undefined when the sub-account has no managing admin. */ assignedAdminUserId?: string; } type McpConfigWriteResult = { ok: true; path: string; bytes: number; serverCount: number; /** Task 178 — same descriptors written to the per-spawn mcp.json, * after placeholder expansion. The tool-inventory probe re-spawns * these to learn what each MCP server offers; routing through this * field guarantees the probe sees the exact command/args/env claude * code does, not a parallel rebuild of the same logic. */ resolvedServers: ReadonlyMap; } | { ok: false; detail: string; }; /** Task 165 — read the agent's `tools:` frontmatter line from * `$CLAUDE_CONFIG_DIR/agents/.md`. Loud-fails when the file * is missing or when no `tools:` line exists in the frontmatter, since * either case means `--agent ` would silently fall back to the * admin surface — the exact defect Task 165 closes. Returns the tool * names in the order they were declared (the caller sorts before * emitting argv). */ export declare function readSpecialistTools(claudeConfigDir: string, specialist: string): string[]; /** Task 215 — pull the `` segment out of a qualified MCP tool name * the way Claude Code's plugin-tool registry surfaces it. Two shapes * exist in the wild: * * - Long prefix: `mcp__plugin____` — emitted by * Claude Code when an MCP server is registered via plugin manifest * (post-Task-209 native plugin-system path; what `--agent ` * specialist spawns receive in Registry #1). * - Short prefix: `mcp____` — emitted by Claude Code when * an MCP server is registered via `--mcp-config ` (admin operator * spawns; what Registry #2 produces directly). * * Both must resolve to the same `` slug so the probe can match * the qualified name against `toolSurface.mcpServers` keys, which the * manager populates from PLUGIN.md basenames in `tool-surface.ts`. The * pre-Task-215 inline regex `/^mcp__(.+?)__/` extracted `plugin__` * from the long-prefix shape (lazy match stops at the first `__`), * which never matched any tool-surface key — the failure mode this * helper closes. * * Returns `null` for any name that matches neither shape. The caller * surfaces those names through the inventory line's `unparseable-tools=` * field so a malformed agent-frontmatter entry can't masquerade as a * missing server. */ export declare function extractServerNameFromQualifiedTool(qualified: string): string | null; /** Task 215 — classify the agent's frontmatter `tools:` list against the * manager's tool-surface registry. Used by both the per-spawn `.mcp.json` * writer (admin) and the tool-inventory probe (specialist). Returns the * set of server names to spawn AND the two diagnostic sets the inventory * line surfaces when non-empty: tokens that didn't parse at all * (`unparseableTools`) and parseable tokens whose server isn't in the * registry (`missingServers`). Both diagnostic sets are empty in the * healthy case, so the existing log line shape stays unchanged for * passing spawns. */ export declare function classifySpecialistTools(tools: readonly string[], toolSurfaceServers: ReadonlyMap): { matchedServers: ReadonlySet; unparseableTools: readonly string[]; missingServers: readonly string[]; }; /** Builds the placeholder map for per-spawn `mcp.json` env substitution. * Exported for Task 142 unit coverage that asserts `PLATFORM_PORT` and * `MAXY_BRAND_PORT` resolve from the manager process env, and for * Task 476 unit coverage that asserts SESSION_ID equals the passed * sessionId and never the accountId. */ export declare function buildMcpPlaceholders(p: Pick, env?: NodeJS.ProcessEnv): Record; export declare function writePerSpawnMcpConfig(p: McpConfigWriteParams): McpConfigWriteResult; /** Test seam. Production never calls this; the `__` prefix matches the * in-repo convention for non-API helpers. */ export declare function __setFirstTurnTimeoutMsForTests(ms: number): void; export declare function __resetFirstTurnTimeoutMsForTests(): void; /** Test seam — clear any leftover per-spawn state between tests so a * failing test doesn't pollute the next one's Map. Production never * invokes this; the only legitimate eviction path is PTY exit. */ export declare function __clearFinalSnapshotStateForTests(): void; export declare function spawnClaudeSession(deps: SpawnDeps, args: SpawnArgs): Promise; export interface StopDeps { killGraceMs: number; logger: Logger; /** Task 250 — systemctl runner for issuing the scope-unit stop. Default * implementation invokes spawnSync; tests inject a recording stub. */ runSystemctl?: SystemctlRunner; /** Task 427 — scope token from the resolved `SessionRow` when the row * was reattached after a manager-process restart (Task 250 boot-seed). * In that case `livePtys` is empty for the sessionId but the row * carries the surviving scope unit token; passing it here lets * `stopSession` issue `systemctl --user stop` against the scope even * though the current manager never spawned that PTY. Null when the * row has no scope token or when the caller has no row context. */ rowScopeUnitToken?: string | null; /** Task 451 — claude pid from the resolved `SessionRow` when the row was * reattached after a manager-process restart. Paired with * `rowScopeUnitToken` so the row-scope stop branch can post-flight * verify the kill — the bug this task fixes is exactly the case where * `systemctl stop` returns 0 but the claude pid keeps running outside * the scope cgroup. Null when the row has no pid (archived/ended) or * when the caller has no row context. */ rowPid?: number | null; } /** Task 250 — outcome of a stopSession call. The HTTP /stop route uses * `path` + `scopeOutcome` to decide the HTTP status and response body. * * Task 427 — `path` discriminates the three branches `stopSession` may * take: `liveptys` (manager owns the PTY, original Task 250 path), * `row-scope` (manager does not own the PTY but the row carries a * surviving scope unit token from boot-seed reattach), `no-store-entry` * (genuine miss — neither a tracker nor a row scope token). */ export interface StopSessionOutcome { existed: boolean; path: 'liveptys' | 'row-scope' | 'no-store-entry'; /** Task 451 — `ok` now means unit stopped AND pid is gone (the pid was * either reaped by the in-cgroup TimeoutStopSec or never had to be * verified). `escalated-term`/`escalated-kill` mean the pid survived * `systemctl stop` and the manager escalated via SIGTERM/SIGKILL * outside the cgroup. `failed-pid-survives` is a distinct error class: * the pid resisted SIGKILL and the operator-visible action did not * achieve termination. */ scopeOutcome: 'ok' | 'already-gone' | 'escalated-term' | 'escalated-kill' | 'failed-pid-survives' | 'error' | 'no-store-entry'; exitCode: number | null; } /** Task 176 — natural-exit handler. Fires when the claude child exits on * its own (operator `/quit`, SIGINT in the PTY, crash, network drop on * `--remote-control`), with no operator `/stop` and no auto-archive in * flight. Mirrors `killSession`'s post-Task-170 fd-release + tracker * removal contract on the second exit branch the original task * explicitly out-of-scoped. Without this, the master fd lingers in * node-pty's internal socket until V8 GC finalises the IPty object, * and the tracker entry survives until manager shutdown — so the * shutdown loop SIGTERMs PIDs that already logged `process-exited`. * * Side-effect order is load-bearing: set `exited` first so any * concurrent `killSession` sees the already-exited branch; release the * fd second (idempotent on a destroyed socket via the try/catch in * `releasePtyMasterFd`); drop the tracker third (Map.delete on a * missing key is a no-op, so the operator-request follow-up that * races us is harmless). The log line carries the same `master-fd=...` * suffix the operator-request path emits — every `kill … pid=` * line in `server.log` now ends in `master-fd=...`, no exceptions. * * Exported so `__tests__/stop-session-fd-release.test.ts` can verify * the contract end-to-end without re-implementing the handler in test * code. */ export declare function handlePtyNaturalExit(deps: { logger: Logger; }, tracker: PtyTracker, exit: { exitCode: number; signal?: number; }, stderrTail: string): void; /** Task 250 + 260 — operator-request stop. Issues `systemctl --user * stop --no-block ` against the PTY's scope unit (Task 250) * instead of `pty.kill('SIGTERM')`; the scope's TimeoutStopSec=5 handles * SIGTERM→SIGKILL escalation inside its own cgroup. Idempotent: a * no-such-unit response (exit code 5) is treated as success because * claude exited naturally and systemd retired the scope. The master fd * is still released locally — fd ownership stays with the manager * process and must be cleaned up there, irrespective of which process * killed claude. * * Task 260 — looks up the tracker in the module-scoped `livePtys` map * (the metadata-rich SessionStore is gone). Deletes by identity (only * if `livePtys.get(sessionId) === t`) so an LRU recycle or a * cross-fixture sessionId collision in tests never clobbers a live * PTY whose tracker was registered after the current call resolved. */ export declare function stopSession(deps: StopDeps, sessionId: string): Promise; /** Task 260 — alias preserved during the http-server callsite rename * in this branch. The canonical export name is `stopSession` (Task 250). */ export declare const killSession: typeof stopSession; /** Task 557 — open file-descriptor count for the manager process. Reads * `/proc/self/fd` directly. Returns -1 on platforms that don't expose * procfs (darwin dev Mac) or on read failure. Used by `[fd-census]` * log lines so an rc-spawn leak is visible as `openFds` rising while * `livePtys` stays flat across successive spawns. */ export declare function openFdCount(): number; /** Task 558 — independent post-archive fd sweep. Runs on a timer (see * `index.ts`), decoupled from spawn/archive request traffic so a leak * surfaces within one sweep interval even with zero user activity. * * Inputs are functions, not data, because the watcher's row state is * owned by `index.ts`; this module owns `livePtys`. The sweep is the * bidirectional reconciliation between them: * * - For every entry in `livePtys` where `isArchived(sessionId)` → * `archivedWithMaster` (the fd leak). Emits `op=orphan-master` and, * if held longer than `ageEscalateMs` past tracker registration, * `op=orphan-master-escalate`. * - For every live (un-archived) session that the watcher knows * about where `!livePtys.has(sessionId)` → * `orphanLiveSessionsNoMaster` (the inverse defect; a live session * that cannot operate because its master is gone). * * Emits `op=post-archive-sweep` and `op=master-reconcile` once per * invocation regardless of findings — the "zero leaks" steady state is * itself a signal the sweep ran. * * `archivedAtMs(sessionId)` is consulted per orphan-master; if the * watcher cannot supply an archive timestamp, the sweep falls back to * `trackerRegisteredAt` (the master cannot have been held longer than * the manager has known about its tracker). `heldSinceArchiveMs` is * the gap from that timestamp to wall-clock now. * * The per-orphan `fd=` field is `unknown` today: node-pty's IPty does * not expose its master fd number through the typed surface this * manager imports, and walking `/proc//fd/*` lsof-style is out of * scope. The orphan's identity is the sessionId + pid; the missing fd * number does not change the alarm semantics. */ export interface PostArchiveSweepDeps { logger: Logger; isArchived(sessionId: string): boolean; liveSessionIds(): string[]; archivedAtMs(sessionId: string): number | null; ageEscalateMs: number; now?(): number; } export interface PostArchiveSweepSummary { archivedSessions: number; orphanMasters: number; liveTrackers: number; liveSessions: number; archivedWithMaster: number; orphanLiveSessionsNoMaster: number; escalated: number; } export declare function runPostArchiveSweep(deps: PostArchiveSweepDeps): PostArchiveSweepSummary; /** Task 557 — register an rc-spawn PTY in the live tracker map so the * master fd has a defined owner and release path. Mirrors the tracker * shape `spawnClaudeSession` writes (Task 260), but skips the spawn * pipeline's sidecar/watcher/url-capture concerns — `/rc-spawn` is * fire-and-forget; claude registers itself with claude.ai/code as a * Remote Control entry without manager-side bookkeeping. The `pty.onExit` * hook lands on `handlePtyNaturalExit`, which marks the tracker exited, * releases the fd, and deletes the entry — closing the leak gap the * Task 552 non-PTY scope path created when it stopped registering a * tracker at all. */ export declare function registerRcSpawnPty(deps: { logger: Logger; }, sessionId: string, pty: PtyHandle, unitToken: string): PtyTracker; /** Task 558 — the rc-spawn `op=fd-release trigger=session-ready` path * (Task 557) is gone: the master IS the live session, not a reclaimable * spawn artifact. The tracker is now released only by `handlePtyNaturalExit` * (the slave exited on its own) or by `stopSession`'s `op=archive-release` * (operator teardown). This export remains only because tests under * `__tests__/` reach for it directly; its body is the same idempotent * release-and-detach, called solely from those tests. Once those tests * switch to the `op=archive-release` shape (Task 558 verification), this * export can go. */ export declare function releaseAndUnregisterRcSpawnPty(deps: { logger: Logger; }, tracker: PtyTracker, trigger: string): { suffix: string; openFds: number; }; /** Structured outcome for a PTY control-byte write, so the route can choose the * right HTTP status without reaching into the tracker. */ export interface WriteInputResult { ok: boolean; bytes?: number; error?: 'not-found' | 'exited' | 'write-failed'; detail?: string; } /** Task 1090 Phase 1 — soft interrupt. Writes a single raw control byte to the * PTY master: ESC (`\x1b`) or Ctrl-C (`\x03`). This is the interrupt keystroke * the operator would press at the terminal: a bare control byte, never wrapped * in DEC bracketed-paste markers (an interrupt must reach the TUI as a control * keystroke, not as pasted content). This is an operator control, not a channel * inbound path — channel inbound is a native `` event, never a PTY * write. Returns a `WriteInputResult` so the route maps the outcome to the same * HTTP statuses (`not-found`→404, `exited`→410, `write-failed`→500). Whether the * byte actually halts an in-flight generation over the `--remote-control` * websocket is the device spike's question; this primitive only delivers it. */ export type InterruptByte = 'esc' | 'ctrl-c'; export declare function writeInterruptToPty(sessionId: string, byte: InterruptByte, logger: Logger): WriteInputResult; export {}; //# sourceMappingURL=pty-spawner.d.ts.map