- Go 100%
ExecStart uses the path of the running gosh (%h under the home directory), -root and -calendar set what is scanned and how often, and an explicit -db is passed on. 'service' or 'timer' prints one unit for redirecting to a file. |
||
|---|---|---|
| docs | ||
| examples | ||
| .gitignore | ||
| db.go | ||
| docs.go | ||
| docs_test.go | ||
| go.mod | ||
| go.sum | ||
| history.go | ||
| LICENSE | ||
| location.go | ||
| location_test.go | ||
| main.go | ||
| plugin.go | ||
| plugin_test.go | ||
| project.go | ||
| README.md | ||
| report.go | ||
| scan.go | ||
| streams.go | ||
| streams_test.go | ||
| timeline.go | ||
| tree.go | ||
| tree_test.go | ||
gosh
Work out what you've been spending your time on, using only what is already on disk.
gosh records file modification times grouped by git project, joins them with your
bash history and a log of the directory each command ran in, and keeps it all in a
small SQLite database. From that it can report time spent per project and replay a
day's work as a single timeline of commands and file changes.
It is the successor to persistent-shell-history (ruby + berkeley dbm).
How it works
- Projects. A project is the nearest ancestor directory containing
.git. When scanning, a repository above the scan root (say a dotfiles repo in$HOME) does not count; the scan root itself stands in as the project. - Files. Each scan upserts every regular file's mtime and size. Only the latest
mtime per file is kept. Files that disappear are marked
deleted_atrather than removed. - Commands. Bash history is imported from the
#<epoch>lines that bash writes whenHISTTIMEFORMATis set. Bash history does not record where a command ran, so an optional prompt hook logsepoch pid ppid cwd command(tab separated): the directory the command started in, and the pid and parent pid of the bash that ran it, which ties commands to a project and to a terminal. - Sessions. Events (file mtimes and command times) for a project are grouped into sessions: a gap of more than 30 minutes ends one. Each session is credited 5 minutes before its first event, since work starts before the first write.
Skipped directories during a scan: .git, node_modules, .cache, target,
__pycache__, .venv, vendor.
Install
Requires Go. No cgo (SQLite is modernc.org/sqlite).
go install git.batts.cloud/vbatts/gosh@latest
or from a checkout:
go build -o gosh .
Setup
-
Make sure bash writes timestamps to its history file, in
~/.bashrc:export HISTTIMEFORMAT="%F %T " -
Optionally enable the cwd hook, also in
~/.bashrc(after anything that sets or unsetsPROMPT_COMMAND):eval "$(gosh init bash)"This appends to
~/.local/state/gosh/cwd.log($XDG_STATE_HOMEand$GOSH_CWD_LOGare honoured). The file is created mode 0600, since commands may contain secrets. -
Collect data:
gosh import gosh scan -root ~/src gosh collect # only if you have plugins, see belowAll three are idempotent, so they are safe to run as often as you like.
-
Run them on a schedule, see the next section. The shell hook only records commands; nothing is loaded into the database until
import,scanandcollectrun.
Running on a schedule
eval "$(gosh init bash)" does not run gosh. It appends each command to cwd.log and
that's all. Loading it, scanning files and running plugins is up to you, and a systemd user
timer is the easiest way to do it.
How often matters, and differently for each source:
- Files:
gosh scanrecords only a file's latest mtime, so the more often it runs the finer the history. A scan of about 30,000 files takes under two seconds. - Commands: the hook writes
cwd.logas you work, so a lateimportloses nothing. Bash itself writes.bash_historyonly when a shell exits. - Plugins:
collectis the only thing that reads them. A late run loses nothing while the source still has its data, but a tool may expire its old logs, so don't leave long gaps.
gosh init systemd prints both units below, filled in with the path of the gosh you ran,
so you needn't edit them:
mkdir -p ~/.config/systemd/user
gosh init systemd service > ~/.config/systemd/user/gosh.service
gosh init systemd timer > ~/.config/systemd/user/gosh.timer
-root dir (repeatable) sets what scan covers and -calendar spec how often it runs
(default *:0/15). A -db given before init is passed on to every command. By hand, they are
~/.config/systemd/user/gosh.service:
[Unit]
Description=Collect gosh activity
[Service]
Type=oneshot
# the leading "-" keeps one failure from skipping the rest
ExecStart=-%h/go/bin/gosh import
ExecStart=-%h/go/bin/gosh scan
ExecStart=-%h/go/bin/gosh collect
~/.config/systemd/user/gosh.timer:
[Unit]
Description=Collect gosh activity regularly
[Timer]
OnCalendar=*:0/15
Persistent=true
[Install]
WantedBy=timers.target
Then:
systemctl --user daemon-reload
systemctl --user enable --now gosh.timer
systemctl --user list-timers gosh.timer # when it next runs
journalctl --user -u gosh.service # what the last run did
Notes:
- The
ExecStartpaths are wheregoshwas when you raninit systemd; if you move it, regenerate the service. By hand,go installputs it in~/go/bin. After upgrading, reinstall it, or the timer keeps running the old binary. scandefaults to~/src. To scan elsewhere, give it-root(repeatable):ExecStart=-%h/go/bin/gosh scan -root %h/src -root %h/work.- Systemd's
PATHis minimal, but plugins still work:gosh plugin addrecords each plugin's absolute path. A plugin that itself needs other programs (gosh-git needsgit) finds them in the usual system directories. OnCalendar=*:0/15means every 15 minutes.Persistent=trueruns a missed run when the machine wakes. UseOnCalendar=hourlyfor a lighter schedule.- Without systemd, the same three commands work from cron:
*/15 * * * * ~/go/bin/gosh import; ~/go/bin/gosh scan; ~/go/bin/gosh collect
Usage
gosh [-db path] <command> [flags]
The database defaults to ~/.config/gosh/state.db.
| Command | Description |
|---|---|
scan [-root dir]... |
Record file mtimes under each root (default ~/src). |
import [-hist file] [-cwdlog file] |
Load shell history and the cwd log. |
report [-since 7d] [-by project|day|path|stream] [-prefix loc] [-depth n] [-sort active|last] [-gap 30m] [-min 5m] [-only streams] [-exclude streams] |
Active time per project, day, path or event stream. |
plugin add <name|path> / check <name|path> / list / remove <name> |
Manage plugins; check validates one without storing anything (see below). |
collect [-plugin name] [-full] |
Run the registered plugins and store the activity they report. |
timeline [-since 1d] [-project text] [-group] [-prefix loc] [-depth n] [-gap 30m] [-only streams] [-exclude streams] |
Commands, file changes and plugin activity in time order; -group gathers them per project. |
init bash / init systemd [service|timer] [-calendar spec] [-root dir]... |
Print the bash prompt hook, or the systemd user units for running import, scan and collect. |
help [setup|schedule|usage|plugins|readme] |
Setup and systemd timer instructions, or this README and the plugin guide in full. They are compiled into the binary, so they come with go install. |
-since accepts d and w suffixes as well as anything time.ParseDuration does.
report and timeline take -only and -exclude, comma separated lists of event streams:
files, cmds, plugins (every plugin) or a registered plugin's name. -exclude wins, so
-only plugins -exclude git is every plugin but git. Unknown names are an error.
$ gosh report -exclude files # time without file mtimes
$ gosh report -by stream # time per source: files, cmds, each plugin
$ gosh timeline -only claude # just that plugin's activity
Report
$ gosh report -since 14d
PROJECT ACTIVE SESSIONS FILES CMDS LAST
~/src/Aspen/Willet/infra-config 1h33m 4 103 12 2026-10-14 14:12
~/src/gosh 0h06m 1 8 3 2026-10-15 09:51
By stream
-by stream has one row per event source: files, cmds and each plugin. A stream's time
comes from its own events alone, so the rows overlap and do not add up to the other views'
totals. Use it to see which plugin the time is coming from.
$ gosh report -by stream
STREAM ACTIVE SESSIONS EVENTS LAST
firefox 24h17m 55 890 2026-10-17 14:05
claude 7h12m 14 984 2026-10-17 14:10
cmds 6h17m 15 191 2026-10-15 10:20
By path
-by path ignores git projects and groups by location instead, like du. Files count at
their own path, commands at the directory they ran in, and plugin events at their path or URI.
-prefix picks where to start (a path, ~/..., or a URI) and -depth how many levels below it
to list; drill down by moving the prefix.
$ gosh report -by path -prefix ~/src -depth 2
$ gosh report -by path -prefix ~/src/gosh # down to individual files
$ gosh report -by path -prefix https://github.com -depth 2 -only firefox
A row's time comes from everything at or below it taken together, so a parent is not the sum of
its children (they overlap in time). With no -prefix, commands that have no known directory
are listed as (no cwd). A row covering a single event, such as a file with one known mtime, shows - for ACTIVE: one
timestamp has no duration. Real per-file time needs a plugin that reports it repeatedly.
Timeline
$ gosh timeline -since 2d -project gosh -group
== ~/src/gosh 2026-10-15 09:50 - 09:51 (0h01m)
09:50:28 ~ ~/src/gosh/db.go
09:50:51 ~ ~/src/gosh/report.go
09:51:36 ~ ~/src/gosh/main.go
09:51:47 $ cd ~/src/gosh (~/src/gosh)
09:51:47 $ ls >/dev/null (~/src/gosh)
Without -group the timeline is one flat list in time order, with the day as a heading and a
blank line wherever the gap is exceeded; each line is tagged [project] (or [site] for a URI)
instead. -group gives the blocks above, one per session for each project.
~ is a file modification and $ is a command, shown with its working directory.
A command's directory and the shell's pid are shown when known: $ cmd (cwd) [pid].
Commands with no project get their own (no cwd) and (no repo) blocks.
-prefix shows only events at or under a path or URI, placed by location instead of by git
project, and -depth (which needs -group) splits the blocks by that many levels below it. -depth 0 (the default)
is the prefix as a whole, which is handy for a file-level view of one directory or site:
$ gosh timeline -prefix ~/src/gosh/docs
$ gosh timeline -group -prefix https://github.com -depth 2
Commands with no known directory have no location, so a -prefix leaves them out. -project
is unchanged and still matches a substring of the project root.
Plugins
Other tools can report activity too, such as an AI coding agent's sessions. A plugin is
an executable named gosh-<name>, found on your PATH the way git finds subcommands.
Plugins never touch the database; gosh runs them and stores what they print.
gosh plugin add claude # runs `gosh-claude describe`, records name, version, path
gosh collect # runs every registered plugin
The protocol (version 2; version 1, absolute paths only, is still accepted):
gosh-<name> describe
{"name":"claude","version":"0.1.0","protocol":2}
gosh-<name> collect --since <unix seconds>
one JSON object per line:
{"ts":1792072307,"path":"/abs/dir","kind":"prompt","ref":"<unique id>","session":"<id>","detail":"..."}
ts, path (an absolute path or a URI), kind and ref are required; ref must be unique
per event within the plugin, which makes re-reporting an event harmless. gosh maps path
to a project exactly as it does for shell commands, and asks for activity from an hour before
the newest event it has stored (collect -full asks for everything). Each plugin's events are
stored all or nothing: a plugin that exits non-zero stores none from that run.
Plugin activity counts toward report (the PLUGINS column and active time) and appears in
timeline as @<plugin> <kind> lines. Events in a directory outside any git repo are shown
under (no repo). In report a URI's activity is a single (web) row; report -by path breaks it
down by site and page.
To write your own, see docs/PLUGINS.md, which has a quickstart, the full
protocol, design advice, and gosh plugin check for validating a plugin. A working example is in
examples/gosh-git.
Plugins so far:
- gosh-tmux (
tmuxactive windows/panes). - gosh-firefox (Firefox browser active history).
- gosh-gnome (GNOME desktop active windows).
- gosh-claude (Claude Code sessions).
- gosh-qwen (Qwen Code sessions).
- gosh-chrome (Chromium-family profile it finds (Chrome, Chromium, Brave, Edge, Vivaldi)).
Limitations
- Only a file's most recent modification is known, so the timeline shows each file once, at its last write. Scanning more often narrows the picture. Commands are complete.
- Commands imported from
.bash_historyalone have no directory, and nothing in that file can recover one. They are never guessed into a project: they appear under(no cwd). Only commands logged by the prompt hook are tied to a project, so install the hook early.(no repo)is for commands whose directory is known but not inside a git repo. - Bash writes a session's history when the shell exits. The prompt hook writes at every prompt, so it covers long-running terminals.
- With
HISTCONTROL=ignoreboth, commands starting with a space and immediate repeats never reach history or the hook. - A command that changes shell state mid-line (
cd a && make) is logged with the directory it started in. cwd.logis re-read in full on every import.
License
MIT, see LICENSE.