SLOPSHOPPER

NOTT

NOTT n=1 engineering runtime

newguardpromptmodeltimer
v0.1.0AGPL-3.0-onlyupdated 2026-10-21menot-you/plugin
A shopper browsing a rack in a slop shop
README

<img src="docs/figures/hero-plugin-dark.svg#gh-dark-mode-only" alt="NOTT, the plugin: turn Claude Code into an engineering team that remembers, tests first and never reviews its own work."> <img src="docs/figures/hero-plugin-light.svg#gh-light-mode-only" alt="NOTT, the plugin: turn Claude Code into an engineering team that remembers, tests first and never reviews its own work.">

<a href="LICENSE"><img src="https://img.shields.io/badge/license-AGPL--3.0-E4733A?labelColor=0F0E0D" alt="License: AGPL-3.0"></a> <a href="docs/INSTALLATION.md"><img src="https://img.shields.io/badge/python-3.14%2B-E4733A?labelColor=0F0E0D" alt="Python 3.14 or later"></a> <a href="https://modelcontextprotocol.io"><img src="https://img.shields.io/badge/MCP-3%20servers-E4733A?labelColor=0F0E0D" alt="Ships three MCP servers"></a> <a href="https://github.com/menot-you/plugin/releases"><img src="https://img.shields.io/github/v/release/menot-you/plugin?include_prereleases&label=release&color=93AE8C&labelColor=0F0E0D" alt="Latest release"></a> <a href="https://github.com/menot-you/plugin/stargazers"><img src="https://img.shields.io/github/stars/menot-you/plugin?color=93AE8C&labelColor=0F0E0D" alt="GitHub stars"></a>

Turn Claude Code into an engineering team that remembers, tests first and never reviews its own work.

Install

curl --proto '=https' --tlsv1.2 -fsSL https://no.tt/install.sh | sh

Or build from source. The plugin has no marketplace listing yet. You build a bundle and register it with the install helper. You need git, uv, Python and an authenticated Claude Code.

git clone https://github.com/menot-you/plugin && cd plugin
uv sync --group dev
python3 scripts/plugin.py build --source "$PWD" --output "$PWD/.nott/bundles/first" --runtime-root "$PWD/.venv"
python3 scripts/plugin.py verify "$PWD/.nott/bundles/first"

Then connect it to one project (use absolute paths that you own):

.venv/bin/nmemory project bootstrap --root /abs/project --project example --client claude
python3 scripts/plugin.py install --bundle "$PWD/.nott/bundles/first" --client claude \
  --config-root /abs/project/.claude --receipt /abs/private/activation.json \
  --runtime-root "$PWD/.venv" --memory-bootstrap /abs/project/.nott/nmemory/bootstrap.json
CLAUDE_CONFIG_DIR=/abs/project/.claude claude

The helper registers the plugin through the native Claude marketplace and plugin commands. It keeps your other client settings. For Codex, use --client codex and a .codex configuration root. The runbook covers each step, rollback and failure diagnosis.

How it works

%%{init: {"theme": "base", "themeVariables": {"primaryColor": "#1B1916", "primaryTextColor": "#ECE7DE", "primaryBorderColor": "#E4733A", "lineColor": "#E4733A", "secondaryColor": "#151311", "tertiaryColor": "#0F0E0D", "fontFamily": "IBM Plex Mono, monospace"}}}%%
flowchart LR
  you["You: direction"] --> client["Claude Code, Codex or AGY"]
  subgraph plugin["NOTT plugin: one source, one native view per client"]
    law["Working contract<br/>NOTT.md, 20,000-byte limit"]
    routes["20 task routes<br/>/nott:&lt;route&gt;"]
    agents["8 agents<br/>readers and a simplifier"]
    hooks["Guard hooks"]
  end
  client --> plugin
  plugin -->|"MCP"| mem["nMEMORY<br/>memory with sources"]
  plugin -->|"MCP"| spawn["nSPAWN<br/>tracked workers"]
  plugin -->|"MCP"| web["nBROWSER<br/>cited web evidence"]
  mem & spawn & web --> result["A checked result"]

Task routes load only when that kind of work starts. Each hook binds only where the client supports its event.

By the numbers

