# Detach skill — architecture ## Directory layout ``` ~/.nanobot/workspace/tasks/ inbox/ — tasks waiting to be picked up (written atomically from new/) running/ — task currently executing done/ — completed tasks (success) failed/ — completed tasks (exception or timeout) archive/ — tasks moved out of the active view; no longer shown by list new/ — atomic write staging: skill writes here, then renames into inbox/ ``` ## Task lifecycle ``` capture (skill) → inbox/ → running/ → done/ or failed/ → archive/ ``` 1. **Capture** — the skill calls `create-task.py`, which writes the task file into `new/` and atomically renames it into `inbox/`. This rename is the trigger for the daemon. The filename timestamp uses microsecond precision (`%Y-%m-%d_%H_%M_%S_%f`, e.g. `2026-06-07_15_00_00_123456-slug.md`), making filename collisions impossible even for simultaneous calls with the same slug. `tasks_common.py` parsers accept both the old second-precision format (`T`-joined, e.g. `2026-06-07T150000`) and the new underscore format for backward compatibility with existing task files. When `--model ` is given, the script fuzzy-resolves it to an exact preset against `config.json` *at capture time* (fail-fast in chat) and stores it in the `model:` frontmatter field. 2. **Daemon pickup** — `tasks-daemon.py` is started by a systemd `.path` unit whenever `inbox/` is non-empty. It processes all files in one pass (Type=oneshot). Concurrency is handled by systemd: the service won't start again while the previous run is still live; the level-triggered `.path` unit re-triggers it after the run if inbox is still non-empty. 3. **Execution** — for each file in `inbox/`: move to `running/`, read frontmatter, call `Nanobot.run(goal, session_key="detach:")` with a 45-minute timeout in an isolated session. If the frontmatter carries `model: `, the daemon switches to it via `bot._loop.set_model_preset(preset)` before running (the same switch the `/model` chat command performs); otherwise the task runs on `agents.defaults.modelPreset`. 4. **Completion** — daemon appends `## Result` and a trailing metadata block (`completed`, `duration_seconds`, `status`) to the file, then moves it to `done/` (success) or `failed/` (exception or timeout). 5. **Notification** — daemon sends a Telegram message to `chat_id` from the frontmatter (or falls back to the first `allowFrom` ID for non-Telegram channels). 6. **Archive** — user explicitly calls the `archive` subaction; `archive-tasks.py` moves selected files from `done/` to `archive/`. ## File format Each task is a single Markdown file: ``` --- created: channel: telegram | websocket | ... chat_id: "" slug: model: # optional; omitted → agent default --- # Goal # Constraints - No user interaction (isolated session, no clarification questions — work with what you have). - # Result --- completed: duration_seconds: status: done | failed ``` The daemon appends the `# Result` section and the trailing `---` block; everything before that is written by `create-task.py` at capture time. ## Scripts | Script | Role | |---|---| | `create-task.py` | Capture: writes task file, ensures queue dirs, atomically moves to `inbox/`; logs `CREATE` to `detach.log` | | `tasks-daemon.py` | Long-running one-shot systemd service; executes tasks, notifies via Telegram; logs lifecycle events to `detach.log` | | `list-tasks.py` | List `running/`, `done/`, `failed/` (capped at 10 newest each) as a flat bullet list, one task per bullet (slug · time · age + indented goal) | | `read-task.py` | Format and print a completed task's result | | `archive-tasks.py` | Move tasks from `done/` to `archive/` (by slug or all); logs `ARCHIVE` to `detach.log` | | `tasks_common.py` | Shared stdlib helpers: paths, parsers, formatters, model-preset resolution, shared `log()` → `~/.nanobot/workspace/log/detach.log` | ## Systemd units Two user-level units under `~/.config/systemd/user/`: - `tasks-daemon.service` — Type=oneshot, runs `tasks-daemon.py` - `tasks-daemon.path` — level-triggered, watches `inbox/`, starts the service when non-empty