# v0.2.3.1 Ollama Settings In-App UI Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** 사용자가 트레이/배너에서 Ollama endpoint + model 을 직접 설정할 수 있도록. JSON file 영속화. precedence: settings > env > default. AbortController + ProviderHolder 통한 in-flight job 안전 교체.

**Architecture:** 새 `SettingsService` (JSON 영속화 + zod 검증) + `ProviderHolder` (mutable provider holder + listeners) + `LocalOllamaProvider.abort()` (AbortController exposure). AiWorker/HealthChecker 가 provider 직접 보유 → holder 통해 간접 참조로 변경. UI 는 React modal 컴포넌트 (별도 BrowserWindow X).

**Tech Stack:** zod 4.3.6 (settings 검증), React 19 modal, node:fs/promises atomic write (temp + rename), Electron IPC 2 신규 channel.

**Spec:** `docs/superpowers/specs/2026-05-04-v0231-ollama-settings-design.md`

---

## File Structure

| File | Role | Action |
|------|------|--------|
| `src/main/services/SettingsService.ts` | JSON 영속화 + zod 검증 | 신규 |
| `src/main/ai/ProviderHolder.ts` | Mutable provider holder + listeners | 신규 |
| `src/main/ai/LocalOllamaProvider.ts` | AbortController exposure + abort() method | 수정 |
| `src/main/ai/AiWorker.ts` | provider → holder.get() | 수정 |
| `src/main/services/HealthChecker.ts` | provider → holder.get() + onReplace | 수정 |
| `src/main/index.ts` | SettingsService 부팅 + holder 생성 | 수정 |
| `src/main/ipc/inboxApi.ts` | 2 신규 IPC handler + 1 push channel | 수정 |
| `src/preload/index.ts` | 2 신규 inboxApi 메서드 + 1 listener | 수정 |
| `src/shared/types.ts` | InboxApi 시그니처 추가 | 수정 |
| `src/main/tray.ts` | "Ollama 설정..." 메뉴 + 10번째 callback | 수정 |
| `src/renderer/inbox/components/OllamaSettingsModal.tsx` | Modal UI | 신규 |
| `src/renderer/inbox/components/OllamaBanner.tsx` | "설정" 링크 추가 | 수정 |
| `src/renderer/inbox/App.tsx` | Modal mount + open/close state | 수정 |
| `tests/unit/SettingsService.test.ts` | 6 cases | 신규 |
| `tests/unit/ProviderHolder.test.ts` | 2 cases | 신규 |
| `tests/unit/LocalOllamaProvider.test.ts` | +2 cases | 수정 |

총 신규 단위 10개. 단위 403 → 413 예상.

---

## Task 1: SettingsService — JSON 영속화 + zod 검증

**Files:**
- Create: `src/main/services/SettingsService.ts`
- Test: `tests/unit/SettingsService.test.ts` (신규)

- [ ] **Step 1: Create test file with first failing case**

Create `tests/unit/SettingsService.test.ts`:

```typescript
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, rmSync, existsSync, readFileSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { SettingsService } from '@main/services/SettingsService.js';

describe('SettingsService', () => {
  let dir: string;
  let svc: SettingsService;

  beforeEach(() => {
    dir = mkdtempSync(join(tmpdir(), 'inkling-settings-'));
    svc = new SettingsService(dir);
  });
  afterEach(() => { rmSync(dir, { recursive: true, force: true }); });

  it('load() returns empty object when file does not exist', async () => {
    const s = await svc.load();
    expect(s).toEqual({});
  });
});
```

- [ ] **Step 2: Run test to verify fail**

Run: `npm test -- SettingsService`
Expected: FAIL — `Cannot find module '@main/services/SettingsService.js'`

- [ ] **Step 3: Implement SettingsService**

Create `src/main/services/SettingsService.ts`:

```typescript
import { readFile, writeFile, mkdir, rename } from 'node:fs/promises';
import { join, dirname } from 'node:path';
import { z } from 'zod';

const OllamaSettingsSchema = z.object({
  endpoint: z.string().url(),
  model: z.string().min(1)
}).strict();

const SettingsSchema = z.object({
  ollama: OllamaSettingsSchema.optional()
}).strict();

export type Settings = z.infer<typeof SettingsSchema>;
export type OllamaSettings = z.infer<typeof OllamaSettingsSchema>;

export class SettingsService {
  private filePath: string;
  private cache: Settings | null = null;

  constructor(profileDir: string) {
    this.filePath = join(profileDir, 'settings.json');
  }

  async load(): Promise<Settings> {
    if (this.cache !== null) return this.cache;
    try {
      const raw = await readFile(this.filePath, 'utf8');
      const parsed = JSON.parse(raw);
      this.cache = SettingsSchema.parse(parsed);
    } catch {
      this.cache = {};
    }
    return this.cache;
  }

  async setOllama(value: OllamaSettings): Promise<void> {
    const validated = OllamaSettingsSchema.parse(value);
    const current = await this.load();
    const next: Settings = { ...current, ollama: validated };
    await mkdir(dirname(this.filePath), { recursive: true });
    const tmpPath = this.filePath + '.tmp';
    await writeFile(tmpPath, JSON.stringify(next, null, 2), 'utf8');
    await rename(tmpPath, this.filePath);
    this.cache = next;
  }
}
```

