diff --git a/docs/TASK_DISPATCH.md b/docs/TASK_DISPATCH.md new file mode 100644 index 0000000..8194259 --- /dev/null +++ b/docs/TASK_DISPATCH.md @@ -0,0 +1,149 @@ +# Task Dispatch + +Task Dispatch lets you launch AI coding agents directly from Obsidian task notes. Each task gets its own tmux session with full context from your vault — the task note body, all linked notes, and access to the OpenAugi MCP server for searching your vault. + +## Prerequisites + +- **tmux** — Install with `brew install tmux`. The plugin auto-detects the binary, or you can set the path manually in settings. +- **An agent CLI** — Claude Code (`claude`) is the default. Any CLI agent that accepts a system prompt flag works. +- **macOS** — Terminal opening uses AppleScript (iTerm2 or Terminal.app). + +## Quick Start + +1. Create a task note with `task_id` in frontmatter: + +```yaml +--- +task_id: fix-auth-bug +working_dir: my-repo +--- + +## Objective + +Fix the authentication bug where sessions expire after 5 minutes. + +## Context + +The auth middleware is in `src/middleware/auth.ts`. The JWT expiry is hardcoded. +``` + +2. Run the command **Task dispatch: Launch or attach** (from the command palette). +3. A terminal window opens with the agent already running, pre-loaded with context from your note and all linked notes. + +## How It Works + +When you launch a task: + +1. **Context assembly** — The plugin reads the task note body (stripped of frontmatter), then gathers all linked notes via the same link traversal used by Distill. Everything is concatenated into a single context bundle. +2. **Temp file** — The context is written to a temp file (default: `/tmp/openaugi/task-{id}-context.md`). +3. **tmux session** — A new tmux session named `task-{id}` is created in the configured working directory. +4. **Agent launch** — The agent CLI is invoked with the context file, e.g.: + ``` + cd '/path/to/repo' && claude --append-system-prompt-file '/tmp/openaugi/task-fix-auth-bug-context.md' "..." + ``` +5. **Terminal opens** — iTerm2 or Terminal.app opens attached to the session. + +If the session already exists, the plugin just attaches to it (no duplicate sessions). + +## Task Note Frontmatter + +| Field | Required | Description | +|-------|----------|-------------| +| `task_id` | Yes | Unique identifier for the task. Also accepts `task-id`. | +| `working_dir` | No | Where the agent starts. Can be a **repo name** (mapped in settings), an **absolute path**, or a **vault-relative path**. Falls back to the default working directory setting. | + +### Working Directory Resolution + +The `working_dir` value is resolved in this order: + +1. **Named repo** — If it matches a name in your configured Repository Paths, the mapped absolute path is used. Case-insensitive. +2. **Absolute path** — If it starts with `/`, used as-is. +3. **Vault-relative path** — Otherwise, resolved against the vault root. +4. **Default** — If `working_dir` is absent, the Default Working Directory setting is used. +5. **Fallback** — If nothing is configured, falls back to `$HOME`. + +The directory is created automatically if it doesn't exist. + +### Example: Using Repo Names + +After configuring repository paths in settings: + +| Name | Path | +|------|------| +| my-app | /Users/chris/repos/my-app | +| api-server | /Users/chris/repos/api-server | +| infra | /Users/chris/repos/infrastructure | + +Your frontmatter just needs the short name: + +```yaml +--- +task_id: add-caching +working_dir: api-server +--- +``` + +## Commands + +| Command | What it does | +|---------|-------------| +| **Task dispatch: Launch or attach** | Creates a new session or attaches to an existing one for the current task note. | +| **Task dispatch: Kill session** | Kills the tmux session for the current task note and cleans up the temp context file. | +| **Task dispatch: List active sessions** | Opens a modal showing all active `task-*` tmux sessions with options to attach or kill each. | + +## Settings + +All settings are under **Settings > OpenAugi > Task Dispatch**. + +### Terminal Application + +Which app opens when attaching to a session. + +- **iTerm2** (default) +- **Terminal.app** + +### tmux Path + +Absolute path to the tmux binary. Leave blank to auto-detect (checks `/opt/homebrew/bin/tmux`, `/usr/local/bin/tmux`, `/usr/bin/tmux`, then `which tmux`). Use the **Detect** button to find it automatically. + +### Default Working Directory + +The directory the agent starts in when a task note doesn't specify `working_dir`. Can be vault-relative (e.g., `OpenAugi/Tasks`) or absolute. Default: `OpenAugi/Tasks`. + +### Repository Paths + +Map short names to absolute folder paths. Each entry has: + +- **Name** — The short name you'll use in frontmatter `working_dir` (e.g., `my-repo`). +- **Path** — The absolute filesystem path (e.g., `/Users/chris/repos/my-repo`). +- **Browse** — Opens the native folder picker to select a directory. Auto-fills the name from the folder basename if empty. + +### Default Agent + +Which agent CLI to use. Default: Claude Code (`claude`). + +### Max Context Characters + +Upper limit on the context bundle size. Content beyond this is truncated. Default: `200,000`. + +### Context Temp Directory + +Where temporary context files are written. Default: `/tmp/openaugi`. Files are cleaned up when sessions are killed. + +## Agent Configuration + +The default agent is Claude Code with `--append-system-prompt` as the context flag. The agent config has four fields: + +| Field | Default | Description | +|-------|---------|-------------| +| `id` | `claude-code` | Internal identifier. | +| `name` | `Claude Code` | Display name in settings. | +| `command` | `claude` | The CLI command to run. | +| `contextFlag` | `--append-system-prompt` | The CLI flag for injecting context. If the flag ends with `-file` (e.g., `--append-system-prompt-file`), the temp file path is passed directly. Otherwise, the file content is expanded inline via `$(cat ...)`. | + +## Tips + +- **Link related context** — Any notes linked from your task note (`[[Design Doc]]`, `[[API Spec]]`) are automatically included in the context bundle. Structure your task notes with relevant links. +- **Keep task IDs unique** — The tmux session name is derived from `task_id`. Duplicates will collide. +- **Session persistence** — tmux sessions survive Obsidian restarts. Use "List active sessions" to find and reattach to running agents. +- **MCP access** — The agent's context includes a note that the OpenAugi MCP server is available for vault searches, so agents can look up additional information beyond what's in the initial context. diff --git a/src/services/task-dispatch-service.ts b/src/services/task-dispatch-service.ts index e365191..c82a3b5 100644 --- a/src/services/task-dispatch-service.ts +++ b/src/services/task-dispatch-service.ts @@ -5,7 +5,7 @@ import * as fs from 'fs'; import * as path from 'path'; import { OpenAugiSettings } from '../types/settings'; import { DistillService } from './distill-service'; -import { AgentConfig, TaskSession } from '../types/task-dispatch'; +import { AgentConfig, RepoPath, TaskSession } from '../types/task-dispatch'; const execAsync = promisify(exec); @@ -41,6 +41,16 @@ export async function detectTmuxPath(): Promise { return null; } +/** + * Look up a working_dir value against the configured repo paths. + * Case-insensitive match. Returns the absolute path if found, or null. + */ +export function resolveRepoPath(name: string, repoPaths: RepoPath[]): string | null { + if (!repoPaths || repoPaths.length === 0) return null; + const match = repoPaths.find(rp => rp.name.toLowerCase() === name.toLowerCase()); + return match?.path ?? null; +} + export class TaskDispatchService { private app: App; private settings: OpenAugiSettings; @@ -236,9 +246,17 @@ export class TaskDispatchService { const cache = this.app.metadataCache.getFileCache(file); const fm = cache?.frontmatter; const workingDir = fm?.['working_dir'] || fm?.['working-dir']; - // Absolute paths from frontmatter are used as-is + if (workingDir && typeof workingDir === 'string') { - return path.isAbsolute(workingDir) ? workingDir : this.resolveVaultPath(workingDir); + // 1. Check if it matches a named repo path + const repoMatch = resolveRepoPath(workingDir, this.settings.taskDispatch.repoPaths); + if (repoMatch) return repoMatch; + + // 2. Absolute path — use as-is + if (path.isAbsolute(workingDir)) return workingDir; + + // 3. Relative path — resolve against vault root + return this.resolveVaultPath(workingDir); } const defaultDir = this.settings.taskDispatch.defaultWorkingDir; @@ -274,7 +292,7 @@ export class TaskDispatchService { context += `\n\n---\n\n## Linked Context\n${linkedContent}`; } - context += '\n\n---\n\n## Additional Context\n\nThe OpenAugi MCP server is available if you need to query for more context beyond what\'s provided here.'; + context += `\n\n---\n\n## Instructions\n\nTask file: ${file.path}\nTask ID: ${taskId}\n\nWork with the context above first. Only search the vault via MCP if needed.\n\nWhen you have results, write them back using:\n python main.py append-results --task-id ${taskId} --input results.json\n\nThe ## Results section of the task note is our shared communication channel.\nLink any files you create as [[wikilinks]] in your results.\n\nYou have MCP tools available for searching the user's Obsidian vault (semantic search, tag search, hub discovery, etc.). Use them to find related notes, look up referenced concepts, or gather additional context when the information above is insufficient.`; // Cap at max characters const maxChars = this.settings.taskDispatch.maxContextChars; @@ -344,7 +362,8 @@ export class TaskDispatchService { : `"$(cat ${this.shellEscape(contextFilePath)})"`; // Explicit cd ensures Claude picks up the correct workspace, even if the // user's shell profile overrides the tmux -c starting directory. - const agentCmd = `cd ${this.shellEscape(workingDir)} && ${agentConfig.command} ${agentConfig.contextFlag} ${contextArg} "Read the task above and begin. Summarize what you understand, then start working."`; + const prompt = 'Read your system prompt carefully. Summarize the task, outline your approach, then begin working.'; + const agentCmd = `cd ${this.shellEscape(workingDir)} && ${agentConfig.command} ${agentConfig.contextFlag} ${contextArg} "${prompt}"`; await execAsync( `${tmux} send-keys -t ${this.shellEscape(sessionName)} ${this.shellEscape(agentCmd)} Enter` ); diff --git a/src/types/settings.ts b/src/types/settings.ts index 4d8b56a..e2c42c4 100644 --- a/src/types/settings.ts +++ b/src/types/settings.ts @@ -70,6 +70,7 @@ export const DEFAULT_SETTINGS: OpenAugiSettings = { ], defaultAgent: 'claude-code', contextTempDir: '/tmp/openaugi', - maxContextChars: 200000 + maxContextChars: 200000, + repoPaths: [] } }; \ No newline at end of file diff --git a/src/types/task-dispatch.ts b/src/types/task-dispatch.ts index 4d71c6a..3817fd3 100644 --- a/src/types/task-dispatch.ts +++ b/src/types/task-dispatch.ts @@ -7,6 +7,11 @@ export interface AgentConfig { contextFlag: string; } +export interface RepoPath { + name: string; // short name used in frontmatter, e.g. "my-repo" + path: string; // absolute filesystem path, e.g. "/Users/chris/repos/my-repo" +} + export type TerminalApp = 'iterm2' | 'terminal-app'; export interface TaskDispatchSettings { @@ -17,6 +22,7 @@ export interface TaskDispatchSettings { defaultAgent: string; contextTempDir: string; maxContextChars: number; + repoPaths: RepoPath[]; } export interface TaskSession { diff --git a/src/ui/settings-tab.ts b/src/ui/settings-tab.ts index a8d8b1c..af6b7cd 100644 --- a/src/ui/settings-tab.ts +++ b/src/ui/settings-tab.ts @@ -416,6 +416,15 @@ export class OpenAugiSettingTab extends PluginSettingTab { return text; }); + // --- Repo Paths --- + containerEl.createEl('h4', { text: 'Repository Paths' }); + containerEl.createEl('p', { + text: 'Map short names to repo folders. Use the name in frontmatter working_dir instead of typing full paths.', + cls: 'setting-item-description' + }); + const repoPathsContainer = containerEl.createDiv('repo-paths-container'); + this.renderRepoPaths(repoPathsContainer); + new Setting(containerEl) .setName('Default agent') .setDesc('Agent to use when task note does not specify one') @@ -476,4 +485,121 @@ export class OpenAugiSettingTab extends PluginSettingTab { }) ); } -} \ No newline at end of file + + private renderRepoPaths(container: HTMLElement): void { + container.empty(); + const repoPaths = this.plugin.settings.taskDispatch.repoPaths; + + for (let i = 0; i < repoPaths.length; i++) { + const rp = repoPaths[i]; + + new Setting(container) + .addText(text => { + text + .setPlaceholder('Name (e.g. my-repo)') + .setValue(rp.name); + text.inputEl.style.width = '120px'; + text.inputEl.addEventListener('blur', async () => { + const value = text.getValue().trim(); + if (value !== this.plugin.settings.taskDispatch.repoPaths[i].name) { + this.plugin.settings.taskDispatch.repoPaths[i].name = value; + await this.plugin.saveSettings(); + } + }); + return text; + }) + .addText(text => { + text + .setPlaceholder('/absolute/path/to/repo') + .setValue(rp.path); + text.inputEl.style.width = '280px'; + text.inputEl.addEventListener('blur', async () => { + const value = text.getValue().trim(); + if (value !== this.plugin.settings.taskDispatch.repoPaths[i].path) { + this.plugin.settings.taskDispatch.repoPaths[i].path = value; + await this.plugin.saveSettings(); + } + }); + return text; + }) + .addExtraButton(button => button + .setIcon('folder-open') + .setTooltip('Browse') + .onClick(async () => { + const chosen = await this.pickFolder(); + if (chosen) { + this.plugin.settings.taskDispatch.repoPaths[i].path = chosen; + // Auto-fill name from folder basename if empty + if (!this.plugin.settings.taskDispatch.repoPaths[i].name) { + const basename = chosen.split('/').pop() || ''; + this.plugin.settings.taskDispatch.repoPaths[i].name = basename; + } + await this.plugin.saveSettings(); + this.renderRepoPaths(container); + } + }) + ) + .addExtraButton(button => button + .setIcon('trash') + .setTooltip('Remove') + .onClick(async () => { + this.plugin.settings.taskDispatch.repoPaths.splice(i, 1); + await this.plugin.saveSettings(); + this.renderRepoPaths(container); + }) + ); + } + + new Setting(container) + .addButton(button => button + .setButtonText('Add repo path') + .onClick(async () => { + this.plugin.settings.taskDispatch.repoPaths.push({ name: '', path: '' }); + await this.plugin.saveSettings(); + this.renderRepoPaths(container); + }) + ); + } + + /** + * Open the native OS folder picker via Electron's dialog API. + * Tries multiple Electron require paths for compatibility across Obsidian versions. + * Returns the selected path, or null if cancelled. + */ + private async pickFolder(): Promise { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let dialog: any = null; + + try { + // Modern Obsidian / Electron: @electron/remote + dialog = require('@electron/remote')?.dialog; + } catch { /* not available */ } + + if (!dialog) { + try { + // Older Electron: remote on the electron module + // eslint-disable-next-line @typescript-eslint/no-var-requires + const electron = require('electron'); + dialog = (electron as any).remote?.dialog; + } catch { /* not available */ } + } + + if (!dialog) { + new Notice('Folder picker not available — type the path manually.'); + return null; + } + + try { + const result = await dialog.showOpenDialog({ + properties: ['openDirectory'], + title: 'Select repository folder' + }); + if (!result.canceled && result.filePaths.length > 0) { + return result.filePaths[0]; + } + } catch { + new Notice('Folder picker failed — type the path manually.'); + } + return null; + } +} \ No newline at end of file diff --git a/tests/mocks/obsidian-module.ts b/tests/mocks/obsidian-module.ts index 818641d..51381bc 100644 --- a/tests/mocks/obsidian-module.ts +++ b/tests/mocks/obsidian-module.ts @@ -66,5 +66,11 @@ export class Notice { } } +export class FileSystemAdapter { + private basePath: string; + constructor(basePath?: string) { this.basePath = basePath || ''; } + getBasePath(): string { return this.basePath; } +} + export class MarkdownView {} export class WorkspaceLeaf {} diff --git a/tests/task-dispatch-service.test.ts b/tests/task-dispatch-service.test.ts new file mode 100644 index 0000000..5ff531d --- /dev/null +++ b/tests/task-dispatch-service.test.ts @@ -0,0 +1,676 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; +import { resolveRepoPath, TaskDispatchService } from '../src/services/task-dispatch-service'; +import { DEFAULT_SETTINGS, OpenAugiSettings } from '../src/types/settings'; +import { RepoPath } from '../src/types/task-dispatch'; +import { TFile } from './mocks/obsidian-module'; + +// ─── Mock child_process.exec to avoid real tmux/osascript calls ────────────── + +const mockExecAsync = vi.fn(); +vi.mock('child_process', () => ({ + exec: (...args: any[]) => { + // promisify(exec) wraps exec into a function that returns a promise. + // We intercept the callback-style call and delegate to mockExecAsync. + const cb = args[args.length - 1]; + if (typeof cb === 'function') { + mockExecAsync(args[0], args[1]) + .then((result: any) => cb(null, result)) + .catch((err: any) => cb(err)); + } + }, +})); + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +function createMockApp(vaultBasePath: string) { + return { + vault: { + adapter: { getBasePath: () => vaultBasePath }, + read: vi.fn(), + getMarkdownFiles: vi.fn().mockReturnValue([]), + }, + metadataCache: { + getFileCache: vi.fn(), + }, + }; +} + +function createMockDistillService() { + return { + getLinkedNotes: vi.fn().mockResolvedValue([]), + aggregateContent: vi.fn().mockResolvedValue({ content: '', sourceNotes: [] }), + }; +} + +function makeSettings(overrides: Partial = {}): OpenAugiSettings { + return { + ...DEFAULT_SETTINGS, + taskDispatch: { + ...DEFAULT_SETTINGS.taskDispatch, + tmuxPath: '/usr/bin/tmux', + ...overrides, + }, + }; +} + +function makeTFile(relativePath: string, frontmatter?: Record): { file: TFile; frontmatter: Record | undefined } { + const file = new TFile(relativePath); + return { file, frontmatter }; +} + +// ─── Tests ─────────────────────────────────────────────────────────────────── + +describe('resolveRepoPath (pure function)', () => { + const repoPaths: RepoPath[] = [ + { name: 'my-repo', path: '/Users/chris/repos/my-repo' }, + { name: 'Other Project', path: '/opt/projects/other' }, + ]; + + it('matches by name (case-insensitive)', () => { + expect(resolveRepoPath('my-repo', repoPaths)).toBe('/Users/chris/repos/my-repo'); + expect(resolveRepoPath('MY-REPO', repoPaths)).toBe('/Users/chris/repos/my-repo'); + expect(resolveRepoPath('My-Repo', repoPaths)).toBe('/Users/chris/repos/my-repo'); + }); + + it('returns null for unknown names', () => { + expect(resolveRepoPath('nonexistent', repoPaths)).toBeNull(); + }); + + it('returns null for empty repo paths', () => { + expect(resolveRepoPath('anything', [])).toBeNull(); + }); + + it('returns null for null/undefined repo paths', () => { + expect(resolveRepoPath('anything', null as any)).toBeNull(); + expect(resolveRepoPath('anything', undefined as any)).toBeNull(); + }); +}); + +describe('TaskDispatchService', () => { + let app: ReturnType; + let distillService: ReturnType; + let settings: OpenAugiSettings; + let service: TaskDispatchService; + + beforeEach(() => { + vi.clearAllMocks(); + app = createMockApp('/Users/chris/vault'); + distillService = createMockDistillService(); + settings = makeSettings(); + service = new TaskDispatchService(app as any, settings, distillService as any); + }); + + // ─── shellEscape ───────────────────────────────────────────────────────── + + describe('shellEscape', () => { + const escape = (str: string) => (service as any).shellEscape(str); + + it('wraps in single quotes', () => { + expect(escape('hello')).toBe("'hello'"); + }); + + it('escapes single quotes in the string', () => { + expect(escape("it's")).toBe("'it'\\''s'"); + }); + + it('handles empty string', () => { + expect(escape('')).toBe("''"); + }); + + it('handles strings with spaces and special chars', () => { + expect(escape('hello world $HOME')).toBe("'hello world $HOME'"); + }); + + it('handles multiple single quotes', () => { + expect(escape("a'b'c")).toBe("'a'\\''b'\\''c'"); + }); + }); + + // ─── getTaskId ─────────────────────────────────────────────────────────── + + describe('getTaskId', () => { + const getTaskId = (file: TFile) => (service as any).getTaskId(file); + + it('reads task_id from frontmatter', () => { + const file = new TFile('Notes/Task.md'); + app.metadataCache.getFileCache.mockReturnValue({ + frontmatter: { task_id: 'abc-123' }, + }); + expect(getTaskId(file)).toBe('abc-123'); + }); + + it('reads task-id (hyphenated) from frontmatter', () => { + const file = new TFile('Notes/Task.md'); + app.metadataCache.getFileCache.mockReturnValue({ + frontmatter: { 'task-id': 'def-456' }, + }); + expect(getTaskId(file)).toBe('def-456'); + }); + + it('returns null when no frontmatter', () => { + const file = new TFile('Notes/Task.md'); + app.metadataCache.getFileCache.mockReturnValue(null); + expect(getTaskId(file)).toBeNull(); + }); + + it('returns null when frontmatter has no task_id', () => { + const file = new TFile('Notes/Task.md'); + app.metadataCache.getFileCache.mockReturnValue({ + frontmatter: { title: 'Hello' }, + }); + expect(getTaskId(file)).toBeNull(); + }); + + it('converts numeric task_id to string', () => { + const file = new TFile('Notes/Task.md'); + app.metadataCache.getFileCache.mockReturnValue({ + frontmatter: { task_id: 42 }, + }); + expect(getTaskId(file)).toBe('42'); + }); + }); + + // ─── getWorkingDir ─────────────────────────────────────────────────────── + + describe('getWorkingDir', () => { + const getWorkingDir = (file: TFile) => (service as any).getWorkingDir(file); + + it('uses working_dir frontmatter as absolute path', () => { + const file = new TFile('Notes/Task.md'); + app.metadataCache.getFileCache.mockReturnValue({ + frontmatter: { working_dir: '/absolute/path' }, + }); + expect(getWorkingDir(file)).toBe('/absolute/path'); + }); + + it('uses working-dir (hyphenated) frontmatter', () => { + const file = new TFile('Notes/Task.md'); + app.metadataCache.getFileCache.mockReturnValue({ + frontmatter: { 'working-dir': '/other/path' }, + }); + expect(getWorkingDir(file)).toBe('/other/path'); + }); + + it('resolves named repo path from frontmatter', () => { + settings.taskDispatch.repoPaths = [ + { name: 'my-repo', path: '/Users/chris/repos/my-repo' }, + ]; + service = new TaskDispatchService(app as any, settings, distillService as any); + + const file = new TFile('Notes/Task.md'); + app.metadataCache.getFileCache.mockReturnValue({ + frontmatter: { working_dir: 'my-repo' }, + }); + expect(getWorkingDir(file)).toBe('/Users/chris/repos/my-repo'); + }); + + it('resolves relative path against vault root', () => { + const file = new TFile('Notes/Task.md'); + app.metadataCache.getFileCache.mockReturnValue({ + frontmatter: { working_dir: 'projects/foo' }, + }); + expect(getWorkingDir(file)).toBe('/Users/chris/vault/projects/foo'); + }); + + it('falls back to defaultWorkingDir setting', () => { + const file = new TFile('Notes/Task.md'); + app.metadataCache.getFileCache.mockReturnValue({ frontmatter: {} }); + settings.taskDispatch.defaultWorkingDir = '/default/dir'; + service = new TaskDispatchService(app as any, settings, distillService as any); + + expect(getWorkingDir(file)).toBe('/default/dir'); + }); + + it('resolves relative defaultWorkingDir against vault root', () => { + const file = new TFile('Notes/Task.md'); + app.metadataCache.getFileCache.mockReturnValue({ frontmatter: {} }); + settings.taskDispatch.defaultWorkingDir = 'OpenAugi/Tasks'; + service = new TaskDispatchService(app as any, settings, distillService as any); + + expect(getWorkingDir(file)).toBe('/Users/chris/vault/OpenAugi/Tasks'); + }); + + it('falls back to HOME when no working dir configured', () => { + const file = new TFile('Notes/Task.md'); + app.metadataCache.getFileCache.mockReturnValue({ frontmatter: {} }); + settings.taskDispatch.defaultWorkingDir = ''; + service = new TaskDispatchService(app as any, settings, distillService as any); + + expect(getWorkingDir(file)).toBe(process.env.HOME); + }); + }); + + // ─── getAgentConfig ────────────────────────────────────────────────────── + + describe('getAgentConfig', () => { + const getAgentConfig = () => (service as any).getAgentConfig(); + + it('returns the configured default agent', () => { + const config = getAgentConfig(); + expect(config.id).toBe('claude-code'); + expect(config.command).toBe('claude'); + }); + + it('falls back to first agent if default not found', () => { + settings.taskDispatch.defaultAgent = 'nonexistent'; + service = new TaskDispatchService(app as any, settings, distillService as any); + + const config = getAgentConfig(); + expect(config.id).toBe('claude-code'); + }); + }); + + // ─── assembleContext ───────────────────────────────────────────────────── + + describe('assembleContext', () => { + it('includes task ID, note body, and instructions', async () => { + const file = new TFile('Notes/Task 1.md'); + app.vault.read.mockResolvedValue('---\ntask_id: test-1\n---\n\nDo the thing.\n\n## Details\nMore info.'); + distillService.getLinkedNotes.mockResolvedValue([]); + + const context = await (service as any).assembleContext(file, 'test-1'); + + expect(context).toContain('# Task: test-1'); + expect(context).toContain('Do the thing.'); + expect(context).toContain('## Details'); + expect(context).toContain('More info.'); + // Instructions section + expect(context).toContain('## Instructions'); + expect(context).toContain('Task file: Notes/Task 1.md'); + expect(context).toContain('Task ID: test-1'); + expect(context).toContain('python main.py append-results --task-id test-1'); + expect(context).toContain('## Results section'); + expect(context).toContain('[[wikilinks]]'); + }); + + it('strips frontmatter from note body', async () => { + const file = new TFile('Notes/Task.md'); + app.vault.read.mockResolvedValue('---\ntask_id: t1\nworking_dir: /foo\n---\n\nBody text.'); + + const context = await (service as any).assembleContext(file, 't1'); + + expect(context).not.toContain('task_id: t1'); + expect(context).not.toContain('working_dir: /foo'); + expect(context).toContain('Body text.'); + }); + + it('includes linked context when present', async () => { + const file = new TFile('Notes/Task.md'); + app.vault.read.mockResolvedValue('---\ntask_id: t1\n---\n\nMain body.'); + + const linkedFile = new TFile('Notes/Reference.md'); + distillService.getLinkedNotes.mockResolvedValue([linkedFile]); + distillService.aggregateContent.mockResolvedValue({ + content: '# Note: Reference\n\nLinked content here.', + sourceNotes: ['Reference'], + }); + + const context = await (service as any).assembleContext(file, 't1'); + + expect(context).toContain('## Linked Context'); + expect(context).toContain('Linked content here.'); + }); + + it('omits linked context section when no linked notes', async () => { + const file = new TFile('Notes/Task.md'); + app.vault.read.mockResolvedValue('---\ntask_id: t1\n---\n\nBody.'); + distillService.getLinkedNotes.mockResolvedValue([]); + + const context = await (service as any).assembleContext(file, 't1'); + + expect(context).not.toContain('## Linked Context'); + }); + + it('truncates context at maxContextChars', async () => { + const file = new TFile('Notes/Task.md'); + const longBody = 'A'.repeat(500); + app.vault.read.mockResolvedValue(`---\ntask_id: t1\n---\n\n${longBody}`); + distillService.getLinkedNotes.mockResolvedValue([]); + + settings.taskDispatch.maxContextChars = 100; + service = new TaskDispatchService(app as any, settings, distillService as any); + + const context = await (service as any).assembleContext(file, 't1'); + + expect(context.length).toBeLessThanOrEqual(100 + 50); // 100 + truncation message + expect(context).toContain('...(context truncated at character limit)'); + }); + }); + + // ─── writeContextFile / cleanupContextFile ─────────────────────────────── + + describe('writeContextFile and cleanupContextFile', () => { + const tmpDir = path.join('/tmp', `openaugi-test-${process.pid}`); + + beforeEach(() => { + settings.taskDispatch.contextTempDir = tmpDir; + service = new TaskDispatchService(app as any, settings, distillService as any); + }); + + afterEach(() => { + try { fs.rmSync(tmpDir, { recursive: true }); } catch { /* ok */ } + }); + + it('writes context to file and returns path', async () => { + const filePath = await (service as any).writeContextFile('abc', 'hello world'); + + expect(filePath).toBe(path.join(tmpDir, 'task-abc-context.md')); + expect(fs.existsSync(filePath)).toBe(true); + expect(fs.readFileSync(filePath, 'utf-8')).toBe('hello world'); + }); + + it('creates directory if it does not exist', async () => { + const deepDir = path.join(tmpDir, 'nested', 'dir'); + settings.taskDispatch.contextTempDir = deepDir; + service = new TaskDispatchService(app as any, settings, distillService as any); + + const filePath = await (service as any).writeContextFile('xyz', 'content'); + expect(fs.existsSync(filePath)).toBe(true); + + // cleanup + fs.rmSync(deepDir, { recursive: true }); + }); + + it('cleans up context file', async () => { + await (service as any).writeContextFile('cleanup-test', 'data'); + const filePath = path.join(tmpDir, 'task-cleanup-test-context.md'); + expect(fs.existsSync(filePath)).toBe(true); + + (service as any).cleanupContextFile('cleanup-test'); + expect(fs.existsSync(filePath)).toBe(false); + }); + + it('cleanupContextFile does not throw for missing file', () => { + expect(() => (service as any).cleanupContextFile('nonexistent')).not.toThrow(); + }); + }); + + // ─── tmuxSessionExists ────────────────────────────────────────────────── + + describe('tmuxSessionExists', () => { + it('returns true when tmux has-session succeeds', async () => { + mockExecAsync.mockResolvedValueOnce({ stdout: '', stderr: '' }); + const result = await (service as any).tmuxSessionExists('/usr/bin/tmux', 'task-abc'); + expect(result).toBe(true); + }); + + it('returns false when tmux has-session fails', async () => { + mockExecAsync.mockRejectedValueOnce(new Error('no session')); + const result = await (service as any).tmuxSessionExists('/usr/bin/tmux', 'task-abc'); + expect(result).toBe(false); + }); + }); + + // ─── createTmuxSession ────────────────────────────────────────────────── + + describe('createTmuxSession', () => { + beforeEach(() => { + // Mock fs.promises.mkdir + vi.spyOn(fs.promises, 'mkdir').mockResolvedValue(undefined as any); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('creates tmux session then sends agent command', async () => { + // new-session + mockExecAsync.mockResolvedValueOnce({ stdout: '', stderr: '' }); + // capture-pane for waitForShellReady + mockExecAsync.mockResolvedValueOnce({ stdout: '$ ', stderr: '' }); + // send-keys + mockExecAsync.mockResolvedValueOnce({ stdout: '', stderr: '' }); + + const agentConfig = { + id: 'claude-code', + name: 'Claude Code', + command: 'claude', + contextFlag: '--append-system-prompt', + }; + + await (service as any).createTmuxSession( + '/usr/bin/tmux', 'task-test', agentConfig, '/tmp/ctx.md', '/home/user/project' + ); + + // Verify new-session was called + const calls = mockExecAsync.mock.calls.map((c: any[]) => c[0] as string); + expect(calls.some(c => c.includes('new-session -d -s'))).toBe(true); + + // Verify send-keys was called with the agent command + const sendKeysCmd = calls.find(c => c.includes('send-keys')); + expect(sendKeysCmd).toBeTruthy(); + expect(sendKeysCmd).toContain('Enter'); + }); + + it('uses contextFlag -file variant to pass path directly', async () => { + mockExecAsync.mockResolvedValueOnce({ stdout: '', stderr: '' }); + mockExecAsync.mockResolvedValueOnce({ stdout: '$ ', stderr: '' }); + mockExecAsync.mockResolvedValueOnce({ stdout: '', stderr: '' }); + + const agentConfig = { + id: 'claude-code', + name: 'Claude Code', + command: 'claude', + contextFlag: '--append-system-prompt-file', + }; + + await (service as any).createTmuxSession( + '/usr/bin/tmux', 'task-test', agentConfig, '/tmp/ctx.md', '/home/user/project' + ); + + // The send-keys call should contain the file path directly (not $(cat ...)) + const sendKeysCall = mockExecAsync.mock.calls.find( + (c: any[]) => typeof c[0] === 'string' && c[0].includes('send-keys') + ); + const cmd = sendKeysCall![0] as string; + expect(cmd).not.toContain('$(cat'); + }); + + it('uses $(cat ...) for non-file context flag', async () => { + mockExecAsync.mockResolvedValueOnce({ stdout: '', stderr: '' }); + mockExecAsync.mockResolvedValueOnce({ stdout: '$ ', stderr: '' }); + mockExecAsync.mockResolvedValueOnce({ stdout: '', stderr: '' }); + + const agentConfig = { + id: 'test', + name: 'Test', + command: 'agent', + contextFlag: '--system-prompt', + }; + + await (service as any).createTmuxSession( + '/usr/bin/tmux', 'task-test', agentConfig, '/tmp/ctx.md', '/home/user/project' + ); + + const sendKeysCall = mockExecAsync.mock.calls.find( + (c: any[]) => typeof c[0] === 'string' && c[0].includes('send-keys') + ); + const cmd = sendKeysCall![0] as string; + expect(cmd).toContain('$(cat'); + }); + }); + + // ─── listActiveSessions ────────────────────────────────────────────────── + + describe('listActiveSessions', () => { + it('parses tmux list-sessions output', async () => { + const timestamp = Math.floor(Date.now() / 1000); + mockExecAsync.mockResolvedValueOnce({ + stdout: `task-abc ${timestamp}\ntask-def ${timestamp}\nnon-task-session ${timestamp}\n`, + stderr: '', + }); + + const sessions = await service.listActiveSessions(); + + expect(sessions).toHaveLength(2); + expect(sessions[0].taskId).toBe('abc'); + expect(sessions[0].tmuxSessionName).toBe('task-abc'); + expect(sessions[1].taskId).toBe('def'); + }); + + it('filters out non-task sessions', async () => { + mockExecAsync.mockResolvedValueOnce({ + stdout: 'my-other-session 1234567890\n', + stderr: '', + }); + + const sessions = await service.listActiveSessions(); + expect(sessions).toHaveLength(0); + }); + + it('returns empty array when tmux is not running', async () => { + mockExecAsync.mockRejectedValueOnce(new Error('no server running')); + + const sessions = await service.listActiveSessions(); + expect(sessions).toHaveLength(0); + }); + }); + + // ─── launchOrAttach ────────────────────────────────────────────────────── + + describe('launchOrAttach', () => { + beforeEach(() => { + vi.spyOn(fs.promises, 'mkdir').mockResolvedValue(undefined as any); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('does nothing for notes without task_id', async () => { + const file = new TFile('Notes/Regular.md'); + app.metadataCache.getFileCache.mockReturnValue({ frontmatter: {} }); + + await service.launchOrAttach(file as any); + + expect(mockExecAsync).not.toHaveBeenCalled(); + }); + + it('attaches to existing session', async () => { + const file = new TFile('Notes/Task.md'); + app.metadataCache.getFileCache.mockReturnValue({ + frontmatter: { task_id: 'existing' }, + }); + + // has-session succeeds (session exists) + mockExecAsync.mockResolvedValueOnce({ stdout: '', stderr: '' }); + // osascript for openTerminal + mockExecAsync.mockResolvedValueOnce({ stdout: '', stderr: '' }); + + await service.launchOrAttach(file as any); + + // Should NOT call new-session + const calls = mockExecAsync.mock.calls.map((c: any[]) => c[0]); + expect(calls.some((c: string) => c.includes('new-session'))).toBe(false); + // Should call osascript to attach + expect(calls.some((c: string) => c.includes('osascript'))).toBe(true); + }); + + it('creates new session for fresh task', async () => { + const file = new TFile('Notes/Task.md'); + app.metadataCache.getFileCache.mockReturnValue({ + frontmatter: { task_id: 'fresh' }, + }); + app.vault.read.mockResolvedValue('---\ntask_id: fresh\n---\n\nDo stuff.'); + + // Ensure contextTempDir exists for writeContextFile + const tmpDir = path.join('/tmp', `openaugi-launch-test-${process.pid}`); + settings.taskDispatch.contextTempDir = tmpDir; + service = new TaskDispatchService(app as any, settings, distillService as any); + + // has-session fails (no existing session) + mockExecAsync.mockRejectedValueOnce(new Error('no session')); + // new-session + mockExecAsync.mockResolvedValueOnce({ stdout: '', stderr: '' }); + // capture-pane (waitForShellReady) + mockExecAsync.mockResolvedValueOnce({ stdout: '$ ', stderr: '' }); + // send-keys + mockExecAsync.mockResolvedValueOnce({ stdout: '', stderr: '' }); + // osascript (openTerminal) + mockExecAsync.mockResolvedValueOnce({ stdout: '', stderr: '' }); + + await service.launchOrAttach(file as any); + + const calls = mockExecAsync.mock.calls.map((c: any[]) => c[0]); + expect(calls.some((c: string) => c.includes('new-session'))).toBe(true); + + // cleanup + try { fs.rmSync(tmpDir, { recursive: true }); } catch { /* ok */ } + }); + }); + + // ─── killSession ───────────────────────────────────────────────────────── + + describe('killSession', () => { + it('does nothing for notes without task_id', async () => { + const file = new TFile('Notes/Regular.md'); + app.metadataCache.getFileCache.mockReturnValue({ frontmatter: {} }); + + await service.killSession(file as any); + expect(mockExecAsync).not.toHaveBeenCalled(); + }); + + it('kills existing session and cleans up context file', async () => { + const file = new TFile('Notes/Task.md'); + app.metadataCache.getFileCache.mockReturnValue({ + frontmatter: { task_id: 'kill-me' }, + }); + + // has-session succeeds + mockExecAsync.mockResolvedValueOnce({ stdout: '', stderr: '' }); + // kill-session + mockExecAsync.mockResolvedValueOnce({ stdout: '', stderr: '' }); + + await service.killSession(file as any); + + const calls = mockExecAsync.mock.calls.map((c: any[]) => c[0]); + expect(calls.some((c: string) => c.includes('kill-session'))).toBe(true); + }); + + it('notifies when no active session found', async () => { + const file = new TFile('Notes/Task.md'); + app.metadataCache.getFileCache.mockReturnValue({ + frontmatter: { task_id: 'no-session' }, + }); + + // has-session fails + mockExecAsync.mockRejectedValueOnce(new Error('no session')); + + // Should not throw + await service.killSession(file as any); + + // Should NOT call kill-session + const calls = mockExecAsync.mock.calls.map((c: any[]) => c[0]); + expect(calls.some((c: string) => c.includes('kill-session'))).toBe(false); + }); + }); + + // ─── openTerminal ──────────────────────────────────────────────────────── + + describe('openTerminal', () => { + it('opens iTerm2 when configured', async () => { + settings.taskDispatch.terminalApp = 'iterm2'; + service = new TaskDispatchService(app as any, settings, distillService as any); + + mockExecAsync.mockResolvedValueOnce({ stdout: '', stderr: '' }); + await service.openTerminal('task-test'); + + const cmd = mockExecAsync.mock.calls[0][0] as string; + expect(cmd).toContain('iTerm2'); + }); + + it('opens Terminal.app when configured', async () => { + settings.taskDispatch.terminalApp = 'terminal-app'; + service = new TaskDispatchService(app as any, settings, distillService as any); + + mockExecAsync.mockResolvedValueOnce({ stdout: '', stderr: '' }); + await service.openTerminal('task-test'); + + const cmd = mockExecAsync.mock.calls[0][0] as string; + expect(cmd).toContain('Terminal'); + expect(cmd).not.toContain('iTerm2'); + }); + }); +});