<img src="docs/figures/chart-tools.svg" width="49%" alt="MCP tools per server: nMEMORY 27, nSPAWN 15, nBROWSER 14, nAPPLE 10."> <img src="docs/figures/chart-tests.svg" width="49%" alt="Tests per component: nSPAWN 4335, nMEMORY 2339, nBROWSER 731, nAPPLE 106.">

Counted on 2026-10-21. Routes: ls skills | wc -l (20). Agents: ls agents | wc -l in the published plugin (8). Tools: the MCP tools/list request to each server. Tests: uv run --group dev pytest --collect-only -q in each member root.

Direction becomes a checked result.

Why

Without NOTTWith NOTT
Each session forgets the decisions of the last one.Session hooks recall past decisions and open work from a local memory store.
The agent writes the fix first and the test later, or never.A hook blocks product edits on a behavior change until a failing test runs.
The agent that wrote the change also approves it./nott:review freezes the diff and sends it to a separate read-only reviewer.
One bad shell command force-pushes main or prints a secret.Guard hooks refuse trunk pushes, force pushes and secrets in commands and output.
The agent repeats a failed fix again and again.After two misses on the same proof, the session must diagnose the cause first.

Quick start

  1. Open Claude Code in the project and give it one small change with a result that you can see.
  2. Type /nott:execute. The agent writes a failing test, makes it pass and reports the proof.
  3. Type /nott:review. A separate reviewer reads the frozen diff and returns findings.
  4. Start a new session and ask for the last decision. The answer comes from memory, with its source.

The routes cover the full cycle:

StageRoutes
Direction/nott:goal, /nott:readback, /nott:brainstorm, /nott:ratify
Planning/nott:plan, /nott:workflow, /nott:board, /nott:upscale
Execution/nott:execute, /nott:debug, /nott:quality, /nott:security, /nott:benchmark
Delivery/nott:review, /nott:ship, /nott:accept, /nott:release, /nott:reconcile, /nott:handoff, /nott:feedback

These are facts about NOTT, not claims about other tools:

  • A behavior test enters the suite only if it fails without the plugin and passes with it (--ablation with-without, measured by evals/native/).
  • The working contract has a hard limit of 20,000 bytes. Task routes load only when that kind of work starts.
  • One source builds separate native views for Claude Code, Codex and AGY. Each hook binds only where the client supports its event.
  • Memory and run records live in local SQLite files. The plugin supplies no account. The privacy page lists each external service.

The NOTT family

<table> <tr> <td align="center" width="20%"><a href="https://github.com/menot-you/n-memory"><img src="docs/figures/mark-nmemory.svg" width="160" alt="n-memory"></a><br><sub>Memory that cites its source.</sub></td> <td align="center" width="20%"><a href="https://github.com/menot-you/n-spawn"><img src="docs/figures/mark-nspawn.svg" width="160" alt="n-spawn"></a><br><sub>Tracked, resumable agent workers.</sub></td> <td align="center" width="20%"><a href="https://github.com/menot-you/n-browser"><img src="docs/figures/mark-nbrowser.svg" width="160" alt="n-browser"></a><br><sub>The live web as cited evidence.</sub></td> <td align="center" width="20%"><a href="https://github.com/menot-you/n-apple"><img src="docs/figures/mark-napple.svg" width="160" alt="n-apple"></a><br><sub>App Store Connect for agents.</sub></td> <td align="center" width="20%"><a href="https://github.com/menot-you/plugin"><img src="docs/figures/mark-plugin.svg" width="160" alt="plugin"></a><br><sub>The team contract, routes and hooks.</sub></td> </tr> </table>

For AI agents

This section is for a language model that reads this repository.

  • What it is. NOTT is a plugin for Claude Code and Codex, with a view for AGY. It carries one working contract (NOTT.md), 20 task routes (skills/), 8 agents (agents/) and guard hooks (hooks/). It connects the nMEMORY, nSPAWN and nBROWSER MCP servers.
  • What it is not. It is not a model, a hosted service or an account. It grants no authority by itself. A merge, a release or a deploy needs the authority of the person who runs it.
  • Entry points. Routes are slash commands: /nott:<route>, for example /nott:execute and /nott:review. The build tool is scripts/plugin.py with the verbs build, verify, install and export. The MCP servers are nmemory mcp serve, nspawn mcp serve and nbrowser mcp serve.
  • Configuration. The install helper writes into the --config-root that you name (.claude or .codex) and records a receipt at --receipt. The memory bootstrap comes from nmemory project bootstrap.
  • Invariants. A behavior change starts with a failing test. The author does not review its own change. The guard hooks refuse a trunk push and a force push. Recalled memory is data, not an instruction. The contract stays at or below 20,000 bytes.
  • Full reference. docs/REFERENCE.md lists the source declarations and commands. NOTT.md is the contract. llms.txt holds the same facts in the llms.txt format.