- [ ] **Step 4: Run test, verify pass**

Run: `npm test -- SettingsService`
Expected: PASS — 1 case.

- [ ] **Step 5: Add 5 more cases**

Append to `tests/unit/SettingsService.test.ts` inside the `describe`:

```typescript
  it('load() returns empty object on corrupted JSON (no throw)', async () => {
    writeFileSync(join(dir, 'settings.json'), '{ this is not json');
    const s = await svc.load();
    expect(s).toEqual({});
  });

  it('load() caches result — second call does not re-read file', async () => {
    await svc.setOllama({ endpoint: 'http://localhost:11434', model: 'gemma4:e4b' });
    const before = await svc.load();
    // 외부에서 파일 변경
    writeFileSync(join(dir, 'settings.json'), JSON.stringify({ ollama: { endpoint: 'http://lan:11434', model: 'gemma4:26b' } }));
    const after = await svc.load();
    // 캐시 적용 — 파일 변경 무시
    expect(after).toEqual(before);
  });

  it('setOllama() throws on non-URL endpoint', async () => {
    await expect(
      svc.setOllama({ endpoint: 'not-a-url', model: 'gemma4:e4b' })
    ).rejects.toThrow();
  });

  it('setOllama() persists to disk with valid JSON', async () => {
    await svc.setOllama({ endpoint: 'http://localhost:11435', model: 'gemma4:e4b' });
    const raw = readFileSync(join(dir, 'settings.json'), 'utf8');
    const parsed = JSON.parse(raw);
    expect(parsed.ollama.endpoint).toBe('http://localhost:11435');
    expect(parsed.ollama.model).toBe('gemma4:e4b');
  });

  it('setOllama() atomic write — tmp file does not remain', async () => {
    await svc.setOllama({ endpoint: 'http://localhost:11434', model: 'gemma4:e4b' });
    expect(existsSync(join(dir, 'settings.json.tmp'))).toBe(false);
    expect(existsSync(join(dir, 'settings.json'))).toBe(true);
  });
});
```

- [ ] **Step 6: Run all tests**

Run: `npm test -- SettingsService`
Expected: PASS — 6 cases.

- [ ] **Step 7: Commit**

```bash
git add src/main/services/SettingsService.ts tests/unit/SettingsService.test.ts
git commit -m "$(cat <<'EOF'
feat(settings): SettingsService — JSON 영속화 + zod 검증 (v0.2.3.1)

- `<profileDir>/settings.json` atomic write (temp + rename)
- 손상 JSON / 파일 없음 → 빈 객체 fallback (no throw)
- in-memory cache (load 1회 file read)
- zod .strict() schema for ollama { endpoint: URL, model: string }
- 단위 +6 cases

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

---

## Task 2: LocalOllamaProvider — AbortController exposure + model param

**Files:**
- Modify: `src/main/ai/LocalOllamaProvider.ts`
- Test: `tests/unit/LocalOllamaProvider.test.ts` (확장)

- [ ] **Step 1: Read current LocalOllamaProvider.ts**

Read the file to confirm current structure. Method `generate()` 안에 `const controller = new AbortController()` 로 method-local 변수 사용 중. 외부에서 abort 못함.

- [ ] **Step 2: Write failing test for abort()**

Append inside `describe('LocalOllamaProvider', ...)` in `tests/unit/LocalOllamaProvider.test.ts`:

```typescript
  it('abort() cancels in-flight generate (rejects with AbortError)', async () => {
    mock.get('http://localhost:11434').intercept({
      path: '/api/generate', method: 'POST'
    }).reply(async () => {
      await new Promise<void>((r) => setTimeout(r, 5000)); // long-running
      return { statusCode: 200, data: '{}' };
    });
    const provider = new LocalOllamaProvider({ timeoutMs: 30_000 });
    const generatePromise = provider.generate({
      text: 'x', todayKst: '2026-05-04', dueDateCandidates: []
    });
    setTimeout(() => provider.abort(), 50);
    await expect(generatePromise).rejects.toThrow();
  });

  it('constructor uses provided model param (not just default)', () => {
    const provider = new LocalOllamaProvider({ model: 'gemma4:26b' });
    expect(provider.name).toBe('local-ollama/gemma4:26b');
  });
```

- [ ] **Step 3: Run, verify fail**

Run: `npm test -- LocalOllamaProvider`
Expected: FAIL — `provider.abort is not a function`. (Second test should pass already since model param exists.)

- [ ] **Step 4: Modify LocalOllamaProvider.ts — expose abortController + add abort()**

Open `src/main/ai/LocalOllamaProvider.ts`. Find the `generate(input)` method and refactor:

```typescript
export class LocalOllamaProvider implements InferenceProvider {
  readonly name: string;
  private endpoint: string;
  private model: string;
  private timeoutMs: number;
  private temperature: number;
  private numPredict: number;
  private abortController: AbortController | null = null;

