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>
166 lines
5.7 KiB
TypeScript
166 lines
5.7 KiB
TypeScript
/**
|
||
* Typing indicator refresh — default module.
|
||
*
|
||
* Most platforms expire a typing indicator after 5–10s, 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 (5–12s 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);
|
||
}
|