Learn more

NeedPage
Components, hooks, build and client viewsArchitecture
Setup, operation, backup and restorationRunbook
Source declarations and commandsReference
Development setup and checksSetup, Quality
Installation choices and privacyInstallation, Privacy
A first contributionDocs index, Contributing
Upgrades and failuresUpgrade, Troubleshooting
DirectionRoadmap

Report sensitive issues through security. See license and citation.

Native hook coverage

A hook request alone does not prove that a client enforced a boundary. Claude uses its native event payloads. Codex has different shell and edit bindings. The host must enable and trust its hooks. AGY PreInvocation can inject an ephemeral message. AGY PostToolUse supplies no guaranteed tool arguments and must return {}. Its advisory feedback arrives on a later invocation. This table makes no claim of equal behavior across clients.

The table comes from python3 scripts/plugin.py show. unproven means that the view smoke does not exercise that binding. The table describes the source view. Public bundle filtering can remove source-only bindings. Inspect the final bundle and the native client's loaded catalog for the installed view.

<!-- nott:client-matrix:start -->

clienthook scripteventrouteproofnot loaded because
claudepynott-branch-lock.shPreToolUseverbatimunproven
claudebash-guards.shPreToolUseverbatimunproven
claudetwo-misses.shPreToolUseverbatimunproven
claudecampaign-pr-guard.pyPreToolUseverbatimunproven
claudeplan-exit-gate.shPreToolUseverbatimunproven
claudedispatch-tier.pyPreToolUseverbatimunproven
claudenmemory-main-only.pyPreToolUseverbatimunproven
claudered-first.shPreToolUseverbatimunproven
claudesession-state.shPreToolUseverbatimunproven
claudelaw-contract.shPostToolUseverbatimunproven
claudenmemory-anchor-queue.shPostToolUseverbatimunproven
claudenspawn-pairing.shPostToolUseverbatimunproven
claudenmemory-act.pyPostToolUseverbatimunproven
claudebash-guard.pyPostToolUseverbatimunproven
claudered-first.shPostToolUseverbatimunproven
claudetwo-misses.shPostToolUseverbatimunproven
claudesecret-in-output.shPostToolUseverbatimunproven
claudesession-state.shPostToolUseverbatimunproven
claudeartifact-copy.pyPostToolUseverbatimunproven
claudesecret-in-output.shPostToolUseFailureverbatimunproven
claudered-first.shPostToolUseFailureverbatimunproven
claudetwo-misses.shPostToolUseFailureverbatimunproven
claudesession-state.shPostToolUseFailureverbatimunproven
claudelaw-session.shSessionStartverbatimunproven
claudelaw-session.shSessionStartverbatimunproven
claudelaw-session.shSessionStartverbatimunproven
claudenmemory-session.shSessionStartverbatimunproven
claudenmemory-act.pySessionStartverbatimunproven
claudenspawn-child-directives.shSessionStartverbatimunproven
claudenspawn-notices.shSessionStartverbatimunproven
claudesession-state.shSessionStartverbatimunproven
claudesession-state.shNotificationverbatimunproven
claudenmemory-snapshot.shSessionEndverbatimunproven
claudenmemory-snapshot.shPreCompactverbatimunproven
claudenmemory-snapshot.shPostCompactverbatimunproven
claudefinal-state.shStopverbatimunproven
claudesession-state.shStopverbatimunproven
claudenmemory-capture-gate.pyStopverbatimunproven
claudesession-state.shUserPromptSubmitverbatimunproven
claudefanout-reminder.shUserPromptSubmitverbatimunproven
claudefinal-state.shUserPromptSubmitverbatimunproven
claudesystem1-suggest.pyUserPromptSubmitverbatimunproven
claudenmemory-recall.pyUserPromptSubmitverbatimunproven
claudenmemory-act.pyUserPromptSubmitverbatimunproven
claudesubagent-law.pySubagentStartverbatimunproven
claudesession-state.shSubagentStopverbatimunproven
claudesession-state.shTeammateIdleverbatimunproven
claudesession-state.shStopFailureverbatimunproven
codexpynott-branch-lock.shPreToolUsecodex pre-shellunproven
codexbash-guards.shPreToolUsecodex pre-shellbash-guards
codextwo-misses.shPreToolUsecodex pre-shelltwo-misses
codexcampaign-pr-guard.pyPreToolUsecodex pre-shellunproven
codexplan-exit-gate.shPreToolUsenot projectedCodex has no ExitPlanMode tool, so neither half of the plan gate has an event to hang on
codexdispatch-tier.pyPreToolUsenot projectedbinds the Claude Agent and Workflow tools, which Codex does not have
codexnmemory-main-only.pyPreToolUsenot projectedit denies a Claude subagent by the agent_id of a Claude payload. On Codex only an nSPAWN child is covered: nMEMORY refuses it by NOTT_NSPAWN_RUN_ID, which nSPAWN forwards. A Codex multi_agent sub-agent is not covered
codexred-first.shPreToolUsecodex pre-shellred-first
codexsession-state.shPreToolUsecodex session-state-presession-state
codexlaw-contract.shPostToolUsenot projectedClaude runs it on PostToolUse Write\Edit\MultiEdit; the Codex post-edit advisory names the contract and does not run it
codexnmemory-anchor-queue.shPostToolUsenot projectedenqueues the EDITED path from a Claude Write\Edit payload (tool_input.file_path); Codex writes by piping a patch into apply_patch from the shell tool, so post-edit sees patch text, not a path, and has no codex-hook-runner.py action for it
codexnspawn-pairing.shPostToolUsenot projectedreads a Claude payload shape and has no codex-hook-runner.py action; Codex serves the event, the handler is not projected
codexnmemory-act.pyPostToolUsenot projectedits act is a Claude dispatch tool on PostToolUse, which Codex runs only through runner actions, so a turn start or a compaction here would have no act to reach
codexbash-guard.pyPostToolUsenot projectedreads a Claude payload shape and has no codex-hook-runner.py action; Codex serves the event, the handler is not projected
codexred-first.shPostToolUsecodex post-editred-first-tested
codextwo-misses.shPostToolUsecodex post-edittwo-misses
codexsecret-in-output.shPostToolUsecodex post-editsecret-output
codexsession-state.shPostToolUsecodex session-state-postsession-state
codexartifact-copy.pyPostToolUsenot projectedbinds the Claude Artifact tool, which Codex does not have
codexsecret-in-output.shPostToolUseFailurenot projectedCodex has no such event. Both Claude handlers (secret-in-output's chain advisory, red-first's tested record) ride post-edit on PostToolUse, which Codex runs after a non-zero Bash exit too (measured 2026-09-14, #444)
codexred-first.shPostToolUseFailurenot projectedCodex has no such event. Both Claude handlers (secret-in-output's chain advisory, red-first's tested record) ride post-edit on PostToolUse, which Codex runs after a non-zero Bash exit too (measured 2026-09-14, #444)
codextwo-misses.shPostToolUseFailurenot projectedCodex has no such event. Both Claude handlers (secret-in-output's chain advisory, red-first's tested record) ride post-edit on PostToolUse, which Codex runs after a non-zero Bash exit too (measured 2026-09-14, #444)
codexsession-state.shPostToolUseFailurenot projectedCodex has no such event. Both Claude handlers (secret-in-output's chain advisory, red-first's tested record) ride post-edit on PostToolUse, which Codex runs after a non-zero Bash exit too (measured 2026-09-14, #444)
codexlaw-session.shSessionStartverbatimunproven
codexlaw-session.shSessionStartverbatimunproven
codexlaw-session.shSessionStartverbatimunproven
codexnmemory-session.shSessionStartverbatimunproven
codexnmemory-act.pySessionStartnot projectedits act is a Claude dispatch tool on PostToolUse, which Codex runs only through runner actions, so a turn start or a compaction here would have no act to reach
codexnspawn-child-directives.shSessionStartverbatimunproven
codexnspawn-notices.shSessionStartnot projectedthe nSPAWN notice drain is at-most-once and only a Claude Code session is a notice caller; draining here would take the notice the project's next Claude session is owed
codexsession-state.shSessionStartverbatimunproven
codexsession-state.shNotificationnot projectedThe Codex view has no native permission notification binding. Waiting tools use PreToolUse.
codexnmemory-snapshot.shSessionEndverbatimunproven
codexnmemory-snapshot.shPreCompactverbatimunproven
codexnmemory-snapshot.shPostCompactverbatimunproven
codexfinal-state.shStopcodex stopvoice
codexsession-state.shStopcodex stopsession-state
codexnmemory-capture-gate.pyStopcodex stopcapture-gate
codexsession-state.shUserPromptSubmitverbatimunproven
codexfanout-reminder.shUserPromptSubmitverbatimunproven
codexfinal-state.shUserPromptSubmitverbatimunproven
codexsystem1-suggest.pyUserPromptSubmitnot projectedits catalog is the Claude agent types under ~/.claude and the routes as Claude names them; a Codex session has neither that Agent tool nor those names
codexnmemory-recall.pyUserPromptSubmitverbatimunproven
codexnmemory-act.pyUserPromptSubmitnot projectedits act is a Claude dispatch tool on PostToolUse, which Codex runs only through runner actions, so a turn start or a compaction here would have no act to reach
codexsubagent-law.pySubagentStartnot projectedCodex has no subagent events; the subagent law reaches Claude subagents only
codexsession-state.shSubagentStopnot projectedCodex has no subagent events; session-state.sh's dead[] record hangs on SubagentStop
codexsession-state.shTeammateIdlenot projectedCodex has no teams and no TeammateIdle event
codexsession-state.shStopFailurenot projectedCodex has no StopFailure event; a dead session is not announced there
agypynott-branch-lock.shPreToolUseagy pre-tool-run-commandunproven
agybash-guards.shPreToolUseagy pre-tool-run-commandbash-guards
agytwo-misses.shPreToolUsenot projectedits ledger fills from the command text after the run, which AGY's PostToolUse payload lacks, so its PreToolUse deny would never fire
agycampaign-pr-guard.pyPreToolUseagy pre-tool-run-commandunproven
agyplan-exit-gate.shPreToolUsenot projectedAGY has no ExitPlanMode tool, so neither half of the plan gate has an event to hang on
agydispatch-tier.pyPreToolUsenot projectedbinds the Claude Agent and Workflow tools, which AGY does not have
agynmemory-main-only.pyPreToolUsenot projectedit denies a Claude subagent by the agent_id of a Claude payload; AGY has no subagent events. nMEMORY refuses an AGY nSPAWN child when its MCP server inherits NOTT_NSPAWN_RUN_ID (not measured)
agyred-first.shPreToolUsenot projectedboth halves: its tested record needs the command text after the run, and AGY's PostToolUse payload is {stepIdx, error, ...common} (agy-customizations docs/hooks.md), so the deny half would call a tested session untested
agysession-state.shPreToolUsenot projectedsurface of the Claude statusline; AGY has no statusline and no subagent events, so a phase file would have no reader
agylaw-contract.shPostToolUseagy post-tool-writelaw-contract
agynmemory-anchor-queue.shPostToolUsenot projectedenqueues the EDITED path from a Claude Write\Edit payload (tool_input.file_path); AGY's PostToolUse payload is {stepIdx, error, ...common} with no path, so there is nothing to enqueue
agynspawn-pairing.shPostToolUsenot projectedreads a Claude MCP tool payload; AGY has no agy-hook-runner.py action for it
agynmemory-act.pyPostToolUsenot projectedAGY's PostToolUse payload is {stepIdx, error, ...common}, with no tool name or input, so no act can be matched
agybash-guard.pyPostToolUsenot projectedreads a Claude payload shape (tool_response) that AGY's PostToolUse does not carry
agyred-first.shPostToolUsenot projectedboth halves: its tested record needs the command text after the run, and AGY's PostToolUse payload is {stepIdx, error, ...common} (agy-customizations docs/hooks.md), so the deny half would call a tested session untested
agytwo-misses.shPostToolUsenot projectedits ledger fills from the command text after the run, which AGY's PostToolUse payload lacks, so its PreToolUse deny would never fire
agysecret-in-output.shPostToolUsenot projectedno output text in AGY's PostToolUse payload
agysession-state.shPostToolUsenot projectedsurface of the Claude statusline; AGY has no statusline and no subagent events, so a phase file would have no reader
agyartifact-copy.pyPostToolUsenot projectedbinds the Claude Artifact tool, which AGY does not have
agysecret-in-output.shPostToolUseFailurenot projectedAGY has no such event; its PostToolUse payload carries {stepIdx, error} and post-tool-run-command names the error in its advisory
agyred-first.sh
Source 3 files
hooks/register.ts 9 lines
1// The plugin's one hooks module: the engine loads a single module per plugin.
2import type { Register } from "claude-code";
3
4import { register as idleHandoff } from "./idle-handoff.ts";
5
6export const register: Register = (on, options) => {
7  idleHandoff(on, options);
8};
9
hooks/idle-handoff.ts 257 lines
1import type { Register } from "claude-code";
2
3const MIN = 60_000;
4const IDLE_DEFAULT = 50 * MIN;
5const MAX_PINGS = 3;
6const RETRY_MS = 30_000;
7const MAX_RETRIES = 3;
8const SELF = "nott";
9//: The owner's own channels, and a follow-up to his UI action; `unclassified` is not
10//: one: it carries the engine's idle notices and delivery receipts.
11const OWNER = new Set(["composer", "bridge", "slack-ping", "channel", "auto-continuation"]);
12
13const PING = "Reply with the single word: ok.";
14const COMPACT_INSTRUCTIONS =
15  "Keep the last state line (RUNNING/STOPPED/DELIVERED), the nMEMORY handoff capsule id " +
16  "and the next valid action verbatim; keep open decisions and their options.";
17
18function handoffPrompt(nonce: string): string {
19  return [
20    "The session has been idle and the prompt cache is about to expire.",
21    "Load /nott:handoff and record the cold-readable continuation in nMEMORY",
22    "(nmemory_session_finish or nmemory_capsule_ingest), then write the state line with",
23    "`~/.nott/bin/state.sh set \"STOPPED: <reason> · next action: <x>\"`.",
24    "Your whole answer is this exact line:",
25    `HANDOFF-DONE ${nonce}`,
26  ].join("\n");
27}
28
29//: Born in `register`, so each load (a session, a reload, a test) starts afresh.
30type State = {
31  timer: { cancel(): void } | null;
32  background: Set<string>;
33  pings: number;
34  dormant: boolean;
35  headless: boolean;
36  nonce: string | null;
37  handoffStarted: boolean;
38  ownerSeq: number;
39  seqAtFire: number;
40  compactOwed: boolean;
41  retries: number;
42  turnOpen: boolean;
43};
44
45function fresh(): State {
46  return {
47    timer: null,
48    background: new Set(),
49    pings: 0,
50    dormant: false,
51    headless: false,
52    nonce: null,
53    handoffStarted: false,
54    ownerSeq: 0,
55    seqAtFire: 0,
56    compactOwed: false,
57    retries: 0,
58    turnOpen: false,
59  };
60}
61
62function cancel(s: State): void {
63  s.timer?.cancel();
64  s.timer = null;
65}
66
67async function arm($: any, s: State): Promise<void> {
68  cancel(s);
69  const raw = Number(await $.env.get("NOTT_IDLE_HANDOFF_MS"));
70  const ms = Number.isFinite(raw) && raw > 0 ? raw : IDLE_DEFAULT;
71  s.timer = $.clock.after(ms, () => void fire($, s));
72}
73
74function compact($: any, s: State): void {
75  $.clock.after(0, () => void tryCompact($, s));
76}
77
78// ponytail: the owner check runs before the await; an owner prompt queued (not yet a
79// running turn) while the summarizer works is not seen. The engine offers no lock.
80async function tryCompact($: any, s: State): Promise<void> {
81  if (s.ownerSeq !== s.seqAtFire) {
82    s.compactOwed = false;
83    return;
84  }
85  try {
86    const result = await $.session.compact({ instructions: COMPACT_INSTRUCTIONS });
87    s.compactOwed = false;
88    if (result && "skip" in result && result.skip) {
89      $.ui.log("idle-handoff: a hook skipped the compaction", { to: "transcript" });
90    }
91  } catch (err) {
92    s.compactOwed = true;
93    $.ui.log(`idle-handoff: compaction refused (${String(err)}); retrying`, { to: "transcript" });
94    if (s.retries < MAX_RETRIES) {
95      s.retries += 1;
96      $.clock.after(RETRY_MS, () => void retryCompact($, s));
97    }
98  }
99}
100
101async function retryCompact($: any, s: State): Promise<void> {
102  if (s.compactOwed && !s.turnOpen) await tryCompact($, s);
103}
104
105function handedOff($: any, s: State): void {
106  s.nonce = null;
107  s.handoffStarted = false;
108  s.dormant = true;
109  cancel(s);
110  s.retries = 0;
111  compact($, s);
112}
113
114async function fire($: any, s: State): Promise<void> {
115  s.timer = null;
116  if (s.nonce !== null) {
117    //: The last handoff never ended with its marker: one per absence, so sleep until a
118    //: message not its own rather than ask again.
119    s.nonce = null;
120    s.handoffStarted = false;
121    s.dormant = true;
122    return;
123  }
124  if (s.background.size > 0 && s.pings < MAX_PINGS) {
125    s.pings += 1;
126    try {
127      const r = await $.model.fork({ prompt: PING });
128      $.ui.log(
129        r?.isAnswered
130          ? `idle-handoff: keep-warm ping ${s.pings}, cache read ${r.usage.cache_read_input_tokens}`
131          : `idle-handoff: keep-warm ping ${s.pings} not answered (${r?.reason ?? "no result"})`,
132        { to: "debug" },
133      );
134    } catch (err) {
135      //: A refused fork (a blocked model) must not stop the clock.
136      $.ui.log(`idle-handoff: keep-warm ping ${s.pings} refused (${String(err)})`, { to: "debug" });
137    }
138    await arm($, s);
139    return;
140  }
141  s.pings = 0;
142  s.nonce = crypto.randomUUID();
143  s.seqAtFire = s.ownerSeq;
144  await $.prompt.submit({ text: handoffPrompt(s.nonce) });
145}
146
147//: The marker is the message's last line, as the prompt asks: a message that only
148//: announces it mid-sentence does not end the handoff.
149function marked(s: State, text: unknown): boolean {
150  if (s.nonce === null || typeof text !== "string") return false;
151  const body = text.trimEnd();
152  const line = `HANDOFF-DONE ${s.nonce}`;
153  return body === line || body.endsWith(`\n${line}`);
154}
155
156//: A blocking Stop hook continues the same turn, and its final answer then loses the
157//: marker an earlier message of the turn carried. The prompt quotes the marker too,
158//: so only the assistant's messages count.
159async function answered($: any, s: State, e: any): Promise<boolean> {
160  if (e.reason !== "answer") return false;
161  if (marked(s, e.answer)) return true;
162  const messages = await $.session.messages();
163  return messages.some((m: any) => m.role === "assistant" && marked(s, m.text));
164}
165
166//: The id a launch left running in the background, per built-in tool; null when it
167//: ran in the foreground.
168function launched(tool: unknown, r: any): string | null {
169  const x = r?.result;
170  if (x === null || typeof x !== "object") return null;
171  switch (tool) {
172    case "Bash":
173      return x.backgroundTaskId ?? null;
174    case "Monitor":
175    case "Workflow":
176      return x.taskId ?? null;
177    case "Agent":
178      if (x.status === "async_launched") return x.agentId ?? null;
179      if (x.status === "remote_launched") return x.taskId ?? null;
180      return null;
181    default:
182      return null;
183  }
184}
185
186async function onSubmit($: any, e: any, next: any, s: State) {
187  const kind = e.origin?.kind;
188  if (kind === "sdk") s.headless = true;
189  if (kind === "task-notification" && typeof e.text === "string") {
190    for (const id of s.background) if (e.text.includes(`<task-id>${id}</task-id>`)) s.background.delete(id);
191  }
192  if (!(kind === "plugin" && e.origin?.name === SELF)) {
193    s.dormant = false;
194    s.pings = 0;
195  }
196  if (OWNER.has(kind)) {
197    s.ownerSeq += 1;
198    s.nonce = null;
199    s.handoffStarted = false;
200    s.compactOwed = false;
201  }
202  return next(e);
203}
204
205async function onStart($: any, e: any, next: any, s: State) {
206  cancel(s);
207  s.turnOpen = true;
208  //: The handoff turn starts with its own prompt: a notification ahead of it does not.
209  if (s.nonce !== null && typeof e.text === "string" && e.text.includes(s.nonce)) s.handoffStarted = true;
210  return next(e);
211}
212
213// ponytail: a task that ends without a notification stays recorded; MAX_PINGS bounds
214// what it costs, and the next handoff runs anyway.
215async function onTool($: any, e: any, next: any, s: State) {
216  const r = await next(e);
217  const id = launched(e.tool, r);
218  if (id !== null) s.background.add(id);
219  return r;
220}
221
222async function onSessionStart($: any, e: any, next: any, s: State) {
223  cancel(s);
224  Object.assign(s, fresh(), { headless: e.isInteractive === false });
225  return next(e);
226}
227
228async function onComplete($: any, e: any, next: any, s: State) {
229  if (e.agentId) return next(e);
230  s.turnOpen = false;
231  if (s.nonce !== null && (await answered($, s, e))) {
232    handedOff($, s);
233  } else if (s.handoffStarted) {
234    //: The handoff ran and ended without its marker (refused, aborted, failed, or the
235    //: line left out): one per absence, so sleep until a message not its own.
236    s.nonce = null;
237    s.handoffStarted = false;
238    s.dormant = true;
239  } else if (s.compactOwed && s.ownerSeq === s.seqAtFire) {
240    compact($, s);
241  } else if (!s.dormant && !s.headless) {
242    //: Also with a handoff pending: if it never ends with its marker (a hook rewrote its
243    //: prompt), the next fire finds the stale nonce and gives up instead of waiting forever.
244    await arm($, s);
245  }
246  return next(e);
247}
248
249export const register: Register = (on) => {
250  const s = fresh();
251  on("prompt.submit", ($: any, e: any, next: any) => onSubmit($, e, next, s));
252  on("turn.start", ($: any, e: any, next: any) => onStart($, e, next, s));
253  on("tool.call", ($: any, e: any, next: any) => onTool($, e, next, s));
254  on("turn.complete", ($: any, e: any, next: any) => onComplete($, e, next, s));
255  on("session.start", ($: any, e: any, next: any) => onSessionStart($, e, next, s));
256};
257
types/index.d.ts 49 lines
1// The values the nott plugin's hooks modules keep in `$.state`, read while drawing.
2
3//: One nSPAWN run as the fleet pane draws it.
4export type FleetRun = {
5  runId: string;
6  lane: string;
7  route: string;
8  phase: string;
9  updatedAt: number;
10  //: The child's last output, ms; silence is measured from it.
11  outputAt: number;
12  isLive: boolean;
13};
14
15//: The fleet as last read: when it was last looked at, when it last answered, and the
16//: runs worth showing.
17export type Fleet = { observedAt: number; readAt: number; runs: FleetRun[] };
18
19//: An agent of this session that is still at work.
20export type AgentRow = { id: string; type: string; description: string; status: string; parentId?: string };
21
22//: A workflow this session launched and whose task has not reported its end.
23export type WorkflowRow = { id: string; name: string; startedAt: number };
24
25//: The pull request of the session's branch, reduced to what the band draws.
26export type CampaignPr = {
27  number: number;
28  ok: number;
29  running: number;
30  failed: string[];
31  review: string;
32  merge: string;
33};
34
35//: The band's last reading and, when the latest gh call failed, why: the reading is then
36//: an older one, or null when gh has never answered.
37export type PrReading = { pr: CampaignPr | null; error: string | null };
38
39declare module "claude-code" {
40  interface PluginState {
41    nott: {
42      fleet: Fleet | null;
43      agents: AgentRow[];
44      workflows: WorkflowRow[];
45      pr: PrReading | null;
46    };
47  }
48}
49