  constructor(opts: LocalOllamaOptions = {}) {
    this.endpoint = opts.endpoint ?? 'http://localhost:11434';
    this.model = opts.model ?? 'gemma4:e4b';
    this.timeoutMs = opts.timeoutMs ?? 120_000;
    this.temperature = opts.temperature ?? 0.2;
    this.numPredict = opts.numPredict ?? 512;
    this.name = `local-ollama/${this.model}`;
  }

  async generate(input: GenerateInput): Promise<AiResponse> {
    this.abortController = new AbortController();
    const timer = setTimeout(() => this.abortController?.abort(), this.timeoutMs);
    try {
      const res = await request(`${this.endpoint}/api/generate`, {
        method: 'POST',
        headers: { 'content-type': 'application/json' },
        body: JSON.stringify({
          model: this.model,
          prompt: buildPrompt(input.text, input.todayKst, input.dueDateCandidates, input.vocab ?? []),
          format: 'json',
          stream: false,
          options: { temperature: this.temperature, num_predict: this.numPredict }
        }),
        signal: this.abortController.signal
      });
      if (res.statusCode < 200 || res.statusCode >= 300) {
        throw new Error(`ollama http ${res.statusCode}`);
      }
      const body = (await res.body.json()) as { response?: string };
      if (!body.response) throw new Error('missing response field');
      let parsed: unknown;
      try { parsed = JSON.parse(body.response); }
      catch (err) { throw new Error(`invalid json in response: ${String(err)}`); }
      return parseAiResponse(parsed);
    } finally {
      clearTimeout(timer);
      this.abortController = null;
    }
  }

  /** v0.2.3.1 — 외부에서 in-flight generate 강제 중단. ProviderHolder.replace 시 사용. */
  abort(): void {
    this.abortController?.abort();
  }

  async healthCheck(): Promise<HealthResult> {
    // 기존 구현 유지
    try {
      const res = await request(`${this.endpoint}/api/tags`, { method: 'GET' });
      if (res.statusCode !== 200) return { ok: false, reason: `tags http ${res.statusCode}` };
      const body = (await res.body.json()) as { models?: Array<{ name: string }> };
      const found = body.models?.some((m) => m.name === this.model);
      return found ? { ok: true, model: this.model }
                   : { ok: false, reason: `${this.model} not installed` };
    } catch (err) {
      return { ok: false, reason: `unreachable: ${(err as Error).message}` };
    }
  }
}
```

핵심 변경: `private abortController: AbortController | null = null;` 필드 추가, `generate()` 가 method-local 대신 인스턴스 필드 사용, `abort()` public 메서드 추가.

- [ ] **Step 5: Run tests**

Run: `npm test -- LocalOllamaProvider`
Expected: PASS — 모든 기존 케이스 + 2 신규.

- [ ] **Step 6: Commit**

```bash
git add src/main/ai/LocalOllamaProvider.ts tests/unit/LocalOllamaProvider.test.ts
git commit -m "$(cat <<'EOF'
feat(ollama): LocalOllamaProvider — abort() + AbortController instance field (v0.2.3.1)

- abortController 가 method-local 에서 private instance field 로 이동
- public abort() 메서드 — 외부에서 in-flight generate 강제 중단
- ProviderHolder.replace() 시 호출되어 endpoint 변경 즉시 반영
- 단위 +2 cases (abort cancellation, model 파라미터)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

---

## Task 3: ProviderHolder + AiWorker/HealthChecker refactor

**Files:**
- Create: `src/main/ai/ProviderHolder.ts`
- Modify: `src/main/ai/AiWorker.ts`
- Modify: `src/main/services/HealthChecker.ts`
- Test: `tests/unit/ProviderHolder.test.ts` (신규)

- [ ] **Step 1: Create ProviderHolder + tests**

Create `tests/unit/ProviderHolder.test.ts`:

```typescript
import { describe, it, expect, vi } from 'vitest';
import { ProviderHolder } from '@main/ai/ProviderHolder.js';
import { LocalOllamaProvider } from '@main/ai/LocalOllamaProvider.js';

describe('ProviderHolder', () => {
  it('replace() fires listener and get() returns new instance', () => {
    const a = new LocalOllamaProvider({ endpoint: 'http://a:11434', model: 'm1' });
    const b = new LocalOllamaProvider({ endpoint: 'http://b:11434', model: 'm2' });
    const holder = new ProviderHolder(a);
    const listener = vi.fn();
    holder.onReplace(listener);
    expect(holder.get()).toBe(a);
    holder.replace(b);
    expect(holder.get()).toBe(b);
    expect(listener).toHaveBeenCalledWith(b);
  });

  it('multiple listeners all fire on replace()', () => {
    const a = new LocalOllamaProvider({ model: 'm1' });
    const b = new LocalOllamaProvider({ model: 'm2' });
    const holder = new ProviderHolder(a);
    const l1 = vi.fn();
    const l2 = vi.fn();
    holder.onReplace(l1);
    holder.onReplace(l2);
    holder.replace(b);
    expect(l1).toHaveBeenCalledWith(b);
    expect(l2).toHaveBeenCalledWith(b);
  });
});
```

- [ ] **Step 2: Run, verify fail**

Run: `npm test -- ProviderHolder`
Expected: FAIL — `Cannot find module '@main/ai/ProviderHolder.js'`.

- [ ] **Step 3: Implement ProviderHolder**

Create `src/main/ai/ProviderHolder.ts`:

```typescript
import type { LocalOllamaProvider } from './LocalOllamaProvider.js';

/**
 * v0.2.3.1 — Mutable provider holder. AiWorker / HealthChecker 가 endpoint 변경 시
 * 새 LocalOllamaProvider 인스턴스를 받도록 indirection layer.
 *
 * 사용 패턴:
 *   const holder = new ProviderHolder(initialProvider);
 *   aiWorker = new AiWorker(repo, holder, opts);   // holder 전달
 *   health = new HealthChecker(holder, opts);
 *   health.onProviderReplace 가 holder.onReplace 통해 polling 즉시 새 provider 사용
 *
 *   // 사용자가 Settings 저장 시:
 *   holder.get().abort();         // in-flight 중단
 *   holder.replace(newProvider);  // 모든 consumer 가 새 인스턴스 사용
 */
export class ProviderHolder {
  private current: LocalOllamaProvider;
  private listeners: Array<(p: LocalOllamaProvider) => void> = [];

  constructor(initial: LocalOllamaProvider) {
    this.current = initial;
  }

  get(): LocalOllamaProvider {
    return this.current;
  }

  replace(next: LocalOllamaProvider): void {
    this.current = next;
    for (const fn of this.listeners) fn(next);
  }

  onReplace(fn: (p: LocalOllamaProvider) => void): void {
    this.listeners.push(fn);
  }
}
```

- [ ] **Step 4: Run tests, verify pass**

Run: `npm test -- ProviderHolder`
Expected: PASS — 2 cases.

- [ ] **Step 5: Refactor AiWorker — provider → holder.get()**

Read `src/main/ai/AiWorker.ts` to confirm structure. Find constructor signature `private provider: InferenceProvider` 및 사용처 `this.provider.generate(...)` / `this.provider.name`.

Replace constructor parameter:

Find:
```typescript
  constructor(
    private repo: NoteRepository,
    private provider: InferenceProvider,
    opts: AiWorkerOptions = {}
  ) {
```

Replace with:
```typescript
  constructor(
    private repo: NoteRepository,
    private holder: ProviderHolder,
    opts: AiWorkerOptions = {}
  ) {
```

(Add `import { ProviderHolder } from './ProviderHolder.js';` at top if not present.)

Then in `processJob`, find:
```typescript
        const res = await this.provider.generate({
          text: note.rawText,
          todayKst: todayIso,
          dueDateCandidates: candidates,
          vocab
        });
```

Replace with:
```typescript
        const res = await this.holder.get().generate({
          text: note.rawText,
          todayKst: todayIso,
          dueDateCandidates: candidates,
          vocab
        });
```

And:
```typescript
        this.repo.updateAiResult(job.noteId, {
          title: res.title,
          summary: res.summary,
          tags: res.tags,
          provider: this.provider.name,
          ...
```

Replace with:
```typescript
        this.repo.updateAiResult(job.noteId, {
          title: res.title,
          summary: res.summary,
          tags: res.tags,
          provider: this.holder.get().name,
          ...
```

Verify any other `this.provider` references and replace with `this.holder.get()`.

- [ ] **Step 6: Refactor HealthChecker similarly**

Read `src/main/services/HealthChecker.ts`. Same pattern — constructor `private provider: InferenceProvider` → `private holder: ProviderHolder`. Internal uses of `this.provider.healthCheck()` → `this.holder.get().healthCheck()`.

If HealthChecker has internal state that depends on provider identity (e.g., last health snapshot), no special handling needed since provider replacement just causes next polling cycle to use new endpoint.

- [ ] **Step 7: Run all tests, verify no regressions**

Run: `npm test`
Expected: PASS — 기존 + 신규 모두. AiWorker / HealthChecker 기존 테스트가 `provider` 파라미터를 전달하던 부분이 fail 할 수 있음.

If existing tests fail because they pass `provider` to AiWorker/HealthChecker constructor: update those tests to wrap in `new ProviderHolder(provider)`. Example:

Find in test files:
```typescript
const worker = new AiWorker(repo, makeProvider(), { ... });
```

Replace with:
```typescript
const provider = makeProvider();
const holder = new ProviderHolder(provider);
const worker = new AiWorker(repo, holder, { ... });
```

(Add `import { ProviderHolder } from '@main/ai/ProviderHolder.js';` at top of affected test files.)

Search affected test files: `tests/unit/AiWorker.test.ts`, `tests/unit/HealthChecker.test.ts`. Update all `new AiWorker(...)` and `new HealthChecker(...)` constructor calls to use `ProviderHolder` wrapper.

- [ ] **Step 8: Run all tests, verify pass**

Run: `npm test`
Expected: PASS — 단위 411/411 (403 기존 + 6 SettingsService + 2 LocalOllamaProvider + 2 ProviderHolder = 413 if counting from start of T1; if some existing tests required modification then total stays 413).

Actually wait — pre-T2 count was 405 (403 + 2 LocalOllamaProvider new). After T1 it's 411 (+6 SettingsService). After T3 it's 413 (+2 ProviderHolder). Goal: 413.

- [ ] **Step 9: Commit**

```bash
git add src/main/ai/ProviderHolder.ts src/main/ai/AiWorker.ts src/main/services/HealthChecker.ts tests/unit/ProviderHolder.test.ts tests/unit/AiWorker.test.ts tests/unit/HealthChecker.test.ts
git commit -m "$(cat <<'EOF'
feat(ollama): ProviderHolder + AiWorker/HealthChecker refactor (v0.2.3.1)

- ProviderHolder: mutable holder + listeners, indirection layer
- AiWorker: constructor 가 InferenceProvider → ProviderHolder
  this.provider.x → this.holder.get().x 전환
- HealthChecker: 동일 패턴
- 기존 AiWorker / HealthChecker 테스트의 constructor 호출에 ProviderHolder wrap
- 단위 +2 cases (ProviderHolder)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

---

## Task 4: index.ts 부팅 + IPC + preload + types

**Files:**
- Modify: `src/main/index.ts`
- Modify: `src/main/ipc/inboxApi.ts`
- Modify: `src/preload/index.ts`
- Modify: `src/shared/types.ts`

- [ ] **Step 1: Modify index.ts — SettingsService 부팅 + holder 생성**

Read `src/main/index.ts` to confirm current structure. Find provider 생성 부분 (around line 60-72):

```typescript
  const resolvedEndpoint = process.env.INKLING_OLLAMA_ENDPOINT ?? 'http://localhost:11434';
  logger.info('ai.endpoint', {
    endpoint: resolvedEndpoint,
    fromEnv: process.env.INKLING_OLLAMA_ENDPOINT !== undefined
  });
  const provider = new LocalOllamaProvider({ endpoint: resolvedEndpoint });
```

Replace with:
```typescript
  const settingsSvc = new SettingsService(paths.profileDir);
  const settings = await settingsSvc.load();

  const resolvedEndpoint = settings.ollama?.endpoint
                         ?? process.env.INKLING_OLLAMA_ENDPOINT
                         ?? 'http://localhost:11434';
  const resolvedModel = settings.ollama?.model ?? 'gemma4:e4b';

  logger.info('ai.endpoint', {
    endpoint: resolvedEndpoint,
    model: resolvedModel,
    source: settings.ollama?.endpoint
      ? 'settings'
      : (process.env.INKLING_OLLAMA_ENDPOINT ? 'env' : 'default')
  });

  const provider = new LocalOllamaProvider({ endpoint: resolvedEndpoint, model: resolvedModel });
  const providerHolder = new ProviderHolder(provider);
```

Add imports at top:
```typescript
import { SettingsService } from './services/SettingsService.js';
import { ProviderHolder } from './ai/ProviderHolder.js';
```

Then update HealthChecker / AiWorker construction below to pass `providerHolder` instead of `provider`:

Find:
```typescript
  const health = new HealthChecker(provider, { ... });
  ...
  const aiWorker = new AiWorker(repo, provider, { ... });
```

Replace with:
```typescript
  const health = new HealthChecker(providerHolder, { ... });
  ...
  const aiWorker = new AiWorker(repo, providerHolder, { ... });
```

- [ ] **Step 2: Modify ipc/inboxApi.ts — 2 IPC handlers**

Read `src/main/ipc/inboxApi.ts`. Add to deps interface (likely an object with services). Look at existing handlers to find pattern.

Add 2 IPC handlers near other recent handlers (e.g., near recall handlers):

```typescript
  ipcMain.handle('inbox:loadOllamaSettings', async () => {
    const s = await deps.settings.load();
    return s.ollama ?? null;
  });

  ipcMain.handle('inbox:saveOllamaSettings', async (_e, value: { endpoint: string; model: string }) => {
    // 검증: 새 인스턴스로 healthCheck
    const trial = new LocalOllamaProvider({ endpoint: value.endpoint, model: value.model });
    const r = await trial.healthCheck();
    if (!r.ok) return { ok: false, reason: r.reason ?? 'unknown' };
    await deps.settings.setOllama(value);
    deps.providerHolder.get().abort();
    deps.providerHolder.replace(trial);
    await deps.health.recheck();
    return { ok: true };
  });
```

Update deps interface to include `settings: SettingsService` and `providerHolder: ProviderHolder`. Add necessary imports:
```typescript
import { LocalOllamaProvider } from '../ai/LocalOllamaProvider.js';
import type { SettingsService } from '../services/SettingsService.js';
import type { ProviderHolder } from '../ai/ProviderHolder.js';
```

- [ ] **Step 3: Modify index.ts — register inboxApi with new deps**

Find existing `registerInboxApi` or similar call in index.ts. Add `settings: settingsSvc, providerHolder` to its deps object:

```typescript
registerInboxApi(getInboxWindow, {
  capture, repo, store, continuity, intent,
  health, exporter, importer, sync, backup, telemetry,
  settings: settingsSvc,        // 신규
  providerHolder                // 신규
});
```

(Adapt to existing argument shape — read inboxApi.ts to confirm.)

- [ ] **Step 4: Modify preload/index.ts — 2 inboxApi methods**

Find existing inboxApi object in preload. Add near other recent methods:

```typescript
    loadOllamaSettings: () => ipcRenderer.invoke('inbox:loadOllamaSettings'),
    saveOllamaSettings: (v: { endpoint: string; model: string }) => ipcRenderer.invoke('inbox:saveOllamaSettings', v),
