一键导入
daemon-architecture
Use when you need to understand daemon route flow, storage model, worker connectivity, and command injection architecture before making changes
用 Codex 或 Claude 帮你安装 复制这段 Prompt,粘贴到 Codex、Claude 或其他助手里,让它检查 Skill 页面并帮你完成安装。
菜单
Use when you need to understand daemon route flow, storage model, worker connectivity, and command injection architecture before making changes
用 Codex 或 Claude 帮你安装 复制这段 Prompt,粘贴到 Codex、Claude 或其他助手里,让它检查 Skill 页面并帮你完成安装。
基于 SOC 职业分类
Use when deploying pigeon code changes across all machines after merging to main
Use when you need to understand worker architecture, endpoint flow, and command routing behavior before making changes
Use when you need to understand the OpenCode plugin event lifecycle, session state transitions, and daemon API contracts
Use when developing or refactoring OpenCode plugin handlers, tests, and daemon integration payloads
Use when you need to understand the swarm IPC subsystem — tables, routes, the per-target arbiter, the session→directory registry, and the wire envelope — before changing it
Use when implementing or testing swarm IPC features (new kinds, channels, plugin tools, or schema changes) with a TDD-first workflow
| name | daemon-architecture |
| description | Use when you need to understand daemon route flow, storage model, worker connectivity, and command injection architecture before making changes |
Use this skill before changing daemon routes, storage schema, worker integration, or injection behavior.
@pigeon/daemon is the local control plane.
packages/daemon/src/app.tspackages/daemon/src/storage/*packages/daemon/src/worker/*GET /healthPOST /session-startPOST /sessions/enable-notifyGET /sessions, GET /sessions/:id, DELETE /sessions/:idPOST /stopPOST /question-asked -- plugin reports AI asked a question; daemon stores pending question in outbox, returns 202 immediately; background OutboxSender delivers Telegram notification with inline option buttonsPOST /question-answered -- plugin reports question was answered locally; daemon clears the pending questionPOST /swarm/send -- enqueue a cross-session swarm message; daemon writes to swarm_messages and returns 202 with msg_id. Background SwarmArbiter delivers via opencode serve prompt_async. See swarm-architecture skill.GET /swarm/inbox?session=<id>[&since=<msg_id>] -- read messages already delivered (state='handed_off') to a target session, optionally cursor-paginated.POST /cleanupsessions: active session registry + transport metadatasession_tokens: reply/command token validation statereply_tokens: message reply-key to token mappinginbox: durable local command ingest queuepending_questions: one pending question per session (PRIMARY KEY on session_id, 4h TTL). Stores the question's request_id, options[], and token so the daemon can translate button presses (e.g. q0) back to option labels and route the answer to the correct plugin endpoint. Wizard columns: current_step, answers_json_v2, version for multi-question flows.outbox: durable notification delivery queue for both question and stop notifications. Keyed by notification_id, kind field ("question" or "stop"), state machine: queued → sending → sent (or failed). Background sender processes every 5s. Terminal entries cleaned after 1 hour.model_override: nullable TEXT column on sessions table. Stores provider/model string (e.g. anthropic/claude-sonnet-4-20250514). Read by command-ingest.ts and passed through the adapter to the plugin.swarm_messages: durable cross-session message queue. Keyed by msg_id (idempotency key), state machine queued → handed_off | failed. Per-target serialized delivery via SwarmArbiter. Schema and column-level details in the swarm-architecture skill.The daemon connects to the worker via HTTP short polling (no WebSocket, no long-lived connections).
Poller class in packages/daemon/src/worker/poller.tsGET /machines/:machineId/next every 5 seconds with Bearer authPOST /commands/:commandId/ackregisterSession, unregisterSession, sendNotification, editNotification, uploadMediaCommand injection is routed through packages/daemon/src/adapters/:
CommandDeliveryAdapter -- interface: deliver(session, command) => Promise<Result>DirectChannelAdapter -- HTTP POST to OpenCode plugin backend endpoint (uses backend_endpoint + backend_auth_token from session)NvimRpcAdapter -- shells out to nvim --server <socket> --remote-expr to call pigeon.dispatch() via RPC (uses nvim_socket + pty_path from session; payload/response are base64-encoded JSON)Routing priority: direct-channel (if backend_endpoint set) > nvim (if nvim_socket set) > error.
Adapters also support deliverQuestionReply(session, input) for routing question answers back to the plugin. Only DirectChannelAdapter implements this (POSTs to /pigeon/direct/question-reply).
FallbackNotifier (in notification-service.ts) implements both StopNotifier and QuestionNotifier:
callback_data: "cmd:TOKEN:q0", "cmd:TOKEN:q1", etc.cmd:TOKEN:v{version}:q{index}). As the user answers each step, the message is edited in-place via POST /notifications/edit to show the next question. On completion, the message is edited to "All answers submitted".splitTelegramMessage() splits it into multiple messages at natural boundaries (paragraph, line, sentence). Reply markup is attached only to the last chunk.Both notification types display a session ID on its own line for easy copy-paste in Telegram.
The notifier tries the worker path first (WorkerNotificationService), falling back to direct Telegram API (TelegramNotificationService).
The daemon mediates bidirectional media relay between the worker's R2 bucket and OpenCode sessions.
When a worker command includes a media: { key, mime, filename, size } field:
ingestWorkerCommand in command-ingest.ts fetches the binary via GET <workerUrl>/media/<key> (Bearer auth).data:<mime>;base64,...).{ mime, filename, url } through the adapter chain to the plugin's /pigeon/direct/execute endpoint.When a stop notification includes media (files captured by the plugin):
WorkerNotificationService.sendStopNotification receives media: Array<{ mime, filename, url }> where url is a data URI.POST <workerUrl>/media/upload (multipart form) with key outbound/<ts>-<uuid>/<filename>.mediaKeys: Array<{ key, mime, filename }> to sendNotification which includes them in the worker's /notifications/send body.The daemon communicates with a local opencode serve instance for headless session management. Configuration:
OPENCODE_URL env var (e.g. http://127.0.0.1:4096) -- no authentication (localhost-only, single-user)OpencodeClient class in packages/daemon/src/opencode-client.tshealthCheck(), createSession(directory), sendPrompt(sessionId, directory, prompt), deleteSession(sessionId), getSessionMessages(sessionId), summarize(sessionId, providerID, modelID), mcpStatus(), mcpConnect(name), mcpDisconnect(name), listProviders()Worker commands of type "launch" and "kill" are handled by dedicated ingest modules in packages/daemon/src/worker/:
launch-ingest.ts: checks opencode health, creates session, sends prompt, replies to Telegram with session ID. Supports bare project name expansion (e.g. pigeon → ~/projects/pigeon).kill-ingest.ts: calls DELETE /session/<id>, replies to Telegram with resultcompact-ingest.ts: fetches session messages, extracts the model from the last user message, calls summarize()mcp-ingest.ts: handles mcp_list (calls mcpStatus()), mcp_enable (calls mcpConnect()), mcp_disable (calls mcpDisconnect())model-ingest.ts: handles model_list (calls listProviders(), filters to allowed providers) and model_set (validates model, stores as model_override on session)All are dispatched by the Poller's command-type callbacks. Acking is handled by the Poller after successful dispatch.
Cross-session messaging between opencode sessions on the same machine, fixing the prompt_async race architecturally by making the daemon the single writer per target.
packages/daemon/src/storage/swarm-schema.ts -- swarm_messages table + indexespackages/daemon/src/storage/swarm-repo.ts -- SwarmRepository, exposed as storage.swarmpackages/daemon/src/swarm/envelope.ts -- <swarm_message v="1"> XML renderer that the receiving session sees in its transcriptpackages/daemon/src/swarm/registry.ts -- SessionDirectoryRegistry (5min TTL cache of sessionId → directory from opencode serve)packages/daemon/src/swarm/arbiter.ts -- SwarmArbiter: per-target queue with at-most-one in-flight prompt_async, retry/backoff ([1s, 2s, 5s, 15s, 60s], MAX_ATTEMPTS=10)The arbiter ticks every 500ms, finds targets with ready (queued, next_retry_at <= now) messages, drains each in parallel but each target serialized internally via an in-process inflight: Map<target, Promise>. On success: markHandedOff. On failure: markRetry (or markFailed after MAX_ATTEMPTS).
Boots conditionally on opencodeClient && config.opencodeUrl in index.ts. Without opencodeUrl, the routes still accept POSTs (messages accumulate in state='queued') but no arbiter drains them.
The opencode-plugin exposes a companion tool (swarm.read) that calls GET /swarm/inbox on behalf of the calling session — see opencode-plugin-architecture.
Senders use ~/.local/bin/pigeon-send (provisioned by workstation users/dev/home.base.nix); the legacy ~/.local/bin/opencode-send auto-routes ses_* targets through pigeon-send with --direct as the escape hatch.
Full details in the swarm-architecture skill (schema columns, route bodies, arbiter algorithm, race-fix proof).
registerSession)./stop with session_id, event, message, summary.notification_id = s:{sessionId}:{now}.kind: "stop". Returns HTTP 202.question tool in OpenCode.question.asked event, enqueues in-memory retry queue (bypasses circuit breaker), calls sendQuestionAsked with 3s timeout./question-asked with session_id, request_id, questions.notification_id = q:{sessionId}:{requestId}.{ok: true, deliveryState: "accepted", notificationId} immediately./notifications/send with notificationId for idempotency.sent. On failure: retries with backoff (5s, 10s, 30s, 60s, 120s). Terminal failure after 10 attempts or 15 minutes.notificationId -- if already delivered, returns {ok: true, deduplicated: true} without calling Telegram again.cmd:TOKEN:q0) or swipe-replies with custom text.q0, q1, ...): translates index to the original option label.DirectChannelAdapter.deliverQuestionReply().POST /notifications/edit). On the final step, all accumulated answers are delivered as a single deliverQuestionReply call with answers: string[][]./question/{requestId}/reply API to unblock the question tool.bash shell, often a sub-shell of an opencode session) runs pigeon-send <to> <payload> (or opencode-send <ses_*> <payload> which auto-routes).pigeon-send POSTs to daemon /swarm/send with {from, to, kind, priority, payload, [reply_to], [msg_id]}.msg_id if not caller-supplied, calls storage.swarm.insert(...), returns HTTP 202 {accepted: true, msg_id} immediately.SwarmArbiter (500ms tick) finds ready messages: storage.swarm.listTargetsWithReady(now).drainTarget collapses concurrent calls onto a single in-flight promise (the at-most-one-in-flight invariant per target).registry.resolve(target) (cached sessionId → directory) → renderEnvelope(...) → opencodeClient.sendPrompt(target, directory, envelopeXml) → storage.swarm.markHandedOff(...).storage.swarm.markRetry with exponential backoff (or markFailed after MAX_ATTEMPTS=10).<swarm_message> XML envelope. It can also call the swarm.read opencode tool to fetch its full inbox (GET /swarm/inbox?session=<own-id>[&since=<msg_id>]).A background hourly timer (session-reaper.ts, started in index.ts) cleans up stale sessions:
last_seen is older than SESSION_TTL_MS (1 week).deleteSession), removes from local storage, unregisters from worker.cleanupExpired for fully expired session records.When command-ingest.ts detects a connection error (ECONNREFUSED, timeout, fetch failed, etc.) during command delivery, it removes the session from local storage. This prevents repeated delivery attempts to a dead plugin process -- subsequent commands get a clear "Session not found" instead.
Long polling at the Worker level would reduce daemon HTTP traffic. Not needed at current scale. See design doc.
npm run --workspace @pigeon/daemon typecheck
npm run --workspace @pigeon/daemon test
Expected: