Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
66dc88a
Keep each session's slot under its own store key, so sessions in one …
claude Oct 15, 2026
52353e2
Give back answers riding a typed prompt that a hook beneath drops; 0.1.5
claude Oct 15, 2026
718e743
Only a session writes its slot; it notes the tasks it takes on in the…
claude Oct 17, 2026
79936da
Claim updates before they go, and never wait long on the store
claude Oct 17, 2026
7ee98a2
Write nothing before the project is read, and close round four's gaps
claude Oct 17, 2026
57edcf6
Take the project on against the list as it stands; skip what isn't a …
claude Oct 17, 2026
26a8159
Keep a task taken back, and what a session that never read the projec…
claude Oct 17, 2026
af981d1
Count a take-back as the session holds it; start afresh in another fo…
claude Oct 17, 2026
c86b951
Bound the start's wait on the store; keep a quiet session's slot
claude Oct 17, 2026
500c670
Keep what a late-reading session was done with; take nothing quiet at…
claude Oct 17, 2026
f55aa3c
Show a take whose write failed; keep a 0.1.4 note off what is taken on
claude Oct 17, 2026
2046d91
Keep a slow pane out of the store's wait; start a new conversation clean
claude Oct 18, 2026
d383867
Mark a resumed session's slot alive as it joins
claude Oct 18, 2026
3b21ef2
Keep a session's end and its tasks through a slow join after /clear
claude Oct 18, 2026
01c7b70
Stop a reworded response from going every turn; close round 14's gaps
claude Oct 18, 2026
2744da6
Hold a response a hook keeps out; guard a move by a flag, not the root
claude Oct 18, 2026
01404e9
Mark heard only the responses list_tasks shows
claude Oct 18, 2026
f29309e
Hold a quick second resume until its folder is read
claude Oct 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "human-in-the-loop",
"displayName": "Human in the loop",
"version": "0.1.4",
"version": "0.1.5",
"description": "Claude assigns you the tasks only you can do. They wait in a My tasks pane, counted under the prompt, until you answer or reject them, so nothing Claude needs from you gets lost in the chat.",
"author": {
"name": "Tzafrir Rehan"
Expand Down
18 changes: 10 additions & 8 deletions docs/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

A Claude Code mod in which Claude assigns tasks to its user. Each task waits in the My tasks pane, counted in a status line under the prompt, until the user fulfills it or rejects it.

Status: v0.1.0 built, at `~/.claude/dev-mods/<session>/human-in-the-loop/` (16 tests pass, strict validation passes), checked against the mod API of Claude Code 2.1.288. Changes since the first draft: the tasks moved from the band above the prompt to a pane plus a status line ([The UI](#the-ui)); Accept is gone; a mid-turn Send now that the running turn refuses goes as a message the moment the turn ends; since 0.1.3, answers saved while Claude worked go when its turn ends too, once no task is left open (with one open, they go with its answer); since 0.1.4, a response whose prompt doesn't enter stays saved, a session back from sleep lets go of the tasks another session took on, and a task number two sessions drew at once is renumbered where their tasks meet ([Persistence](#persistence)). What only a live session can confirm is listed under [Spike first](#spike-first).
Status: v0.1.0 built, at `~/.claude/dev-mods/<session>/human-in-the-loop/` (16 tests pass, strict validation passes), checked against the mod API of Claude Code 2.1.288. Changes since the first draft: the tasks moved from the band above the prompt to a pane plus a status line ([The UI](#the-ui)); Accept is gone; a mid-turn Send now that the running turn refuses goes as a message the moment the turn ends; since 0.1.3, answers saved while Claude worked go when its turn ends too, once no task is left open (with one open, they go with its answer); since 0.1.4, a response whose prompt doesn't enter stays saved, a session back from sleep lets go of the tasks another session took on, and a task number two sessions drew at once is renumbered where their tasks meet ([Persistence](#persistence)); since 0.1.5, each session's slot has a store key of its own that only it writes, so two sessions in one folder can't write over each other's tasks, a session notes the tasks it takes on in its own slot, in the same write, and answers riding a typed prompt that a hook beneath drops, or takes them out of, wait for the next one. What only a live session can confirm is listed under [Spike first](#spike-first).

## The problem

Expand Down Expand Up @@ -222,14 +222,16 @@ An answer that looks like a secret (`sk_live_`, `sk-ant-`, `AKIA…`, `ghp_`, `x
## Persistence

- **`$.state` (the session's):** the task list, the selected task, the open field and whether Claude is working. The pane draws from it, and it survives hot reloads.
- **`$.store` (across sessions, one JSON file per plugin):** the key `tasks:<project root>` (`$.session.root()`, the folder the session started in) holds `{ nextId, sessions, taken }`: the next task number, shared by the project's sessions; one slot per session with its open tasks and undelivered updates, when it was last alive (`seenMs`), and when it ended (`endedMs`); and, per session that went quiet, the numbers of the tasks another session took on from it. Each session writes its own slot only (`hooks/project.ts`), so sessions never write over each other's tasks.
- **`$.store` (across sessions, one JSON file per plugin):** each session's slot sits under a key of its own, `tasks:<project root>#session:<id>` (`$.session.root()`, the folder the session started in; a root too long for the store's keys, 256 UTF-16 code units, goes by its hash), and only that session writes it: its open tasks and undelivered updates, when it was last alive (`seenMs`), when it ended (`endedMs`), the next task number as it counts them (`nextId`), and the tasks it took on from other sessions (`took`). `$.store` writes one key at a time and has no compare-and-set, so a key per session is what keeps one session from putting back a stale copy of another's tasks (`hooks/project.ts`). A session id has no `#`, so a key that goes on with one past the prefix is another folder's, whose path goes on with `#session:`. Anything stored that isn't a slot is skipped, and so is anything in a slot that isn't a task, a count or a note. 0.1.4 kept the whole project in one value under `tasks:<project root>`: this version reads it (skipping any entry in it that isn't a slot or a note), takes on its sessions' tasks like any others, and changes it only to take their slots out, leave a note for a quiet one, and, while a 0.1.4 session is alive there, raise the task count it numbers from when it falls behind; each change is made to the value read fresh just before, once the session's own slot carries what it relies on (a note of 0.1.4's leaves once the tasks it named are let go in the slot).
- **Tasks belong to the session that assigned them.** Its pane shows them, and answers go to its Claude. Another live session in the same project shows none of them; its status line counts them: `1 task in another session`.
- **A session is alive** while it writes its slot: at every change, and on a heartbeat every minute. At `session.end` it marks its slot ended.
- **A new session takes on** the tasks of every session that ended, or went quiet for 3 minutes (a crash, a kill): they show "from an earlier session", that session's Claude learns of them from the `prompt.context` block, and their slots leave the store. A resumed session takes its own slot back.
- **A quiet session that comes back** (a laptop asleep longer than 3 minutes) finds a note in `taken` at its next heartbeat or change: it lets those tasks go instead of writing them back, so one task never waits in two panes, and a toast tells the user to answer them in the other session. Notes are dropped after 30 days.
- **After `/clear`** the process goes on as a new session with no `session.start`: at `session.end` with reason `clear`, the mod joins the project again, taking on the tasks it had.
- **Task numbers** count per project, read fresh from the store at each assignment. `$.store` has only `get` and `set`, so it can't reserve a number: two sessions assigning at the same moment can draw the same one. That's harmless while they're apart, since each shows and answers only its own tasks. Where their tasks meet (one session taking on another's), the session's own tasks keep their numbers, the ones its Claude knows, and a duplicate gets the next free number.
- **A response that doesn't reach Claude stays saved.** It is marked delivered before `$.prompt.submit`, so no other path sends it twice; a prompt that doesn't enter (a hook drops it, or the call fails) puts it back as not sent yet, with a toast, for Send now or the next prompt.
- **A session is alive** while it writes its slot: at every change, and on a heartbeat every minute; one that never held a task writes none. At `session.end` it marks its slot ended in one write, reading nothing (one that never read the project reads it first): the exit's budget is short, and the end waits for the write through half of what is left of it at most. A slot not marked in time goes stale, and is taken on 3 minutes later.
- **A new session takes on** the tasks of every session that ended, or went quiet for 3 minutes (a crash, a kill): they show "from an earlier session", and that session's Claude learns of them from the `prompt.context` block. Its own slot notes what it took, in the same write that holds the tasks; only then does an ended session's slot leave the store, and an empty one's, unless it changed since it was read. A crash in between leaves no task to take on twice: the note says it's taken. Until that write lands (a store that fails just then), a session it took from that comes back keeps the tasks too, and lets them go once it reads the note. A slot that leaves takes with it any copy of it in 0.1.4's value, where a 0.1.4 session's write can put back the copy from before its session came to this version. A resumed session takes its own slot back.
- **A quiet session that comes back** (a laptop asleep longer than 3 minutes) finds the note in the slot of the session that took its tasks on, at its next heartbeat, or before a press, a prompt or a call of its tools acts on its tasks once it has neither written its slot nor read the project for two heartbeats: it lets those tasks go instead of writing them back, so one task never waits in two panes, and a toast tells the user to answer them in the other session. Its slot stays until it comes back, or for 30 days: then the session that took its tasks removes it, and carries on the slot's own notes of what it took, so none of those is taken on twice. A note goes once the tasks are let go, or once their slot is gone. A session that takes its own tasks back from one that had taken them (resumed after it ended, say, while that one is quiet) keeps them: its note is the later, and outweighs the other's (the note as the session holds it, before the store has it too), which that session drops when it comes back and lets them go.
- **Reads and writes:** joining, the heartbeat and assigning read the project, one read per key; a press or a prompt, which a person waits on, writes the session's own slot and reads nothing, but for the sleep check above. The session's state is what counts: a write that fails, the next one makes, the count in it too, though the tasks are gone by then. A store slow to answer holds up a press, a prompt, a tool call or the session's start 5 seconds at most for each piece of store work it waits on (the sleep check's read, then its write): the store work finishes on its own, in its turn, and until it does nothing else waits on the store, and the heartbeat asks it for nothing more. Opening the pane is no store work: a surface slow to open it holds up the start or a heartbeat 5 seconds at most, and the store goes on being asked as before.
- **After `/clear` or a resume in place** the process goes on as another session with no `session.start`: at `session.end` with reason `clear` or `resume`, the mod joins the project again as that session, taking on the tasks it had as it holds them: the end write may have failed. It joins once the store work in flight is done; until then (a store slow to answer), a task Claude assigns is the new session's all the same, and a session that ends first ends as any other: it takes nothing quiet, and its slot is marked ended. Resumed into another folder, it starts there as a new session would, and until it has read that folder (the last, when it is resumed again first) it acts on no task it holds: Claude can't assign, list or withdraw a task, no response goes to Claude, and no prompt carries one. Then it holds none of this folder's tasks, which wait in this folder's store as its last write there left them, nor an answer typed to one, and numbers from that folder's count.
- **A session that can't read the project as it joins** (a store that fails just then) writes nothing until it has, 0.1.4's value included: the store may hold tasks of its own it hasn't taken on, a resumed session's, that a write would go over. The heartbeat, assigning and its end read the project again; it then takes on what the store holds for it, with what it held meanwhile: the tasks of the session it left (which its state names, so a hot reload keeps it), or, when no session of the process has read the project, those its sessions assigned, numbered without the project's count. Each of those sessions has ended unless the store says otherwise: alive or quiet there, another process has resumed it since, or may have, and it keeps its slot, while what it assigned here goes on in this session's. Read only as it ends, the session takes nothing from a quiet session, which may come back: the next session to start takes that on. Read later, the project shows as at joining, though the write that follows fails: the pane opens for what was taken on. A session takes the project on against its task list as it stands at that moment, so a press meanwhile is kept; its own tasks it is done with stay in it, and no task it takes on gets one of their numbers.
- **Task numbers** count per project, read fresh from the store at each assignment: each slot carries its session's count, and a slot leaves the store only once a later one carries it, so the next number is past every number the project's sessions drew. `$.store` has no compare-and-set, so it can't reserve a number: two sessions assigning at the same moment can draw the same one. That's harmless while they're apart, since each shows and answers only its own tasks. Where their tasks meet (one session taking on another's), the session's own tasks keep their numbers, the ones its Claude knows, and a duplicate gets the next free number, as does a task taken on whose number a 0.1.4 note names as taken from the session: a 0.1.4 session's write can put that note back after it was read.
- **A response that doesn't reach Claude stays saved.** It is claimed (marked delivered, in one change of the task list) before its prompt or its row goes, the mod's own or a typed prompt it rides, so no other path sends it twice; a prompt that doesn't enter with it (a hook drops the prompt or takes the response out, or the call fails) puts it back as not sent yet, for Send now or the next prompt, and so does a row the running turn refuses, to go when the turn ends. A toast says so, but for a prompt of the user's own that it rode and a hook dropped: the engine shows why, and the response waits as it did. A hook beneath that only rewrites a response (masks a secret in it, say) still delivers it: its head entered, `#5 <title>:` in a message's words, `#5 done: <title>.` in the digest beside a typed prompt's; one that takes some responses out gives back those alone, and one that rewords a head gives that response back, since Claude hearing it twice beats not hearing it. One given back by a prompt that entered goes with Send now, the next prompt or another response as it goes, but sends nothing by itself as a turn ends: the turn that prompt started would end with it waiting, and send it again, over and over. An answer typed in the prompt box while Claude is idle carries every response not sent yet, those a batch of presses was about to send among them; when another send took it as it was saved, the prompt is dropped, saying it went.
- **Not in v1:** expiry (the user rejects stale tasks), cross-project tasks, and moving a task between two live sessions.

## Guardrails
Expand Down
Loading
Loading