New:Introducing Socket Scanning for VS Code Marketplace Extensions.Learn more →
Get Started

@codixus/mt

Package Overview
Dependencies
Maintainers
1
Versions
45
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@codixus/mt

Cross-platform skill manager + dev shortcut CLI

latest
Source
npmnpm
Version
0.20.2
Version published
Maintainers
1
Created
Source

@codixus/mt

Cross-platform skill manager + dev shortcut CLI for Claude Code, Codex, Gemini CLI, Cursor and other AI coding agents.

Install

npm i -g @codixus/mt
# or
bun add -g @codixus/mt

Requires Bun ≥1.0 (the CLI ships as .ts and runs through bun).

Skill manager

Subscribe to skill repos (public or private), install only what you need, sync across machines.

mt login                                                       # save GitHub PAT to ~/.mt/auth.json (chmod 600)
mt add anthropics/skills --path "skills/frontend-design"       # cherry-pick a single skill
mt add me/private-skills --path "skills/*" --exclude "cxs-*"   # subscribe to a whole repo with filters
mt sync                                                         # fetch + reconcile skills + hooks (idempotent)
mt sync --global --force                                        # also wire the shortcut rc files (zsh + bash) in one shot
mt list                                                         # show configured + installed
mt remove <name>                                                # remove + reconcile orphans
mt skills disable <name>                                        # exclude one skill from a repo subscription, delete its files now
mt skills enable <name>                                         # clear the disable flag and sync it back in

Two scopes, auto-detected by cwd:

  • Global (~/.mt/): installs to ~/.claude/, ~/.codex/, ~/.cursor/hooks.json, ~/.gemini/, ~/.opencode/, ~/.config/opencode/, etc.
  • Local (<project>/.mt/): installs only to that project's .claude/ etc.

Use --global or --local to override detection.

Per-name overrides live in skills.json's skills map, keyed by skill/command/agent leaf name:

{
  "entries": [{ "name": "codixus", "repo": "codixus/skills", "path": "skills/*" }],
  "skills": {
    "cxs-app-audit": { "enabled": false },
    "mtc": { "alias": "mt-commit" }
  }
}

enabled: false excludes that name from every future mt sync (whether it comes from a glob subscription or not) and deletes any previously-synced copy immediately; alias materialises a new short name as a copy of an existing leaf. mt skills disable/mt skills enable manage the enabled flag for you; alias entries are still hand-edited directly in skills.json.

Shell shortcuts

Cross-platform alias manager (bash, zsh, fish, PowerShell 7+).

mt shortcuts add b "bun install"
mt shortcuts add bt "bun test"
mt shortcuts add mkcd "mkdir -p {1} && cd {1}"
mt shortcuts sync                                               # one-time: wires up your rc file
exec zsh
b foo                                                           # → bun install foo
mkcd test/dir                                                   # → mkdir -p test/dir && cd test/dir

Templated args use {N} / {@} placeholders; mt translates per shell.

Built-in utilities

mt kp 3000 5173               # kill processes on ports (cross-platform)
mt sv ./public -p 8080        # serve directory as static web server
mt open .                     # cross-platform Finder/Explorer/xdg-open
mt awake                      # block system/display sleep + screensaver until Ctrl+C
mt rimraf node_modules dist
mt compress *.png             # lossless image compress in-place (with .bak)
mt convert webp *.png         # batch format conversion (lossless quality 100)
mt paste shot.png             # save the clipboard image as a PNG
mt paste                      # same, named clipboard-YYYYMMDD-HHMMSS.png

Supported image formats: PNG, JPG/JPEG, WebP, AVIF.

mt paste reads the image on the system clipboard and writes it as a PNG. It uses osascript on macOS, PowerShell on Windows, and wl-paste (Wayland) or xclip (X11) on Linux - one of those two has to be installed for the Linux path. An existing file at the target is overwritten.

Linter

mt lint is a generic project linter - works in any project, not skill-specific. ~35 rules covering things ESLint doesn't: secrets (AWS/JWT/Stripe/GitHub PATs), AI-slop phrases, Turkish diacritics, typography (smart quotes, em-dash, mojibake), design tokens (off-scale px/rem, raw hex, magic z-index), markdown structure, cross-platform footguns, and i18n locale sync.

mt lint                       # lint staged files
mt lint --all                 # lint all tracked files
mt lint --fix                 # apply safe auto-fixes
mt lint init                  # write .mt/lint.json (auto-detects project type)
mt lint list-rules            # show all rules + severity
mt lint explain <ruleId>      # show rule detail
mt lint install-hook          # install pre-commit hook

Watching (mt watch)

Watch something from the background and have its developments delivered into your agent chat, so you do not poll in a loop. Three targets: a pull request (mt watch pr), an issue (mt watch issue), and a repository (mt watch repo).

PR watching

Watch a pull request and have its developments (CI results, new commits, reviews, comments) delivered into your agent chat, so you do not poll gh pr checks in a loop.

