Files
nanoclaw/src/modules/typing/index.ts
gavrielc 4202041d0b 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>
2026-04-18 14:46:19 +03:00

166 lines
5.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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);
}