```

- [ ] **Step 5: Modify shared/types.ts — InboxApi signatures**

Find `InboxApi` interface. Add 2 method signatures:

```typescript
  loadOllamaSettings(): Promise<{ endpoint: string; model: string } | null>;
  saveOllamaSettings(v: { endpoint: string; model: string }): Promise<{ ok: true } | { ok: false; reason: string }>;
```

- [ ] **Step 6: Run typecheck + tests**

Run: `npm run typecheck`
Expected: 0 errors.

Run: `npm test`
Expected: 413/413 pass (no new tests in this task, no regressions).

- [ ] **Step 7: Commit**

```bash
git add src/main/index.ts src/main/ipc/inboxApi.ts src/preload/index.ts src/shared/types.ts
git commit -m "$(cat <<'EOF'
feat(ollama): index 부팅 + IPC + preload + types (v0.2.3.1)

- index.ts: SettingsService.load() 후 endpoint/model 결정 (settings > env > default)
- providerHolder 생성 후 HealthChecker / AiWorker 에 전달
- IPC: inbox:loadOllamaSettings + inbox:saveOllamaSettings
  - save: 임시 provider 로 healthCheck 통과 시에만 영속화 + holder.replace
  - 기존 in-flight generate 는 abort()
- preload + InboxApi shared types

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

---

## Task 5: OllamaSettingsModal + App.tsx mount + OllamaBanner 링크

**Files:**
- Create: `src/renderer/inbox/components/OllamaSettingsModal.tsx`
- Modify: `src/renderer/inbox/components/OllamaBanner.tsx`
- Modify: `src/renderer/inbox/App.tsx`

- [ ] **Step 1: Create OllamaSettingsModal.tsx**

Create `src/renderer/inbox/components/OllamaSettingsModal.tsx`:

```typescript
import React, { useEffect, useState } from 'react';
import { inboxApi } from '../api.js';

interface Props {
  open: boolean;
  onClose: () => void;
}

export function OllamaSettingsModal({ open, onClose }: Props): React.ReactElement | null {
  const [endpoint, setEndpoint] = useState('http://localhost:11434');
  const [model, setModel] = useState('gemma4:e4b');
  const [error, setError] = useState<string | null>(null);
  const [saving, setSaving] = useState(false);

  // 마운트/open 시 현재 설정 fetch
  useEffect(() => {
    if (!open) return;
    void inboxApi.loadOllamaSettings().then((s) => {
      if (s) {
        setEndpoint(s.endpoint);
        setModel(s.model);
      }
      setError(null);
    });
  }, [open]);

  if (!open) return null;

  async function handleSave() {
    setSaving(true);
    setError(null);
    try {
      const r = await inboxApi.saveOllamaSettings({ endpoint, model });
      if (r.ok) {
        onClose();
      } else {
        setError(r.reason);
      }
    } catch (e) {
      setError(String(e));
    } finally {
      setSaving(false);
    }
  }

  return (
    <div style={{
      position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.4)',
      display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000
    }}>
      <div style={{
        background: '#fff', borderRadius: 8, padding: 20, minWidth: 400, maxWidth: 500,
        boxShadow: '0 4px 16px rgba(0,0,0,0.2)'
      }}>
        <h2 style={{ margin: '0 0 12px 0', fontSize: 16 }}>Ollama 설정</h2>
        <div style={{ marginBottom: 12 }}>
          <label style={{ display: 'block', fontSize: 12, color: '#666', marginBottom: 4 }}>
            Endpoint URL
          </label>
          <input
            type="text"
            value={endpoint}
            onChange={(e) => setEndpoint(e.target.value)}
            placeholder="http://localhost:11434"
            style={{ width: '100%', padding: '6px 8px', fontSize: 13, border: '1px solid #ccc', borderRadius: 4 }}
            disabled={saving}
          />
        </div>
        <div style={{ marginBottom: 12 }}>
          <label style={{ display: 'block', fontSize: 12, color: '#666', marginBottom: 4 }}>
            Model
          </label>
          <input
            type="text"
            value={model}
            onChange={(e) => setModel(e.target.value)}
            placeholder="gemma4:e4b"
            style={{ width: '100%', padding: '6px 8px', fontSize: 13, border: '1px solid #ccc', borderRadius: 4 }}
            disabled={saving}
          />
        </div>
        {error && (
          <div style={{
            background: '#fce4e4', color: '#a33', padding: '6px 10px', borderRadius: 4,
            fontSize: 12, marginBottom: 12
          }}>
            저장 실패: {error}
          </div>
        )}
        <div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
          <button
            onClick={onClose}
            disabled={saving}
            style={{
              background: 'transparent', color: '#666',
              border: '1px solid #ccc', borderRadius: 4,
              padding: '6px 14px', fontSize: 12, cursor: saving ? 'not-allowed' : 'pointer'
            }}
          >
            취소
          </button>
          <button
            onClick={() => void handleSave()}
            disabled={saving}
            style={{
              background: saving ? '#999' : '#0a4b80', color: '#fff',
              border: 'none', borderRadius: 4,
              padding: '6px 14px', fontSize: 12, cursor: saving ? 'not-allowed' : 'pointer'
            }}
          >
            {saving ? '검증 중...' : '저장'}
          </button>
        </div>
      </div>
    </div>
  );
}
```

