# Investigation: Handler Bugs #392-#395

**Issues**:
- #392 — `/commands` shows "0 - undefined" for codebases with empty commands ([link](https://github.com/dynamous-community/remote-coding-agent/issues/392))
- #393 — Server fails to start in git worktrees due to missing .env symlink ([link](https://github.com/dynamous-community/remote-coding-agent/issues/393))
- #394 — `no_ai_credentials` fatal error provides no actionable guidance ([link](https://github.com/dynamous-community/remote-coding-agent/issues/394))
- #395 — `/setcwd` rejection message provides no guidance ([link](https://github.com/dynamous-community/remote-coding-agent/issues/395))

**Type**: BUG (#392, #393, #394), UX (#395)
**Investigated**: 2026-02-17T12:00:00Z

### Assessment

| Metric     | Value  | Reasoning                                                                                                |
| ---------- | ------ | -------------------------------------------------------------------------------------------------------- |
| Severity   | MEDIUM | No data loss or security issues; features partially broken with workarounds (use correct commands, manually create symlinks) |
| Complexity | LOW    | 3 files total, isolated changes, no architectural impact, clear fixes with existing codebase patterns    |
| Confidence | HIGH   | Root causes confirmed with code evidence; existing patterns in codebase to mirror (`workflow-events.ts` JSON parsing) |

---

## Problem Statement

Four related handler/startup issues produce unhelpful or incorrect output for users: (1) `/commands` shows character indices instead of "no commands" message when SQLite stores commands as JSON string, (2) server can't find `.env` in worktrees because `dotenv/config` relies on a gitignored symlink, (3) the `no_ai_credentials` fatal error gives zero guidance on what credentials are needed, and (4) `/setcwd` path rejection doesn't explain why or what to do instead.

---

## Analysis

### Issue #392 — Root Cause: SQLite JSON String Not Parsed

**WHY**: `/commands` shows `0 - undefined, 1 - undefined`
↓ BECAUSE: `Object.entries(commands)` iterates over string characters
Evidence: `command-handler.ts:671` — `for (const [name, def] of Object.entries(commands))`

↓ BECAUSE: `commands` is the string `"{}"` not the object `{}`
Evidence: `command-handler.ts:661` — `const commands = codebase?.commands ?? {};` — `??` doesn't trigger because `"{}"` is truthy

↓ ROOT CAUSE: SQLite stores `commands` as `TEXT DEFAULT '{}'`, and neither the SQLite adapter nor `getCodebase()` parses JSON
Evidence: `db/adapters/sqlite.ts:188` — `commands TEXT DEFAULT '{}'`
Evidence: `db/codebases.ts:32-37` — `getCodebase()` returns raw row, no JSON.parse

**Note**: `getCodebaseCommands()` at `codebases.ts:57` has the same bug — `{ ...(result.rows[0]?.commands ?? {}) }` would spread the string `"{}"` into `{0: "{", 1: "}"}`.

**Existing pattern**: `workflow-events.ts:78` correctly handles this:
```typescript
data: typeof row.data === 'string' ? JSON.parse(row.data) : row.data,
```

### Issue #393 — Root Cause: dotenv Searches Wrong Directory in Worktrees

**WHY**: Server exits with `no_ai_credentials` in worktrees
↓ BECAUSE: `process.env` has no credentials loaded
↓ BECAUSE: `import 'dotenv/config'` didn't find `.env`
↓ BECAUSE: When `bun --filter @archon/server dev` runs, CWD may be `packages/server/`, and the symlink `packages/server/.env → ../../.env` doesn't exist in worktrees
↓ ROOT CAUSE: The `.env` symlink is gitignored (`.gitignore:80`) and not tracked, so worktrees never have it
Evidence: `packages/server/src/index.ts:8` — `import 'dotenv/config';`
Evidence: `.gitignore` — `packages/server/.env` is ignored

**Fix approach**: Remove `dotenv/config` import entirely and use Bun's built-in `.env` loading, which automatically searches up the directory tree to the git root. This is the simplest and most maintainable approach since the project already uses Bun as its runtime.

### Issue #394 — Root Cause: Terse Fatal Log Lost Human Guidance

**WHY**: Developer doesn't know what credentials to set
↓ BECAUSE: Fatal log message is just `'no_ai_credentials'` with no context
↓ ROOT CAUSE: During #321 (structured logging migration), the multi-line `console.error` was replaced with a bare Pino message that lost the guidance text
Evidence: `packages/server/src/index.ts:78` — `getLog().fatal('no_ai_credentials');`

### Issue #395 — Root Cause: Error Message Missing Context

The `/setcwd` rejection says `Path must be within ${workspacePath} directory` but doesn't explain:
- Why the restriction exists (security)
- What paths are valid (registered repos)
- What to do instead (`/clone` to register a repo)

Evidence: `command-handler.ts:446` — `return { success: false, message: \`Path must be within ${workspacePath} directory\` };`

### Affected Files

| File                                              | Lines   | Action | Description                                                    |
| ------------------------------------------------- | ------- | ------ | -------------------------------------------------------------- |
| `packages/core/src/handlers/command-handler.ts`   | 655-675 | UPDATE | #392: Use `getCodebaseCommands()` instead of `getCodebase()`  |
| `packages/core/src/handlers/command-handler.ts`   | 444-446 | UPDATE | #395: Improve `/setcwd` rejection message                      |
| `packages/core/src/db/codebases.ts`               | 50-58   | UPDATE | #392: Add JSON.parse for SQLite string values in `getCodebaseCommands()` |
| `packages/server/src/index.ts`                    | 6-8     | UPDATE | #393: Remove `dotenv/config` import, rely on Bun's built-in   |
| `packages/server/src/index.ts`                    | 77-79   | UPDATE | #394: Add actionable guidance to fatal log                     |
| `packages/core/src/handlers/command-handler.test.ts` | 739-777 | UPDATE | Add test for SQLite JSON string edge case                      |

### Integration Points

- `command-handler.ts:660` calls `codebaseDb.getCodebase()` → should call `getCodebaseCommands()` instead
- `codebases.ts:57` returns commands for multiple callers (command-set, command-invoke, load-commands)
- `index.ts:8` dotenv import runs before any other code
- `index.ts:78` fatal log exits process immediately — must include all guidance inline

### Git History

- **#392 introduced**: `718e01b` — monorepo restructure (pre-existing since SQLite was added)
- **#393 introduced**: `ae82347` — SQLite as default database (added dotenv dependency)
- **#394 introduced**: `c7cd67e` — Pino structured logging migration (lost guidance text)
- **#395 introduced**: pre-existing since path validation was added

---

## Implementation Plan

### Step 1: Fix `getCodebaseCommands()` JSON parsing (#392)

**File**: `packages/core/src/db/codebases.ts`
**Lines**: 50-58
**Action**: UPDATE

**Current code:**
```typescript
export async function getCodebaseCommands(
  id: string
): Promise<Record<string, { path: string; description: string }>> {
  const result = await pool.query<{
    commands: Record<string, { path: string; description: string }>;
  }>('SELECT commands FROM remote_agent_codebases WHERE id = $1', [id]);
  // Spread to ensure mutable copy - Bun's SQLite driver returns frozen objects
  return { ...(result.rows[0]?.commands ?? {}) };
}
```

**Required change:**
```typescript
export async function getCodebaseCommands(
  id: string
): Promise<Record<string, { path: string; description: string }>> {
  const result = await pool.query<{
    commands: Record<string, { path: string; description: string }> | string;
  }>('SELECT commands FROM remote_agent_codebases WHERE id = $1', [id]);
  const raw = result.rows[0]?.commands;
  // SQLite returns TEXT columns as strings; PostgreSQL JSONB returns objects
  const parsed = typeof raw === 'string' ? JSON.parse(raw) : raw ?? {};
  // Spread to ensure mutable copy - Bun's SQLite driver returns frozen objects
  return { ...parsed };
}
```

**Why**: Mirrors the pattern from `workflow-events.ts:78`. Handles both SQLite (TEXT → string) and PostgreSQL (JSONB → object).

---

### Step 2: Update `/commands` handler to use `getCodebaseCommands()` (#392)

**File**: `packages/core/src/handlers/command-handler.ts`
**Lines**: 660-661
**Action**: UPDATE

**Current code:**
```typescript
const codebase = await codebaseDb.getCodebase(conversation.codebase_id);
const commands = codebase?.commands ?? {};
```

**Required change:**
```typescript
const commands = await codebaseDb.getCodebaseCommands(conversation.codebase_id);
```

**Why**: `getCodebaseCommands()` exists specifically for this purpose and (after Step 1) handles JSON parsing correctly.

---

### Step 3: Remove `dotenv/config` import, rely on Bun (#393)

**File**: `packages/server/src/index.ts`
**Lines**: 6-8
**Action**: UPDATE

**Current code:**
```typescript
// Load environment variables FIRST
// Note: packages/server/.env is a symlink to the root .env file
import 'dotenv/config';
```

**Required change:**
```typescript
// Environment variables loaded by Bun automatically from .env at project root
// Bun searches up the directory tree, so no symlink needed in worktrees
```

**Why**: Bun automatically loads `.env` from CWD and searches parent directories up to the git root. This eliminates the symlink dependency entirely. The `dotenv` package becomes unnecessary for the server entry point.

**Verify**: After this change, check if `dotenv` is still used elsewhere in the codebase. If not, it can be removed from `package.json` (but defer that to a separate cleanup).

---

### Step 4: Add actionable guidance to `no_ai_credentials` (#394)

**File**: `packages/server/src/index.ts`
**Lines**: 77-79
**Action**: UPDATE

**Current code:**
```typescript
if (!hasClaudeCredentials && !hasCodexCredentials) {
  getLog().fatal('no_ai_credentials');
  process.exit(1);
}
```

**Required change:**
```typescript
if (!hasClaudeCredentials && !hasCodexCredentials) {
  getLog().fatal(
    {
      options: [
        'Set CLAUDE_USE_GLOBAL_AUTH=true in .env (requires `claude /login`)',
        'Set CLAUDE_API_KEY in .env',
        'Set CODEX_ID_TOKEN + CODEX_ACCESS_TOKEN in .env',
      ],
      hint: 'Create a .env file at the project root. See .env.example for reference.',
    },
    'no_ai_credentials'
  );
  process.exit(1);
}
```

**Why**: Pino's structured format (object first, message second) keeps the log machine-parseable while pino-pretty renders the options array as human-readable guidance in TTY.

---

### Step 5: Improve `/setcwd` rejection message (#395)

**File**: `packages/core/src/handlers/command-handler.ts`
**Lines**: 445-446
**Action**: UPDATE

**Current code:**
```typescript
if (!isPathWithinWorkspace(resolvedCwd)) {
  return { success: false, message: `Path must be within ${workspacePath} directory` };
}
```

**Required change:**
```typescript
if (!isPathWithinWorkspace(resolvedCwd)) {
  return {
    success: false,
    message:
      `Path must be within the Archon workspaces directory (${workspacePath}).\n\n` +
      'To work with a repository, use:\n' +
      '  /clone <repo-url> — Clone and register a remote repo',
  };
}
```

**Why**: Tells the user why the restriction exists and what to do instead. Keeps it concise — one alternative action is enough.

---

### Step 6: Add/Update Tests

**File**: `packages/core/src/handlers/command-handler.test.ts`
**Action**: UPDATE

**Test cases to add:**

```typescript
// In the /commands describe block, after existing tests:

test('should handle commands as JSON string from SQLite', async () => {
  const conversation = { ...baseConversation, codebase_id: 'cb-123' };
  // Simulate SQLite returning commands as JSON string (TEXT column)
  mockGetCodebaseCommands.mockResolvedValue({
    plan: { path: '.claude/commands/plan.md', description: 'Plan command' },
  });

  const result = await handleCommand(conversation, '/commands');
  expect(result.success).toBe(true);
  expect(result.message).toContain('plan');
  expect(result.message).not.toContain('undefined');
});
```

**Note**: The existing test at line 761 (`should show message when no commands registered`) uses `commands: {}` which is already an object — it passes on both SQLite and PostgreSQL. After Step 2, this test needs updating since the handler now calls `getCodebaseCommands()` instead of `getCodebase()`. Update mock expectations accordingly.

**Update existing tests** (lines 739-777):

The `/commands` tests currently mock `getCodebase()` to return full codebase objects. After Step 2, the handler calls `getCodebaseCommands()` instead, so these tests need to mock `mockGetCodebaseCommands` instead of `mockGetCodebase`.

```typescript
// Line 739-758: Update to use mockGetCodebaseCommands
test('should list registered commands', async () => {
  const conversation = { ...baseConversation, codebase_id: 'cb-123' };
  mockGetCodebaseCommands.mockResolvedValue({
    plan: { path: '.claude/commands/plan.md', description: 'Plan command' },
    execute: { path: '.claude/commands/execute.md', description: 'Execute command' },
  });

  const result = await handleCommand(conversation, '/commands');
  expect(result.success).toBe(true);
  expect(result.message).toContain('plan');
  expect(result.message).toContain('execute');
});

// Line 761-777: Update to use mockGetCodebaseCommands
test('should show message when no commands registered', async () => {
  const conversation = { ...baseConversation, codebase_id: 'cb-123' };
  mockGetCodebaseCommands.mockResolvedValue({});

  const result = await handleCommand(conversation, '/commands');
  expect(result.success).toBe(true);
  expect(result.message).toContain('No commands registered');
});
```

**File**: `packages/core/src/db/codebases.test.ts`
**Action**: UPDATE (if it exists and tests `getCodebaseCommands`)

Add test for JSON string handling:
```typescript
test('getCodebaseCommands should parse JSON string from SQLite', async () => {
  // This would require mocking the database adapter
  // to return a string instead of an object
});
```

---

## Patterns to Follow

**From codebase — JSON parsing for SQLite TEXT columns:**
```typescript
// SOURCE: packages/core/src/db/workflow-events.ts:78
data: typeof row.data === 'string' ? JSON.parse(row.data) : row.data,
```

**From codebase — Pino structured logging with context:**
```typescript
// SOURCE: packages/server/src/index.ts:94
getLog().fatal({ err: error }, 'database_connection_failed');
```

**From codebase — Helpful error messages with alternatives:**
```typescript
// SOURCE: packages/core/src/handlers/command-handler.ts:528-530
responseMessage += `\n\n📁 Found: ${commandFolder}/\nUse /load-commands ${commandFolder} to register commands.`;
```

---

## Edge Cases & Risks

| Risk/Edge Case                                        | Mitigation                                                         |
| ----------------------------------------------------- | ------------------------------------------------------------------ |
| `getCodebaseCommands()` with malformed JSON string    | `JSON.parse` will throw — wrap in try/catch, return `{}`           |
| Bun `.env` auto-loading behavior changes              | Document the dependency in code comment; `.env.example` still used |
| `dotenv` still used elsewhere in codebase             | Check with `grep`; only remove from deps if unused everywhere      |
| PostgreSQL returns JSONB as object (no parsing needed) | `typeof raw === 'string'` guard handles both correctly             |
| Existing tests mock `getCodebase()` for `/commands`   | Update test mocks to use `getCodebaseCommands()` after Step 2      |

---

## Validation

### Automated Checks

```bash
bun run type-check
bun test packages/core/src/handlers/command-handler.test.ts
bun test packages/core/src/db/codebases.test.ts
bun run lint
bun run format:check
```

### Manual Verification

1. **#392**: Clone a repo with no `.archon/commands/`, run `/commands` — should show "No commands registered"
2. **#393**: Create a worktree, run `bun run dev:server` — should start successfully
3. **#394**: Remove all AI credential env vars, run server — should show actionable guidance
4. **#395**: Run `/setcwd /tmp` — should explain the restriction and suggest `/clone`

---

## Scope Boundaries

**IN SCOPE:**
- Fix `/commands` JSON string handling in `getCodebaseCommands()` and handler
- Remove `dotenv/config` import from server entry point
- Improve `no_ai_credentials` fatal log message
- Improve `/setcwd` rejection message
- Update existing tests for `/commands` handler

**OUT OF SCOPE (do not touch):**
- Removing `dotenv` from `package.json` dependencies (may be used elsewhere)
- Adding JSON parsing to `getCodebase()` broadly (only fix the specific call path)
- Changing the path validation logic in `isPathWithinWorkspace()`
- Adding `/register` command mentioned in #395 (future enhancement)

---

## Metadata

- **Investigated by**: Claude
- **Timestamp**: 2026-02-17T12:00:00Z
- **Artifact**: `.claude/PRPs/issues/issues-392-395.md`
