SLOPSHOPPER

Human in the loop

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…

newpanebandrowsguardcommand
★ 8v0.1.4MITupdated 2026-10-05tzafrir/human-in-the-loop
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · human-in-the-loop
│ ┃ my-tasks ✕ › fix the failing auth test and add an audit log call │ ┃ No tasks for you right now. │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /my-tasks │ ⎿ human-in-the-loop: No tasks for you right now. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · my-tasks
No tasks for you right now.
README

Human in the loop: Claude hands you the tasks only you can do; they wait in the My tasks pane until you answer, and your answers go back to Claude

Human in the loop

Claude hands you the tasks only you can do. They wait until you act, and your answer goes back to Claude.

Why

Claude often needs something only you can do: a key in .env, an approval on staging, a command on your own machine, a decision. Today it says so in the middle of a reply. You're busy steering the next fix, and the ask scrolls away. At best Claude keeps repeating "I still need you to…"; at worst the ask is lost.

With this mod, Claude assigns the ask as a task instead. It waits in the My tasks pane, counted under the prompt, until you do it, answer it or reject it.

An example

Get our Stripe webhooks library ready to open-source.

Claude works through it, and three times hits something only you can do: the integration tests need your Stripe key, making the repo public is your call, and the package needs a name. Each becomes a task, and Claude keeps working:

╭─ My tasks ──────────────────────────────────────────────────────────╮
│ ☐ 3 tasks for you                                                   │
│                                                                     │
│ ☐ #1 Add STRIPE_SECRET_KEY to .env  2m                              │
│   The integration tests run against Stripe's test mode and need it. │
│   Done when: It's in .env. Don't paste it here.                     │
│   d: Done   r: Answer…   x: Reject…                                 │
│ ☐ #2 Decide whether to make the repo public  1m                     │
│ ☐ #3 Choose the package's name on npm  now                          │
│                                                                     │
│ Esc: back to the prompt   Hide                                      │
╰─────────────────────────────────────────────────────────────────────╯
☐ 3 tasks for you · /my-tasks
  • #1 you do in your editor, then press d. The key never passes through Claude.
  • #2 is your decision: 1 makes the repo public, 2 keeps it private.
  • #3 needs your words: press r, type @acme/stripe-hooks, Enter.

Answer whenever it suits you. Answers you give while Claude works wait for it, and your next answer after it stops takes them along. Here #1 and #2 were answered while Claude worked and #3 after, so Claude gets them as one message, in your words:

My responses to tasks you gave me (Human in the loop):

#1 Add STRIPE_SECRET_KEY to .env: Done.

#2 Decide whether to make the repo public: I chose "Make it public".

#3 Choose the package's name on npm:
@acme/stripe-hooks

Claude picks up where it left off: the integration tests pass, the repo goes public, and the package gets its name. Publishing is left to you.

How it works

  • Claude assigns with assign_task: an imperative title, why it's needed, what counts as done, and a kind (do, answer or choose).
  • The status line under the prompt counts what's waiting: ☐ 3 tasks for you · /my-tasks.
  • The My tasks pane opens by itself when a task arrives. In fullscreen it's a sidebar beside the transcript; on the main screen it sits above the prompt. /my-tasks opens it any time, with the keyboard.
  • You respond: Done, an option (1–4), an answer (r), or a rejection with a reason (x). Click, or focus the pane with /my-tasks or ctrl+x tab and use the keys.
  • Your answer reaches Claude:
  • while Claude is idle, right away;
  • while it works, it waits, so Claude isn't interrupted, and goes when Claude finishes its turn. If a task is still open then, your answers wait for that one too and arrive together (or with your next prompt). Send now sends right away.
  • Long answers (a log, a stack trace) go through your own prompt box: Long answer… starts it with ↳ Answer to #3:, and you paste and press Enter.
  • Claude keeps working. Unlike a question dialog, a task doesn't stop Claude: it carries on with everything else while the task waits for you, and takes the task back if it finds another way.
  • Nothing is lost across a compaction or a new session: open tasks stay with the project and Claude is told about them again. With several sessions open in one project, each keeps its own tasks, so your answer always reaches the Claude that asked.

It never draws in the band above the prompt, so it works beside band mods such as What's Agent Doing.

Guardrails

  • At most 5 open tasks at a time.
  • The same ask twice is one task.
  • Only the main agent assigns tasks; a subagent reports what it needs in its result.
  • An answer that looks like a secret (an API key, a token, a private key) is held with a warning before it is sent.

Install

In Claude Code:

/plugin marketplace add tzafrir/human-in-the-loop
/plugin install human-in-the-loop@human-in-the-loop

Or from your shell:

claude plugin marketplace add tzafrir/human-in-the-loop
claude plugin install human-in-the-loop@human-in-the-loop

Then start a new session (or run /reload-plugins).

Requirements: Claude Code in a terminal, or in the desktop app's Code tab. The plugin is a mod, written with Claude Code's function hooks, which are early access: they load only where function hooks are enabled, and their API may change between releases.

Data and privacy

  • Stays on your machine. Tasks live in the session's own state and in the plugin's own store (a JSON file under your Claude Code configuration directory), keyed by project folder, so open tasks come back in the next session there. No network, no files of yours, no processes.
  • Your responses become part of the conversation. Claude reads what you answer, and the session's transcript on disk keeps it, like anything you type. That's why tasks ask you to put a secret where it belongs and press Done, never to paste it, and why an answer that looks like a secret is held first.
  • What it adds to the conversation: the three tools, a few lines in the system prompt telling Claude about tasks, your responses, and, at a conversation's start or after a compaction, a short list of the tasks still open.

