@whimmy-ai/whimmy 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { OpenClawPluginApi } from 'openclaw/plugin-sdk';
2
- import { whimmyPlugin, registerWhimmyHooks, setApprovalManager, broadcastApprovalRequest } from './src/channel';
2
+ import { whimmyPlugin, registerWhimmyHooks } from './src/channel';
3
3
  import { setWhimmyRuntime } from './src/runtime';
4
4
  import { registerWhimmyCli } from './src/setup';
5
5
 
@@ -13,18 +13,6 @@ const plugin = {
13
13
  api.registerChannel({ plugin: whimmyPlugin });
14
14
  registerWhimmyCli(api);
15
15
  registerWhimmyHooks(api);
16
-
17
- // Register a gateway method that captures the ExecApprovalManager reference.
18
- // The manager is only accessible via GatewayRequestHandlerOptions.context,
19
- // so we use this method as the capture point.
20
- // The backend should call this method once after connecting to the gateway.
21
- api.registerGatewayMethod('whimmy.approval.init', (opts) => {
22
- const manager = opts.context.execApprovalManager;
23
- if (manager) {
24
- setApprovalManager(manager);
25
- }
26
- opts.respond(true, { ok: true });
27
- });
28
16
  },
29
17
  };
30
18
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@whimmy-ai/whimmy",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Whimmy channel plugin for OpenClaw",
5
5
  "main": "index.ts",
6
6
  "type": "module",
package/src/channel.ts CHANGED
@@ -1,9 +1,12 @@
1
1
  import WebSocket from 'ws';
2
2
  import { randomUUID } from 'node:crypto';
3
+ import { readFileSync } from 'node:fs';
4
+ import { join } from 'node:path';
5
+ import { homedir } from 'node:os';
3
6
  import type { OpenClawConfig, OpenClawPluginApi } from 'openclaw/plugin-sdk';
4
7
  import { getWhimmyRuntime } from './runtime';
5
8
  import { resolveConnection, resolveConnectionAsync, buildWsUrl, isConfigured as isConfiguredUtil, uploadFile } from './utils';