- [ ] **Step 2: Modify App.tsx — mount modal + open state + IPC channel listener**

Read `src/renderer/inbox/App.tsx`. Add modal state + handler:

```typescript
import { OllamaSettingsModal } from './components/OllamaSettingsModal.js';

export function App(): React.ReactElement {
  // ... existing useInbox / useState ...
  const [ollamaSettingsOpen, setOllamaSettingsOpen] = useState(false);

  useEffect(() => {
    // ... existing onNoteUpdated / onOllamaStatus ...
    const unsubOpen = inboxApi.onOpenOllamaSettings(() => setOllamaSettingsOpen(true));
    return () => { /* existing */ unsubOpen(); };
  }, [/* existing deps */]);

  // ... 기존 return ...
  return (
    <>
      <div className="header">{/* ... */}</div>
      {/* ... */}
      <OllamaBanner onOpenSettings={() => setOllamaSettingsOpen(true)} />
      {/* ... 기존 banner stack ... */}
      <OllamaSettingsModal
        open={ollamaSettingsOpen}
        onClose={() => setOllamaSettingsOpen(false)}
      />
    </>
  );
}
```

(Adapt to existing JSX structure. Verify return shape and integrate accordingly.)

- [ ] **Step 3: Add `onOpenOllamaSettings` to preload**

In `src/preload/index.ts`, add IPC listener bridge:

```typescript
    onOpenOllamaSettings: (cb: () => void) => {
      const handler = () => cb();
      ipcRenderer.on('inbox:openOllamaSettings', handler);
      return () => ipcRenderer.removeListener('inbox:openOllamaSettings', handler);
    },
```

In `src/shared/types.ts` InboxApi:
```typescript
  onOpenOllamaSettings(cb: () => void): () => void;
```

- [ ] **Step 4: Modify OllamaBanner.tsx — "설정" 링크 추가**

Read `src/renderer/inbox/components/OllamaBanner.tsx`. Add prop + button:

```typescript
interface OllamaBannerProps {
  onOpenSettings?: () => void;
}

export function OllamaBanner({ onOpenSettings }: OllamaBannerProps): React.ReactElement | null {
  // ... 기존 ...

  // 기존 Banner JSX 안에 적절한 위치에 button 추가:
  return (
    <div style={{ /* 기존 style */ }}>
      <span>{/* 기존 메시지 */}</span>
      {onOpenSettings && (
        <button
          onClick={onOpenSettings}
          style={{
            marginLeft: 'auto',
            background: 'transparent', color: 'inherit',
            border: '1px solid currentColor', borderRadius: 4,
            padding: '2px 8px', fontSize: 12, cursor: 'pointer'
          }}
        >
          설정
        </button>
      )}
      {/* 기존 재확인 버튼 등 */}
    </div>
  );
}
```

(Adapt to existing OllamaBanner JSX — keep "재확인" 버튼 같이.)

- [ ] **Step 5: Run typecheck + tests**

Run: `npm run typecheck`
Expected: 0 errors.

Run: `npm test`
Expected: 413/413.

- [ ] **Step 6: Commit**

```bash
git add src/renderer/inbox/components/OllamaSettingsModal.tsx src/renderer/inbox/components/OllamaBanner.tsx src/renderer/inbox/App.tsx src/preload/index.ts src/shared/types.ts
git commit -m "$(cat <<'EOF'
feat(ollama): OllamaSettingsModal + App mount + OllamaBanner 설정 링크 (v0.2.3.1)

- OllamaSettingsModal: endpoint + model freetext 입력, 저장 시 healthCheck → 성공 닫기, 실패 inline 에러
- App.tsx: ollamaSettingsOpen state + onOpenOllamaSettings IPC subscribe
- OllamaBanner: onOpenSettings prop 추가, 우측 "설정" 버튼
- preload + types: onOpenOllamaSettings listener bridge

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

---

## Task 6: 트레이 메뉴 + IPC push channel

**Files:**
- Modify: `src/main/tray.ts`
- Modify: `src/main/index.ts`

- [ ] **Step 1: Modify tray.ts — "Ollama 설정..." 메뉴**

Read `src/main/tray.ts`. Find `createTray` 함수의 positional callback list (현재 9개). 10번째 callback `runOpenOllamaSettings` 추가.

Find existing menu template. Add new item near other config-style items:

```typescript
{ label: 'Ollama 설정...', click: () => runOpenOllamaSettings() },
```

Add new positional parameter:
```typescript
export function createTray(
  // ... 기존 9개 callbacks ...
  runRetryAllFailed: () => void,
  runOpenOllamaSettings: () => void   // 10번째
): void {
  // ... 기존 코드 ...
}
```

- [ ] **Step 2: Modify index.ts — pass runOpenOllamaSettings**

Find `createTray(...)` call in index.ts. Add 10th argument:

```typescript
createTray(
  // ... 기존 9개 ...
  () => { void capture.retryAllFailed(); },
  () => {
    const win = getInboxWindow();
    if (win) win.webContents.send('inbox:openOllamaSettings');
  }
);
```

- [ ] **Step 3: Run typecheck + tests**

Run: `npm run typecheck`
Expected: 0 errors.

Run: `npm test`
Expected: 413/413.

- [ ] **Step 4: Commit**

```bash
git add src/main/tray.ts src/main/index.ts
git commit -m "$(cat <<'EOF'
feat(ollama): 트레이 메뉴 "Ollama 설정..." (v0.2.3.1)

