Data model

September 2, 2026 · View on GitHub

Directories

Three directories, different owners:

  • ~/Library/Application Support/termic/ — app-owned: projects.json, tasks/, scratch/, settings.json. Docker mode adds three more here: docker/ (the editable Dockerfile + build metadata), docker-agents/<agent>/ (one container config dir per agent, so a login survives --rm) and docker-forge/{gh,glab}/ (the gh / glab login, SHARED by every agent and task, see sandbox.md). Path via dirs::data_local_dir().join("termic") in lib.rs#data_dir().
  • $TMPDIR/termic-attachments/ — files handed to an agent as FILES rather than as text: <task_id>/ for a staged drop, clipboard/ for an image pasted into a terminal (clipboard_image_save, pruned after 7 days). Deliberately NOT under the app data dir, which the Seatbelt profile ends by denying outright (CLI token); $TMPDIR is on builtin_runtime_paths and is mounted read-only into every Docker container at the same absolute path. lib.rs#attachments_dir().
  • ~/Library/Application Support/com.simion.termic/ — tauri-plugin-window-state owned (window position/size). Path from tauri.conf.json#identifier.
  • ~/.config/termic/themes/ — user-owned, hand-authored custom theme files (docs/themes.md). $XDG_CONFIG_HOME respected; shared by release + dev builds (no termic_dev split). Path via lib.rs#themes_dir_path().

