refactor: scaffold module registries and default-module layout

Additive change — existing code paths still run via inline fallbacks.
Prepares core for per-module extractions in PR #3 onward.

Four registries added with empty defaults:
  - delivery action handlers (delivery.ts)
  - router inbound gate (router.ts)
  - response dispatcher (index.ts)
  - MCP tool self-registration (container/agent-runner/src/mcp-tools/server.ts)

Default modules moved to src/modules/ for signaling:
  - src/modules/typing/       (extracted from delivery.ts)
  - src/modules/mount-security/ (moved from src/mount-security.ts)

Both are imported directly by core — no hook, no registry. Removal
requires editing core imports.

Migrator now keys applied rows by name (uniqueness) so module
migrations can pick arbitrary version numbers. Stored version column
is auto-assigned as an applied-order sequence.

sqlite_master guards added around core calls into module-owned tables
(user_roles, agent_destinations, pending_questions). No-ops today;
load-bearing after the owning modules are extracted.

MODULE-HOOK markers placed at scheduling's two skill-edit sites
(host-sweep.ts recurrence call, poll-loop.ts pre-task gate). PR #4
replaces the marked blocks when scheduling moves to its module.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gavrielc
2026-04-18 14:46:19 +03:00
parent 1888ecc1e9
commit 4202041d0b
19 changed files with 480 additions and 234 deletions

165
src/modules/typing/index.ts Normal file
View File

@@ -0,0 +1,165 @@
/**
* Typing indicator refresh — default module.
*
* Most platforms expire a typing indicator after 510s, so a one-shot
* call on message arrival goes stale long before the agent finishes
* thinking. This module keeps it alive by re-firing `setTyping` on a
* short interval — but only while the agent is actually WORKING, gated
* on the heartbeat file's mtime after an initial grace period.
*
* After delivering a user-facing message, the refresh is paused for
* POST_DELIVERY_PAUSE_MS so the client-side indicator can visually
* clear.
*
* Default module status:
* - Lives in src/modules/ for signaling (not really core), but ships
* on main and is imported directly by core. No registry, no hook.
* - Removing requires editing src/router.ts, src/delivery.ts, and
* src/container-runner.ts to drop the calls.
*/
import fs from 'fs';
import { heartbeatPath } from '../../session-manager.js';
const TYPING_REFRESH_MS = 4000;
/**
* Grace window from startTypingRefresh: fire typing unconditionally
* for this long regardless of heartbeat state. Covers container
* spawn/wake latency (512s on cold start before first heartbeat).
*/
const TYPING_GRACE_MS = 15000;
/**
* After the grace window, a heartbeat must be mtimed within this
* many ms of now to count as "agent is working." Heartbeats land
* every few hundred ms during active work, so 6s is well above
* the working floor and small enough to stop typing quickly when
* the agent goes idle.
*/
const HEARTBEAT_FRESH_MS = 6000;
/**
* After we deliver a user-facing message, pause typing for this
* long so the client-side indicator has time to visually clear.
* Tuned for the longest common expiry (Discord ~10s). The interval
* stays running; ticks inside the pause just skip the setTyping call.
*/
const POST_DELIVERY_PAUSE_MS = 10000;
interface TypingAdapter {
setTyping?(channelType: string, platformId: string, threadId: string | null): Promise<void>;
}
interface TypingTarget {
agentGroupId: string;
channelType: string;
platformId: string;
threadId: string | null;
interval: NodeJS.Timeout;
startedAt: number;
pausedUntil: number; // epoch ms; 0 = not paused
}
let adapter: TypingAdapter | null = null;
const typingRefreshers = new Map<string, TypingTarget>();
/**
* Bind the typing module to the channel delivery adapter so it can
* call `setTyping`. Called once by `src/delivery.ts` inside
* `setDeliveryAdapter`. Passing a fresh adapter replaces the prior
* binding and leaves active refreshers in place (they'll use the
* new adapter on their next tick).
*/
export function setTypingAdapter(a: TypingAdapter): void {
adapter = a;
}
async function triggerTyping(channelType: string, platformId: string, threadId: string | null): Promise<void> {
try {
await adapter?.setTyping?.(channelType, platformId, threadId);
} catch {
// Typing is best-effort — don't let it fail delivery or routing.
}
}
function isHeartbeatFresh(agentGroupId: string, sessionId: string): boolean {
const hbPath = heartbeatPath(agentGroupId, sessionId);
try {
const stat = fs.statSync(hbPath);
return Date.now() - stat.mtimeMs < HEARTBEAT_FRESH_MS;
} catch {
return false;
}
}
export function startTypingRefresh(
sessionId: string,
agentGroupId: string,
channelType: string,
platformId: string,
threadId: string | null,
): void {
const existing = typingRefreshers.get(sessionId);
if (existing) {
// Already refreshing. Fire an immediate tick for the new inbound
// event and reset the grace window — the new message restarts
// the container-wake latency budget. Also clear any lingering
// post-delivery pause: a new inbound means the user expects
// typing to show immediately.
triggerTyping(channelType, platformId, threadId).catch(() => {});
existing.startedAt = Date.now();
existing.pausedUntil = 0;
return;
}
// Immediate tick + periodic refresh.
triggerTyping(channelType, platformId, threadId).catch(() => {});
const startedAt = Date.now();
const interval = setInterval(() => {
const entry = typingRefreshers.get(sessionId);
if (!entry) return; // stopped externally since this tick was scheduled
// Inside a post-delivery pause: skip setTyping but keep the
// interval running so we resume automatically once the pause
// expires.
if (entry.pausedUntil > Date.now()) return;
const withinGrace = Date.now() - entry.startedAt < TYPING_GRACE_MS;
if (withinGrace || isHeartbeatFresh(entry.agentGroupId, sessionId)) {
triggerTyping(entry.channelType, entry.platformId, entry.threadId).catch(() => {});
return;
}
// Out of grace AND heartbeat stale — agent is idle, stop refreshing.
clearInterval(entry.interval);
typingRefreshers.delete(sessionId);
}, TYPING_REFRESH_MS);
// unref so a stale refresher can't hold the event loop alive.
interval.unref();
typingRefreshers.set(sessionId, {
agentGroupId,
channelType,
platformId,
threadId,
interval,
startedAt,
pausedUntil: 0,
});
}
/**
* Pause the typing refresh for POST_DELIVERY_PAUSE_MS. Called after
* a user-facing message is delivered so the client-side indicator
* has a chance to visually clear before the agent's next SDK event
* pushes it back on. No-op if no refresh is active for this session.
*/
export function pauseTypingRefreshAfterDelivery(sessionId: string): void {
const entry = typingRefreshers.get(sessionId);
if (!entry) return;
entry.pausedUntil = Date.now() + POST_DELIVERY_PAUSE_MS;
}
export function stopTypingRefresh(sessionId: string): void {
const entry = typingRefreshers.get(sessionId);
if (!entry) return;
clearInterval(entry.interval);
typingRefreshers.delete(sessionId);
}