mt watch pr subscribe   --pr "owner/repo#123" --thread <session-id> [--events checks,commit,review,comment] [--interval 30]
mt watch pr unsubscribe (--pr "owner/repo#123" | --all) --thread <session-id>
mt watch pr status      [--thread <session-id>] [--json]
mt watch pr wait        --thread <session-id> [--timeout 600]
  • --pr accepts owner/repo#N or a github.com PR URL. --thread is the agent's session id.
  • subscribe is idempotent per PR (re-subscribing updates events/interval and keeps the cursor) and starts a detached watcher process. The watcher polls with conditional (ETag) requests where GitHub supports them (the PR object and check-runs), so an unchanged poll of those returns 304 and does not count against the rate limit; review and comment polls are still counted. Against the 5000/hour authenticated budget this is comfortable for a handful of watches. It needs no cron, launchd, or root.
  • Updates land in chat through the pr-watch hook (Stop + UserPromptSubmit), the same state-gated pattern as the autopilot hooks: silent for any thread with no watches. An idle session wakes automatically when events land: an asyncRewake Stop waiter (Claude Code >= 2.1.x) sleeps until the watcher queues a batch, then delivers it as a system reminder and wakes the session. Codex has no rewake mechanism, so delivery there stays on turn boundaries. mt watch pr wait remains for an explicit blocking wait - run it with run_in_background; it exits 0 with the events as JSON, or 8 on timeout.
  • A merged/closed PR, or the 24h TTL, auto-removes the subscription with a final state event. When the last watch on a thread goes, the watcher stops itself.
  • Auth defaults to the gh CLI token (gh auth token), then falls back to ~/.mt/auth.json, $MT_GITHUB_TOKEN, $GITHUB_TOKEN. --key <gh|config|env> pins exactly one source. gh-first is deliberate: on a machine whose ~/.mt/auth.json points at an account that cannot see the org's private repos, config-first silently resolves a token that 404s.

Issue watching

Watch one issue and get told when someone comments on it, closes it, or changes its labels or assignees.