Entities

  • Project (projects.json, single JSON array) — git repo path + scripts + preview_url template + preview_browser (GH #245, an Option<String> on purpose: absent = follow the global Settings.preview_browser, Some("") = force the OS default for this project even when the global names a browser, Some(cmd) = override. A plain String cannot express that middle state, since empty is already spoken for by "inherit" — which is why tasks_path, the other project override, gets away with being one. Personal, never .termic.yaml: a launch command is machine-specific, so a committed open -a "Google Chrome" would be a silently dead link for a teammate on Linux, whereas a preview_url is portable) + files_to_copy globs (personal list wins when non-empty, else the repo's committed .termic.yaml one — effective_files_to_copy) + default_cli + extra_named_ports (personal env-var-name list for GH #196, unioned with the repo's committed .termic.yaml extra_named_ports; yaml order first, deduped, invalid/reserved names dropped — see effective_extra_named_ports) + optional group label (UI-only collapsible folder in the sidebar; no filesystem effect; a group exists iff ≥1 project carries the label. All group reads go through groupOf() in src/lib/projectGroups.ts, THE normalization point: trim + ALL-CAPS, so mixed-case labels on disk converge to one group. Collapse state + folder color live in localStorage keyed by normalized name, pruned when a group disappears).

  • ProjectMember (inline in projects.json, multi-repo projects only) — one repo mounted inside every task under a multi-repo project. Self-contained (root_path + name + base_branch), never a reference to a registered Project. Carries its own setup_script / run_script / archive_script and its own files_to_copy globs, all with the same resolution rule: the value here wins when non-empty, otherwise that member repo's OWN committed .termic.yaml (member_effective_script, member_effective_files_to_copy). Which gitignored files a repo needs is a property of that repo, which is why the list sits here and not on the host — the host project's own files_to_copy covers the task ROOT (the host worktree) and nothing else. Frozen onto each task's composition at create (TaskMember), so editing a member only affects future tasks. The copy runs for worktree members only: a repo-root member IS the live checkout and already holds its files. GH #264.

  • Task (tasks/<uuid>.json) — git worktree branched from project's base_branch. Worktrees live at ~/termic/tasks/<project>/<name>/ by default (configurable per project and globally). is_main_checkout=true tasks point at the project's live checkout (no worktree, archive skips rm -rf). Optional order holds the sidebar position within the project, written by drag-to-reorder (task_reorder). Projects get their order from the projects.json array; tasks are a file each, so they need the explicit key. load_tasks sorts on (order, created) with a missing order LAST, which is why a project nobody has dragged still reads oldest-first and a new task appends at the bottom of a reordered one. Each task also owns a consecutive port block (GH #196), allocated at create by allocate_task_ports: port ($TERMIC_PORT) + one port per composition member (base+1+i) + extra_named_ports (frozen name→port pairs from the project's effective list, injected wherever TERMIC_PORT is set and expanded in the preview URL) + a 5-port buffer. The block length is stored on the task (port_block_len) at allocation; blocks first-fit over non-archived tasks from the bottom of the configured port range (task_port_min/task_port_max, default 18100-65535, GH #271; archived blocks are reused; restoring re-homes a block another task claimed meanwhile). Occupancy means "another task owns it", never "the OS says it is free": termic does not probe, so picking a range nothing else on the machine uses is the user's call, and a server started on a port something else already holds fails to bind in its own run tab. A range with no room left fails the allocation loudly at task create; top_up_extra_ports instead logs and keeps the pairs it has, because failing a spawn over one missing named port would be worse. Note PORT_ALLOC_MIN (1024) is the "this record predates port blocks" sentinel and is deliberately NOT the configurable floor: sharing them meant raising the floor above an existing task made that task's block invisible to every occupancy scan. Every load-occupancy→allocate→persist sequence holds PORT_ALLOC_LOCK, so concurrent creates / restores / top-ups can't scan the same snapshot and claim the same ports. This replaced the old 18100 + task count formula, which could collide with multi-repo member ports. Names added to the config LATER reach existing tasks lazily: every tab spawn / run-script launch calls top_up_extra_ports, which freezes missing names into the task's buffer slots, overflowing to the next free single port anywhere once the buffer is full (task_port_intervals counts those strays as occupied for all later allocations; a restore re-home re-compacts them into a fresh contiguous block). Frozen pairs never move; names removed from the config keep injecting. Pre-existing tasks deserialize with an empty pair list and pick names up the same way.

  • Settings (settings.json) — preview_browser (GH #245: app-wide command template that opens preview URLs and terminal links; empty = OS default), repos_dir, welcomed, agents[] (claude/gemini/codex defaults + customs; each has command/args/yolo_args/runtime_yolo_command). Defaults seeded if agents is empty. schema_version gates one-time on-disk migrations. task_port_min / task_port_max (GH #271) are the window port blocks are allocated from; 0 on either means the default 18100-65535, so read the pair through PortRange::from_settings (Rust) or resolvePortRange (src/lib/portRange.ts), never raw.

  • Scratchpad (scratch/<task_id>/index.json + scratch/<task_id>/<pad_id>.txt, GH #244) — an untitled buffer that survives a relaunch, scoped to ONE task. Stored here rather than in the worktree so it never appears in git status, in the agent's review diff, or in a commit. The index record (id, title, syntax, order, created_at, updated_at) exists because a pad has no filename to re-derive a title or syntax from, and one index read beats stat-ing N files on launch. Pads are NOT part of persisted_tabs, which is agent-tabs-only by construction; they restore from this index when their task is first entered.

    Archiving a task leaves its pads alone. Archiving is recoverable (the task stays in History, the branch stays in git) and notes about the work are exactly what someone wants back when they restore it. The only thing that deletes pads is delete_task_file, the hard delete behind History's "Empty archive" and project removal — pinned by only_the_hard_delete_purges_scratchpads in lib.rs.

  • Tab (per task, in useApp) — terminal (PTY running a CLI), edit (CodeMirror), diff (vs HEAD), dir (folder listing), scratch (an untitled buffer, GH #244 — a distinct type rather than an EditTab with an empty path, so blame / review comments / the disk-watch banner / "locate in file tree" become compiler errors to answer rather than runtime surprises). PTYs die with the app.

Migrations

The "Task" entity was called "Workspace" before, on disk and in code. A one-time startup migration (migrate_workspaces_to_tasks in lib.rs, gated by settings.schema_version) renames the metadata dir workspaces/tasks/ and rewrites the is_repo_root field to is_main_checkout (serde alias still reads the old name). It is metadata-only: it deliberately does NOT move worktree directories or rewrite each task's path. CWD-resume agents (Claude Code's --continue) resume the most recent session by working directory, so relocating a worktree would silently orphan its history. Existing worktrees stay under ~/termic/workspaces/…; NEW worktrees are created under ~/termic/tasks/… (default_worktrees_base()), and the two roots coexist while the old one empties out lazily as tasks are archived/recreated. The metadata rename is atomic (stage in tasks.tmp/, then one rename into place), guarded by a tasks-migration.lock, backs up to backups/pre-tasks-<ts>/, and prunes-on-corruption (an unparseable record, or an active worktree whose dir was deleted externally, is dropped + logged to tasks-migration.log, never carried forward). The JS half (src/lib/lsMigration.ts) renames the persisted localStorage pref keys (workspaceExpandModetaskExpandMode, collapsedWorkspacescollapsedTasks, plus the two newWorkspaceLast* keys); everything else in localStorage is keyed by task UUID, which never changes.