@iterahq/learning-agent
v0.4.4
Published
The Itera Learning local agent: capture hooks, the spool, the installer and the shipper. Captures your coding-agent work and ships it to the Itera Learning engine.
Maintainers
Readme
@iterahq/learning-agent
The Itera Learning local agent. It hooks your coding-agent sessions (Claude Code, Codex), captures redacted turns, and ships version 2 sessions with ordered turn moments to the Itera Learning engine. Version 0.4.0 adds the Codex TOML installer, capture consent rules, and a packaged loopback monitor. It retains protocol 0.3.0 and version 2 session emission. Version 0.4.3 adds native Windows hooks, portable consent checks, and visible verification progress. Version 0.4.4 keeps consent previews responsive when an old open turn retains acknowledged spool history. Use the learner-machine guide to upgrade and verify the machine.
This package is public and client-only. It ships with no secrets. Inspect it
before you install: npm view @iterahq/learning-agent, or
npm pack @iterahq/learning-agent && tar tzf *.tgz to unpack the tarball without
installing anything.
Install
Linux, macOS, and native Windows terminals:
npm i -g @iterahq/learning-agent
itera-learning install claude-code # Claude Code
itera-learning install codex # Codex CLIThe Codex installer adds one marked block to ~/.codex/config.toml. It preserves
all source bytes outside that block. A DFL login is required to ship
(dfl-auth login). Full guide: docs/INSTALL.md in the monorepo.
Upgrade an installed agent
Agent 0.4.1 does not contain the update command. Use this command once:
npx --yes --registry=https://registry.npmjs.org --@iterahq:registry=https://registry.npmjs.org @iterahq/learning-agent@latest updateFor all later releases, close Claude Code and Codex. Then run:
itera-learning updateThe command installs the current public npm release. It detects existing Claude Code and Codex integrations and refreshes only those hooks. It preserves the spool, journal, shipped ledger, credentials, consent rules, and CLI settings. The one-time registry options bypass an existing GitHub Packages scope.
Per-path capture consent
The agent allows capture by default. A new install works without a prompt or a
rules file. The optional append-only ledger is ~/.itera-learning/consent.rules.
A valid file with no matching rule also allows capture.
itera-learning consent list
itera-learning consent verify # private disposable self-check
itera-learning consent allow '/work/public/**'
itera-learning consent deny '/work/private/**'
itera-learning consent ask '/work/review/**'On Windows, drive and UNC patterns are valid. For example, use
itera-learning consent deny 'C:\Users\Learner\Private\**' in PowerShell.
The CLI stores one canonical, case-insensitive path form.
Each command appends one scope="capture" rule. The last matching rule for a
path wins. A deny on any candidate path withholds the event. An ask holds the
event for a later decision. Add an allow rule for the same glob to release a
held spool episode. Run itera-learning monitor for the primary UI. Its Consent tab uses the same
local ledger and exported consent API.
itera-learning consent verify checks append order and scope in a private disposable ledger.
It checks mode 0600 on POSIX systems. On Windows, it reports Windows ACL protection
without a false POSIX mode claim. It never changes the learner's real rules.
The capture hook checks consent before it writes the spool. The shipper checks again after authentication and immediately before each POST attempt. Thus, a deny added after capture or during authentication still prevents transmission. An existing malformed or unreadable ledger fails closed. This includes a broken symlink, an inaccessible parent, and an invalid JSON escape. The journal records only the decision, stage, and stable rule ID. It does not record the withheld path or raw payload.
The ledger uses path globs. Rules can use *, **, and ?. The ? glob
matches one Unicode code point at both gates. The agent checks cwd,
project_root, and every absolute JSON string in the hook event. This
includes escaped strings, arrays, nested objects, and new field names. A coding
CLI can omit a path from its event. A path-specific rule cannot classify that
missing value. With an existing ledger, a control character in an absolute path
stops capture because the shell cannot preserve its glob meaning. A missing
ledger still allows capture. Use ITERA_LEARNING_DISABLED=1 when you need a
machine-wide stop that does not depend on event path metadata.
Local Activity and Consent monitor
itera-learning monitor # http://127.0.0.1:5173/#consent
itera-learning monitor --port 5194 # choose another local portThe installed package includes the server and UI assets. No checkout, Vite process,
backend login, or CDN is needed. The Ship journal returns 50 rows per page so the
browser keeps one bounded page in memory. The server always binds to 127.0.0.1.
The command accepts no public-host override. Stop it with Ctrl+C.
Version 0.4.4 skips transcript and diff reconstruction for acknowledged episodes.
This keeps Consent responsive when an older open turn retains shipped spool rows.
The Consent tab shows rules, pending paths, counts, source sessions, destination origin, and post-redaction payload previews. Allow, Deny, and Ask append local rules. Missing or unmatched rules allow capture. There is no first-run modal. Previews use the real session planner inside private temporary state. They do not change the live spool, cursor, cache, outbox, configuration, or journal. An unsafe or incomplete preview appears as unavailable, rather than as exact JSON.
Only the local rules endpoint writes consent state. The monitor never posts capture payloads to the engine. Capture-time denials contain safe metadata only; the monitor cannot reconstruct their withheld paths or payloads. Execution consent is outside this release.
Verify a fresh capture
itera-learning verify start claude-code
# Or: itera-learning verify start codexPaste the printed prompt into a new session. Wait for the response and close the session. Then run:
itera-learning verify finishThe command requires the unique fresh session, SessionEnd, a version 2 episode,
nonempty agent output, and complete engine acknowledgement. It reports invalid
historical NDJSON lines without printing their content.
Export your server-held learning data
Run itera-learning export --out /private/new-directory with a build that includes this command.
The destination must not exist. The command uses your normal caller session in memory.
The command requests learning-export/v2. The engine must support format=v2 before this client release.
It validates both exact v1 and v2 contracts, including the known schema before or after re-pass receipts.
V2 explicitly excludes retired checkpoints and singleton configuration. It includes their current models, artifacts, and all deployed receipts.
Shared reference metadata is separate from your records. Every collection has a count and SHA-256 hash.
Directories use mode 0700. Files use mode 0600. The completion manifest is written last.
An inaccessible table, unstable second read, invalid hash, unknown inventory, or foreign-owned row stops the export.
The client validates the complete collection inventory before it creates the destination. HTTP errors do not trigger a retry or a downgrade.
This command does not change server data. It does not export local spool files, provider copies, or backups.
See the data boundary for the exact scope and current stop-control limitations.
What it does, and what leaves your machine
- Capture hooks write one spool line per prompt, turn end, and
SessionEnd. The hook is POSIXsh— no node, no python, no jq on the critical path. - On
StopandSessionEnd, one command appends capture before it starts a detached shipper. There is no recurring timer. A manualship --oncealso processes the spool. - The shipper contacts exactly two hosts:
api.iterahq.dev(trade your DFL token for an Itera session) andengine.iterahq.dev(POST the episodes). - Every captured line and every shipped transcript slice passes the shared
hooks/redact.shscrub first. Redaction is best-effort defence-in-depth, not a guarantee — the hard control isITERA_LEARNING_DISABLED=1, which stops all capture at once.
What each episode carries
Each session carries project_root and ordered moments. Each moment retains
the original CLI session_ref and a redacted turn envelope. The first capture
of that turn freezes these fields from the CLI transcript:
agent_turn— the agent's final message plus the turn's tool calls. It grounds actor attribution in what the agent DID, not how the prompt was phrased.diff— the working-tree diff of the turn'scwdat ship time: the git ref, the branch, a size-boundedgit diff, and a--statsummary. Attached to the latest turn percwd. Across consecutive turns it separates the agent's edits from the learner's edits on top.superseded_by+dwell_ms— on an interrupted turn (a new prompt arrived before the turn ended): a reference to the prompt that replaced it, and how long the learner let it run first. The rhythm and rephrase-chain signal.abandoned— the paths the turn refused: denied permissions, rejected plans, stopped or errored tool calls. Read from the same transcript slice; each detail is redacted. Known limit: a turn the learner killed before it reached theStophook has no slice, so its abandoned paths are not captured.
Optional transcript and diff fields remain absent after a capture miss. A
missing or invalid absolute cwd holds the spool for repair. A non-Git
directory uses its normalized absolute cwd as its root. A missing legacy
CLI reference becomes unavailable, with session_ref_missing: true in the
moment payload. This marker does not invent a CLI session identity.
Ship-on-Stop, and the retry spool
Each ship is a full idempotent catch-up over the whole spool. The spool is the durable local buffer: it is not cleared until a ship succeeds. So:
- A ship that fails (the network is down) leaves the batch in the spool, untouched, and does not advance the ledger.
- The next
Stop,SessionEnd, or manual ship retries the exact frozen outbox. Later transcript, diff, spool, or configuration changes cannot alter that request. A partial acknowledgement retains the whole request. The engine returns existing receipts for sessions it already accepted. - A transient blip is smoothed over by a bounded in-process retry before the
ship gives up to the next
Stop.
The spool is bounded: once a spool file is fully shipped and no longer the newest few, it is pruned. A file that still holds an unshipped or open line is never pruned.
Session boundaries and configuration
The gap is 30 minutes by default. Set session_gap_minutes to a positive
number in ~/.itera-learning/config.json (or $ITERA_LEARNING_HOME/config.json):
{ "session_gap_minutes": 45 }A root change, machine context change, gap greater than the configured value,
or matching SessionEnd closes a session. An end marker must match the latest
turn’s CLI source/reference and follow every captured turn’s end. A marker from
an older overlapping terminal does not close newer work. A CLI reference or source change
alone does not split it. Turn timestamps determine order. The session retains
all CLI references in its moments. Git supplies the repository root when available.
The trailing session stays open until a later invocation observes a complete
gap or a SessionEnd. A prompt without its turn end also keeps the session
open. No timer wakes solely to flush that session. An explicit SessionEnd
can close an incomplete turn without inventing its missing turn end.
Private durable state
The ledger has two new adjacent files: .capture-cache.json and .outbox.json.
Both contain private, redacted capture data, never credentials. Files use mode
0600; directories use 0700. File sync, atomic rename, and directory sync
complete before POST. The cache freezes each completed turn before an open
session waits for more turns. The outbox freezes the exact request and the
next ledger state. Only a full acknowledgement advances the network ship ledger.
A local-only library call instead commits its explicit local sink.
An open session holds the byte-offset barrier at its first retained spool fragment. Acknowledged turns after that barrier remain in the shipped-ID set. Late lines already present in the spool pair by timestamp. Lines that arrive after an acknowledged session form a later capture; they do not rewrite an existing receipt. Corrupt durable state fails closed.
A batch holds at most 500 sessions and 7 MiB. Remaining sessions stay pending. A single session above 7 MiB or 10,000 moments stays in the spool and requires inspection; the client does not silently split or discard it.
A PID and nonce lock protects each local outbox. Recovery requires proof that
the local owner PID is dead. A live, paused, inaccessible, or unknown PID keeps
the lock. Recovery leaves one small, nonempty .reaped-<nonce> directory per
crashed owner. These tombstones prevent two recovery attempts from removing a
new owner's lock. Keep them while shippers can run. No age-based lease can
steal a live lock. A contending detached ship retries normal lock acquisition
for up to 60 seconds. Thus a final hook that arrives after the owner's collection
can run after that owner releases. Exhaustion fails visibly in the detached log;
the spool stays intact for a later hook or manual ship. There is no unbounded
retry loop. The client checks ownership before durable writes and every POST.
The v1 spool reader and turn API remain available. Each prompt retains its original spool version. Before v2 emission, the client drains legacy prompts through v1 batches with their original episode IDs. This preserves idempotency when the old client reached the engine but lost its acknowledgement. A new v2 turn-end can close an old v1 prompt; that turn still drains as v1. Genuinely unsent legacy turns are sent, not discarded or repackaged into new session IDs. Ready legacy turns drain first. Unresolved legacy prompts retain their original byte barriers and remain eligible for a later v1 Stop. They do not block independently closed v2-origin sessions. The same invocation continues to v2 after full acknowledgement of ready legacy turns. A partial legacy acknowledgement stops that transition. The boundary is explicit: old prompts remain turn-grain data; only new v2 prompts form session moments. Upgrade preserves spool files, pending turns, ledger offsets, and journals. Uninstall does not remove this history. Package and host release remain a separate operation.
Ship journal — the local, readable record
Every ship attempt appends one line to an append-only NDJSON journal. It is the data source the opt-in local monitor reads, and the artifact that proves a failed-then-recovered ship. It is written on both Linux and macOS.
- Location:
$ITERA_LEARNING_JOURNAL, default~/.itera-learning/journal.ndjson(mode0600). - Format: append-only NDJSON, UTF-8, one JSON object per line. The shipper
never rewrites or truncates it; a reader tails it. Every line carries
vfor forward compatibility.
Each line:
{
"v": 1,
"ts": "2026-09-01T16:00:00.000Z", // ISO-8601 UTC of the ship attempt
"event": "ship",
"outcome": "shipped", // shipped | local | noop | failed | dry-run
"machine": "my-host",
"agent_version": "0.1.0",
"trigger": "stop", // stop | manual | unknown
"collected": {
// what collect() found this pass
"lines_read": 4,
"episodes": 2,
"pending": 0, // prompts still waiting for their turn to end
"orphan_turn_ends": 0,
"lines_skipped": 0, // unreadable / half-written lines
},
"batch": {
// the batch this pass built, or null on a noop
"size": 2,
"episode_ids": ["ep_…", "ep_…"], // lets a monitor diff "on device" vs "shipped"
},
"engine": {
// present only when a POST happened, else null
"url": "https://engine.iterahq.dev",
"accepted": 2,
"duplicate": 0,
"rejected": 0,
},
"sink_path": "/…/shipped/….json", // the local sink file, when one was written
"spool_pruned": ["20260825.ndjson"], // spool files reclaimed this pass
"error": null, // the failure message, on a "failed" outcome
"duration_ms": 37,
}Outcome semantics
| outcome | meaning |
| --------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
| shipped | POSTed to the engine, every episode acknowledged, ledger advanced. |
| local | no engine URL set; wrote the local sink only, ledger advanced. |
| noop | nothing new to ship. |
| failed | the POST failed, or the engine did not acknowledge every episode. Ledger did NOT advance; the batch stays in the spool and retries on the next Stop. |
| dry-run | a dry run; nothing written. |
How the (future) monitor reads it
- Liveness: the
tsof the last line is when the shipper last ran. - Pending vs shipped: collect the
episode_idsof everyshipped/localline and diff against what the spool holds on device. - Retry backlog: a
failedline whose ids reappear on a latershippedline is a recovered batch; afailedline whose ids never reappear is a batch still stranded.
Uninstall
itera-learning uninstall claude-code # removes exactly the two hooks
itera-learning uninstall claude-code --all # also removes any legacy timer (never touches ~/.itera-learning)
itera-learning uninstall codex # removes only the managed TOML blockEnvironment
| Variable | Purpose |
| --------------------------------- | ----------------------------------------------------------------------------------------------- |
| ITERA_LEARNING_DISABLED | 1 stops all capture at once. |
| ITERA_LEARNING_HOME | Data root. Default ~/.itera-learning. |
| ITERA_LEARNING_API_URL | Engine origin. Unset ships to https://engine.iterahq.dev; set empty for local-only (no POST). |
| ITERA_LEARNING_EMBED_URL | itera-api origin for the token trade. Default https://api.iterahq.dev. |
| ITERA_LEARNING_JOURNAL | Ship-journal path. Default <home>/journal.ndjson. |
| ITERA_LEARNING_CONSENT_RULES | Capture-consent ledger. Default <home>/consent.rules. |
| ITERA_LEARNING_SPOOL_KEEP_FILES | Newest spool files kept when pruning. Default 3. |
| ITERA_LEARNING_TOKEN | A fixed Itera token; skips the DFL trade. For tests. |
| ITERA_LEARNING_SESSION_FILE | Optional path to cache the Itera session, mode 600. Off by default. |
Release
Where this client runs today, which version each host holds, and how a new version reaches every one of them. A release to an unknown fleet loses the episodes of the host nobody remembered, so the fleet is written down here.
Surveyed 2026-09-07. Every DFL VPS in dfl-infra:vps/_inventory.yaml was
scanned read-only. The scan looked for the global npm package, the ~/.itera-learning
data root, the hook lines in the CLI settings file, a systemd unit or a cron
entry, and a running shipper process.
Hosts
| Host | Present | Path | Version | Invoked by | Updates by |
| ----------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------- | ------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| openclaw-tainan (claude-main), hostname openclaw-tainan-debian-4gb-hel1-2 | yes | /usr/lib/node_modules/@iterahq/learning-agent, bin symlinked at /usr/bin/itera-learning | 0.2.0 | Claude Code hooks in /root/.claude/settings.json: UserPromptSubmit → capture.sh user-prompt claude-code; Stop → capture.sh turn-end claude-code then ship-on-stop.sh claude-code. No timer, no cron, no systemd unit. | manual: npm i -g @iterahq/learning-agent@<version> (the hook lines point at the global path, so they survive the upgrade; re-run itera-learning install claude-code only if the hook shape changed) |
| Tainan's Mac, hostname Tainans-MacBook-Pro-2.local | yes (per Tainan) | UNKNOWN — Tainan to confirm | UNKNOWN — Tainan to confirm | Claude Code hooks (same two events) | manual: npm i -g @iterahq/learning-agent@<version> |
| sandbox-compute (sandbox-manager-containers-debian-16gb-hel1-1) | no | — | — | — | — |
| sandbox-db (sandbox-manager-db-debian-8gb-hel1-1) | no | — | — | — | — |
| dokploy-vps (dokploy-debian-4gb-fsn1-1) | no | — | — | — | — |
| dfl-runner (runner-ubuntu-4gb-fsn1-1) | no | — | — | — | — |
| dfl-runner-02 (runner-02-8gb-nbg1-2) | no | — | — | — | — |
| amb-hq (amb-hq-4gb-hel1-2) | no | — | — | — | — |
| crm-debian-4gb-hel1-1 | UNVERIFIED — claude-main holds no SSH key for this host, so the scan could not run. No episode in the database carries its hostname, so it is presumed absent. | — | — | — | — |
Two matches on the scan are NOT this client. dfl-runner and dfl-runner-02
carry itera-runner-recycle.service / .timer, which recycle the GitHub Actions
runners of the iterahq org. amb-hq carries iterahq-code.service and
dfl-auth-gate-iterahq.service. All five units are unrelated to the capture
client. Do not read a hostname match on the string itera as an installation.
Database reconciliation
The machines that actually shipped episodes are the ground truth for the fleet.
Read from learning.work_episodes on the Itera Supabase project
(kjefmwlopzeaqgqtlbdn) on 2026-09-07:
| machine | machine_class | Episodes | First | Last | Location |
| ----------------------------------- | ---------------------------- | -------- | ---------- | ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| openclaw-tainan-debian-4gb-hel1-2 | agent (343) + human (76) | 419 | 2026-08-28 | 2026-09-07 | openclaw-tainan — matched, version 0.2.0 |
| Tainans-MacBook-Pro-2.local | human | 386 | 2026-08-28 | 2026-09-04 | Tainan's Mac — matched, version UNKNOWN |
| openclaw-tainan | human | 6 | 2026-08-28 | 2026-08-28 | synthetic, not a host. Hand-POSTed demo and M4 smoke rows from claude-main (project_ref /root and /root/repos/*-demo, prompts prefixed [DEMO] or M4 … smoke). |
| smoke-laptop | human | 2 | 2026-09-01 | 2026-09-01 | synthetic, not a host. A fabricated fixture hostname (project_ref /home/user/proj). |
Zero machines are location: UNKNOWN. Every hostname in the database is
either a host in the table above or a synthetic fixture.
The survey found two gaps. The first is now closed by T5; the second remains:
- T5 now records the client version. At the survey time, the batch parsed
agent_versionand the engine dropped it. The deployed T5 ingest writes it towork_episodes.agent_version. A rollout can now compare the protocol version, client version, grain, and moments in the database. The per-host journal remains the local shipping receipt. ingest_batch_idis alwaysNULL. The column exists onwork_episodes, the route passesnullfor it on every insert, and noingest_batchestable exists. It cannot group a batch either.
At the survey time, schema_version read 1 for every row on every machine.
The row count moves — claude-main ships on every
turn — so read the counts above as the shape of the fleet, not as a fixed total.
Protocol version
This 0.4.4 client emits version 2 for new capture. The legacy schemas remain fixed at version 1. Legacy prompts drain through v1 receipts before v2 emission. The engine accepts both versions.
| Contract | File | Version | Scope |
| -------------------------------------- | ---------------------------------- | ------- | ------------------------------------------- |
| SPOOL_LINE_VERSION | packages/protocol/src/spool.ts | 1 | frozen legacy reader |
| captureSpoolLineSchema | packages/protocol/src/spool.ts | 2 | current capture hooks, including SessionEnd |
| EPISODE_VERSION | packages/protocol/src/episode.ts | 2 | current session and batch emission |
| episodeSchema / episodeBatchSchema | packages/protocol/src/episode.ts | 1 | frozen legacy drain API |
The engine stores the actual envelope version as work_episodes.schema_version.
The release candidate and its provenance constant are 0.4.4.
The protocol package remains 0.3.0. The D5 release gate is complete.
Published release and host verification
Version 0.4.4 skips transcript and diff reconstruction for acknowledged episodes behind an open-turn barrier. Version 0.4.3 added native Windows hooks, portable consent checks, and visible verification progress. It retains the deployed v2 ingest/export routes and migration 0089. It preserves the verified 0.4.0 monitor, consent, and capture behavior. The normal authenticated API accepted a marked learner-class v2 test. Recent human v1 receipts confirm backward compatibility with agent 0.2.0. See the learner-machine guide.
For an approved host, use the safe package replacement sequence above. Reinstall its existing CLI integrations. Preserve its spool, ledger, capture cache, outbox, journal, consent ledger, and CLI settings. Do not add a new integration as an incidental part of a package upgrade. Verify the installed CLI version, local hook paths, and a new journal receipt. Require agent 0.4.4 and version 2 session/moment data for the fresh capture test. A packed fixture or VPS result does not prove a learner-Mac upgrade.
Maintainers must run npm run test:agent-package before each future release.
That test builds and installs local tarballs in an isolated prefix.
It exercises the installed CLI and monitor. The test does not publish a package.
Keep the public agent and protocol artifacts immutable after publication.
Repair invalid historical spool records
Agent 0.4.1 can write invalid NDJSON when generic secret redaction meets an
escaped quote. The shipper skips those records and reports lines_skipped.
Install a fixed release before repair. Close Claude Code and Codex, wait for any
shipper to finish, then run:
itera-learning repair-spoolThe command archives each exact original and invalid physical line under the
private ~/.itera-learning/quarantine/ directory. Its manifest records file,
line, byte count, and SHA-256 evidence. The active spool keeps every valid line.
The command preserves shipped episode IDs and resets only affected file cursors,
so the engine can deduplicate any replayed valid episode.
Residual gap (v1)
Shipping still requires a DFL account (dfl-auth login). A true outsider with no
DFL account cannot ship in v1. This is a deliberate scope cut; the itera-native
login CLI is the later multi-user path.
Export catalog compatibility in 0.4.3
The client requests learning-export/v2 and accepts exactly four source catalogs: 31/32 tables before D, or 29/30 after D. The receipt table accounts for the one-table difference. D removes only checkpoints and generation_config from these catalogs. Unknown, duplicate, partially retired, or missing required tables fail before any export file is created. V1 compatibility accepts only the original 31/32 catalogs; it never silently changes a v1 response. Complete collection, ownership, count, and hash checks remain required.
Use itera-learning update. Preserve the spool and ledger. Agent 0.4.4 keeps protocol 0.3.0 and the version 2 transport contract.
