From 7660c798b5a64cada43aea93545745c8f2ae1c3b Mon Sep 17 00:00:00 2001 From: Chris Lettieri Date: Tue, 24 Feb 2026 21:05:29 -0500 Subject: [PATCH] task dispatch --- CLAUDE.md | 14 +- PLAN.md | 119 +++++++++++ docs/CODEBASE_MAP.md | 45 +++- manifest.json | 4 +- package.json | 2 +- src/main.ts | 63 ++++++ src/services/task-dispatch-service.ts | 286 ++++++++++++++++++++++++++ src/types/plugin.ts | 2 + src/types/settings.ts | 16 ++ src/types/task-dispatch.ts | 25 +++ src/ui/session-list-modal.ts | 82 ++++++++ src/ui/settings-tab.ts | 63 ++++++ 12 files changed, 713 insertions(+), 8 deletions(-) create mode 100644 PLAN.md create mode 100644 src/services/task-dispatch-service.ts create mode 100644 src/types/task-dispatch.ts create mode 100644 src/ui/session-list-modal.ts diff --git a/CLAUDE.md b/CLAUDE.md index fc12c8d..740b9ef 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -49,17 +49,23 @@ Read the docs/CODEBASE_MAP.md to understand the project at a high level. Be sure ## Development Guidelines ### Build Commands + +**Important:** `npm` is not on the default PATH in this environment. Source nvm first: +```bash +export PATH="$HOME/.nvm/versions/node/$(ls $HOME/.nvm/versions/node/ | head -1)/bin:$PATH" +``` + +Then run commands as normal: ```bash # Development build with hot reload npm run dev -# Production build +# Production build (includes typecheck) npm run build - -# Type checking -npm run typecheck ``` +There is no standalone `typecheck` script — `npm run build` runs `tsc -noEmit -skipLibCheck` before bundling. + ### Code Standards - TypeScript with strict mode enabled - ESLint configuration for code quality diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..b5d9f84 --- /dev/null +++ b/PLAN.md @@ -0,0 +1,119 @@ +# Plan: Automated Testing for OpenAugi Plugin + +## Problem + +Currently all testing is manual: open Obsidian, create notes, run commands, inspect output. This means: +- Claude Code can't verify changes without you manually testing +- No regression safety net +- Slow iteration cycle + +## Analysis: Obsidian API Coupling + +After reviewing every service, here's the coupling breakdown: + +| Layer | Services | Obsidian APIs Used | +|-------|----------|--------------------| +| **Pure logic** (no Obsidian) | `OpenAIService`, `sanitizeFilename`, `BacklinkMapper`, `estimateTokens` | None - just `fetch` and string ops | +| **Light coupling** | `FileService` | `Vault.adapter.exists`, `Vault.createFolder`, `Vault.create` | +| **Heavy coupling** | `DistillService`, `ContextGatheringService` | `MetadataCache`, `vault.read`, `vault.getMarkdownFiles`, link resolution, Dataview API | + +## Strategy: Mock Obsidian, Test Against Real Files + +We create a **mock Obsidian API** backed by Node.js `fs`, pointed at a **test vault** with fixture markdown files. This lets us test ~80% of logic without Obsidian running. + +No E2E/Electron testing for now — that's complex, fragile, and low-value compared to service-level testing. + +## What We'll Build + +### 1. Add Vitest (test framework) + +- Install `vitest` as dev dependency +- Add `npm test` and `npm run test:watch` scripts +- Configure to handle TypeScript, mock the `obsidian` module globally + +### 2. Create mock Obsidian API (`tests/mocks/obsidian-mock.ts`) + +A filesystem-backed mock that simulates: +- **`TFile`** — wraps real files with `path`, `basename`, `extension`, `stat` +- **`Vault`** — `read()`, `create()`, `getMarkdownFiles()`, `adapter.exists()`, `createFolder()`, `getAbstractFileByPath()` — all backed by Node.js `fs` +- **`MetadataCache`** — parses `[[wikilinks]]` from markdown files to build `resolvedLinks`, `getFileCache()`, and `getFirstLinkpathDest()` +- **`App`** — ties vault + metadataCache together +- **`Notice`** — no-op (just logs) + +This is ~150 lines of code and gives us the ability to test DistillService, ContextGatheringService, and FileService against real markdown files. + +### 3. Create test vault with fixtures (`tests/vault/`) + +A small set of markdown files committed to the repo that cover key scenarios: + +``` +tests/vault/ +├── Root Note.md # Has [[links]] to other notes +├── Linked Note A.md # Forward-linked from root +├── Linked Note B.md # Forward-linked from root +├── Backlink Source.md # Links back to Root Note +├── Journal Note.md # Has ### 2026-02-20 date headers +├── Dataview Note.md # Has ```dataview blocks +├── Collection Note.md # Has - [x] [[checked]] checkboxes +├── Context Note.md # Has context: section +├── Special Characters!.md # Tests filename sanitization +├── Deeply Linked/ +│ └── Deep Note.md # Tests depth traversal +└── Excluded Folder/ + └── Should Skip.md # Tests folder exclusion +``` + +### 4. Write tests + +**Pure unit tests** (no mocks needed): +- `filename-utils.test.ts` — `sanitizeFilename`, `BacklinkMapper.processBacklinks` +- `openai-service.test.ts` — prompt construction, `extractCustomContext`, response parsing (mock `fetch`) + +**Integration tests** (with mock Obsidian): +- `distill-service.test.ts` — link extraction, backlink discovery, content aggregation, journal filtering, date parsing +- `context-gathering-service.test.ts` — BFS traversal, character limits, folder exclusion, backlink snippets +- `file-service.test.ts` — file creation, collision handling, session folders, backlink mapping + +### 5. npm scripts + +```json +{ + "test": "vitest run", + "test:watch": "vitest", + "test:coverage": "vitest run --coverage" +} +``` + +## What This Enables + +After setup, Claude Code can: +1. Make a code change +2. Run `npm test` +3. See if anything broke +4. Add new test cases for edge cases discovered during development + +You can also add test notes to `tests/vault/` to capture edge cases you find during manual testing — they become permanent regression tests. + +## File Changes Summary + +| Action | File | +|--------|------| +| Install | `vitest` dev dependency | +| Create | `vitest.config.ts` — test config | +| Create | `tests/mocks/obsidian-mock.ts` — mock Obsidian API | +| Create | `tests/vault/*.md` — ~10 fixture notes | +| Create | `tests/filename-utils.test.ts` | +| Create | `tests/openai-service.test.ts` | +| Create | `tests/distill-service.test.ts` | +| Create | `tests/file-service.test.ts` | +| Create | `tests/context-gathering-service.test.ts` | +| Update | `package.json` — add test scripts + vitest dep | +| Update | `tsconfig.json` — exclude tests from build | +| Update | `.gitignore` — add `tests/vault-output/` for test artifacts | + +## Not in Scope (for now) + +- **E2E testing with real Obsidian** — requires Playwright + Electron orchestration, fragile +- **OpenAI API integration tests** — would cost real API credits; we mock `fetch` instead +- **UI modal testing** — Obsidian modals are tightly coupled to the DOM +- **Task dispatch testing** — requires tmux, hard to automate in CI diff --git a/docs/CODEBASE_MAP.md b/docs/CODEBASE_MAP.md index d0e3499..63af2d0 100644 --- a/docs/CODEBASE_MAP.md +++ b/docs/CODEBASE_MAP.md @@ -13,7 +13,10 @@ A comprehensive reference for navigating and extending this Obsidian plugin. | Link traversal & aggregation | [services/distill-service.ts](../src/services/distill-service.ts) | | Unified context discovery | [services/context-gathering-service.ts](../src/services/context-gathering-service.ts) | | Settings UI | [ui/settings-tab.ts](../src/ui/settings-tab.ts) | +| Task dispatch service | [services/task-dispatch-service.ts](../src/services/task-dispatch-service.ts) | +| Task dispatch types | [types/task-dispatch.ts](../src/types/task-dispatch.ts) | | Context modals | [ui/context-gathering-modal.ts](../src/ui/context-gathering-modal.ts), [ui/context-selection-modal.ts](../src/ui/context-selection-modal.ts), [ui/context-preview-modal.ts](../src/ui/context-preview-modal.ts) | +| Session list modal | [ui/session-list-modal.ts](../src/ui/session-list-modal.ts) | --- @@ -31,7 +34,14 @@ src/ │ ├── openai-service.ts # OpenAI API calls │ ├── file-service.ts # File creation & output │ ├── distill-service.ts # Content aggregation, link traversal -│ └── context-gathering-service.ts # Unified discovery orchestration +│ ├── context-gathering-service.ts # Unified discovery orchestration +│ └── task-dispatch-service.ts # tmux session & agent dispatch +├── types/ +│ ├── plugin.ts # Plugin interface +│ ├── settings.ts # Settings interfaces & defaults +│ ├── context.ts # Context gathering types +│ ├── transcript.ts # API response types +│ └── task-dispatch.ts # Task dispatch types (agents, sessions, frontmatter) ├── ui/ │ ├── settings-tab.ts # Settings panel │ ├── loading-indicator.ts # Status bar spinner @@ -39,6 +49,7 @@ src/ │ ├── context-selection-modal.ts # Stage 2: Checkbox selection │ ├── context-preview-modal.ts # Stage 3: Preview & action │ ├── prompt-selection-modal.ts # Custom prompt picker +│ ├── session-list-modal.ts # Task dispatch session list │ └── recent-activity-modal.ts # (Legacy) └── utils/ └── filename-utils.ts # Sanitization, backlink mapping @@ -148,6 +159,35 @@ gatherContext(config: ContextGatheringConfig): Promise --- +### TaskDispatchService (`task-dispatch-service.ts`) + +Manages tmux-based agent sessions dispatched from task notes. + +**Key Methods:** +- `launchOrAttach(file)` - If tmux session exists, attach; otherwise assemble context, create session, start agent +- `killSession(file)` - Kill tmux session for the given task note, update frontmatter +- `listActiveSessions()` - List all `task-*` tmux sessions +- `killSessionById(taskId)` - Kill session by ID (from session list modal) +- `openTerminal(sessionName)` - Open terminal app attached to tmux session + +**Context Assembly:** +- Reads task note body (strips frontmatter) +- Gets linked notes via `DistillService.getLinkedNotes()` +- Aggregates via `DistillService.aggregateContent()` +- Writes to temp file at `/tmp/openaugi/task-{id}-context.md` +- Context injected into agent via `--append-system-prompt-file` (Claude Code) + +**Frontmatter:** +- Reads `task_id`, `agent`, `status` via `metadataCache.getFileCache()` +- Updates `session_active`, `last_session` via `fileManager.processFrontMatter()` + +**Shell Execution:** +- Uses Node.js `child_process.exec` (available via Electron) +- tmux commands: `has-session`, `new-session`, `send-keys`, `kill-session`, `list-sessions` +- Terminal opening: `osascript` for iTerm2 or Terminal.app + +--- + ## Types Reference ### Settings (`types/settings.ts`) @@ -248,6 +288,9 @@ Commands are registered in `main.ts`: | `openaugi-process-notes` | Process notes | Unified flow: linked notes | | `openaugi-process-recent` | Process recent activity | Unified flow: recent activity | | `openaugi-save-context` | Save context | Save raw aggregated content | +| `task-dispatch-launch` | Task dispatch: Launch or attach | Launch or attach to agent tmux session | +| `task-dispatch-kill` | Task dispatch: Kill session | Kill active tmux session for task note | +| `task-dispatch-list` | Task dispatch: List active sessions | Show modal of all active task sessions | --- diff --git a/manifest.json b/manifest.json index e58fb42..25f80b1 100644 --- a/manifest.json +++ b/manifest.json @@ -1,10 +1,10 @@ { "id": "openaugi", "name": "OpenAugi", - "version": "0.4.1", + "version": "0.5.0", "minAppVersion": "1.8.9", "description": "Process information faster with augmented intelligence (AI for thinkers). Parse your voice notes into atomic notes, tasks, and summaries. Grab context from dataview queries and linked notes. De-duplicate and merge atomic ideas into a clean, organized vault.", "author": "Chris Lettieri", "authorUrl": "https://bitsofchris.com", - "isDesktopOnly": false + "isDesktopOnly": true } diff --git a/package.json b/package.json index 38c0325..7dc100f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "open-augi", - "version": "0.4.1", + "version": "0.5.0", "description": "", "main": "main.js", "scripts": { diff --git a/src/main.ts b/src/main.ts index 9426858..6bc6e81 100644 --- a/src/main.ts +++ b/src/main.ts @@ -4,14 +4,17 @@ import { OpenAIService } from './services/openai-service'; import { FileService } from './services/file-service'; import { DistillService } from './services/distill-service'; import { ContextGatheringService } from './services/context-gathering-service'; +import { TaskDispatchService } from './services/task-dispatch-service'; import { OpenAugiSettingTab } from './ui/settings-tab'; import { LoadingIndicator } from './ui/loading-indicator'; +import { SessionListModal } from './ui/session-list-modal'; import { sanitizeFilename, createFileWithCollisionHandling } from './utils/filename-utils'; import { PromptSelectionModal, PromptSelectionConfig } from './ui/prompt-selection-modal'; import { ContextGatheringModal } from './ui/context-gathering-modal'; import { ContextSelectionModal } from './ui/context-selection-modal'; import { ContextPreviewModal } from './ui/context-preview-modal'; import { CommandOptions, ContextGatheringConfig, GatheredContext, DiscoveredNote } from './types/context'; +import { TaskSession } from './types/task-dispatch'; /** * A simple tokeinzer to estimate the number of tokens @@ -29,6 +32,7 @@ export default class OpenAugiPlugin extends Plugin { fileService: FileService; distillService: DistillService; contextGatheringService: ContextGatheringService; + taskDispatchService: TaskDispatchService; loadingIndicator: LoadingIndicator; async onload() { @@ -112,6 +116,53 @@ export default class OpenAugiPlugin extends Plugin { } }); + // Task dispatch commands + this.addCommand({ + id: 'task-dispatch-launch', + name: 'Task dispatch: Launch or attach', + callback: async () => { + const activeFile = this.app.workspace.getActiveFile(); + if (activeFile && activeFile.extension === 'md') { + await this.taskDispatchService.launchOrAttach(activeFile); + } else { + new Notice('Please open a task note first'); + } + } + }); + + this.addCommand({ + id: 'task-dispatch-kill', + name: 'Task dispatch: Kill session', + callback: async () => { + const activeFile = this.app.workspace.getActiveFile(); + if (activeFile && activeFile.extension === 'md') { + await this.taskDispatchService.killSession(activeFile); + } else { + new Notice('Please open a task note first'); + } + } + }); + + this.addCommand({ + id: 'task-dispatch-list', + name: 'Task dispatch: List active sessions', + callback: async () => { + const sessions = await this.taskDispatchService.listActiveSessions(); + const modal = new SessionListModal( + this.app, + sessions, + async (session: TaskSession) => { + await this.taskDispatchService.openTerminal(session.tmuxSessionName); + }, + async (session: TaskSession) => { + await this.taskDispatchService.killSessionById(session.taskId); + new Notice(`Killed session: ${session.taskId}`); + } + ); + modal.open(); + } + }); + // Add settings tab this.addSettingTab(new OpenAugiSettingTab(this.app, this)); } @@ -144,6 +195,11 @@ export default class OpenAugiPlugin extends Plugin { this.distillService, this.settings ); + this.taskDispatchService = new TaskDispatchService( + this.app, + this.settings, + this.distillService + ); } /** @@ -334,6 +390,13 @@ export default class OpenAugiPlugin extends Plugin { savedData.recentActivityDefaults ); } + if (savedData?.taskDispatch) { + this.settings.taskDispatch = Object.assign( + {}, + DEFAULT_SETTINGS.taskDispatch, + savedData.taskDispatch + ); + } } async saveSettings() { diff --git a/src/services/task-dispatch-service.ts b/src/services/task-dispatch-service.ts new file mode 100644 index 0000000..e747e92 --- /dev/null +++ b/src/services/task-dispatch-service.ts @@ -0,0 +1,286 @@ +import { App, TFile, Notice } from 'obsidian'; +import { exec } from 'child_process'; +import { promisify } from 'util'; +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'; + +const execAsync = promisify(exec); + +export class TaskDispatchService { + private app: App; + private settings: OpenAugiSettings; + private distillService: DistillService; + + constructor( + app: App, + settings: OpenAugiSettings, + distillService: DistillService + ) { + this.app = app; + this.settings = settings; + this.distillService = distillService; + } + + /** + * Launch a new session or attach to an existing one for the given task note. + * The plugin only reads task_id from frontmatter — all other task state + * is managed by the MCP server / agent inside the session. + */ + async launchOrAttach(file: TFile): Promise { + const taskId = this.getTaskId(file); + if (!taskId) { + new Notice('This note is not a task note. Add task_id to frontmatter.'); + return; + } + + const sessionName = `task-${taskId}`; + const agentConfig = this.getAgentConfig(); + + try { + const exists = await this.tmuxSessionExists(sessionName); + + if (exists) { + new Notice(`Attaching to session: ${taskId}`); + await this.openTerminal(sessionName); + } else { + new Notice(`Launching session: ${taskId}`); + + const contextContent = await this.assembleContext(file, taskId); + const contextFilePath = await this.writeContextFile(taskId, contextContent); + + await this.createTmuxSession(sessionName, agentConfig, contextFilePath); + await this.openTerminal(sessionName); + } + } catch (error) { + console.error('Task dispatch error:', error); + const msg = error instanceof Error ? error.message : String(error); + + if (msg.includes('tmux') && msg.includes('not found')) { + new Notice('tmux is not installed. Install it with: brew install tmux'); + } else { + new Notice(`Task dispatch failed: ${msg}`); + } + } + } + + /** + * Kill the tmux session for the given task note. + */ + async killSession(file: TFile): Promise { + const taskId = this.getTaskId(file); + if (!taskId) { + new Notice('This note is not a task note. Add task_id to frontmatter.'); + return; + } + + const sessionName = `task-${taskId}`; + + try { + const exists = await this.tmuxSessionExists(sessionName); + if (!exists) { + new Notice(`No active session for: ${taskId}`); + return; + } + + await execAsync(`tmux kill-session -t ${this.shellEscape(sessionName)}`); + this.cleanupContextFile(taskId); + + new Notice(`Killed session: ${taskId}`); + } catch (error) { + console.error('Failed to kill session:', error); + new Notice(`Failed to kill session: ${error instanceof Error ? error.message : String(error)}`); + } + } + + /** + * Kill a session by task ID (used from the session list modal). + */ + async killSessionById(taskId: string): Promise { + const sessionName = `task-${taskId}`; + try { + await execAsync(`tmux kill-session -t ${this.shellEscape(sessionName)}`); + this.cleanupContextFile(taskId); + } catch (error) { + console.error('Failed to kill session:', error); + throw error; + } + } + + /** + * List all active task tmux sessions. + */ + async listActiveSessions(): Promise { + try { + const { stdout } = await execAsync( + 'tmux list-sessions -F "#{session_name} #{session_created}" 2>/dev/null' + ); + + const sessions: TaskSession[] = []; + + for (const line of stdout.trim().split('\n')) { + if (!line.trim()) continue; + + const parts = line.trim().split(' '); + const sessionName = parts[0]; + const createdTimestamp = parts[1]; + + if (!sessionName.startsWith('task-')) continue; + + const taskId = sessionName.replace('task-', ''); + const noteFile = this.findTaskNote(taskId); + + const startedAt = createdTimestamp + ? new Date(parseInt(createdTimestamp) * 1000).toISOString() + : 'unknown'; + + sessions.push({ + taskId, + tmuxSessionName: sessionName, + startedAt, + noteFile + }); + } + + return sessions; + } catch { + // tmux not running or no sessions — return empty + return []; + } + } + + /** + * Open terminal attached to a tmux session (public for use from modal). + */ + async openTerminal(sessionName: string): Promise { + const terminalApp = this.settings.taskDispatch.terminalApp; + const attachCmd = `tmux attach -t ${this.shellEscape(sessionName)}`; + + let osascript: string; + if (terminalApp === 'iterm2') { + osascript = `tell application "iTerm2" to create window with default profile command "${attachCmd}"`; + } else { + osascript = `tell app "Terminal" to do script "${attachCmd}"`; + } + + try { + await execAsync(`osascript -e '${osascript}'`); + } catch (error) { + console.error('Failed to open terminal:', error); + new Notice(`Could not open ${terminalApp === 'iterm2' ? 'iTerm2' : 'Terminal.app'}. Check your terminal settings.`); + } + } + + // --- Private helpers --- + + /** + * Read task_id from frontmatter. This is the only field the plugin cares about. + */ + private getTaskId(file: TFile): string | null { + const cache = this.app.metadataCache.getFileCache(file); + const taskId = cache?.frontmatter?.['task_id']; + return taskId ? String(taskId) : null; + } + + private async assembleContext(file: TFile, taskId: string): Promise { + // Read note body and strip frontmatter + const rawContent = await this.app.vault.read(file); + const noteBody = rawContent.replace(/^---\n[\s\S]*?\n---\n?/, '').trim(); + + // Get linked notes and aggregate their content + const linkedFiles = await this.distillService.getLinkedNotes(file); + let linkedContent = ''; + + if (linkedFiles.length > 0) { + const { content } = await this.distillService.aggregateContent(linkedFiles); + linkedContent = content; + } + + let context = `# Task: ${taskId}\n\n---\n\n## Task Note\n\n${noteBody}`; + + if (linkedContent) { + 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.'; + + // Cap at max characters + const maxChars = this.settings.taskDispatch.maxContextChars; + if (context.length > maxChars) { + context = context.substring(0, maxChars) + '\n\n...(context truncated at character limit)'; + } + + return context; + } + + private async writeContextFile(taskId: string, content: string): Promise { + const dir = this.settings.taskDispatch.contextTempDir; + const filePath = path.join(dir, `task-${taskId}-context.md`); + + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + + fs.writeFileSync(filePath, content, 'utf-8'); + return filePath; + } + + private cleanupContextFile(taskId: string): void { + const filePath = path.join( + this.settings.taskDispatch.contextTempDir, + `task-${taskId}-context.md` + ); + try { + if (fs.existsSync(filePath)) { + fs.unlinkSync(filePath); + } + } catch { + // Non-critical, ignore cleanup failures + } + } + + private async tmuxSessionExists(sessionName: string): Promise { + try { + await execAsync(`tmux has-session -t ${this.shellEscape(sessionName)} 2>/dev/null`); + return true; + } catch { + return false; + } + } + + private async createTmuxSession( + sessionName: string, + agentConfig: AgentConfig, + contextFilePath: string + ): Promise { + await execAsync(`tmux new-session -d -s ${this.shellEscape(sessionName)}`); + + const agentCommand = `${agentConfig.command} ${agentConfig.contextFlag} ${this.shellEscape(contextFilePath)}`; + await execAsync( + `tmux send-keys -t ${this.shellEscape(sessionName)} ${this.shellEscape(agentCommand)} Enter` + ); + } + + private getAgentConfig(): AgentConfig { + const agents = this.settings.taskDispatch.agents; + const id = this.settings.taskDispatch.defaultAgent; + return agents.find(a => a.id === id) || agents[0]; + } + + private findTaskNote(taskId: string): TFile | undefined { + const files = this.app.vault.getMarkdownFiles(); + for (const file of files) { + const cache = this.app.metadataCache.getFileCache(file); + if (cache?.frontmatter?.['task_id'] === taskId) { + return file; + } + } + return undefined; + } + + private shellEscape(str: string): string { + return `'${str.replace(/'/g, "'\\''")}'`; + } +} diff --git a/src/types/plugin.ts b/src/types/plugin.ts index 41c3209..863060d 100644 --- a/src/types/plugin.ts +++ b/src/types/plugin.ts @@ -2,10 +2,12 @@ import { Plugin } from 'obsidian'; import { OpenAugiSettings } from './settings'; import { OpenAIService } from '../services/openai-service'; import { FileService } from '../services/file-service'; +import { TaskDispatchService } from '../services/task-dispatch-service'; export default interface OpenAugiPlugin extends Plugin { settings: OpenAugiSettings; openAIService: OpenAIService; fileService: FileService; + taskDispatchService: TaskDispatchService; saveSettings(): Promise; } \ No newline at end of file diff --git a/src/types/settings.ts b/src/types/settings.ts index 443d36e..a7328ca 100644 --- a/src/types/settings.ts +++ b/src/types/settings.ts @@ -1,4 +1,5 @@ import { App } from 'obsidian'; +import { TaskDispatchSettings } from './task-dispatch'; export interface RecentActivitySettings { daysBack: number; @@ -28,6 +29,7 @@ export interface OpenAugiSettings { enableDistillLogging: boolean; recentActivityDefaults: RecentActivitySettings; contextGatheringDefaults: ContextGatheringDefaults; + taskDispatch: TaskDispatchSettings; } export const DEFAULT_SETTINGS: OpenAugiSettings = { @@ -53,5 +55,19 @@ export const DEFAULT_SETTINGS: OpenAugiSettings = { filterRecentSectionsOnly: true, includeBacklinks: true, backlinkContextLines: 0 + }, + taskDispatch: { + terminalApp: 'iterm2', + agents: [ + { + id: 'claude-code', + name: 'Claude Code', + command: 'claude', + contextFlag: '--append-system-prompt-file' + } + ], + defaultAgent: 'claude-code', + contextTempDir: '/tmp/openaugi', + maxContextChars: 200000 } }; \ No newline at end of file diff --git a/src/types/task-dispatch.ts b/src/types/task-dispatch.ts new file mode 100644 index 0000000..926dd07 --- /dev/null +++ b/src/types/task-dispatch.ts @@ -0,0 +1,25 @@ +import { TFile } from 'obsidian'; + +export interface AgentConfig { + id: string; + name: string; + command: string; + contextFlag: string; +} + +export type TerminalApp = 'iterm2' | 'terminal-app'; + +export interface TaskDispatchSettings { + terminalApp: TerminalApp; + agents: AgentConfig[]; + defaultAgent: string; + contextTempDir: string; + maxContextChars: number; +} + +export interface TaskSession { + taskId: string; + tmuxSessionName: string; + startedAt: string; + noteFile?: TFile; +} diff --git a/src/ui/session-list-modal.ts b/src/ui/session-list-modal.ts new file mode 100644 index 0000000..b9a1678 --- /dev/null +++ b/src/ui/session-list-modal.ts @@ -0,0 +1,82 @@ +import { App, Modal, Setting } from 'obsidian'; +import { TaskSession } from '../types/task-dispatch'; + +export class SessionListModal extends Modal { + private sessions: TaskSession[]; + private onAttach: (session: TaskSession) => void; + private onKill: (session: TaskSession) => void; + + constructor( + app: App, + sessions: TaskSession[], + onAttach: (session: TaskSession) => void, + onKill: (session: TaskSession) => void + ) { + super(app); + this.sessions = sessions; + this.onAttach = onAttach; + this.onKill = onKill; + } + + onOpen() { + const { contentEl } = this; + contentEl.empty(); + contentEl.createEl('h2', { text: 'Active Task Sessions' }); + + if (this.sessions.length === 0) { + contentEl.createEl('p', { text: 'No active task sessions found.' }); + new Setting(contentEl) + .addButton(button => button + .setButtonText('Close') + .onClick(() => this.close()) + ); + return; + } + + for (const session of this.sessions) { + const elapsed = this.formatElapsed(session.startedAt); + + new Setting(contentEl) + .setName(session.taskId) + .setDesc(`Started ${elapsed}`) + .addButton(button => button + .setButtonText('Attach') + .setCta() + .onClick(() => { + this.onAttach(session); + this.close(); + }) + ) + .addButton(button => button + .setButtonText('Kill') + .setWarning() + .onClick(() => { + this.onKill(session); + this.close(); + }) + ); + } + + new Setting(contentEl) + .addButton(button => button + .setButtonText('Close') + .onClick(() => this.close()) + ); + } + + onClose() { + const { contentEl } = this; + contentEl.empty(); + } + + private formatElapsed(isoTimestamp: string): string { + if (isoTimestamp === 'unknown') return 'unknown'; + const ms = Date.now() - new Date(isoTimestamp).getTime(); + const minutes = Math.floor(ms / 60000); + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.floor(hours / 24); + return `${days}d ago`; + } +} diff --git a/src/ui/settings-tab.ts b/src/ui/settings-tab.ts index 447ef09..6bfb308 100644 --- a/src/ui/settings-tab.ts +++ b/src/ui/settings-tab.ts @@ -1,6 +1,7 @@ import { App, PluginSettingTab, Setting, Notice, DropdownComponent } from 'obsidian'; import type OpenAugiPlugin from '../types/plugin'; import { OpenAIService } from '../services/openai-service'; +import { TerminalApp } from '../types/task-dispatch'; export class OpenAugiSettingTab extends PluginSettingTab { plugin: OpenAugiPlugin; @@ -350,6 +351,68 @@ export class OpenAugiSettingTab extends PluginSettingTab { }) ); + // Task Dispatch Settings Header + containerEl.createEl('h3', { text: 'Task Dispatch' }); + + new Setting(containerEl) + .setName('Terminal application') + .setDesc('Which terminal app to open for agent sessions') + .addDropdown(dropdown => dropdown + .addOption('iterm2', 'iTerm2') + .addOption('terminal-app', 'Terminal.app') + .setValue(this.plugin.settings.taskDispatch.terminalApp) + .onChange(async (value) => { + this.plugin.settings.taskDispatch.terminalApp = value as TerminalApp; + await this.plugin.saveSettings(); + }) + ); + + new Setting(containerEl) + .setName('Default agent') + .setDesc('Agent to use when task note does not specify one') + .addDropdown(dropdown => { + for (const agent of this.plugin.settings.taskDispatch.agents) { + dropdown.addOption(agent.id, agent.name); + } + dropdown.setValue(this.plugin.settings.taskDispatch.defaultAgent); + dropdown.onChange(async (value) => { + this.plugin.settings.taskDispatch.defaultAgent = value; + await this.plugin.saveSettings(); + }); + }); + + new Setting(containerEl) + .setName('Max context characters') + .setDesc('Maximum characters to include in the context bundle sent to the agent') + .addText(text => text + .setPlaceholder('200000') + .setValue(String(this.plugin.settings.taskDispatch.maxContextChars)) + .onChange(async (value) => { + const num = parseInt(value); + if (!isNaN(num) && num > 0) { + this.plugin.settings.taskDispatch.maxContextChars = num; + await this.plugin.saveSettings(); + } + }) + ); + + new Setting(containerEl) + .setName('Context temp directory') + .setDesc('Directory for temporary context files passed to agents') + .addText(text => { + text + .setPlaceholder('/tmp/openaugi') + .setValue(this.plugin.settings.taskDispatch.contextTempDir); + text.inputEl.addEventListener('blur', async () => { + const value = text.getValue(); + if (value !== this.plugin.settings.taskDispatch.contextTempDir) { + this.plugin.settings.taskDispatch.contextTempDir = value; + await this.plugin.saveSettings(); + } + }); + return text; + }); + // Advanced Settings Header containerEl.createEl('h3', { text: 'Advanced Settings' });