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) anddocker-forge/{gh,glab}/(the gh / glab login, SHARED by every agent and task, see sandbox.md). Path viadirs::data_local_dir().join("termic")inlib.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);$TMPDIRis onbuiltin_runtime_pathsand 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 fromtauri.conf.json#identifier.~/.config/termic/themes/— user-owned, hand-authored custom theme files (docs/themes.md).$XDG_CONFIG_HOMErespected; shared by release + dev builds (notermic_devsplit). Path vialib.rs#themes_dir_path().
Entities
-
Project (
projects.json, single JSON array) — git repo path + scripts +preview_urltemplate +preview_browser(GH #245, anOption<String>on purpose: absent = follow the globalSettings.preview_browser,Some("")= force the OS default for this project even when the global names a browser,Some(cmd)= override. A plainStringcannot express that middle state, since empty is already spoken for by "inherit" — which is whytasks_path, the other project override, gets away with being one. Personal, never.termic.yaml: a launch command is machine-specific, so a committedopen -a "Google Chrome"would be a silently dead link for a teammate on Linux, whereas apreview_urlis portable) +files_to_copyglobs (personal list wins when non-empty, else the repo's committed.termic.yamlone —effective_files_to_copy) +default_cli+extra_named_ports(personal env-var-name list for GH #196, unioned with the repo's committed.termic.yamlextra_named_ports; yaml order first, deduped, invalid/reserved names dropped — seeeffective_extra_named_ports) + optionalgrouplabel (UI-only collapsible folder in the sidebar; no filesystem effect; a group exists iff ≥1 project carries the label. All group reads go throughgroupOf()insrc/lib/projectGroups.ts, THE normalization point: trim + ALL-CAPS, so mixed-case labels on disk converge to one group. Collapse state + folder color live inlocalStoragekeyed 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 ownsetup_script/run_script/archive_scriptand its ownfiles_to_copyglobs, 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 ownfiles_to_copycovers the task ROOT (the host worktree) and nothing else. Frozen onto each task'scompositionat 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'sbase_branch. Worktrees live at~/termic/tasks/<project>/<name>/by default (configurable per project and globally).is_main_checkout=truetasks point at the project's live checkout (no worktree, archive skipsrm -rf). Optionalorderholds the sidebar position within the project, written by drag-to-reorder (task_reorder). Projects get their order from theprojects.jsonarray; tasks are a file each, so they need the explicit key.load_taskssorts on(order, created)with a missingorderLAST, 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 byallocate_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_portsinstead logs and keeps the pairs it has, because failing a spawn over one missing named port would be worse. NotePORT_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 holdsPORT_ALLOC_LOCK, so concurrent creates / restores / top-ups can't scan the same snapshot and claim the same ports. This replaced the old18100 + task countformula, which could collide with multi-repo member ports. Names added to the config LATER reach existing tasks lazily: every tab spawn / run-script launch callstop_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_intervalscounts 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 hascommand/args/yolo_args/runtime_yolo_command). Defaults seeded ifagentsis empty.schema_versiongates 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 throughPortRange::from_settings(Rust) orresolvePortRange(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 ingit 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 ofpersisted_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 byonly_the_hard_delete_purges_scratchpadsinlib.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 anEditTabwith an emptypath, 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
(workspaceExpandMode → taskExpandMode, collapsedWorkspaces → collapsedTasks,
plus the two newWorkspaceLast* keys); everything else in localStorage is keyed
by task UUID, which never changes.