v2: SQLite state adapter, admin commands, compact feedback

- Replace in-memory Chat SDK state with SqliteStateAdapter — thread
  subscriptions now persist across restarts
- Add migration 002 for chat_sdk_kv, subscriptions, locks, lists tables
- Handle /clear in agent-runner (reset sessionId) — SDK has
  supportsNonInteractive:false for this command
- Pass /compact, /context, /cost, /files through to SDK as admin commands
- Skip admin commands in follow-up poll so they start fresh queries
- Emit compact_boundary events as user-visible feedback messages
- Pass NANOCLAW_ADMIN_USER_ID and NANOCLAW_ASSISTANT_NAME to containers

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
gavrielc
2026-04-09 03:58:35 +03:00
parent c31bb02c06
commit 8a06b01646
10 changed files with 283 additions and 47 deletions
+1 -1
View File
@@ -9,7 +9,7 @@ import type { MessageInRow } from './db/messages-in.js';
*/
export type CommandCategory = 'admin' | 'filtered' | 'passthrough' | 'none';
const ADMIN_COMMANDS = new Set(['/remote-control', '/clear', '/compact']);
const ADMIN_COMMANDS = new Set(['/remote-control', '/clear', '/compact', '/context', '/cost', '/files']);
const FILTERED_COMMANDS = new Set(['/help', '/login', '/logout', '/doctor', '/config']);
export interface CommandInfo {
+29 -15
View File
@@ -81,7 +81,6 @@ export async function runPollLoop(config: PollLoopConfig): Promise<void> {
if (cmdInfo.category === 'admin') {
if (!adminUserId || cmdInfo.senderId !== adminUserId) {
// Not admin — send error, mark completed
log(`Admin command denied: ${cmdInfo.command} from ${cmdInfo.senderId} (msg: ${msg.id})`);
writeMessageOut({
id: generateId(),
@@ -94,7 +93,24 @@ export async function runPollLoop(config: PollLoopConfig): Promise<void> {
commandIds.push(msg.id);
continue;
}
// Admin user — format as system command
// Handle admin commands directly
if (cmdInfo.command === '/clear') {
log('Clearing session (resetting sessionId)');
sessionId = undefined;
resumeAt = undefined;
writeMessageOut({
id: generateId(),
kind: 'chat',
platform_id: routing.platformId,
channel_type: routing.channelType,
thread_id: routing.threadId,
content: JSON.stringify({ text: 'Session cleared.' }),
});
commandIds.push(msg.id);
continue;
}
// Other admin commands — pass through to agent
normalMessages.push(msg);
continue;
}
@@ -174,25 +190,16 @@ function formatMessagesWithCommands(messages: MessageInRow[]): string {
for (const msg of messages) {
if (msg.kind === 'chat' || msg.kind === 'chat-sdk') {
const cmdInfo = categorizeMessage(msg);
if (cmdInfo.category === 'passthrough') {
if (cmdInfo.category === 'passthrough' || cmdInfo.category === 'admin') {
// Flush normal batch first
if (normalBatch.length > 0) {
parts.push(formatMessages(normalBatch));
normalBatch.length = 0;
}
// Pass raw command text (no XML wrapping)
// Pass raw command text (no XML wrapping) — SDK handles it natively
parts.push(cmdInfo.text);
continue;
}
if (cmdInfo.category === 'admin') {
// Format admin command as a system command block
if (normalBatch.length > 0) {
parts.push(formatMessages(normalBatch));
normalBatch.length = 0;
}
parts.push(`[SYSTEM COMMAND: ${cmdInfo.command}]\n${cmdInfo.text}`);
continue;
}
}
normalBatch.push(msg);
}
@@ -218,8 +225,15 @@ async function processQuery(query: AgentQuery, routing: RoutingContext, config:
const pollHandle = setInterval(() => {
if (done) return;
// Skip system messages — they're responses for MCP tools (e.g., ask_user_question)
const newMessages = getPendingMessages().filter((m) => m.kind !== 'system');
// Skip system messages (MCP tool responses) and admin commands (need fresh query)
const newMessages = getPendingMessages().filter((m) => {
if (m.kind === 'system') return false;
if (m.kind === 'chat' || m.kind === 'chat-sdk') {
const cmd = categorizeMessage(m);
if (cmd.category === 'admin') return false;
}
return true;
});
if (newMessages.length > 0) {
const newIds = newMessages.map((m) => m.id);
markProcessing(newIds);
@@ -212,6 +212,10 @@ export class ClaudeProvider implements AgentProvider {
yield { type: 'error', message: 'API retry', retryable: true };
} else if (message.type === 'system' && (message as { subtype?: string }).subtype === 'rate_limit_event') {
yield { type: 'error', message: 'Rate limit', retryable: false, classification: 'quota' };
} else if (message.type === 'system' && (message as { subtype?: string }).subtype === 'compact_boundary') {
const meta = (message as { compact_metadata?: { pre_tokens?: number } }).compact_metadata;
const detail = meta?.pre_tokens ? ` (${meta.pre_tokens.toLocaleString()} tokens compacted)` : '';
yield { type: 'result', text: `Context compacted${detail}.` };
} else if (message.type === 'system' && (message as { subtype?: string }).subtype === 'task_notification') {
const tn = message as { summary?: string };
yield { type: 'progress', message: tn.summary || 'Task notification' };