SLOPSHOPPER

task-tree

Painel lateral com a árvore de tarefas planejadas (até 3 níveis), marcando e colapsando as concluídas

newpaneguardcommandtoaststatus
v0.7.6no licenseupdated 2026-10-24AlbertKellner/claude-code-mod-task-tree/task-tree
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · task-tree
│ ┃ Tarefas ✕ › fix the failing auth test and add an audit log call │ ┃ Nenhuma tarefa planejada. [≡] ▤ │ ● task-tree: task-tree: get_session falhou: TypeError: undefined is n │ ● task-tree: task-tree: get_session falhou: TypeError: undefined is n │ ⏺ 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 │ │ › /task-tree │ ⎿ task-tree: Painel de tarefas aberto. Painéis: task-tree (aba atr │ ● task-tree: task-tree: get_session falhou: TypeError: undefined is n │ ● task-tree: task-tree: get_session falhou: TypeError: undefined is n │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Tarefas
Nenhuma tarefa planejada. [≡] ▤
README

claude-code-mod-task-tree

Mod do Claude Code que mostra, num painel lateral, a árvore de tarefas planejadas da sessão (até 3 níveis), marcando as tarefas em andamento e colapsando as concluídas.

O plugin task-tree expõe três ferramentas ao modelo: plan (publica o plano), start (marca tarefas em andamento) e done (marca tarefas concluídas).

Instalação

A instalação é pelo marketplace do repositório, a partir da branch main.

Pelo terminal

claude plugin marketplace add "AlbertKellner/claude-code-mod-task-tree"
claude plugin install task-tree@claude-code-mod-task-tree

De dentro do Claude Code

/plugin marketplace add AlbertKellner/claude-code-mod-task-tree
/plugin install task-tree@claude-code-mod-task-tree

Abra uma sessão nova depois de instalar para o plugin carregar.

Por uma sessão do Claude Code

Cole este prompt numa sessão e o Claude faz a instalação:

Instale o plugin task-tree do Claude Code a partir do GitHub:

1. Rode `claude plugin marketplace add "AlbertKellner/claude-code-mod-task-tree"`.
2. Rode `claude plugin install task-tree@claude-code-mod-task-tree`.
3. Rode `claude plugin list` e confirme que `task-tree@claude-code-mod-task-tree` está `enabled`.
4. Se eu carregava o mod por pasta local (`CLAUDE_CODE_PLUGIN_DIRS` em `~/.claude/settings.json` ou `--plugin-dir`), mostre o caminho antigo e peça minha confirmação antes de removê-lo, para o plugin não carregar em duplicidade.
5. Avise que preciso abrir uma sessão nova para o plugin carregar.

Atualizar por uma sessão do Claude Code

Cole este prompt numa sessão (no Claude Code desktop ou no terminal) e o Claude atualiza o plugin para a versão mais recente da main:

Atualize o plugin task-tree do Claude Code para a versão mais recente:

1. Rode `claude plugin list` e anote a versão atual de `task-tree@claude-code-mod-task-tree`.
2. Rode `claude plugin marketplace update claude-code-mod-task-tree`.
3. Rode `claude plugin update task-tree@claude-code-mod-task-tree`.
4. Rode `claude plugin list` de novo, confirme que o plugin está `enabled` e me diga a versão antes e depois.
5. Se eu carregava o mod por pasta local (`CLAUDE_CODE_PLUGIN_DIRS` em `~/.claude/settings.json` ou `--plugin-dir`), avise que é essa pasta que carrega, e não o plugin atualizado.
6. Avise que preciso abrir uma sessão nova para a versão nova carregar.

Verificar

claude plugin list

task-tree@claude-code-mod-task-tree deve aparecer como enabled.

Atualizar e remover

claude plugin marketplace update claude-code-mod-task-tree
claude plugin update task-tree@claude-code-mod-task-tree
claude plugin uninstall task-tree@claude-code-mod-task-tree

Se você usava o mod local

Quem carregava o mod por pasta (CLAUDE_CODE_PLUGIN_DIRS ou --plugin-dir) deve tirar esse caminho depois de instalar, para o plugin não carregar em duplicidade.

Desenvolvimento

O plugin fica em task-tree/. Para testar uma alteração sem instalar, aponte a pasta para o Claude Code:

claude --plugin-dir ./task-tree
Source 23 files
hooks/register.tsx 32 lines
1// The hooks module: registers every hook of the mod, each one a function of its own file (the kit
2// takes a hook by the name of a function; the state's keys are literal in the file that calls
3// $.state). Nothing else lives here.
4
5import type { Register } from 'claude-code'
6
7import { classicSessionStart, sessionStart } from './session-start'
8import { promptCompose, promptSubmit } from './prompt-submit'
9import { commandRun } from './command'
10import { doneTool, planTool, startTool } from './tools'
11import { permissionRequest } from './permission'
12import { turnComplete, turnStep } from './turn'
13import { toolCall } from './tool-call'
14import { press, render } from './render'
15
16export const register: Register = on => {
17  on('session.start', sessionStart)
18  on('classic.SessionStart', classicSessionStart)
19  on('prompt.submit', promptSubmit)
20  on('prompt.compose', promptCompose)
21  on('command.run', { command: 'task-tree' }, commandRun)
22  on('tool.call', { tool: 'mcp__task-tree__plan' }, planTool)
23  on('tool.call', { tool: 'mcp__task-tree__done' }, doneTool)
24  on('tool.call', { tool: 'mcp__task-tree__start' }, startTool)
25  on('classic.PermissionRequest', permissionRequest)
26  on('turn.step', turnStep)
27  on('turn.complete', turnComplete)
28  on('tool.call', toolCall)
29  on('ui.press', press)
30  on('ui.render', { component: 'Pane', requestId: 'task-tree' }, render)
31}
32
hooks/session-start.ts 81 lines
1// session.start: registers the command and the tools, brings the panel back from the store, opens
2// the pane and starts reading the linked sessions. classic.SessionStart: /clear empties the panel,
3// a resume brings the session's panel back.
4
5import { MAIN } from '../draw/layout'
6import { COMMAND, TOOLS } from '../text/rules'
7import { loaded } from '../state/durability'
8import { PANE, adopt, replace, restore, restoreLinks, restoreView, restoreWork, setWork } from './panel'
9import { refreshChildren, watchSessions } from './sessions'
10import { level } from './tools'
11
12// The state's keys this file reads or writes, as literals: the kit's validator wants them written in the
13// file that calls $.state (../state/durability has a row for every one).
14
15import type { EngineInterface } from 'claude-code'
16const groupsState = { plugin: 'task-tree', key: 'groups' } as const
17const waitsState = { plugin: 'task-tree', key: 'waits' } as const
18const sendsState = { plugin: 'task-tree', key: 'sends' } as const
19
20export async function sessionStart($: EngineInterface, e: any, next: any) {
21  await $.command.register({ name: 'task-tree', description: COMMAND })
22  await $.tool.register({
23    name: 'plan',
24    isDeferred: false,
25    description: TOOLS.plan,
26    inputSchema: {
27      type: 'object',
28      required: ['tasks'],
29      properties: { tasks: level(level(level())), request: { type: 'string' } },
30    },
31  })
32  await $.tool.register({
33    name: 'done',
34    isDeferred: false,
35    description: TOOLS.done,
36    inputSchema: {
37      type: 'object',
38      required: [],
39      properties: {
40        ids: { type: 'array', items: { type: 'string' } },
41        summary: { type: 'string', description: TOOLS.summary },
42      },
43    },
44  })
45  await $.tool.register({
46    name: 'start',
47    isDeferred: false,
48    description: TOOLS.start,
49    inputSchema: {
50      type: 'object',
51      required: ['ids'],
52      properties: { ids: { type: 'array', items: { type: 'string' } } },
53    },
54  })
55  // With nothing held (a new process), a resumed session's panel comes back from the store.
56  await adopt($)
57  if ((await $.state.get(groupsState)).value === undefined) await restore($, await $.session.id())
58  loaded.add('groups')
59  await restoreView($)
60  await restoreWork($)
61  await restoreLinks($)
62  const r = await next(e)
63  // Opens the panel with the session, so nobody has to type /task-tree.
64  void $.ui.open({ id: PANE, title: 'Tarefas' }).catch(() => {})
65  // The linked sessions' states change outside this session: polled while the session runs.
66  void refreshChildren($).catch(() => {})
67  watchSessions($)
68  return r
69}
70
71export async function classicSessionStart($: EngineInterface, e: any, next: any) {
72  if (e.source === 'clear') {
73    await replace($, [{ agent: MAIN, label: '', tasks: [] }])
74    await $.state.set(waitsState, [])
75    await $.state.set(sendsState, [])
76    await setWork($, [])
77  }
78  if (e.source === 'resume') await restore($, e.session_id)
79  return next(e)
80}
81
hooks/prompt-submit.ts 132 lines
1// prompt.submit: what a prompt is (said, pure: another session's message, a subagent's hand-back,
2// the app's notice, or a request) decides its effects: the messages received are kept, the pane
3// comes back, a request opens a turn and drops the open question, and the reminders (a session
4// that ended its turn, a message taken up, what the last request left open) go in its context.
5// prompt.compose: the rules go in the system prompt while the panel's tools are there.
6
7import { MAIN, hasOpen } from '../draw/layout'
8import { PROMPT, RULES } from '../text/rules'
9import { changeAsks, gist, reminders, dropWaits, openList, openTurn, showPane, wake } from './panel'
10import { pick } from './tool-call'
11import { endedNote, noteReceived, refreshChildren, refreshStale, takenNote, watchSessions } from './sessions'
12
13// The state's keys this file reads or writes, as literals: the kit's validator wants them written in the
14// file that calls $.state (../state/durability has a row for every one).
15const groupsState = { plugin: 'task-tree', key: 'groups' } as const
16const turnAbortedState = { plugin: 'task-tree', key: 'turnAborted' } as const
17
18// The main loop's group; a subagent's is keyed by its agentId.
19// Prompt origins (`prompt.submit`'s `e.origin.kind`) that continue the request in progress rather
20// than make a new one: background tasks' notifications (a subagent's hand-back), observers'
21// reports, follow-ups of a UI action and the engine's own notices.
22const NO_TURN = ['task-notification', 'observer', 'observer-activity', 'auto-continuation', 'unclassified']
23
24// What a prompt is, with the app's status blocks (`<system-reminder>…</system-reminder>`, put in
25// front of a session's first message and of queued deliveries) taken out: another session's message,
26// a subagent handing back, the app's own notice, or a request.
27export type Said = { body: string; peer: boolean; handback: boolean; notice: boolean; title: string; from?: { id: string; name?: string } }
28export function said(text: string): Said {
29  const body = text.replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, '').trim()
30  const peer = /<cross-session-message\b/.test(body)
31  const handback = !peer && (/^<agent-message from="(?!local_)/.test(body) || /\[Subagent hand-back\]/.test(body))
32  const notice = !peer && !handback && (body === '' || body.startsWith('<side-sessions-event>'))
33  const inner = /<cross-session-message[^>]*>([\s\S]*?)(<\/cross-session-message>|$)/.exec(body)?.[1]
34  // Who sent it (the first message's tag): its id and the name the tag gives.
35  const tag = /<cross-session-message\b[^>]*>/.exec(body)?.[0] ?? ''
36  const id = /\bfrom="([^"]+)"/.exec(tag)?.[1]
37  const name = /\bname="([^"]*)"/.exec(tag)?.[1]
38  const from = peer && id ? { id, ...(name ? { name } : {}) } : undefined
39  return { body, peer, handback, notice, title: peer && inner !== undefined ? inner : body, ...(from ? { from } : {}) }
40}
41
42// Reminds the model, with every prompt, that every request opens a task in the panel.
43
44import type { EngineInterface } from 'claude-code'
45export async function promptSubmit($: EngineInterface, e: any, next: any) {
46  // Where it came from (`e.origin.kind`, set by the engine). A background task's notification (a
47  // subagent handing back, a command that ended) or an engine notice (`unclassified`: idle notices,
48  // delivery receipts, the desktop app's events) continues the request in progress: no turn, no
49  // reminder of a new request; only a short one to bring the panel up to date.
50  const kind = String((e as any).origin?.kind ?? '')
51  const text = typeof e.text === 'string' ? e.text : ''
52  const it = said(text)
53  // Another session's messages are kept (from, when) and published, whatever this prompt turns out
54  // to be: the sender confirms its queued SendMessage by them (watchSends).
55  await noteReceived($, text)
56  // A prompt starts a turn: what the last one left open is in progress again.
57  await wake($)
58  // The last turn of this session was interrupted (stopped): a message of another session that comes
59  // next gets the reminder to replan the panel (the person's rule); any request clears the mark.
60  const aborted = (await $.state.get(turnAbortedState)).value === true
61  watchSessions($)
62  // The person's own prompt: the panel comes back if it is not on screen (see showPane).
63  if (!it.peer && !it.notice && !it.handback && !NO_TURN.includes(kind)) await showPane($)
64  // The desktop app's own notices (a session that ended its turn, <side-sessions-event>) and a
65  // subagent's hand-back (`<agent-message from="<agentId>">` "[Subagent hand-back] …") are known by
66  // their text: they do not come as `unclassified` or `task-notification`.
67  if (!it.peer && (NO_TURN.includes(kind) || it.notice || it.handback)) {
68    // The app's notice says a session ended its turn: read the list now, for its reminders.
69    if (it.notice) await refreshChildren($).catch(() => {})
70    const note = await endedNote($)
71    const taken = await takenNote($)
72    return next({
73      ...e,
74      context: [
75        ...(e.context ?? []),
76        ...(note ? [note] : []),
77        ...(taken ? [taken] : []),
78        it.handback ? PROMPT.handback : kind === 'task-notification' ? PROMPT.background : PROMPT.notice,
79      ],
80    })
81  }
82  // The person's own message answers what the main loop left asked: the end-of-turn box's question
83  // (its buttons' words pick an option, else the message is the answer, in a few words) and a
84  // question answered by a chat message instead of an option (`chat`).
85  if (!it.peer) {
86    await changeAsks($, cur =>
87      cur.map(a => {
88        if (a.agent !== MAIN) return a
89        if (a.tool === 'wrap_up' && a.state === 'open') return { ...a, items: a.items.map(x => pick(x, it.title)), state: 'answered' }
90        if (a.state === 'chat' && a.items.every(x => x.text === undefined && (x.chosen ?? []).length === 0))
91          return { ...a, items: a.items.map((x, i) => (i === 0 ? { ...x, chosen: [], text: gist(it.title) } : x)) }
92        return a
93      }),
94    )
95  }
96  // Safety net: a new message means the main loop has no question open.
97  await dropWaits($, w => w.agent !== MAIN || w.kind === 'permission')
98  // Any question of the main loop still open is over with it.
99  await changeAsks($, cur => cur.map(a => (a.agent === MAIN && a.state === 'open' && a.tool !== 'wrap_up' ? { ...a, state: 'dropped' } : a)))
100  // A new request gets its own reminder at its first tool call without a plan.
101  reminders.unplanned = 0
102  reminders.planned = false
103  // Every other message opens a turn, those another session sends too (they arrive as meta): each
104  // is a request of its own, and the current work follows turns (startsWork).
105  await openTurn($, it.title, !it.peer, it.from)
106  // A stale list first: a session that ended its turn is reminded of now, not later.
107  await refreshStale($).catch(() => {})
108  const note = await endedNote($)
109  const taken = await takenNote($)
110  // What the last request left open (a question answered here, work this message replaces)
111  // is listed, so it is closed rather than left running under the new plan.
112  const { value: groups = [] } = await $.state.get(groupsState)
113  const tasks = groups.find(g => g.agent === MAIN)?.tasks ?? []
114  if (aborted) await $.state.set(turnAbortedState, false)
115  // After an interrupted turn, a message of another session asks for a replan of the panel instead
116  // of the list of what was left open (the replan covers it); the person's own prompt as before.
117  const left = aborted && it.peer
118    ? hasOpen(tasks) ? PROMPT.interrupted(openList(tasks)) : PROMPT.interruptedEmpty
119    : hasOpen(tasks) ? PROMPT.open(openList(tasks), reminders.answeredInChat) : undefined
120  reminders.answeredInChat = false
121  return next({
122    ...e,
123    context: [...(e.context ?? []), ...(note ? [note] : []), ...(taken ? [taken] : []), ...(left ? [left] : []), PROMPT.request],
124  })
125}
126
127export async function promptCompose($: EngineInterface, e: any, next: any) {
128  const r = await next(e)
129  if (!e.tools.includes('mcp__task-tree__plan')) return r
130  return { ...r, sections: [...r.sections, { id: 'task-tree:regras', text: RULES, scope: 'session' as const }] }
131}
132
hooks/command.ts 35 lines
1// /task-tree: opens the pane, picks the view (resumida | completa), and says what the app answered.
2
3import { PANE, DRAW_LIMIT, drawn, publishPanel, setView } from './panel'
4
5// Opens the panel, asked by the person (placed at any width), and says what the app answered and
6// which panes it holds, so a panel that does not show up can be told apart: undrawn (the width),
7// open behind another plugin's pane (a tab), or not there at all.
8// `/task-tree resumida` or `/task-tree completa` also picks the view, for a panel that cannot be
9// clicked (it does not show).
10
11import type { EngineInterface } from 'claude-code'
12export async function commandRun($: EngineInterface, e: any) {
13  const arg = String((e as any).args ?? '').trim().toLowerCase()
14  if (arg === 'resumida' || arg === 'summary') await setView($, 'summary')
15  if (arg === 'completa' || arg === 'full') await setView($, 'full')
16  let opened: any
17  try {
18    opened = await $.ui.open({ id: PANE, title: 'Tarefas', focus: true })
19  } catch (err) {
20    opened = { isPlaced: false, reason: String(err) }
21  }
22  let panes = ''
23  try {
24    panes = (await $.ui.panes())
25      .map((p: any) => `${p.id}${p.isShown ? ' (na frente)' : p.isPlaced ? ' (aba atrás de outra)' : ' (não desenhado)'}`)
26      .join(', ')
27  } catch {}
28  void publishPanel($).catch(() => {})
29  return {
30    text: opened?.isPlaced
31      ? `Painel de tarefas aberto. Painéis: ${panes || 'nenhum'}. Desenho: ${Math.round(drawn.size / 1024)} KB de ${Math.round(DRAW_LIMIT / 1024)} KB.`
32      : `O app não pôs o painel na tela: ${String(opened?.reason ?? 'sem motivo')}. Painéis: ${panes || 'nenhum'}.`,
33  }
34}
35
hooks/tools.ts 227 lines
1// The panel's own tools, plan, done and start: their schemas and what each does to the caller's
2// group (the edits are ../state/tasks, pure), with the warnings each answers.
3
4import { update } from 'claude-code'
5
6import type { Group, Send, TaskNode, Turn } from '../types'
7import { DONE } from '../text/rules'
8import { MAIN, flat, tasksOf, hasOpen, count, interrupt } from '../draw/layout'
9import { plural } from '../draw/svg'
10import { begin, build, duplicates, edit, fillStarts, finish, resolve, settle } from '../state/tasks'
11import type { InNode } from '../state/tasks'
12import { PANE, reminders, renameAnchors, change, ensureGroup, joinWork, lastBrief, stamp, startsWork, wake } from './panel'
13import { keepTime } from './sessions'
14
15// The state's keys this file reads or writes, as literals: the kit's validator wants them written in the
16// file that calls $.state (../state/durability has a row for every one).
17
18import type { EngineInterface } from 'claude-code'
19const groupsState = { plugin: 'task-tree', key: 'groups' } as const
20const sendsState = { plugin: 'task-tree', key: 'sends' } as const
21const workState = { plugin: 'task-tree', key: 'work' } as const
22const briefState = { plugin: 'task-tree', key: 'brief' } as const
23
24export const node = {
25  type: 'object',
26  required: ['id', 'title'],
27  properties: { id: { type: 'string' }, title: { type: 'string' } },
28}
29export const level = (inner?: object) => ({
30  type: 'array',
31  items: inner ? { ...node, properties: { ...node.properties, children: inner } } : node,
32})
33
34export async function planTool($: EngineInterface, e: any, next: any) {
35  try {
36    const agent = e.agentId ?? MAIN
37    if (agent === MAIN) reminders.planned = true
38    if (agent === MAIN) await wake($)
39    const input = ((e as any).input ?? e) as { tasks?: InNode[]; request?: string }
40    const fresh = build(input.tasks ?? [], 1, undefined, await stamp($))
41    const request = typeof input.request === 'string' ? input.request.replace(/\s+/g, ' ').trim().slice(0, 80) : ''
42    const dup = duplicates(fresh)
43    if (dup.length > 0) {
44      return { result: `erro: ids repetidos no plano (${dup.join(', ')}); use ids únicos.`, isError: true }
45    }
46    await ensureGroup($, agent)
47    // Keeps earlier level-1 tasks: an open one with an id the new plan reuses is replanned
48    // (replaced in its place) when it is of the request open now; a closed one stays as history,
49    // renamed if its id collides. An open one of an earlier request (the person sent another
50    // message since) is not replanned: the new plan's task goes to the new request, and the old one,
51    // abandoned, stays in its request interrupted and renamed (the person's panel: a new "1" took
52    // the place of an open "1" under the request before, and the summary kept that request in sight).
53    const ids = fresh.map(t => t.id)
54    const tag = Math.random().toString(36).slice(2, 6)
55    const rename = (t: TaskNode): TaskNode => ({ ...t, id: `${tag}:${t.id}`, children: t.children.map(rename) })
56    const fresh0 = agent === MAIN && (await startsWork($))
57    const { value: before = [] } = await $.state.get(groupsState)
58    const beforeMain = (before as Group[]).find(g => g.agent === MAIN)
59    // The request open now (the person's last message): the main group's last turn.
60    const nowTurn = beforeMain?.turns?.[beforeMain.turns.length - 1]?.id
61    const sameRequest = (t: TaskNode) => agent !== MAIN || nowTurn === undefined || t.turn === undefined || t.turn === nowTurn
62    const replanned = (t: TaskNode) => t.status === 'open' && ids.includes(t.id) && sameRequest(t)
63    const replannedIds = agent === MAIN ? (beforeMain?.tasks ?? []).filter(replanned).map(t => t.id) : ids
64    // A replanned task starts over: the sends from it (or below it) go with it. A renamed one keeps
65    // them, renamed with it (renameAnchors).
66    if (agent === MAIN) await update($, sendsState, cur => (cur ?? []).filter(s => !replannedIds.includes(String(s.anchor ?? '').split('/')[0] as string)))
67    const renamed = (beforeMain?.tasks ?? []).filter(t => ids.includes(t.id) && !replanned(t)).map(t => t.id)
68    const now = await stamp($)
69    const groups = await change($, all => {
70      return all.map(g => {
71        if (g.agent !== agent) return g
72        // In plan order: a replanned task takes the place of the one it replaces; the new ones
73        // go below the ones already there.
74        const reused = (t: TaskNode) => replanned(t)
75        // Abandoned by a new request: interrupted (what was left open), then renamed.
76        const away = (t: TaskNode) => rename(t.status === 'open' ? interrupt(now)(t) : t)
77        const inPlace = (list: TaskNode[]) => {
78          const out = g.tasks.map(t => (reused(t) ? (list.find(f => f.id === t.id) as TaskNode) : ids.includes(t.id) ? away(t) : t))
79          return [...out, ...list.filter(f => !g.tasks.some(t => reused(t) && t.id === f.id))]
80        }
81        if (agent !== MAIN) return { ...g, tasks: inPlace(fresh) }
82        // The main group's level-1 tasks go to the turn open now (the person's last message),
83        // which `request` names; a replanned one stays in the turn it was in.
84        let turns: Turn[] = g.turns ?? []
85        let cur = turns[turns.length - 1]
86        if (request !== '') {
87          if (cur === undefined) {
88            cur = { id: 1, title: request }
89            turns = [cur]
90          } else {
91            cur = { ...cur, title: request }
92            turns = [...turns.slice(0, -1), cur]
93          }
94        }
95        const was = (id: string) => g.tasks.find(t => replanned(t) && t.id === id)?.turn
96        const placed = fresh.map(t => {
97          const turn = was(t.id) ?? cur?.id
98          return turn === undefined ? t : { ...t, turn }
99        })
100        return { ...g, turns, tasks: inPlace(placed) }
101      })
102    })
103    if (agent === MAIN) {
104      // The sessions' ties to a renamed task go with it (see renameAnchors).
105      await renameAnchors($, renamed, tag)
106      // A closed level-1 task of the current work renamed by this plan stays in it.
107      const { value: cur = [] } = await $.state.get(workState)
108      const kept = fresh0 ? [] : renamed.filter(id => (cur as string[]).includes(id)).map(id => `${tag}:${id}`)
109      await joinWork($, [...ids, ...kept], fresh0)
110    }
111    void $.ui.open({ id: PANE, title: 'Tarefas' }).catch(() => {})
112    // What this plan sent (every level) and what is open in the caller's own group: not the
113    // whole panel, which holds the other loops' tasks too.
114    const [gd, gn] = count(groups.find(g => g.agent === agent)?.tasks ?? [])
115    const sent = plural(flat(fresh).length, 'tarefa', 'tarefas')
116    return { result: `Plano exibido no painel: ${sent} neste plano (${plural(gn - gd, 'aberta', 'abertas')} no seu grupo).` }
117  } catch (err) {
118    return { result: 'erro: ' + String(err), isError: true }
119  }
120}
121
122export async function doneTool($: EngineInterface, e: any, next: any) {
123  try {
124    const agent = e.agentId ?? MAIN
125    if (agent === MAIN) reminders.planned = true
126    if (agent === MAIN) await wake($)
127    const input = ((e as any).input ?? e) as { ids?: string[]; summary?: unknown }
128    if (agent === MAIN && typeof input.summary === 'string' && input.summary.trim() !== '') await $.state.set(briefState, input.summary)
129    let notes: string[] = []
130    let closed: string[] = []
131    let unstarted: string[] = []
132    const now = await stamp($)
133    const groups = await change($, all => {
134      notes = []
135      closed = []
136      unstarted = []
137      const others = tasksOf(all.filter(g => g.agent !== agent))
138      return all.map(g => {
139        if (g.agent !== agent) return g
140        let tasks = g.tasks
141        for (const id of input.ids ?? []) {
142          const { node: hit, error } = resolve(tasks, id)
143          if (error) notes.push(error)
144          else if (!hit) notes.push(resolve(others, id).node ? `"${id}" é de outro agente` : `"${id}" não existe`)
145          else if (hit.status === 'interrupted') notes.push(`"${id}" foi interrompida`)
146          else {
147            unstarted.push(...flat([hit]).filter(t => t.status === 'open' && t.children.length === 0 && t.started === undefined).map(t => t.id))
148            tasks = settle(edit(fillStarts(tasks, hit.id, now), hit.id, finish(now)), now)
149            closed.push(...flat([hit]).filter(t => t.status === 'open').map(t => t.id))
150          }
151        }
152        return { ...g, tasks }
153      })
154    })
155    // Closing the task a message was sent from while it is still queued: allowed, with a warning.
156    if (agent === MAIN) {
157      const { value: sends = [] } = await $.state.get(sendsState)
158      const main = flat(groups.find(g => g.agent === MAIN)?.tasks ?? [])
159      for (const s of (sends as Send[]).filter(s => !s.taken && closed.includes(s.anchor ?? ''))) {
160        const t = main.find(t => t.id === s.anchor)
161        if (t && t.status === 'done') notes.push(DONE.queued(s.to, t.id))
162      }
163      // The send stays with the closed task: its ✉ is drawn until the session takes the message up
164      // (watchSends), whatever the model did with the task. An id used again by a new plan renames the
165      // old task and its send with it (renameAnchors), or drops both (a replanned task).
166      // The last step closed with no summary: the ↩ line would say only "resposta".
167      if ((await lastBrief($)) === undefined && closed.length > 0 && !hasOpen(groups.find(g => g.agent === MAIN)?.tasks ?? []))
168        notes.push(DONE.noSummary)
169    }
170    const [d, n] = count(groups.find(g => g.agent === agent)?.tasks ?? [])
171    // Several steps never started and closed together: the panel showed the first one in progress
172    // all along, then jumped to the last, the steps between at 0:00 (the person's words: "ficou
173    // parada muito tempo na primeira tarefa, e derepente pulo para a ultima").
174    if (unstarted.length >= 2) notes.push(DONE.unstarted(unstarted))
175    return { result: `Concluídas: ${d}/${n} no seu grupo.` + (notes.length > 0 ? ` Avisos: ${notes.join('; ')}.` : '') }
176  } catch (err) {
177    return { result: 'erro: ' + String(err), isError: true }
178  }
179}
180
181export async function startTool($: EngineInterface, e: any, next: any) {
182  try {
183    const agent = e.agentId ?? MAIN
184    if (agent === MAIN) reminders.planned = true
185    if (agent === MAIN) await wake($)
186    const input = ((e as any).input ?? e) as { ids?: string[] }
187    let notes: string[] = []
188    let begun: string[] = []
189    let roots: string[] = []
190    const now = await stamp($)
191    const fresh0 = agent === MAIN && (await startsWork($))
192    await change($, all => {
193      notes = []
194      begun = []
195      roots = []
196      let seq = flat(tasksOf(all)).reduce((top, t) => Math.max(top, t.started ?? 0), 0) + 1
197      const others = tasksOf(all.filter(g => g.agent !== agent))
198      return all.map(g => {
199        if (g.agent !== agent) return g
200        let tasks = g.tasks
201        for (const id of input.ids ?? []) {
202          const { node: hit, error } = resolve(tasks, id)
203          if (error) notes.push(error)
204          else if (!hit) notes.push(resolve(others, id).node ? `"${id}" é de outro agente` : `"${id}" não existe`)
205          else if (hit.status !== 'open' && hit.children.length > 0) {
206            notes.push(`"${id}" já foi concluída e tem subtarefas: replaneje o nível 1 com plan`)
207          } else {
208            tasks = begin(tasks, hit.id, seq++, now)
209            begun.push(hit.title)
210            roots.push(hit.id.split('/')[0] as string)
211          }
212        }
213        return { ...g, tasks }
214      })
215    })
216    if (agent === MAIN) await joinWork($, roots, fresh0)
217    await keepTime($)
218    return {
219      result:
220        (begun.length > 0 ? `Em andamento: ${begun.join('; ')}.` : 'Nada marcado.') +
221        (notes.length > 0 ? ` Avisos: ${notes.join('; ')}.` : ''),
222    }
223  } catch (err) {
224    return { result: 'erro: ' + String(err), isError: true }
225  }
226}
227
hooks/permission.ts 23 lines
1// classic.PermissionRequest: a permission the person is asked for shows in the panel until the
2// call ends (questions and plans are shown from tool.call).
3
4import { MAIN } from '../draw/layout'
5import { addWait } from './panel'
6
7// A permission the person is asked for: the panel shows it until the call ends (no
8// event marks the answer). Questions and plans are shown from tool.call below.
9
10import type { EngineInterface } from 'claude-code'
11export async function permissionRequest($: EngineInterface, e: any, next: any) {
12  if (e.tool_name !== 'AskUserQuestion' && e.tool_name !== 'ExitPlanMode') {
13    const agent = e.agent_id ?? MAIN
14    await addWait($, {
15      agent,
16      kind: 'permission',
17      tool: e.tool_name,
18      key: `${agent}:${e.tool_name}`,
19    })
20  }
21  return next(e)
22}
23
hooks/turn.ts 141 lines
1// turn.step: the main loop's model and effort, published for the card. turn.complete: the turn's
2// answer (the recap, else the final message) goes on its turn for the card; a subagent's turn ending
3// interrupts what it left open.
4
5import type { AgentInfo, EngineInterface } from 'claude-code'
6
7import { BRIEF } from '../text/rules'
8import { MAIN, hasOpen, interrupt } from '../draw/layout'
9import { engineText } from '../io/host'
10import { change, dropAsked, dropWaits, lastBrief, lastRecap, publishPanel, stamp } from './panel'
11
12// The state's keys this file reads or writes, as literals: the kit's validator wants them written in the
13// file that calls $.state (../state/durability has a row for every one).
14const groupsState = { plugin: 'task-tree', key: 'groups' } as const
15const engineState = { plugin: 'task-tree', key: 'engine' } as const
16const rateState = { plugin: 'task-tree', key: 'rate' } as const
17const briefState = { plugin: 'task-tree', key: 'brief' } as const
18const recapState = { plugin: 'task-tree', key: 'recap' } as const
19const turnEndedState = { plugin: 'task-tree', key: 'turnEnded' } as const
20const turnAbortedState = { plugin: 'task-tree', key: 'turnAborted' } as const
21
22// The ↩ line says the answer in at most BRIEF characters (the text module's, cited in every text
23// that names the limit): the summary the session gave, else the first sentence of the answer;
24// longer, cut at a word with "…". The whole answer stays in the tooltip.
25export function brief(text: string): string {
26  const one = text.replace(/\s+/g, ' ').trim()
27  if (one.length <= BRIEF) return one
28  const cut = one.slice(0, BRIEF - 1)
29  const at = cut.lastIndexOf(' ')
30  return `${(at > BRIEF / 2 ? cut.slice(0, at) : cut).replace(/[\s,.;:–—-]+$/, '')}…`
31}
32
33// The output rate of the main loop, "n toks/s" (the person's words: "a contagem de tokens por
34// segundo de cada sessão, usando o padrão 'n toks/s'"): each model call of the turn is a sample,
35// its output tokens (the step's usage) over its duration (this hook sees the call begin and end).
36// The rate is the samples of the last RATE_WINDOW ms summed, tokens over time spent in the calls
37// (not over the wall clock: between calls the model writes nothing, and the tools' time would drag
38// the number down); with none that recent in a turn still going, the last three. The kit gives no
39// rate of its own ($.session.usage has context, cost and limits), so each session measures its own
40// and publishes it for its card.
41export const RATE_WINDOW = 10000
42type Sample = { at: number; tokens: number; ms: number }
43let samples: Sample[] = []
44export function rateOf(list: Sample[], now: number): number | undefined {
45  const recent = list.filter(s => now - s.at <= RATE_WINDOW)
46  const used = recent.length > 0 ? recent : list.slice(-3)
47  const ms = used.reduce((a, s) => a + s.ms, 0)
48  return ms > 0 ? Math.round((used.reduce((a, s) => a + s.tokens, 0) * 1000) / ms) : undefined
49}
50
51// A subagent's turn ending is the subagent ending: what it left open is interrupted.
52// A teammate ends a turn per message and is left alone.
53// The model and effort of the main loop's requests, published for the card in the session that
54// opened this one (the person's words: "mostre o nome do modelo e o esforco de cada sessao"); and
55// the call's output rate (above), published when it changes.
56export async function* turnStep($: EngineInterface, e: any, next: any) {
57  const main = !e.agentId
58  if (main && typeof e.model === 'string') {
59    const text = engineText(e.model, e.effort)
60    if ((await $.state.get(engineState)).value !== text) {
61      await $.state.set(engineState, text)
62      void publishPanel($).catch(() => {})
63    }
64  }
65  const t0 = main ? await stamp($) : undefined
66  const r = yield* next(e)
67  const tokens = r?.usage?.output_tokens
68  if (t0 !== undefined && typeof tokens === 'number') {
69    const t1 = await stamp($)
70    if (t1 !== undefined && t1 > t0) {
71      samples = [...samples, { at: t1, tokens, ms: t1 - t0 }].slice(-20)
72      const toks = rateOf(samples, t1)
73      if (toks !== undefined && (await $.state.get(rateState)).value?.toks !== toks) {
74        await $.state.set(rateState, { toks, at: t1, live: true })
75        void publishPanel($).catch(() => {})
76      }
77    }
78  }
79  return r
80}
81
82export async function turnComplete($: EngineInterface, e: any, next: any) {
83  const r = await next(e)
84  const agent = e.agentId
85  // A question or plan dialog never outlives the turn that opened it (one answered by a chat
86  // message may end without the call returning here).
87  await dropAsked($, agent ?? MAIN)
88  // What the turn answered, kept on its turn (the last one of the main group) and published: the
89  // card of this session in the one that opened it shows it under the turn's tasks (the person's
90  // words: "adicione nos paineis de sessao, o que foi gerado como resposta"). The end-of-turn
91  // recap (wrap_up), which is what the opener's notice carries; else the final message.
92  if (!agent && !e.isAborted) {
93    const recap = await lastRecap($)
94    const said = (recap ?? e.answer ?? '').replace(/\s+/g, ' ').trim().slice(0, 600)
95    const given = await lastBrief($)
96    const short = given !== undefined ? brief(given) : undefined
97    if (recap !== undefined) await $.state.set(recapState, '')
98    if (given !== undefined) await $.state.set(briefState, '')
99    if (said !== '') {
100      await change($, all =>
101        all.map(g => {
102          if (g.agent !== MAIN) return g
103          const turns = g.turns ?? []
104          const last = [...turns].reverse().find(u => g.tasks.some(t => t.turn === u.id))
105          return last ? { ...g, turns: turns.map(u => (u.id === last.id ? { ...u, reply: said, ...(short !== undefined ? { brief: short } : {}) } : u)) } : g
106        }),
107      )
108    }
109  }
110  // The turn ended: what it left open is not in progress until the next prompt (the person: a session
111  // that ended its turn shows no task running), drawn with no close and its clock stopped here.
112  if (!agent) await $.state.set(turnEndedState, (await stamp($)) ?? 0)
113  // The turn's output rate closes into its mean over the whole turn, shown dim until the next turn
114  // (the person's choice for a stopped session); the samples start over with the next turn.
115  if (!agent) {
116    const ms = samples.reduce((a, s) => a + s.ms, 0)
117    const mean = ms > 0 ? Math.round((samples.reduce((a, s) => a + s.tokens, 0) * 1000) / ms) : undefined
118    samples = []
119    const cur = (await $.state.get(rateState)).value
120    if (mean !== undefined) await $.state.set(rateState, { toks: mean, at: (await stamp($)) ?? 0, live: false })
121    else if (cur && cur.live) await $.state.set(rateState, { ...cur, live: false })
122  }
123  // Interrupted (stopped) before it was over: the next message of another session is reminded to
124  // replan (prompt.submit); the person's own next prompt clears it.
125  if (!agent) await $.state.set(turnAbortedState, e.isAborted === true)
126  // The turn's answer grew the context: publish its share for the session that opened this one.
127  if (!agent) await publishPanel($)
128  if (agent) {
129    const info = ((await $.agent.list()) as AgentInfo[]).find(a => a.id === agent)
130    if (info?.type !== 'teammate' && info?.teammateId === undefined) {
131      await dropWaits($, w => w.agent !== agent)
132      const { value } = await $.state.get(groupsState)
133      if (value?.some(g => g.agent === agent && hasOpen(g.tasks))) {
134        const now = await stamp($)
135        await change($, all => all.map(g => (g.agent === agent ? { ...g, tasks: g.tasks.map(interrupt(now)) } : g)))
136      }
137    }
138  }
139  return r
140}
141
hooks/tool-call.ts 304 lines
1// tool.call, the generic hook: one handler per responsibility, run in order (R17). Before the call:
2// a launch (Agent) records the task the loop is on; a question or a plan to approve takes the whole
3// call (the wait while it is open, the answer's reminder); a wrap_up keeps its recap and its
4// question as a wait. After the call, for the main loop only: the clocks and the sessions' list come
5// back, a start_session records its session, then each note handler adds, in order, what it has to say
6// to the result (a message only queued, a session that ended its turn, a message taken up, what a
7// wrap_up leaves open) and, last, the reminder to plan: the first call with no open plan, and every
8// 10th after it (what prompt.submit misses: answers to questions, work Claude continues on its own).
9
10import { update } from 'claude-code'
11
12import type { Ask, AskItem, Child, Group, Send, TaskNode } from '../types'
13import { ASK, SEND, UNPLANNED, WRAP_UP } from '../text/rules'
14import { MAIN, flat, hasOpen, current, startedLast } from '../draw/layout'
15import { reminders, addWait, change, askingAnchors, changeAsks, dropWaits, gist, lastBrief, openList, publishPanel, sendAnchor, stamp, usedAnchors, wake } from './panel'
16import { endedNote, keepTime, recordStart, refreshStale, takenNote, watchSessions } from './sessions'
17
18// The state's keys this file reads or writes, as literals: the kit's validator wants them written in the
19// file that calls $.state (../state/durability has a row for every one).
20const groupsState = { plugin: 'task-tree', key: 'groups' } as const
21const launchesState = { plugin: 'task-tree', key: 'launches' } as const
22const childrenState = { plugin: 'task-tree', key: 'children' } as const
23const sendsState = { plugin: 'task-tree', key: 'sends' } as const
24const asksState = { plugin: 'task-tree', key: 'asks' } as const
25const recapState = { plugin: 'task-tree', key: 'recap' } as const
26
27// What the handlers share about one call: the loop that made it (`agent`), the event, when a
28// message was sent (`sentAt`, before the call: a message delivered at once opens the session's turn
29// before the call returns) and what came back (`r`).
30type Call = { agent: string; e: any; sentAt?: number; r?: any }
31type Notes = ($: any, c: Call, notes: string[]) => Promise<void>
32
33// ---- before the call
34
35// A launch records the task the launching loop is on, where the subagent's group goes.
36async function launch($: any, c: Call) {
37  if (c.e.tool !== 'Agent' && c.e.tool !== 'Task') return
38  const { value = [] } = await $.state.get(groupsState)
39  const { anchor } = current(value.find((g: Group) => g.agent === c.agent)?.tasks ?? [])
40  const description = String(c.e.description ?? '')
41  await update($, launchesState, cur => [...(cur ?? []), { parent: c.agent, anchor, description }].slice(-20))
42}
43
44// A question or a plan to approve: the panel says so while it is open (the dialog opens inside
45// next(e)), and the answer comes back with a reminder to bring the panel up to date while the
46// asking loop has open tasks. Takes the whole call: answers it, or `undefined` when the call is
47// not one of these.
48async function asked($: any, c: Call, next: any): Promise<any | undefined> {
49  const { agent, e } = c
50  const kind = e.tool === 'AskUserQuestion' ? 'question' : e.tool === 'ExitPlanMode' ? 'plan' : undefined
51  if (!kind) return undefined
52  // A question belongs to the task in progress it asks about: the main loop asking with none open
53  // is refused (ExitPlanMode is let through: plan mode may hold the panel's tools).
54  if (kind === 'question' && agent === MAIN) {
55    const { value = [] } = await $.state.get(groupsState)
56    if (!hasOpen(value.find((g: Group) => g.agent === MAIN)?.tasks ?? [])) {
57      return { deny: ASK.refused }
58    }
59  }
60  const key = e.tool_use_id
61  await addWait($, { agent, kind, tool: e.tool, key })
62  // The question is kept, drawn under the task in progress with its options as subtasks, and kept
63  // after the answer: the option picked ✓, the others ✕ (the person's words: "envie o resumo da
64  // pergunta como texto de tarefa, e abra as sub tarefas com as alternativas disponíveis").
65  const input = (e.input ?? e) as { questions?: { question?: unknown; header?: unknown; options?: { label?: unknown }[]; multiSelect?: unknown }[] }
66  const items: AskItem[] =
67    kind === 'plan'
68      ? [{ title: 'Aprovar o plano', options: ['Aprovar', 'Continuar planejando'] }]
69      : (input.questions ?? []).map(q => ({
70          title: gist(String(q.question ?? '')),
71          whole: [q.header, q.question].filter(x => typeof x === 'string' && x !== '').join(': '),
72          options: (q.options ?? []).map(o => String(o.label ?? '')).filter(x => x !== ''),
73          ...(q.multiSelect === true ? { multi: true } : {}),
74        }))
75  const at = await stamp($)
76  const anchor = await askAnchor($, agent)
77  await changeAsks($, cur => [...cur, { key, wait: key, agent, tool: String(e.tool), at, anchor, items, state: 'open' }])
78  let settled = false
79  try {
80    const r = await next(e)
81    const text = resultText(r)
82    // Answered by a chat message instead of an option: that message is still to come.
83    const inChat = /did not answer|sent a message instead/i.test(text)
84    if (inChat && agent === MAIN) reminders.answeredInChat = true
85    await changeAsks($, cur => cur.map(a => (a.key !== key ? a : inChat ? { ...a, state: 'chat' } : { ...a, items: answer(a, text), state: 'answered' })))
86    settled = true
87    const { value = [] } = await $.state.get(groupsState)
88    if (!hasOpen(value.find((g: Group) => g.agent === agent)?.tasks ?? [])) return r
89    return { ...r, context: [...(r.context ?? []), inChat ? ASK.inChat : ASK.answered] }
90  } finally {
91    await dropWaits($, w => w.key !== key)
92    if (!settled) await changeAsks($, cur => cur.map(a => (a.key === key && a.state === 'open' ? { ...a, state: 'dropped' } : a)))
93  }
94}
95
96// Where a question is drawn: the task in progress of the loop that asks, else (a wrap_up at the end
97// of a turn, nothing open) the task started last.
98async function askAnchor($: any, agent: string): Promise<string | undefined> {
99  const { value = [] } = await $.state.get(groupsState)
100  const tasks = (value as Group[]).find(g => g.agent === agent)?.tasks ?? []
101  return current(tasks).anchor ?? startedLast(tasks)?.id
102}
103// The text of a tool's result, whatever shape it came in.
104function resultText(r: any): string {
105  const v = r?.result ?? r
106  if (typeof v === 'string') return v
107  if (Array.isArray(v)) return v.map(x => (typeof x === 'string' ? x : (x?.text ?? ''))).join('\n')
108  if (v && typeof v.text === 'string') return v.text
109  return JSON.stringify(v ?? '')
110}
111// The answers a result gives ("question"="answer", in order), each matched to its item's options: an
112// option's label picks it (several, comma-separated, for a multiple choice); what matches none is the
113// answer typed, in a few words. ExitPlanMode: approved or not.
114export function answer(a: Ask, text: string): AskItem[] {
115  if (a.tool === 'ExitPlanMode') {
116    const ok = /approved/i.test(text) && !/not approved|rejected|doesn't want|does not want/i.test(text)
117    return a.items.map(it => ({ ...it, chosen: [ok ? 'Aprovar' : 'Continuar planejando'] }))
118  }
119  const pairs = [...text.matchAll(/"((?:[^"\\]|\\.)*)"\s*=\s*"((?:[^"\\]|\\.)*)"/g)].map(m => ({ q: m[1], v: m[2] }))
120  return a.items.map((it, i) => {
121    const hit = pairs.find(p => it.whole !== undefined && it.whole.endsWith(p.q)) ?? pairs.find(p => gist(p.q) === it.title) ?? pairs[i]
122    return hit ? pick(it, hit.v) : it
123  })
124}
125// An item answered with `value`: the options it names (by their labels), else the text, shortened.
126export function pick(it: AskItem, value: string): AskItem {
127  const norm = (s: string) => s.trim().toLowerCase()
128  const exact = it.options.filter(o => norm(o) === norm(value))
129  if (exact.length > 0) return { ...it, chosen: exact }
130  const byMsg = (it.messages ?? []).findIndex(m => norm(m) === norm(value))
131  if (byMsg >= 0 && it.options[byMsg] !== undefined) return { ...it, chosen: [it.options[byMsg]] }
132  if (it.multi) {
133    const parts = value.split(/\s*,\s*/).filter(Boolean)
134    const chosen = it.options.filter(o => parts.some(p => norm(p) === norm(o)))
135    if (chosen.length > 0 && chosen.length === parts.length) return { ...it, chosen }
136  }
137  return { ...it, chosen: [], text: gist(value) }
138}
139
140// The desktop app's end-of-turn box with a question and buttons (wrap_up with `ask`): the call
141// returns at once and the turn ends, but the person is still asked. The notice stays until the next
142// prompt (prompt.submit drops it) or a wrap_up with no question. The recap is kept for the card.
143async function wrapUpAsk($: any, c: Call) {
144  if (String(c.e.tool) !== 'mcp__ccd_turn__wrap_up') return
145  const input = (c.e.input ?? c.e) as { ask?: unknown; recap?: unknown }
146  if (c.agent === MAIN && typeof input.recap === 'string' && input.recap.trim() !== '') await $.state.set(recapState, input.recap)
147  const key = `${c.agent}:wrap_up:ask`
148  await dropWaits($, w => w.key !== key)
149  // An earlier question of the box left with no answer is over: this wrap_up replaces it.
150  const asked = typeof input.ask === 'string' && input.ask.trim() !== ''
151  await changeAsks($, cur => cur.map(a => (a.wait === key && a.state === 'open' ? { ...a, state: 'dropped' } : a)))
152  if (asked) {
153    await addWait($, { agent: c.agent, kind: 'choice', tool: 'wrap_up', key })
154    // Kept as a question with the buttons as its options; the answer comes with the next prompt.
155    const actions = Array.isArray((input as any).actions) ? ((input as any).actions as { label?: unknown; message?: unknown }[]) : []
156    const options = actions.map(x => String(x.label ?? x.message ?? '')).filter(x => x !== '')
157    const messages = actions.map(x => String(x.message ?? x.label ?? ''))
158    const item: AskItem = { title: gist(String(input.ask)), whole: String(input.ask), options, messages }
159    const anchor = await askAnchor($, c.agent)
160    const at = await stamp($)
161    await changeAsks($, cur => [...cur, { key: String(c.e.tool_use_id ?? `${key}:${at ?? 0}`), wait: key, agent: c.agent, tool: 'wrap_up', at, anchor, items: [item], state: 'open' }])
162  }
163}
164
165// ---- after the call (the main loop's), each adding its notes to the result
166
167// A message to another session that was only queued: its task stays in progress until the session
168// takes it up (watchSends), and the panel says so.
169const sentMessage: Notes = async ($, c, notes) => {
170  const { e, r, sentAt } = c
171  if (String(e.tool) !== 'SendMessage') return
172  const out = JSON.stringify(r.result ?? r)
173  const to = String(e.to ?? e.input?.to ?? '')
174  // The task the message is sent from, once, before this send is recorded anywhere (a round, a send):
175  // with several tasks in progress it is the first one with no message yet (sendAnchor), and the
176  // records below must not move it to the next.
177  const { value: groups0 = [] } = await $.state.get(groupsState)
178  const { value: kids0 = [] } = await $.state.get(childrenState)
179  const { value: sends0 = [] } = await $.state.get(sendsState)
180  const { value: asks0 = [] } = await $.state.get(asksState)
181  const anchor = sendAnchor((groups0 as Group[]).find(g => g.agent === MAIN)?.tasks ?? [], usedAnchors(kids0 as Child[], sends0 as Send[]), askingAnchors(asks0 as Ask[]))
182  // The session is used for the task in progress: the summary view draws it there.
183  {
184    // Each message opens a round of the session, drawn in a card of its own under the task it was
185    // sent from once the session takes it up (see split). A session the host's list does not hold
186    // yet (just opened, or listed late) gets its entry here, with the round, as recordStart does:
187    // the round was lost, and the card of the turn the message opened had no task to go under.
188    const round = sentAt === undefined ? [] : [{ at: sentAt, anchor }]
189    if (to !== '') {
190      await update($, childrenState, cur => {
191        const list = cur ?? []
192        const mine = (ch: { id: string; name: string }) => ch.id === to || (ch.name !== '' && ch.name === to)
193        if (list.some(mine)) {
194          return list.map(ch => (mine(ch) ? { ...ch, ...(anchor !== undefined ? { lastAnchor: anchor } : {}), rounds: [...(ch.rounds ?? []), ...round].slice(-20) } : ch))
195        }
196        if (!to.startsWith('local_')) return list
197        return [...list, { id: to, name: '', title: 'Sessão…', running: true, archived: false, starting: true, anchor, ...(anchor !== undefined ? { lastAnchor: anchor } : {}), rounds: round }]
198      })
199    }
200  }
201  // Waiting for delivery: queued (or held) and not taken up yet. Only "its turn has started"
202  // confirms; a queued result may still say when it "will be delivered".
203  const waitsDelivery = /queued|held|pending/i.test(out) && !/turn has started/i.test(out)
204  // Every send is kept until the other session is seen taking it up (its published `received` or a
205  // turn of it after the send, see watchSends): its task shows ✉ meanwhile, also when the delivery
206  // was at once (the person's words: "nao esta aparecendo as tarefas com o icone de mensagem, para
207  // sinalizar que essa etapa esta em andamento"). A failed send keeps none.
208  const failed = r.isError === true || /success[\\"]*:\s*false/.test(out)
209  if (to !== '' && !failed) {
210    const at = sentAt ?? (await stamp($))
211    await update($, sendsState, cur => [
212      ...(cur ?? []).filter(s => !(s.to === to && s.anchor === anchor)),
213      { to, anchor, at, ...(waitsDelivery ? {} : { delivered: true }) },
214    ])
215    // The task it went from is a message task from now on (TaskNode.msg): kept on the task itself,
216    // since the send is dropped once taken up, and published with the panel for the card upstream.
217    if (anchor !== undefined) {
218      const mark = (list: TaskNode[]): TaskNode[] =>
219        list.map(t => (t.id === anchor ? (t.msg ? t : { ...t, msg: true }) : t.children.length > 0 ? { ...t, children: mark(t.children) } : t))
220      await change($, all => all.map(g => (g.agent === MAIN ? { ...g, tasks: mark(g.tasks) } : g)))
221    }
222    await publishPanel($)
223  }
224  if (waitsDelivery && to !== '') {
225    const all = flat((groups0 as Group[]).find(g => g.agent === MAIN)?.tasks ?? [])
226    const t = all.find(t => t.id === anchor)
227    notes.push(SEND.queued(to, t ? `"${t.id}" ${t.title}` : undefined))
228  }
229}
230
231// The sessions' reminders: one whose turn ended, one that took up a queued message.
232const sessionNotes: Notes = async ($, _c, notes) => {
233  const ended = await endedNote($)
234  if (ended) notes.push(ended)
235  const taken = await takenNote($)
236  if (taken) notes.push(taken)
237}
238
239// The end of a turn in the desktop app: what is still open is listed, so nothing finished is left
240// open (a task done through a session, one overtaken by the next); a turn with no summary is said.
241const wrapUpOpen: Notes = async ($, c, notes) => {
242  if (String(c.e.tool) !== 'mcp__ccd_turn__wrap_up') return
243  const { value = [] } = await $.state.get(groupsState)
244  const tasks = value.find((g: Group) => g.agent === MAIN)?.tasks ?? []
245  if ((await lastBrief($)) === undefined) notes.push(WRAP_UP.noBrief)
246  if (hasOpen(tasks)) notes.push(WRAP_UP.open(openList(tasks)))
247}
248
249// In this order, after the call (the kit wants each one called by name, so no list is walked).
250
251// The reminder to plan, in the result of the first call of the main loop with no open plan and of
252// every 10th after it; a call of the panel's own tools, or a request that planned, starts the count over.
253async function reminder($: any, c: Call, r: any) {
254  if (String(c.e.tool).startsWith('mcp__task-tree__')) {
255    reminders.unplanned = 0
256    return r
257  }
258  const { value = [] } = await $.state.get(groupsState)
259  if (reminders.planned || hasOpen(value.find((g: Group) => g.agent === MAIN)?.tasks ?? [])) {
260    reminders.unplanned = 0
261    return r
262  }
263  reminders.unplanned++
264  if (reminders.unplanned % 10 !== 1) return r
265  return { ...r, context: [...(r.context ?? []), UNPLANNED] }
266}
267
268// ---- the hook
269
270import type { EngineInterface } from 'claude-code'
271
272export async function toolCall($: EngineInterface, e: any, next: any) {
273  const c: Call = { agent: e.agentId ?? MAIN, e }
274  // A call of the main loop: its turn is running, what the last one left open is in progress again.
275  if (c.agent === MAIN) await wake($)
276  await launch($, c)
277  const answered = await asked($, c, next)
278  if (answered !== undefined) return answered
279  await wrapUpAsk($, c)
280  c.sentAt = String(e.tool) === 'SendMessage' ? await stamp($) : undefined
281  try {
282    c.r = await next(e)
283  } finally {
284    await dropWaits($, w => w.key !== `${c.agent}:${e.tool}`)
285  }
286  if (c.agent !== MAIN) return c.r
287  // After a reload the clock is gone with the old module: the next call starts it again.
288  await keepTime($)
289  watchSessions($)
290  await refreshStale($).catch(() => {})
291  if (String(e.tool) === 'mcp__ccd_session__start_session') {
292    const out = JSON.stringify(c.r.result ?? c.r)
293    await recordStart($, out)
294  }
295  if (c.r.deny !== undefined) return c.r
296  const notes: string[] = []
297  await sentMessage($, c, notes)
298  await sessionNotes($, c, notes)
299  await wrapUpOpen($, c, notes)
300  let r = c.r
301  if (notes.length > 0) r = { ...r, context: [...(r.context ?? []), ...notes] }
302  return reminder($, c, r)
303}
304
hooks/render.tsx 468 lines
1// ui.render: the pane, drawn from the state through layout() (../draw/layout, pure) and the SVG
2// builders (../draw/svg), with the Buttons laid over the lines where ../draw/geometry says; a drawing
3// that throws shows the error in one line. ui.press: the pane's presses, answered by key.
4
5import { atom, read, update } from 'claude-code'
6
7import type { Send } from '../types'
8import { lineWidth, overlays } from '../draw/geometry'
9import type { Placed } from '../draw/geometry'
10import { MAIN, flat, hasOpen, withMain, count, total, atWork, sessionTime, current, startedLast, layout, guides, labelCols, frames, split, newBreaksMemo, gitLabel } from '../draw/layout'
11import type { Row } from '../draw/layout'
12import { GLYPH, MSG_TERM, RING_LABEL, chips, clockText, gitAlt, gitTerm, headerSvg, hoverLayer, hovers, percent, prefix, progress, rateText, ratioBar, rightText, ringTerm, rowAlt, rowHeight, rowSvg } from '../draw/svg'
13import { FRAME, animate, barOf, chipIn, chipTones, fadeIn, growFrom, mark, pop } from '../draw/motion'
14import { debug } from '../io/log'
15import { PANE, DRAW_LIMIT, drawn, act, actions, ensureAsks, ensureGroups, ensureView, ensureWork, knownId, setView, stamp } from './panel'
16import { keepTime, openSession, watchSessions } from './sessions'
17
18// The pane's lines the person opened or closed (see setFold): the fold of a line, written from a
19// press (a render may not write).
20const folds = atom({ plugin: 'task-tree', key: 'collapsed' } as const, {} as Record<string, boolean>)
21async function setFold($: any, key: string, closed: boolean) {
22  await update($, folds, cur => ({ ...cur, [key]: closed }))
23}
24
25// The state's keys this file reads or writes, as literals: the kit's validator wants them written in the
26// file that calls $.state (../state/durability has a row for every one).
27const groupsState = { plugin: 'task-tree', key: 'groups' } as const
28const waitsState = { plugin: 'task-tree', key: 'waits' } as const
29const childrenState = { plugin: 'task-tree', key: 'children' } as const
30const childrenErrorState = { plugin: 'task-tree', key: 'childrenError' } as const
31const sendsState = { plugin: 'task-tree', key: 'sends' } as const
32const turnEndedState = { plugin: 'task-tree', key: 'turnEnded' } as const
33const viewState = { plugin: 'task-tree', key: 'view' } as const
34const workState = { plugin: 'task-tree', key: 'work' } as const
35const rateState = { plugin: 'task-tree', key: 'rate' } as const
36const gitState = { plugin: 'task-tree', key: 'git' } as const
37const asksState = { plugin: 'task-tree', key: 'asks' } as const
38
39// The two drawings of the lines layout() made, each from the rows alone (and the header the render
40// built): the desktop's Svgs with their Boxes, the terminal's text. `$` is for the presses.
41// No ▾/▸ (the person's words: "quero que o botao de colapse nao apareca, e que a funcao de colapsar
42// e expandir esteja no icone de progresso da tarefa"): a line with something below it folds and
43// opens by its progress icon, a plain Button (the pointer inverts it); a line with nothing below has
44// no Button. Terminal: the icon glyph is the Button. Desktop: the Svg draws the icon and a Button
45// with an empty label lies over it.
46const pressOf = ($: any, r: Row) => () => void setFold($, (r.fold as any).key, (r.fold as any).open)
47
48// Desktop: the header's Svg (its tooltip layer, the view toggle), then one Svg per line and, over it,
49// the Boxes `overlays` places (the geometry module's one rule: anchored by the nearest edge, none
50// crossing another): the Button that folds the line over its icon (on a session's card, the ⑂ left of
51// its name), the Button that opens the session over its context ring (the person's words: "quando
52// clicar nele, abra a sessao que ele representa"; the app shows it in this window's main pane,
53// open_session_in, focus, and says in a toast what it refuses), and the tooltip layer over a cut
54// name. A Box is placed in whole cells, the line's whole height, and centers its Button both ways;
55// the last one drawn is on top.
56function drawDesktop($: any, ui: any, lines: Row[], W: number, top: { source: string; height: number }, alt: string, chrome: { errorLine: any; views: any[]; now?: number; view?: string }) {
57  const { Box, Svg, Button } = ui
58  const keys = rowKeys(lines)
59  const lanes = guides(lines)
60  const hides = labelCols(lines)
61  const cards = frames(lines)
62  // What changed since the drawing before (../draw/motion): new lines fade in (together); a
63  // line whose state changed pops its icon; a bar whose count changed grows; a new chip fades in.
64  // The header's keys carry the view: each view's header has its own bar and chips.
65  const headKey = `head@${chrome.view ?? ''}`
66  const sources = lines.map((r, i) => rowSvg(r, lanes[i], cards[i], W, hides[i]))
67  const tones = chipTones(top.source)
68  const marks = mark(
69    [
70      ...keys.map(k => [k, ''] as [string, string]),
71      ...lines.map((r, i) => [`${keys[i]}#tone`, progress(r)] as [string, string]),
72      ...lines.flatMap((r, i) => (r.msg || r.recv ? [[`${keys[i]}#msg`, (r.msg ?? r.recv) as string] as [string, string]] : [])),
73      ...sources.map((s, i) => [`${keys[i]}#bar`, barOf(s)] as [string, string]),
74      ...tones.map(t => [`${headKey}#chip:${t}`, ''] as [string, string]),
75      [`${headKey}#bar`, barOf(top.source)],
76    ],
77    chrome.now,
78  )
79  // The icon pops only for a change of the line's own that lands somewhere new: a task with no
80  // subtasks when it starts or closes; a parent or a request only when it closes (✓), not on its way
81  // between in progress and waiting as its subtasks go; a session's card never.
82  const popsAt = (i: number) => {
83    const r = lines[i]
84    // A message task's ✉ pops when its color changes (queued → delivered → cut), as the icons do.
85    if (r.msg || r.recv) return marks.changed.has(`${keys[i]}#msg`)
86    const k = `${keys[i]}#tone`
87    if (!marks.changed.has(k) || r.card || r.session) return false
88    const to = progress(r)
89    if (r.leaf) return to === 'done' || to === 'interrupted' || (to === 'current' && marks.before.get(k) === 'open')
90    return to === 'done' && (r.key?.includes('t:') || r.key?.includes('T:'))
91  }
92  // A drawing with motion asks for the next frame (nextFrame): the mod draws the frames itself, since
93  // the app makes the Svgs again when it likes and an animation in them starts over each time.
94  let animated = false
95  const moving = (source: string, rules: string[]) => {
96    if (rules.length > 0) animated = true
97    return animate(source, rules)
98  }
99  const ago = (k: string) => marks.ago.get(k) ?? 0
100  const motion = (i: number) =>
101    marks.enter.has(keys[i])
102      ? [fadeIn(ago(keys[i]))]
103      : [
104          ...(popsAt(i) ? [lines[i].msg || lines[i].recv ? pop(false, ago(`${keys[i]}#msg`)) : pop(progress(lines[i]) === 'done', ago(`${keys[i]}#tone`))] : []),
105          ...(marks.changed.has(`${keys[i]}#bar`) && barOf(sources[i]) !== '' ? growFrom(marks.before.get(`${keys[i]}#bar`), barOf(sources[i]), ago(`${keys[i]}#bar`)) : []),
106        ]
107  const headMotion = [
108    ...tones.filter(t => marks.enter.has(`${headKey}#chip:${t}`)).map(t => chipIn(t, ago(`${headKey}#chip:${t}`))),
109    ...(marks.changed.has(`${headKey}#bar`) && barOf(top.source) !== '' ? growFrom(marks.before.get(`${headKey}#bar`), barOf(top.source), ago(`${headKey}#bar`)) : []),
110  ]
111  const over = (r: Row, p: Placed, source: string, H: number) =>
112    p.kind === 'icon' ? (
113      <Box position="absolute" top={0} bottom={0} left={p.left} width={p.width} flexDirection="row" alignItems="center" justifyContent="center">
114        <Button key={`fold:${(r.fold as any).key}`} label={' '} plain onPress={act(`fold:${(r.fold as any).key}`, pressOf($, r))} />
115      </Box>
116    ) : p.kind === 'ring' ? (
117      <Box position="absolute" top={0} bottom={0} right={p.right} width={p.width} flexDirection="row" alignItems="center" justifyContent="center">
118        <Button key={`open:${r.sid}`} label={RING_LABEL} plain onPress={act(`open:${r.sid}`, () => void openSession($, r.sid as string, r.title))} />
119      </Box>
120    ) : (
121      <Box position="absolute" top={0} left={p.left}>
122        <Svg source={hoverLayer(source, p.width, H)} alt="" width={p.width} height={H} isInteractive />
123      </Box>
124    )
125  const drawing = (
126    <Box flexDirection="column">
127      {chrome.errorLine}
128      <Box key="head" position="relative">
129        <Svg source={moving(top.source, headMotion)} alt={alt} width={W} height={top.height} />
130        {hovers(top.source) ? (
131          <Box position="absolute" top={0} left={0}>
132            <Svg source={hoverLayer(top.source, W - 64, 28)} alt="" width={W - 64} height={28} isInteractive />
133          </Box>
134        ) : null}
135        <Box position="absolute" top={0} right={0} flexDirection="row" alignItems="center">
136          {chrome.views}
137        </Box>
138      </Box>
139      {lines.map((r, i) => (
140        <Box key={`r${i}`} flexDirection="row" alignItems="center">
141          {(source => {
142            const H = rowHeight(r, cards[i])
143            const placed = overlays({ depth: r.depth, folds: !!r.fold, ring: !!(r.session && r.sid && r.ring), hover: hovers(source) }, Math.max(0, cards[i].cards.length - 1), W)
144            const one = (kind: Placed['kind']) => {
145              const p = placed.find(x => x.kind === kind)
146              return p ? over(r, p, source, H) : null
147            }
148            return (
149              <Box position="relative">
150                <Svg source={moving(source, motion(i))} alt={rowAlt(r)} width={W} height={H} />
151                {one('icon')}
152                {one('ring')}
153                {one('name')}
154              </Box>
155            )
156          })(sources[i])}
157        </Box>
158      ))}
159    </Box>
160  )
161  if (animated) nextFrame($)
162  return drawing
163}
164
165// The next frame of a drawing with motion (see drawDesktop and ../draw/motion), FRAME ms on: one
166// wait at a time. A drawing past DUR has no motion and asks for none, which ends the frames; the
167// clock's tick (keepTime) is another invalidate and lives beside these.
168let framing = false
169function nextFrame($: any) {
170  if (framing) return
171  framing = true
172  try {
173    $.clock.after(FRAME, () => {
174      framing = false
175      try {
176        $.ui.invalidate('ui.render')
177      } catch {}
178    })
179  } catch {
180    framing = false
181  }
182}
183
184// A line's name across drawings, for its motion: what the layout says it is (Row.key), the same in
185// every state; a second line of the same name gets a number.
186const rowKeys = (lines: Row[]) => {
187  const n = new Map<string, number>()
188  return lines.map(r => {
189    const k = r.key ?? `${r.depth}|${r.tone}|${r.title}`
190    const i = n.get(k) ?? 0
191    n.set(k, i + 1)
192    return i === 0 ? k : `${k}~${i}`
193  })
194}
195
196// Terminal: the same lines as text, the guides in box-drawing characters, color only on the icon
197// (the Button that folds, on a line with something below).
198function drawTerminal($: any, ui: any, lines: Row[], header: any[], errorLine: any) {
199  const { Box, Text, Button } = ui
200  const iconButton = (r: Row, glyph: string, color: string) => (
201    <Button key={`fold:${(r.fold as any).key}`} plain onPress={act(`fold:${(r.fold as any).key}`, pressOf($, r))}>
202      <Text color={color}>{glyph}</Text>
203    </Button>
204  )
205  const lanes = guides(lines)
206  const hides = labelCols(lines)
207  return (
208    <Box flexDirection="column">
209      {errorLine}
210      {header}
211      {lines.map((r, i) => {
212        // A message task: the ✉ in every state, in the color of where the message is.
213        const env = r.msg ?? r.recv
214        // A question waiting: a dotted circle in amber (the terminal has no balloon).
215        const [glyph, color] = env ? [GLYPH.sent[0], MSG_TERM[env]] : r.balloon ? ['◌', 'warning'] : GLYPH[progress(r)]
216        const forked = r.session === true || r.kids === true
217        const quiet = r.tone === 'done' || r.tone === 'interrupted' || r.tone === 'archived' || r.tone === 'section' || r.tone === 'reply'
218        const words = rightText(r)
219        const time = typeof r.time === 'number' ? clockText(r.time) : r.time === null ? '—' : ''
220        const tail = (forked ? `  ${GLYPH.running[0]}` : '') + (words ? `  ${words}` : '') + (time ? `  ${time}` : '')
221        const lead = prefix(r, lanes[i], hides[i])
222        // A session's label: the session glyph (the Button that folds the card) left of its name, then
223        // its tags, no count or time.
224        const parts = r.session ? [
225          r.fold ? iconButton(r, GLYPH.running[0], 'inactive') : <Text key="icon" color="inactive">{GLYPH.running[0]}</Text>,
226          <Text key="title" bold dimColor>{` ${r.title}`}</Text>,
227          r.fold && !r.fold.open ? <Text key="more" dimColor>{' ⋯'}</Text> : null,
228          // The repository and branch right of the name.
229          r.git ? <Text key="git" dimColor>{`  ${gitTerm(r.git)}`}</Text> : null,
230          // Waiting on the person: the word in amber (the label has no icon to color).
231          r.meta ? (
232            <Text key="meta" color={r.tone === 'wait' || r.meta === 'aguardando' ? 'warning' : undefined} dimColor={r.meta !== 'aguardando'}>{`  ${r.meta}`}</Text>
233          ) : null,
234          // The output rate, then the context ring last, on its own.
235          r.rate ? <Text key="rate" dimColor>{`  ${rateText(r.rate.toks)}`}</Text> : null,
236          r.ring ? <Text key="ring" color={ringTerm(r.ring.percent)}>{`  ${r.ring.percent}%`}</Text> : null,
237        ] : [
238          glyph ? r.fold ? iconButton(r, glyph, color) : <Text key="icon" color={color}>{glyph}</Text> : null,
239          <Text key="title" bold={r.strong} dimColor={quiet}>{glyph ? ` ${r.title}` : r.title}</Text>,
240          r.fold && !r.fold.open ? <Text key="more" dimColor>{' ⋯'}</Text> : null,
241          tail ? <Text key="tail" dimColor>{tail}</Text> : null,
242        ]
243        if (!r.fold) {
244          return (
245            <Text key={`r${i}`}>
246              {lead ? <Text color="inactive">{lead}</Text> : null}
247              {parts}
248            </Text>
249          )
250        }
251        return (
252          <Box key={`r${i}`} flexDirection="row">
253            {lead ? <Text color="inactive">{lead}</Text> : null}
254            {parts}
255          </Box>
256        )
257      })}
258    </Box>
259  )
260}
261
262// The pane's presses, answered by key (see actions), each one logged to the debug log.
263export async function press($: EngineInterface, e: any, next: any) {
264  if (e.plugin !== 'task-tree' || e.requestId !== PANE) return next(e)
265  const fn = actions.get(e.element)
266  await debug($, `task-tree: press ${e.element} (${e.surface})${fn ? '' : ': sem ação conhecida'}`)
267  if (!fn) return next(e)
268  fn()
269  return { element: e.element }
270}
271
272// A drawing that throws is not dropped in silence: the pane shows the error in one line, and the
273// session publishes it (drawn.error) for whoever looks into a panel that does not show up.
274
275import type { EngineInterface } from 'claude-code'
276export async function render($: EngineInterface, e: any, next: any) {
277 try {
278  drawn.error = undefined
279  const made = await (async () => {
280  const ui = $.ui.resolve(e) as any
281  const { Box, Text } = ui
282  // After a restart without session.start, the groups, the view and the work come from the store
283  // (see ensureGroups, ensureView, ensureWork): a drawing cannot write the state, so what they
284  // brought is used here.
285  const brought = await ensureGroups($)
286  const viewBrought = await ensureView($)
287  const workBrought = await ensureWork($)
288  const asksBrought = await ensureAsks($)
289  // After a hot reload of the mod (each saved edit of the clone reloads it in every session) the clocks
290  // were the old module's, gone with it, and they came back only with the next event (a tool call, a
291  // refresh, a start): with a task timing and no event, the pane stood still. The app draws the pane
292  // when the mod loads: that drawing starts them again (the 1 s clock only while something runs, and
293  // the reread of the sessions). Both do nothing while they run; the clock may be asked while drawing
294  // (only a state write is refused then).
295  await keepTime($).catch(() => {})
296  watchSessions($)
297  const { value: heldState } = await $.state.get(groupsState)
298  const held = heldState ?? brought
299  const { value: waits = [] } = await $.state.get(waitsState)
300  const { value: sessions = [] } = await $.state.get(childrenState)
301  const { value: sessionsError = '' } = await $.state.get(childrenErrorState)
302  // The sends still waiting to be taken up (see watchSends): "mensagem na fila".
303  const { value: sends = [] } = await $.state.get(sendsState)
304  const queued = (sends as Send[]).filter(s => !s.taken)
305  // The whole session's count, for the header's tooltip; the header itself counts what the
306  // view draws (see shown, below). The sessions are cut into their rounds' cards first, so a
307  // discarded round is left out here too (the clock is not needed for the cut).
308  const [ad, an] = total({ groups: withMain(held ?? []), waits, children: sessions }, 0, c =>
309    split(c, sessions, knownId(), withMain(held ?? []).find(g => g.agent === MAIN)?.tasks ?? [], undefined, newBreaksMemo()),
310  )
311  // The times are drawn against now; the pane's clock (keepTime) redraws them each second.
312  const now = await stamp($)
313  // What the person opened or closed.
314  const chosen = await read($, folds)
315  const { value: heldView } = await $.state.get(viewState)
316  const view = heldView ?? viewBrought ?? 'full'
317  const summary = view === 'summary'
318  const { value: heldWork } = await $.state.get(workState)
319  const work = heldWork ?? workBrought ?? []
320  // The lines, from the model and the person's choices (../draw/layout, pure): all the drawing
321  // below needs besides the width.
322  const { value: ended } = await $.state.get(turnEndedState)
323  // This session's own output rate ("n toks/s"), for its header (turn.step measures it).
324  const { value: rate } = await $.state.get(rateState)
325  const headRate = rate ? { toks: rate.toks, live: rate.live } : undefined
326  // Its repository and branch (refreshGit keeps them), for the header's count line.
327  const { value: git } = await $.state.get(gitState)
328  const headGit = git ? gitLabel(git) : undefined
329  // The questions asked, drawn under their tasks with their options.
330  const { value: heldAsks } = await $.state.get(asksState)
331  const asks = heldAsks ?? asksBrought ?? []
332  const model = { groups: held ?? [], waits, children: sessions, sends: queued, view, work: work as string[], asks, ...(typeof ended === 'number' ? { ended } : {}) }
333  const lines = layout(model, chosen, now, knownId())
334  drawn.rows = lines.length
335
336  // The header: the level-1 task in progress (or "Tarefas"), the count, and chips for what
337  // is in progress, waiting, interrupted and the sessions at work.
338  const mainTasks = withMain(held ?? []).find(g => g.agent === MAIN)?.tasks ?? []
339  // With none open, the level-1 task a session is still at work under. The task in progress now is the
340  // one started last (current()'s anchor: the task a notice goes under); its level-1 task is the title.
341  // It took the first of the lit tasks, the one started longest ago, and a task an earlier request left
342  // open and started won over the current one. current() is computed once (it walks the whole tree).
343  // With nothing in progress: the task started last (done or not; the person: "o título é o do passo
344  // iniciado por último"), then the most recent level-1 task with a session at work under it (by its
345  // current round, see atWork: an earlier round's task never), then the most recent request's.
346  const now0 = current(mainTasks).anchor ?? startedLast(mainTasks)?.id
347  const newest = [...mainTasks].sort((a, b) => (b.plannedAt ?? 0) - (a.plannedAt ?? 0))
348  const headTask =
349    (now0 !== undefined ? mainTasks.find(t => t.id === now0 || flat(t.children).some(k => k.id === now0)) : undefined) ??
350    newest.find(t => atWork(t, sessions, 0).live) ??
351    newest[0]
352  const head = headTask?.title ?? 'Tarefas'
353  // The header counts, times and chips what the view draws: the tasks the drawn lines stand
354  // for (Row.nodes), so the summary view's header is the summary's and the full view's, with its
355  // group limit, the drawn groups'. The whole session's count stays in the tooltip.
356  const shown = lines.flatMap(r => r.nodes ?? [])
357  const [d, n, x] = count(shown)
358  const tally = (pick: (r: Row) => boolean | undefined) => lines.filter(pick).length
359  const headTime = sessionTime(shown, tally(r => r.live) > 0 || hasOpen(shown), now)
360  // A notice is a wait; a session waiting counts only when it published no notice of its own.
361  const sentIds = queued.map(q => q.anchor ?? '')
362  // A task waiting on a session is not in progress: this session's (the queued sends' tasks) and
363  // the ones the lines show waiting (a card's ✉ task too), by the tasks the lines stand for.
364  const sentTasks = new Set(lines.filter(r => r.tone === 'sent').flatMap(r => r.nodes ?? []))
365  // Open leaves only, a parent never: started ones (◉) are in progress, the rest (○) pending.
366  // Message tasks are not work (they count in no x/y), and are left out of these chips too.
367  const open = flat(shown).filter(t => t.status === 'open' && t.children.length === 0 && !t.msg && !t.askState && !t.opt && !sentIds.includes(t.id) && !sentTasks.has(t))
368  const active = open.filter(t => t.startedAt !== undefined && t.endedAt === undefined).length
369  const pending = open.length - active
370  const whole = ad !== d || an !== n ? `sessão inteira: ${ad} de ${an}` : ''
371  const list = chips(active, tally(r => (r.tone === 'wait' && r.label === '') || r.pending), x, tally(r => r.live), tally(r => r.tone === 'sent'), pending)
372  const errorLine = sessionsError ? <Text key="error" dimColor>Sessões indisponíveis: {sessionsError}</Text> : null
373
374  const { Button } = ui
375
376  // The view toggle: two icon buttons at the right end of the header's title line, ≡ the
377  // summary (what is in progress), ▤ the whole tree. The chosen one is the surface's primary
378  // (highlighted) button, the other dim; on the terminal the chosen one is in brackets. A desktop
379  // draws a Button's `label` as its text, so the label is the glyph alone; which view is chosen
380  // is said in the header's alt.
381  const VIEWS = [
382    ['summary', '≡'],
383    ['full', '▤'],
384  ] as const
385  const views =
386    e.surface === 'desktop'
387      ? VIEWS.map(([v, glyph]) =>
388          view === v ? (
389            <Button key={`view:${v}`} label={glyph} variant="primary" onPress={act(`view:${v}`, () => void setView($, v))} />
390          ) : (
391            <Button key={`view:${v}`} label={glyph} plain dimColor onPress={act(`view:${v}`, () => void setView($, v))} />
392          ),
393        )
394      : VIEWS.flatMap(([v, glyph], i) => [
395          ...(i > 0 ? [<Text key="gap"> </Text>] : []),
396          <Button key={`view:${v}`} label={view === v ? `[${glyph}]` : glyph} plain dimColor={view !== v} onPress={act(`view:${v}`, () => void setView($, v))} />,
397        ])
398
399  if (e.surface === 'desktop' && 'Svg' in ui) {
400    const { Svg } = ui
401    const W = lineWidth((e.props as any).bodyColumns || 40)
402    const top = headerSvg(head, d, n, list, W, headTime, headRate, headGit)
403    const alt =
404      (n + x === 0
405        ? 'Nenhuma tarefa planejada'
406        : [
407            `${head}: ${d} de ${n} (${percent(d, n)}%)`,
408            ...(headTime !== undefined ? [clockText(headTime)] : []),
409            ...(headRate ? [`${rateText(headRate.toks)}${headRate.live ? '' : ' (último turno)'}`] : []),
410            ...(headGit ? [gitAlt(headGit)] : []),
411            ...list.map(c => c.text),
412          ].join(', ')) + (whole ? `; ${whole}` : '') + `. Visão: ${summary ? 'resumida' : 'completa'}, selecionada (≡ resumida, ▤ completa)`
413    return drawDesktop($, ui, lines, W, top, alt, { errorLine, views, now, view })
414  }
415
416  // Terminal: the same lines as text, the guides in box-drawing characters, color only on
417  // the icon; the ▾/▸ in the guides, before the icon.
418  const header =
419    n + x === 0
420      ? [
421          <Box key="head" flexDirection="row">
422            <Text dimColor>Nenhuma tarefa planejada.</Text>
423            <Text>{'  '}</Text>
424            {views}
425          </Box>,
426        ]
427      : [
428          <Box key="head" flexDirection="row">
429            <Text>
430              <Text bold>{head}</Text>
431              {`  ${d} de ${n} · ${percent(d, n)}%`}
432              {headTime !== undefined ? <Text dimColor>{`  ${clockText(headTime)}`}</Text> : null}
433              {headRate ? <Text dimColor>{`  ${rateText(headRate.toks)}`}</Text> : null}
434              {headGit ? <Text dimColor>{`  ${gitTerm(headGit)}`}</Text> : null}
435              {whole ? <Text dimColor>{`  (${whole})`}</Text> : null}
436            </Text>
437            <Text>{'  '}</Text>
438            {views}
439          </Box>,
440          <Text key="bar">
441            <Text color="success">{ratioBar(d, n, 20)[0]}</Text>
442            <Text color="inactive">{ratioBar(d, n, 20)[1]}</Text>
443            {list.map(c => (
444              <Text key={c.tone} color={GLYPH[c.tone][1]}>{`  ${c.tone === 'current' ? '●' : GLYPH[c.tone][0]} ${c.text.split(' ')[0]}`}</Text>
445            ))}
446          </Text>,
447        ]
448  return drawTerminal($, ui, lines, header, errorLine)
449  })()
450  // The drawing's size, said by /task-tree; past DRAW_LIMIT the app stopped showing the pane (a
451  // long session's history, 1.3 MB): an emergency drawing says so in one line instead.
452  drawn.size = JSON.stringify(made ?? null).length
453  if (drawn.size > DRAW_LIMIT) {
454    await debug($, `task-tree: desenho de ${Math.round(drawn.size / 1024)} KB, acima de ${Math.round(DRAW_LIMIT / 1024)} KB`)
455    const { Text } = $.ui.resolve(e) as any
456    return (
457      <Text color="warning">{`Painel grande demais (${Math.round(drawn.size / 1024)} KB): mostrando só o essencial. Use ≡ (/task-tree resumida) para a visão resumida.`}</Text>
458    )
459  }
460  return made
461 } catch (err) {
462    drawn.error = String((err as any)?.stack ?? err).slice(0, 600)
463    await debug($, `task-tree: erro ao desenhar o painel: ${drawn.error}`)
464    const { Text } = $.ui.resolve(e) as any
465    return <Text color="error">{`Painel de tarefas: erro ao desenhar: ${String(err).slice(0, 300)}`}</Text>
466  }
467}
468
draw/layout.ts 1331 lines
1// The panel's lines (Row[]) from its model: the pure half of the drawing. layout() turns the
2// state read once (groups, waits, the sessions this one opened, the sends still queued, the view
3// and the current work), the lines the person folded and the clock into the rows the desktop and
4// the terminal then draw; nothing here reads the state or the host. With it the model's helpers
5// (counts, times, sessions' cards split by message, the current work's cut) that the hooks module
6// uses too, imported from here.
7// Nothing is read behind the arguments' back: the clock is `now`, passed down to split; the cache of
8// the sessions' breaks is a BreaksMemo made by layout() for one drawing and passed down; the sends
9// still queued reach the task lines as `pending`, from the panel that holds them.
10
11import type { Ask, Child, Group, Panel, Send, TaskNode, Turn, Wait } from '../types'
12import { PAD } from './geometry'
13
14// The main loop's agent id in the groups.
15export const MAIN = ''
16
17export const flat = (list: TaskNode[]): TaskNode[] => list.flatMap(t => [t, ...flat(t.children)])
18export const tasksOf = (groups: Group[]) => groups.flatMap(g => g.tasks)
19export const hasOpen = (list: TaskNode[]) => flat(list).some(t => t.status === 'open')
20export const withMain = (groups: Group[]): Group[] =>
21  groups.some(g => g.agent === MAIN) ? groups : [{ agent: MAIN, label: '', tasks: [] }, ...groups]
22
23// A task's clock stops when it closes (`now` is undefined where the time cannot be read).
24export const stop = (t: TaskNode, now?: number) => (t.startedAt !== undefined ? (t.endedAt ?? now) : t.endedAt)
25
26// What a subagent left open when it ended.
27export const interrupt =
28  (now?: number) =>
29  (t: TaskNode): TaskNode =>
30    t.status === 'open'
31      ? { ...t, status: 'interrupted', started: undefined, endedAt: stop(t, now), children: t.children.map(interrupt(now)) }
32      : t
33
34// A turn that ended (or a session that ended its turn) touches only what was in progress: a task
35// started and not closed reads as interrupted, its clock stopped at `at`; a task never started stays
36// pending, the plan goes on in the next turn (the person's panel: four steps never started came out
37// "✕ interrompida"). An open parent keeps its pending subtasks, its clock stopped; with nothing open
38// left below it, it is interrupted too. Nothing lights up without start (current), so a halted
39// panel needs no other mark. A task with a message still queued (`sent`: the anchors of the sends
40// not taken up) is left as it is, open and started: it waits on that session, not on this turn,
41// and shows ✉ until the delivery (the person: "não mostre como interrompida, deixe como mensagem
42// até ela ser entregue"; it came out ✕ once the turn ended with the send still queued). With `all`,
43// the pending tasks read as interrupted too (the person: "reaproveitar o interrompida"): a session
44// that ended its turn (its card), and this session's own panel when nothing of it is going on, no
45// task started and open and no send queued; with something going on, the pending ones stay ○, the
46// work is not over. Only the ✉ is never interrupted.
47export const halt =
48  (at?: number, sent: string[] = [], all = false) =>
49  (t: TaskNode): TaskNode => {
50    if (t.status !== 'open') return t
51    if (t.children.length === 0) {
52      if (sent.includes(t.id)) return t
53      return all || t.started !== undefined || t.startedAt !== undefined ? interrupt(at)(t) : t
54    }
55    const children = t.children.map(halt(at, sent, all))
56    return hasOpen(children) ? { ...t, started: undefined, endedAt: stop(t, at), children } : interrupt(at)({ ...t, children })
57  }
58
59// A task whose clock runs: started and not closed.
60export const timing = (list: TaskNode[]) => flat(list).some(t => t.status === 'open' && t.startedAt !== undefined && t.endedAt === undefined)
61// From the first start to the last close, or to now while any task runs; undefined when no
62// task was started (or the time is unknown).
63export function span(list: TaskNode[], now?: number): number | undefined {
64  const all = flat(list).filter(t => t.startedAt !== undefined)
65  if (all.length === 0) return undefined
66  const from = Math.min(...all.map(t => t.startedAt as number))
67  const to = timing(list) ? now : Math.max(...all.map(t => t.endedAt ?? 0))
68  return to === undefined || to < from ? undefined : to - from
69}
70export const elapsed = (t: TaskNode, now?: number) => {
71  if (t.startedAt === undefined || t.status === 'interrupted') return undefined
72  const to = t.endedAt ?? (t.status === 'open' ? now : undefined)
73  return to === undefined || to < t.startedAt ? undefined : to - t.startedAt
74}
75
76// A task's own subtasks, leaving out what is drawn under it but is not its work: a question asked
77// there (askState) and a question's options (opt). A task with nothing else below it is a leaf.
78export const ownKids = (t: TaskNode) => (t.children.length === 0 ? t.children : t.children.filter(c => !c.askState && !c.opt))
79// The questions asked (Ask) drawn under the task they were asked from, as tasks of the drawing only:
80// one per question (`askState`; open, done once answered, interrupted with no answer), its options
81// below it (`opt`: picked ✓, the others ✕ once answered, ○ while open), and the answer typed
82// instead, "Resposta: …" ✓. A question whose task is not in these tasks is not drawn.
83export function withAsks(tasks: TaskNode[], asks: Ask[]): TaskNode[] {
84  if (asks.length === 0) return tasks
85  const nodes = (a: Ask): TaskNode[] =>
86    a.items.map((it, i) => {
87      const id = `?${a.key}:${i}`
88      const answered = a.state === 'answered' || (a.state === 'chat' && (it.text !== undefined || (it.chosen ?? []).length > 0))
89      const status = a.state === 'dropped' ? 'interrupted' : answered ? 'done' : 'open'
90      const chosen = it.chosen ?? []
91      const opts: TaskNode[] = it.options.map((o, j) => ({
92        id: `${id}:${j}`,
93        short: String(j + 1),
94        title: o,
95        status: status === 'open' ? 'open' : chosen.includes(o) ? 'done' : 'interrupted',
96        opt: true,
97        children: [],
98      }))
99      const said: TaskNode[] = it.text !== undefined ? [{ id: `${id}:r`, short: 'r', title: `Resposta: ${it.text}`, status: 'done', opt: true, children: [] }] : []
100      return { id, short: '?', title: it.title, status, askState: a.state, ...(it.whole ? { whole: it.whole } : {}), children: [...opts, ...said] } as TaskNode
101    })
102  const hang = (list: TaskNode[]): TaskNode[] =>
103    list.map(t => {
104      const mine = asks.filter(a => a.anchor === t.id)
105      const children = t.children.length > 0 ? hang(t.children) : t.children
106      return mine.length > 0 || children !== t.children ? { ...t, children: [...children, ...mine.flatMap(nodes)] } : t
107    })
108  return hang(tasks)
109}
110// A panel with its questions hung under their tasks, each group's own (by the agent that asked);
111// the wait a question stands for while open is not drawn apart.
112export function panelAsks(p: Panel, asks: Ask[] = p.asks ?? []): Panel {
113  if (asks.length === 0) return p
114  const open = new Set(asks.filter(a => a.state === 'open').flatMap(a => [a.key, a.wait ?? a.key]))
115  return {
116    ...p,
117    groups: (p.groups ?? []).map(g => ({ ...g, tasks: withAsks(g.tasks, asks.filter(a => a.agent === g.agent)) })),
118    waits: (p.waits ?? []).filter(w => !open.has(w.key)),
119  }
120}
121
122// Leaves done, leaves counted, leaves interrupted (kept out of the count). A message task counts for
123// nothing (only the work does; the leaves of the session it sent to still count, in its card).
124export function count(list: TaskNode[]): [number, number, number] {
125  let d = 0
126  let n = 0
127  let x = 0
128  for (const t of flat(list)) {
129    if (t.msg || t.opt || ownKids(t).length > 0) continue
130    if (t.status === 'interrupted') x++
131    else {
132      n++
133      if (t.status === 'done') d++
134    }
135  }
136  return [d, n, x]
137}
138
139// How deep the sessions opened by opened sessions are followed (in the count and the drawing).
140export const DEEP = 3
141// A session still at work: running, or waiting on the person.
142export const live = (c: Child) => !c.archived && (c.running || c.waiting === true)
143// A round of a session that was discarded (the person's words: "as tarefas dessa sessão foram
144// descartadas"): an earlier round's card (not the session's leading one, see split) left with a
145// task open when the next message began the round after it, or the leading round of a session whose
146// turn was stopped (aborted) with a task open. Its leaves, the ✓ ones too, count for nothing in the
147// lines above it and in the header; the card itself is drawn as it is. A session that merely ended
148// its turn with pending tasks is not discarded (its 3/5 reads 3/3, as before), nor an archived one
149// (history, its interrupted ones out of the total as before).
150export const discarded = (c: Child): boolean => {
151  if (c.archived || !hasOpen(tasksOf(rawPanel(c).groups ?? []))) return false
152  if (c.part !== undefined && !c.lead) return true
153  return !live(c) && c.aborted === true
154}
155// A session's panel; earlier versions published only its main group's tasks. An archived session
156// stays in the tree as history (its last published panel): what it left open will not be done,
157// so it reads as interrupted (out of the count's total, drawn gray). A session that ended its turn
158// (not at work, not just opened) is not working either: what it left open reads as interrupted too,
159// started or still pending, with no clock and out of "em andamento" and of "pendentes" (the person's
160// panel: a card "parada" with its tasks ◉ and the clock running, then with ○ pending ones and "3/5");
161// once it works again, they are its open tasks again, and a start or done there undoes the ✕.
162export const rawPanel = (c: Child): Panel => c.panel ?? { groups: [{ agent: MAIN, label: '', tasks: c.tasks ?? [] }], waits: [], children: [] }
163export const panelOf = (c: Child): Panel => {
164  const p = rawPanel(c)
165  if (!c.archived && (live(c) || c.starting)) return p
166  if (c.archived) return { ...p, waits: [], groups: (p.groups ?? []).map(g => ({ ...g, tasks: g.tasks.map(interrupt(undefined)) })) }
167  return { ...p, groups: (p.groups ?? []).map(g => ({ ...g, tasks: g.tasks.map(halt(undefined, [], true)) })) }
168}
169// A session in one card per message the main loop sent it (`rounds`): the card of a message holds
170// the turns the session opened after it (its tasks in plan order, the subagents, notices and
171// sessions under them) and goes under the task the message was sent from; the first card, the
172// turns from before any message, stays where the session was opened. A message not taken up yet
173// opened no turn, so it has no card (its task shows the queued envelope meanwhile). The card with
174// the session's work in progress (else the last) waits and shows its context; while the session
175// runs, every card with open tasks is drawn at work.
176// The messages that opened a round of `c`: those `me` (the session drawing it) sent, with the task
177// they were sent from, and those a sister session sent (its published `received`), drawn under
178// the task the sister's own card was under when it sent (followed down a chain of sisters).
179// `peer`: a sister's message; `known`: its origin was found (else drawn only after a while, see split).
180export type Break = { at: number; anchor?: string; peer?: boolean; known?: boolean; orphan?: boolean }
181// The messages `me` sent this session with no round recorded (the send was not seen: the panel
182// was off, or the mod reloaded or crashed then): each one the session took up (its published
183// `received`), matched in order against the rounds, opens a round all the same, or its turns would
184// join the round before it, under a task of an earlier request, drawn above requests between them
185// (the person's words: "layout quebrou novamente"). Its task is the main task of `me` that was open
186// when the message came (the one that sent it, waiting on it), else the one started last before it
187// (as a message sent between tasks is anchored); with none, no task: the card goes to the end of the
188// panel, never between other requests. A session with no round at all is matched the same way (it
189// returned nothing: the svg's turns from a message sent before the host listed it had no card).
190export function unsent(c: Child, me: string | undefined, owner: TaskNode[]): Break[] {
191  const rounds = [...(c.rounds ?? [])].sort((a, b) => a.at - b.at)
192  if (me === undefined) return []
193  const got = (c.panel?.received ?? [])
194    .filter(x => x.from === me && typeof x.at === 'number' && (rounds.length === 0 || x.at > rounds[0].at))
195    .map(x => x.at as number)
196    .sort((a, b) => a - b)
197  const out: Break[] = []
198  let i = 0
199  for (const at of got) {
200    // The earliest round not matched yet, sent before the message came: that send's message.
201    if (i < rounds.length && rounds[i].at <= at) {
202      i++
203      continue
204    }
205    const before = flat(owner)
206      .filter(t => typeof t.startedAt === 'number' && t.startedAt <= at)
207      .sort((a, b) => (b.startedAt as number) - (a.startedAt as number))
208    const open = before.find(t => t.endedAt === undefined || t.endedAt >= at) ?? before[0]
209    out.push({ at, anchor: open?.id, orphan: true })
210  }
211  return out
212}
213
214// The task an anchor names, as of a moment: the plans reuse level-1 ids between requests, and a
215// closed task whose id a new plan takes is renamed (`tag:id`, see planTool), so a tie recorded before
216// the rename (a published file from an earlier version, a store from before this rule) still holds
217// the bare id, which now names the new task. Only that case is touched: when the task holding the
218// id was planned after the moment and a renamed one (`*:id`) planned before it exists, the anchor
219// is the last of those planned before the moment (the one that held the id then). Everything else
220// (the id's task planned before the moment, no plan times, no renamed holder) stays as it is.
221export function resolveAnchor(owner: TaskNode[], anchor: string | undefined, at: number | undefined): string | undefined {
222  if (anchor === undefined || at === undefined) return anchor
223  const all = flat(owner)
224  const exact = all.find(t => t.id === anchor)
225  if (exact === undefined || exact.plannedAt === undefined || exact.plannedAt <= at) return anchor
226  const suffix = ':' + anchor
227  const held = all
228    .filter(t => t.id.endsWith(suffix) && t.id.length > suffix.length && !t.id.slice(0, -suffix.length).includes(':') && t.plannedAt !== undefined && t.plannedAt <= at)
229    .sort((a, b) => (a.plannedAt as number) - (b.plannedAt as number))
230  const last = held[held.length - 1]
231  return last?.id ?? anchor
232}
233
234// Each session's breaks once per list of sisters (one drawing): the chain follows a sister's own
235// breaks for every message it sent, and sisters message each other in a ring (implementation →
236// docs → SVG → governance → implementation). Recomputed per message, with up to 20 kept each, that
237// was 20^5 calls per card on every redraw, and it crashed the hooks worker. A session met again
238// inside its own chain counts with its own rounds only.
239// The cache lives for one drawing (layout() makes it and passes it down): `hits` by an explicit key,
240// the list of sisters (numbered as met, `lists`), the drawing session, the path and the card.
241export type BreaksMemo = { lists: WeakMap<Child[], number>; next: number; hits: Map<string, Break[]> }
242export const newBreaksMemo = (): BreaksMemo => ({ lists: new WeakMap(), next: 0, hits: new Map() })
243export function breaks(c: Child, sisters: Child[], me: string | undefined, seen: string[], owner: TaskNode[], memo: BreaksMemo): Break[] {
244  let list = memo.lists.get(sisters)
245  if (list === undefined) memo.lists.set(sisters, (list = memo.next++))
246  // The path is in the key: a session met again inside its chain is cut there, and only there.
247  const key = `${list}|${me ?? ''}|${seen.join(',')}|${c.id}|${c.part ?? ''}`
248  const hit = memo.hits.get(key)
249  if (hit !== undefined) return hit
250  const own: Break[] = [...(c.rounds ?? []).map(r => ({ at: r.at, anchor: resolveAnchor(owner, r.anchor, r.at) })), ...unsent(c, me, owner)]
251  if (seen.includes(c.id) || seen.length > 4) return own
252  const path = [...seen, c.id]
253  const sisterBreaks = new Map<string, Break[]>()
254  const peer: Break[] = (c.panel?.received ?? []).flatMap(x => {
255          const from = sisters.find(k => k.id === x.from && k.id !== c.id)
256          if (from === undefined || x.from === me || typeof x.at !== 'number') return []
257          // The sister sent it in one of its turns: the message belongs to that turn's round (the one
258          // the turn opened after), not to the last round before it arrived, which may be a later
259          // message the sister had not taken up yet.
260          let fb = sisterBreaks.get(from.id)
261          if (fb === undefined) sisterBreaks.set(from.id, (fb = breaks(from, sisters, me, path, owner, memo)))
262          const fmain = withMain(from.panel?.groups ?? []).find(g => g.agent === MAIN)
263          const fturns = fmain?.turns ?? []
264          // First, the sister's own sending task, while its ✉ is up (its published `sends`): the turn
265          // that task was planned in. Else the sister's last turn before the message came.
266          const out = (from.panel?.sends ?? []).filter(s => (s.to === c.id || (c.name !== '' && s.to === c.name)) && (s.at ?? 0) <= x.at).pop()
267          const task = out?.anchor ? fmain?.tasks.find(t => flat([t]).some(k => k.id === out.anchor)) : undefined
268          const viaSend = task?.turn !== undefined ? fturns.find(u => u.id === task.turn)?.at : undefined
269          const sent = fturns.filter(u => typeof u.at === 'number' && (u.at as number) <= x.at).pop()
270          const when = viaSend ?? sent?.at
271          const before = when === undefined ? [] : fb.filter(b => b.at <= when)
272          const last = before[before.length - 1]
273          return [{ at: x.at, anchor: last ? last.anchor : resolveAnchor(owner, from.anchor, begins(from)), peer: true, known: when !== undefined && (last !== undefined || from.anchor !== undefined) }]
274        })
275  const out = [...own, ...peer].sort((a, b) => a.at - b.at)
276  memo.hits.set(key, out)
277  return out
278}
279
280// When a card's work began: its first turn (the message it was opened or sent with), else its
281// first planned or started task. Cards under one task are drawn in this order, the order the work
282// ran in (implementation, then the sessions it passed it on to), not grouped by session.
283export function begins(c: Child): number {
284  const p = panelOf(c)
285  const main = withMain(p.groups ?? []).find(g => g.agent === MAIN)
286  const ats = [
287    ...(main?.turns ?? []).flatMap(u => (typeof u.at === 'number' ? [u.at] : [])),
288    ...flat(tasksOf(p.groups ?? [])).flatMap(t => [t.plannedAt, t.startedAt].filter((x): x is number => typeof x === 'number')),
289  ]
290  return ats.length > 0 ? Math.min(...ats) : Infinity
291}
292export const inOrder = (list: Child[]) =>
293  list
294    .map((c, i) => ({ c, i, at: begins(c) }))
295    .sort((a, b) => (a.at === b.at ? a.i - b.i : a.at - b.at))
296    .map(x => x.c)
297
298// While a sister's message has no known origin yet (the sister's state not read yet), its card is
299// not drawn, for up to WAIT_ORIGIN ms: it would show at the root and jump into place on the next read.
300export const WAIT_ORIGIN = 10000
301// `now`: the drawing's clock (undefined where it cannot be read); `memo`: the drawing's cache of breaks.
302export function split(c0: Child, sisters: Child[], me: string | undefined, owner: TaskNode[], now: number | undefined, memo: BreaksMemo): Child[] {
303  // A card already cut to one round (the summary view's cut() splits the sessions before the pass
304  // that draws them) is kept as it is, with no breaks computed for it.
305  if (c0.part !== undefined) return [c0]
306  // The session's ties, as of when they were made: where it was opened (by when its work began)
307  // and where its last message came from (by that message), see resolveAnchor.
308  const lastRound = [...(c0.rounds ?? [])].sort((a, b) => a.at - b.at).pop()
309  const opened = begins(c0)
310  const c: Child = {
311    ...c0,
312    anchor: resolveAnchor(owner, c0.anchor, Number.isFinite(opened) ? opened : undefined),
313    lastAnchor: resolveAnchor(owner, c0.lastAnchor, lastRound?.at ?? (Number.isFinite(opened) ? opened : undefined)),
314  }
315  const rounds = breaks(c, sisters, me, [], owner, memo)
316  const p = c.panel
317  if (p === undefined) return [c]
318  const groups = withMain(p.groups ?? [])
319  const main = groups.find(g => g.agent === MAIN) as Group
320  // The turns the person opened by typing in that session (`own`): each one a card of its own, at
321  // the end of the request that opened the session (the person: "turno aberto direto pelo usuário
322  // na filha vira cartão próprio, sem âncora, no fim do pedido de origem"), not under the task of
323  // the last message this session sent (work it did not ask for hung from "Reverter o commit…").
324  const ownTurns = (main.turns ?? []).filter(u => u.own === true)
325  if (rounds.length === 0 && ownTurns.length === 0) return [c]
326  const openedIn = owner.find(t => t.id === c.anchor || flat(t.children).some(k => k.id === c.anchor))
327  const originEnd = openedIn?.turn === undefined ? undefined : [...owner].reverse().find(t => t.turn === openedIn.turn)?.id
328  const turns = main.turns ?? []
329  const roundOf = (u: Turn) => rounds.reduce((k, r, i) => (u.at !== undefined && u.at >= r.at ? i + 1 : k), 0)
330  // The part (round) of each turn, once per turn; a task with no turn, or with a turn not listed, is
331  // in part 0 (the card before the first round), as before.
332  const partOfTurn = new Map<number, number>()
333  // Messages of `me` queued while the session worked are delivered together and open one turn: the
334  // work starts after the last of them, so that turn goes to the round of the last one (the person:
335  // "as lineares precisam seguir uma linha do tempo"); the earlier ones close with the delivery, with
336  // no card. A turn that got messages of `me` (its published `received` has some since the turn
337  // before) takes the last round of `me` sent since the turn before; any other turn (opened by a
338  // sister, or older than what `received` keeps) goes to the last round before it, as before.
339  const got = (p.received ?? []).filter(x => x.from === me && typeof x.at === 'number').map(x => x.at as number)
340  let before = -Infinity
341  for (const u of [...turns].sort((a, b) => (a.at ?? 0) - (b.at ?? 0))) {
342    if (partOfTurn.has(u.id)) continue
343    if (u.own === true) {
344      // Its own part, keyed below zero (one per turn), apart from the rounds.
345      partOfTurn.set(u.id, -1 - ownTurns.findIndex(o => o.id === u.id))
346      if (u.at !== undefined) before = u.at
347      continue
348    }
349    let k = roundOf(u)
350    if (u.at !== undefined && got.some(x => x > before && x <= (u.at as number))) {
351      let last = -1
352      rounds.forEach((r, i) => {
353        if (!r.peer && r.at > before && r.at <= (u.at as number)) last = i
354      })
355      if (last >= 0) k = last + 1
356    }
357    partOfTurn.set(u.id, k)
358    if (u.at !== undefined) before = u.at
359  }
360  const turnPart = (id?: number) => (id === undefined ? 0 : (partOfTurn.get(id) ?? 0))
361  // The level-1 tasks of each part, in the list's order, and all the tasks under them (flat), once.
362  const tasksByPart = new Map<number, TaskNode[]>()
363  for (const t of main.tasks) {
364    const k = turnPart(t.turn)
365    const list = tasksByPart.get(k)
366    if (list) list.push(t)
367    else tasksByPart.set(k, [t])
368  }
369  const parts = [...tasksByPart.keys()].sort((a, b) => a - b)
370  if (parts.length < 2 && (parts[0] ?? 0) === 0) return [c]
371  const tasksIn = (k: number) => tasksByPart.get(k) ?? []
372  const flatIn = new Map(parts.map(k => [k, flat(tasksIn(k))] as const))
373  const busy = parts.find(k => (flatIn.get(k) ?? []).some(t => t.status === 'open' && t.started !== undefined))
374  const lead = busy ?? parts[parts.length - 1]
375  // What hangs from a task goes to that task's card (the first part holding the task, in part order,
376  // as the search was); with no such task, to the leading card.
377  const partOfTask = new Map<string, number>()
378  for (const k of parts) for (const t of flatIn.get(k) ?? []) if (!partOfTask.has(t.id)) partOfTask.set(t.id, k)
379  const holder = (anchor?: string) => (anchor === undefined ? lead : (partOfTask.get(anchor) ?? lead))
380  // The part a group's or a wait's agent belongs to, once per agent (the main group's anchor is the
381  // task in progress, found once).
382  const owners = new Map<string, number>()
383  const ownerOf = (agent: string) => {
384    let k = owners.get(agent)
385    if (k === undefined) {
386      k = agent === MAIN ? holder(current(main.tasks).anchor) : holder(groups.find(g => g.agent === agent)?.anchor)
387      owners.set(agent, k)
388    }
389    return k
390  }
391  const waiting = (k: number) => {
392    const b = k > 0 ? rounds[k - 1] : undefined
393    return b?.peer === true && !b.known && now !== undefined && now - b.at < WAIT_ORIGIN
394  }
395  return parts.filter(k => !waiting(k)).map(k => {
396    const tasks = tasksIn(k)
397    const ids = new Set(tasks.map(t => t.id))
398    const turnsOf = new Set(tasks.map(t => t.turn))
399    const at = k === lead
400    return {
401      ...c,
402      part: k,
403      lead: at,
404      ...(k > 0 && rounds[k - 1] !== undefined ? { partAt: rounds[k - 1].at } : k < 0 && ownTurns[-1 - k]?.at !== undefined ? { partAt: ownTurns[-1 - k].at } : {}),
405      anchor: k < 0 ? originEnd : k === 0 ? c.anchor : rounds[k - 1]?.orphan || rounds[k - 1]?.peer ? rounds[k - 1]?.anchor : (rounds[k - 1]?.anchor ?? c.anchor),
406      lastAnchor: at ? c.lastAnchor : undefined,
407      // A card with open tasks of a session at work is at work too (the new group waits its turn).
408      running: c.running && (at || hasOpen(tasks)),
409      waiting: at ? c.waiting : false,
410      // Every card shows the session's ring, at work or not: its last reported share (the person's words:
411      // "mesmo depois que a sessao termina, o icone se contexto precisa continuar aparecendo").
412      context: c.context,
413      tasks: undefined,
414      panel: {
415        ...p,
416        groups: groups
417          .map(g => (g.agent === MAIN ? { ...g, tasks, turns: turns.filter(u => turnsOf.has(u.id)) } : g))
418          .filter(g => g.agent === MAIN || ownerOf(g.agent) === k),
419        waits: (p.waits ?? []).filter(w => ownerOf(w.agent) === k),
420        children: (p.children ?? []).filter(s => holder(s.anchor) === k),
421        ...(p.work ? { work: p.work.filter(id => ids.has(id)) } : {}),
422      },
423    }
424  })
425}
426
427// The count of a panel with its sessions' tasks: they are part of the tree, under the task
428// they were opened on (an archived one too, as history); a discarded round counts for nothing.
429// `splitter` cuts a raw session into its rounds' cards first (split), so an abandoned round is told
430// apart; without it, only a stopped session's own abort is.
431export function total(p: Panel, level = 0, splitter?: (c: Child) => Child[]): [number, number, number] {
432  let [d, n, x] = count(tasksOf(p.groups ?? []))
433  if (level >= DEEP) return [d, n, x]
434  for (const c of (p.children ?? []).flatMap(c => (splitter ? splitter(c) : [c]))) {
435    if (discarded(c)) continue
436    const [cd, cn, cx] = total(panelOf(c), level + 1)
437    d += cd
438    n += cn
439    x += cx
440  }
441  return [d, n, x]
442}
443
444// The sessions opened on a task or below it (archived ones as history): their leaves add to its
445// count, closed task or not (the session is a link, not a task: the header counts them the same).
446// The open ones add to its time, and while one is still at work on its current round there (`busy`)
447// the task is drawn in progress, its clock running, whatever its own status says: a task closed with
448// done (the send's, closed on delivery) stays ◉ with its parents and request until the session
449// stops, then reads ✓ again (the person's rule). A session opened on a closed task adds no time; `any` says one hangs there or below (`direct`: from this very task, where the icon goes), `live` that one is still at work there, and
450// `cards` holds all their tasks, for a folded line (it stands for what is drawn below it, the cards too).
451// `Ring`: a session's share of its context used (see contextRing), on its card's label.
452// `faded`: an archived session's last share, drawn dim.
453export type Ring = { percent: number; name: string; faded?: boolean }
454export const ringOf = (c: Child): Ring | undefined =>
455  typeof c.context === 'number' ? { percent: c.context, name: c.name || c.title, ...(c.archived ? { faded: true } : {}) } : undefined
456// `direct`: a session's card hangs from this very task (opened on it, or its current round there),
457// not from one below it: only that line gets the session icon (the person's words: "somente a
458// tarefa que agrupa imediatamente acima deve mostrar o ícone de sessão"). `open`: a session below
459// has a task still open (for the icon's state).
460export type Work = { busy: boolean; any: boolean; direct: boolean; live: boolean; open: boolean; d: number; n: number; tasks: TaskNode[]; cards: TaskNode[] }
461export const idle: Work = { busy: false, any: false, direct: false, live: false, open: false, d: 0, n: 0, tasks: [], cards: [] }
462// A panel's tasks with its sessions' (archived ones too), as deep as the count follows them.
463export function allTasks(p: Panel, level: number): TaskNode[] {
464  const own = tasksOf(p.groups ?? [])
465  if (level >= DEEP) return own
466  return own.concat(...(p.children ?? []).map(c => allTasks(panelOf(c), level + 1)))
467}
468// Where a session's current round is (see atWork): none for a card of an earlier round.
469export const roundAt = (c: Child) => (c.part === undefined ? (c.lastAnchor ?? c.anchor) : c.lastAnchor)
470export function atWork(t: TaskNode, sessions: Child[], level: number): Work {
471  const ids = flat([t]).map(k => k.id)
472  const below = sessions.filter(c => c.anchor !== undefined && ids.includes(c.anchor))
473  const open = (k: TaskNode): string[] => (k.status === 'done' ? [] : [k.id, ...k.children.flatMap(open)])
474  const counted = open(t)
475  const mine = below.filter(c => counted.includes(c.anchor as string))
476  // At work, a session is where its current round is: its last message's task (lastAnchor), not
477  // where it was opened nor where an earlier round was. A card cut to one round counts only if it is
478  // the session's leading one (the one that keeps lastAnchor); an earlier round's card never does.
479  // Else every task it ever worked under came back as in progress while it ran (the summary view
480  // pulled old requests in, and the header took the first one's title).
481  const here = sessions.filter(c => {
482    const a = roundAt(c)
483    return a !== undefined && ids.includes(a)
484  })
485  // In progress while a session works on its current round anywhere in the subtree, the task closed
486  // or not (the person's words: "as linhas pai anteriores somente deveriam ficar como concluídas,
487  // quando as filhas fossem concluídas"): the send's task is closed on delivery and by the main
488  // loop's done, and its line, its parents and its request came out ✓ (or ◷) with the session still
489  // at work below. Once it stops, the line shows its own status again.
490  const hereOpen = here
491  // A discarded round (discarded) adds nothing to the count nor to what the line stands for; it
492  // still marks the line (`any`) and its card is drawn.
493  const kept = below.filter(c => !discarded(c))
494  let d = 0
495  let n = 0
496  for (const c of kept) {
497    const [cd, cn] = total(panelOf(c), level + 1)
498    d += cd
499    n += cn
500  }
501  const tasks = mine.flatMap(c => allTasks(panelOf(c), level + 1))
502  const cards = kept.flatMap(c => allTasks(panelOf(c), level + 1))
503  const direct = sessions.some(c => c.anchor === t.id || roundAt(c) === t.id)
504  const left = [...below, ...here].some(c => hasOpen(tasksOf(rawPanel(c).groups ?? [])))
505  return { busy: hereOpen.some(live), any: below.length > 0 || here.length > 0, direct, live: here.some(live), open: left, d, n, tasks, cards }
506}
507// An open parent with something done below it and nothing in progress: drawn waiting (◷), its
508// time stopped at the last close below it.
509export const resting = (list: TaskNode[]) => flat(list).some(k => k.status === 'done' && !k.askState && !k.opt)
510export function stillSpan(list: TaskNode[]): number | undefined {
511  const all = flat(list)
512  const starts = all.flatMap(t => (t.startedAt ?? t.plannedAt) ?? [])
513  const ends = all.flatMap(t => t.endedAt ?? [])
514  if (starts.length === 0 || ends.length === 0) return undefined
515  const span = Math.max(...ends) - Math.min(...starts)
516  return span < 0 ? undefined : span
517}
518
519// A task's time: from the first start to the last close of it, its subtasks and its sessions'
520// tasks, or to now while any of them runs or a session below it is at work.
521export function timeOf(t: TaskNode, w: Work, now?: number): number | undefined {
522  return timeSpan([t, ...w.tasks], w.busy, now)
523}
524export function timeSpan(list: TaskNode[], busy: boolean, now?: number): number | undefined {
525  if (!busy) return span(list, now)
526  const starts = flat(list).flatMap(k => (k.startedAt === undefined ? [] : [k.startedAt]))
527  return starts.length === 0 || now === undefined ? undefined : now - Math.min(...starts)
528}
529
530// Every open task with no subtasks (◉ or ○ leaves), in every group of the panel and in its
531// sessions not archived (as deep as the count goes), so it is M − N of the "N de M" (interrupted
532// ones are out of both); none means nothing is open (nothingOpen). The ones waiting on a session (`sent`, main group
533// here) have their own chip and are left out of this one.
534export function activeLeaves(p: Panel, level: number, sent: string[] = []): number {
535  let k = 0
536  for (const g of withMain(p.groups ?? [])) {
537    k += flat(g.tasks).filter(
538      t => t.status === 'open' && t.children.length === 0 && !(g.agent === MAIN && sent.includes(t.id)),
539    ).length
540  }
541  if (level >= DEEP) return k
542  for (const c of p.children ?? []) if (!c.archived) k += activeLeaves(panelOf(c), level + 1)
543  return k
544}
545
546// A whole session's time: starts are a task's start, else its plan.
547export function sessionTime(list: TaskNode[], running: boolean, now?: number): number | undefined {
548  const all = flat(list)
549  const starts = all.flatMap(t => (t.startedAt ?? t.plannedAt) ?? [])
550  if (starts.length === 0) return undefined
551  const from = Math.min(...starts)
552  const ends = all.flatMap(t => t.endedAt ?? [])
553  const to = running ? now : ends.length > 0 ? Math.max(...ends) : undefined
554  return to === undefined || to < from ? undefined : to - from
555}
556
557export function firstOpen(items: TaskNode[]): string[] {
558  for (const t of items) {
559    // A question asked under a task, and its options, are not the work in progress.
560    if (t.status !== 'open' || t.askState || t.opt) continue
561    if (ownKids(t).length === 0) return [t.id]
562    const rest = firstOpen(t.children)
563    if (rest.length > 0) return [t.id, ...rest]
564  }
565  return []
566}
567// The task started last (its startedAt the latest; done or not): where a message sent with nothing
568// in progress is anchored, and what names the header with nothing in progress. On a tie (a parent
569// starts with its child) the later one in the tree wins, the child.
570export function startedLast(list: TaskNode[]): TaskNode | undefined {
571  let pick: TaskNode | undefined
572  for (const t of flat(list)) {
573    if (typeof t.startedAt !== 'number') continue
574    if (pick === undefined || t.startedAt >= (pick.startedAt as number)) pick = t
575  }
576  return pick
577}
578export function pathTo(items: TaskNode[], id: string): string[] {
579  for (const t of items) {
580    if (t.id === id) return [t.id]
581    const rest = pathTo(t.children, id)
582    if (rest.length > 0) return [t.id, ...rest]
583  }
584  return []
585}
586
587// The tasks drawn in yellow, with their parents, and the one a waiting notice goes
588// under: the tasks in progress (the last one started for the notice; a parent's first
589// open task). With none started, nothing: a task is in progress only once the model calls start
590// (the person's decision after the visual test: "sem start, nada aceso"). The first open task used
591// to light up by default, and a message sent between two tasks was anchored to the next pending
592// one, which then closed on delivery; a never-started step ran a clock.
593export function current(list: TaskNode[]): { lit: string[]; anchor?: string } {
594  const started = flat(list)
595    .filter(t => t.status === 'open' && t.started !== undefined)
596    .sort((a, b) => (a.started ?? 0) - (b.started ?? 0))
597  if (started.length === 0) return { lit: [], anchor: undefined }
598  let lit: string[] = []
599  let anchor: string | undefined
600  for (const s of started) {
601    const inner = firstOpen(s.children)
602    lit = lit.concat(pathTo(list, s.id), inner)
603    anchor = inner.length > 0 ? inner[inner.length - 1] : s.id
604  }
605  return { lit, anchor }
606}
607
608export const waitText = (w: Wait) =>
609  w.kind === 'question'
610    ? 'Aguardando sua resposta'
611    : w.kind === 'choice'
612      ? 'Aguardando sua escolha'
613      : w.kind === 'plan'
614      ? 'Aguardando aprovação do plano'
615      : `Aguardando permissão: ${w.tool}`
616
617// How many requests (message groups) the top panel draws, the latest ones.
618export const SHOWN_TURNS = 3
619// And in a session's card, the last request of that card (its round).
620export const SHOWN_CARD_TURNS = 3
621
622// The most lines a drawing holds, decided on the rows before any SVG is made: past it, whole blocks
623// (a level-0 line and what hangs under it) go from the start, the oldest, and one line counts them;
624// the recent ones, where the work is, stay. The size limit above stays as the last resort. Far
625// above a real panel (a long session drew 31 lines); the open steps of a huge plan reached it.
626export const MAX_ROWS = 400
627export function limitRows(rows: Row[]): Row[] {
628  if (rows.length <= MAX_ROWS) return rows
629  const room = MAX_ROWS - 1
630  const before = (n: number): Row => ({ key: 'sec:before', depth: 0, tone: 'section', title: `+${n} linhas anteriores`, label: 'Linhas anteriores, não desenhadas' })
631  const from = rows.findIndex((r, i) => r.depth === 0 && i > 0 && rows.length - i <= room)
632  if (from > 0) return [before(from), ...rows.slice(from)]
633  // The last block alone is past the limit: what came before it goes whole, and the block is drawn
634  // to the limit, the rest counted at its end.
635  let last = 0
636  for (let i = rows.length - 1; i > 0; i--) {
637    if (rows[i].depth === 0) {
638      last = i
639      break
640    }
641  }
642  const head = last > 0 ? [before(last)] : []
643  const kept = rows.slice(last, last + room - head.length)
644  return [...head, ...kept, { key: 'sec:after', depth: 1, tone: 'section', title: `+${rows.length - last - kept.length} linhas`, label: 'Linhas seguintes, não desenhadas' }]
645}
646
647// A session's panel cut to its current work, for the summary view: its main level-1 tasks of the
648// work (all those of the turns they are in), the subagents and sessions under them or still at
649// work, and the same cut on those sessions. With no work published (earlier versions), the last
650// turn that has tasks; with no turns either, the last open level-1 task, else the last one.
651// Whether anything in the summary's sight is still open: the current work (`ids`, level-1 tasks of the
652// main group) with the other level-1 tasks of their requests, a message not yet taken up from one of
653// them (✉), or a session with a round under one of them (where it was opened, or any message sent to
654// it from there) that is still at work or has an open task (as the card draws it, panelOf: a stopped
655// session's started tasks read interrupted, its pending ones stay open). The person's rules for the
656// summary: "as tarefas concluídas deveriam ficar do jeito que estão até todas as tarefas estarem
657// concluídas, só então o painel seria resetado com um novo turno; senão, as tarefas vão sendo
658// adicionadas no painel", and "as tarefas da visão resumida somente são resetadas quando a sessão
659// terminou e todas as tarefas estão concluídas". A new request with something open in sight keeps
660// everything in sight, done requests included; with nothing open, it clears the summary (joinWork).
661// It used to keep only the level-1 tasks with something open of their own, so a done request left
662// while another was still open, and a card with an open task did not hold its request; and it looked
663// only at where a session was opened, with an open task required, so a session at work under a later
664// request (its current round there) did not hold that request, and its card left the summary.
665export function openInSight(p: Panel, ids: string[], sends: { anchor?: string; taken?: boolean }[]): boolean {
666  const main = withMain(p.groups ?? []).find(g => g.agent === MAIN)
667  const tasks = main?.tasks ?? []
668  const turns = tasks.filter(t => ids.includes(t.id)).flatMap(t => (t.turn === undefined ? [] : [t.turn]))
669  const seen = tasks.filter(t => ids.includes(t.id) || (t.turn !== undefined && turns.includes(t.turn)))
670  if (hasOpen(seen)) return true
671  const all = flat(seen).map(t => t.id)
672  if (sends.some(s => !s.taken && s.anchor !== undefined && all.includes(s.anchor))) return true
673  // A session's ties, as the cards resolve them (split): a round's anchor by when it was sent.
674  const anchors = (c: Child) =>
675    [c.anchor, c.lastAnchor, ...(c.rounds ?? []).map(r => resolveAnchor(tasks, r.anchor, r.at))].filter((a): a is string => a !== undefined)
676  return (p.children ?? []).some(c => anchors(c).some(a => all.includes(a)) && (live(c) || hasOpen(allTasks(panelOf(c), 1))))
677}
678
679export function trimToWork(p: Panel, level: number): Panel {
680  const groups = withMain(p.groups ?? [])
681  const main = groups.find(g => g.agent === MAIN) as Group
682  let work = p.work ?? []
683  if (work.length === 0) {
684    const last = [...(main.turns ?? [])].reverse().find(u => main.tasks.some(t => t.turn === u.id))
685    const lone = [...main.tasks].reverse().find(t => t.status === 'open') ?? main.tasks[main.tasks.length - 1]
686    work = last ? main.tasks.filter(t => t.turn === last.id).map(t => t.id) : lone ? [lone.id] : []
687  }
688  // The requests of the work, whole. A session still at work under a request out of the work does not
689  // pull the request back (the person's decision; it did, and a day-old request came back with its
690  // old cards while a session worked), nor is its card drawn loose: a card is drawn where its task is.
691  // A card with no task to go under is left out of the summary too (it was drawn loose at the root:
692  // with few requests in sight every old session's card counted as recent, and days-old cards of
693  // archived and stopped sessions piled up after the last request).
694  const wanted = work
695  const turnsIn = main.tasks.filter(t => wanted.includes(t.id)).flatMap(t => (t.turn === undefined ? [] : [t.turn]))
696  const keep = main.tasks.filter(t => wanted.includes(t.id) || (t.turn !== undefined && turnsIn.includes(t.turn)))
697  const ids = flat(keep).map(t => t.id)
698  const under = (anchor?: string) => anchor !== undefined && ids.includes(anchor)
699  return {
700    ...p,
701    groups: groups
702      .map(g => (g.agent === MAIN ? { ...g, tasks: keep, turns: (g.turns ?? []).filter(u => keep.some(t => t.turn === u.id)) } : g))
703      .filter(g => g.agent === MAIN || under(g.anchor) || hasOpen(g.tasks)),
704    children:
705      level >= DEEP
706        ? (p.children ?? [])
707        : (p.children ?? [])
708            .filter(c => under(c.anchor))
709            .map(c => ({ ...c, panel: trimToWork(panelOf(c), level + 1), tasks: undefined })),
710  }
711}
712
713export function running(p: Panel, level = 0): boolean {
714  if (timing(tasksOf(p.groups ?? []))) return true
715  // A session at work keeps the task it was opened on running (see atWork).
716  return level < DEEP && (p.children ?? []).some(live)
717}
718
719// The panel as drawn: one line per task, notice, session or subagent, with its depth in
720// the tree. The desktop draws each line as an Svg, the terminal as a Text.
721export type Tone = 'open' | 'current' | 'done' | 'interrupted' | 'wait' | 'running' | 'archived' | 'agent' | 'section' | 'sent' | 'paused' | 'reply'
722// A message task's state, as its ✉ is colored and its word on the right says: still queued for the
723// session (blue), taken up by it (green, the session may still be at work), interrupted or
724// discarded (gray).
725export type MsgState = 'queued' | 'delivered' | 'cut'
726// A question's word on the right: waiting, answered by an option, answered by a message, no answer.
727export const ASK_WORD: Record<Ask['state'], string> = { open: 'aguardando', answered: 'respondida', chat: 'por mensagem', dropped: 'interrompida' }
728export const MSG_WORD: Record<MsgState, string> = { queued: 'na fila', delivered: 'entregue', cut: 'interrompida' }
729// Where a session works, as its card and the header draw it: a repository with its branch (the
730// branch a detached head's short hash), or a folder with no repository (`folder`, `repo` its path).
731export type GitLabel = { repo: string; branch?: string; detached?: boolean; folder?: boolean }
732export const gitLabel = (g: { kind?: 'git' | 'folder'; repo: string; branch?: string; detached?: boolean }): GitLabel =>
733  g.kind === 'folder'
734    ? { repo: g.repo, folder: true }
735    : { repo: g.repo, branch: g.branch ?? '', ...(g.detached ? { detached: true } : {}) }
736// The state a session's icon shows (sessionIcon): at work, done, stopped with something open, archived.
737export type SessionState = 'ativa' | 'concluida' | 'parada' | 'arquivada'
738// The state of the sessions hanging from a task, from their work (atWork): drawn only on the task a
739// card hangs from directly.
740export const kidsState = (w: { direct: boolean; live: boolean; open: boolean }): SessionState | undefined =>
741  !w.direct ? undefined : w.live ? 'ativa' : w.open ? 'parada' : 'concluida'
742export type Row = {
743  depth: number
744  tone: Tone
745  title: string
746  // The whole text for the tooltip, when the title is a short form of it (the ↩ line's summary).
747  whole?: string
748  // Said only in the alt, after the title: a session's title, its card showing just its name.
749  hint?: string
750  // What the alt text says first ("Em andamento"); empty for a notice.
751  label: string
752  strong?: boolean
753  // Right-aligned: done/total (with a mini bar on a task: `bar`), a word after it, and the time
754  // the task (or the session's, the subagent's tasks) took or is taking, in ms.
755  sub?: [number, number]
756  bar?: boolean
757  meta?: string
758  // null: a closed task whose time is unknown (panels of earlier versions), drawn as "—".
759  time?: number | null
760  // What the line is, the same in every state (its title, fold or nodes may change): its task, turn,
761  // session, wait, answer or section. The render's motion follows lines by it.
762  key?: string
763  // A task with no subtasks.
764  leaf?: boolean
765  // A message task (TaskNode.msg): its icon is always the ✉, in the color of this state.
766  msg?: MsgState
767  // A question asked (TaskNode.askState) still open: the balloon in its icon's place; `opt`: one of
768  // its options.
769  balloon?: boolean
770  opt?: boolean
771  // A level-1 task derived from a message received (Turn.msg): the ✉ in its icon's place, in its
772  // own state's color (`queued` open, `delivered` done, `cut` interrupted); no word on the right.
773  recv?: MsgState
774  // A session's or subagent's header: it and the lines below it are drawn in a card.
775  card?: boolean
776  // For the header's chips: a task in progress with nothing open below it; a session at work;
777  // a session waiting with no notice of its own drawn.
778  active?: boolean
779  live?: boolean
780  pending?: boolean
781  // A line with something below it opens and closes (▾/▸): `key` names it in `folds`, `open`
782  // says whether what is below is drawn.
783  fold?: { key: string; open: boolean }
784  // A session's card: its icon is the branch in every state (the tone colors it).
785  session?: boolean
786  // A task a session's card hangs from directly (opened on it, or its current round there): the
787  // session icon beside it, drawn even while the task is closed, in the state of the sessions there
788  // (`kidsState`: at work while one is alive; done once all ended with nothing open; stopped
789  // otherwise). The lines above it (its parents, the request) show only the count and bar.
790  kids?: boolean
791  kidsState?: SessionState
792  // The context ring of a session: on its card, and on the highest line above it (its turn, else
793  // its level-1 task) with the largest share among the sessions there.
794  ring?: Ring
795  // A session's card: its id, for the ring's button that opens the session (see ringOver).
796  sid?: string
797  // The tasks the line stands for in the header's count and time: a leaf's own; a folded line's
798  // whole subtree (its sessions' tasks too); none for an open parent (its lines below stand for
799  // theirs) or a line that is not a task. The header sums the drawn lines' nodes, so each view's
800  // header says what that view shows (the person's words: "cada visao deve refletir os dados do
801  // seu tipo de vizualizacao").
802  nodes?: TaskNode[]
803  // A session's card: its model and effort ("Opus 5.5 · high"), drawn left of the ring, and its
804  // output rate ("n toks/s"), drawn left of that, dim when it is the last turn's mean (not live).
805  engine?: string
806  rate?: { toks: number; live: boolean }
807  // A session's card: its repository (folder name) and branch, drawn right of its name with their
808  // icons (the repository goes first when the line is short).
809  git?: GitLabel
810}
811
812// With no summary from the session, the ↩ line says only this, the whole answer in its tooltip: the
813// answer cut to the line's width summed nothing up (the person's words: "voce esta imprimindo o
814// texto integral cortado").
815export const REPLY = 'resposta'
816// The ↩ line of a turn shows its summary (the done's `summary`); a turn with none has no ↩ line. The
817// answer's text is never shown cut (the person: "quero que seja um resumo da resposta, super curto"),
818// and the placeholder "resposta" is gone too (the person: the ↩ line only with real text).
819export const hasBrief = (u: Turn) => typeof u.brief === 'string' && u.brief.trim() !== ''
820// Some task under `t` (or `t` itself) is open and waits on a session: a message from it not yet taken up.
821export const waitingBelow = (t: TaskNode, pending: { anchor?: string }[]) =>
822  pending.length > 0 && flat([t]).some(k => k.status === 'open' && pending.some(s => s.anchor === k.id))
823
824// From the rows to how they are drawn: the tree lines (guides), the cards each line is inside
825// (frames) and the session labels whose column stays empty (labelCols).
826// The tree lines of a row: `bars[k]` continues the line of the ancestor at depth k past it,
827// `last` ends its own branch here, `down` starts a line to the rows below it.
828export type Guide = { bars: boolean[]; last: boolean; down: boolean }
829export function guides(rows: Row[]): Guide[] {
830  // Whether a row at `level` comes later, before the tree climbs above it.
831  const later = (i: number, level: number) => {
832    for (let j = i + 1; j < rows.length; j++) {
833      if (rows[j].depth < level) return false
834      if (rows[j].depth === level) return true
835    }
836    return false
837  }
838  return rows.map((r, i) => ({
839    bars: Array.from({ length: Math.max(0, r.depth - 1) }, (_, k) => later(i, k + 1)),
840    last: !later(i, r.depth),
841    down: (rows[i + 1]?.depth ?? -1) > r.depth,
842  }))
843}
844
845// The cards a line is drawn in, outermost first: the card of the header at `depth`, whether it
846// starts (`first`) or ends (`last`) on this line. A card takes its header and every line below
847// it deeper than it; its edges add `PAD` px above its first line and below its last (the px, and
848// where the columns, cards and overlays sit, are the geometry module's: ../draw/geometry).
849export type Card = { depth: number; first: boolean; last: boolean }
850export type Frame = { cards: Card[]; top: number; bottom: number }
851// A session's card is headed by a label (its name), not a task line: the tasks inside are its
852// roots, with no tree line to the label. Per row, the depths of the session labels it is inside:
853// their column (col(depth)) is drawn empty.
854export function labelCols(rows: Row[]): number[][] {
855  const open: number[] = []
856  return rows.map(r => {
857    while (open.length > 0 && open[open.length - 1] >= r.depth) open.pop()
858    const mine = open.slice()
859    if (r.session) open.push(r.depth)
860    return mine
861  })
862}
863export function frames(rows: Row[]): Frame[] {
864  const open: number[] = []
865  return rows.map((r, i) => {
866    while (open.length > 0 && open[open.length - 1] >= r.depth) open.pop()
867    if (r.card) open.push(r.depth)
868    const next = rows[i + 1]
869    const cards = open.map(depth => ({
870      depth,
871      first: r.card === true && depth === r.depth,
872      last: next === undefined || next.depth <= depth,
873    }))
874    return { cards, top: r.card ? PAD : 0, bottom: cards.filter(c => c.last).length * PAD }
875  })
876}
877
878// What layout() reads: the state as the render read it, once.
879export type Model = {
880  groups: Group[]
881  waits: Wait[]
882  // The sessions this one opened, as the host lists them.
883  children: Child[]
884  // The sends still waiting to be taken up.
885  sends: Send[]
886  view: 'full' | 'summary'
887  work: string[]
888  // When this session's turn ended, until the next prompt: what its main loop left open is drawn
889  // with no close and its clock stopped then (a session that ended its turn shows no task running).
890  ended?: number
891  // The questions asked, drawn under their tasks (withAsks).
892  asks?: Ask[]
893}
894// The lines the person opened or closed (the folds atom): true closed, false open.
895export type Choices = Record<string, boolean>
896
897// The panel's lines from the model and the person's choices, against `now` (the times) and for
898// `ownId` (this session's id, for the sisters' messages). Pure: it reads nothing but its arguments.
899export function layout(model: Model, choices: Choices, now: number | undefined, ownId: string | undefined): Row[] {
900  const ended = model.ended
901  // The tasks of the sends still queued are spared by the halt (they wait on a session, not on the turn).
902  // With nothing going on in the main group (no task started and open, no send queued), its pending
903  // tasks read as interrupted too; with something going on, they stay pending (see halt).
904  const sentFrom = model.sends.filter(s => !s.taken).flatMap(s => (s.anchor === undefined ? [] : [s.anchor]))
905  const mainTasks = model.groups.find(g => g.agent === MAIN)?.tasks ?? []
906  const goingOn = sentFrom.length > 0 || flat(mainTasks).some(t => t.children.length === 0 && t.status === 'open' && t.startedAt !== undefined && t.endedAt === undefined)
907  const halted = ended === undefined ? model.groups : model.groups.map(g => (g.agent === MAIN ? { ...g, tasks: g.tasks.map(halt(ended, sentFrom, !goingOn)) } : g))
908  // The questions asked, hung under their tasks after the halt (one still open waits on the person).
909  const asked = panelAsks({ groups: halted, waits: model.waits, children: [] }, model.asks ?? [])
910  const held = asked.groups
911  const waits = asked.waits
912  const sessions = model.children
913  const queued = model.sends
914  const chosen = choices
915  const summary = model.view === 'summary'
916  const work = model.work
917  // The breaks of the sessions' cards, computed once per drawing (see breaks).
918  const memo = newBreaksMemo()
919  const notice = (w: Wait, depth: number): Row => ({ key: `w:${w.agent}:${w.key}`, depth, tone: 'wait', title: waitText(w), label: '' })
920
921  // What the person opened or closed, read by the builders below. They run once per drawing: the
922  // header's chips count the drawn lines' nodes (Row.nodes, see ui.render), so no pass without the
923  // choices is needed (there was one, discarded, until phase 6).
924  const fl: Record<string, boolean> = chosen
925  // Drawn below a line: the person's choice, else the default.
926  const opened = (key: string, byDefault: boolean) => (fl[key] === undefined ? byDefault : !fl[key])
927
928  // A task line and, while it is open, what hangs below it (`below`) and its subtasks.
929  // Nothing folds by itself when it is done (the person's words: "se eu quiser, eu mesmo
930  // colapso"): every line comes open and only the ▾/▸ closes it. A task with anything below it
931  // opens and closes (`fold`, keyed under `scope`). `pending`: the sends from this group's tasks
932  // still waiting to be taken up (the panel's that holds the group).
933  const rows = (
934    items: TaskNode[],
935    depth: number,
936    top: number,
937    lit: string[],
938    below: (t: TaskNode, depth: number) => Row[],
939    scope: string,
940    work: (t: TaskNode) => Work,
941    pending: Send[],
942    // The turn a level-1 task was planned in, when another session's message opened it (Turn.msg).
943    recvOf: (t: TaskNode) => Turn | undefined = () => undefined,
944  ): Row[] =>
945    items.flatMap(t => {
946      // Sessions opened on it or below add to its count and keep it in progress (atWork).
947      const w = work(t)
948      // A message sent from it is still queued: it waits on that session (its own icon), also once
949      // the model closed it with done right after sending (the person: the ✉ stays until the delivery,
950      // whatever the model does with the task; it was dropped by the done, and the task came out ✓).
951      // The ✉ comes first, whatever the task's status says (a queued send outlives the turn: halt).
952      const waitsOn = pending.filter(s => s.anchor === t.id).map(s => s.to)
953      // A task below it waits on a session (✉): the work is going on (it was drawn not started).
954      const waitsBelow = t.status === 'open' && t.children.length > 0 && waitingBelow(t, pending)
955      const tone: Tone =
956        waitsOn.length > 0
957          ? 'sent'
958          : t.status === 'interrupted'
959            ? 'interrupted'
960            : w.busy || waitsBelow
961              ? 'current'
962              : t.status === 'done'
963                ? flat(t.children).some(k => k.status === 'interrupted')
964                  ? 'interrupted'
965                  : 'done'
966                : lit.includes(t.id)
967                  ? 'current'
968                  : ownKids(t).length > 0 && resting(ownKids(t))
969                    ? 'paused'
970                    : 'open'
971      // Its own subtasks: a question asked under it (and the question's options) are not its work.
972      const own = ownKids(t)
973      const [ld, ln] = own.length > 0 ? count(own) : w.any ? count([t]) : [0, 0]
974      const [sd, sn] = [ld + w.d, ln + w.n]
975      // A message task (a send went out from it): its ✉ in the color of where the message is, the
976      // word on the right saying it (the person: "mantenha o ícone e troque somente a cor").
977      const msg: Row['msg'] =
978        own.length > 0 || !t.msg ? undefined : waitsOn.length > 0 ? 'queued' : t.status === 'interrupted' ? 'cut' : 'delivered'
979      // A level-1 task derived from a message received: the ✉ in its icon's place, blue while open,
980      // green done, gray interrupted (the person's words: "o ícone do 'analisar a tarefa' deveria ser
981      // uma carta também"); who sent it and the message in its tooltip. It counts as work.
982      const from = recvOf(t)
983      const recv: Row['recv'] =
984        from === undefined ? undefined : t.status === 'done' ? 'delivered' : t.status === 'interrupted' ? 'cut' : 'queued'
985      const said = from === undefined ? undefined : `Mensagem de ${from.from?.name || from.from?.id || 'outra sessão'}: ${from.title}`
986      // A question asked (askState) and its options (opt): the question with the balloon while it waits,
987      // ✓ answered, ✕ with no answer, its word on the right; the options ○, then ✓ picked and ✕ the
988      // others; no count, bar or time of their own (the person's words: "mantenha somente a sub
989      // tarefa selecionada com um check verde, e as descartadas com o X cinza").
990      const asking = t.askState !== undefined
991      const askWord = asking ? ASK_WORD[t.status === 'open' && t.askState === 'chat' ? 'chat' : t.status === 'open' ? 'open' : t.status === 'interrupted' ? 'dropped' : t.askState === 'chat' ? 'chat' : 'answered'] : undefined
992      const row: Row = {
993        key: `${scope}t:${t.id}`,
994        leaf: own.length === 0,
995        depth,
996        tone,
997        title: t.title,
998        label:
999          tone === 'sent'
1000            ? `Aguardando confirmação de ${waitsOn.join(', ')}`
1001            : msg !== undefined
1002              ? `Mensagem ${MSG_WORD[msg]}`
1003              : ({ open: 'Aberta', current: 'Em andamento', done: 'Concluída', interrupted: 'Interrompida', paused: 'Aguardando' }[tone as string] ?? ''),
1004        strong: tone === 'current' || tone === 'sent' || (depth === top && t.status === 'open'),
1005        sub: (t.children.length > 0 || w.any) && tone !== 'interrupted' && sn > 0 ? [sd, sn] : undefined,
1006        bar: true,
1007        meta: msg !== undefined ? MSG_WORD[msg] : askWord !== undefined ? askWord : t.opt ? undefined : tone === 'interrupted' ? 'interrompida' : undefined,
1008        ...(msg !== undefined ? { msg } : {}),
1009        ...(recv !== undefined ? { recv, hint: said, whole: said } : {}),
1010        ...(asking && t.status === 'open' ? { balloon: true, pending: true } : {}),
1011        ...(asking && t.whole ? { whole: t.whole } : {}),
1012        ...(t.opt ? { opt: true } : {}),
1013        time:
1014          (tone === 'paused' ? stillSpan([t, ...w.tasks]) : timeOf(t, w, now)) ??
1015          (tone === 'done' || tone === 'interrupted'
1016            ? null
1017            : tone === 'current' && t.plannedAt !== undefined && now !== undefined
1018              ? now - t.plannedAt
1019              : undefined),
1020        kids: w.direct || undefined,
1021        kidsState: kidsState(w),
1022        // The context ring is each session's own, on its card only (the person's words: "nao precisa
1023        // colocar esse icone na tarefa pai que originou essas sessoes"); the ⑂ still marks a task
1024        // with sessions below.
1025        // In progress of its own (the chip); a session at work below it counts as a session.
1026        active: tone !== 'sent' && !asking && !t.opt && t.status === 'open' && lit.includes(t.id) && !own.some(c => c.status === 'open'),
1027      }
1028      if (asking || t.opt) {
1029        row.sub = undefined
1030        row.time = undefined
1031      }
1032      const under = below(t, depth)
1033      if (t.children.length === 0 && under.length === 0) return [{ ...row, nodes: [t] }]
1034      const key = `${scope}t:${t.id}`
1035      // Nothing closes by itself when it is done: only the person folds a line (▾/▸).
1036      const kids = t.children
1037      const rest = opened(key, true) ? [...under, ...rows(kids, depth + 1, top, lit, below, scope, work, pending)] : []
1038      row.fold = { key, open: rest.length > 0 }
1039      row.nodes = rest.length > 0 ? (own.length === 0 ? [t] : []) : [t, ...w.cards]
1040      return [row, ...rest]
1041    })
1042
1043  // A turn of the main group: what the person asked, with the level-1 tasks planned for it
1044  // below. Its count, bar and time add up its tasks and the sessions opened under them; it is
1045  // in progress while one of them is (or a session below is at work), and closes into one line
1046  // once all of them are closed.
1047  const turnRows = (
1048    u: Turn,
1049    list: TaskNode[],
1050    top: number,
1051    lit: string[],
1052    below: (t: TaskNode, depth: number) => Row[],
1053    scope: string,
1054    work: (t: TaskNode) => Work,
1055    pending: Send[],
1056    // The turn's own ↩ (the top panel's): a card draws its session's answers below its tasks.
1057    reply = true,
1058  ): Row[] => {
1059    const ws = list.map(work)
1060    const busy = ws.some(w => w.busy)
1061    const [ld, ln] = count(list)
1062    const [d, n] = [ws.reduce((a, w) => a + w.d, ld), ws.reduce((a, w) => a + w.n, ln)]
1063    const tone: Tone =
1064      busy || list.some(t => t.status === 'open' && (lit.includes(t.id) || waitingBelow(t, pending)))
1065        ? 'current'
1066        : list.some(t => t.status === 'open')
1067          ? resting(list)
1068            ? 'paused'
1069            : 'open'
1070          : flat(list).some(t => t.status === 'interrupted')
1071            ? 'interrupted'
1072            : 'done'
1073    const key = `${scope}T:${u.id}`
1074    const open = opened(key, true)
1075    const row: Row = {
1076      key,
1077      depth: top,
1078      tone,
1079      title: u.title,
1080      label:
1081        { open: 'Pedido aberto', current: 'Pedido em andamento', done: 'Pedido concluído', interrupted: 'Pedido interrompido', paused: 'Aguardando' }[
1082          tone as string
1083        ] ?? 'Pedido',
1084      strong: tone === 'current' || tone === 'open' || tone === 'paused',
1085      sub: n > 0 ? [d, n] : undefined,
1086      bar: true,
1087      time:
1088        (tone === 'paused' ? stillSpan([...list, ...ws.flatMap(w => w.tasks)]) : timeSpan([...list, ...ws.flatMap(w => w.tasks)], busy, now)) ??
1089        (tone === 'done' || tone === 'interrupted'
1090          ? null
1091          : tone === 'current' && now !== undefined && list.some(t => t.plannedAt !== undefined)
1092            ? now - Math.min(...list.flatMap(t => (t.plannedAt === undefined ? [] : [t.plannedAt])))
1093            : undefined),
1094      fold: { key, open },
1095      // No session icon on a request: a card never hangs from it directly (see Work.direct).
1096    }
1097    // The ring goes on the turn, the highest line above the sessions, not on its level-1 tasks.
1098    const inner = open ? rows(list, top + 1, top + 1, lit, below, scope, work, pending, () => (u.msg ? u : undefined)) : []
1099    row.nodes = open ? [] : [...list, ...ws.flatMap(w => w.cards)]
1100    // The turn's answer closes the message's group, after all its level-1 tasks, as on a card;
1101    // not when a session's card hangs in the group: the answers that count are in the cards, and
1102    // one after a card still at work would read as the group being over.
1103    const said: Row[] =
1104      reply && open && hasBrief(u) && !ws.some(w => w.any)
1105        ? [{ key: `${scope}r:${u.id}`, depth: top + 1, tone: 'reply', title: u.brief as string, whole: u.reply ?? u.brief, label: 'Resposta' }]
1106        : []
1107    return [row, ...inner, ...said]
1108  }
1109
1110  // A session this one opened, in a card: "title · name", the count and time of its tasks, and
1111  // its state in a word once it is not just running. While it runs or waits its own panel is
1112  // drawn below it as it draws it (its waits under its task in progress, its subagents and
1113  // sessions); once it stops it closes into one line, which the person may open. The icon is
1114  // the gray branch while it is at work; the clock when it waits with no notice of its own to
1115  // show (earlier versions); the check once it stopped.
1116  const session = (c: Child, depth: number, level: number, scope: string): Row[] => {
1117    const p = panelOf(c)
1118    const pending = c.waiting === true && (p.waits ?? []).length === 0
1119    // Stopped with tasks still open (it waits for a signal, or the next message): not done; its
1120    // tasks stay in sight, as it planned them.
1121    const parked = !live(c) && !c.archived && hasOpen(tasksOf(rawPanel(c).groups ?? []))
1122    const tone: Tone = pending ? 'wait' : live(c) ? 'running' : c.archived ? 'archived' : parked ? 'paused' : 'done'
1123    // A card of one round (split) speaks for its group only: done, it shows the check, with no
1124    // word that would say the whole session ended.
1125    const state = c.waiting ? 'aguardando' : live(c) ? undefined : c.archived ? 'arquivada' : parked ? 'parada' : c.part !== undefined ? undefined : 'concluída'
1126    const [cd, cn] = total(p, level + 1)
1127    const header: Row = {
1128      key: `${scope}s:${c.id}#${c.partAt ?? c.part ?? 0}`,
1129      depth,
1130      tone,
1131      // The card's first line is the session alone (its name); its tasks start on the line below.
1132      // The session's title (often worded like a task) goes only to the alt, not the line.
1133      title: c.name || c.title,
1134      label: 'Sessão',
1135      hint: c.name && c.name !== c.title ? c.title : undefined,
1136      strong: live(c),
1137      sub: cn > 0 ? [cd, cn] : undefined,
1138      meta: state,
1139      time: span(tasksOf(p.groups ?? []), now) ?? (live(c) ? undefined : null),
1140      card: true,
1141      session: true,
1142      sid: c.id,
1143      engine: c.engine,
1144      rate: c.rate ? { toks: c.rate.toks, live: c.rate.live } : undefined,
1145      git: c.git ? gitLabel(c.git) : undefined,
1146      ring: ringOf(c),
1147      live: live(c),
1148      pending,
1149    }
1150    if (level >= DEEP) return [header]
1151    // A card past the first (split) has keys of its own.
1152    // The fold of a card is kept by when its message was sent (partAt), not by its index among the
1153    // rounds: at the cap of 20 rounds a new message shifts every index, and the person's fold moved to
1154    // another card (an open card came back folded).
1155    const part = c.part ? `#${c.partAt ?? c.part}` : ''
1156    const key = `${scope}s:${c.id}${part}`
1157    const inside = `${scope}${c.id}${part}>`
1158    // Under the card's tasks, what the session answered at the end of each of its turns there, in
1159    // turn order: a ↩ line, dim, with no count or time (not a task).
1160    const replies: Row[] = (withMain(p.groups ?? []).find(g => g.agent === MAIN)?.turns ?? [])
1161      .filter(hasBrief)
1162      .map(u => ({ key: `${scope}${c.id}>r:${u.id}`, depth: depth + 1, tone: 'reply' as Tone, title: u.brief as string, whole: u.reply ?? u.brief, label: 'Resposta' }))
1163    const inner = opened(key, true) ? [...panel(p, depth + 1, level + 1, inside, c.id), ...replies] : []
1164    // Whether a closed one has anything to open: its panel, drawn as it would be.
1165    if (inner.length > 0 || panel(p, depth + 1, level + 1, inside, c.id).length > 0) header.fold = { key, open: inner.length > 0 }
1166    // A discarded round's lines stand for nothing in the header's count (discarded), ✓ ones included.
1167    const out = discarded(c)
1168    header.nodes = out || inner.length > 0 ? [] : allTasks(p, level + 1)
1169    return [header, ...(out ? inner.map(r => ({ ...r, nodes: [] })) : inner)]
1170  }
1171
1172  // One session's panel from `base`: its main group, its subagents' groups and the sessions
1173  // it opened. A session goes under the main task it was opened on, open or closed (a closed
1174  // one shows only it); at the end of the list when it has no such task. Archived ones are
1175  // left out. Done tasks stay in their place in the plan, closed.
1176  const panel = (p0: Panel, base: number, level: number, scope: string, me?: string): Row[] => {
1177    // A session's card draws the questions it asked under their tasks too (the top panel's are hung
1178    // above, from the model).
1179    const p = level > 0 ? panelAsks(p0) : p0
1180    // The sends of this panel's main group still waiting to be taken up: this session's at the top,
1181    // what each session publishes in its card (its task shows ✉ too).
1182    const pending = level > 0 ? (p.sends ?? []).filter(x => !x.taken) : queued
1183    const groups = withMain(p.groups ?? [])
1184    const waits = p.waits ?? []
1185    // Archived sessions stay, as history, drawn gray with "arquivada".
1186    // One card per message the session was sent (split).
1187    const sessions = (p.children ?? []).flatMap(c => split(c, p.children ?? [], me, withMain(p.groups ?? []).find(g => g.agent === MAIN)?.tasks ?? [], now, memo))
1188
1189    // A subagent's group hangs from the loop that launched it; one whose launcher has no
1190    // group (it never planned) hangs from the main one.
1191    const agents = groups.map(g => g.agent)
1192    const launcher = (g: Group) => (agents.includes(g.parent ?? MAIN) ? (g.parent ?? MAIN) : MAIN)
1193    const kidsOf = (agent: string) => groups.filter(g => g.agent !== MAIN && launcher(g) === agent)
1194
1195    const main = groups.find(g => g.agent === MAIN) as Group
1196    const allIds = flat(main.tasks).map(t => t.id)
1197    const loose = sessions.filter(c => c.anchor === undefined || !allIds.includes(c.anchor))
1198    // A group's tasks from `top` (`list`), with what hangs below them. Its subagents' groups
1199    // go under the task they were launched on, hidden with it once it closes; one launched
1200    // on no task (or a task replanned away) ends the group. `extras` adds those groups and
text/rules.ts 196 lines
1// The texts the mod hands the model: the panel's rules in the system prompt, the tools' and the
2// command's descriptions, the reminders prompt.submit adds to each prompt, the warnings in the
3// results of done, wrap_up and the other tool calls, and the notes about sessions this one opened.
4// All in one place, so what the model reads about the same thing is said once: the limit of the ↩
5// summary (BRIEF) is interpolated here, in every text that names it. Pure strings and functions of
6// strings; the hooks module imports them. tests/textos.test.ts keeps them letter by letter against
7// tests/golden/textos.ts.
8
9// The ↩ line says the answer in at most BRIEF characters (done's `summary`, else the first
10// sentence of the answer; see brief in the hooks).
11export const BRIEF = 120
12const LIMIT = `até ${BRIEF} caracteres`
13const EXAMPLE = '(ex.: "Commit 946f653 no PR #5; 151 testes ok")'
14
15// The panel's rules, added to the system prompt of every request that offers its tools,
16// so the mod behaves the same on any machine with nothing in CLAUDE.md.
17export const RULES = [
18  '# Painel de tarefas (task-tree)',
19  '',
20  'O usuário acompanha o trabalho num painel lateral de tarefas. Regras:',
21  '',
22  '- Todo pedido abre pelo menos uma tarefa no painel, mesmo uma pergunta simples ou respondida só com ' +
23    'pesquisa ou leitura de arquivos. A primeira chamada de ferramenta é mcp__task-tree__plan com a árvore ' +
24    'de tarefas: até 3 níveis (nível 1 genérico, nível 3 mais granular), ids únicos dentro do plano ' +
25    '("1", "1.2", "1.2.3"). Uma tarefa basta para um pedido simples.',
26  '- Abaixo do nível 1 os ids ganham o id dele como prefixo ("D/1.2"). done e start aceitam o id curto ' +
27    'quando não é ambíguo (preferem a tarefa aberta) ou o completo, e avisam no resultado ids ' +
28    'inexistentes, ambíguos ou de outro agente.',
29  '- No primeiro plan de cada mensagem do usuário, passe em `request` um título curto do que foi pedido: ' +
30    'no painel, as tarefas de nível 1 ficam agrupadas pela mensagem em que foram planejadas, com esse título.',
31  '- Etapa por etapa, no momento em que acontece: mcp__task-tree__start ao começar cada tarefa (várias ' +
32    'podem estar em andamento) e mcp__task-tree__done assim que ela estiver de fato concluída, antes de ' +
33    'passar à próxima. Nunca feche várias de uma vez no fim: o painel fica parado na primeira e pula para ' +
34    'a última, com 0:00 nas do meio. Um pai fecha sozinho quando todos os filhos fecham.',
35  `- No done do último passo do pedido, passe em \`summary\` um resumo da resposta em ${LIMIT} ` +
36    '(ex.: "Cartões na raiz: causa na recarga; correção enviada"): é a linha ↩ do painel, com a resposta ' +
37    'inteira no tooltip. Sem ele, o painel usa a primeira frase da resposta.',
38  '- Para replanejar, chame plan de novo com o nível 1 inteiro. Um nível 1 aberto com o mesmo id é ' +
39    'substituído do zero no mesmo lugar da lista: remarque com done o que já estava concluído. Um nível 1 ' +
40    'já concluído fica como histórico. start só reabre tarefas sem subtarefas; para refazer um pai ' +
41    'concluído, replaneje o nível 1.',
42  '- Não crie plano retroativo para trabalho já feito: se perceber que começou sem plano, planeje o que falta.',
43  '- Antes de perguntar (AskUserQuestion, ExitPlanMode, wrap_up com ask), mantenha em andamento a tarefa a ' +
44    'que a pergunta pertence e feche-a só depois da resposta; se a resposta vier pelo chat, feche-a no ' +
45    'início do turno seguinte. AskUserQuestion sem tarefa aberta é recusado.',
46  '- Depois que o usuário responder uma pergunta ou aprovar um plano, atualize o painel (done, start ou ' +
47    'plan) antes de seguir. Se ele responder por mensagem no chat em vez de escolher uma opção, a mensagem ' +
48    'é a resposta.',
49  '- Respondido um AskUserQuestion, além do done da tarefa da pergunta, planeje as próximas tarefas: chame ' +
50    'mcp__task-tree__plan com o que a resposta definiu (o rumo escolhido, os itens confirmados), na ordem ' +
51    'em que vai executar, e só então siga com start e done.',
52  '- Subagentes que usam o painel ganham um grupo próprio, mostrado embaixo da tarefa em que foram ' +
53    'lançados. Cada agente só mexe no próprio grupo; o que ele deixar aberto ao terminar vira interrompido.',
54  '- Uma tarefa de enviar mensagem a outra sessão (SendMessage) fica com o ícone de mensagem (✉) do ' +
55    'envio até a sessão destino começar a tratar a mensagem, mesmo quando a entrega é imediata ' +
56    '("delivered; its turn has started on it"). Não chame done nela: quando o recebimento é confirmado, ' +
57    'o painel a conclui sozinho (mesmo com esta sessão parada). Com "queued", o painel também avisa.',
58  '- Sessões abertas com start_session aparecem embaixo da tarefa em andamento quando foram abertas, com ' +
59    'as tarefas do painel delas: antes de abrir uma sessão, marque com start a tarefa a que ela pertence.',
60].join('\n')
61
62// The /task-tree command and the three tools, as the model sees them.
63export const COMMAND = 'Abre o painel da árvore de tarefas e diz o estado dele; com resumida ou completa, troca a visão'
64export const TOOLS = {
65  plan:
66    'Adiciona um plano à árvore de tarefas do painel lateral do usuário. As tarefas de nível 1 anteriores são mantidas; ' +
67    'uma aberta com o mesmo id é substituída (replanejamento, que recomeça tudo dentro dela). ' +
68    'Até 3 níveis: nível 1 genérico, nível 3 mais granular. Ids únicos dentro do plano (ex.: "1", "1.2", "1.2.3"); ' +
69    'abaixo do nível 1 eles ganham o id dele como prefixo ("D/1.2"), então planos diferentes podem repetir ids. ' +
70    'Use no início de todo pedido (uma tarefa basta para um pedido simples) e ao replanejar. ' +
71    'No primeiro plan de cada mensagem do usuário, passe em request um título curto do que foi pedido: ' +
72    'o painel agrupa as tarefas de nível 1 por mensagem, com esse título.',
73  done:
74    'Marca tarefas do painel como concluídas, pelos ids, assim que cada uma termina. ' +
75    'Aceita o id curto ("1.2") quando não é ambíguo, ou o completo ("D/1.2"). ' +
76    'Um pai fica concluído quando todos os filhos estão; marcar um pai conclui a subárvore. ' +
77    `No done da última etapa do turno, passe summary: a resposta do turno em manchete de ${LIMIT} ` +
78    `${EXAMPLE}. Sem etapa a fechar, chame done só com summary.`,
79  summary:
80    `Resumo curto da resposta deste turno, em ${LIMIT} (o que mudou e o resultado), tipo manchete ` +
81    `${EXAMPLE}. Vira a linha ↩ do painel; a resposta inteira fica no tooltip.`,
82  start:
83    'Marca tarefas do painel como em andamento (destacadas em amarelo) ao começar cada uma. ' +
84    'Várias podem estar em andamento; sem nenhuma marcada, nenhuma tarefa aparece em andamento. ' +
85    'Em uma tarefa concluída sem subtarefas, reabre a tarefa e os pais dela; ' +
86    'para refazer um pai concluído, replaneje o nível 1 com plan. Ids como no done.',
87}
88
89// Every reminder and warning opens with this.
90const PANEL = 'Painel de tarefas (task-tree): '
91
92// Added to the prompt (prompt.submit): with the person's message, the reminder of a new request
93// and, with tasks still open, their list (`answered`: the message answers the last question); with
94// a background task's notification, a subagent's hand-back or the app's own notice, a short one.
95export const PROMPT = {
96  request:
97    PANEL + 'todo pedido abre pelo menos uma tarefa no painel, mesmo uma ' +
98    'pergunta simples ou respondida só com pesquisa ou leitura de arquivos. A PRIMEIRA chamada de ' +
99    'ferramenta é mcp__task-tree__plan com a árvore de tarefas (até 3 níveis; uma tarefa basta ' +
100    'para um pedido simples). Etapa por etapa: mcp__task-tree__start ao começar cada tarefa e ' +
101    'mcp__task-tree__done assim que ela estiver de fato concluída, antes da próxima (nunca várias de ' +
102    'uma vez no fim). Ao replanejar, chame plan de novo. ' +
103    'Antes de perguntar (AskUserQuestion, ExitPlanMode, wrap_up com ask), mantenha em andamento a ' +
104    'tarefa da pergunta e feche-a só depois da resposta. No primeiro plan desta mensagem, passe em ' +
105    'request um título curto do que foi pedido. No done da última etapa do turno, passe summary: ' +
106    `resumo da resposta em ${LIMIT}, tipo manchete.`,
107  open: (list: string, answered: boolean) =>
108    (answered ? 'Esta mensagem é a resposta do usuário à pergunta anterior. ' : '') +
109    PANEL + `tarefas abertas antes desta mensagem: ${list}. Feche com ` +
110    'mcp__task-tree__done as que esta mensagem conclui ou substitui (por exemplo, a de perguntar ou de ' +
111    'esperar a resposta) antes de seguir ou de planejar o pedido novo; deixe abertas só as que continuam.',
112  handback: PANEL + 'o subagente terminou; atualize o painel (mcp__task-tree__done no que ele concluiu).',
113  background:
114    PANEL + 'uma tarefa em segundo plano (um subagente, um comando) terminou; ' +
115    'atualize o painel: mcp__task-tree__done no que ela concluiu.',
116  notice: PANEL + 'mantenha o painel em dia (done no que terminou, start no que começa).',
117  // The last turn was interrupted (stopped) and a message of another session came next (the
118  // person's rule): the model replans the panel, joining what the interrupted work left open with
119  // what the message asks, before going on. Only for a turn another session's message opens; the
120  // person's own prompt gets the usual reminders.
121  interrupted: (list: string) =>
122    PANEL + 'seu turno anterior foi interrompido antes de terminar, e esta mensagem de outra sessão ' +
123    'chegou em seguida. Antes de seguir, replaneje o painel: chame mcp__task-tree__plan com o nível 1 ' +
124    `inteiro, juntando o que ficou do trabalho interrompido (tarefas abertas: ${list}) e o que esta ` +
125    'mensagem pede, na ordem em que vai executar. Remarque com mcp__task-tree__done o que já estava ' +
126    'concluído. Depois siga etapa por etapa com start e done.',
127  interruptedEmpty:
128    PANEL + 'seu turno anterior foi interrompido antes de terminar, e esta mensagem de outra sessão ' +
129    'chegou em seguida. Antes de seguir, replaneje o painel com o que esta mensagem pede: chame ' +
130    'mcp__task-tree__plan e siga etapa por etapa com start e done.',
131}
132
133// A question (AskUserQuestion) or a plan to approve: refused with no open task; answered, by an
134// option or by a message still to come.
135export const ASK = {
136  refused:
137    PANEL + 'não há tarefa aberta no painel, e uma pergunta sempre fica sob ' +
138    'uma tarefa em andamento. Abra (mcp__task-tree__plan) ou reabra (mcp__task-tree__start) a tarefa ' +
139    'a que esta pergunta pertence e pergunte de novo; feche-a só depois da resposta.',
140  inChat:
141    PANEL + 'o usuário vai responder por mensagem no chat, e a mensagem ' +
142    'dele é a resposta. Ao recebê-la, antes de seguir, feche com mcp__task-tree__done a tarefa ' +
143    'desta pergunta e planeje as próximas tarefas: mcp__task-tree__plan com o que a resposta definir.',
144  answered:
145    PANEL + 'o usuário respondeu. Antes de seguir, atualize o painel: ' +
146    'mcp__task-tree__done no que a resposta concluiu e mcp__task-tree__plan com as próximas tarefas ' +
147    'que ela definiu, na ordem em que vai executar; depois start no que começa agora.',
148}
149
150// Warnings in done's result (joined after "Avisos:").
151export const DONE = {
152  noSummary: `falta o summary: chame done de novo só com summary, a resposta do turno em ${LIMIT} (vira a linha ↩)`,
153  unstarted: (ids: string[]) =>
154    `${ids.length} etapas fecharam juntas sem start (${ids.map(id => `"${id}"`).join(', ')}): o painel ficou ` +
155    'parado na primeira e elas aparecem com 0:00. Daqui em diante, marque cada etapa com mcp__task-tree__start ' +
156    'ao começá-la e com mcp__task-tree__done ao terminá-la, uma de cada vez',
157  queued: (to: string, id: string) => `a mensagem para ${to} ("${id}") ainda está na fila, sem confirmação de recebimento`,
158}
159
160// Notes in a tool call's context: the desktop app's end-of-turn box (wrap_up), a message to another
161// session only queued (`task`: the id and title of the task it was sent from, if any), and a tool
162// call with no plan open.
163export const WRAP_UP = {
164  noBrief:
165    PANEL + 'falta o resumo do turno. Antes da mensagem final, chame ' +
166    `mcp__task-tree__done com summary: a resposta em ${LIMIT}, tipo manchete (ids pode ficar vazio).`,
167  open: (list: string) =>
168    PANEL + 'o turno termina com tarefas abertas no painel: ' +
169    `${list}. Antes da mensagem final, marque com mcp__task-tree__done as que já foram ` +
170    'concluídas (inclusive as feitas por outra sessão ou por outro caminho). Deixe abertas só as que ' +
171    'de fato continuam pendentes; com ask, a tarefa da pergunta fica em andamento até a resposta.',
172}
173export const SEND = {
174  queued: (to: string, task: string | undefined) =>
175    PANEL + `a mensagem para ${to} ficou na fila, sem confirmação de recebimento. ` +
176    `Mantenha em andamento a tarefa do envio${task ? ` (${task})` : ''}: o painel avisa quando a ` +
177    'sessão começar a tratá-la, e então a conclui sozinho (pode encerrar o turno).',
178}
179export const UNPLANNED =
180  PANEL + 'há chamada de ferramenta sem plano aberto no painel. Todo pedido ' +
181  'abre pelo menos uma tarefa: chame mcp__task-tree__plan agora, mcp__task-tree__start ao começar ' +
182  'e mcp__task-tree__done só quando a tarefa estiver de fato concluída.'
183
184// The sessions this one opened: one took up a queued message (`what`: each session, with the task
185// it was sent from); one ended its turn with the task it was opened on still open (`sessions`: each
186// with that task; `open`: the panel's open tasks).
187export const SESSION = {
188  taken: (what: string[]) =>
189    PANEL + `recebimento confirmado, a sessão começou a tratar a mensagem: ${what.join('; ')}. ` +
190    'O painel já concluiu a tarefa do envio; se ela ainda tinha algo a fazer, reabra com mcp__task-tree__start.',
191  ended: (sessions: string[], open: string) =>
192    PANEL + `terminou o turno a sessão ${sessions.join(', a sessão ')}, e a tarefa de ` +
193    'origem continua aberta. Confira o relato e marque com mcp__task-tree__done o que o trabalho delegado ' +
194    `concluiu. Tarefas abertas no painel: ${open}.`,
195}
196
state/durability.ts 178 lines
1// Where each piece of the panel lives and how it comes back: the durability table. Three layers
2// hold the mod's data, and each dies at a different time:
3// - `$.state`: survives a hot reload of the mod (a saved edit), dies with the process (a crash of
4//   the hooks worker, /model, a restart of the app);
5// - `$.store`: survives everything, keyed by the session (`<name>:<session uuid>`), so a resume
6//   finds it;
7// - a variable of the module: dies with the hot reload too.
8// A hot reload does not run session.start, and a process that restarted without session.start (the
9// worker respawned) starts from an empty state: so what is kept in the store is also brought back
10// on first use after a reload (the `loaded` flags), else the first write would overwrite the store
11// with the reduced panel (H6.1) and the ties would be lost (H3.2). One table says it for every key;
12// the hooks module reads and writes through it.
13
14import type { PluginState } from 'claude-code'
15
16import type { Child } from '../types'
17
18export type Key = keyof PluginState['task-tree']
19// Where it lives: the state alone; the state with a copy in the store; the store alone; a variable
20// of the module (the hooks module's, or this one's).
21export type Lives = 'state' | 'state+store' | 'store' | 'module'
22// How the store's copy meets what the state already holds when it comes back:
23// - `current`: what the state holds wins; the store's copy is taken only when the state has none;
24// - `fill`: the state's entries keep their fields and get from the store's the ones they lack
25//   (a session's anchor, lastAnchor and rounds); an empty state takes the store's whole;
26// - `union`: the store's entries the state lacks go first, then the state's (the last `keep`);
27// - `replace`: the store's copy replaces the state's (a resume);
28// - `none`: nothing to merge (not in the store).
29export type Merge = 'current' | 'fill' | 'union' | 'replace' | 'none'
30// When the store's copy is brought back: session.start (a new process); a resume
31// (classic.SessionStart with source 'resume', which replaces); the first use (a write, a drawing or
32// a publication) after a reload that ran no session.start; the first save after such a reload.
33export type When = 'start' | 'resume' | 'first-use' | 'first-save'
34export type Row = {
35  key: string
36  lives: Lives
37  // The store's key prefix (`<store>:<session uuid>`); `field`: this key is one field of that
38  // store record (the links record holds four).
39  store?: 'session' | 'view' | 'work' | 'links' | 'sessions'
40  field?: boolean
41  merge: Merge
42  // `union`: how many of the merged entries are kept (the last ones).
43  keep?: number
44  restoredAt: When[]
45  note: string
46}
47
48export const TABLE: readonly Row[] = [
49  // The panel.
50  { key: 'groups', lives: 'state+store', store: 'session', merge: 'current', restoredAt: ['start', 'resume', 'first-use'],
51    note: 'the groups (tasks, turns, answers); a resume replaces the state with the store\'s; subagents\' open tasks come back interrupted' },
52  { key: 'tasks', lives: 'state', merge: 'none', restoredAt: [],
53    note: 'legacy: the one list before groups, moved into the main group once (adopt) at session.start or the first write' },
54  { key: 'view', lives: 'state+store', store: 'view', merge: 'current', restoredAt: ['start', 'first-use'],
55    note: 'the pane\'s view (summary or full); a new session starts with the summary; after a restart without session.start the first drawing or write brings it back (ensureView), else the pane would fall to the default view' },
56  { key: 'asks', lives: 'state+store', store: 'asks', merge: 'current', restoredAt: ['first-use'],
57    note: 'the questions asked (AskUserQuestion, ExitPlanMode, a wrap_up with ask), open or answered, the last 50: drawn under the task they were asked from, with their options; after a restart the first use brings them back (ensureAsks)' },
58  { key: 'work', lives: 'state+store', store: 'work', merge: 'current', restoredAt: ['start', 'first-use'],
59    note: 'the current work (the summary view\'s level-1 tasks); after a restart without session.start the first write brings it back (ensureWork), else the next plan would write its own over the store\'s' },
60  // The ties to other sessions: one store record (`links`), four state keys.
61  { key: 'children', lives: 'state+store', store: 'links', field: true, merge: 'fill', restoredAt: ['start', 'first-save'],
62    note: 'the sessions this one opened as the host lists them; the store keeps only their ties (id, name, title, anchor, lastAnchor, rounds), and a session the host leaves out keeps its ties in the store (keptLinks)' },
63  { key: 'sends', lives: 'state+store', store: 'links', field: true, merge: 'union', restoredAt: ['start', 'first-save'],
64    note: 'messages to other sessions not yet taken up' },
65  { key: 'received', lives: 'state+store', store: 'links', field: true, merge: 'union', keep: 20, restoredAt: ['start', 'first-save'],
66    note: 'messages of other sessions this one got (the last 20)' },
67  { key: 'launches', lives: 'state+store', store: 'links', field: true, merge: 'union', keep: 20, restoredAt: ['start', 'first-save'],
68    note: 'Agent calls not yet claimed by a subagent\'s group (the last 20)' },
69  // The turn's own: they survive a reload, not the process.
70  { key: 'waits', lives: 'state', merge: 'none', restoredAt: [], note: 'what waits on the person (a question, a plan, a permission, a wrap_up with ask)' },
71  { key: 'engine', lives: 'state', merge: 'none', restoredAt: [], note: 'the model and effort of the main loop, published for the card' },
72  { key: 'rate', lives: 'state', merge: 'none', restoredAt: [], note: 'the output rate ("n toks/s") of the main loop\'s last model calls, live or the last turn\'s mean, published for the card' },
73  { key: 'git', lives: 'state', merge: 'none', restoredAt: [], note: 'this session\'s repository (folder name) and branch, read off .git/HEAD at each publication and clock tick, published for the card' },
74  { key: 'ended', lives: 'state', merge: 'none', restoredAt: [], note: 'sessions whose turn ended, until the main loop is reminded' },
75  { key: 'childrenError', lives: 'state', merge: 'none', restoredAt: [], note: 'why the host\'s list of sessions failed, drawn in the panel' },
76  { key: 'brief', lives: 'state', merge: 'none', restoredAt: [], note: 'done\'s summary until the turn ends (the ↩ line); in the state so a reload mid-turn keeps it' },
77  { key: 'recap', lives: 'state', merge: 'none', restoredAt: [], note: 'wrap_up\'s recap until the turn ends (the turn\'s answer for the card); in the state so a reload between the wrap_up and the end of the turn keeps it' },
78  { key: 'collapsed', lives: 'state', merge: 'none', restoredAt: [], note: 'the lines the person folded or opened (the folds atom)' },
79  { key: 'turnEnded', lives: 'state', merge: 'none', restoredAt: [], note: 'when this session\'s turn ended, until the next prompt: what it left open is drawn with no close and no clock' },
80  { key: 'turnAborted', lives: 'state', merge: 'none', restoredAt: [], note: 'the main loop\'s last turn ended interrupted (isAborted), until the next request: a message of another session then gets the reminder to replan' },
81  // The store alone.
82  { key: 'sessions', lives: 'store', store: 'sessions', merge: 'none', restoredAt: [], note: 'the sessions whose panel the store keeps, newest first, KEEP of them' },
83  // Variables of the module: a hot reload starts them over.
84  { key: 'loaded', lives: 'module', merge: 'none', restoredAt: [], note: 'which store copies this module brought back (groups, links): a reload brings them back again on first use' },
85  { key: 'keptLinks', lives: 'module', merge: 'none', restoredAt: ['start', 'first-save'], note: 'the links record as the store holds it, refilled whenever it is read or written; the ties of sessions the host leaves out' },
86  { key: 'ownId', lives: 'module', merge: 'none', restoredAt: [], note: 'this session\'s local_ id (asked again after a reload)' },
87  { key: 'refreshedAt', lives: 'module', merge: 'none', restoredAt: [], note: 'when the host\'s list was last read' },
88  { key: 'drawNow', lives: 'module', merge: 'none', restoredAt: [], note: 'the clock of the drawing in progress' },
89  { key: 'drawSize', lives: 'module', merge: 'none', restoredAt: [], note: 'the last drawing\'s size, published' },
90  { key: 'drawRows', lives: 'module', merge: 'none', restoredAt: [], note: 'the last drawing\'s lines, published' },
91  { key: 'renderError', lives: 'module', merge: 'none', restoredAt: [], note: 'the last drawing\'s error, published' },
92  { key: 'actions', lives: 'module', merge: 'none', restoredAt: [], note: 'the pane\'s buttons by key: a press between a reload and the next drawing finds none' },
93  { key: 'ticker', lives: 'module', merge: 'none', restoredAt: [], note: 'the 1 s clock, started again by the pane\'s drawing (the app draws it when the mod loads) and by the next start, refresh or tool call' },
94  { key: 'poller', lives: 'module', merge: 'none', restoredAt: [], note: 'the 3 s reread of the sessions, started again likewise (the pane\'s drawing too)' },
95  { key: 'engineSeen', lives: 'module', merge: 'none', restoredAt: [], note: 'the host\'s model per session, a minute each' },
96  { key: 'samples', lives: 'module', merge: 'none', restoredAt: [], note: 'the turn\'s model calls so far (tokens out, duration), the last 20, for the output rate; a reload mid-turn starts them over (the state keeps the last rate)' },
97  { key: 'breaksMemo', lives: 'module', merge: 'none', restoredAt: [], note: 'a drawing\'s cache of the cards\' splits' },
98  { key: 'unplanned, planned, answeredInChat', lives: 'module', merge: 'none', restoredAt: [], note: 'the request\'s reminders: calls without a plan, whether it planned, whether a question is answered by the next message' },
99  { key: 'lastPublished, lastLinks, lastStatus', lives: 'module', merge: 'none', restoredAt: [], note: 'the last published file (without its clocks and panes), links record and status text: written or set again only when they change; a reload writes each once more' },
100  { key: 'home', lives: 'module', merge: 'none', restoredAt: [], note: 'the home folder of the published files, asked of the host once per module' },
101  { key: 'usedReceipts', lives: 'module', merge: 'none', restoredAt: [], note: 'the messages received by other sessions already matched to a send of this one (watchSends): each closes one task; a reload starts it over' },
102]
103
104// The keys of `$.state`: every key the hooks module reads or writes has a row (durabilidade.test.ts
105// fails on one that has not).
106export const STATE_KEYS = TABLE.filter(r => r.lives === 'state' || r.lives === 'state+store').map(r => r.key as Key)
107export const STORES = [...new Set(TABLE.flatMap(r => (r.store ? [r.store] : [])))]
108// The four state keys the links record holds.
109export const LINK_KEYS = TABLE.filter(r => r.store === 'links').map(r => r.key as 'children' | 'sends' | 'received' | 'launches')
110
111export const rowOf = (key: string): Row => {
112  const row = TABLE.find(r => r.key === key)
113  if (!row) throw new Error(`task-tree: chave sem linha na tabela de durabilidade: ${key}`)
114  return row
115}
116export const stateOf = <K extends Key>(key: K) => ({ plugin: 'task-tree', key }) as const
117// Whether the table brings a key back at that moment.
118export const restoredAt = (key: string, when: When) => rowOf(key).restoredAt.includes(when)
119// The store's key of a session's copy.
120export const storeKey = (key: string, id: string) => `${rowOf(key).store}:${id}`
121// How many sessions' panels the store keeps for a resume.
122export const KEEP = 30
123
124// Which store copies this module brought back since it loaded ('groups', 'links'): a hot reload
125// starts the set empty, so the first use after it brings them back again.
126export const loaded = new Set<string>()
127
128// What the store keeps of a session this one linked to: its ties to the tasks, not its state.
129export type Link = Pick<Child, 'id' | 'name' | 'title' | 'anchor' | 'lastAnchor' | 'rounds'>
130export const linkOf = (c: Child): Link => ({ id: c.id, name: c.name, title: c.title, anchor: c.anchor, lastAnchor: c.lastAnchor, rounds: c.rounds })
131
132// The store's copy meeting the state's, by the row's `merge` (see Merge).
133export function merge<T>(key: string, cur: T[] | undefined, old: T[]): T[] {
134  const row = rowOf(key)
135  switch (row.merge) {
136    case 'union': {
137      const now = cur ?? []
138      const seen = new Set(now.map(x => JSON.stringify(x)))
139      const all = [...old.filter(x => !seen.has(JSON.stringify(x))), ...now]
140      return row.keep !== undefined ? all.slice(-row.keep) : all
141    }
142    case 'fill': {
143      if (cur === undefined || cur.length === 0) return old
144      return (cur as unknown as Child[]).map(c => {
145        const o = (old as unknown as Link[]).find(k => k.id === c.id)
146        return (o ? { ...c, anchor: c.anchor ?? o.anchor, lastAnchor: c.lastAnchor ?? o.lastAnchor, rounds: c.rounds ?? o.rounds } : c) as unknown as T
147      })
148    }
149    case 'replace':
150      return old
151    case 'current':
152      return cur !== undefined ? cur : old
153    case 'none':
154      return cur ?? []
155  }
156}
157// `current` for a single value: what the state holds, else the store's.
158export const current = <T>(cur: T | undefined, old: T): T => (cur !== undefined ? cur : old)
159
160// The store's copy of a key for a session (`id`, this one's by default); whatever the store answers,
161// to be checked by the caller. Throws as the store does: the callers decide what a failure means.
162export async function loadStore($: any, key: string, id?: string): Promise<unknown> {
163  return $.store.get(storeKey(key, id ?? (await $.session.id())))
164}
165export async function saveStore($: any, key: string, value: unknown): Promise<void> {
166  await $.store.set(storeKey(key, await $.session.id()), value)
167}
168// The panel's copy, and the list of the sessions that have one (the newest KEEP; the others' copies
169// are deleted).
170export async function saveGroups($: any, groups: unknown): Promise<void> {
171  const id: string = await $.session.id()
172  await $.store.set(storeKey('groups', id), groups)
173  const known = ((await $.store.get(rowOf('sessions').store as string)) as string[] | undefined) ?? []
174  const order = [id, ...known.filter(k => k !== id)]
175  for (const old of order.slice(KEEP)) await $.store.delete(storeKey('groups', old))
176  await $.store.set(rowOf('sessions').store as string, order.slice(0, KEEP))
177}
178