- createTray 10번째 positional callback runOpenOllamaSettings
- 트레이 → 메뉴 클릭 → main 이 inbox:openOllamaSettings IPC push
- renderer App.tsx 가 구독해 modal open

backlog #4/#26 (TrayCallbacks object refactor) 와 합산 — v0.2.4 시 일괄 정리

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

---

## Task 7: Closure — version bump + roadmap mark + final gates

**Files:**
- Modify: `package.json` (0.2.3 → 0.2.3.1)
- Modify: `package-lock.json`

- [ ] **Step 1: Verify final gate matrix**

Run sequentially:
```bash
npm run typecheck
npm test
npm run test:e2e
```

Expected:
- typecheck: 0 errors
- 단위: **413/413**
- e2e: 1/1

If failures, fix before proceeding.

- [ ] **Step 2: Bump version**

In `package.json`:
```json
  "version": "0.2.3.1",
```

In `package-lock.json` (top + nested package "" entry):
```json
  "version": "0.2.3.1",
```

- [ ] **Step 3: Commit version bump**

```bash
git add package.json package-lock.json
git commit -m "$(cat <<'EOF'
chore(release): v0.2.3.1 — Ollama 설정 in-app UI (patch cut)

dogfood unblock 패치. v0.2.3 의 INKLING_OLLAMA_ENDPOINT env var 의존 →
in-app UI (트레이 + 배너) 에서 endpoint + model 변경 가능.

게이트: typecheck 0 / 단위 413 / e2e 1
다음: PR + 머지 후 binary 재빌드 + Gitea release v0.2.3.1

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

- [ ] **Step 4: Push branch**

```bash
git push -u origin feat/v0231-ollama-settings
```

- [ ] **Step 5: Create PR via Gitea API**

Use file-based body (UTF-8 safety) with curl. Title: `feat(ollama): v0.2.3.1 — in-app endpoint/model 설정`. Body should reference spec and decisions.

(Implementer: this is the controller's job — report DONE and let controller handle.)

---

## Self-Review (executed by plan author inline)

**1. Spec coverage:**
- ✅ Q1=B (Endpoint + Model) → T2 model param + T5 modal 입력란 2개
- ✅ Q2=A (Freetext) → T5 `<input type="text">` 단순
- ✅ Q3=B (JSON file) → T1 SettingsService
- ✅ 7개 invariants from spec §3.1:
  - 저장=검증 통과 → T4 IPC handler 의 healthCheck 분기
  - Provider mutability via re-create → T3 ProviderHolder.replace
  - Settings precedence → T4 index.ts settings ?? env ?? default
  - 단일 settings file + atomic write → T1 SettingsService.setOllama
  - HealthChecker rebind → T3 holder.onReplace listener (HealthChecker 가 자체 등록)
  - Backward compat → T1 빈 객체 fallback test
  - Cross-platform → T1 SettingsService.test 가 mkdtempSync 통해 OS-agnostic 경로 사용
- ✅ Privacy invariant — telemetry emit 없음 (spec §5)
- ✅ All "Out of scope" 항목 미구현

**2. Placeholder scan:**
- ✅ "TBD" / "TODO" 없음
- T3 step 6 의 "Update affected test files" 는 검증 가능한 instruction (search + replace 패턴 명시)
- T5 step 2 의 "Adapt to existing JSX structure" 는 read 후 통합이라 actionable

**3. Type consistency:**
- `SettingsService.load(): Promise<Settings>` (T1) === T4 `await deps.settings.load()` 사용
- `ProviderHolder` 의 `get() / replace() / onReplace()` (T3) === T4 IPC handler 의 `deps.providerHolder.get().abort()` + `.replace(trial)`
- `OllamaSettings { endpoint, model }` 시그니처가 T1/T4/T5 모두 일치
- `LocalOllamaProvider.abort()` (T2) === T4 IPC handler 의 `providerHolder.get().abort()`

**Test count verification:**
- T1: 6 (SettingsService)
- T2: 2 (LocalOllamaProvider — abort + model)
- T3: 2 (ProviderHolder)
- Total new: **10**. 단위 403 + 10 = **413**.

기존 AiWorker / HealthChecker 테스트는 ProviderHolder 로 wrap 만 하면 되므로 count 영향 없음.

---

## Roadmap Relation

- v0.2.3 dogfood unblock 패치. v0.2.3.1 (patch increment, 정식 cut #1~#7 와 별개)
- 머지 후 binary 재빌드 (Windows + Mac) + Gitea release v0.2.3.1
- ≥1주 dogfood soak → v0.2.4 brainstorm 트리거 시점에 v0.2.4 backlog 38건 + 신규 피드백 + 본 cut 의 `ollama_settings_changed` count-only telemetry 후속 결정 일괄 triage
