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…


Claude hands you the tasks only you can do. They wait until you act, and your answer goes back to Claude.
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.
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
d. The key never passes through Claude.1 makes the repo public, 2 keeps it private.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.
assign_task: an imperative title, why it's needed, what counts as done, and a kind (do, answer or choose).☐ 3 tasks for you · /my-tasks./my-tasks opens it any time, with the keyboard.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.↳ Answer to #3:, and you paste and press Enter.It never draws in the band above the prompt, so it works beside band mods such as What's Agent Doing.
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.
↳ Answer to #3: …), which it turns into that same response, and to attach responses not yet sent as notes Claude reads.assign_task, list_tasks and withdraw_task, which the mod itself serves. Every other tool call passes through untouched.Report a problem or ask for a feature in GitHub Issues. Read the privacy policy.
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
| File | What it does |
|---|---|
hooks/register.tsx | The tools Claude calls, delivering your responses, the My tasks pane and status line, /my-tasks, long answers through the prompt box |
hooks/project.ts | Each session's tasks in the project's store, and taking on those of sessions that ended |
hooks/tasks.ts | Reading a task from Claude's call, what Claude is told, the status line, secret shapes |
hooks/text.ts | Printable labels, ages, sizes |
types/index.d.ts | The $.state contract: the tasks, the selection, the open field |
tests/register.test.tsx | Assigning, the pane and /my-tasks, every response and when it reaches Claude, long answers, tasks carried across sessions |
docs/DESIGN.md | The design and the decisions behind it |
MIT
hooks/register.tsx 1036 lines1import { 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}
1036hooks/tasks.ts 274 lines1import 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}
274hooks/project.ts 152 lines1import 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}
152hooks/text.ts 47 lines1/** 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}
47types/index.d.ts 63 lines1/**
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