6
- import { ensureWhimmyAgent } from './sync';
9
+ import { ensureWhimmyAgent, collectChangedMemoryFiles } from './sync';
7
10
  import type {
8
11
  WhimmyConfig,
9
12
  WhimmyChannelPlugin,
@@ -33,28 +36,14 @@ import type {
33
36
  ToolResultPayload,
34
37
  HistoryMessage,
35
38
  AgentInfo,
39
+ AgentConfig,
40
+ AgentMemorySyncPayload,
36
41
  } from './types';
37
42
 
38
- // ============ Exec Approval Manager Singleton ============
43
+ // ============ Per-Agent Config Cache ============
39
44
 
40
- /**
41
- * Minimal interface matching ExecApprovalManager.resolve().
42
- * The full class isn't exported from openclaw/plugin-sdk's barrel,
43
- * so we type just the method we need.
44
- */
45
- interface ApprovalManagerLike {
46
- resolve(recordId: string, decision: 'allow-once' | 'allow-always' | 'deny', resolvedBy?: string | null): boolean;
47
- }
48
-
49
- let approvalManager: ApprovalManagerLike | null = null;
50
-
51
- export function setApprovalManager(manager: ApprovalManagerLike): void {
52
- approvalManager = manager;
53
- }
54
-
55
- export function getApprovalManager(): ApprovalManagerLike | null {
56
- return approvalManager;
57
- }
45
+ /** Stores the latest AgentConfig per agentId so hooks can read it. */
46
+ const agentConfigCache = new Map<string, AgentConfig>();
58
47
 
59
48
  // ============ Config Helpers ============
60
49
 
@@ -93,11 +82,34 @@ function sendEvent(ws: WebSocket, event: string, payload: unknown): boolean {
93
82
  /** Pending tool call results: callId → resolve function */
94
83
  const toolResultWaiters = new Map<string, (result: ToolResultPayload) => void>();
95
84
 
85
+ // ============ Approval Waiters ============
86
+
87
+ /** Pending approval decisions: executionId → resolve function */
88
+ const approvalWaiters = new Map<string, (approved: boolean) => void>();
89
+
90
+ /** Session-level approval memory: agentId → Set of already-approved tool names */
91
+ const sessionApprovals = new Map<string, Set<string>>();
92
+
96
93
  // ============ AskUserQuestion Waiters ============
97
94
 
98
95
  /** Pending user question answers: questionId → resolve function */
99
96
  const askUserQuestionWaiters = new Map<string, (answers: Record<string, string>) => void>();
100
97
 
98
+ // ============ Model Context Limits ============
99
+
100
+ /** Resolve the max context window size for a given model identifier. */
101
+ function resolveMaxContextTokens(model: string): number {
102
+ const m = model.toLowerCase();
103
+ if (m.includes('opus')) return 200_000;
104
+ if (m.includes('haiku')) return 200_000;
105
+ if (m.includes('sonnet')) return 200_000;
106
+ if (m.includes('gpt-4o')) return 128_000;
107
+ if (m.includes('gpt-4')) return 128_000;
108
+ if (m.includes('o1') || m.includes('o3') || m.includes('o4')) return 200_000;
109
+ // Default fallback.
110
+ return 200_000;
111
+ }
112
+
101
113
  // ============ History Formatting ============
102
114
 
103
115
  function formatHistoryForAgent(history: HistoryMessage[]): string {
@@ -135,6 +147,9 @@ async function handleHookAgent(
135
147
 
136
148
  log?.info?.(`[Whimmy] Inbound: agent=${request.agentId} session=${request.sessionKey} text="${request.message.slice(0, 80)}..."`);
137
149
 
150
+ // Cache agent config so hooks can read it later.
151
+ agentConfigCache.set(request.agentId, request.agentConfig);
152
+
138
153
  // Sync Whimmy agent config into OpenClaw (model + system prompt).
139
154
  const syncedCfg = await ensureWhimmyAgent(request.agentId, request.agentConfig, log);
140
155
 
@@ -286,6 +301,27 @@ async function handleHookAgent(
286
301
  log?.error?.(`[Whimmy] dispatch error: ${dispatchErr.message}`);
287
302
  }
288
303
 
304
+ // Sync changed memory files back to the backend (non-fatal).
305
+ try {
306
+ const workspace = syncedCfg.agents?.defaults?.workspace
307
+ ?? join(homedir(), '.openclaw', 'workspace');
308
+ const agentDir = join(workspace, 'agents', request.agentId);
309
+ const changedFiles = collectChangedMemoryFiles(request.agentId, agentDir, log);
310
+
311
+ if (changedFiles) {
312
+ const memoryPayload: AgentMemorySyncPayload = {
313
+ sessionKey: request.sessionKey,
314
+ agentId: request.agentId,
315
+ files: changedFiles,
316
+ };
317
+ sendEvent(ws, 'agent.memory_sync', memoryPayload);
318
+ const fileNames = Object.keys(changedFiles).join(', ');
319
+ log?.info?.(`[Whimmy] Sent memory sync for ${request.agentId}: ${fileNames}`);
320
+ }
321
+ } catch (memErr: any) {
322
+ log?.debug?.(`[Whimmy] Memory sync failed (non-fatal): ${memErr.message}`);
323
+ }
324
+
289
325
  // Clear typing indicator and send chat.done.
290
326
  const presenceIdle: ChatPresencePayload = {
291
327
  sessionKey: request.sessionKey,
@@ -294,11 +330,45 @@ async function handleHookAgent(
294
330
  };
295
331
  sendEvent(ws, 'chat.presence', presenceIdle);
296
332
 
333
+ // Read token usage and context info from session store.
334
+ let tokenCount: number | undefined;
335
+ let cost: number | undefined;
336
+ let context: ChatChunkPayload['context'];
337
+ try {
338
+ const raw = readFileSync(storePath, 'utf-8');
339
+ const store = JSON.parse(raw) as Record<string, {
340
+ inputTokens?: number;
341
+ outputTokens?: number;
342
+ totalTokens?: number;
343
+ contextTokens?: number;
344
+ model?: string;
345
+ }>;
346
+ const entry = store[sessionKey] ?? store[mainSessionKey];
347
+ if (entry) {
348
+ const total = entry.totalTokens ?? ((entry.inputTokens ?? 0) + (entry.outputTokens ?? 0));
349
+ tokenCount = total > 0 ? total : undefined;
350
+
351
+ if (entry.contextTokens && entry.contextTokens > 0) {
352
+ const maxContext = resolveMaxContextTokens(entry.model ?? request.agentConfig.model);
353
+ context = {
354
+ used: entry.contextTokens,
355
+ max: maxContext,
356
+ percent: Math.round((entry.contextTokens / maxContext) * 100),
357
+ };
358
+ }
359
+ }
360
+ } catch {
361
+ // Session store may not exist yet on first message — ignore.
362
+ }
363
+
297
364
  const done: ChatChunkPayload = {
298
365
  sessionKey: request.sessionKey,
299
366
  agentId: request.agentId,
300
367
  content: '',
301
368
  done: true,
369
+ tokenCount,
370
+ cost,
371
+ context,
302
372
  };
303
373
  sendEvent(ws, 'chat.done', done);
304
374
  }
@@ -309,19 +379,13 @@ async function handleHookApproval(
309
379
  ): Promise<void> {
310
380
  log?.info?.(`[Whimmy] Approval: execution=${request.executionId} approved=${request.approved}`);
311
381
 
312
- const manager = getApprovalManager();
313
- if (!manager) {
314
- log?.warn?.(`[Whimmy] ExecApprovalManager not yet captured — cannot resolve execution ${request.executionId}`);
315
- return;
316
- }
317
-
318
- const decision = request.approved ? 'allow-once' as const : 'deny' as const;
319
- const resolved = manager.resolve(request.executionId, decision, 'whimmy');
320
-
321
- if (resolved) {
322
- log?.info?.(`[Whimmy] Resolved execution ${request.executionId} → ${decision}`);
382
+ const waiter = approvalWaiters.get(request.executionId);
383
+ if (waiter) {
384
+ approvalWaiters.delete(request.executionId);
385
+ waiter(request.approved);
386
+ log?.info?.(`[Whimmy] Resolved approval waiter ${request.executionId} → ${request.approved}`);
323
387
  } else {
324
- log?.warn?.(`[Whimmy] Failed to resolve execution ${request.executionId} (expired or unknown)`);
388
+ log?.warn?.(`[Whimmy] No waiter for approval ${request.executionId} (expired or unknown)`);
325
389
  }
326
390
  }
327
391
 
@@ -356,6 +420,41 @@ export function broadcastApprovalRequest(payload: ExecApprovalRequestedPayload):
356
420
  broadcastEvent('exec.approval.requested', payload);
357
421
  }
358
422
 
423
+ /**
424
+ * Request approval from the user for a tool call.
425
+ * Sends the request to the app and waits for a response.
426
+ */
427
+ export function requestApproval(
428
+ sessionKey: string,
429
+ agentId: string,
430
+ toolName: string,
431
+ params: Record<string, unknown>,
432
+ timeoutMs = 120_000,
433
+ ): Promise<boolean> {
434
+ const executionId = randomUUID();
435
+
436
+ return new Promise<boolean>((resolve, reject) => {
437
+ const timer = setTimeout(() => {
438
+ approvalWaiters.delete(executionId);
439
+ reject(new Error(`Approval timed out after ${timeoutMs}ms (executionId=${executionId})`));
440
+ }, timeoutMs);
441
+
442
+ approvalWaiters.set(executionId, (approved) => {
443
+ clearTimeout(timer);
444
+ resolve(approved);
445
+ });
446
+
447
+ broadcastApprovalRequest({
448
+ sessionKey,
449
+ agentId,
450
+ executionId,
451
+ toolName,
452
+ action: `${toolName}(${JSON.stringify(params).slice(0, 200)})`,
453
+ params,
454
+ });
455
+ });
456
+ }
457
+
359
458
  /** Forward an ask_user_question event to all connected Whimmy backends. */
360
459
  export function broadcastAskUserQuestion(payload: AskUserQuestionPayload): void {
361
460
  broadcastEvent('ask_user_question', payload);
@@ -892,6 +991,25 @@ export const whimmyPlugin: WhimmyChannelPlugin = {
892
991
  },
893
992
  };
894
993
 
994
+ // ============ Tool Name Aliases ============
995
+
996
+ /**
997
+ * Map framework-internal tool names to user-facing names used in approval config.
998
+ * The backend sends tools like ["Bash","Write","Edit"], but the framework
999
+ * fires before_tool_call with internal names like "exec".
1000
+ */
1001
+ const TOOL_APPROVAL_ALIASES: Record<string, string> = {
1002
+ exec: 'Bash',
1003
+ };
1004
+
1005
+ function toolMatchesApprovalList(toolName: string, toolList: string[]): boolean {
1006
+ if (toolList.includes('*')) return true;
1007
+ if (toolList.includes(toolName)) return true;
1008
+ const alias = TOOL_APPROVAL_ALIASES[toolName];
1009
+ if (alias && toolList.includes(alias)) return true;
1010
+ return false;
1011
+ }
1012
+
895
1013
  // ============ Tool Lifecycle Hooks ============
896
1014
 
897
1015
  /**
@@ -904,6 +1022,12 @@ export function registerWhimmyHooks(api: OpenClawPluginApi): void {
904
1022
 
905
1023
  // Intercept AskUserQuestion: forward to Whimmy UI and wait for answer.
906
1024
  if (event.toolName === 'AskUserQuestion' || event.toolName === 'ask_user_question') {
1025
+ const agentCfg = agentConfigCache.get(ctx.agentId || 'default');
1026
+ const auqConfig = agentCfg?.askUserQuestion;
1027
+
1028
+ // Skip if explicitly disabled.
1029
+ if (auqConfig?.enabled === false) return;
1030
+
907
1031
  const questions = (event.params?.questions ?? []) as AskUserQuestion[];
908
1032
  if (questions.length === 0) return;
909
1033
 
@@ -912,11 +1036,14 @@ export function registerWhimmyHooks(api: OpenClawPluginApi): void {
912
1036
  const parts = ctx.sessionKey.split(':');
913
1037
  const whimmySessionKey = parts.length >= 4 ? parts.slice(3).join(':') : ctx.sessionKey;
914
1038
 
1039
+ const timeoutMs = auqConfig?.timeoutMs ?? 120_000;
1040
+
915
1041
  try {
916
1042
  const answers = await askUserQuestion(
917
1043
  whimmySessionKey,
918
1044
  ctx.agentId || 'default',
919
1045
  questions,
1046
+ timeoutMs,
920
1047
  );
921
1048
 
922
1049
  // Return modified params with the user's answers filled in.
@@ -935,11 +1062,79 @@ export function registerWhimmyHooks(api: OpenClawPluginApi): void {
935
1062
  }
936
1063
  }
937
1064
 
1065
+ // Approval interception: check if this tool requires user approval.
1066
+ const agentId = ctx.agentId || 'default';
1067
+ const agentCfg = agentConfigCache.get(agentId);
1068
+ const approvalCfg = agentCfg?.approvals;
1069
+
1070
+ if (approvalCfg?.enabled) {
1071
+ const toolList = approvalCfg.tools ?? ['*'];
1072
+ const needsApproval = toolMatchesApprovalList(event.toolName, toolList);
1073
+
1074
+ if (needsApproval) {
1075
+ // Session mode: skip if already approved for this tool in this session.
1076
+ if (approvalCfg.mode === 'session') {
1077
+ const approved = sessionApprovals.get(agentId);
1078
+ if (approved?.has(event.toolName)) {
1079
+ // Already approved this session — fall through to lifecycle broadcast.
1080
+ } else {
1081
+ // Need to ask.
1082
+ const parts = ctx.sessionKey.split(':');
1083
+ const whimmySessionKey = parts.length >= 4 ? parts.slice(3).join(':') : ctx.sessionKey;
1084
+ const timeoutMs = approvalCfg.timeoutMs ?? 120_000;
1085
+
1086
+ try {
1087
+ const allowed = await requestApproval(
1088
+ whimmySessionKey,
1089
+ agentId,
1090
+ event.toolName,
1091
+ (event.params ?? {}) as Record<string, unknown>,
1092
+ timeoutMs,
1093
+ );
1094
+
1095
+ if (allowed) {
1096
+ // Remember for session mode.
1097
+ if (!sessionApprovals.has(agentId)) sessionApprovals.set(agentId, new Set());
1098
+ sessionApprovals.get(agentId)!.add(event.toolName);
1099
+ } else {
1100
+ return { block: true, blockReason: `User denied ${event.toolName}` };
1101
+ }
1102
+ } catch (err: any) {
1103
+ api.logger?.warn?.(`[Whimmy] Approval request failed: ${err.message}`);
1104
+ return { block: true, blockReason: `Approval timed out for ${event.toolName}` };
1105
+ }
1106
+ }
1107
+ } else {
1108
+ // 'always' mode: ask every time.
1109
+ const parts = ctx.sessionKey.split(':');
1110
+ const whimmySessionKey = parts.length >= 4 ? parts.slice(3).join(':') : ctx.sessionKey;
1111
+ const timeoutMs = approvalCfg.timeoutMs ?? 120_000;
1112
+
1113
+ try {
1114
+ const allowed = await requestApproval(
1115
+ whimmySessionKey,
1116
+ agentId,
1117
+ event.toolName,
1118
+ (event.params ?? {}) as Record<string, unknown>,
1119
+ timeoutMs,
1120
+ );
1121
+
1122
+ if (!allowed) {
1123
+ return { block: true, blockReason: `User denied ${event.toolName}` };
1124
+ }
1125
+ } catch (err: any) {
1126
+ api.logger?.warn?.(`[Whimmy] Approval request failed: ${err.message}`);
1127
+ return { block: true, blockReason: `Approval timed out for ${event.toolName}` };
1128
+ }
1129
+ }
1130
+ }
1131
+ }
1132
+
938
1133
  // Default: broadcast tool.start lifecycle event.
939
1134
  const executionId = randomUUID();
940
1135
  const payload: ToolLifecyclePayload = {
941
1136
  sessionKey: ctx.sessionKey,
942
- agentId: ctx.agentId || 'default',
1137
+ agentId: agentId,
943
1138
  executionId,
944
1139
  toolName: event.toolName,
945
1140
  status: 'running',
package/src/sync.ts CHANGED
@@ -1,10 +1,10 @@
1
1
  import { createHash } from 'node:crypto';
2
- import { mkdirSync, writeFileSync } from 'node:fs';
2
+ import { mkdirSync, writeFileSync, readFileSync, readdirSync, existsSync, statSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
4
  import { homedir } from 'node:os';
5
5
  import type { OpenClawConfig } from 'openclaw/plugin-sdk';
6
6
  import { getWhimmyRuntime } from './runtime';
7
- import type { AgentConfig, Logger } from './types';
7
+ import type { AgentConfig, Logger, MemoryFileEntry } from './types';
8
8
 
9
9
  /** In-memory cache: agentId → hash of full synced config. */
10
10
  const hashCache = new Map<string, string>();
@@ -15,6 +15,8 @@ function computeHash(agentConfig: AgentConfig): string {
15
15
  systemPrompt: agentConfig.systemPrompt ?? '',
16
16
  skills: agentConfig.skills ?? null,
17
17
  skillEntries: agentConfig.skillEntries ?? null,
18
+ approvals: agentConfig.approvals ?? null,
19
+ askUserQuestion: agentConfig.askUserQuestion ?? null,
18
20
  });
19
21
  return createHash('sha256').update(data).digest('hex');
20
22
  }
@@ -86,6 +88,22 @@ export async function ensureWhimmyAgent(
86
88
  log?.info?.(`[Whimmy] Synced skill entries: [${names.join(', ')}]`);
87
89
  }
88
90
 
91
+ // When Whimmy handles approvals, disable framework-level exec prompting
92
+ // so the two systems don't race. See: ExecApprovalManager / ask modes.
93
+ if (agentConfig.approvals?.enabled) {
94
+ const tools = agentConfig.approvals.tools ?? ['*'];
95
+ log?.info?.(`[Whimmy] Approvals enabled: mode=${agentConfig.approvals.mode ?? 'always'} tools=[${tools.join(', ')}]`);
96
+
97
+ (entry as any).tools = {
98
+ ...((entry as any).tools ?? {}),
99
+ exec: {
100
+ ...(((entry as any).tools ?? {}).exec ?? {}),
101
+ ask: 'off',
102
+ },
103
+ };
104
+ log?.info?.(`[Whimmy] Set exec ask=off for ${agentId} (Whimmy is sole approval surface)`);
105
+ }
106
+
89
107
  // Resolve workspace dir and write SOUL.md.
90
108
  const workspace = cfg.agents?.defaults?.workspace
91
109
  ?? join(homedir(), '.openclaw', 'workspace');
@@ -112,3 +130,84 @@ export async function ensureWhimmyAgent(
112
130
 
113
131
  return cfg;
114
132
  }
133
+
134
+ // ============ Memory File Sync ============
135
+
136
+ /** In-memory hash cache for memory files: "agentId:filename" → SHA256 hash. */
137
+ const memoryHashCache = new Map<string, string>();
138
+
139
+ /** Top-level memory files to scan (excludes framework files like SOUL.md, BOOTSTRAP.md, AGENTS.md). */
140
+ const MEMORY_FILES = ['USER.md', 'IDENTITY.md', 'TOOLS.md', 'HEARTBEAT.md'];
141
+
142
+ /** Max number of files from memory/ subdir. */
143
+ const MEMORY_SUBDIR_MAX_FILES = 10;
144
+
145
+ /** Max total size in bytes for all memory files. */
146
+ const MEMORY_MAX_TOTAL_BYTES = 100 * 1024; // 100KB
147
+
148
+ /**
149
+ * Collect memory files that have changed since the last sync.
150
+ * Returns a record of changed files, or null if nothing changed.
151
+ */
152
+ export function collectChangedMemoryFiles(
153
+ agentId: string,
154
+ agentDir: string,
155
+ log?: Logger,
156
+ ): Record<string, MemoryFileEntry> | null {
157
+ const changed: Record<string, MemoryFileEntry> = {};
158
+ let totalBytes = 0;
159
+
160
+ function tryFile(filename: string, filePath: string): void {
161
+ if (!existsSync(filePath)) return;
162
+
163
+ let content: string;
164
+ try {
165
+ content = readFileSync(filePath, 'utf-8');
166
+ } catch {
167
+ return;
168
+ }
169
+
170
+ if (!content.trim()) return;
171
+
172
+ const bytes = Buffer.byteLength(content, 'utf-8');
173
+ if (totalBytes + bytes > MEMORY_MAX_TOTAL_BYTES) {
174
+ log?.debug?.(`[Whimmy] Memory sync: skipping ${filename} (would exceed 100KB cap)`);
175
+ return;
176
+ }
177
+
178
+ const hash = createHash('sha256').update(content).digest('hex');
179
+ const cacheKey = `${agentId}:${filename}`;
180
+
181
+ if (memoryHashCache.get(cacheKey) === hash) return;
182
+
183
+ totalBytes += bytes;
184
+ changed[filename] = { content, hash };
185
+ memoryHashCache.set(cacheKey, hash);
186
+ }
187
+
188
+ // Scan top-level memory files.
189
+ for (const filename of MEMORY_FILES) {
190
+ tryFile(filename, join(agentDir, filename));
191
+ }
192
+
193
+ // Scan memory/ subdir for *.md files.
194
+ const memoryDir = join(agentDir, 'memory');
195
+ if (existsSync(memoryDir)) {
196
+ try {
197
+ const entries = readdirSync(memoryDir)
198
+ .filter(f => f.endsWith('.md'))
199
+ .slice(0, MEMORY_SUBDIR_MAX_FILES);
200
+
201
+ for (const entry of entries) {
202
+ const filename = `memory/${entry}`;
203
+ tryFile(filename, join(memoryDir, entry));
204
+ }
205
+ } catch {
206
+ // memory/ dir may not be readable — ignore.
207
+ }
208
+ }
209
+
210
+ if (Object.keys(changed).length === 0) return null;
211
+
212
+ return changed;
213
+ }
package/src/types.ts CHANGED
@@ -54,6 +54,10 @@ export interface AgentConfig {
54
54
  skills?: string[];
55
55
  /** Global skill entries to sync (enable/disable, API keys, env vars). */
56
56
  skillEntries?: Record<string, SkillEntryConfig>;
57
+ /** Approval settings — controls whether exec commands require user approval via Whimmy. */
58
+ approvals?: ApprovalConfig;
59
+ /** AskUserQuestion settings — controls interactive question forwarding to Whimmy. */
60
+ askUserQuestion?: AskUserQuestionConfig;
57
61
  }
58
62
 
59
63
  /** SkillEntryConfig — per-skill configuration synced from Whimmy. */
@@ -64,6 +68,25 @@ export interface SkillEntryConfig {
64
68
  config?: Record<string, unknown>;
65
69
  }
66
70
 
71
+ /** ApprovalConfig — controls tool approval forwarding to Whimmy. */
72
+ export interface ApprovalConfig {
73
+ /** Enable approval flow. Default: false. */
74
+ enabled?: boolean;
75
+ /** 'always' = every matched tool call needs approval, 'session' = approve once per session. */
76
+ mode?: 'session' | 'always';
77
+ /** Tool names that require approval. Omit or ['*'] = all tools. */
78
+ tools?: string[];
79
+ /** Timeout in ms before auto-denying. Default: 120000 (2 min). */
80
+ timeoutMs?: number;
81
+ }
82
+
83
+ /** AskUserQuestionConfig — controls AskUserQuestion interception. */
84
+ export interface AskUserQuestionConfig {
85
+ enabled?: boolean;
86
+ /** Timeout in milliseconds before auto-blocking. Default: 120000 (2 min). */
87
+ timeoutMs?: number;
88
+ }
89
+
67
90
  /** HookAttachment describes a file attached to a user message. */
68
91
  export interface HookAttachment {
69
92
  filePath: string;
@@ -141,6 +164,18 @@ export interface ChatChunkPayload {
141
164
  messageId?: string;
142
165
  tokenCount?: number;
143
166
  cost?: number;
167
+ /** Context window usage stats (only on chat.done). */
168
+ context?: ContextUsage;
169
+ }
170
+
171
+ /** ContextUsage — how full the agent's context window is. */
172
+ export interface ContextUsage {
173
+ /** Current context tokens used. */
174
+ used: number;
175
+ /** Max context tokens for the model. */
176
+ max: number;
177
+ /** Percentage of context used (0-100). */
178
+ percent: number;
144
179
  }
145
180
 
146
181
  /** ChatMediaPayload — file or voice message sent back to backend. */
@@ -243,6 +278,21 @@ export interface ExecApprovalRequestedPayload {
243
278
  executionId: string;
244
279
  toolName: string;
245
280
  action: string;
281
+ /** Tool call parameters — so the app can show what exactly is being requested. */
282
+ params?: Record<string, unknown>;
283
+ }
284
+
285
+ /** MemoryFileEntry — a single memory file with content and hash. */
286
+ export interface MemoryFileEntry {
287
+ content: string;
288
+ hash: string;
289
+ }
290
+
291
+ /** AgentMemorySyncPayload — syncs changed memory files to the backend. */
292
+ export interface AgentMemorySyncPayload {
293
+ sessionKey: string;
294
+ agentId: string;
295
+ files: Record<string, MemoryFileEntry>;
246
296
  }
247
297
 
248
298
  /** ToolLifecyclePayload — tool start/done/error sent back to backend. */