kankaku
v0.7.1
Published
pi extension that records agent work time per prompt, excluding waits for the user, with subagent linkage and task/session views
Downloads
768
Maintainers
Readme
kankaku
A pi extension that measures how long an agent actually spends working on each prompt, so the time can later be accounted for (billing, reporting).
Docs and guide: kankaku.io.
What it measures
For every prompt, kankaku tracks the span from before_agent_start to
agent_settled (or to session_shutdown if pi exits mid-run) and splits it
into:
waitingMs: time pi spent blocked on the user — the union ofui_prompt_start/endspans and the execution spans of configured interactive tools (defaultask_user_question,ask_user_choice). Union avoids double-counting when a tool internally triggers a UI prompt.workMs:wallMs - waitingMs, the actual work time.
Every pi process — the orchestrator and any subagent child spawned by
subagent_run — records its own prompt-to-idle spans, tagged with a role
(orchestrator or subagent) and its pid/parentPid, so records can be
joined later.
Install
kankaku is a pi package. Pick one source:
pi install npm:kankaku # from npm
pi install git:github.com/soyunninja/kankaku # from git (add @v0.1.0 to pin)
pi install /absolute/path/to/kankaku # local checkout, no copypi install writes to your global ~/.pi/agent/settings.json, so the
extension loads in every pi process, including the subagent children that
subagent_run spawns. Use -l to install into a project's .pi/settings.json
instead; note that project-local resources load only after the project is
trusted, which a subagent child may not inherit.
To try it without installing: pi -e /absolute/path/to/kankaku.
Quick start
Once installed, kankaku records every prompt on its own; there is nothing
to start. Inside pi's TUI, type /kankaku to open the panel, the one
place everything is managed from:
╭─ >_ kankaku ─────────────────────────────────────────╮
│ │
│ → Target Billing client, project, hub task │
│ Report Today/all totals, tasks, sessions │
│ Sync Status, sync now, sync all, backfill │
│ Export Write today's or every task as csv │
│ Doctor Orphan/uncertain subagent counts │
│ About Versions, KANKAKU_DIR, hub URL │
│ │
│ ↑↓ move · enter open · esc close │
╰──────────────────────────────────────────────────────╯- Target is where you pick the client and project the time is billed to and, with a hub, the task you are working on right now.
- Report shows today's work, waiting and cost, per task or grouped by client or project.
- Sync pushes the consolidated tasks to your hub when one is configured.
Every panel action is also a subcommand (/kankaku tasks, /kankaku sync,
…) for scripts and headless runs — see "The /kankaku command" below. The
footer clock (🕒 03:12 · acme) shows the running prompt's elapsed time
and billing client while an agent works.
Record schema
Each line in worklog.jsonl is one JSON object:
{
"schema": 1,
"id": "uuid",
"role": "orchestrator",
"pid": 4242,
"parentPid": 4000,
"project": "/abs/project/path",
"sessionId": "…",
"sessionFile": "…",
"mode": "tui",
"model": "anthropic/claude-opus",
"client": "acme",
"sessionName": "billing sprint",
"sessionDir": "/abs/custom/session/dir",
"clientId": "pocketbase-record-id",
"clientName": "Acme",
"projectId": "pocketbase-record-id",
"projectName": "Portal",
"machine": "laptop",
"prompt": "first 200 chars of the first prompt",
"startedAt": "2026-09-10T16:00:00.000Z",
"settledAt": "2026-09-10T16:04:10.000Z",
"wallMs": 250000,
"waitingMs": 30000,
"workMs": 220000,
"runs": 2,
"turns": 9,
"tools": { "bash": 4, "read": 3, "subagent_run": 1, "ask_user_question": 1 },
"subagents": [{ "toolCallId": "…", "agent": "sdd-explore", "mode": "task", "taskId": "t1", "ms": 90000 }],
"segments": { "review": 62000 },
"usage": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0, "cost": 0 },
"status": "completed",
"roleConfidence": "uncertain",
"orchestratorRef": { "pid": 4000, "project": "/abs/other-worktree", "startedAt": "2026-09-10T15:59:00.000Z", "dir": "/abs/other-worktree/.kankaku" }
}status is one of completed, aborted (the last assistant message had
stopReason: "aborted"), or interrupted (pi shut down while still
running).
runs counts the agent loops inside the record: the first one plus every
continuation pi ran before settling it (an automatic retry after a provider
error, overflow recovery, a queued steer or follow-up). Informational only.
trigger is optional. "extension" marks a record no user prompt started:
an extension woke the agent itself — this is how gentle-pi resumes the
orchestrator when a background subagent finishes. Its prompt is the fixed
text (no user prompt — run started by an extension). Without it that work
would not be recorded at all, since pi only announces user prompts.
roleConfidence and orchestratorRef are both optional and normally
absent — see "Subagents" below. roleConfidence is only ever set to
"uncertain", and only on an orchestrator-role record kankaku could not
positively prove top-level; orchestratorRef is only ever set on a
subagent-role record that discovered its tracked ancestor via the
machine-wide process registry. Its optional dir field carries that
orchestrator's resolved kankaku directory — the real top-level one even
across a subagent-of-subagent chain — and is what this process's own
work log and inflight checkpoints were actually routed into when it
differs from this process's own (see "Subagents" > "Cross-worktree write
routing"). Neither field, nor orchestratorRef.dir, bumps
WORK_RECORD_SCHEMA — a record without them (from an older kankaku build)
remains valid.
clientId, clientName, projectId, projectName and machine are only
present once a hub is configured (see "Hub (PocketBase)"); every report and
export written before this feature, or by a user without a hub, is
unaffected. hubTaskId/hubTaskTitle are set alongside them only when a
hub task is linked to the session (/kankaku task pick) — see "Linking to
a hub task" below.
sessionDir is present only when pi's session manager reports a
non-default session directory (--session-dir, or a resumed session
started that way) — exactly the condition under which pi's own printed "To
resume this session: ..." line includes --session-dir. Most records never
carry it. /kankaku doctor shows it for the current session when set, and
it is available on a task's orchestrator record (TaskView.sessionDir) for
anything that wants to reconstruct the exact pi --session-dir <dir>
--session <id> resume command locally. When a hub is configured it is also
sent as session_dir on every sync (see "Hub (PocketBase)" > "Sync" >
"Agent and measurement quality").
Task and session views
Each WorkRecord still measures one pi process's own prompt-to-idle span.
But a subagent_run in background mode returns immediately while its
child process keeps working, so the orchestrator's own wallMs can
under-report how long the task actually took. Two derived, read-only views
correct for that, built purely from pid/parentPid/startedAt/settledAt
already present on every record — no new fields are persisted to
worklog.jsonl.
- Task: one confirmed orchestrator record (see "Subagents" below —
an orchestrator-role record flagged uncertain never anchors a task) plus
every subagent record matched to it —
parentPid === orchestrator.pidand the child'sstartedAtfalling inside the orchestrator's[startedAt, settledAt]window;projectis only a hint, preferred when it matches but never a hard filter (see "Subagents"). (If a pid is reused across runs and several orchestrator records match, a same-project candidate is preferred, then the latest-starting one.) A task'swallMsis the union of the orchestrator's interval and every matched child's interval — never their sum — so parallel background children are not double-counted, and a child that outlives the orchestrator's own settle time correctly extends the task's span.waitingMsis the orchestrator's own waiting time,workMs = wallMs - waitingMs, andusageis the sum of the orchestrator's and every child's token/cost totals. - Session: tasks grouped by
sessionId(tasks with nosessionIdare grouped under"unknown"). A session'swallMsis the union of every interval — orchestrator and subagent alike — across all of its tasks;waitingMsis the sum of each task'swaitingMs, andworkMs = wallMs - waitingMs. - Orphan subagents: a subagent record with no matching orchestrator record (for example, its parent's record was lost, or a cross-worktree registry entry had already expired) is excluded from every task but is not silently dropped — it stays visible so gaps in the log are noticeable rather than hidden. See "Subagents" for how a cross-worktree child is usually reunited before it ever becomes an orphan.
Subagents
kankaku recognises gentle-pi's subagent_run tool as opening a subagent
span (unchanged from before this section); this describes how it decides,
for a process that shows no such marker, whether it is a genuine top-level
session or actually someone's subagent — and how a gentle-pi subagent
running in a different git worktree than its orchestrator still gets
correctly counted.
The role/state model
Every record still carries the same binary persisted role
("orchestrator" | "subagent", unchanged — see "Record schema"). On top
of it, kankaku's task/session views and the hub sync apply a four-state
classification:
- orchestrator — confirmed top-level: no recognised child-env-marker
(
GENTLE_PI_AGENTS_CHILD=1, or an explicitKANKAKU_ROLE=orchestrator— see "Interactive sessions andKANKAKU_ROLE" below) is present, and either no live tracked ancestor process was found, or this session is itself interactive (see "The registry" and "Interactive sessions" below). This is the default for a plain, ordinarypisession — unaffected by any of this. - subagent (joined) — a gentle-pi child matched to its orchestrator, as described in "Task and session views" above.
- subagent (orphan) — a gentle-pi child that could not be matched to
any orchestrator (shown separately, never dropped —
orphanSubagents). - uncertain — no recognised child-env-marker, a live tracked ancestor
process was found, and this process is not itself an interactive
TUI session: it cannot be proven top-level, so it is never counted as a
new task locally and never synced to the hub as one, but it is not
dropped either —
WorkRecord.roleConfidenceis set to"uncertain"on it, and/kankaku doctor(and a one-line hint on the plain/kankakusummary) surface it so the gap is visible instead of silently wrong. This is the fix for a real bug: a subagent mechanism kankaku does not specifically recognise (for example, pi's own bundled referencesubagentexample, which sets no env marker at all) used to default to"orchestrator"outright — a phantom top-level task on top of the time already measured inside its parent's own tool-call span, billed twice. An unrecognised process now degrades to a safe, visible undercount instead of a silent, unrecoverable overcount. An interactive session is never demoted this way, no matter what its ancestry looks like — see "Interactive sessions andKANKAKU_ROLE" below for why, and for the escape hatch when kankaku still gets it wrong.
An uncertain classification is recoverable going forward: once the
mechanism is recognised (for example, by upgrading kankaku, setting
KANKAKU_ROLE explicitly, or — in a later version — registering it via a
configured tool/env marker), a later /kankaku sync all or backfill
picks up the record correctly. It never resolves itself by guessing. A
record that was already written uncertain, however, cannot be rewritten
after the fact — worklog.jsonl is append-only and kankaku never edits a
past line (see AGENTS.md) — so only a run after the fix correctly
anchors a task; there is no migration that goes back and reclassifies old
lines.
The registry
Every kankaku process writes a small entry to
~/.kankaku/run/<pid>.json at startup — pid, parentPid, role,
project, its resolved (and, for a routed subagent, actually-used —
see "Cross-worktree write routing" below) KANKAKU_DIR, startedAt, and
processStartId (below) — independent of any project's own KANKAKU_DIR,
so it survives a project boundary. Both ~/.kankaku/run and its entry
files are created owner-only (0700/0600 — an existing looser mode, left
by an older kankaku build, is tightened on the next write, best-effort);
they name absolute project paths and session ids. The registry is a
startup-time lookup only — "who is my tracked ancestor, and where does
it keep its log" — resolved once, at process factory time, and never
consulted again later as a live pointer (this used to matter: see
"Cross-worktree write routing" below for why it no longer does). This is
what powers both of the following:
- Uncertain detection: a process with no child-env-marker walks its own
OS ancestor chain (one snapshot, see "Ancestor-chain detection" below)
looking for any live registry entry whose identity it can actually
prove — see "Identity, not just pid" below. Finding one means some
other tracked kankaku process is an ancestor of this one; combined with
this process not being an interactive TUI session (see "Interactive
sessions and
KANKAKU_ROLE" below), it is classifieduncertainrather than defaulting toorchestrator. - Cross-worktree write routing (ADR 0023, rewritten for a real bug —
see below): a gentle-pi subagent running in a different git worktree than
its orchestrator walks its ancestor chain, finds its orchestrator's
registry entry (identity-verified), and resolves it to an
orchestratorRef({ pid, project, startedAt, dir }—diralso resolves through a subagent-of-subagent chain to the real, top-level orchestrator, never a middle hop). When that orchestrator's directory differs from this process's own, the child writes its work log and its inflight crash-recovery checkpoints straight into the orchestrator's directory instead of its own cwd-relative one — so parent and child records end up in the sameworklog.jsonlfrom the moment the child's first record is appended, not merely discovered there later. The orchestrator's laterbuildTaskscall joins them with the samepid/parentPid/project-hint keys it always has; the interval-union rule itself is still computed in exactly one place (buildTasks) — this only changes where the bytes physically live, never how they are joined. If the orchestrator's directory cannot be created or written to (gone, or no permission), the child falls back to its own local directory instead of losing the record, and/kankaku doctorreports the fallback so it can be reunited manually; a record is always written to exactly one log, never both. Because reunification no longer depends on any pointer still being alive at read time, it survives the child's own exit cleanup removing its registry entry — which, for gentle-pi's main case (a blockingsubagent_runin task mode), has already happened by the time the parent regains control. If ancestry could not be established at all (or the write genuinely could not go anywhere), the child stays a visible orphan instead — undercounted, never lost, and never compensated for by summing two independently synced rows: the hub never sums two unions to recover a missing one, since that would double-count the overlap between parent and child.projectis therefore only ever a hint for the join (preferred when it matches), never a hard filter. - The registry is swept opportunistically (when a process writes its own entry) — see "Registry cleanup and health" below — so it does not grow unbounded and never keeps serving a stale identity.
Identity, not just pid — the PID-reuse fix
Matching an ancestor pid to a registry entry by pid number alone is not
safe: operating systems reuse pids. A kankaku process that dies without
cleanup (a crash, kill -9) can leave its ~/.kankaku/run/<pid>.json
entry behind; the OS can later hand that same pid to the user's own
interactive shell, and every genuine top-level pi session launched from
that shell would then falsely resolve a "tracked ancestor" — silently
misclassified uncertain forever, its task never synced. This inverts the
whole guarantee this feature exists for, so identity is proven, not
assumed:
- Every registry entry also carries
processStartId: an approximate, self-consistent epoch-ms estimate of that process's actual OS start time. This process's ownprocessStartId(the one it records about itself) is derived cheaply and portably — `Date.now() - process.uptime()- 1000
, sampled once at factory time — with **no subprocess spawn and no/procread at all**, so it is available on every platform, Windows included, and never adds startup cost (see "Startup cost" below). Verifying *another* process's (an ancestor's) live identity still needs a fresh reading of that specific pid from an OS ancestor-chain snapshot: on macOS/BSD,ps -eo pid,ppid,etime([[dd-]hh:]mm:sselapsed time, forced through the portableetimekeyword — BSDpshas noetimes); on Linux,/proc//stat'sstarttime(clock ticks since boot) combined with/proc/uptime, assuming the near-universalUSER_HZ=100— a wrong assumption never causes a false match, since the same (possibly wrong) constant is used both when an entry is written and whenever it is re-verified, and a process'sstarttimeticks never change during its life.process.uptime()-derived andps//proc-derived readings of the *same* process instance agree within the same tolerance (2000ms, which also absorbs each source's own second-granularity rounding) — this is cross-checked against a real OS reading byscripts/e2e-cross-worktree-real-processes.ts`. Windows has no supported source for a live ancestor's start time — see "Ancestor-chain detection" — so an ancestor still cannot be identity-verified there, even though this process's own id is now always available.
- 1000
- A match is only trusted when both sides prove the same identity: the
registry entry's own
processStartIdand a fresh re-derivation of that live pid's start time (from the ancestor's own current snapshot) agree within tolerance. A pid with a registry entry but a mismatched — or unprovable, on either side — identity is walked past exactly like an untracked hop, not treated as a match; if nothing further up the chain is provable either, ancestry detection reports "no tracked ancestor," which is the same safe fallback as if the registry were empty (this process classifies as a confirmedorchestrator, neveruncertain, from an unprovable candidate alone). - A legacy entry with no
processStartIdat all (written by a kankaku build predating this field) is never trusted for identity matching or kept around: it reads as stale and is removed by the normal sweep the next time any process writes its own entry.
Registry cleanup and health
- Every kankaku process removes its own entry file on a normal exit and on
session_shutdown(best-effort, verifying the on-disk file'spidandprocessStartIdstill match its own before unlinking, so it can never remove a file it does not verifiably own) — a crash still leaves the entry for the next sweep. Immediately before unlinking a discarded entry, the sweep also re-reads that file and compares it byte-for-byte against what it judged stale: if the pid was reused and a fresh entry already written to the same path in the meantime, the file is left alone instead of destroying a live registration the sweep never actually evaluated. - The opportunistic sweep (run whenever any process writes its own entry)
removes: entries for a dead pid; entries whose pid is alive but whose
recorded identity no longer matches that live process (pid reuse); and,
as a last resort, entries older than 7 days regardless of
aliveness/identity. An entry with no verifiable identity at all
(legacy/malformed, no
processStartId) is never itself grounds for deletion while its pid is alive and within the age ceiling — such an entry is never used for ancestor matching either way (matching always requires a verifiableprocessStartIdon both sides), but deleting it outright used to risk un-registering a genuinely live orchestrator whose own start-time read happened to fail, at the mercy of an unrelated sibling process's sweep. It still gets cleaned up the ordinary way, once its pid dies or it ages out. The sweep never removes the entry the writing process itself just wrote. /kankaku doctorreports registry health: how many entries it currently trusts, how many it would discard, and why (dead / stale-reuse / over-age).
Ancestor-chain detection
Reading "a live tracked ancestor process" above requires one OS-level
ancestor-chain snapshot. On Linux this is a set of /proc/<pid>/stat reads
(ppid and start-time ticks together, plus one /proc/uptime read); on
macOS, one ps -eo pid,ppid,etime snapshot (ppid and
elapsed-time-since-start together); a shell-wrapper hop with no registry
entry of its own is walked past, not stopped at.
Startup cost. This snapshot is taken at most once per process, at
extension startup, never on a later hot path — and, since it is the only
part of startup that ever spawns anything, it is skipped entirely unless
there is something for it to find: the machine-wide registry is read
first, and the snapshot is only taken when at least one other entry
exists that could possibly be this process's ancestor. The common case (no
other kankaku process running on the machine at all) therefore never
spawns ps or reads /proc — this process's own identity
(processStartId) is unaffected, since it comes from process.uptime()
instead (see "Identity, not just pid" above).
On a platform or environment where this mechanism cannot run at all —
Windows (no supported mechanism in this version), or any platform where a
fresh attempt still fails (ps//proc missing, timing out, or producing
unreadable output) — ancestor-chain detection degrades gracefully to "no
ancestor found" (never a spawn attempt beyond the one failed try, never a
crash). Critically, this does not mean every unmarked process there is
classified uncertain: with no way to check, kankaku falls back to the
same marker-only detection it used before this feature existed
(GENTLE_PI_AGENTS_CHILD=1/KANKAKU_ROLE=subagent → subagent, anything
else → confirmed orchestrator) — the deliberately chosen default, because
marking every genuine top-level session uncertain on such a platform
would drop all of that user's work, which is far worse than the narrow
overcount risk this guards against elsewhere. The trade-off is visible, not
silent: /kankaku doctor reports ancestor-chain detection as unavailable
whenever this happens (distinguishing it from "checked, no tracked
ancestor found" — a separate, always-accurate report never folded into
roleConfidence) and names KANKAKU_ROLE as the remedy for a genuine
subagent system that needs marking explicitly on such a platform — see
"Interactive sessions and KANKAKU_ROLE" below.
The /kankaku doctor diagnostic
/kankaku doctor reports, with no network call:
- How many records are orphaned subagents, and why.
- How many are
uncertain, and why. - Whether ancestor-chain detection is actually usable right now (see
above) — and, when it is not, a reminder that an unmarked subagent
system on this platform/environment may be counted twice, with
KANKAKU_ROLEnamed as the fix. KANKAKU_ROLE, when it decided this process's role, as the deciding signal — or, when it did not (a confirmed child marker took precedence, or an interactive session'ssubagentoverride was ignored — see "Interactive sessions andKANKAKU_ROLE" below), the contradiction and the resolved outcome instead.- Whether this process is a subagent that could not write to its
orchestrator's directory and fell back to its own local one (see
"Cross-worktree write routing" above) — a hint to go reunite that record
manually, since
worklog.jsonlcan never be rewritten after the fact. - Registry health (see "Registry cleanup and health" above).
- The current session's non-default session directory, when set.
The plain /kankaku summary also appends a one-line hint (N uncertain
record(s) excluded from tasks — run /kankaku doctor) whenever any exist,
so an undercount is never silent.
Interactive sessions and KANKAKU_ROLE
Every subagent mechanism kankaku recognises today launches its child
non-interactively, over pipes (gentle-pi's --mode rpc, pi's own
bundled subagent example's --mode json -p, pi-subagents) — a human
never sits in front of one. A process running as an interactive TUI
session (ctx.mode === "tui", pi's own signal for "a real terminal, a
human is here") is therefore always treated as a genuine top-level session
and is never classified uncertain, even when some ancestor in its
process chain happens to be a tracked pi process (for example, pi launched
from inside another pi's bash tool). Interactivity can only be known once
pi's own ExtensionContext is available, at session_start — later than
this process's binary role (orchestrator vs. subagent) is decided, but
roleConfidence is deferred and finalised exactly once, then, and stays
stable for the rest of the process's life.
KANKAKU_ROLE=orchestrator or KANKAKU_ROLE=subagent is an explicit
escape hatch — validated; any other value is ignored, falling back to
normal detection. Use it to force a session kankaku still gets wrong: mark
a genuine subagent system it does not recognise as subagent (this is
also the remedy /kankaku doctor names when ancestor-chain detection is
unavailable on the current platform), or force a session orchestrator
regardless of what its ancestry looks like. It has no effect on a record
already written — see "The role/state model" above.
Scope it to one invocation. Never export it in a shell rc, tmux config,
or CI environment file. process.env is inherited by every OS child by
default: an exported KANKAKU_ROLE reaches every pi invocation that
shell/session ever starts, subagents included. Set it only on the one
command it is meant for:
KANKAKU_ROLE=orchestrator pi ...Precedence (rewritten for a real bug — a BLOCKER fix). KANKAKU_ROLE
no longer overrides every other signal unconditionally:
- A confirmed child marker (
GENTLE_PI_AGENTS_CHILD=1, set only by the subagent runner itself, never something a shell rc/tmux/CI environment would export) always wins, even over an explicitKANKAKU_ROLE=orchestrator. Without this, aKANKAKU_ROLE=orchestratorexport that leaked into a shell rc — the natural thing to do after hitting a falseuncertainonce — would turn every one of that shell's later subagent invocations into a confirmed, independently-billed orchestrator: systematic multi-counting, invisible until someone compares the hub totals against what actually happened. KANKAKU_ROLE=subagent, with no confirmed marker, is ignored for an interactive session (ctx.mode === "tui"). No subagent mechanism kankaku recognises ever launches its child interactively, so this is almost always the mirror leak — a globally exportedKANKAKU_ROLE=subagentreaching a genuine top-level terminal session — and honouring it would silently drop that session's own work from every report and the hub (an orphaned subagent record that never anchors a task), with no way to recover it later, sinceworklog.jsonlis append-only. Between kankaku's two guiding rules — "undercount is recoverable, overcount is not" (which governs the opposite risk, inventing extra billing, and does not apply to this contradiction) and "never silently drop genuine work" — this one is governed by the second: the override is ignored, the session is classifiedorchestrator(what it structurally must be), and the contradiction is surfaced once viactx.ui.notify(a warning) atsession_startand in/kankaku doctor— never resolved silently.KANKAKU_ROLE=orchestratorhas no such exception: forcing a sessionorchestratorcan never drop work, only (rarely) invent a task that should not exist, a risk the user accepted by setting it explicitly.- Otherwise
KANKAKU_ROLE, when set to a recognised value, decides — as before.
/kankaku doctor reports KANKAKU_ROLE as the deciding signal only when
it actually decided anything: it flags "override present AND child marker
present" with the resolved outcome (subagent, per rule 1) when both are
set, and reports the resolved orchestrator outcome (per rule 2) when a
subagent override was ignored for an interactive session — in neither
case does it claim the override was the deciding signal.
Non-propagation. KANKAKU_ROLE decides only the process that reads
it. kankaku strips it from its own process.env right after reading it
(before spawning anything), so a child it spawns — a subagent runner, a
tool shell — never inherits it, even when this process's own copy came
from something outside kankaku's control (a shell rc, tmux, CI). This is
a second, independent layer on top of rule 1 above: rule 1 already
neutralises a leaked KANKAKU_ROLE=orchestrator for any recognised
subagent mechanism (its confirmed marker always wins regardless), but
stripping means the leak can never reach an unrecognised one, or any
other child process, either.
Captured once per process, survives /new//resume//fork//reload.
pi re-invokes an extension's factory function in the SAME OS process for
each of those (it "reloads and rebinds extensions" for the new session);
kankaku reads KANKAKU_ROLE and decides role from the very first
invocation and reuses that exact result for every later one in the same
process, so a KANKAKU_ROLE=orchestrator you set for one pi command
stays honoured across every /new//resume//fork//reload you run
inside that same session, not just the first. This does not widen the
"scope it to one invocation" rule above — it still applies only to the one
pi process you set it on, and is still stripped from that process's own
process.env right after the first read, so it is still never inherited
by anything that process spawns. It only means "one invocation" is
honoured for as long as that OS process stays alive, across every reload,
rather than being silently forgotten the moment pi reloads extensions
internally.
Subagent profiles (phase 6b)
kankaku recognises a subagent-opening tool call through a SubagentProfile
(one per ecosystem package), not a single hardcoded tool name. Three
profiles are built in:
- gentle-pi (first-class):
subagent_run, joined by explicittaskId(result.details.gentleAgents), confirmed byGENTLE_PI_AGENTS_CHILD=1. Nothing about gentle-pi changes — every field it already exposed (agent, mode, taskId, live status, cross-worktreecwd) still does. - pi's bundled reference example: the
subagenttool, no env marker at all — recognised only through ancestry, always startsuncertainuntil the registry/ancestor-chain mechanism above corroborates it. - pi-subagents: also registers a tool named
subagent, confirmed byPI_SUBAGENT_DEPTH(present with any value — its own recursion-depth counter, not a fixed sentinel).
Two packages registering a tool with the exact same name (subagent) is a
real ambiguity kankaku never guesses through: which ecosystem package
actually made a given call can only be told apart by its child-env marker
(present in the child process, not visible from the parent's tool-call
alone), so a call to subagent still opens a span (best-effort agent/mode,
kept only when every candidate profile that reports one agrees), but is
never attributed to one specific profile unless a marker resolves it.
Nothing money- or join-affecting is ever taken from an ambiguous call
either — no usage, no taskId — even when one of the colliding
profiles would normally forward one, because kankaku cannot tell whether
that specific call actually came from that profile. /kankaku doctor
reports this as an "ambiguous tool name" line.
KANKAKU_SUBAGENT_TOOLS registers one or more additional tool names as
subagent-opening spans, comma-separated, parsed exactly like
KANKAKU_INTERACTIVE_TOOLS — always additive to the built-ins, never
replacing gentle-pi's own recognition.
KANKAKU_SUBAGENT_CHILD_ENV registers one or more child-process env
markers that confirm a process as this configured tool's subagent,
;-separated NAME=VALUE (exact match) or a bare NAME (presence-only,
any non-empty value) — mirrors KANKAKU_SEGMENTS's tolerant parsing:
malformed entries are skipped, not fatal.
KANKAKU_SUBAGENT_TOOLS=my_subagent_tool
KANKAKU_SUBAGENT_CHILD_ENV=MY_TOOL_CHILD=1What NOT to use as a marker. A configured marker must be exclusive to
the child process your subagent tool actually spawns — never an ambient
variable pi, your shell, npm, or the OS sets on every process. kankaku
rejects an obviously-ambient name outright at load time (case-insensitive):
PI_CODING_AGENT and AI_AGENT (pi sets both on every process it runs,
not just a subagent's child), the generic shell/OS variables PATH,
HOME, USER, SHELL, PWD, CI, LANG, TMUX, and anything prefixed
PI_, TERM, LC_, NODE_, NPM_, or KANKAKU_. A rejected marker
never reaches the configured profile — it is reported once via
ctx.ui.notify and listed in /kankaku doctor, never silently accepted.
This denylist cannot enumerate every possible ambient variable, though, so
there is a second, runtime layer: a configured marker never demotes an
interactive session, exactly like KANKAKU_ROLE=subagent already does
not (see "Interactive sessions and KANKAKU_ROLE" above) — if a configured
marker matches on a session that turns out to be interactive, kankaku
treats it as the orchestrator it structurally must be and warns once
(escalated to a stronger warning when that session also has no tracked
ancestor at all, the clearest sign the "marker" is actually ambient). A
built-in marker (GENTLE_PI_AGENTS_CHILD, PI_SUBAGENT_DEPTH) keeps
the unconditional precedence it always had — no built-in mechanism kankaku
recognises ever launches its child interactively, so this exception never
actually applies to it in practice.
Verify with /kankaku doctor. After configuring
KANKAKU_SUBAGENT_CHILD_ENV, run /kankaku doctor from an ordinary
top-level session: it must not report a "configured marker" or
"rejected marker" line for a ordinary interactive session. If it does, the
chosen name is either denylisted or ambient enough to trip the interactive
guard — pick something the third-party tool's own child process sets that
nothing else on the system would ever set.
A confirmed marker from a configured profile that passes both layers above
still takes the same "always wins over KANKAKU_ROLE" precedence gentle-pi's
own marker already had for a non-interactive process — see "Interactive
sessions and KANKAKU_ROLE" above.
/kankaku doctor reports the active profile set, any configured tools/
markers, which profile matched each subagent record (or "unmatched" when
no marker resolved it), any rejected marker names with why, and a
configured-marker-ignored-for-interactivity contradiction when one occurs.
In-process subagents (phase 6c)
A subagent tool result's usage field — pi's own documented convention
for "a tool making nested LLM calls should return their combined Usage
as usage" — is recorded on the span itself (never folded into the
triggering record's own usage totals at write time any more), and added to
the task's aggregate total by buildTasks — the one place per-task
usage is ever assembled — except when this same task also has a joined
child record confirmed by the same profile: that child's own usage
already carries this cost through its own confirmed-marker/ancestry join,
so the span's forwarded figure is excluded instead of counted a second
time. gentle-pi is unaffected (its result never carries one — cost for its
children is, and stays, tracked through the registry/ancestry join above).
A profile whose marker can also produce an ancestry-joined child record
with its own usage (pi-subagents) never forwards usage even when its
result happens to carry one, to avoid counting the same nested work twice
by construction; a configured profile that declares both a marker
and forwards usage relies on the runtime reconciliation above instead (see
"Subagent profiles (phase 6b)"). Usage is never forwarded for an
ambiguous tool-name match (2+ profiles registering the same name, e.g.
subagent) — see "Subagent profiles (phase 6b)" above.
Real in-process (same-OS-process, no separate pid) subagent nesting was
investigated directly against pi's own source and documented API
(docs/extensions.md) for this release: none of gentle-pi, pi's bundled
reference example, or pi-subagents actually run a child inside the
parent's process — every one of them spawns a real, separate OS process.
pi's own in-process mechanism (ctx.newSession/ctx.fork) replaces one
session with another sequentially in the same process (the old session's
session_shutdown fires, then the new one's session_start — never
concurrently), which is exactly what "kankaku reads/writes a fresh record
per session_start, same pid" already handles correctly. As a defensive
guard for the pattern true concurrent nesting would leave behind,
/kankaku doctor flags two confirmed-orchestrator records sharing a pid
with overlapping [startedAt, settledAt] windows as "likely
in-process nesting", unioning (never summing) their wall time via the
same interval-union primitive buildTasks itself uses — informational
only, it never changes a task's own numbers. This has not been observed
from any real subagent mechanism in this codebase's research; if pi (or an
extension built on its SDK) grows genuine concurrent in-process nesting in
the future, this is the signal that would surface it.
Limitations, honestly
- Windows has no ancestor-chain detection (an ancestor can never be
identity-verified there), though this process's own
processStartIdis always available regardless of platform — see "Identity, not just pid" above. Mark a genuine subagent system explicitly withKANKAKU_ROLEon such a platform; see "Interactive sessions andKANKAKU_ROLE" above. - gentle-pi's child cannot currently read its own task id — the cross-worktree join above relies on ancestry plus the registry, not on an explicit shared id, because upstream gentle-pi does not hand the child process its task id today. If that changes upstream, a future kankaku version can upgrade this join to a higher-confidence explicit-id match.
- Ancestor-chain detection only sees the chain as it exists when a
process looks. A detached child reparented to init/launchd before that
point cannot recover its original ancestry this way — the same limitation
the existing
pid/parentPidcapture already has (see AGENTS.md). - A record already written
uncertain(or already routed to a fallback local directory) cannot be rewritten.worklog.jsonlis append-only; fixing the underlying cause (upgrading kankaku, settingKANKAKU_ROLE, restoring access to an orchestrator's directory) only helps a later run's records, never edits a line already on disk. There is no migration planned for this — it follows directly from "never rewrite the log" (see AGENTS.md).
The /kankaku command
/kankaku with no arguments, run inside pi's TUI, opens an overlay panel
modelled on pi's own /settings (the same pi-tui SettingsList/
SelectList widgets, the same keys) from which every kankaku view and
action is reachable:
- Target — billing client, project, hub task, and the legacy label
(see "Billing labels" and "Hub (PocketBase)" below). The Task row lists
the open/doing hub tasks of the current project; picking one links the
session, exactly like
/kankaku task pickbelow — session-only, never persisted (see "Linking to a hub task"). - Report — today/all totals, tasks, sessions, clients, and projects:
the same five views
/kankaku's subcommands produce. - Sync (hub only) — status, sync now, sync all, backfill, and a catalog refresh.
- Export — write today's or every task as csv/json.
- Doctor — orphan/uncertain subagent counts and ancestor-detection availability.
- About — versions, the resolved
KANKAKU_DIR, the hub URL, and every env-only setting, read-only.
Keys: ↑↓ move, Enter open a section or select a value, Esc or ← go
back (or close the panel at the root), q close from anywhere. Mouse: the
footer hints and list rows are clickable, but only in pi's fullscreen
mode — pi does not dispatch mouse events in its regular (non-fullscreen)
mode, so there the panel is keyboard-only.
Every subcommand below is unchanged, and is exactly what headless (print
or RPC) mode still uses — /kankaku there keeps showing today's totals
directly, never the panel, since there is no UI to open one in. Run
/kankaku <subcommand> (with arguments) in the TUI to skip the panel and
go straight to that subcommand's output, exactly as before the panel
existed.
Plain /kankaku (headless, or any subcommand below) shows today's totals
(work, waiting, record count) per role, plus a union-based tasks segment.
Each totals line also shows cache hit NN% when tokens were recorded: the
share of prompt input tokens served from the provider's prompt cache, cache
reads over input plus cache reads plus cache writes; the segment is
omitted, not shown as 0%, when no tokens were recorded. In the
interactive TUI the report is appended to the chat transcript as a durable
card that is never sent to the LLM; without a UI (print or RPC mode) it
falls back to a notification. Arguments are whitespace-separated and
order-insensitive:
/kankaku(headless only — the TUI opens the panel instead, whose Report screen defaults to the same view) — today's role totals and tasks segment, each with its estimated cost./kankaku all— same, but across every record./kankaku tasks— one line per task (time, union wall/work, cost, subagent count, truncated prompt) for the current pi session. Addallfor every session. If the current session has nosessionId, tasks from every session are shown instead./kankaku sessions— one line per session (id, time range, union wall/work, cost, task count) for today. Addallfor every day./kankaku client <name>— set the billing client for the current pi session./kankaku clientalone shows the effective client and which source it came from;/kankaku client --clearremoves the session-level override. See "Billing labels" below. When a hub is configured,<name>must match a catalog client's code or name (case-insensitive) instead of being free text — see "Hub (PocketBase)"./kankaku clients— one line per client (work/waiting/wall time, cost, task count) for today. Addallfor every day. Tasks with no resolved client are grouped under(none)./kankaku doctor— orphan/uncertain subagent record counts and why, plus ancestor-detection platform availability. No network call. See "Subagents".
The following are available only when a hub is configured (see "Hub (PocketBase)" below):
/kankaku target— show the effective client/project and which source produced it./kankaku target pickruns the picker again (works mid-session; the new target applies to records settled afterwards)./kankaku target clearclears the session-level target./kankaku task(or/kankaku task pick) — link this session to an open/doing hub task of the effective project./kankaku task cleardrops the link. See "Linking to a hub task" below./kankaku catalog refresh— force a catalog refresh and report the client/project counts./kankaku projects— one line per project (work/waiting/wall time, cost, task count) for today. Addallfor every day. Tasks with no resolved project are grouped under(no project).
Cost figures are the sum of usage.cost as priced by pi's model table
(per-million-token rates in models.json, adjustable with modelOverrides).
For subscription-based providers this is an estimate at API list prices, not
an invoice.
While an agent is running, pi's status bar shows a 🕒 mm:ss · <client> indicator (the client part appears only when one resolves); while idle it shows 💼 <client>, or nothing when no client resolves. The entry is keyed zz-kankaku so it sorts last among extension statuses. The running indicator carries
the elapsed time for the current run.
Billing labels
Every WorkRecord can carry a client — who the work is billed to — so
reports and exports can be grouped by client. The effective client is
resolved from three sources, in decreasing precedence:
- Session — set with
/kankaku client <name>(see above), persisted as akankaku-clientcustom session entry and restored on session reload. KANKAKU_CLIENT— the environment variable, a per-process default.- Project —
clientin<KANKAKU_DIR>/config.json(e.g.{"client": "acme"}), the project's own default.
A client name must match /^[A-Za-z0-9._-]{1,64}$/; anything else (empty,
too long, containing spaces or other characters) is ignored and resolution
falls through to the next source.
A subagent_run child process does not resolve its own client — a
subagent's own WorkRecord never carries client. Instead, the task
view (see "Task and session views") exposes the client from its
orchestrator record only, so /kankaku tasks, /kankaku clients, and the
export all see subagent work grouped under the task's (i.e. the
orchestrator's) client.
sessionName is also attached to every record from pi.getSessionName(),
so reports can show which named session produced a task.
Hub (PocketBase)
kankaku can optionally resolve the billing client (and a project) from a
PocketBase instance instead of free text, so cajamar/Cajamar/cjamar
can no longer become three different clients. This is phase 1 of the hub
integration (catalog + selection only): nothing is uploaded anywhere.
Configuration
Set KANKAKU_PB_URL, KANKAKU_PB_EMAIL, KANKAKU_PB_PASSWORD, or write
~/.kankaku/credentials.json:
{ "url": "https://pb.example.com", "email": "[email protected]", "password": "secret" }Environment variables take precedence over the file, field by field. The
hub URL must be HTTPS unless it points at localhost/127.0.0.1/::1; a
plain-HTTP URL for any other host is refused (surfaced once via a
notification). The project's own <KANKAKU_DIR>/config.json is never read
for credentials — it is project-local and frequently committed.
KANKAKU_MACHINE optionally names this machine (for a multi-machine setup
later); it defaults to the OS hostname and is attached to every record as
machine once the hub is configured.
When no hub is configured, kankaku behaves exactly as it does today — this whole feature is additive and every existing behaviour, record shape, and report stays unchanged.
Selection
On session_start, for the orchestrator role with a UI available:
- Session — restored from the last
kankaku-targetsession entry (including a remembered "skipped" choice, so a reload does not ask again). - Project config —
clientId/projectIdin<KANKAKU_DIR>/config.json. repo_paths— the current working directory matched against each project'srepo_paths(exact match, or a subdirectory of one; the longest match wins).- Otherwise, a picker:
ctx.ui.selectfor the client (active clients, sorted by name, plus "— skip —"), then for the project (active projects of that client, plus "(no project)" and "— skip —"). Declining at either step — "— skip —" or dismissing the dialog — cancels the whole pick and is remembered for the session. The picker shows the freshly refreshed catalog when the hub answered within the deadline described in "Caching and offline behaviour" below; otherwise it falls back to the cache.
After a pick, kankaku asks whether to remember it for this repository; a
"yes" merges clientId/projectId into <KANKAKU_DIR>/config.json.
An id from any source that no longer resolves to an active, non-"unassigned" catalog entry is treated as absent for that source and resolution falls through to the next one, exactly like the legacy client precedence.
Once a hub target is active for a run, the legacy client label is set to
the target's client code (so every existing report/export keeps grouping
correctly), and the record additionally carries clientId, clientName,
and — when a project is selected — projectId/projectName. A subagent
never resolves its own target, exactly like the legacy client label — the
task view exposes it from the orchestrator record only.
The status bar shows 💼 <client> · <project> (or just 💼 <client> without
a project) in place of the legacy client label, both idle and during a run.
Mid-session, the /kankaku panel's Target screen (see "The /kankaku
command" above) is the interactive way to change the client or project
without going through /kankaku target pick's picker dialog — it edits the
same session-level target, applies through the same SessionTarget, and
has its own "Remember" row for <KANKAKU_DIR>/config.json.
Linking to a hub task
/kankaku task (or /kankaku task pick) links the current session to one
of the effective project's existing hub tasks rows: a ctx.ui.select
picker lists the project's open/doing tasks, sorted by title (colliding
titles are disambiguated with the task's external reference, or its id).
/kankaku task clear drops the link, keeping the rest of the session
target. kankaku never creates a task from pi — this only links to one
that already exists in the hub.
The link is session-only: unlike clientId/projectId, it is never
persisted to <KANKAKU_DIR>/config.json, and it is never asked for at
session_start — you always link a task explicitly, with /kankaku task.
Any target change (/kankaku target pick, /kankaku target clear, or the
legacy /kankaku client <name>) drops the linked task, since a new client
or project makes the old task's link meaningless. A task whose project no
longer matches the effective project (e.g. after a target change or a
reassignment in the hub) is also dropped by the domain resolver, never
silently linked across projects. A task already marked done when linked
keeps linking for the rest of the session — only the picker itself hides
done tasks, so you cannot accidentally pick a closed one, but finishing
the picked task in the hub mid-session does not break the link. Subagent
records never carry a linked task, exactly like clientId/projectId —
the task view exposes it from the orchestrator record only, and
formatWorkTargetLabel appends it to the status-bar/report label as
<client> · <project> › <task title>.
The /kankaku panel's Target screen's Task row is the interactive
equivalent of /kankaku task pick/clear: it lists the same open/doing
tasks, applies the link through the same SessionTarget.setTask, and
stays just as session-only — picking a task there is never persisted to
config.json either.
Caching and offline behaviour
The catalog (clients/projects) is cached machine-wide at
~/.kankaku/catalog.json. On session_start, for the orchestrator role
with a UI available, kankaku always starts a background refresh when a
cache already exists — regardless of the cache's age — so a client or
project created in the hub minutes ago shows up without waiting for a TTL
to expire (the 6-hour TTL and isStale() still exist and still gate other
callers, but session start no longer depends on them). If the target
resolves silently from the project config file or repo_paths against
the cached snapshot, ensurePicked returns immediately without waiting
for that refresh at all; it keeps running in the background and
catalog.read() reflects it once it lands, exactly as before. Only when
the picker is actually about to be shown does kankaku wait for the
in-flight refresh, bounded by a short deadline (1.5s by default,
pickerRefreshDeadlineMs): if the hub answers in time, the picker offers
the fresh clients/projects; otherwise (or if the refresh fails) it falls
back to the cached snapshot silently, and the refresh keeps running
in the background rather than being aborted. /kankaku target pick (the
explicit re-pick command) follows the same wait-then-fall-back rule. When
there is no cache at all, one refresh is still awaited (bounded by the hub
client's own request timeout, 3s by default) before falling back — this
path is unchanged. If the hub is unreachable and there is no cache,
kankaku notifies once (kankaku: hub unreachable, using local labels) and
continues exactly as it would without a hub configured; a background
refresh that merely fails once a cache already exists is silent, with no
notification. /kankaku catalog refresh still forces a refresh on demand
independently of any of this. The cache file is always written owner-only
(0600); if kankaku is the first thing to ever create ~/.kankaku itself
(no project has put its own .kankaku there), the directory is created
owner-only (0700) too — but an already-existing ~/.kankaku is never
chmod'd, since it may be a project's own kankaku directory (see "The
registry" below for the same rule applied to run/).
Privacy (catalog)
The catalog itself (clients/projects) is read-only — nothing about that data is ever written back. Whether your own work records ever leave the machine is a separate, opt-in decision: see "Sync" below.
Sync
Once a hub is configured, kankaku can push consolidated task rows (see
"Task and session views" above) to PocketBase, so a project/task manager
can report AI time and cost per project. This is an outbox pattern:
worklog.jsonl stays the local source of truth, append-only and never
rewritten, exactly as without a hub. A separate sync step reads it and
uploads what is pending — nothing in a pi event handler ever waits on the
network.
What gets uploaded. One task_entries row per task — never raw
WorkRecords re-aggregated on the server. The union-of-intervals rule
(wallMs, "Task and session views") is computed exactly once, locally, by
buildTasks; the hub only ever sums already-consolidated rows. When
KANKAKU_SYNC_RECORDS is not 0 (the default), each task's underlying
WorkRecords are also uploaded as work_records, raw per-run detail for
drilling into a task — these rows overlap each other and must never be
summed, unlike task_entries.
Idempotency and the revisit window. Every task is upserted by its id
(the orchestrator record's id), never blindly created — safe to
re-send. A task is not final the moment its orchestrator settles: a
background subagent can settle after it and extend the task's union
(wallMs, cost, subagent count) for a task that may already be in
PocketBase. So every sync revisits a trailing window behind its own
watermark — KANKAKU_SYNC_WINDOW_HOURS, 24h by default — and re-evaluates
every task whose endedAt falls inside it. A cheap content hash per task
(<KANKAKU_DIR>/sync-state.json) means an unchanged task inside the window
costs nothing: running /kankaku sync twice in a row performs zero writes.
The window is anchored to syncedThrough (the watermark), never to
current wall-clock time — see "Limitations" below for what that means for
a background subagent that settles long after its orchestrator, and after
the directory has otherwise gone quiet.
Assignment is create-only. You (or whoever reassigns work in the hub's
web app) can move a task from one client/project to another directly in
PocketBase — for example, moving a "Sin determinar" row to its real
client once you have identified it. A later re-sync of that same task
must never undo that: on create kankaku sends the full row, including
client/project/task/legacy_client_label; on every subsequent update
it sends measurement fields only (wall_ms, cost, status, ...) and
never touches assignment fields again. task (the linked tasks relation)
is create-only for the exact same reason: reassigning which task a row
belongs to in the web app is never undone by a later sync. If you need
kankaku itself to change a task's assignment, do it in the web app, not by
re-syncing.
Historical ("Sin determinar") records. A record with no clientId, or
whose clientId no longer resolves in the catalog, is routed to the hub's
"Sin determinar" (unassigned) client, carrying its old free-text client
label (or clientName) forward as legacy_client_label — the exact
mechanism that lets you bulk-reassign "everything that said cjamar" once,
in the web app, from the unassigned queue.
Agent and measurement quality. Every task_entries row also carries
who produced it and how well each figure was measured, so the hub can
label what it has instead of silently blending incompatible numbers from
different agents: agent ("pi"), agent_version (pi's own version,
when it could be determined — never guessed, omitted otherwise), plugin
("kankaku"), plugin_version (this package's own version),
waiting_quality (always "measured" for kankaku/pi — it always
instruments waiting time), cost_quality ("measured" when the task's
own record or any joined subagent observed a real provider cost figure on
at least one turn; "unknown" when none did, e.g. a subscription/OAuth
provider that reports no cost — kankaku has no token-price estimator, so
it never sends "estimated"), and subagent_linkage ("not_applicable"
when the task opened no subagent spans; "linked" when at least as many
child records were joined as spans were opened; "unlinked" otherwise —
a task-level approximation, since there is no per-span correlation id
today, see "Subagents" > "Limitations"). These are measurement fields, not
assignment: sent on every create and update, and included in the sync
content hash, so a background subagent that joins later — improving
cost_quality/subagent_linkage without changing any other number —
still triggers a resync. An older hub predating these fields simply
ignores them (PocketBase silently drops unrecognized fields on write); no
capability probing is needed.
Session directory. session_dir carries a task's non-default session
directory (TaskView.sessionDir, see "Record schema") to the hub, so a
resumable session can be resumed from the web, not just locally via
/kankaku doctor. It is optional — only present when pi reports a
non-default session directory — and, like the fields above, a measurement
field: sent on both create and update, and included in the sync content
hash so a session dir change alone triggers a resync. Like repo_project,
it is an absolute local filesystem path (username, disk layout) — the same
category of exposure the hub already accepts for repo_project, not a new
one. An older hub predating this field simply ignores it (PocketBase
silently drops unrecognized fields on write).
Privacy. KANKAKU_SYNC_PROMPT controls whether a task's prompt text
leaves the machine at all: none (default — omitted entirely), truncated
(first 120 chars plus …), or full.
Commands:
/kankaku sync— push everything pending (new tasks, plus anything inside the revisit window that changed)./kankaku sync all— a full re-evaluation: every task, not just the window. Safe and cheap to run — the content hash still skips anything unchanged./kankaku sync status— the current watermark, a locally-computed pending count (no network), how many never-synced tasks fall outside the current revisit window (needssync all— see "Limitations" below), and the last sync error, if any./kankaku backfill— a full sync, reported grouped bylegacy_client_label: how many tasks went to "Sin determinar" and under which old label, so you know what to reassign in the web app's unassigned queue. This never rewritesworklog.jsonllocally — the reassignment happens once, in PocketBase, and survives every future sync (see "Assignment is create-only" above).
Automatic sync. Unless KANKAKU_SYNC_AUTO=0, kankaku also syncs
automatically on three triggers (orchestrator role only): fire-and-forget
(never awaited, errors never surface as a failure of the run that
triggered them) on session_start (after crash recovery) and again after
agent_settled; and, on session_shutdown, one awaited, time-bounded
sync — pi awaits its session_shutdown handlers with no timeout of its
own, so this is the one place kankaku's own handler awaits the network, up
to shutdownSyncTimeoutMs (default 3 s). This is what makes the last
prompt(s) of a session reach the hub when the session ends, rather than
only on the next session's session_start: quitting with an unreachable
hub costs at most that timeout longer, never more, and cleanup (status
bar, session-client bookkeeping) still runs even if the sync times out or
fails. All three triggers share one single-flight guard, so they never
race each other within a process — a session_shutdown sync that arrives
while one is already in flight awaits that same one rather than starting a
second — and a lock file (<KANKAKU_DIR>/sync.lock, an atomic
exclusive-create so two racing processes can never both acquire it, stale
after 5 minutes) keeps two pi processes from syncing the same directory
concurrently. Subagents never sync. None of the three triggers notify on
success; on failure (including a shutdown timeout) they notify at most
once per session (kankaku: sync failed: ... / kankaku: shutdown sync
timed out) — check /kankaku sync status for the details, including on a
later run.
The automatic path is cheap on every prompt, not just fire-and-forget: it
skips entirely (no read of worklog.jsonl, no network) when the log has
not changed since the last successful sync, for all three triggers.
Otherwise, only agent_settled — fired once per prompt — is throttled, to
at most once per KANKAKU_SYNC_MIN_INTERVAL_MINUTES (default 5; 0
disables the throttle); since right after agent_settled the log has
just changed (a record was just appended), this throttle is what actually
keeps that trigger cheap. session_start and session_shutdown never
throttle: a session boundary is worth catching up on regardless of how
recently the last automatic run happened, so a stuck hub does not stay
silently unsynced across restarts, and the shutdown sync is already
bounded by its own timeout. None of this ever applies to a manual
/kankaku sync, sync all, or backfill.
Network/validation failures. A network or server (5xx) error stops a sync run where it is and does not advance its watermark past the failing task — nothing is lost, and the next sync (manual or automatic) picks up exactly there. A task that fails validation (e.g. a genuinely malformed payload) is recorded with its reason and skipped — not retried on every single run — but is retried automatically the moment its content changes.
Limitations:
- Sync state (
sync-state.json) is per repository/machine, not centralized; there is no standalone CLI entry point yet (npx kankaku syncoutside of pi) — see "Roadmap". - A late background child, and the revisit window (R3). A background
subagent can settle well after its (possibly cross-worktree)
orchestrator process has already exited — its record still writes
correctly into the orchestrator's
worklog.jsonl(see "Subagents" > "Cross-worktree write routing"), but nothing syncs it until that directory is next visited: pi opened there again (session_start's auto-sync), or/kankaku sync/sync allrun there manually. Subagents themselves never sync (see "Automatic sync" above). An ordinary incremental sync then picks the late child up wherever the task sits: a task the hub already holds is re-synced whenever i
