@automagik/genie 4.260409.12 → 4.260409.13

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@automagik/genie",
3
- "version": "4.260409.12",
3
+ "version": "4.260409.13",
4
4
  "description": "Collaborative terminal toolkit for human + AI workflows",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "genie",
3
- "version": "4.260409.12",
3
+ "version": "4.260409.13",
4
4
  "description": "Human-AI partnership for Claude Code. Share a terminal, orchestrate workers, evolve together. Brainstorm ideas, turn them into wishes, execute with /work, validate with /review, and ship as one team.",
5
5
  "author": {
6
6
  "name": "Namastex Labs"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "genie-plugin",
3
- "version": "4.260409.12",
3
+ "version": "4.260409.13",
4
4
  "private": true,
5
5
  "description": "Runtime dependencies for genie bundled CLIs",
6
6
  "type": "module",
@@ -97,6 +97,8 @@ export interface SpawnParams {
97
97
  newWindow?: boolean;
98
98
  /** Tmux window target to split into (e.g., "genie:3"). */
99
99
  windowTarget?: string;
100
+ /** Skip genie hook dispatch injection (e.g., omni-originated sessions that shouldn't trigger orchestration hooks). */
101
+ skipHooks?: boolean;
100
102
  }
101
103
 
102
104
  /** Result of a successful launch-command build. */
@@ -353,17 +355,20 @@ export function buildClaudeCommand(params: SpawnParams): LaunchCommand {
353
355
 
354
356
  appendSystemPromptFlags(parts, params);
355
357
 
356
- // Inject hook dispatch + permissions via --settings (deep-merges with existing settings)
357
- const dispatchCmd = buildDispatchCommand();
358
- const hookEntry = { type: 'command', command: dispatchCmd, timeout: 15 };
359
- const settingsObj: Record<string, unknown> = {
360
- hooks: {
358
+ // Inject hook dispatch + permissions via --settings (deep-merges with existing settings).
359
+ // Skip hooks for omni-originated sessions to prevent orchestration side-effects
360
+ // (e.g., auto-spawning qa/configure agents from seller sessions).
361
+ const settingsObj: Record<string, unknown> = {};
362
+ if (!params.skipHooks) {
363
+ const dispatchCmd = buildDispatchCommand();
364
+ const hookEntry = { type: 'command', command: dispatchCmd, timeout: 15 };
365
+ settingsObj.hooks = {
361
366
  PreToolUse: [{ matcher: '*', hooks: [hookEntry] }],
362
367
  PostToolUse: [{ matcher: '*', hooks: [hookEntry] }],
363
368
  UserPromptSubmit: [{ hooks: [hookEntry] }],
364
369
  Stop: [{ hooks: [hookEntry] }],
365
- },
366
- };
370
+ };
371
+ }
367
372
  // Merge permissions into settings when provided
368
373
  if (params.permissions) {
369
374
  const perms: Record<string, string[]> = {};
@@ -371,7 +376,9 @@ export function buildClaudeCommand(params: SpawnParams): LaunchCommand {
371
376
  if (params.permissions.deny?.length) perms.deny = params.permissions.deny;
372
377
  if (Object.keys(perms).length > 0) settingsObj.permissions = perms;
373
378
  }
374
- parts.push('--settings', escapeShellArg(JSON.stringify(settingsObj)));
379
+ if (Object.keys(settingsObj).length > 0) {
380
+ parts.push('--settings', escapeShellArg(JSON.stringify(settingsObj)));
381
+ }
375
382
 
376
383
  // Emit --disallowedTools when the agent declares tool restrictions
377
384
  if (params.disallowedTools?.length) {
@@ -129,6 +129,7 @@ export function buildOmniSpawnParams(
129
129
  promptMode: entry.promptMode,
130
130
  systemPromptFile: join(entry.dir, 'AGENTS.md'),
131
131
  initialPrompt: fullInitialPrompt,
132
+ skipHooks: true,
132
133
  nativeTeam: {
133
134
  enabled: true,
134
135
  agentName,
@@ -226,7 +227,7 @@ export class ClaudeCodeOmniExecutor implements IExecutor {
226
227
  if (!this.safePgCall) return null;
227
228
  const agent = await this.safePgCall(
228
229
  'tmux-find-or-create-agent',
229
- () => agents.findOrCreateAgent(agentName, 'omni', 'omni'),
230
+ () => agents.findOrCreateAgent(`${agentName}:${chatId}`, 'omni', 'omni'),
230
231
  null,
231
232
  { chatId },
232
233
  );
@@ -13,7 +13,6 @@
13
13
  import { type NatsConnection, StringCodec, type Subscription, connect } from 'nats';
14
14
  import type { Sql } from '../lib/db.js';
15
15
  import { resolveExecutorType } from '../lib/executor-config.js';
16
- import { isPaneAlive } from '../lib/tmux.js';
17
16
  import { BridgeSessionStore } from './bridge-session-store.js';
18
17
  import type { ExecutorSession, IExecutor, OmniMessage } from './executor.js';
19
18
  import { ClaudeCodeOmniExecutor } from './executors/claude-code.js';
@@ -188,6 +187,8 @@ export class OmniBridge {
188
187
  */
189
188
  private sessions = new Map<string, SessionEntry>();
190
189
  private messageQueue: OmniMessage[] = [];
190
+ /** Dedup cache: messageId → receive timestamp. Prevents JetStream redelivery duplicates. */
191
+ private recentMessageIds = new Map<string, number>();
191
192
  private idleCheckTimer: ReturnType<typeof setInterval> | null = null;
192
193
  private sc = StringCodec();
193
194
 
@@ -288,13 +289,13 @@ export class OmniBridge {
288
289
  });
289
290
 
290
291
  // Subscribe to all omni messages
291
- this.sub = this.nc.subscribe('omni.message.>');
292
+ this.sub = this.nc.subscribe('omni.message.>', { queue: 'genie-bridge' });
292
293
  this.processSubscription();
293
294
 
294
295
  // Turn lifecycle events from Omni
295
296
  const turnSubs = ['omni.turn.open.>', 'omni.turn.done.>', 'omni.turn.nudge.>', 'omni.turn.timeout.>'];
296
297
  for (const topic of turnSubs) {
297
- const sub = this.nc.subscribe(topic);
298
+ const sub = this.nc.subscribe(topic, { queue: 'genie-bridge' });
298
299
  this.processTurnEvents(sub);
299
300
  }
300
301
 
@@ -302,7 +303,7 @@ export class OmniBridge {
302
303
  // sends a reset token (e.g. 🗑️). Paired with omni-side publisher in
303
304
  // automagik-dev/omni#361. Subject hierarchy: omni.session.reset.{instance}.{chat}
304
305
  // (recursive `>` because WhatsApp chat ids contain dots).
305
- const sessionResetSub = this.nc.subscribe('omni.session.reset.>');
306
+ const sessionResetSub = this.nc.subscribe('omni.session.reset.>', { queue: 'genie-bridge' });
306
307
  this.processSessionResetEvents(sessionResetSub);
307
308
 
308
309
  // Start idle session checker
@@ -500,94 +501,23 @@ export class OmniBridge {
500
501
  }
501
502
 
502
503
  /**
503
- * Recover sessions that survived a bridge restart.
504
+ * Clean up stale sessions from previous bridge runs.
504
505
  *
505
- * Queries PG for active sessions, checks whether their tmux panes are still
506
- * alive, and re-populates the in-memory sessions Map for live ones. Dead
507
- * panes and SDK sessions (which can't survive a process restart) are marked
508
- * orphaned. This runs once during start(), after sessionStore is initialized.
506
+ * Orphans ALL active sessions on startup. Live panes will be re-created
507
+ * on demand when the next message arrives. This prevents duplicate key
508
+ * errors and misrouting from stale session state.
509
509
  */
510
510
  private async recoverSessions(): Promise<void> {
511
511
  if (!this.sessionStore) return;
512
512
 
513
- const activeSessions = await this.safePgCall(
514
- 'recover_list_active',
515
- (sql) => new BridgeSessionStore(sql).list('active'),
516
- [],
513
+ const orphanedCount = await this.safePgCall(
514
+ 'recover_orphan_all',
515
+ (sql) => new BridgeSessionStore(sql).markAllOrphaned(),
516
+ 0,
517
517
  );
518
518
 
519
- if (activeSessions.length === 0) return;
520
-
521
- console.log(`[omni-bridge] Found ${activeSessions.length} active session(s) from previous run, recovering...`);
522
-
523
- const orphanIds: string[] = [];
524
-
525
- for (const row of activeSessions) {
526
- const key = `${row.agent_name}:${row.chat_id}`;
527
-
528
- // SDK sessions can't survive a process restart — always orphan them.
529
- if (!row.tmux_pane_id) {
530
- orphanIds.push(row.id);
531
- continue;
532
- }
533
-
534
- // Duplicate guard: if another row already recovered this chat, orphan the older one.
535
- if (this.sessions.has(key)) {
536
- orphanIds.push(row.id);
537
- continue;
538
- }
539
-
540
- // Check if the tmux pane is still alive.
541
- let alive = false;
542
- try {
543
- alive = await isPaneAlive(row.tmux_pane_id);
544
- } catch {
545
- // tmux not available or pane check failed — treat as dead.
546
- }
547
-
548
- if (!alive) {
549
- orphanIds.push(row.id);
550
- continue;
551
- }
552
-
553
- // Pane is alive — reconstruct the in-memory session entry.
554
- const session: ExecutorSession = {
555
- id: row.executor_id ?? row.id,
556
- agentName: row.agent_name,
557
- chatId: row.chat_id,
558
- executorType: 'tmux',
559
- createdAt: new Date(row.started_at).getTime(),
560
- lastActivityAt: new Date(row.last_activity_at).getTime(),
561
- tmux: { session: '', window: '', paneId: row.tmux_pane_id },
562
- };
563
-
564
- const entry: SessionEntry = {
565
- session,
566
- instanceId: row.instance_id,
567
- spawning: false,
568
- buffer: [],
569
- idleTimer: null,
570
- pgBridgeSessionId: row.id,
571
- };
572
-
573
- this.sessions.set(key, entry);
574
- this.resetIdleTimer(key);
575
-
576
- console.log(`[omni-bridge] Recovered session ${key} (pane=${row.tmux_pane_id})`);
577
- }
578
-
579
- // Mark dead/SDK sessions as orphaned in one batch.
580
- if (orphanIds.length > 0) {
581
- await this.safePgCall(
582
- 'recover_mark_orphaned',
583
- (sql) => new BridgeSessionStore(sql).markOrphaned(orphanIds),
584
- undefined,
585
- );
586
- console.log(`[omni-bridge] Marked ${orphanIds.length} stale session(s) as orphaned`);
587
- }
588
-
589
- if (this.sessions.size > 0) {
590
- console.log(`[omni-bridge] Recovered ${this.sessions.size} live session(s)`);
519
+ if (orphanedCount > 0) {
520
+ console.log(`[omni-bridge] Startup cleanup: orphaned ${orphanedCount} stale session(s) from previous run`);
591
521
  }
592
522
  }
593
523
 
@@ -658,6 +588,24 @@ export class OmniBridge {
658
588
 
659
589
  console.log(`[omni-bridge] NATS message received: ${msg.subject} agent=${parsed.agent} chat=${parsed.chatId}`);
660
590
 
591
+ // Dedup: skip messages already processed (JetStream redelivery protection)
592
+ // biome-ignore lint/suspicious/noExplicitAny: NATS payload has extra fields beyond OmniMessage
593
+ const messageId = (parsed as any).messageId as string | undefined;
594
+ if (messageId && this.recentMessageIds.has(messageId)) {
595
+ console.log(`[omni-bridge] Dedup: skipping duplicate messageId=${messageId}`);
596
+ continue;
597
+ }
598
+ if (messageId) {
599
+ this.recentMessageIds.set(messageId, Date.now());
600
+ // Prune stale entries when cache grows large (TTL 60s)
601
+ if (this.recentMessageIds.size > 1000) {
602
+ const cutoff = Date.now() - 60_000;
603
+ for (const [id, ts] of this.recentMessageIds) {
604
+ if (ts < cutoff) this.recentMessageIds.delete(id);
605
+ }
606
+ }
607
+ }
608
+
661
609
  if (!parsed.chatId || !parsed.agent) {
662
610
  console.warn('[omni-bridge] Dropping message: missing chatId or agent', msg.subject);
663
611
  continue;
@@ -999,7 +947,7 @@ export class OmniBridge {
999
947
  OMNI_INSTANCE: payloadEnv?.OMNI_INSTANCE ?? message.instanceId,
1000
948
  OMNI_CHAT: payloadEnv?.OMNI_CHAT ?? message.chatId,
1001
949
  OMNI_MESSAGE: payloadEnv?.OMNI_MESSAGE ?? (raw.messageId as string) ?? '',
1002
- OMNI_TURN_ID: payloadEnv?.OMNI_TURN_ID ?? '',
950
+ OMNI_TURN_ID: payloadEnv?.OMNI_TURN_ID || '',
1003
951
  OMNI_SENDER_NAME: payloadEnv?.OMNI_SENDER_NAME ?? message.sender ?? '',
1004
952
  };
1005
953