Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
"plugins": [
{
"name": "genie",
"version": "4.260409.4",
"version": "4.260409.8",
"source": "./plugins/genie",
"description": "Human-AI partnership for Claude Code. Share a terminal, orchestrate workers, evolve together. Brainstorm ideas, wish them into plans, make with parallel agents, ship as one team. A coding genie that grows with your project."
}
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@automagik/genie",
"version": "4.260409.4",
"version": "4.260409.8",
"description": "Collaborative terminal toolkit for human + AI workflows",
"type": "module",
"bin": {
Expand Down
2 changes: 1 addition & 1 deletion plugins/genie/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "genie",
"version": "4.260409.4",
"version": "4.260409.8",
"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.",
"author": {
"name": "Namastex Labs"
Expand Down
2 changes: 1 addition & 1 deletion plugins/genie/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "genie-plugin",
"version": "4.260409.4",
"version": "4.260409.8",
"private": true,
"description": "Runtime dependencies for genie bundled CLIs",
"type": "module",
Expand Down
58 changes: 58 additions & 0 deletions src/__tests__/_shared-sdk-query-mock.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
/**
* Shared mock for `@anthropic-ai/claude-agent-sdk`.
*
* Bun's `mock.module()` is process-global and first-registration-wins. When
* multiple test files register competing mocks for the same module, whichever
* loads first locks the global cache, and other files' mocks become dead weight.
*
* This file provides a SINGLE `queryMock` instance used by both:
* - `src/__tests__/sdk-integration.test.ts`
* - `src/services/executors/__tests__/_sdk-mocks.ts`
*
* It ONLY mocks `@anthropic-ai/claude-agent-sdk` — no directory, registry, or
* other module mocks — so it's safe to import from any test file without
* polluting unrelated modules.
*/

import { mock } from 'bun:test';

/** Default SDK query implementation — yields one assistant reply + success result with session_id. */
const defaultQueryImpl = () => {
const gen = (async function* () {
yield { type: 'assistant', message: { content: [{ type: 'text', text: 'reply' }] } };
yield { type: 'result', subtype: 'success', session_id: 'sdk-session-aaa' };
})();
return Object.assign(gen, {
interrupt: mock(),
setPermissionMode: mock(),
setModel: mock(),
return: mock(async () => ({ value: undefined, done: true })),
throw: mock(async () => ({ value: undefined, done: true })),
});
};

export const queryMock = mock(defaultQueryImpl);

export function resetQueryMock(): void {
queryMock.mockReset();
queryMock.mockImplementation(defaultQueryImpl);
}

// ============================================================================
// Register the SDK mock — this is the single source of truth for the process.
// ============================================================================

mock.module('@anthropic-ai/claude-agent-sdk', () => ({
query: queryMock,
createSdkMcpServer: mock((opts: any) => ({
type: 'sdk' as const,
name: opts.name,
instance: {},
})),
tool: mock((_name: string, _desc: string, _schema: any, handler: any) => ({
name: _name,
description: _desc,
inputSchema: _schema,
handler,
})),
}));
26 changes: 4 additions & 22 deletions src/__tests__/sdk-integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,33 +6,15 @@
* permission gate, frontmatter parsing, and config priority layering.
*/

import { beforeEach, describe, expect, it, mock } from 'bun:test';
import { beforeEach, describe, expect, it, type mock } from 'bun:test';
import type { SpawnContext } from '../lib/executor-types.js';

// ============================================================================
// Mock the SDK module before any provider imports
// Use the shared SDK mock — eliminates process-global mock.module() race with
// executor tests. See _shared-sdk-query-mock.ts for details.
// ============================================================================

const mockQuery = mock(() => {
const gen = (async function* () {
yield { type: 'assistant', message: { content: [{ type: 'text', text: 'hello' }] } };
})();
return Object.assign(gen, {
interrupt: mock(),
setPermissionMode: mock(),
setModel: mock(),
return: mock(async () => ({ value: undefined, done: true })),
throw: mock(async () => ({ value: undefined, done: true })),
});
});

mock.module('@anthropic-ai/claude-agent-sdk', () => ({
query: mockQuery,
}));

// No audit mocking — routeSdkMessage is fire-and-forget (.catch(() => {}))
// and these tests never iterate the message stream, so audit is never invoked.
// Removing audit mocks prevents spyOn leaks that corrupt audit.test.ts in the same bun process.
import { queryMock as mockQuery } from './_shared-sdk-query-mock.js';

// ============================================================================
// Dynamic imports (must come after mock.module)
Expand Down
27 changes: 27 additions & 0 deletions src/db/migrations/035_bridge_sessions.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
-- 035_bridge_sessions.sql — Dedicated bridge session tracking
-- Persists omni bridge sessions so they survive process restarts.
-- Complements the executors table with bridge-specific fields.

CREATE TABLE IF NOT EXISTS genie_bridge_sessions (
id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
executor_id TEXT REFERENCES executors(id) ON DELETE SET NULL,
instance_id TEXT NOT NULL,
chat_id TEXT NOT NULL,
agent_name TEXT NOT NULL,
tmux_pane_id TEXT,
claude_session_id TEXT,
status TEXT NOT NULL DEFAULT 'active'
CHECK (status IN ('active', 'closed', 'orphaned')),
started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
last_activity_at TIMESTAMPTZ NOT NULL DEFAULT now(),
closed_at TIMESTAMPTZ,
metadata JSONB DEFAULT '{}'
);

CREATE INDEX IF NOT EXISTS idx_bridge_sessions_status
ON genie_bridge_sessions(status);
CREATE INDEX IF NOT EXISTS idx_bridge_sessions_instance_chat
ON genie_bridge_sessions(instance_id, chat_id);
CREATE INDEX IF NOT EXISTS idx_bridge_sessions_active
ON genie_bridge_sessions(status, last_activity_at)
WHERE status = 'active';
Comment on lines +5 to +27

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Enforce one active row per bridge session key.

The new indexes speed lookups, but nothing here prevents two status='active' rows for the same logical session. The runtime already assumes singular active rows during recovery, so duplicate inserts will make restart recovery nondeterministic and keep stale sessions alive in PG.

Suggested migration
 CREATE INDEX IF NOT EXISTS idx_bridge_sessions_active
   ON genie_bridge_sessions(status, last_activity_at)
   WHERE status = 'active';
+
+CREATE UNIQUE INDEX IF NOT EXISTS uniq_bridge_sessions_active_key
+  ON genie_bridge_sessions(instance_id, agent_name, chat_id)
+  WHERE status = 'active';
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
CREATE TABLE IF NOT EXISTS genie_bridge_sessions (
id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
executor_id TEXT REFERENCES executors(id) ON DELETE SET NULL,
instance_id TEXT NOT NULL,
chat_id TEXT NOT NULL,
agent_name TEXT NOT NULL,
tmux_pane_id TEXT,
claude_session_id TEXT,
status TEXT NOT NULL DEFAULT 'active'
CHECK (status IN ('active', 'closed', 'orphaned')),
started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
last_activity_at TIMESTAMPTZ NOT NULL DEFAULT now(),
closed_at TIMESTAMPTZ,
metadata JSONB DEFAULT '{}'
);
CREATE INDEX IF NOT EXISTS idx_bridge_sessions_status
ON genie_bridge_sessions(status);
CREATE INDEX IF NOT EXISTS idx_bridge_sessions_instance_chat
ON genie_bridge_sessions(instance_id, chat_id);
CREATE INDEX IF NOT EXISTS idx_bridge_sessions_active
ON genie_bridge_sessions(status, last_activity_at)
WHERE status = 'active';
CREATE TABLE IF NOT EXISTS genie_bridge_sessions (
id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
executor_id TEXT REFERENCES executors(id) ON DELETE SET NULL,
instance_id TEXT NOT NULL,
chat_id TEXT NOT NULL,
agent_name TEXT NOT NULL,
tmux_pane_id TEXT,
claude_session_id TEXT,
status TEXT NOT NULL DEFAULT 'active'
CHECK (status IN ('active', 'closed', 'orphaned')),
started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
last_activity_at TIMESTAMPTZ NOT NULL DEFAULT now(),
closed_at TIMESTAMPTZ,
metadata JSONB DEFAULT '{}'
);
CREATE INDEX IF NOT EXISTS idx_bridge_sessions_status
ON genie_bridge_sessions(status);
CREATE INDEX IF NOT EXISTS idx_bridge_sessions_instance_chat
ON genie_bridge_sessions(instance_id, chat_id);
CREATE INDEX IF NOT EXISTS idx_bridge_sessions_active
ON genie_bridge_sessions(status, last_activity_at)
WHERE status = 'active';
CREATE UNIQUE INDEX IF NOT EXISTS uniq_bridge_sessions_active_key
ON genie_bridge_sessions(instance_id, agent_name, chat_id)
WHERE status = 'active';
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/db/migrations/035_bridge_sessions.sql` around lines 5 - 27, The schema
allows multiple rows with status='active' for the same logical session
(instance_id, chat_id), which breaks recovery assumptions; add a unique partial
index to enforce at most one active row per session key by creating a unique
index on (instance_id, chat_id) WHERE status = 'active' (e.g., CREATE UNIQUE
INDEX IF NOT EXISTS uniq_bridge_sessions_active_per_key ON
genie_bridge_sessions(instance_id, chat_id) WHERE status = 'active'); reference
the genie_bridge_sessions table and the existing status, instance_id, chat_id
columns when making this migration change.

52 changes: 52 additions & 0 deletions src/genie-commands/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,57 @@ async function checkWorkerProfiles(): Promise<CheckResult[]> {
return results;
}

/**
* Check Omni bridge health
*/
async function checkBridge(): Promise<CheckResult[]> {
const results: CheckResult[] = [];

try {
const { getBridge } = await import('../services/omni-bridge.js');
const bridge = getBridge();

if (!bridge) {
results.push({
name: 'Bridge running',
status: 'warn',
message: 'not running in this process',
suggestion: 'Bridge starts automatically with: genie serve',
});
return results;
}
Comment on lines +312 to +327

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

This health check only works inside the bridge-owning process.

genie doctor runs in a separate CLI process, so getBridge() will usually be null even while genie serve has a healthy bridge running. That makes doctor report a warning on healthy installs instead of reporting real bridge state.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/genie-commands/doctor.ts` around lines 312 - 327, checkBridge currently
assumes it runs inside the bridge process by calling getBridge() which is null
for the CLI; update checkBridge to treat a null getBridge() as "not local" and
perform a remote health probe instead: keep the existing local-path logic using
getBridge() to report status when present, but when getBridge() is null call the
bridge health endpoint (e.g., HTTP GET to the configured bridge host/port or a
known health path) and base the CheckResult on that response; only return the
"not running in this process" warning if both the local getBridge() is null and
the remote health probe fails/unreachable. Ensure you reference checkBridge and
getBridge in the change and use the configured/default bridge address for the
remote probe.


const s = await bridge.status();

results.push({
name: 'NATS connection',
status: s.connected ? 'pass' : 'fail',
message: s.connected ? `connected (${s.natsUrl})` : `disconnected (${s.natsUrl})`,
suggestion: s.connected ? undefined : 'Check NATS server: nats-server or omni start',
});

results.push({
name: 'Active sessions',
status: 'pass',
message: `${s.activeSessions} / ${s.maxConcurrent} (queue: ${s.queueDepth})`,
});

results.push({
name: 'PG backing',
status: s.pgAvailable ? 'pass' : 'warn',
message: s.pgAvailable ? 'connected' : 'degraded (in-memory)',
});
} catch {
results.push({
name: 'Bridge module',
status: 'warn',
message: 'could not load omni-bridge',
});
}

return results;
}

/**
* Main doctor command
*/
Expand Down Expand Up @@ -334,6 +385,7 @@ export async function doctorCommand(options?: { fix?: boolean }): Promise<void>
runCheckSection('Configuration', await checkConfiguration(), counts);
runCheckSection('Tmux', await checkTmux(), counts);
runCheckSection('Worker Profiles', await checkWorkerProfiles(), counts);
runCheckSection('Omni Bridge', await checkBridge(), counts);

// Summary
console.log();
Expand Down
2 changes: 1 addition & 1 deletion src/lib/runtime-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ export type RuntimeEventKind =
| 'tool_result'
| 'system'
| 'qa';
export type RuntimeEventSource = 'provider' | 'mailbox' | 'chat' | 'registry' | 'hook';
export type RuntimeEventSource = 'provider' | 'mailbox' | 'chat' | 'registry' | 'hook' | 'sdk';
export type RuntimeEventDirection = 'in' | 'out';

export interface RuntimeEvent {
Expand Down
Loading