mt watch issue subscribe   --issue "owner/repo#12" [--thread <session-id>] [--events comment,state,label,assign] [--interval 30] [--key gh|config|env]
mt watch issue unsubscribe (--issue "owner/repo#12" | --all) [--thread <session-id>]
mt watch issue status      [--thread <session-id>] [--json]
mt watch issue logs        [--thread <session-id>] [--lines 50]
  • --issue accepts owner/repo#N or a github.com issue URL. A /pull/N URL is rejected: that is a mt watch pr input.
  • Default events are comment,state,assign; label is opt-in, since label churn is triage traffic most watches never act on. state is always implicit.
  • The first poll seeds from the issue's current state and emits nothing, so subscribing to a three-year-old issue never replays its comment history into your chat.
  • A close is terminal: it arrives as a final state event and drops the subscription, the way a merge drops a PR watch. There is no reopen event, because a surviving watch is always on an open issue.
  • subscribe runs a one-shot access preflight. It also tells you when the number is actually a pull request - GitHub numbers issues and PRs from one sequence, so #12 may well be a PR, and an issue watch on it would silently carry no checks or reviews. The poller stays quiet about this on purpose: a permanent error note there would back the watch off to its 300s ceiling for the whole TTL.
  • An issue watch and a PR watch on the same number coexist. Subscription ids are kind-prefixed (issue:owner/repo#12) precisely so the two cannot overwrite each other.
  • Defaults: interval 30s (15s floor), TTL 24 hours - the same as a PR watch, since an issue watch resolves when the issue closes.

Repo watching

Watch a repository and get told when a new pull request or issue is opened in it, so an agent can react (review it, subscribe it) instead of noticing by accident.

mt watch repo subscribe   --repo "owner/repo" [--thread <session-id>] [--events pr,issue] [--interval 60] [--key gh|config|env]
mt watch repo unsubscribe (--repo "owner/repo" | --all) [--thread <session-id>]
mt watch repo status      [--thread <session-id>] [--json]
mt watch repo logs        [--thread <session-id>] [--lines 50]
  • --repo accepts owner/repo or a github.com repo URL. Subscribe to several repos by calling subscribe once per repo.
  • It reports one thing: something was opened. A repo watch does not subscribe what it finds and starts nothing on its own. You decide, per item, whether to act and whether to track it with mt watch pr subscribe --pr owner/repo#N or mt watch issue subscribe --issue owner/repo#N.
  • --events picks the kinds: pr (the default, and what every subscription made before issue support keeps meaning), issue, or both. Each kind carries its own cursor and its own ETag, so turning issues on later never replays the ones already open. Changing the set restarts the watcher, so a process from an older build cannot keep polling only the kinds it knew about.
  • The first poll of each kind seeds from what is currently open and emits nothing, so items already open when you subscribed never arrive as "new". After that, everything numbered above that kind's cursor produces one opened event, oldest first.
  • GET /issues serves pull requests too, so the issue half drops entries carrying pull_request: a PR is never announced twice.
  • No filtering otherwise: drafts and bot PRs are reported too. Decide which ones deserve action in the instructions you give the agent.
  • Defaults: interval 60s (15s floor), TTL 7 days. Errors are soft - a 404/403/network failure keeps the watch alive and surfaces in status, since a repo can become visible later.
  • One watcher process per thread serves every watch - PR, issue and repo alike; unsubscribe --all drops all of them and stops it.

Listening (mt listen)

Get a desktop notification the moment a PR or issue you care about moves - reviewed, commented on, merged, closed, relabeled, reassigned - without opening GitHub or subscribing to each one by hand. Unlike mt watch (one Claude session, thread-scoped, gone after 24h), mt listen is account-wide and session-independent: it polls your GitHub notifications feed once per turn and stays up for as long as you leave it running, on a desktop, with no agent session required.

mt listen          [--interval 60] [--reasons author,assign,mention,review_requested] [--events review,comment,state,label,assign] [--notify auto|osascript|cockpit|both|none] [--key gh|config|env] [--daemon]
mt listen status    [--login <name>] [--json]
mt listen stop      [--login <name>]
mt listen logs      [--login <name>] [--lines 50]
  • One feed request per turn, not one per PR. It polls GET /notifications (with If-None-Match), the same endpoint GitHub's own notification bell uses, instead of mt watch's per-thread polling - so a thread that did not move costs nothing at all, and an unchanged feed costs nothing even for the ones that did (304, x-ratelimit-used does not move). A thread that did move is then handed to the existing poll engine, a handful of requests each; the bootstrap turn pays that once per in-scope thread.
  • reason is the scope filter, for free. GitHub's notifications API already tags each item with why it is in your feed (author, review_requested, assign, mention, ...); --reasons is just an allowlist against that field, so "PRs I opened, was asked to review, was assigned, or was mentioned in" needs no extra API calls to compute.
  • CI/checks are deliberately out of scope this iteration. --events accepts review,comment,state,label,assign; checks and commit are rejected with a usage error rather than silently ignored, since the notifications feed does not carry check-run activity anyway - a real check/CI listener would need its own per-PR polling loop, the exact cost this design avoids.
  • The first turn after a fresh start only prints seeded N threads. It seeds a cursor for every thread currently in scope and produces no events or notifications, so listening for the first time never floods your desktop with everything already open - only what changes after that counts as news.
  • The feed is a 50-item window. Each turn reads per_page=50 with all=true and no pagination, so on an account with more than 50 threads in flight the bootstrap turn seeds the newest 50 and the rest arrive later as a generic first-seen line rather than their actual event; if more than 50 move inside one interval, the overflow is picked up on a later turn only once it moves again.
  • A new thread's first event comes from the notification itself, not the poll engine. The seeding poll's own output is discarded on purpose - the engine does emit on a first poll (a PR that is already merged reports its terminal state straight away) - so the item that just entered your scope announces itself once, from the notification, instead of twice or not at all.
  • State is keyed by GitHub login (~/.mt/listen/<login>/), resolved via GET /user at startup and printed as listening as <login>, because the token mt gh resolves and the token gh auth token resolves are not guaranteed to be the same account on every machine - you should always be able to see which one you are actually listening as. Two accounts can listen side by side; status/stop/logs default to every login found on disk and take --login to scope to one.
  • Only one listener per login at a time. A second mt listen for an already-running login prints the live pid and exits 3 instead of starting a competing poll loop against the same state file.
  • A thread whose poll keeps failing is never silent. The failure is recorded on that thread (cleared on its next successful poll) and reported twice: once as a log line the turn it first happens, and continuously in mt listen status's error count, so a stuck thread does not require opening state.json by hand to notice.
  • A corrupt or unreadable state.json still starts a fresh listener rather than crashing, and says so on the next turn instead of silently discarding whatever was there.
  • --daemon re-execs the identical foreground loop detached (spawn(..., {detached:true}).unref()), the same primitive mt watch's background watcher uses - no cron, no launchd, no root. Its stdout/stderr land in the per-login log mt listen logs tails.
  • The effective turn interval is max(--interval, X-Poll-Interval): --interval sets your floor (default 60s, minimum 30s - a value between 1 and 29 is clamped up to 30 with a note, while zero, a negative and a non-number are usage errors at exit 2), but if GitHub's response asks for a longer wait that wins, so the listener never polls faster than GitHub wants.
  • Notification delivery goes through src/shared/notify.ts (--notify auto|osascript|cockpit|both|none, default auto: Cockpit's file queue if Cockpit is running, otherwise a native osascript/notify-send banner) - extracted from the truncate/spawn logic in hooks/pretooluse/notify-ask-question.ts, which still carries its own byte-identical truncate() because a hook cannot import from src/. That is the only other truncation rule in the tree; the platform switch has one further copy, in src/cli/autopilot/tick.ts, which has no such excuse and is the follow-up worth migrating onto this sink.
  • Auth defaults to the same gh-first chain as mt watch (gh auth token, then ~/.mt/auth.json, then $MT_GITHUB_TOKEN/$GITHUB_TOKEN); --key gh|config|env pins one source. No token resolving anywhere is a clean mt: listen: ... message at exit 2, never a stack trace.

Claiming a working tree (mt claim)

Several agents run on one machine at once. Two of them landing in the same working tree produce failures that look like nothing from either side: a poisoned git index, an overwritten uncommitted change, two branches racing one remote ref, a test run whose output belongs to somebody else's edit. Every command succeeded, so nothing reports it.

A claim is an agent saying "I am driving this working tree", in a place every other agent on the machine can check.

mt claim tree acquire [slug] [--path <dir>] [--actor <name>] [--note <text>] [--scope <glob-or-file>]... [--takeover] [--session <id>] [--json]
mt claim tree check   [slug] [--path <dir>] [--scope <glob-or-file>]... [--session <id>] [--json]
mt claim tree renew   [slug] [--path <dir>] [--actor <name>] [--session <id>] [--json]
mt claim tree release ([slug] | --path <dir> | --all) [--actor <name>] [--session <id>] [--json]
mt claim tree list    [--repo <org/repo>] [--session <id>] [--stale] [--json]
mt claim tree sweep   [--json]
mt claim tree doctor  [--json]
  • The identity is a slug: org/repo@branch, plus a #worktree discriminator when the tree is a linked worktree. You may declare it, but mt measures the real one from git and refuses a declaration that disagrees, printing what it measured. A claim on a slug that does not describe reality protects nothing while reading as protection, which is worse than no claim at all. With no argument it measures one and prints it, so the ordinary call is mt claim tree acquire.
  • A directory that is not a git repository cannot be claimed. That is deliberate: it keeps a session started in a multi-repo workspace root from holding every repo under it. Such a session takes one claim per repo it actually writes to.
  • Two grades of conflict. The same working tree held by another session is hard: exit 3, and the message names the holder and suggests a free worktree path, because "work somewhere else" is only useful advice when it comes with a path. The same org/repo@branch in a different tree is soft: the claim is written, exit 0, and a warning goes to stderr. Those two do not clobber each other's files; they argue over the remote ref. (With --scope, below, "held" narrows to "held on files your scope can touch".)
  • A claim can cover part of a tree. --scope <glob-or-file> (repeatable, relative to the tree root) claims only those paths; no --scope claims the whole tree, as before. Two sessions may hold one tree, on one branch, when their scopes cannot match a common file: the acquire succeeds, exit 0, and a warning on stderr names the other holder, its scope and the git rule below. If any file can match both scopes it is hard again (exit 3, and the message prints the colliding patterns). The check is a real glob-against-glob intersection, not a comparison of strings, so apps/*/src/** collides with apps/web/src/a.ts and not with apps/web/docs/a.md; when it cannot tell, it says they overlap, because a false "disjoint" is the failure this feature exists to prevent. Patterns: * (within one path segment), ** (whole segments, also zero of them), ? and {a,b}; a directory name (src/, or a bare src/newdir that does not exist yet) covers everything below it; comparison ignores case. There are no character classes, and a [ or ] in a --scope is refused (exit 2) rather than read as a plain character: src/[ab].ts would silently claim a file literally named that. Write ? for each bracket (app/?id?/** covers app/[id]/) or claim the parent. ( and ) are ordinary characters. Relative patterns are relative to the tree root, not the current directory, and a symlinked path is resolved to where the file really is (so CLAUDE.md and the AGENTS.md it points to collide; a link that leads outside the tree, or whose real name uses *, ?, {, }, [, ] or \, is refused). Only the part of a pattern before its first *, ? or { can be resolved: a link hidden behind a wildcard or a brace group ({CLAUDE,README}.md, CLAUDE.m?) is not seen when the claim is taken, so two names for one file can still both be acquired there; the guard warns the other session when it edits. A control character in a scope is refused. --scope . is the whole tree, a scope outside the tree or containing .. is refused with exit 2, and so is syntax the dialect does not have (!negation, extglobs). A file name containing *, ?, {, } or \ cannot be claimed by its exact name; claim its directory. Repeating acquire with a new --scope replaces your scope; --takeover removes only the holders whose scope overlaps yours.
  • A scope does not make a shared tree safe for git. Disjoint files still share one index, one HEAD and one branch pointer. git stash, git checkout ., git restore ., git add -A and switching branches reach into the other agent's work whatever either scope says. Under a shared tree stage and commit by path: git add <paths>, git commit -- <paths>.
  • Owner is a session plus an actor. $CLAUDE_CODE_SESSION_ID is inherited by tool shells, so a session's main agent and every subagent it dispatches see the same id. --actor distinguishes them, so parallel subagents of one session are visible to each other (soft, not hard) instead of invisible.
  • Liveness, not bookkeeping. A claim counts only while its agent process is alive AND it was renewed inside two hours. A crashed agent's claim is reclaimable at once; a forgotten one expires on its own. Editing through the file tools renews its claim, so an agent editing files never loses one; a session that writes only through Bash (sed -i, a heredoc, git) renews nothing and should mt claim tree renew, because the guard matches the edit tools and not arbitrary shell.
  • list is the board, not your claims. It never narrows to the caller's own session, even when the environment names one: an agent running it to find out whether anyone else is in a tree would otherwise see only its own rows, which is the opposite of the question it asked. --session narrows only when you type it.
  • --takeover acquires through a hard conflict and records who was displaced. sweep clears dead claims by hand; doctor reports whether the hooks below are actually installed, across both ~/.claude/settings.json and ~/.codex/hooks.json.

Exit codes: 0 free, soft or re-entrant; 1 from doctor when something is broken; 2 usage or verification failure; 3 hard conflict, or releasing another session's claim; 4 the registry lock could not be taken.

The registry is one file, ~/.mt/claims.json, machine-local and behind a cross-process lock. Two agents on two laptops pushing one branch is a different problem with a different answer.

The hooks that make it speak

The CLI is the gate; the hooks are what make the mechanism work when no gate was called.

  • PostToolUse touches a per-session heartbeat, so a session busy inside one long turn stops reading as idle (mt sessions uses the same signal).
  • PreToolUse watches file edits. Writing a file another live session holds (its scope covers the file, or it claimed the whole tree) gets you a note naming the holder and its scope; a scoped neighbour that does not cover the file stays silent. Writing a file outside your own scope gets you the command to widen it, and writing into a tree you never claimed gets you the exact command to claim it (only that file, with --scope, when another session holds a part of the tree, because a whole-tree acquire would be refused), each at most three times, fifteen minutes apart, then silence. It never blocks a call and never touches a permission decision: a guard that bricks the session that merely forgot to claim gets turned off within a day.
  • SessionStart lists whatever is live on the machine, and prints nothing when nothing is.
  • SessionEnd releases the session's claims, best effort. It is an optimisation, not the guarantee: SessionEnd does not run on a crash, which is what the two-hour ceiling is for.

Agent hooks (Claude, Codex, Gemini, OpenCode)

Built-in hooks ship with mt and are merged on mt sync into:

  • ~/.claude/settings.json (Claude)
  • ~/.codex/hooks.json (Codex)
  • ~/.gemini/config/hooks.json (Antigravity)
  • ~/.gemini/settings.json (Gemini CLI)
  • ~/.config/opencode/plugins/mt-hooks.ts plus mt-hooks.manifest.json (OpenCode)

Pick which ones you want via ~/.mt/hooks.json (include / exclude over predefined ids).

mt setup --profile mt --global    # writes ~/.mt/hooks.json with include: ["*"]
mt hooks list                     # show built-in hooks + enabled state
mt hooks enable  <id>             # turn a hook on
mt hooks disable <id>             # turn a hook off
mt hooks sync                     # reconcile both hook files now

Shipped hooks:

idwhat it does
block-claude-coauthorBlocks a Claude/Anthropic or Cursor co-author trailer, a Generated/Made with … attribution line, or an @anthropic.com / @cursor.com email - in git Bash calls and in file content (Write/Edit/MultiEdit on Claude Code, apply_patch added lines on Codex), closing the bypass of writing the trailer into a file and git commit -F-ing it.
block-host-destructiveBlocks Bash commands that write HOST-scope state shared by every agent, session and user on the machine: Wi-Fi radio, network interfaces, routing, DNS, packet filter, power/shutdown, launchd, system config, disks, keychain, and kills of the agent session or the runtime it runs on. Reads are never blocked, so diagnosis stays possible (pmset -g log, networksetup -getairportpower, diskutil list), and turning a radio back on is recovery and passes. A heredoc fed to a shell or written into a script is scanned; one fed to a known non-shell interpreter (python3, jq, psql, ...) is that interpreter's data and is not. Deny messages name an in-scope alternative. There is no per-call override: for a deliberate, approved change to this machine, run mt hooks disable block-host-destructive first.
block-foreign-pr-mergeBlocks any Bash command that merges a pull request whose author is not the account gh is authenticated as in that directory - gh pr merge, mt gh pr merge, a REST PUT .../pulls/N/merge, a GraphQL mergePullRequest. Reading, reviewing, commenting on and checking out other people's PRs are untouched; only the irreversible act is denied. A merge the guard cannot pin to one concrete PR is denied rather than waved through, so gh pr merge $n inside a loop is refused and merges name their PR. So is a merge whose author lookup fails. The identity is resolved the same way the merge would resolve it (mt gh where a git profile governs the directory, ambient gh otherwise). Unlike every other hook here it has no agent-reachable escape hatch: mt hooks disable block-foreign-pr-merge is itself denied, so lifting it is something the human does in their own shell. Origin: on 2026-08-06 an agent told to merge four named PRs swept the workspace for APPROVED + CLEAN and merged three colleagues' PRs into dev/devx under the user's account in one 13-second loop; the first author opened a revert PR two hours later. Known false positive, same class as block-host-destructive and left in the safe direction: a multi-line git commit -m (or any Bash-borne prose) that quotes a merge command is read as running one, because the tokenizer resets quote state at each newline. Write that text through a file (git commit -F, Write) or reword it; the guard is not the thing to bend.
block-em-en-dashBlocks new content containing an em-dash or en-dash (AI-typography tell) - Write/Edit/MultiEdit on Claude Code, apply_patch added lines on Codex. The hook self-exempts edits to its own source under hooks/pretooluse/block-em-en-dash.{ts,partial.json,partial.codex.json}; for other files that legitimately contain those characters, run mt hooks disable block-em-en-dash first.
protect-hooksBlocks Write/Edit/MultiEdit (Claude Code) and apply_patch (Codex) edits whose target is a managed hook source (hooks/pretooluse/*.{ts,partial.json,partial.codex.json}, hooks/lib/*.ts, hooks/registry.json, and anything under ~/.agents/hooks/), so the agent can't weaken or disable the guards by editing their source. To change a hook, run mt hooks disable <id> first, edit, then mt hooks sync. Covers file-tool edits only - not Bash sed/redirection, nor the merged settings.json/hooks.json.
inject-mt-startSessionStart hook (fires on startup/clear/compact): injects the mt-start development-workflow skill into context, plus the workspace docs/MEMORY.md and docs/WATCHLIST.md when present (memory first, watchlist last, so the due items sit closest to the user's message). Reads the installed skill at ~/.agents/skills/mt-start/SKILL.md (single source of truth, no duplication) and stays silent when it is absent. Each file's path resolves from $MT_DOCS, then $WS/docs, then ~/Documents/Coding/docs.
notify-ask-questionPreToolUse on AskUserQuestion and ExitPlanMode: fires an OS notification (osascript on macOS, notify-send on Linux) whenever the agent hands control back to you, so neither a question nor a plan awaiting approval goes unnoticed in a background or unfocused session. Titled with the current project directory; bodied with the first question's text, or with the plan's first meaningful line (markdown heading/bullet markers stripped). Bodies are capped at 120 chars, since ExitPlanMode carries the whole plan document. Never blocks the call - a missing/failing notifier is silent. Codex has no ExitPlanMode tool today, so that matcher alternative is inert there.
agent-backgroundPreToolUse on Agent: forces every subagent call to run_in_background: true via updatedInput, no matter what the model asked for, and attaches a short additionalContext note telling it to use the wait for independent next-step work instead of idling. Codex has no Agent/subagent tool today, so the matcher never fires there.
claimPostToolUse + PreToolUse + SessionStart + SessionEnd: makes mt claim speak when nobody called it. The PostToolUse half touches a per-session heartbeat on every tool call, which is what stops a session busy inside one long turn from reading as idle. The PreToolUse half watches file edits: writing into a working tree another live session holds gets a note naming the holder; writing into a tree this session never claimed gets the exact command to claim it, at most three times, fifteen minutes apart, then silence. It observes and annotates, it never gates a call and never touches a permission decision - a guard that bricks the session that merely forgot to claim gets turned off within a day. SessionStart lists what is live on the machine and prints nothing when nothing is; SessionEnd releases the session's claims best-effort, waiting at most 300ms for the lock rather than eating the 1.5s budget every SessionEnd hook shares. Liveness is the agent's real pid plus a two-hour renewal ceiling, because SessionEnd does not run on a crash.
resume-without-user-inputPermissionRequest + PreToolUse on AskUserQuestion/ExitPlanMode: resumes a call that has been waiting on a human for too long, judged per interaction rather than by machine idle time. Every call that hands control back is registered in ~/.mt/pending-tool-calls/<uuid>.json the moment it starts waiting; once it has been pending for the window (default 90s, MT_RESUME_SECS) the hook answers with the safe and recommended option and tells the model a hook answered, not a person. This is the gap afk-auto-approve cannot see: while you type in another window, macOS idle time never crosses its threshold, so a dialog in a pane you are not looking at stalls indefinitely. The question and plan surfaces gate on presence before holding anything, because there a wait hides the call instead of delaying it (MT_RESUME_PRESENT_SECS, see below). Pushing or merging to main is never auto-approved, deny/ask rules still win, and mt pending observes or overrides any waiting call. Silent during autopilot runs and with MT_RESUME=0.
pin-repo-cwd (opt-in)PreToolUse on Bash: denies a repo-touching git or gh call that carries no absolute anchor of its own, so it cannot answer from whichever sibling repository the cwd happens to be sitting in. Denied: a bare repo-touching call after a relative cd (or after a cd whose target the shell computes at run time, cd $D), a git -C/--git-dir/--work-tree value that is relative or computed, a gh -R/--repo/GH_REPO= value that is computed, and any repo-touching call made from a cwd that is not inside a git repository at all. The deny message prints the same call pinned, with /abs/path/to/repo and OWNER/NAME as placeholders, because the hook cannot know which repo you meant. Never touched: anything that is not git/gh/mt git/mt gh; git clone/init/version/help/ls-remote, any --help/--version, git config --global/--system; every gh command outside pr, issue, run, workflow, release, label, secret, variable, cache, ruleset, browse; and heredoc bodies, which are text being written somewhere rather than commands being run. It never spawns a process, never writes a file, and any internal error is a silent pass. Opt-in: include: ["*"] does not select it; run mt hooks enable pin-repo-cwd then mt sync --global. Four known limits: an absolute cd into the wrong sibling repo is allowed (the hook can tell a path is pinned, never that it is the pin you meant); a bare call with no cd at all is allowed whenever the shell is already parked inside any repo, so sitting in repo A and asking about repo B is not adjudicated; code handed to a shell runner (bash -c "...", sh -c, eval) is not inspected; and only the eleven gh subcommands listed above are inspected, so an unpinned gh repo view or gh api repos/... can still answer from the cwd's repo. Origin: issue #28 measured five agent runs in this multi-repo workspace answering from the wrong repository, one of them reporting a pull request as MERGED while the PR asked about was OPEN; two prose rules in skills were reasoned around, hence a mechanism.
progress (opt-in)PostToolUse + Stop: every MT_PROGRESS_SECS (default 300, floor 30) of a session's working time, asks the agent for a three-part progress report - what has been done, what is happening or being waited on, what is next - and has it run mt progress report, which measures the branch, commit, pull request and issue facts itself instead of trusting the narrative (see Progress reports). When the run keeps an mt task list, the prompt first has the agent reconcile that list (read back by run tag, statuses moved, new work added) and write the three parts from it, so the report is written from the list rather than from memory. PostToolUse is the only event that fires inside a long turn, which is exactly the session a human cannot follow; every other lifecycle hook waits for a turn boundary that may be an hour away. The steady-state not-due path is one statSync and prints nothing, because PostToolUse stdout is fed back to the model after every tool call; the only write off the firing path is arming the very first window. Silent for a dispatched subagent: it shares its parent's session id, so the hook compares the event's transcript_path against the session record's own transcript. That comparison needs a session record, which only the separate session-track hook writes: with progress enabled and session-track disabled there are no records, nothing is suppressed, and a subagent both gets prompted and consumes its parent's window. The <SUBAGENT-STOP> line in the injected prompt is the only guard in that configuration. Silent with MT_PROGRESS=0. The Stop half never prompts and never blocks - it marks the session idle in the feed, so a stale "currently doing X" cannot hang there after the turn ended, and it leaves the window's clock running. Opt-in: include: ["*"] does not select it, because it injects text into every session on a clock and spends a tool call answering; run mt hooks enable progress then mt sync --global.
stopfailure-auto-resume (opt-in)StopFailure (+ Stop to reset the counter): on a retryable API error (rate_limit, overloaded, server_error, unknown) that ends a turn, backs off then wakes the session with exit 2 so it retries automatically - the same idea as claude.ai's usage-limit auto-resume, applied to a transient API failure instead of a quota reset. StopFailure has no decision control at all, so nothing here can stop the error from ending the turn; what it can do is run in the background afterward and wake the now-idle session later, the same asyncRewake mechanism pr-watch/inbox use to wake a session for an external event. Backoff is exponential with jitter, doubling from MT_STOPFAILURE_BASE_MS (default 20s) up to MT_STOPFAILURE_CAP_MS (default 240s); the run gives up after MT_STOPFAILURE_MAX_RETRIES consecutive failures (default 5) and drops a Cockpit "needs-input" banner so a human notices instead of the session sitting there retrying forever. A plain Stop (the turn recovered) clears the counter, so a later unrelated error starts its own backoff from attempt 1. Left alone on purpose: authentication_failed, oauth_org_not_allowed, account_on_hold, billing_error, invalid_request, model_not_found, max_output_tokens - none of those are transient, so a retry fails the same way and only delays a human noticing something needs fixing. Opt-in: include: ["*"] does not select it, because it spends API usage on your behalf without asking each time; run mt hooks enable stopfailure-auto-resume then mt sync --global. MT_STOPFAILURE_RESUME=0 turns it off entirely. Codex has no StopFailure-equivalent event in this integration today, so the Codex partial is empty and the hook is Claude Code only.

Hooks marked opt-in are not selected by "*"; mt hooks enable <id> turns them on.

The merge is idempotent: managed handlers are matched by absolute script path under ~/.agents/hooks/, so user-added entries in any target file are preserved untouched. Hooks are global only. Gemini CLI timeouts are converted from seconds to milliseconds. Antigravity uses a hook-id keyed hooks.json. OpenCode loads a plugin that runs the same scripts.

Codex note: Codex requires reviewing and trusting non-managed hooks before they run, keyed to the exact hook definition - after a sync adds or changes hooks, run /hooks inside Codex to trust them. Per-hook Codex variants live next to the Claude partials as *.partial.codex.json (the Claude partial is reused when no variant exists).

Calls waiting on you (mt pending)

The state behind the resume-without-user-input hook: one JSON file per call that is currently blocked on a human, under ~/.mt/pending-tool-calls/.

mt pending list [--json]        # what is waiting, and how long until it resumes
mt pending sweep [--watch]      # stamp calls past their window, drop dead-session orphans
mt pending resume <id>          # continue one now instead of at the window
mt pending hold <id>            # keep it for yourself; the dialog waits however long you need

One policy, two readers. The hook holding a call polls its own entry, so the window is enforced with nothing scheduled; mt pending sweep applies the same rule from outside, which is what makes a call resumable early (resume), cancellable (hold), observable while it waits, and collectable when the session that was waiting is killed (a SIGKILL runs no cleanup, so the entry outlives its waiter until a sweep drops it). Ids accept any unambiguous prefix, so the 8 characters list prints are enough.

Cron is optional. Add it if you want orphan collection and stamps without opening a terminal:

* * * * * $(which mt) pending sweep >/dev/null 2>&1     # crontab
mt pending sweep --watch --interval-secs 5              # or a loop in a terminal

Questions and plans are gated on presence first. A PreToolUse hook returns before its tool runs, and for AskUserQuestion and ExitPlanMode the tool's own run is the render: while the hook waits there is no dialog on screen at all. Holding one of those for a user who is sitting right there does not delay the question, it hides it and then answers it for them. So those two surfaces use machine idle, not the interaction clock: they are held only for a user who has not touched this machine for the whole window, real keyboard or mouse input during a wait hands the call straight back within a second, and an unreadable idle signal (non-darwin, remote session, broken ioreg) always renders the question. The permission dialog keeps the interaction clock, because it can be held without being hidden.

Knobs: MT_RESUME_SECS (window, default 90), MT_RESUME_PRESENT_SECS (how recently you must have touched the machine to count as present, default: the window itself) and MT_RESUME=0 (off). A window far above the hook's 150s timeout in settings.json cannot land: the hook is killed first and the dialog simply waits for you, which is the fail-open direction everywhere in this feature.

Progress reports (mt progress)

A long session is hard to follow: it says it is continuing, and you cannot tell whether it opened the pull request, whether it was supposed to, or what it is waiting on. mt progress makes each session say so on a clock, and measures the parts a narrative gets wrong.

mt progress log --follow                 # the live feed of every session, full reports
mt progress log --session <id> [--all]   # one session; --all includes stale ones
mt progress status [--json]              # one line per session: last report + PR state
mt progress report --done "..." --now "..." --next "..."   # the agent runs this, not you
mt progress sweep [--json]               # drop tick dirs of dead sessions; feeds are kept

The progress hook (opt-in, see the hooks table) drives it. Every MT_PROGRESS_SECS of a session's working time it hands the agent three headings to answer: what has been done so far, what is happening or being waited on right now, and what is next. When the run keeps an mt task list, the prompt first has the agent reconcile that list (read back by run tag, statuses moved, new work added) and write the three parts from it, so the report is written from the list rather than from memory. The agent answers by running mt progress report. The clock is cumulative across turns: a session whose turns are all short still reports, because a turn ending does not restart the window.

The facts are measured, not claimed. The agent writes the three narrative fields and nothing else. mt progress report resolves the branch, the ahead count, the working-tree state, the last commit, the pull request on that head branch (gh pr list) and the issues opened by you since the session started, and writes those into the report itself. An agent that believes it opened a PR when it did not produces a report that says there is no PR. That is the whole point of the command existing.

Reports append to ~/.mt/progress/<session>.jsonl, one JSON line each, and mt progress log reads them back. A desktop or Cockpit banner fires only when a measured fact actually changed (a PR appeared, its state, draft flag or check results moved, an issue was opened, the branch changed) or the report is marked blocked, so the feed carries everything and the banner stays worth looking at. Banner bodies are capped at 120 characters, like every other notification here: the banner is a glance, the feed is the report.

Knobs: MT_PROGRESS_SECS (interval, default 300, floor 30), MT_PROGRESS_NOTIFY (auto|osascript|cockpit|both|none, default auto) and MT_PROGRESS=0 (off).

Two limits, stated rather than hidden. A single tool call that runs for ten minutes fires no PostToolUse, so no report is produced inside that window; the feed shows the age of the last report instead of pretending. And a dispatched subagent shares its parent's session id, so the hook compares the event's transcript_path against the session record's own transcript and stays silent for anything else.

Your task list (mt task)

Arbitrary work you do not want to forget, kept outside any one agent session: one markdown file per task under ~/.mt/tasks/<id>.md.

mt task add "Fix the ads tx-history bug" --repo backend --cwd /path/to/backend --tag p2
mt task add "Rewrite the paywall copy" --body-stdin < notes.md
mt task list [--json]                  # grouped by status
mt task show <id>                      # the whole thing, body included
mt task set <id> --status in_progress  # todo | in_progress | blocked | done
mt task link <id> --session <sid>      # record an agent session started for this task
mt task sweep                          # drop links to sessions the registry lost
mt task doctor                         # is anything failing to parse?

This is not mt autopilot task, and the difference is the point. An autopilot task belongs to one run: it is filed to keep a thread going and it dies with the goal. These outlive every session, have no goal above them, and are the thing you start a session from - which is why each carries a markdown body (the context a new session gets handed) and the list of sessions that have worked on it.

Markdown with YAML frontmatter rather than JSON, because the body is written for a reader: links, constraints, what "done" means. Writes are atomic (tmp + rename) with no lock, so a concurrent reader never sees half a task, and a file that stops parsing is skipped rather than guessed at - which is what doctor exists to surface, since a quietly short list looks exactly like a genuinely short one.

link is how a task and an agent session find each other. Cockpit writes it the moment it starts a session (with an id it minted itself, so the link exists before the session's own hooks fire), and mt sessions list turns those ids back into live state. sweep drops ids the registry no longer knows - the links only; the task itself is never swept, since outliving its sessions is the whole reason it exists.

Git profiles + command policies

Declarative git/GitHub identities and per-repo command policies, reconciled by mt sync into git's own native config so git works automatically for every caller (human, IDE, agent, script, local CI) - no shell wrapping required for git itself.

Source of truth: ~/.mt/git.json (chmod 600), two top-level arrays:

{
  "profiles": [
    {
      "id": "work",                 // stable slug [a-z0-9-]
      "name": "Metehan Kurucu",     // git user.name
      "email": "metehan@acme.com",  // git user.email
      "ghToken": "ghp_...",         // optional - omit for a passthrough profile
      "repos": ["/Users/mt/Documents/Coding/work"],
      "isDefault": false
    }
  ],
  "policies": [
    {
      "repos": ["/Users/mt/Documents/Coding/work"],
      "default": "allow",
      "rules": [
        { "deny": "git push", "when": { "branch": "main" }, "message": "no direct push to main" },
        { "deny": "gh pr merge" }
      ]
    }
  ]
}
  • Profiles (profiles): id/name/email/ghToken/repos/isDefault. Exactly one profile must have isDefault: true (it catches every path not matched by another profile's repos); the longest matching repos prefix wins for overlapping paths. A profile with no ghToken is a passthrough: identity is set but auth is left to your existing git/gh setup.
  • Policies (policies): repo-scoped rules of deny/allow command patterns ("git push", "gh pr merge", ...), each optionally gated by when.branch (glob), when.args (token match, e.g. --force), when.remote. First matching rule wins; unmatched commands fall back to default ("allow" = denylist, "deny" = allowlist).
mt git-profile add --id work --name "..." --email "..." --repos <p1,p2> [--token-stdin] [--default]
mt git-profile list [--json]         # never prints tokens; shows hasToken
mt git-profile set <id> ...          # same flags as add
mt git-profile remove <id>
mt git-profile bind <id> <path>      # add a path to a profile's repos
mt git-profile unbind <path>
mt git-profile which [--path <dir>]  # which profile + policy govern cwd/path + why
mt git-profile doctor                # verify gitconfig block, hooks, gh
mt git-policy list [--path <dir>] [--json]   # effective rules for a path
mt git-policy check "git push origin main"   # dry-run a command (ALLOW/DENY + why)
mt git <args...>                     # policy-checked git wrapper
mt gh <args...>                      # policy-checked gh wrapper

Tokens are entered via --token-stdin only, never as a plain argv (shell history / ps leak).

mt sync reconciles git.json into ~/.gitconfig (a marker-delimited block: an unconditional include for the default profile, includeIf "gitdir/i:<path>/" for the rest, ordered by specificity) plus a per-profile gitconfig at ~/.mt/git-profiles/<id>.gitconfig that sets [user] identity, a credential.helper pointing at mt git-credential --profile <id>, and core.hooksPath = ~/.mt/git-hooks. That is what makes it automatic: any git invocation under a governed path, from anyone, picks up the right identity/auth/hooks with zero per-call wrapping.

Command policies are enforced two different ways depending on which tool is being policed:

  • git - natively, via the core.hooksPath hooks (pre-push, pre-commit) that mt sync scaffolds. They shell out to mt git-hook, which resolves the repo's policy and refuses the operation (e.g. blocking a push to main) before git completes it. This fires for every caller, not just interactive shells.
  • gh - has no native per-directory config surface, so there is no equivalent hook. Coverage is mt gh (and the gh alias below) checking the policy before spawning the real gh binary; a script or CI job invoking the real gh binary directly bypasses it.

mt git/mt gh (and the git/gh shortcuts the mt profile ships) exec the real git/gh binaries directly - no recursion. Native git enforcement (identity, credential helper, core.hooksPath) does not depend on the alias at all; it applies even if you never touch git/gh through mt. The alias is a convenience layer that adds an earlier, friendlier policy refusal at the interactive prompt for both tools, and is presently the only local coverage gh gets outside of a future agent hook.

Honest limits:

  • The pre-push/pre-commit policy hooks are a guardrail against mistakes, not an adversarial security boundary: git push --no-verify or a locally overridden core.hooksPath (e.g. husky) bypasses them. For unbypassable protection of a branch like main, use GitHub branch protection / rulesets (server-side) - mt's policy is local convenience on top of that, not a replacement for it.
  • gh has no native per-directory config, so its coverage is inherently narrower than git's: the mt gh wrapper and the gh alias, not a standalone script or CI job calling the real gh binary directly.

Tokens live only in ~/.mt/git.json (chmod 600); the generated per-profile gitconfig never embeds a token, it references mt git-credential --profile <id> (and mt gh-token), which read the token from git.json at call time.

Skill authoring

If you maintain a skills repo, these helpers operate on the repo's skills/, commands/, and agents/ folders:

mt skills validate            # check SKILL.md frontmatter + structure
mt skills new <name>          # scaffold a new skill (SKILL.md template)
mt skills list                # list skills + commands in this repo

Self-update

mt upgrade                    # re-install @codixus/mt@latest via your package manager

Configuration

mt uses a .mt/ config folder (npm-style global vs local).

FilePurposeScope
skills.jsonskill subscriptions + cherry-picksglobal + local
shortcuts.jsonshell aliasesglobal only
hooks.jsonAgent hook include/excludeglobal only
lint.jsonlint rules + ignore patternsglobal + local
manifest.jsonmt's record of installed files (don't edit)global + local
auth.jsonGitHub token (chmod 600)global only
tasks/<id>.mdthe global task list (mt task)global only

Auth chain (in order): ~/.mt/auth.json → $MT_GITHUB_TOKEN → $GITHUB_TOKEN → gh auth token. mt never embeds tokens; it delegates to whatever auth you already have.

License

MIT.

Keywords

cli

FAQs

Package last updated on 30 Sep 2026

Related posts