@automagik/genie 4.260409.2 → 4.260409.4

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.
@@ -2,31 +2,45 @@ import { describe, expect, test } from 'bun:test';
2
2
  import { buildOmniSpawnParams, sanitizeWindowName } from './claude-code.js';
3
3
 
4
4
  describe('sanitizeWindowName', () => {
5
- test('WhatsApp DM: number@s.whatsapp.net wa-number', () => {
5
+ // --- Without chatName (fallback to JID) ---
6
+ test('WhatsApp DM: always uses phone number', () => {
6
7
  expect(sanitizeWindowName('5512982298888@s.whatsapp.net')).toBe('wa-5512982298888');
7
8
  });
8
9
 
9
- test('WhatsApp group: id@g.us group-id', () => {
10
- expect(sanitizeWindowName('120363422699972298@g.us')).toBe('group-120363422699972298');
10
+ test('WhatsApp DM: ignores chatName, always phone', () => {
11
+ expect(sanitizeWindowName('5512982298888@s.whatsapp.net', 'Felipe Rosa')).toBe('wa-5512982298888');
11
12
  });
12
13
 
13
- test('LID format: id@lid lid-id', () => {
14
+ test('WhatsApp group without name: uses group ID', () => {
15
+ expect(sanitizeWindowName('120363422699972298@g.us')).toBe('grp-120363422699972298');
16
+ });
17
+
18
+ test('WhatsApp group with name: uses chat name', () => {
19
+ expect(sanitizeWindowName('120363422699972298@g.us', 'NMSTX leadership')).toBe('grp-NMSTXleadership');
20
+ });
21
+
22
+ test('LID without name: uses lid-id', () => {
14
23
  expect(sanitizeWindowName('54958418317348@lid')).toBe('lid-54958418317348');
15
24
  });
16
25
 
26
+ test('LID with name: uses wa- prefix + contact name', () => {
27
+ expect(sanitizeWindowName('54958418317348@lid', 'Felipe Rosa')).toBe('wa-FelipeRosa');
28
+ });
29
+
30
+ // --- Determinism ---
31
+ test('same chatId always produces same name (no sender dependency)', () => {
32
+ const id = '120363422699972298@g.us';
33
+ // Without chatName, always deterministic from JID
34
+ expect(sanitizeWindowName(id)).toBe(sanitizeWindowName(id));
35
+ });
36
+
17
37
  test('different DM numbers produce different names', () => {
18
38
  const a = sanitizeWindowName('5511999999999@s.whatsapp.net');
19
39
  const b = sanitizeWindowName('5511888888888@s.whatsapp.net');
20
40
  expect(a).not.toBe(b);
21
- expect(a).toBe('wa-5511999999999');
22
- expect(b).toBe('wa-5511888888888');
23
- });
24
-
25
- test('identical inputs produce identical output', () => {
26
- const id = '5511999999999@s.whatsapp.net';
27
- expect(sanitizeWindowName(id)).toBe(sanitizeWindowName(id));
28
41
  });
29
42
 
43
+ // --- Edge cases ---
30
44
  test('fallback: unknown format uses chat- prefix', () => {
31
45
  expect(sanitizeWindowName('user@domain.com/resource')).toBe('chat-userdomain.comresource');
32
46
  });
@@ -47,15 +61,21 @@ describe('sanitizeWindowName', () => {
47
61
  test('output is path-safe — no slashes, dots, or colons', () => {
48
62
  const names = [
49
63
  sanitizeWindowName('5512982298888@s.whatsapp.net'),
50
- sanitizeWindowName('120363422699972298@g.us'),
51
- sanitizeWindowName('54958418317348@lid'),
64
+ sanitizeWindowName('120363422699972298@g.us', 'Test Group'),
65
+ sanitizeWindowName('54958418317348@lid', 'Felipe'),
52
66
  sanitizeWindowName('some-weird-id'),
53
67
  ];
54
68
  for (const name of names) {
55
- // Must be safe as both tmux window name and filename component
56
69
  expect(name).not.toMatch(/[\/.:]/);
57
70
  }
58
71
  });
72
+
73
+ test('long chat name is truncated to 30 chars', () => {
74
+ const longName = 'A'.repeat(50);
75
+ const result = sanitizeWindowName('120363422699972298@g.us', longName);
76
+ // "grp-" prefix + 30 chars max
77
+ expect(result.length).toBeLessThanOrEqual(34);
78
+ });
59
79
  });
60
80
 
61
81
  describe('buildOmniSpawnParams', () => {
@@ -84,15 +104,17 @@ describe('buildOmniSpawnParams', () => {
84
104
  expect(params.systemPromptFile).toBe('/home/genie/agents/simone/AGENTS.md');
85
105
  });
86
106
 
87
- test('injects turn-based prompt as systemPrompt', () => {
107
+ test('injects turn-based prompt into initialPrompt (not systemPrompt)', () => {
88
108
  const params = buildOmniSpawnParams('simone', 'chat123', fakeEntry, {
89
109
  OMNI_INSTANCE: 'inst-1',
90
110
  OMNI_SENDER_NAME: 'Stefani',
91
111
  });
92
- expect(params.systemPrompt).toContain('WhatsApp Turn-Based Conversation');
93
- expect(params.systemPrompt).toContain('Stefani');
94
- expect(params.systemPrompt).toContain('inst-1');
95
- expect(params.systemPrompt).toContain('chat123');
112
+ // Turn context goes in initialPrompt, not systemPrompt
113
+ expect(params.systemPrompt).toBeUndefined();
114
+ expect(params.initialPrompt).toContain('WhatsApp Turn');
115
+ expect(params.initialPrompt).toContain('Stefani');
116
+ expect(params.initialPrompt).toContain('inst-1');
117
+ expect(params.initialPrompt).toContain('chat123');
96
118
  });
97
119
 
98
120
  test('enables nativeTeam with agent name and color', () => {
@@ -107,9 +129,21 @@ describe('buildOmniSpawnParams', () => {
107
129
  expect(params.model).toBe('opus');
108
130
  });
109
131
 
110
- test('passes initialMessage as initialPrompt', () => {
111
- const params = buildOmniSpawnParams('simone', 'chat123', fakeEntry, {}, 'Hello!');
112
- expect(params.initialPrompt).toBe('Hello!');
132
+ test('passes initialMessage appended to turn context in initialPrompt', () => {
133
+ const params = buildOmniSpawnParams(
134
+ 'simone',
135
+ 'chat123',
136
+ fakeEntry,
137
+ {
138
+ OMNI_INSTANCE: 'inst-1',
139
+ OMNI_SENDER_NAME: 'Stefani',
140
+ },
141
+ 'Hello!',
142
+ );
143
+ expect(params.initialPrompt).toContain('WhatsApp Turn');
144
+ expect(params.initialPrompt).toContain('Hello!');
145
+ // User message comes after separator
146
+ expect(params.initialPrompt).toContain('---\n\nHello!');
113
147
  });
114
148
 
115
149
  test('generates a sessionId', () => {
@@ -2,18 +2,19 @@
2
2
  * ClaudeCodeOmniExecutor -- tmux-based IExecutor implementation.
3
3
  *
4
4
  * Spawns Claude Code processes in tmux windows (one per chat),
5
- * delivers messages via Claude Code's native team inbox, and
6
- * injects env vars so agents can call `omni say/done` directly.
5
+ * delivers follow-up messages via genie's PG mailbox pipeline
6
+ * (mailbox.send PG LISTEN/NOTIFY scheduler deliverToPane),
7
+ * and injects env vars so agents can call `omni say/done` directly.
7
8
  */
8
9
 
9
10
  import { randomUUID } from 'node:crypto';
10
- import { mkdirSync, writeFileSync } from 'node:fs';
11
11
  import { homedir } from 'node:os';
12
- import { dirname, join } from 'node:path';
12
+ import { join } from 'node:path';
13
13
  import * as directory from '../../lib/agent-directory.js';
14
14
  import type { DirectoryEntry } from '../../lib/agent-directory.js';
15
15
  import * as agents from '../../lib/agent-registry.js';
16
16
  import * as registry from '../../lib/executor-registry.js';
17
+ import * as mailbox from '../../lib/mailbox.js';
17
18
  import { buildLaunchCommand } from '../../lib/provider-adapters.js';
18
19
  import type { SpawnParams } from '../../lib/provider-adapters.js';
19
20
  import { shellQuote } from '../../lib/team-lead-command.js';
@@ -23,36 +24,81 @@ import { buildTurnBasedPrompt } from './turn-based-prompt.js';
23
24
 
24
25
  interface TmuxSessionState {
25
26
  executorId: string | null;
27
+ /** Agent ID in genie's agents table (for mailbox delivery). */
28
+ agentId: string | null;
29
+ /** Agent repo/working directory (for mailbox repoPath). */
30
+ repoPath: string | null;
31
+ }
32
+
33
+ /**
34
+ * Sanitize a string for use as tmux window name and inbox filename.
35
+ * Strips unsafe characters, truncates to 30 chars.
36
+ */
37
+ function safeName(raw: string, maxLen = 30): string {
38
+ return raw.replace(/[^a-zA-Z0-9._-]/g, '').slice(0, maxLen) || 'unknown';
26
39
  }
27
40
 
28
41
  /**
29
42
  * Convert a chat JID into a human-readable, path-safe tmux window name.
30
43
  *
44
+ * MUST be deterministic from chatId alone — senderName changes per message
45
+ * in groups, so it cannot be part of the key. The optional chatName is
46
+ * resolved once at spawn time from omni's chat database.
47
+ *
31
48
  * Uses `-` separator (not `/`) so the name is safe as both a tmux window
32
49
  * name AND a filename component in the Claude Code team inbox path.
33
50
  *
34
51
  * Formats:
35
52
  * 5512982298888@s.whatsapp.net → wa-5512982298888
36
- * 120363422699972298@g.us → group-120363422699972298
53
+ * 120363422699972298@g.us → grp-NMSTXleadership (if chatName) or grp-120363422699972298
37
54
  * 54958418317348@lid → lid-54958418317348
38
55
  * other → chat-<sanitized prefix>
39
56
  */
40
- export function sanitizeWindowName(chatId: string): string {
41
- // WhatsApp DM: number@s.whatsapp.net
57
+ export function sanitizeWindowName(chatId: string, chatName?: string): string {
58
+ // WhatsApp DM: number@s.whatsapp.net — always use phone number
42
59
  const whatsappDm = chatId.match(/^(\d+)@s\.whatsapp\.net$/);
43
60
  if (whatsappDm) return `wa-${whatsappDm[1]}`;
44
61
 
45
- // WhatsApp group: id@g.us
62
+ // WhatsApp group: id@g.us — use chatName if available
46
63
  const whatsappGroup = chatId.match(/^(\d+)@g\.us$/);
47
- if (whatsappGroup) return `group-${whatsappGroup[1]}`;
64
+ if (whatsappGroup) return `grp-${chatName ? safeName(chatName) : whatsappGroup[1]}`;
48
65
 
49
- // LID format: id@lid
66
+ // LID format: id@lid — use chatName (contact name) if available
50
67
  const lid = chatId.match(/^(\d+)@lid$/);
51
- if (lid) return `lid-${lid[1]}`;
68
+ if (lid) return chatName ? `wa-${safeName(chatName)}` : `lid-${lid[1]}`;
52
69
 
53
70
  // Fallback: sanitize for tmux and file paths (no special chars)
54
- const clean = chatId.replace(/[^a-zA-Z0-9._-]/g, '').slice(0, 30);
55
- return `chat-${clean || 'unknown'}`;
71
+ return `chat-${safeName(chatId)}`;
72
+ }
73
+
74
+ /**
75
+ * Look up the chat/contact name from omni API for human-readable window naming.
76
+ * Queries GET /api/v2/chats?externalId=<jid> — returns the chat name.
77
+ * Returns null if lookup fails (best-effort, never blocks spawn).
78
+ */
79
+ async function lookupChatName(chatId: string, _instanceId: string): Promise<string | null> {
80
+ try {
81
+ const configPath = join(homedir(), '.omni', 'config.json');
82
+ const { readFileSync } = await import('node:fs');
83
+ const config = JSON.parse(readFileSync(configPath, 'utf-8'));
84
+ const apiUrl = config.apiUrl || 'http://localhost:8882';
85
+ const apiKey = config.apiKey || '';
86
+ if (!apiKey) return null;
87
+
88
+ const url = `${apiUrl}/api/v2/chats?externalId=${encodeURIComponent(chatId)}`;
89
+ const res = await fetch(url, {
90
+ headers: { Authorization: `Bearer ${apiKey}` },
91
+ signal: AbortSignal.timeout(3000),
92
+ });
93
+ if (!res.ok) return null;
94
+ // API returns { items: [...] } at root (no data wrapper)
95
+ const body = (await res.json()) as { items?: { name?: string; externalId?: string }[] };
96
+ // Find exact match by externalId since the API may return multiple results
97
+ const match = body.items?.find((c) => c.externalId === chatId) ?? body.items?.[0];
98
+ return match?.name || null;
99
+ } catch {
100
+ return null;
101
+ }
56
102
  }
57
103
 
58
104
  /**
@@ -67,7 +113,12 @@ export function buildOmniSpawnParams(
67
113
  ): SpawnParams {
68
114
  const instanceId = env.OMNI_INSTANCE ?? '';
69
115
  const senderName = env.OMNI_SENDER_NAME ?? 'whatsapp-user';
70
- const turnPrompt = buildTurnBasedPrompt(senderName, instanceId, chatId);
116
+ const turnContext = buildTurnBasedPrompt(senderName, instanceId, chatId);
117
+
118
+ // Turn instructions go in the initial prompt (before the user's message),
119
+ // NOT in the system prompt. System prompt = agent identity (AGENTS.md).
120
+ // Turn context = operational instructions for this specific interaction.
121
+ const fullInitialPrompt = initialMessage ? `${turnContext}\n\n---\n\n${initialMessage}` : turnContext;
71
122
 
72
123
  return {
73
124
  provider: (entry.provider as SpawnParams['provider']) ?? 'claude',
@@ -77,8 +128,7 @@ export function buildOmniSpawnParams(
77
128
  model: entry.model,
78
129
  promptMode: entry.promptMode,
79
130
  systemPromptFile: join(entry.dir, 'AGENTS.md'),
80
- systemPrompt: turnPrompt,
81
- initialPrompt: initialMessage,
131
+ initialPrompt: fullInitialPrompt,
82
132
  nativeTeam: {
83
133
  enabled: true,
84
134
  agentName,
@@ -106,18 +156,24 @@ export class ClaudeCodeOmniExecutor implements IExecutor {
106
156
  await executeTmux(`send-keys -t '${paneId}' ${shellQuote(nudgeText)} Enter`);
107
157
  }
108
158
 
109
- async spawn(agentName: string, chatId: string, env: Record<string, string>): Promise<ExecutorSession> {
159
+ async spawn(
160
+ agentName: string,
161
+ chatId: string,
162
+ env: Record<string, string>,
163
+ initialMessage?: string,
164
+ ): Promise<ExecutorSession> {
110
165
  const resolved = await directory.resolve(agentName);
111
166
  if (!resolved) throw new Error(`Agent "${agentName}" not found in genie directory`);
112
167
 
113
168
  const entry = resolved.entry;
114
169
  const tmuxSession = agentName;
115
- const windowName = sanitizeWindowName(chatId);
170
+ const chatName = await lookupChatName(chatId, env.OMNI_INSTANCE ?? '');
171
+ const windowName = sanitizeWindowName(chatId, chatName ?? undefined);
116
172
  const { paneId, created } = await ensureTeamWindow(tmuxSession, windowName, entry.dir);
117
173
 
118
174
  if (created) {
119
175
  const omniEnv: Record<string, string> = { ...env, GENIE_OMNI_CHAT_ID: chatId, GENIE_OMNI_AGENT: agentName };
120
- const params = buildOmniSpawnParams(agentName, chatId, entry, omniEnv);
176
+ const params = buildOmniSpawnParams(agentName, chatId, entry, omniEnv, initialMessage);
121
177
  const launch = buildLaunchCommand(params);
122
178
 
123
179
  // Merge omni-specific env vars with those produced by buildLaunchCommand
@@ -130,16 +186,21 @@ export class ClaudeCodeOmniExecutor implements IExecutor {
130
186
  }
131
187
 
132
188
  const sessionKey = `${agentName}:${chatId}`;
133
- const executorId = await this.registerInWorldA(
189
+ const registration = await this.registerInWorldA(
134
190
  agentName,
135
191
  chatId,
136
192
  env.OMNI_INSTANCE ?? '',
137
193
  tmuxSession,
138
194
  windowName,
139
195
  paneId,
196
+ entry.dir,
140
197
  );
141
- this.sessions.set(sessionKey, { executorId });
142
- if (executorId) await this.updateState(executorId, 'running', chatId);
198
+ this.sessions.set(sessionKey, {
199
+ executorId: registration?.executorId ?? null,
200
+ agentId: registration?.agentId ?? null,
201
+ repoPath: entry.dir,
202
+ });
203
+ if (registration?.executorId) await this.updateState(registration.executorId, 'running', chatId);
143
204
 
144
205
  const now = Date.now();
145
206
  return {
@@ -160,7 +221,8 @@ export class ClaudeCodeOmniExecutor implements IExecutor {
160
221
  tmuxSession: string,
161
222
  tmuxWindow: string,
162
223
  tmuxPaneId: string,
163
- ): Promise<string | null> {
224
+ repoPath: string,
225
+ ): Promise<{ executorId: string; agentId: string } | null> {
164
226
  if (!this.safePgCall) return null;
165
227
  const agent = await this.safePgCall(
166
228
  'tmux-find-or-create-agent',
@@ -169,6 +231,28 @@ export class ClaudeCodeOmniExecutor implements IExecutor {
169
231
  { chatId },
170
232
  );
171
233
  if (!agent) return null;
234
+
235
+ // Update agent record with pane_id and repo_path so the scheduler daemon's
236
+ // deliverToPane() (via PG LISTEN/NOTIFY) can resolve this agent for mailbox delivery.
237
+ await this.safePgCall(
238
+ 'tmux-update-agent-pane',
239
+ async () => {
240
+ const sql = await import('../../lib/db.js').then((m) => m.getConnection());
241
+ await sql`
242
+ UPDATE agents
243
+ SET pane_id = ${tmuxPaneId},
244
+ session = ${tmuxSession},
245
+ repo_path = ${repoPath},
246
+ window_name = ${tmuxWindow},
247
+ state = 'idle',
248
+ last_state_change = now()
249
+ WHERE id = ${agent.id}
250
+ `;
251
+ },
252
+ undefined,
253
+ { chatId },
254
+ );
255
+
172
256
  const executor = await this.safePgCall(
173
257
  'tmux-create-executor',
174
258
  () =>
@@ -182,7 +266,7 @@ export class ClaudeCodeOmniExecutor implements IExecutor {
182
266
  null,
183
267
  { chatId },
184
268
  );
185
- return executor?.id ?? null;
269
+ return executor ? { executorId: executor.id, agentId: agent.id } : null;
186
270
  }
187
271
 
188
272
  private async updateState(executorId: string, state: 'running' | 'working' | 'idle', chatId: string): Promise<void> {
@@ -198,30 +282,29 @@ export class ClaudeCodeOmniExecutor implements IExecutor {
198
282
  async deliver(session: ExecutorSession, message: OmniMessage): Promise<void> {
199
283
  const state = this.sessions.get(session.id);
200
284
  if (state?.executorId) await this.updateState(state.executorId, 'working', session.chatId);
201
- const tmuxSessionName = session.tmux?.session ?? session.agentName;
202
- const inboxDir = join(
203
- process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), '.claude'),
204
- 'teams',
205
- tmuxSessionName,
206
- 'inboxes',
207
- );
208
- const inboxFile = join(inboxDir, `${sanitizeWindowName(session.chatId)}.json`);
209
- mkdirSync(dirname(inboxFile), { recursive: true });
210
- let messages: { from: string; text: string; summary: string; timestamp: string; read: boolean }[] = [];
211
- try {
212
- const { readFileSync } = await import('node:fs');
213
- messages = JSON.parse(readFileSync(inboxFile, 'utf-8'));
214
- } catch {
215
- /* start fresh */
285
+
286
+ // Build turn context + user message for the follow-up turn
287
+ const senderName = message.sender || 'whatsapp-user';
288
+ const turnContext = buildTurnBasedPrompt(senderName, message.instanceId, session.chatId);
289
+ const body = `${turnContext}\n\n---\n\n[${senderName}]: ${message.content}`;
290
+
291
+ // Deliver via genie's PG mailbox pipeline:
292
+ // mailbox.send() PG INSERT + NOTIFY → scheduler daemon → deliverToPane() → tmux send-keys
293
+ // This provides: durability, observability (runtime_events), retry on missed NOTIFY (30s poll),
294
+ // and delivery confirmation (delivered_at timestamp).
295
+ const agentId = state?.agentId;
296
+ const repoPath = state?.repoPath;
297
+
298
+ if (agentId && repoPath) {
299
+ try {
300
+ await mailbox.send(repoPath, `omni:${senderName}`, agentId, body);
301
+ } catch (err) {
302
+ console.error(`[claude-code] mailbox.send failed for ${session.id}:`, err instanceof Error ? err.message : err);
303
+ }
304
+ } else {
305
+ console.error(`[claude-code] deliver: no agentId/repoPath for session ${session.id}, message lost`);
216
306
  }
217
- messages.push({
218
- from: message.sender || 'whatsapp-user',
219
- text: message.content,
220
- summary: message.content.slice(0, 120),
221
- timestamp: message.timestamp || new Date().toISOString(),
222
- read: false,
223
- });
224
- writeFileSync(inboxFile, JSON.stringify(messages, null, 2));
307
+
225
308
  session.lastActivityAt = Date.now();
226
309
  if (state?.executorId) await this.updateState(state.executorId, 'idle', session.chatId);
227
310
  }
@@ -239,6 +322,12 @@ export class ClaudeCodeOmniExecutor implements IExecutor {
239
322
  { executorId: state.executorId, chatId: session.chatId },
240
323
  );
241
324
  }
325
+ // Clean up agent registry so deliverToPane() won't try to deliver to a dead pane
326
+ if (state?.agentId && this.safePgCall) {
327
+ await this.safePgCall('tmux-unregister-agent', () => agents.unregister(state.agentId as string), undefined, {
328
+ chatId: session.chatId,
329
+ });
330
+ }
242
331
  this.sessions.delete(session.id);
243
332
  }
244
333
  }
@@ -57,18 +57,23 @@ async function loadSystemPrompt(entry: directory.DirectoryEntry): Promise<string
57
57
  async function resolveSystemPrompt(
58
58
  entry: directory.DirectoryEntry,
59
59
  state: SdkSessionState,
60
- message: OmniMessage,
61
- chatId: string,
62
60
  ): Promise<{ prompt: string | undefined; isTurnBased: boolean }> {
63
- let prompt = await loadSystemPrompt(entry);
61
+ const prompt = await loadSystemPrompt(entry);
64
62
  const isTurnBased = Boolean(state.env.OMNI_INSTANCE);
65
- if (isTurnBased) {
66
- const turnPrompt = buildTurnBasedPrompt(message.sender, state.env.OMNI_INSTANCE, state.env.OMNI_CHAT ?? chatId);
67
- prompt = prompt ? `${turnPrompt}\n\n${prompt}` : turnPrompt;
68
- }
63
+ // Turn-based instructions are injected into the user message (initialPrompt),
64
+ // NOT into the system prompt. System prompt = agent identity only.
69
65
  return { prompt, isTurnBased };
70
66
  }
71
67
 
68
+ /**
69
+ * Build the turn context prefix for the user message.
70
+ * Returns empty string if not in a turn-based session.
71
+ */
72
+ function buildTurnContextPrefix(state: SdkSessionState, message: OmniMessage, chatId: string): string {
73
+ if (!state.env.OMNI_INSTANCE) return '';
74
+ return buildTurnBasedPrompt(message.sender, state.env.OMNI_INSTANCE, state.env.OMNI_CHAT ?? chatId);
75
+ }
76
+
72
77
  interface QueryResult {
73
78
  text: string;
74
79
  sessionId?: string;
@@ -297,7 +302,12 @@ export class ClaudeSdkOmniExecutor implements IExecutor {
297
302
  this.pendingNudges.set(session.id, text);
298
303
  }
299
304
 
300
- async spawn(agentName: string, chatId: string, env: Record<string, string>): Promise<ExecutorSession> {
305
+ async spawn(
306
+ agentName: string,
307
+ chatId: string,
308
+ env: Record<string, string>,
309
+ _initialMessage?: string,
310
+ ): Promise<ExecutorSession> {
301
311
  const resolved = await directory.resolve(agentName);
302
312
  if (!resolved) {
303
313
  throw new Error(`Agent "${agentName}" not found in genie directory`);
@@ -437,7 +447,7 @@ export class ClaudeSdkOmniExecutor implements IExecutor {
437
447
 
438
448
  const entry = resolved.entry;
439
449
  const permissionConfig = resolvePermissionConfig(entry.permissions);
440
- const { prompt: systemPrompt, isTurnBased } = await resolveSystemPrompt(entry, state, message, session.chatId);
450
+ const { prompt: systemPrompt, isTurnBased } = await resolveSystemPrompt(entry, state);
441
451
 
442
452
  if (state.executorId) await this.updateState(state.executorId, 'working', session.chatId);
443
453
 
@@ -470,12 +480,17 @@ export class ClaudeSdkOmniExecutor implements IExecutor {
470
480
  extraOptions.resume = state.claudeSessionId;
471
481
  }
472
482
 
483
+ // Build query content: turn context (if turn-based) + nudge (if pending) + user message
484
+ const turnPrefix = buildTurnContextPrefix(state, message, session.chatId);
473
485
  let queryContent = message.content;
474
486
  const pendingNudge = this.pendingNudges.get(session.id);
475
487
  if (pendingNudge) {
476
488
  queryContent = `[system] ${pendingNudge}\n\n${message.content}`;
477
489
  this.pendingNudges.delete(session.id);
478
490
  }
491
+ if (turnPrefix) {
492
+ queryContent = `${turnPrefix}\n\n---\n\n${queryContent}`;
493
+ }
479
494
 
480
495
  const { messages: queryMessages } = state.provider.runQuery(
481
496
  {
@@ -1,38 +1,28 @@
1
1
  /**
2
- * Turn-based WhatsApp system prompt for agents spawned via the Omni bridge.
2
+ * Turn-based WhatsApp orchestration prompt for agents spawned via the Omni bridge.
3
3
  *
4
- * Injected on every delivery when OMNI_INSTANCE is present in the executor env.
5
- * Teaches the agent how to reply via omni CLI verbs and close the turn.
4
+ * Injected as a prefix to the initial user message (NOT in the system prompt).
5
+ * This is operational context it tells the agent HOW to interact in this turn,
6
+ * not WHO it is. Identity stays in AGENTS.md (system prompt).
6
7
  */
7
8
 
8
9
  export function buildTurnBasedPrompt(senderName: string, instanceId: string, chatId: string): string {
9
10
  return `
10
- # WhatsApp Turn-Based Conversation
11
+ [WhatsApp Turn — reply to ${senderName}]
11
12
 
12
- You are responding to a WhatsApp message from ${senderName}.
13
- Your context is pre-set (instance: ${instanceId}, chat: ${chatId}) — do NOT use \`omni use\` or \`omni open\`.
13
+ You received a WhatsApp message. Read it, reply, then close the turn.
14
+ Context is pre-set (instance: ${instanceId}, chat: ${chatId}) — do NOT run \`omni use\` or \`omni open\`.
14
15
 
15
- ## Reply Channels
16
+ Reply with omni verbs:
17
+ - \`omni say "your reply"\` — send a text message
18
+ - \`omni speak "text" --voice Kore\` — send a voice note
19
+ - \`omni imagine "prompt"\` — generate and send an image
20
+ - \`omni react "👍"\` — react to the trigger message
21
+ - \`omni done\` — close the turn (ALWAYS your last action)
16
22
 
17
- You have two equivalent ways to send a reply to the user:
23
+ Flow: 1) \`omni say "..."\` to reply 2) \`omni done\` to close.
24
+ Bare text output goes nowhere — you MUST use omni verbs to reach the user.
18
25
 
19
- 1. **SendMessage** (preferred): \`SendMessage(recipient: "omni", message: "your reply")\` —
20
- intercepted by the omni bridge and delivered as a WhatsApp text message.
21
- You may call SendMessage multiple times in one turn for multi-message replies.
22
- 2. **omni done text='...'** — closes the turn AND sends a final text in one call.
23
-
24
- ## Available Tools
25
-
26
- - SendMessage(recipient: "omni", message: '...') — send a text reply (repeatable)
27
- - omni done text='...' — send final text + close turn (use as the LAST action)
28
- - omni done react='emoji' — react instead of replying, then close turn
29
- - omni done media='/path' caption='...' — send media + close turn
30
- - omni done skip=true — close turn silently
31
-
32
- ## Rules
33
-
34
- 1. Use \`SendMessage(recipient: "omni", ...)\` for normal text replies.
35
- 2. ALWAYS call \`omni done\` as your LAST action to close the turn — even if you already sent SendMessage replies, call \`omni done skip=true\`.
36
- 3. Do NOT generate bare text as your reply — it will go nowhere. Use SendMessage or omni done.
26
+ The user's message:
37
27
  `.trim();
38
28
  }