@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.
- package/.claude-plugin/marketplace.json +1 -1
- package/dist/genie.js +46 -38
- package/package.json +1 -1
- package/plugins/genie/.claude-plugin/plugin.json +1 -1
- package/plugins/genie/package.json +1 -1
- package/src/lib/agent-directory.ts +16 -0
- package/src/lib/agent-sync.ts +21 -1
- package/src/lib/frontmatter.test.ts +120 -0
- package/src/lib/frontmatter.ts +13 -0
- package/src/lib/provider-adapters.ts +22 -4
- package/src/lib/providers/__tests__/claude-sdk-permissions.test.ts +124 -8
- package/src/lib/providers/claude-sdk-permissions.ts +55 -2
- package/src/services/executor.ts +6 -1
- package/src/services/executors/__tests__/claude-sdk.test.ts +21 -12
- package/src/services/executors/claude-code.test.ts +56 -22
- package/src/services/executors/claude-code.ts +136 -47
- package/src/services/executors/claude-sdk.ts +24 -9
- package/src/services/executors/turn-based-prompt.ts +16 -26
- package/src/services/omni-bridge.ts +66 -3
|
@@ -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
|
-
|
|
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
|
|
10
|
-
expect(sanitizeWindowName('
|
|
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('
|
|
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
|
|
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
|
-
|
|
93
|
-
expect(params.systemPrompt).
|
|
94
|
-
expect(params.
|
|
95
|
-
expect(params.
|
|
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
|
|
111
|
-
const params = buildOmniSpawnParams(
|
|
112
|
-
|
|
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
|
|
6
|
-
*
|
|
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 {
|
|
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 →
|
|
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 `
|
|
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
|
-
|
|
55
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
|
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, {
|
|
142
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
-
|
|
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
|
-
|
|
61
|
+
const prompt = await loadSystemPrompt(entry);
|
|
64
62
|
const isTurnBased = Boolean(state.env.OMNI_INSTANCE);
|
|
65
|
-
|
|
66
|
-
|
|
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(
|
|
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
|
|
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
|
|
2
|
+
* Turn-based WhatsApp orchestration prompt for agents spawned via the Omni bridge.
|
|
3
3
|
*
|
|
4
|
-
* Injected
|
|
5
|
-
*
|
|
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
|
-
|
|
11
|
+
[WhatsApp Turn — reply to ${senderName}]
|
|
11
12
|
|
|
12
|
-
You
|
|
13
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|