task dispatch

This commit is contained in:
Chris Lettieri 2026-02-24 21:05:29 -05:00
parent f0a8816653
commit 7660c798b5
12 changed files with 713 additions and 8 deletions

View file

@ -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

119
PLAN.md Normal file
View file

@ -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

View file

@ -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<GatheredContext>
---
### 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 |
---

View file

@ -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
}

View file

@ -1,6 +1,6 @@
{
"name": "open-augi",
"version": "0.4.1",
"version": "0.5.0",
"description": "",
"main": "main.js",
"scripts": {

View file

@ -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() {

View file

@ -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<void> {
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<void> {
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<void> {
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<TaskSession[]> {
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<void> {
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<string> {
// 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<string> {
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<boolean> {
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<void> {
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, "'\\''")}'`;
}
}

View file

@ -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<void>;
}

View file

@ -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
}
};

View file

@ -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;
}

View file

@ -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`;
}
}

View file

@ -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' });