What it sends to Claude, and which tool calls it answers

  • The prompts it submits. When you respond to a task while Claude is idle, when Claude finishes a turn with your saved responses waiting and no task left open, or when you press Send now, the mod submits your response as a prompt in your own words. For each task it carries only the task's number and title and what you did: "Done", the option you chose, your answer, or your reason for rejecting it. It carries nothing else from the conversation or from your machine. One fixed line is the only other prompt it submits: "I answered a task while you were finishing your reply; my answer is above." It goes when your answer arrived during Claude's final reply.
  • While Claude works: Send now adds the same response as a row Claude reads at its next step, with a notice in the transcript for you ("Sent your response to #3 to Claude") that Claude doesn't read.
  • Your prompts: the mod reads them only to recognize a long answer (↳ Answer to #3: …), which it turns into that same response, and to attach responses not yet sent as notes Claude reads.
  • The tool calls it answers: only calls to its own three tools, assign_task, list_tasks and withdraw_task, which the mod itself serves. Every other tool call passes through untouched.

Support

Report a problem or ask for a feature in GitHub Issues. Read the privacy policy.

Develop

Load the plugin from a clone; the session reloads it as you edit:

git clone https://github.com/tzafrir/human-in-the-loop
claude --plugin-dir human-in-the-loop
claude plugin validate --strict human-in-the-loop/.claude-plugin/plugin.json
claude plugin test human-in-the-loop
FileWhat it does
hooks/register.tsxThe tools Claude calls, delivering your responses, the My tasks pane and status line, /my-tasks, long answers through the prompt box
hooks/project.tsEach session's tasks in the project's store, and taking on those of sessions that ended
hooks/tasks.tsReading a task from Claude's call, what Claude is told, the status line, secret shapes
hooks/text.tsPrintable labels, ages, sizes
types/index.d.tsThe $.state contract: the tasks, the selection, the open field
tests/register.test.tsxAssigning, the pane and /my-tasks, every response and when it reaches Claude, long answers, tasks carried across sessions
docs/DESIGN.mdThe design and the decisions behind it

License

MIT

Source 5 files
hooks/register.tsx 1036 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer, ToolSpec } from 'claude-code'
3
4import type { Editing, Task } from '../types'
5import {
6  MAX_OPEN,
7  MAX_TITLE,
8  answerIn,
9  assignedOf,
10  bridgeOf,
11  contextOf,
12  digestOf,
13  isActive,
14  isShown,
15  listOf,
16  looksSecret,
17  messageOf,
18  parseAsk,
19  rowsFor,
20  sameAsk,
21  statusOf,
22} from './tasks'
23import { HEARTBEAT_MS, adopt, elsewhereIn, endSlot, storedOf, takenFrom, withSlot } from './project'
24import type { Stored } from './project'
25import { ago, oneLine, plural, printable } from './text'
26
27const PLUGIN = 'human-in-the-loop'
28
29const ASSIGN = 'mcp__human-in-the-loop__assign_task'
30const LIST = 'mcp__human-in-the-loop__list_tasks'
31const WITHDRAW = 'mcp__human-in-the-loop__withdraw_task'
32
33/**
34 * Three lines at the end of the system prompt: where an ask for the user goes
35 * so it stays in front of them, that a review is one even when the reply asks
36 * for it too (the user is often busy and lets the reply's question go by),
37 * and where they see it. Said as what to do,
38 * never what not to: nothing here stops Claude reminding the user of a task.
39 */
40const STEERING = [
41  "When you need something only the user can do (a secret put in place, an action on their machine or an account you can't reach, a decision that's theirs), assign it with mcp__human-in-the-loop__assign_task so it stays in front of them until they act on it.",
42  'That includes asking the user to review or approve your work: assign it as a task even when you also ask in your reply, since they may be busy with other things when you ask, and the task keeps it in front of them.',
43  'The user sees open tasks in their My tasks pane, or opens it with /my-tasks, and their response reaches you as a message.',
44].join('\n')
45
46/** Resolutions pressed this close together go to Claude as one message. */
47const BATCH_MS = 800
48
49/** The pane the tasks wait in, and the command that opens it. */
50const PANE = 'my-tasks'
51const PANE_TITLE = 'My tasks'
52const MAX_PANE_ROWS = 18
53
54const TOOLS: readonly ToolSpec[] = [
55  {
56    name: 'assign_task',
57    description: [
58      'Give the user a task that only they can do, and keep it in front of them until they act on it.',
59      'Use it for a secret or credential they must put in place, an action on their machine or on an account you cannot reach (a dashboard, a device, a deploy approval), a decision that is theirs to make, or a review or approval of your work.',
60      'Assign it even when you also ask in your reply: the user may be busy with other things, and the task keeps the ask in front of them.',
61      'A task does not block: you keep working while it waits, and the response reaches you whenever the user gets to it, so it suits anything you need from them at some point in the session.',
62      'AskUserQuestion is the blocking kind: it waits for the answer, so it suits what you need before you can go on.',
63      'One task per need: list_tasks shows what is already open.',
64      'Write the title as an imperative the user can act on without scrolling back.',
65      'For a secret, ask the user to put it where it belongs and mark the task done, rather than paste it.',
66      'The user sees open tasks in their My tasks pane, or opens it with /my-tasks.',
67      'The user rejects (with a reason) or fulfills it (done, an option, or an answer in their words). Their response reaches you as a message, or when you call list_tasks.',
68    ].join(' '),
69    inputSchema: {
70      type: 'object',
71      properties: {
72        title: {
73          type: 'string',
74          description: 'What the user should do, as an imperative they can act on without scrolling back. Under 80 characters.',
75        },
76        why: {
77          type: 'string',
78          description: 'One sentence: what it unblocks, or why only the user can do it.',
79        },
80        done_when: {
81          type: 'string',
82          description: "Optional: what counts as done. For a secret: \"It is in .env. Don't paste it here.\"",
83        },
84        kind: {
85          type: 'string',
86          enum: ['do', 'answer', 'choose'],
87          description: 'do: the user does something and marks it done. answer: you need their words (an error, a log, test results). choose: they pick one of options.',
88        },
89        options: {
90          type: 'array',
91          items: { type: 'string' },
92          minItems: 2,
93          maxItems: 4,
94          description: 'For choose only: 2 to 4 short options.',
95        },
96      },
97      required: ['title', 'why', 'kind'],
98    },
99  },
100  {
101    name: 'list_tasks',
102    description: [
103      'List the tasks you gave the user and what they did with them: accepted, done (with their answer or choice), rejected (with their reason).',
104      'Call it when you are about to need a task\'s result, when told an answer is saved for you, or before assigning, to avoid a duplicate.',
105    ].join(' '),
106    inputSchema: {
107      type: 'object',
108      properties: {
109        show: {
110          type: 'string',
111          enum: ['updates', 'open', 'all'],
112          description: 'updates (the default): open tasks and responses you have not seen. open: open tasks only. all: every task this session.',
113        },
114        id: { type: 'number', description: 'One task, whole, by its number.' },
115      },
116    },
117  },
118  {
119    name: 'withdraw_task',
120    description: 'Take back a task you gave the user when you no longer need it: you found another way, or the plan changed. The user sees the reason.',
121    inputSchema: {
122      type: 'object',
123      properties: {
124        id: { type: 'number', description: 'The task\'s number.' },
125        reason: { type: 'string', description: 'One short sentence the user sees.' },
126      },
127      required: ['id'],
128    },
129  },
130]
131
132const tasks = atom({ plugin: 'human-in-the-loop', key: 'tasks' } as const, [] as readonly Task[])
133const nextId = atom({ plugin: 'human-in-the-loop', key: 'nextId' } as const, 1)
134const selectedId = atom({ plugin: 'human-in-the-loop', key: 'selectedId' } as const, null as number | null)
135const editing = atom({ plugin: 'human-in-the-loop', key: 'editing' } as const, null as Editing | null)
136const loadedFor = atom({ plugin: 'human-in-the-loop', key: 'loadedFor' } as const, null as string | null)
137const isWorking = atom({ plugin: 'human-in-the-loop', key: 'isWorking' } as const, false)
138
139/**
140 * Send now goes to Claude as a message (`auto` while it is idle), Save for
141 * later waits (`auto` while it works) until its turn ends with every task
142 * answered, a later answer while it is idle, or the user's next prompt.
143 */
144type How = 'auto' | 'now' | 'later'
145
146/** What the module tracks beside `$.state`; a hot reload starts it over. */
147const live = {
148  sessionId: '',
149  root: '',
150  /** A Send now row joined the running turn and no step has read it yet. */
151  isUnread: false,
152  /** A Send now the running turn could not take: it goes once the turn ends. */
153  isSendingAtEnd: false,
154  /** What the user has typed in an answer field so far, by task. */
155  drafts: new Map<number, string>(),
156  batch: null as Timer | null,
157  /** Says in the project's store that this session is alive. */
158  heartbeat: null as Timer | null,
159  /** Tasks waiting in the project's other live sessions. */
160  elsewhere: 0,
161  /**
162   * Turns begun and not yet ended. `turn.start` names no loop, so a loop
163   * other than main's (an engine side request, a teammate) may begin one that
164   * ends under an agent id: each ends by its own id.
165   */
166  turns: new Set<string>(),
167  /** Whether a turn runs, in the engine's own word (the band's `isWorking`), once it has said. */
168  engineWorking: undefined as boolean | undefined,
169}
170
171/** Whether Claude is at work: the engine's word when it has given one, else the turns begun and not ended. */
172function working(): boolean {
173  return live.engineWorking ?? live.turns.size > 0
174}
175
176export const register: Register = on => {
177  on('session.start', async ($, e, next) => {
178    for (const tool of TOOLS) {
179      await $.tool.register(tool)
180    }
181
182    await $.command.register({ name: 'my-tasks', description: 'Show the tasks Claude gave you' })
183    await join($)
184
185    return next(e)
186  })
187
188  // An ended session's tasks wait in the store for the next session in the
189  // project. After /clear the process goes on as a new session with no
190  // session.start, so it joins the project again here.
191  on('session.end', async ($, e, next) => {
192    live.heartbeat?.cancel()
193    live.heartbeat = null
194
195    try {
196      await writeStore($, endSlot(await readStore($), e.sessionId, await $.clock.now()))
197    } catch {
198      // A store that can't be written in the exit's short budget leaves the
199      // slot to go stale, and the next session takes it on then.
200    }
201
202    const ended = await next(e)
203
204    if (e.reason === 'clear') {
205      $.clock.after(0, () => void join($))
206    }
207
208    return ended
209  })
210
211  on('command.run', { command: 'my-tasks' }, async $ => {
212    if ((await read($, tasks)).filter(isShown).length === 0) {
213      return { text: 'No tasks for you right now.' }
214    }
215
216    await show($, true)
217
218    return {}
219  })
220
221  // The tools stay in the prompt's list, descriptions and all, where an MCP
222  // tool would wait behind ToolSearch: the description is what steers Claude
223  // to assign a task, so the ask stays in front of the user.
224  on('tool.describe', { tool: 'mcp__human-in-the-loop__assign_task' }, async ($, e, next) => ({ ...(await next(e)), isDeferred: false }))
225  on('tool.describe', { tool: 'mcp__human-in-the-loop__list_tasks' }, async ($, e, next) => ({ ...(await next(e)), isDeferred: false }))
226  on('tool.describe', { tool: 'mcp__human-in-the-loop__withdraw_task' }, async ($, e, next) => ({ ...(await next(e)), isDeferred: false }))
227
228  on('tool.call', { tool: ASSIGN }, async ($, e) => {
229    if (e.agentId !== undefined) {
230      return { deny: 'Only the main agent assigns tasks to the user. Say what you need from the user in your result instead.' }
231    }
232
233    const ask = parseAsk(e as unknown as Record<string, unknown>)
234
235    if ('error' in ask) {
236      return { deny: ask.error }
237    }
238
239    const active = (await read($, tasks)).filter(isActive)
240    const same = active.find(task => sameAsk(task.title, ask.title))
241
242    if (same !== undefined) {
243      return { result: `Task #${same.id} already asks the user for this ("${same.title}"); nothing new was assigned.` }
244    }
245
246    if (active.length >= MAX_OPEN) {
247      return {
248        deny: `The user already has ${MAX_OPEN} open tasks. Withdraw one you no longer need, or wait for them to act (list_tasks shows them).`,
249      }
250    }
251
252    // Task numbers count per project, across its sessions: the store's count
253    // is read fresh. Two sessions assigning at the same moment can still draw
254    // one number; each shows and answers only its own, and where their tasks
255    // meet (one taking on the other's), `adopt` gives the later a new one.
256    const counted = (await readStore($)).nextId
257    const id = await read($, nextId).then(n => Math.max(n, counted))
258    await update($, nextId, () => id + 1)
259    const nowMs = await $.clock.now()
260    const task: Task = {
261      id,
262      ...ask,
263      state: 'open',
264      update: 'none',
265      sessionId: live.sessionId,
266      createdMs: nowMs,
267      updatedMs: nowMs,
268    }
269
270    await change($, list => [...list, task])
271    await update($, selectedId, chosen => chosen ?? id)
272
273    if (!(await show($, false))) {
274      $.ui.toast(`Claude assigned you a task: ${printable(task.title, MAX_TITLE)}. See /my-tasks`)
275    }
276
277    return { result: assignedOf(task, active.length + 1) }
278  })
279
280  on('tool.call', { tool: LIST }, async ($, e) => {
281    const args = e as unknown as Record<string, unknown>
282    const id = typeof args['id'] === 'number' ? args['id'] : undefined
283    const scope = args['show'] === 'open' || args['show'] === 'all' ? args['show'] : 'updates'
284    const list = await read($, tasks)
285
286    const picked =
287      id !== undefined
288        ? list.filter(task => task.id === id)
289        : scope === 'all'
290          ? list
291          : scope === 'open'
292            ? list.filter(isActive)
293            : list.filter(isShown)
294
295    if (id !== undefined && picked.length === 0) {
296      return { result: `No task #${id}.` }
297    }
298
299    // A subagent's read tells the main agent nothing.
300    if (e.agentId === undefined) {
301      await delivered($, picked.filter(task => task.update === 'pending').map(task => task.id))
302    }
303
304    return { result: listOf(picked, await $.clock.now()) }
305  })
306
307  on('tool.call', { tool: WITHDRAW }, async ($, e) => {
308    if (e.agentId !== undefined) {
309      return { deny: 'Only the main agent withdraws tasks.' }
310    }
311
312    const args = e as unknown as Record<string, unknown>
313    const id = args['id']
314    const reason = typeof args['reason'] === 'string' ? oneLine(args['reason']) : ''
315    const task = (await read($, tasks)).find(one => one.id === id)
316
317    if (task === undefined) {
318      return { deny: `No task #${String(id)}.` }
319    }
320
321    if (!isActive(task)) {
322      return { deny: `Task #${task.id} is already ${task.state}; there is nothing to withdraw.` }
323    }
324
325    await edit($, task.id, one => ({
326      ...one,
327      state: 'withdrawn',
328      update: 'delivered',
329      ...(reason === '' ? {} : { reason }),
330    }))
331    await settle($, task.id)
332    const why = reason === '' ? '' : `. ${printable(reason, 200)}`
333    $.ui.toast(`Claude withdrew the task: ${printable(task.title, MAX_TITLE)}${why}`, { timeoutMs: 8000 })
334
335    return { result: `Withdrew task #${task.id}; it is gone from the user's My tasks pane.` }
336  })
337
338  on('turn.start', async ($, e, next) => {
339    live.isUnread = false
340    live.turns.add(e.turnId)
341    await syncWorking($)
342
343    return next(e)
344  })
345
346  on('turn.step', async function* ($, e, next) {
347    if (e.agentId === undefined) {
348      live.isUnread = false
349    }
350
351    return yield* next(e)
352  })
353
354  on('turn.complete', async ($, e, next) => {
355    const result = await next(e)
356
357    // Main's turn ending ends every turn; another loop's ends its own.
358    if (e.agentId === undefined) {
359      live.turns.clear()
360      live.engineWorking = false
361    } else {
362      live.turns.delete(e.turnId)
363    }
364
365    await syncWorking($)
366
367    if (e.agentId === undefined) {
368      // Answers saved while Claude worked go now that it's free, unless a
369      // task is still open: then they go with that last answer, together.
370      const all = await read($, tasks)
371      const isAllAnswered = all.some(task => task.update === 'pending') && !all.some(isActive)
372
373      if (live.isSendingAtEnd || isAllAnswered) {
374        live.isSendingAtEnd = false
375        // This message wakes Claude, which reads any row it missed above it.
376        live.isUnread = false
377        void send($, true)
378      } else if (live.isUnread) {
379        // A Send now that joined the turn during its final reply sits in the
380        // conversation with no step left to read it: wake Claude up for it.
381        live.isUnread = false
382        void $.prompt.submit({ text: 'I answered a task while you were finishing your reply; my answer is above.', asUser: true })
383      }
384    }
385
386    return result
387  })
388
389  // Draws nothing and passes the band on: it only reads the engine's word on
390  // whether a turn runs, which no turn event gives for every loop.
391  on('ui.render', { component: 'AbovePrompt' }, ($, e, next) => {
392    if (live.engineWorking !== e.props.isWorking) {
393      live.engineWorking = e.props.isWorking
394      $.clock.after(0, () => void syncWorking($))
395    }
396
397    return next(e)
398  })
399
400  on('prompt.submit', async ($, e, next) => {
401    if (e.origin.kind === 'plugin' && e.origin.name === PLUGIN) {
402      return next(e)
403    }
404
405    const bridged = answerIn(e.text)
406    const task = bridged === null ? undefined : (await read($, tasks)).find(one => one.id === bridged.id && isActive(one))
407
408    if (bridged !== null && task !== undefined) {
409      if (looksSecret(bridged.answer)) {
410        return {
411          drop: `This looks like a secret, so it wasn't sent. Put it where it belongs, then mark #${task.id} Done.`,
412        }
413      }
414
415      const given = bridged.answer === '' ? {} : { answer: bridged.answer }
416
417      if (e.turnId !== undefined) {
418        await resolve($, task.id, { state: 'done', ...given }, 'later')
419        const when = (await read($, tasks)).some(isActive) ? 'with your other answers' : 'when this turn ends'
420
421        return {
422          drop: `Saved as your answer to #${task.id}. Claude gets it ${when}, or press Send now in /my-tasks.`,
423        }
424      }
425
426      // Claude is idle: this prompt is the answer, sent with every other
427      // update Claude has not heard.
428      await resolve($, task.id, { state: 'done', ...given }, 'later')
429      const due = (await read($, tasks)).filter(one => one.update === 'pending')
430      await delivered($, due.map(one => one.id))
431
432      return next({ ...e, text: messageOf(due) })
433    }
434
435    const due = (await read($, tasks)).filter(one => one.update === 'pending')
436
437    if (due.length === 0) {
438      return next(e)
439    }
440
441    await delivered($, due.map(one => one.id))
442
443    return next({ ...e, context: [...(e.context ?? []), digestOf(due)] })
444  })
445
446  // Read at a conversation's start and again after a compaction: what is
447  // still with the user, so Claude neither forgets nor assigns it twice.
448  // Added last, on the session's side of the cache boundary, wherever the
449  // tool can be reached: offered outright, or found through ToolSearch.
450  on('prompt.compose', async ($, e, next) => {
451    const composed = await next(e)
452    const isReachable = e.tools.some(tool => tool === ASSIGN || tool === 'ToolSearch')
453
454    if (!isReachable || e.traits.includes('print') || e.traits.includes('bare')) {
455      return composed
456    }
457
458    return { sections: [...composed.sections, { id: 'human-in-the-loop:tasks', text: STEERING, scope: 'session' }] }
459  })
460
461  on('prompt.context', async ($, e, next) => {
462    const result = await next(e)
463    const shown = (await read($, tasks)).filter(isShown)
464
465    if (shown.length === 0) {
466      return result
467    }
468
469    return { ...result, blocks: [...result.blocks, { name: 'humanInTheLoop', text: contextOf(shown, live.sessionId) }] }
470  })
471
472  on('ui.render', { component: 'ToolUse', props: { tool: ASSIGN } }, ($, e, next) => {
473    const input = e.props.input as Record<string, unknown> | null | undefined
474    const title = typeof input?.['title'] === 'string' ? input['title'] : ''
475
476    if (title === '' || e.props.isErrored || e.props.isInterrupted) {
477      return next(e)
478    }
479
480    const { Box, Text } = $.ui.resolve(e)
481
482    return (
483      <Box flexDirection="row">
484        <Text color="yellow">{'☐ '}</Text>
485        <Text bold>{'Assigned you a task: '}</Text>
486        <Text wrap="truncate-end">{printable(title, MAX_TITLE)}</Text>
487      </Box>
488    )
489  })
490
491  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
492    const elements = $.ui.resolve(e)
493    const { Box, Text, Button } = elements
494    // The mobile app draws no field yet: answers and reasons wait for a surface that does.
495    const Input = 'Input' in elements ? elements.Input : undefined
496    const shown = (await read($, tasks)).filter(isShown)
497
498    if (shown.length === 0) {
499      return <Text dimColor>No tasks for you right now.</Text>
500    }
501
502    const chosen = await read($, selectedId)
503    const field = await read($, editing)
504    const working = await read($, isWorking)
505    const nowMs = await $.clock.now()
506    const canType = Input !== undefined
507
508    const active = shown.filter(isActive)
509    const saved = shown.filter(task => !isActive(task))
510    const selected = active.find(task => task.id === chosen) ?? active[0]
511
512    const headline =
513      active.length > 0 ? `${plural(active.length, 'task')} for you` : `${plural(saved.length, 'response')} not sent yet`
514    const savedNote = active.length > 0 && saved.length > 0 ? ` · ${saved.length} not sent yet` : ''
515
516    const glyph = <Text color="yellow">{'☐ '}</Text>
517    const earlier = (task: Task) => (task.sessionId === live.sessionId ? '' : ' · from an earlier session')
518
519    const actionsOf = (task: Task) => {
520      const choices =
521        task.kind === 'choose' && task.options !== undefined
522          ? task.options.map((option, i) => (
523              <Button
524                key={`option-${i + 1}`}
525                plain
526                hotkey={String(i + 1)}
527                label={printable(option, 60)}
528                onPress={() => resolve($, task.id, { state: 'done', answer: option }, 'auto')}
529              />
530            ))
531          : [<Button key="done" plain hotkey="d" label="Done" onPress={() => resolve($, task.id, { state: 'done' }, 'auto')} />]
532
533      const typed = canType
534        ? [
535            <Button
536              key="answer"
537              plain
538              hotkey="r"
539              label={task.kind === 'choose' ? 'Other…' : 'Answer…'}
540              onPress={() => startEditing($, task.id, 'answer')}
541            />,
542            <Button key="reject" plain hotkey="x" label="Reject…" onPress={() => startEditing($, task.id, 'reason')} />,
543          ]
544        : [<Button key="reject" plain hotkey="x" label="Reject" onPress={() => resolve($, task.id, { state: 'rejected' }, 'auto')} />]
545
546      return (
547        <Box key="actions" flexDirection="row" flexWrap="wrap" columnGap={3}>
548          {choices}
549          {typed}
550        </Box>
551      )
552    }
553
554    const answerField = (task: Task, held: string | undefined) => {
555      if (Input === undefined) {
556        return null
557      }
558
559      if (held !== undefined) {
560        return (
561          <Box key="held" flexDirection="column">
562            <Text color="yellow" wrap="wrap">
563              This looks like a secret. Claude and the transcript on disk would see it. Put it where it belongs and press
564              Done instead.
565            </Text>
566            <Box flexDirection="row" columnGap={3}>
567              <Button key="edit" plain label="Edit" onPress={() => update($, editing, (): Editing => ({ id: task.id, field: 'answer' }))} />
568              <Button key="send-anyway" plain label="Send anyway" onPress={() => answer($, task.id, held, 'auto', true)} />
569            </Box>
570          </Box>
571        )
572      }
573
574      return (
575        <Box key="field" flexDirection="column">
576          <Input
577            key="answer"
578            label="Answer › "
579            placeholder={
580              !working
581                ? 'Enter sends it to Claude'
582                : active.length > 1
583                  ? 'Enter saves it; Claude gets it with your other answers'
584                  : 'Enter saves it; Claude gets it when this turn ends'
585            }
586            value={live.drafts.get(task.id) ?? ''}
587            submitLabel={working ? 'save' : 'send'}
588            autoFocus
589            onInput={value => {
590              live.drafts.set(task.id, value)
591            }}
592            onSubmit={value => answer($, task.id, value, 'auto')}
593          />
594          <Box flexDirection="row" columnGap={3}>
595            <Button
596              key="other-way"
597              plain
598              label={working ? 'Send now' : 'Save for later'}
599              onPress={() => answer($, task.id, live.drafts.get(task.id) ?? '', working ? 'now' : 'later')}
600            />
601            <Button key="long" plain label="Long answer…" onPress={() => longAnswer($, task.id)} />
602            <Button key="cancel" plain label="Cancel" onPress={() => stopEditing($)} />
603          </Box>
604        </Box>
605      )
606    }
607
608    const reasonField = (task: Task) => {
609      if (Input === undefined) {
610        return null
611      }
612
613      return (
614        <Box key="field" flexDirection="column">
615          <Input
616            key="reason"
617            label="Reason (optional) › "
618            placeholder="Enter rejects it"
619            submitLabel="reject"
620            autoFocus
621            onSubmit={value => {
622              const reason = oneLine(value)
623              return resolve($, task.id, { state: 'rejected', ...(reason === '' ? {} : { reason }) }, 'auto')
624            }}
625          />
626          <Button key="cancel" plain label="Cancel" onPress={() => stopEditing($)} />
627        </Box>
628      )
629    }
630
631    const selectedRows = (task: Task) => {
632      const isEditing = field !== null && field.id === task.id
633      const control = !isEditing
634        ? actionsOf(task)
635        : field.field === 'answer'
636          ? answerField(task, field.held)
637          : reasonField(task)
638
639      return (
640        <Box key={`task-${task.id}`} flexDirection="column">
641          <Box flexDirection="row">
642            {glyph}
643            <Text bold wrap="truncate-end">{`#${task.id} ${printable(task.title, MAX_TITLE)}`}</Text>
644            <Text dimColor>{`  ${ago(nowMs - task.createdMs)}${earlier(task)}`}</Text>
645          </Box>
646          <Box flexDirection="column" paddingLeft={2}>
647            <Text wrap="wrap">{printable(task.why, 300)}</Text>
648            {task.doneWhen === undefined ? null : <Text dimColor wrap="wrap">{`Done when: ${printable(task.doneWhen, 300)}`}</Text>}
649            {control}
650          </Box>
651        </Box>
652      )
653    }
654
655    const taskRows = active.map(task =>
656      task.id === selected?.id ? (
657        selectedRows(task)
658      ) : (
659        <Box key={`task-${task.id}`} flexDirection="row">
660          {glyph}
661          <Button
662            key={`select-${task.id}`}
663            plain
664            dimColor
665            label={`#${task.id} ${printable(task.title, MAX_TITLE)}`}
666            onPress={() => select($, task.id)}
667          />
668          <Text dimColor>{`  ${ago(nowMs - task.createdMs)}${earlier(task)}`}</Text>
669        </Box>
670      ),
671    )
672
673    const savedRows = saved.map((task, i) => (
674      <Box key={`saved-${task.id}`} flexDirection="row">
675        {task.state === 'rejected' ? <Text color="red">{'✗ '}</Text> : <Text color="green">{'✓ '}</Text>}
676        <Text dimColor wrap="truncate-end">{`#${task.id} ${printable(task.title, 60)} · not sent yet  `}</Text>
677        <Button key={`send-${task.id}`} plain {...(i === 0 ? { hotkey: 's' } : {})} label="Send now" onPress={() => sendNow($)} />
678      </Box>
679    ))
680
681    const hint = e.props.isFocused ? 'Esc: back to the prompt' : 'Click, or press ctrl+x tab to use the keys'
682
683    return (
684      <Box flexDirection="column">
685        <Box key="head" flexDirection="row">
686          <Text color={active.length > 0 ? 'yellow' : 'gray'}>{active.length > 0 ? '☐ ' : '✓ '}</Text>
687          <Text bold>{headline}</Text>
688          <Text dimColor>{savedNote}</Text>
689        </Box>
690        <Box key="tasks" flexDirection="column" marginTop={1}>
691          {taskRows}
692          {savedRows}
693        </Box>
694        <Box key="foot" flexDirection="row" marginTop={1} columnGap={3}>
695          <Text dimColor>{hint}</Text>
696          <Button key="hide" plain dimColor role="dismiss" label="Hide" onPress={() => $.ui.close({ id: PANE })} />
697        </Box>
698      </Box>
699    )
700  })
701}
702
703/** Keeps the pane's word on whether Claude works (Enter sends, or saves) in step. */
704async function syncWorking($: EngineInterface) {
705  const now = working()
706
707  await update($, isWorking, () => now)
708}
709
710/**
711 * Joins the project as this session: takes on its tasks, the store's slot
712 * kept alive by a heartbeat, the status line and, for tasks taken on from an
713 * earlier session, the pane.
714 */
715async function join($: EngineInterface) {
716  live.sessionId = await $.session.id()
717  live.root = await $.session.root()
718  await load($)
719
720  live.heartbeat?.cancel()
721  live.heartbeat = $.clock.every(HEARTBEAT_MS, () => void beat($))
722
723  // Tasks from an earlier session: the status line says so, and the pane
724  // opens where the terminal is wide enough to seat it unasked.
725  if (await refresh($)) {
726    await show($, false)
727  }
728}
729
730/**
731 * Takes on the tasks this session owns in the project's store: its own (a
732 * resumed session) and those of sessions that ended or went quiet. A live
733 * session's tasks stay its own: answers go to the session that asked. A hot
734 * reload keeps the session's tasks as they are.
735 */
736async function load($: EngineInterface) {
737  const nowMs = await $.clock.now()
738
739  if ((await read($, loadedFor)) === live.sessionId) {
740    await beat($)
741    return
742  }
743
744  const { adopted, stored } = adopt(await readStore($), live.sessionId, nowMs)
745
746  await update($, tasks, () => adopted)
747  await update($, nextId, n => Math.max(n, stored.nextId))
748  await update($, loadedFor, () => live.sessionId)
749  await writeStore($, withSlot(stored, live.sessionId, adopted, nowMs))
750  live.elsewhere = elsewhereIn(stored, live.sessionId, nowMs)
751  await reselect($)
752}
753
754/** Says this session is alive, and counts the tasks waiting in the project's other sessions. */
755async function beat($: EngineInterface) {
756  const nowMs = await $.clock.now()
757  const found = await readStore($)
758  const stored = await letGo($, found)
759  const elsewhere = elsewhereIn(stored, live.sessionId, nowMs)
760
761  await writeStore($, withSlot(stored, live.sessionId, await read($, tasks), nowMs))
762
763  if (stored !== found || elsewhere !== live.elsewhere) {
764    live.elsewhere = elsewhere
765    await refresh($)
766  }
767}
768
769/**
770 * Lets go of the tasks another session took on while this one was quiet (a
771 * laptop asleep, say): they are that session's now, and the user answers
772 * them there. Resolves the store with that note read, or `stored` itself.
773 */
774async function letGo($: EngineInterface, stored: Stored): Promise<Stored> {
775  const { ids, stored: rest } = takenFrom(stored, live.sessionId)
776
777  if (rest === stored) {
778    return stored
779  }
780
781  const lost = (await read($, tasks)).filter(task => ids.includes(task.id) && isShown(task))
782
783  await update($, tasks, list => list.filter(task => !ids.includes(task.id)))
784
785  for (const task of lost) {
786    await settle($, task.id)
787  }
788
789  if (lost.length > 0) {
790    const which = lost.map(task => `#${task.id}`).join(', ')
791    const [noun, them] = lost.length === 1 ? ['task', 'it'] : ['tasks', 'them']
792
793    $.ui.toast(`Another session in this project took on ${noun} ${which} while this one was away. Answer ${them} there.`, {
794      timeoutMs: 8000,
795    })
796  }
797
798  return rest
799}
800
801function storeKey(): string {
802  return `tasks:${live.root}`
803}
804
805async function readStore($: EngineInterface): Promise<Stored> {
806  return storedOf(await $.store.get(storeKey()))
807}
808
809async function writeStore($: EngineInterface, stored: Stored) {
810  await $.store.set(storeKey(), stored)
811}
812
813/** Changes the task list and keeps this session's slot of the project's store in step. */
814async function change($: EngineInterface, fn: (list: readonly Task[]) => readonly Task[]) {
815  const nowMs = await $.clock.now()
816  const stored = await letGo($, await readStore($))
817  const list = await update($, tasks, fn)
818  const counted = { ...stored, nextId: Math.max(stored.nextId, await read($, nextId)) }
819
820  await writeStore($, withSlot(counted, live.sessionId, list, nowMs))
821  live.elsewhere = elsewhereIn(stored, live.sessionId, nowMs)
822  await refresh($)
823}
824
825/**
826 * Keeps the status line in step with the tasks, and closes the pane once
827 * nothing is left in it. Resolves whether anything is shown.
828 */
829async function refresh($: EngineInterface): Promise<boolean> {
830  const shown = (await read($, tasks)).filter(isShown)
831
832  $.ui.status(statusOf(shown, live.elsewhere))
833
834  if (shown.length === 0) {
835    await $.ui.close({ id: PANE }).catch(() => undefined)
836  }
837
838  return shown.length > 0
839}
840
841/**
842 * Opens the My tasks pane sized to what it holds; `isAsked` when the person
843 * asked for it (/my-tasks), so it takes the keyboard. Resolves whether the
844 * surface seated it: one opened unasked waits on a narrow terminal.
845 */
846async function show($: EngineInterface, isAsked: boolean): Promise<boolean> {
847  const shown = (await read($, tasks)).filter(isShown)
848  const rows = rowsFor(shown, await read($, selectedId), (await read($, editing)) !== null, MAX_PANE_ROWS)
849  // The pane is a view of the tasks: one that can't open costs the user a
850  // click on /my-tasks, never the task itself.
851  const opened = await $.ui
852    .open({ id: PANE, title: PANE_TITLE, rows, ...(isAsked ? { focus: true as const } : {}) })
853    .catch(() => ({ isPlaced: false as const }))
854
855  return opened.isPlaced
856}
857
858/** Re-sizes the pane to what it holds now, when it is on screen. */
859async function fit($: EngineInterface) {
860  const panes = await $.ui.panes().catch(() => [])
861
862  if (panes.some(pane => pane.id === PANE && pane.isPlaced)) {
863    await show($, false)
864  }
865}
866
867async function edit($: EngineInterface, id: number, fn: (task: Task) => Task) {
868  const nowMs = await $.clock.now()
869
870  await change($, list => list.map(task => (task.id === id ? { ...fn(task), updatedMs: nowMs } : task)))
871}
872
873async function delivered($: EngineInterface, ids: readonly number[]) {
874  if (ids.length === 0) {
875    return
876  }
877
878  await change($, list => list.map(task => (ids.includes(task.id) ? { ...task, update: 'delivered' } : task)))
879}
880
881/** Gives back updates marked delivered that never reached Claude: they wait again. */
882async function undelivered($: EngineInterface, ids: readonly number[]) {
883  await change($, list => list.map(task => (ids.includes(task.id) && task.update === 'delivered' ? { ...task, update: 'pending' } : task)))
884}
885
886/** Keeps the selection on a task still open, and drops a field whose task is gone. */
887async function settle($: EngineInterface, id: number) {
888  live.drafts.delete(id)
889  await update($, editing, field => (field?.id === id ? null : field))
890  await reselect($)
891}
892
893async function reselect($: EngineInterface) {
894  const active = (await read($, tasks)).filter(isActive)
895
896  await update($, selectedId, chosen => (active.some(task => task.id === chosen) ? chosen : (active[0]?.id ?? null)))
897}
898
899async function select($: EngineInterface, id: number) {
900  await update($, selectedId, () => id)
901  await update($, editing, field => (field?.id === id ? field : null))
902  await fit($)
903}
904
905/** The user fulfilled or rejected a task; `how` says when Claude hears of it. */
906async function resolve($: EngineInterface, id: number, patch: Pick<Task, 'state'> & Partial<Task>, how: How) {
907  await edit($, id, task => ({ ...task, ...patch, update: 'pending' }))
908  await settle($, id)
909
910  if (how === 'now' || (how === 'auto' && !working())) {
911    sendSoon($)
912  }
913}
914
915async function answer($: EngineInterface, id: number, text: string, how: How, isSecretOk = false) {
916  const value = text.trim()
917
918  if (value === '') {
919    $.ui.toast('Type an answer first, or press Cancel.')
920    return
921  }
922
923  if (!isSecretOk && looksSecret(value)) {
924    await update($, editing, (): Editing => ({ id, field: 'answer', held: value }))
925    return
926  }
927
928  await resolve($, id, { state: 'done', answer: value }, how)
929}
930
931async function startEditing($: EngineInterface, id: number, field: Editing['field']) {
932  await update($, selectedId, () => id)
933  await update($, editing, (): Editing => ({ id, field }))
934  await fit($)
935
936  // A field the pane can't focus (it doesn't hold the keyboard) is still a click away.
937  void $.ui.focus({ requestId: PANE, key: field }).then(undefined, () => undefined)
938}
939
940async function stopEditing($: EngineInterface) {
941  await update($, editing, () => null)
942  await fit($)
943}
944
945/** Moves a long answer into the prompt box, where the real editor takes pastes and lines. */
946async function longAnswer($: EngineInterface, id: number) {
947  const box = await $.prompt.read()
948  const draft = live.drafts.get(id) ?? ''
949  const text = bridgeOf(id) + [draft, box.text].filter(part => part.trim() !== '').join('\n')
950  const filled = await $.prompt.fill({ text, mode: 'replace' })
951
952  if (!filled.isFilled) {
953    $.ui.toast("Couldn't open the prompt box for your answer; type it here instead.")
954    return
955  }
956
957  live.drafts.delete(id)
958  await update($, editing, () => null)
959  $.ui.toast('Write your answer in the prompt box (Esc gets you there), then press Enter.', {
960    timeoutMs: 8000,
961  })
962}
963
964function sendNow($: EngineInterface) {
965  live.batch?.cancel()
966  live.batch = null
967  void send($)
968}
969
970function sendSoon($: EngineInterface) {
971  live.batch?.cancel()
972  live.batch = $.clock.after(BATCH_MS, () => {
973    live.batch = null
974    void send($)
975  })
976}
977
978/**
979 * Hands Claude every update it has not heard: a turn of its own while it is
980 * idle, a row its running turn reads at the next step while it works.
981 * `isTurnOver`: sent as main's turn ends, when Claude is idle whatever the
982 * engine's last word on it was.
983 */
984async function send($: EngineInterface, isTurnOver = false) {
985  const due = (await read($, tasks)).filter(task => task.update === 'pending')
986
987  if (due.length === 0) {
988    return
989  }
990
991  const text = messageOf(due)
992  const ids = due.map(task => task.id)
993  const which = ids.map(id => `#${id}`).join(', ')
994
995  if (isTurnOver || !working()) {
996    // Marked delivered first, so no other path sends them meanwhile; a prompt
997    // that doesn't enter gives them back, to go with the next send.
998    await delivered($, ids)
999    const refused = await $.prompt.submit({ text, asUser: true }).then(
1000      entered => entered.drop,
1001      (error: unknown) => (error instanceof Error ? error.message : String(error)),
1002    )
1003
1004    if (refused !== undefined) {
1005      await undelivered($, ids)
1006      const reason = printable(refused, 200).replace(/[.!]+$/, '')
1007
1008      $.ui.toast(`Claude didn't get your response to ${which}: ${reason}. It's saved; press Send now in /my-tasks.`, {
1009        timeoutMs: 8000,
1010      })
1011    }
1012
1013    return
1014  }
1015
1016  const isTaken = await $.session.append({ message: { type: 'user', content: [{ type: 'text', text }] } }).then(
1017    appended => appended.deny === undefined,
1018    () => false,
1019  )
1020
1021  if (!isTaken) {
1022    live.isSendingAtEnd = true
1023    $.ui.toast(`Claude gets your response to ${which} when it finishes this turn.`)
1024    return
1025  }
1026
1027  live.isUnread = true
1028  await delivered($, ids)
1029
1030  try {
1031    await $.session.append({ message: { type: 'system', content: [{ type: 'text', text: `Sent your response to ${which} to Claude` }] } })
1032  } catch {
1033    // The notice is for the user's eyes only; Claude has the answer either way.
1034  }
1035}
1036
hooks/tasks.ts 274 lines
1import type { Task, TaskKind } from '../types'
2import { ago, oneLine, plural, sizeOf } from './text'
3
4/** Open tasks at once; the next is refused. */
5export const MAX_OPEN = 5
6export const MAX_TITLE = 80
7const MAX_WHY = 300
8const MAX_OPTION = 60
9/** Answers longer than this are named in a digest, not quoted. */
10const MAX_INLINE_ANSWER = 2000
11
12const KINDS: readonly TaskKind[] = ['do', 'answer', 'choose']
13
14/** Still the user's to act on. */
15export function isActive(task: Task): boolean {
16  return task.state === 'open'
17}
18
19/** In the My tasks pane: the user's to act on, or acted on and not yet told to Claude. */
20export function isShown(task: Task): boolean {
21  return isActive(task) || task.update === 'pending'
22}
23
24/** A title as two near-identical asks compare equal. */
25export function sameAsk(a: string, b: string): boolean {
26  const norm = (s: string) => oneLine(s.toLowerCase().replace(/[^\p{L}\p{N}\s]/gu, ' '))
27
28  return norm(a) === norm(b)
29}
30
31export type Ask = Pick<Task, 'title' | 'why' | 'doneWhen' | 'kind' | 'options'>
32
33/** Reads `assign_task`'s arguments, or says what is wrong with them. */
34export function parseAsk(args: Record<string, unknown>): Ask | { error: string } {
35  const text = (key: string) => (typeof args[key] === 'string' ? oneLine(args[key] as string) : '')
36  const title = text('title')
37  const why = text('why')
38  const doneWhen = text('done_when')
39  const kind = args['kind'] ?? 'do'
40
41  if (title === '') {
42    return { error: 'title is required: what the user should do, as an imperative.' }
43  }
44
45  if (title.length > MAX_TITLE) {
46    return { error: `title is ${title.length} characters; keep it under ${MAX_TITLE} and put the rest in why.` }
47  }
48
49  if (why === '') {
50    return { error: 'why is required: one sentence on what it unblocks.' }
51  }
52
53  if (!KINDS.includes(kind as TaskKind)) {
54    return { error: 'kind must be "do", "answer" or "choose".' }
55  }
56
57  const raw = Array.isArray(args['options']) ? args['options'] : []
58  const options = raw.filter((o): o is string => typeof o === 'string').map(oneLine).filter(o => o !== '')
59
60  if (kind === 'choose' && (options.length < 2 || options.length > 4)) {
61    return { error: 'a choose task needs 2 to 4 options.' }
62  }
63
64  if (options.some(o => o.length > MAX_OPTION)) {
65    return { error: `keep each option under ${MAX_OPTION} characters.` }
66  }
67
68  return {
69    title,
70    why: why.slice(0, MAX_WHY),
71    ...(doneWhen === '' ? {} : { doneWhen: doneWhen.slice(0, MAX_WHY) }),
72    kind: kind as TaskKind,
73    ...(kind === 'choose' ? { options } : {}),
74  }
75}
76
77/**
78 * What the user did with a task, after its head: their answer, choice or
79 * reason; '' when there is nothing more to say. `isFull` quotes a long
80 * answer whole; a digest names it instead and points at `list_tasks`.
81 */
82function bodyOf(task: Task, isFull: boolean): string {
83  switch (task.state) {
84    case 'rejected':
85      return task.reason ? `The user's reason: "${task.reason}"` : 'No reason given.'
86    case 'withdrawn':
87      return task.reason ? `You withdrew it: ${task.reason}` : 'You withdrew it.'
88    case 'done':
89      if (task.answer === undefined || task.answer === '') {
90        return ''
91      }
92
93      if (task.kind === 'choose' && task.options?.includes(task.answer)) {
94        return `The user chose: ${task.answer}`
95      }
96
97      if (!isFull && task.answer.length > MAX_INLINE_ANSWER) {
98        return `The user's answer (${sizeOf(task.answer.length)}) is saved: call list_tasks with id ${task.id} to read it.`
99      }
100
101      return `The user's answer:\n${task.answer}`
102    default:
103      return ''
104  }
105}
106
107/** One task's news for Claude: `#5 done: <title>.` and what the user said. */
108export function newsOf(task: Task, isFull: boolean): string {
109  const body = bodyOf(task, isFull)
110
111  return `#${task.id} ${task.state}: ${task.title}.${body === '' ? '' : ` ${body}`}`
112}
113
114/** What the user did with a task, in their own words. */
115function ownWordsOf(task: Task): string {
116  const head = `#${task.id} ${task.title}:`
117
118  switch (task.state) {
119    case 'rejected':
120      return task.reason ? `${head} I won't do this. ${task.reason}` : `${head} I won't do this.`
121    case 'done':
122      if (task.answer === undefined || task.answer === '') {
123        return `${head} Done.`
124      }
125
126      if (task.kind === 'choose' && task.options?.includes(task.answer)) {
127        return `${head} I chose "${task.answer}".`
128      }
129
130      return `${head}\n${task.answer}`
131    default:
132      return head
133  }
134}
135
136/**
137 * What reaches Claude as a message: every response it has not heard,
138 * whole, in the user's own words, since they are the user's.
139 */
140export function messageOf(due: readonly Task[]): string {
141  const lines = due.map(ownWordsOf)
142
143  return `${due.length === 1 ? 'My response to a task' : 'My responses to tasks'} you gave me (Human in the loop):\n\n${lines.join('\n\n')}`
144}
145
146/** What rides along with the user's next prompt: the updates, long answers named. */
147export function digestOf(due: readonly Task[]): string {
148  const lines = due.map(task => newsOf(task, false))
149
150  return `Task updates since you last checked (Human in the loop):\n${lines.join('\n')}`
151}
152
153/** Read after a compaction or at a session's start: what is still with the user. */
154export function contextOf(shown: readonly Task[], sessionId: string): string {
155  const lines = shown.map(task => {
156    const earlier = task.sessionId === sessionId ? '' : ' (from an earlier session)'
157    return isActive(task) ? `#${task.id} ${task.state}${earlier}: ${task.title}. ${task.why}` : newsOf(task, false)
158  })
159
160  return [
161    'Tasks you gave the user, through the Human in the loop tools (assign_task, list_tasks, withdraw_task):',
162    ...lines,
163    'The user sees them in their My tasks pane, or with /my-tasks. Withdraw any you no longer need.',
164  ].join('\n')
165}
166
167/** `list_tasks`'s answer. */
168export function listOf(tasks: readonly Task[], nowMs: number): string {
169  if (tasks.length === 0) {
170    return 'No tasks match.'
171  }
172
173  return tasks
174    .map(task => {
175      const age = ago(nowMs - task.createdMs)
176      const detail = isActive(task)
177        ? [`Why: ${task.why}`, task.doneWhen ? `Done when: ${task.doneWhen}` : '', task.options ? `Options: ${task.options.join(' | ')}` : '']
178        : bodyOf(task, true).split('\n')
179
180      return [`#${task.id} ${task.state} · assigned ${age === 'now' ? 'just now' : `${age} ago`} · ${task.title}`, ...detail]
181        .filter(line => line.trim() !== '')
182        .join('\n  ')
183    })
184    .join('\n\n')
185}
186
187/** `assign_task`'s answer. */
188export function assignedOf(task: Task, open: number): string {
189  return [
190    `Assigned task #${task.id}: "${task.title}".`,
191    'The user sees it in their My tasks pane, or with /my-tasks, until they act on it. Their response reaches you as a message, or through list_tasks.',
192    'You can carry on with work that does not depend on it.',
193    `Open tasks: ${open} of ${MAX_OPEN}.`,
194  ].join(' ')
195}
196
197/**
198 * The status line under the prompt: this session's tasks and responses not
199 * sent yet, and how many tasks wait in the project's other live sessions,
200 * which this session neither shows nor answers.
201 */
202export function statusOf(shown: readonly Task[], elsewhere = 0): string | undefined {
203  const open = shown.filter(isActive).length
204  const saved = shown.length - open
205  const parts = [
206    open > 0 ? `☐ ${plural(open, 'task')} for you` : '',
207    saved > 0 ? `${open > 0 ? '' : '✓ '}${saved} not sent yet` : '',
208  ].filter(part => part !== '')
209  const others = elsewhere > 0 ? `${plural(elsewhere, 'task')} in another session` : ''
210
211  if (parts.length === 0) {
212    return others === '' ? undefined : others
213  }
214
215  return [...parts, '/my-tasks', others].filter(part => part !== '').join(' · ')
216}
217
218/**
219 * The rows the My tasks pane asks for: a head, the selected task whole, a row
220 * for every other task, and a hint; at most `max`.
221 */
222export function rowsFor(shown: readonly Task[], selectedId: number | null, isEditing: boolean, max: number): number {
223  const open = shown.filter(isActive)
224  const selected = open.find(task => task.id === selectedId) ?? open[0]
225  const whole =
226    selected === undefined
227      ? 0
228      : 1 + Math.ceil(selected.why.length / 70) + (selected.doneWhen === undefined ? 0 : 1) + (isEditing ? 2 : 1)
229
230  return Math.min(max, 2 + whole + (open.length - (selected === undefined ? 0 : 1)) + (shown.length - open.length) + 1)
231}
232
233const BRIDGE = /^\s*↳?\s*Answer to #(\d+):[ \t]*\n?/i
234
235/** What a long answer written in the prompt box starts with. */
236export function bridgeOf(id: number): string {
237  return `↳ Answer to #${id}: `
238}
239
240/** A prompt that answers a task: the task's id and the answer, or null. */
241export function answerIn(text: string): { id: number; answer: string } | null {
242  const match = BRIDGE.exec(text)
243
244  return match ? { id: Number(match[1]), answer: text.slice(match[0].length).trim() } : null
245}
246
247const SECRET_SHAPES: readonly RegExp[] = [
248  /\bsk_(live|test)_[0-9A-Za-z]{10,}/,
249  /\bsk-ant-[\w-]{10,}/,
250  /\bsk-[A-Za-z0-9_-]{20,}/,
251  /\bA(KIA|SIA)[0-9A-Z]{16}\b/,
252  /\bgh[pousr]_[A-Za-z0-9]{30,}/,
253  /\bgithub_pat_[A-Za-z0-9_]{30,}/,
254  /\bxox[abprs]-[\w-]{10,}/,
255  /\bAIza[\w-]{30,}/,
256  /-----BEGIN [A-Z ]*PRIVATE KEY-----/,
257  /\beyJ[\w-]{10,}\.[\w-]{10,}\.[\w-]{10,}/,
258]
259
260/**
261 * Whether an answer looks like it carries a secret: a known key shape, or a
262 * long token mixing upper case, lower case and digits (a git hash, all lower
263 * case hex, is not one).
264 */
265export function looksSecret(text: string): boolean {
266  if (SECRET_SHAPES.some(shape => shape.test(text))) {
267    return true
268  }
269
270  return (text.match(/[A-Za-z0-9+/_=-]{32,}/g) ?? []).some(
271    token => /[a-z]/.test(token) && /[A-Z]/.test(token) && /[0-9]/.test(token),
272  )
273}
274
hooks/project.ts 152 lines
1import type { Task } from '../types'
2import { isShown } from './tasks'
3
4/** How often a live session says so in the project's store. */
5export const HEARTBEAT_MS = 60_000
6
7/** A session not heard from for this long is gone: its tasks are free to adopt. */
8export const STALE_MS = 3 * 60_000
9
10/** How long a note of tasks taken from a quiet session waits for it to come back. */
11export const TAKEN_MS = 30 * 24 * 60 * 60_000
12
13/** One session's part of a project: its tasks, and when it was last alive. */
14export type Slot = {
15  tasks: Task[]
16  seenMs: number
17  /** Set when the session ended: its tasks wait for the next session here. */
18  endedMs?: number
19}
20
21/**
22 * The tasks another session took on from a session that went quiet (a laptop
23 * asleep, say), by that session's numbers: if it comes back, it lets them go.
24 */
25export type Taken = { ids: number[]; atMs: number }
26
27/**
28 * What a project keeps in the store: the next task number, shared by every
29 * session there, each session's own tasks, and the tasks taken from sessions
30 * that went quiet. A session writes its own slot only, so two sessions in one
31 * project never write over each other's tasks.
32 */
33export type Stored = { nextId: number; sessions: Record<string, Slot>; taken: Record<string, Taken> }
34
35/** Reads what the store holds, the first layout (one list for the project) included. */
36export function storedOf(raw: unknown): Stored {
37  const value = (raw ?? {}) as { nextId?: unknown; sessions?: unknown; tasks?: unknown; taken?: unknown }
38  const nextId = typeof value.nextId === 'number' ? value.nextId : 1
39  const taken = value.taken !== null && typeof value.taken === 'object' ? (value.taken as Record<string, Taken>) : {}
40
41  if (value.sessions !== null && typeof value.sessions === 'object') {
42    return { nextId, sessions: value.sessions as Record<string, Slot>, taken }
43  }
44
45  // The first layout kept one list per project: its tasks belong to sessions
46  // that have ended.
47  const sessions: Record<string, Slot> = {}
48
49  for (const task of Array.isArray(value.tasks) ? (value.tasks as Task[]) : []) {
50    const slot = (sessions[task.sessionId] ??= { tasks: [], seenMs: 0, endedMs: 0 })
51    slot.tasks.push(task)
52  }
53
54  return { nextId, sessions, taken: {} }
55}
56
57/** Whether a session's tasks are free for another to take on: it ended, or went quiet. */
58export function isGone(slot: Slot, nowMs: number): boolean {
59  return slot.endedMs !== undefined || nowMs - slot.seenMs > STALE_MS
60}
61
62/**
63 * Takes on, for `sessionId`, its own slot (a resumed session) and every slot
64 * of a session that is gone; their slots leave the store. Live sessions keep
65 * theirs. A session that went quiet without ending is noted with the tasks
66 * taken from it, in case it comes back.
67 *
68 * Two sessions assigning at the same moment can draw one number (the store
69 * has no way to reserve one): where their tasks meet here, the session's own
70 * keep theirs, the numbers its Claude knows, and a later one gets a new one.
71 */
72export function adopt(stored: Stored, sessionId: string, nowMs: number): { adopted: Task[]; stored: Stored } {
73  const adopted: Task[] = []
74  const sessions: Record<string, Slot> = {}
75  const taken: Record<string, Taken> = {}
76  const slots = Object.entries(stored.sessions).sort(([a], [b]) => Number(b === sessionId) - Number(a === sessionId))
77  const numbers = slots.flatMap(([, slot]) => slot.tasks.map(task => task.id))
78  let nextId = Math.max(stored.nextId, ...numbers.map(id => id + 1))
79
80  for (const [id, note] of Object.entries(stored.taken)) {
81    if (id !== sessionId && nowMs - note.atMs < TAKEN_MS) {
82      taken[id] = note
83    }
84  }
85
86  for (const [id, slot] of slots) {
87    if (id !== sessionId && !isGone(slot, nowMs)) {
88      sessions[id] = slot
89      continue
90    }
91
92    const shown = slot.tasks.filter(isShown)
93
94    if (id !== sessionId && slot.endedMs === undefined && shown.length > 0) {
95      taken[id] = { ids: [...(taken[id]?.ids ?? []), ...shown.map(task => task.id)], atMs: nowMs }
96    }
97
98    for (const task of shown) {
99      adopted.push(adopted.some(one => one.id === task.id) ? { ...task, id: nextId++ } : task)
100    }
101  }
102
103  adopted.sort((a, b) => a.id - b.id)
104
105  return { adopted, stored: { nextId, sessions, taken } }
106}
107
108/**
109 * The numbers of `sessionId`'s tasks another session took on while it was
110 * quiet, and the store with that note read; none when nothing was taken.
111 */
112export function takenFrom(stored: Stored, sessionId: string): { ids: readonly number[]; stored: Stored } {
113  const note = stored.taken[sessionId]
114
115  if (note === undefined) {
116    return { ids: [], stored }
117  }
118
119  const taken = { ...stored.taken }
120  delete taken[sessionId]
121
122  return { ids: note.ids, stored: { ...stored, taken } }
123}
124
125/** `sessionId`'s slot set to `tasks`, alive now; a session with nothing left has no slot. */
126export function withSlot(stored: Stored, sessionId: string, tasks: readonly Task[], nowMs: number): Stored {
127  const sessions = { ...stored.sessions }
128  const shown = tasks.filter(isShown)
129
130  if (shown.length === 0) {
131    delete sessions[sessionId]
132  } else {
133    sessions[sessionId] = { tasks: shown, seenMs: nowMs }
134  }
135
136  return { ...stored, sessions }
137}
138
139/** `sessionId`'s slot marked ended, so the next session in the project takes its tasks on. */
140export function endSlot(stored: Stored, sessionId: string, nowMs: number): Stored {
141  const slot = stored.sessions[sessionId]
142
143  return slot === undefined ? stored : { ...stored, sessions: { ...stored.sessions, [sessionId]: { ...slot, endedMs: nowMs } } }
144}
145
146/** How many tasks wait in the project's other live sessions. */
147export function elsewhereIn(stored: Stored, sessionId: string, nowMs: number): number {
148  return Object.entries(stored.sessions)
149    .filter(([id, slot]) => id !== sessionId && !isGone(slot, nowMs))
150    .reduce((sum, [, slot]) => sum + slot.tasks.filter(isShown).length, 0)
151}
152
hooks/text.ts 47 lines
1/** Collapses runs of whitespace, newlines included, to single spaces. */
2export function oneLine(text: string): string {
3  return text.replace(/\s+/g, ' ').trim()
4}
5
6/** Cuts `text` to `max` characters, marking the cut with an ellipsis. */
7export function clip(text: string, max: number): string {
8  return text.length > max ? `${text.slice(0, max - 1)}…` : text
9}
10
11/**
12 * A label safe to draw: control, format and line-separator characters out
13 * (the engine refuses a drawing holding one), whitespace collapsed, and cut
14 * to `max`. Titles and reasons are text the model or the user wrote.
15 */
16export function printable(text: string, max: number): string {
17  const spaced = text.replace(/[\p{Cc}\p{Zl}\p{Zp}]/gu, ' ').replace(/\p{Cf}/gu, '')
18
19  return clip(oneLine(spaced), max)
20}
21
22export function plural(count: number, word: string): string {
23  return `${count} ${word}${count === 1 ? '' : 's'}`
24}
25
26/** `now`, `4m`, `3h`, `2d`: how long ago, at a glance. */
27export function ago(ms: number): string {
28  const minutes = Math.floor(Math.max(0, ms) / 60_000)
29
30  if (minutes < 1) {
31    return 'now'
32  }
33
34  if (minutes < 60) {
35    return `${minutes}m`
36  }
37
38  const hours = Math.floor(minutes / 60)
39
40  return hours < 24 ? `${hours}h` : `${Math.floor(hours / 24)}d`
41}
42
43/** `840 B`, `2.3 KB`. */
44export function sizeOf(chars: number): string {
45  return chars < 1024 ? `${chars} B` : `${(chars / 1024).toFixed(1)} KB`
46}
47
types/index.d.ts 63 lines
1/**
2 * What Claude needs from the user: something done (`do`), an answer in
3 * their own words (`answer`), or one of a few options (`choose`).
4 */
5export type TaskKind = 'do' | 'answer' | 'choose'
6
7/**
8 * Where a task stands: waiting for the user (`open`), fulfilled (`done`),
9 * turned down (`rejected`), or taken back by Claude (`withdrawn`).
10 */
11export type TaskState = 'open' | 'done' | 'rejected' | 'withdrawn'
12
13/**
14 * Whether Claude has heard of the user's latest move on the task: nothing
15 * to tell (`none`), told nothing yet (`pending`), or told (`delivered`).
16 */
17export type TaskUpdate = 'none' | 'pending' | 'delivered'
18
19/** A task Claude gave the user. */
20export type Task = {
21  /** Counts up per project: #1, #2, … */
22  id: number
23  title: string
24  why: string
25  doneWhen?: string
26  kind: TaskKind
27  options?: readonly string[]
28  state: TaskState
29  /** The user's answer, or the option they chose. */
30  answer?: string
31  /** Why the user rejected it, or why Claude withdrew it. */
32  reason?: string
33  update: TaskUpdate
34  /** The session that assigned it. */
35  sessionId: string
36  createdMs: number
37  updatedMs: number
38}
39
40/**
41 * The selected task's field, when the pane shows one in place of its
42 * buttons: an answer, or a reason for rejecting. `held` is an answer held
43 * back because it looks like a secret.
44 */
45export type Editing = {
46  id: number
47  field: 'answer' | 'reason'
48  held?: string
49}
50
51declare module 'claude-code' {
52  interface PluginState {
53    'human-in-the-loop': {
54      tasks: readonly Task[]
55      nextId: number
56      selectedId: number | null
57      editing: Editing | null
58      loadedFor: string | null
59      isWorking: boolean
60    }
61  }
62}
63