SLOPSHOPPER

agent-board

A local board for coding agents: shared progress, persistent questions and live replies. Optional Claude Code pane.

paneguardcommandprompttimeragents
★ 29v0.24.0MITupdated 2026-10-26WenyuChiou/agent-board
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · agent-board
│ ┃ Progress ✕ › fix the failing auth test and add an audit log call │ ┃ Progress │ ┃ ⏺ Read(src/auth.ts) │ ┃ ● 0 waiting ▸ 0 in progress ⚑ 0 due soon ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ No work in progress right now. ⎿ Added 2 lines, removed 1 line │ ┃ Once Claude starts on a Task list, it shows ⏺ Bash(bun test) │ ┃ up here. You can also type "+ project: thing ⎿ 3 pass, 1 fail │ ┃ to do" below to add one. │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ Ask something; start with + to add a task ⏎ │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ Weekly report Notifications: on Legend │ ┃ From your progress file › /progress │ ⎿ agent-board: Progress pane opened: /Users/dev/.claude/progress.m │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Progress
Progress ● 0 waiting ▸ 0 in progress ⚑ 0 due soon ✓ 0 done today No work in progress right now. Once Claude starts on a Task list, it shows up here. You can also type "+ project: thing to do" below to add one. Ask something; start with + to add a task ⏎ Send Weekly report Notifications: on Legend From your progress file
README

<h1 align="center">Agent Board</h1>

<img alt="An animated board in six scenes: Claude Code, Codex, Antigravity and OpenCode report to one terminal board; their steps tick off live; Codex's question jumps to the top; one key copies the answer; a message written on the board's page reaches Codex and its reply shows, a preview coming in 0.24.2; and one npx command connects every agent, on npm from 0.24.0. Synthetic data." src="docs/images/cover-en-dark.svg" width="860">

<a href="#supported-agents"><img src="docs/images/agents/chip-claude-code.svg" height="28" alt="Claude Code"></a> <a href="#supported-agents"><img src="docs/images/agents/chip-codex.svg" height="28" alt="Codex"></a> <a href="#supported-agents"><img src="docs/images/agents/chip-antigravity.svg" height="28" alt="Antigravity"></a> <a href="#supported-agents"><img src="docs/images/agents/chip-opencode.svg" height="28" alt="OpenCode"></a> <a href="#supported-agents"><img src="docs/images/agents/chip-cursor.svg" height="28" alt="Cursor"></a> <a href="#supported-agents"><img src="docs/images/agents/chip-grok-build.svg" height="28" alt="Grok Build"></a> <a href="#supported-agents"><img src="docs/images/agents/chip-muse-code.svg" height="28" alt="Muse Code"></a> <a href="#supported-agents"><img src="docs/images/agents/chip-hermes-agent.svg" height="28" alt="Hermes Agent"></a> <a href="#supported-agents"><img src="docs/images/agents/chip-openclaw.svg" height="28" alt="OpenClaw"></a> <a href="#supported-agents"><img src="docs/images/agents/chip-deepseek-harness.svg" height="28" alt="DeepSeek Harness"></a>

You have Claude Code on one project, Codex on another and Antigravity on a third, and when one of them stops to ask you something, you only find out when you happen to switch back. Agent Board puts that question in front of you the moment it's asked, with a button for each answer. It runs on your computer and keeps everything in one Markdown file.

Quick start

With Node.js 22+, in any terminal:

npx @wenyuchiou/agent-board setup
npx @wenyuchiou/agent-board setup --apply
npx @wenyuchiou/agent-board --serve

The first command finds the agents on this computer and shows what setup would change, writing nothing. The second makes the change, and the third opens the board's page. To set up one agent only, add --agent and its name, such as --agent codex. More in the setup guide.

Then:

  • Start a new session of each agent: a session that was already open is not connected.
  • Claude Code connects through its plugin instead: claude plugin marketplace add WenyuChiou/agent-board, then claude plugin install agent-board@agent-board.
  • Codex: trust its hooks in /hooks (how).
  • OpenCode, OpenClaw: reload their plugins.
  • dot and other web chats: paste the last section of AGENTS.md into the agent's instructions, then paste the block it writes into the board's page.

To install from a clone of this repository instead, with Node.js 18+:

git clone https://github.com/WenyuChiou/agent-board.git
cd agent-board
node scripts/setup-agent.mjs
node scripts/setup-agent.mjs --apply

setup-agent.mjs takes the same options as npx @wenyuchiou/agent-board setup, such as --agent codex. On Windows, use the exact paths it prints. What each one writes: setup guide.

Paste this into the agent you already use:

Install Agent Board from https://github.com/WenyuChiou/agent-board on this computer.
Read its AGENTS.md, README and setup guide. Identify my current host and the other installed coding agents, then preview the common installer: node scripts/setup-agent.mjs.
Preserve existing settings and the shared progress path. Follow my approval requirements for persistent changes, then apply the applicable hooks, plugins and board command. Do not require or call another AI product to bootstrap this one.
Complete any required scoped host trust, read the settings back, and have this session report its own real task progress. Open the interactive board.
Verify actual hook events separately for CLI, desktop App and IDE. Installed files, transport acceptance and an ACK are different from a working progress connection.
Tell me which existing sessions need reload and which adapters remain unsupported or unverified. Never scan private conversations or silently skip a blocker.

Key commands

Open the board in your agent

| | Agent | Board command | |:-:|---|---| | <img src="docs/images/agents/claude-code.svg" width="20" alt="Claude Code"> | Claude Code | /progress | | <img src="docs/images/agents/codex.svg" width="20" alt="Codex"> | Codex CLI and App | $agent-board | | <img src="docs/images/agents/antigravity.svg" width="20" alt="Antigravity"> | Antigravity | /agent-board (not verified yet) | | <img src="docs/images/agents/opencode.svg" width="20" alt="OpenCode"> | OpenCode | /board | | <img src="docs/images/agents/cursor.svg" width="20" alt="Cursor"> | Cursor | /agent-board (not verified yet) | | <img src="docs/images/agents/grok-build.svg" width="20" alt="Grok Build"> | Grok Build | /agent-board (not verified yet) | | <img src="docs/images/agents/muse-code.svg" width="20" alt="Muse Code"> | Muse Code | /agent-board (not verified yet) | | <img src="docs/images/agents/hermes-agent.svg" width="20" alt="Hermes Agent"> | Hermes Agent | /agent-board | | <img src="docs/images/agents/openclaw.svg" width="20" alt="OpenClaw"> | OpenClaw | /agent-board | | <img src="docs/images/agents/deepseek-harness.svg" width="20" alt="DeepSeek Harness"> | DeepSeek Harness | /agent-board (not verified yet) |

In the Claude desktop app, which cannot show the pane, the footer shows the board's status and /progress gives you a link to the board's page, where you can answer questions and archive work; if that page cannot start, it opens the read-only page. The board page needs the board script that /progress connect installs, or the next session start updates.

Open the board anywhere

WhereCommand
Any terminalnode ~/.agent-board/agent-board.mjs --watch (--html opens it in the browser)
A browsernode ~/.agent-board/agent-board.mjs --serve
A chat app, through an agent (Hermes Agent, OpenClaw)node ~/.agent-board/agent-board.mjs --chat
Check one agent's setupnode ~/.agent-board/agent-board.mjs --codex-doctor --agent codex

In a chat app, --chat prints the board as plain text cut to fit one message, with no page or link. It shows your home folder as ~ and hides the credential forms it knows, such as token=...; a secret in another shape is not recognised, so keep secrets out of the board.

In a terminal, --watch works like the pane:

KeyDoes
↑ ↓, EnterPick a line, open it
xTick the next step of your own task; it goes into the work log
dConfirm one of your own tasks, or a stopped session's work, done
aArchive one of your own tasks, or a stopped session's block
1–9, y / nCopy the answer to another agent's question, to paste in that agent
cCopy a command, or the command that reopens the session that asked
h, uMark another agent's question already answered: hidden on this computer, nothing sent; show the hidden ones again
iShow or fold the tasks idle more than 7 days
o, qOpen the browser view; quit

Finished work leaves the board by itself. The board writes progress.md as carefully as the pane does, and leaves other sessions' blocks alone unless you archive one that has stopped or confirm it done.

Supported agents

| | Agent | Runs in | On the board | Reply from the board | |:-:|---|---|---|---| | <img src="docs/images/agents/claude-code.svg" width="20" alt="Claude Code"> | Claude Code | CLI | Its own pane: every task and turn | In the pane | | <img src="docs/images/agents/codex.svg" width="20" alt="Codex"> | Codex | CLI, App | Six hooks, plus the steps it reports | Copy; send to a connected session | | <img src="docs/images/agents/antigravity.svg" width="20" alt="Antigravity"> | Antigravity | CLI, App, IDE | Four hooks, plus the steps it reports | Copy; send to a connected CLI | | <img src="docs/images/agents/opencode.svg" width="20" alt="OpenCode"> | OpenCode | CLI | Session events, plus steps from its progress tool | Copy | | <img src="docs/images/agents/cursor.svg" width="20" alt="Cursor"> | Cursor | IDE, CLI | Hooks | Copy | | <img src="docs/images/agents/grok-build.svg" width="20" alt="Grok Build"> | Grok Build | CLI | Four hooks | Copy; --allow-resume once a session ended | | <img src="docs/images/agents/muse-code.svg" width="20" alt="Muse Code"> | Muse Code | CLI | Two hooks (stop, end) | Copy | | <img src="docs/images/agents/hermes-agent.svg" width="20" alt="Hermes Agent"> | Hermes Agent | Chat | The board and its skill; its own hooks are still to come | Copy | | <img src="docs/images/agents/openclaw.svg" width="20" alt="OpenClaw"> | OpenClaw | Chat | Its plugin's session events | Copy | | <img src="docs/images/agents/deepseek-harness.svg" width="20" alt="DeepSeek Harness"> | DeepSeek Harness | CLI | Its plugin's session events | Copy | | | dot, other web chats | Web | A block you paste | Answer in its chat |

Hooks say whether a session is working or waiting; its steps and when it is done come from the agent's own reports. Gemini CLI is still a target (--agent gemini) for paid and enterprise use: see setup.

AgentChecked
Claude CodeClaude Code 2.1.301 in CI
Codex0.163.0-alpha.10.2 app-server (record); CLI in the 0.23.5 live run; App untested
AntigravityCLI on Windows (record); the App opens the board but does not report a question yet (#285); IDE untested
OpenCodePlugin SDK 1.19.3 (record)
CursorUntested
Grok Build1.0.62 with a mock model
Muse CodeUntested
Hermes Agent0.19.0 with a mock model
OpenClawSource contract and synthetic callbacks (record); app untested
DeepSeek HarnessPlugin loader (record); app untested
dot, other web chatsUntested

"With a mock model" means the real agent ran against a stand-in model. A live run of 0.23.5 in each app is in the parity record: there a question from Codex CLI or OpenCode did not reach the board (fixed after 0.23.5, #213), and neither did one from the Antigravity app (#285).

What you see

  • Answer the one that is waiting. A question from any agent sits at the top, with a button for each answer. Once you turn messaging on (--messages-dir), Send reaches a session that has a live connection; otherwise Copy gives you the reply to paste into its chat.
  • Check the others are still moving. Each agent's task shows its current step and when it last reported. Work nobody has touched for a week folds into one N idle 7+ days line.
  • Clear what is finished or dead. Finished work moves to a monthly archive by itself. A session that stopped halfway can be archived, confirmed done or taken over.
  • Tell an agent something without going there. Type a reply on the board: with messaging on, it goes straight to that session if it is connected; otherwise the board copies it for you to paste.

The terminal board (--watch), with Codex's question open: y or n copies the answer.

<img alt="The terminal board with a Codex CLI question open; y or n copies the answer. Synthetic data." src="docs/images/board/codex-en-dark.svg">

Claude Code's pane, beside the chat: the same board, with the question's answers as buttons.

<img alt="Claude Code's progress pane: counts of what is waiting, in progress, due soon and done today, then the question waiting on you, a release plan, with a button for each answer, a box for another reply, a button to hide it once answered, and a line for the 4 more waiting on you. Synthetic data." src="docs/images/pane-en-dark.svg">

Both pictures are drawn by the board's own code from synthetic data. Native hook evidence is recorded separately.

The board's page

The board commands above open the board as a page in your browser (in a chat app they print plain text instead, with no link). From any terminal, node ~/.agent-board/agent-board.mjs --serve does the same; run it again to reopen the page. On the page you can:

  • Answer a question: Send to a session that is online (with messaging on), or Copy the reply.
  • Tick your next step, archive a task, or Archive now to clear everything finished.
  • Already answered, hide a question you answered in its own chat: hidden on this computer until that session asks something new; nothing is sent.
  • Paste a block from a web chat such as Muse or dot.

The page listens on 127.0.0.1 only, and its address carries a random key: anything on this computer that has the link can use it. It keeps running after you close the tab; Close the board stops it. Add --lan to open it on a phone on the same Wi-Fi: that link is plain HTTP and anyone who has it can use the page, so use it only on a network you trust. Details and limits are in the guide.

How it works

<img alt="Claude Code sessions, other agents through their hooks, and you all write one Markdown file, progress.md; the pane, progress.html, the archive and the status line read it. Synthetic data." src="docs/images/flow-en.svg" width="100%">

Each agent's hooks write what its session is doing into its own block of one local progress.md (a fresh install uses ~/.agent-board/progress.md). Task steps come only from the agent's own reports, and no private conversation is read. The board reads the file without calling a model; the optional Claude plugin can sum up turns with Claude's model, as the guide says. You can read and edit the file too: the file format. In English, 繁體中文 and 简体中文.

Troubleshooting

Start with the doctor. It checks what the installer wrote for one agent and changes nothing:

node ~/.agent-board/agent-board.mjs --codex-doctor --agent codex

Use the agent's own name after --agent (the doctor keeps its old name for every agent). installed says the files are in place and ready that the agent has a status adapter. connected stays unverified: only a new session that shows up on the board proves the connection.

You seeTry
An agent is not on the boardStart a new session of it; one that was already open is not connected. Codex also needs its hooks trusted in /hooks.
Nothing changed after installingRun npx @wenyuchiou/agent-board setup again without --apply (from a checkout: node scripts/setup-agent.mjs) and read its conflicts and adapter-pending lines; edited files are left alone and listed there.
An old version is still runningA running session keeps the version it loaded: start a new one. To update, see the guide.
An agent shows up twiceIt may be connected both ways: by the Claude plugin's /progress connect and by setup. /progress connect status lists what the plugin connected; remove one with /progress connect remove or --codex-remove --agent <name>.
A question will not go awayAnswer it in its own chat, then press Already answered, hide (h). After 3 days it moves into Earlier unanswered questions, and after 7 days into Older unanswered questions.

More answers are in the guide's FAQ and Troubleshooting. Found a bug? Open an issue with the bug form, paste the doctor's output, and follow the guide's safe-report steps: no session ids, home-folder paths or tokens.

The pane follows Claude Code's theme (/config, then Theme); the page and progress.html follow your computer's light or dark mode until you pick Light or Dark at the top right of the page. Every mark has its own shape, so it reads without colour; the pages draw a step still to do as a small dot rather than a ring, so it does not look like a button:

| Mark | Means | | Mark | Means | |:-:|---|---|:-:|---| | ● | Waiting on you | | ◐ | Running, or waiting on someone else | | ▸ | Doing now | | ! | Stuck | | ○ • | To do (a ring in the pane, a small dot on the pages) | | ⚑ | Due within two weeks | | ✓ | Done | | – | Cancelled |

Documentation

ReadFor
This READMEThe first five minutes
GuideDaily use, keys, the page, update and uninstall, FAQ, troubleshooting
SetupWhat the installer writes for each agent
Codex · Messaging · File format · Local exchange · Remote reportersReference
AGENTS.md · Contributing · SecurityWorking on the code; how a non-Claude agent reports by hand

License

MIT. Agent logos are from Simple Icons (CC0) and Lobe Icons (MIT) and belong to their owners; an agent without a logo is shown by its initial.

Source 49 files
hooks/register.tsx 4259 lines
1import { BOARD_SCRIPT } from './board-script'
2import { connectRecordPath, hasOwnConnectData, recoveryUnreadableCode } from './connect-pure'
3import { sha256 } from './sha256'
4import { CONNECT_BOOTSTRAP, CONNECT_EXECUTE, workerRequest, workerStage } from './connect-worker'
5import { LEDGER_LINK, LINK_EXISTS, LINK_KEPT_SOURCE } from './ledger-move'
6import type { LegacyConnectRequest, ConnectState } from '../scripts/connect-transaction'
7import { atom, read, update } from 'claude-code'
8import type { EngineInterface, PluginOptions, PromptSubmitResult, Register } from 'claude-code'
9
10import type { RenderSurface } from 'claude-code'
11
12import type { CutBlock, ProgressAnswer, ProgressMirror, ProgressPr, ProgressSnapshot, ResolveResult } from '../types'
13import {
14  addManualTask,
15  confirmMatches,
16  confirmTask,
17  confirmedTarget,
18  CUT_SETTLE_MS,
19  cutBlockLines,
20  cutOffBlocks,
21  cutOffSpans,
22  extractAutoBlock,
23  manualTaskCount,
24  removeAutoBlock,
25  setManualStep,
26  shortId,
27  scanBlocks,
28  takeArchivable,
29  archivableAsking,
30  takeManualDone,
31  takeTask,
32  upsertAutoBlock,
33} from './blocks'
34import type { ConfirmedTask, ConfirmRef } from './blocks'
35import { ciFinished, GH_FIELDS, prFromGh } from './github'
36import {
37  appendJournal,
38  countDay,
39  journalLine,
40  archivePath,
41  journalPath,
42  localDay,
43  parseJournal,
44  previousMonth,
45  progressReport,
46} from './journal'
47import type { JournalEntry } from './journal'
48import {
49  applyTaskCreate,
50  applyTaskUpdate,
51  applyTodoWrite,
52  asData,
53  cleanContext,
54  cleanTitle,
55  clip,
56  completedSince,
57  applyCompletionOverlay,
58  completionKey,
59  completionOverlay,
60  completionOfBlock,
61  completionPrefix,
62  isCompletionOverlay,
63  dataBlock,
64  emptyMirror,
65  fallbackTitle,
66  isAllDone,
67  fromTaskList,
68  markAnswered,
69  noticesOf,
70  resumeNote,
71  stateOf,
72} from './mirror'
73import type { ListedTask } from './mirror'
74import { baseName, countSteps, doneKey, isHereProject, isTaskComplete, parseProgress, progressTail, STALE_MS } from './parse'
75import type { ProgressProject } from '../types'
76import { dashboardHtml } from './dashboard'
77import { peekInbox, redact } from './board'
78import type { PendingTurn } from './board'
79import { paletteFor } from './palette'
80import { drawPane, pressTarget, reviseKey } from './pane'
81import type { PaneActions, PaneModel, PaneUi, PressResult } from './pane'
82import type { PressOutcome } from '../types'
83import { isQuickAdd, parseQuickAdd, quickAddWords } from './quick'
84import type { Lang, Strings } from './i18n'
85import { RULE_ID, RULES } from './rule'
86import { chosenLang, langOf, plainReason, resolveLang, STRINGS } from './strings'
87import { answerTurns, applyDigest, clearAsked, clearBackgroundJobs, cleanHandoff, finishBackgroundJob, isAskingYou, isPersonsAnswer, isWork, lastQuestion, markAsked, needsRefresh, parseDigest, replyData, requestData, startBackgroundJob, turnTools } from './digest'
88import type { Digest } from './digest'
89import { boardScript, isNodeSupported, isSetupState, isWindowsHost, nodeVersionOf, openclawPrinted, openclawSavedBy, openclawTooOld, psQuote, setupLines, setupProblemText, setupSeed } from './connect'
90import type { SetupProblem } from './connect'
91import { liveBoardArgv, liveBoardPage, liveBoardScript } from './live-page'
92import {
93  ANSWER_KEEP,
94  answerKey,
95  answerAgentPrompt,
96  restoreAgentBoardQuestion,
97  withAgentPrompts,
98  claimAction,
99  CLAIM_MS,
100  failedName,
101  inboxOrder,
102  isInboxFile,
103  isQuestionHold,
104  isQuestionStale,
105  isTextLog,
106  isTranscriptPath,
107  keepInboxDone,
108  keepInboxFailed,
109  MAX_TRIES,
110  RETAKE_TRY,
111  RETAKE_PER_LOOK,
112  promptKey,
113  setAsideOf,
114  readClaim,
115  readTurn,
116  retakenFailedName,
117  takenName,
118  turnOrder,
119  normalizeTurnOrder,
120  isBoardTurn,
121  transcriptTail,
122  turnsToDigest,
123} from './inbox'
124import type { AgentTurn } from './inbox'
125import { keepRecent, liveToldKeys, remindersOf } from './remind'
126import { addPruned, boardRecords, boardTag, companionKeys, countPruned, currentPart, doneRecord, inboxDropped, isExpiredRecord, isSameWrite, monthParts, overCapKeys, pruneBatch, prunedNote, prunedSince, prunedTotal, readMonthParts, RETENTION, sizeWarnings, staleFailedInbox, staleInboxTemps, staleStoreTemps, storeBytes, STORE_FILE, unseenFailedInbox } from './retention'
127import type { ListedFile, StoreRecord } from './retention'
128import { INBOX_DROPPED_FILE } from './hook-reporters'
129import { agentName, answeredJson, answeredPath, buildView, mirrorMatchesTask, parseAnswered, parseSeen, seenJson, seenPath, statusLine, viewCache, withAnswered, withSeen } from './view'
130import type { StepRef, TaskRef, TaskRow, View, WaitingItem } from './view'
131import { acknowledgementKeys, archiveQuestionsKept, captureCovers, captureHashOf, captureJson, capturePath, copiesToKeep, cutOffScopes, emptyQuestions, identitiesToRetire, parseCapture, withCaptures, isAskedBy, isQuestionProof, isQuestionSnapshot, isTornLedger, ledgerLeftovers, migratedAnswered, observeQuestions, ownQuestionsKey, parseQuestions, prunableProofs, questionFilesKey, questionProofJson, questionProofOf, questionProofPath, questionScope, questionWords, questionsJson, questionsPath, resolveQuestion, resolvedQuestionsToRetire, QUESTION_FILES_KEPT } from './questions'
132import type { HeldCapture, HeldProof, QuestionAnswerEvidence, QuestionCapture, QuestionProofs, QuestionRecord } from './questions'
133
134const PANE = 'agent-board'
135const POLL_MS = 2000
136const TICK_MS = 30 * 1000
137// How often the inbox of other agents' turns is looked at: a stat, and a read when it changed.
138const INBOX_MS = 5 * 1000
139// How often a pull request whose checks are still running is looked at again.
140const PR_MS = 2 * 60 * 1000
141// How often a session looks for work old enough to archive, besides at its start.
142const ARCHIVE_MS = 60 * 60 * 1000
143const DAY = 24 * 60 * 60 * 1000
144// How long a model call may take before it counts as no answer: one that never comes back
145// must not hold the inbox, or a task's name, for good.
146const MODEL_MS = 30 * 1000
147// takeAgentTurns can ask three times when the block moves under it:
148// ten files at three 30-second calls just fit CLAIM_MS.
149const QUEUE_MAX = 10
150// Why an inbox file is still there, kept in the store's `inboxFailed`.
151const NO_SUMMARY = 'the model gave no summary and the reply asks nothing'
152const NOT_KEPT = 'the block was not on the board when read back'
153
154const EMPTY: ProgressSnapshot = { path: '', isMissing: true, error: null, mtimeMs: 0, size: 0, projects: [] }
155
156const snapshot = atom({ plugin: 'agent-board', key: 'snapshot' } as const, EMPTY)
157const here = atom({ plugin: 'agent-board', key: 'here' } as const, [] as string[])
158const mine = atom({ plugin: 'agent-board', key: 'mine' } as const, null as ProgressMirror | null)
159const now = atom({ plugin: 'agent-board', key: 'now' } as const, 0)
160const hasTaskTools = atom({ plugin: 'agent-board', key: 'hasTaskTools' } as const, true)
161
162const expanded = atom({ plugin: 'agent-board', key: 'expanded' } as const, '')
163const pressResults = atom({ plugin: 'agent-board', key: 'pressResults' } as const, {} as Record<string, PressResult>)
164const answer = atom({ plugin: 'agent-board', key: 'answer' } as const, null as ProgressAnswer | null)
165const resolveResult = atom({ plugin: 'agent-board', key: 'resolveResult' } as const, null as ResolveResult | null)
166const isNotifyOn = atom({ plugin: 'agent-board', key: 'isNotifyOn' } as const, true)
167// The person's /config theme, which picks the dark or light 和色 set.
168const theme = atom({ plugin: 'agent-board', key: 'theme' } as const, 'dark')
169// Steps finished today, from the journal.
170const today = atom({ plugin: 'agent-board', key: 'today' } as const, 0)
171const isLegendOpen = atom({ plugin: 'agent-board', key: 'isLegendOpen' } as const, false)
172// The language the pane speaks (strings.ts resolveLang), settled at session start.
173const lang = atom({ plugin: 'agent-board', key: 'lang' } as const, 'zh-TW')
174// Which folded sections the person opened; kept across sessions in $.store.
175const opened = atom({ plugin: 'agent-board', key: 'opened' } as const, [] as string[])
176// When the pane first saw each hand-kept task done (by doneKey), to archive it a day later.
177const doneSeen = atom({ plugin: 'agent-board', key: 'doneSeen' } as const, {} as Record<string, number>)
178// Questions the person marked answered (view.ts askKeys), read from progress-answered.json
179// beside progress.md, which every session on this machine and the board script share.
180const hiddenAsks = atom({ plugin: 'agent-board', key: 'hiddenAsks' } as const, [] as string[])
181const questionLedger = atom({ plugin: 'agent-board', key: 'questionLedger' } as const, emptyQuestions())
182const hasQuestionsProblem = atom({ plugin: 'agent-board', key: 'hasQuestionsProblem' } as const, false)
183const hasPendingQuestionsProblem = atom({ plugin: 'agent-board', key: 'hasPendingQuestionsProblem' } as const, false)
184const firstSeen = atom({ plugin: 'agent-board', key: 'firstSeen' } as const, {} as Record<string, number>)
185// The review whose reply box Needs changes opened.
186const revising = atom({ plugin: 'agent-board', key: 'revising' } as const, '')
187
188const messageOf = (error: unknown): string => (error instanceof Error ? error.message : String(error))
189
190const wordsOf = async ($: EngineInterface): Promise<Strings> => STRINGS[langOf(await read($, lang)) ?? 'zh-TW']
191
192// Module state: reset by a reload, which only happens between turns.
193let isPolling = false
194let isTurnRunning = false
195let writes: Promise<unknown> = Promise.resolve()
196let questionTrims: Promise<void> = Promise.resolve()
197let settingSaves: Promise<unknown> = Promise.resolve()
198let askSeq = 0
199let pressSeq = 0
200let isPaneOpen = false
201let hasNoPaneSurface = false
202let hasPaneOpenAnswer = false
203// /progress runs still starting the board page; the footer says so until each one answers.
204let startingPages = 0
205let lastStatus: string | undefined
206let isCheckingPr = false
207// gh is tried until it fails to start (not installed) or says it is signed out.
208let hasGh = true
209let countedDay = ''
210let lastDashboard = ''
211// Set once the questions and due dates already on file at the start are counted as told.
212let isReminderSeeded = false
213let isReminding = false
214// What the person said this turn, for summing the turn up when it ends.
215let turnPrompt = ''
216// Whether the prompt starting the next turn is the person's own message, as prompt.submit
217// saw it.
218let isNextTurnAnswer = false
219// turn.start has no origin; undefined alone permits its legacy text fallback.
220let nextTurnNotification: boolean | undefined
221let hasTurnBackgroundLaunch = false
222let turnBackgroundJobIds = new Set<string>()
223let turnBackgroundLaunchesWithoutId = 0
224let hasBackgroundActivity = false
225let pendingBackgroundSaves = 0
226let backgroundChanges: Promise<unknown> = Promise.resolve()
227let isDraining = false
228// Other agents' turns are summed up one session's files at a time, after the drain that took
229// them has let go of the inbox.
230let digests: Promise<unknown> = Promise.resolve()
231const inboxQueued = new Set<string>()
232// Set once this session has made sure the store has a record of the inbox (see drainInbox).
233let hasInboxRecord = false
234let inboxSeen = -1
235let inboxListed = 0
236// Each retained file and reason is named once, so two stuck files cannot take turns notifying.
237const inboxNoticesShown = new Set<string>()
238const inboxRetakeRefused = new Map<string, number>()
239const inboxHoldMissing = new Map<string, number>()
240const pendingJournals = new Map<string, Record<string, string[]>>()
241const pendingInboxQuestions = new Map<string, PendingTurn>()
242const heldInboxQuestions = new Set<string>()
243let heldInboxQuestionsAt: number | null = null
244const droppingInboxQuestions = new Set<string>()
245let pendingQuestionRetry: Promise<PendingTurn[]> | null = null
246let hasRestoredInboxQuestions = false
247const journalNoticesShown = new Set<string>()
248let journalNoticeKey = ''
249let dashboardAt = 0
250
251// CLAUDE_PROGRESS_FILE wins; then CLAUDE_CONFIG_DIR, where Claude Code keeps its own files
252// when moved; otherwise ~/.claude/progress.md. USERPROFILE comes before HOME because that is
253// where Claude Code keeps ~/.claude on Windows even when HOME is set (Git Bash, MSYS); it is
254// unset on macOS and Linux.
255// Worked out once per session (session.start); the 2-second poll and the inbox look reuse it.
256let knownPath: string | null = null
257
258const progressPath = async ($: EngineInterface): Promise<string> => {
259  if (knownPath === null) knownPath = await findProgressPath($)
260
261  return knownPath
262}
263
264// One cheap trip to the host and back. The hooks worker answers Claude Code's heartbeat only
265// while it waits on the host, and Claude Code unloads a mod whose worker has not answered for
266// 5 s (#371), so a long run of the board's own work (a big board's view, many store reads)
267// stops here between its parts. A failed look changes nothing.
268const breathe = async ($: EngineInterface): Promise<void> => {
269  await $.fs.exists(await progressPath($)).catch(() => false)
270}
271
272const findProgressPath = async ($: EngineInterface): Promise<string> => {
273  const explicit = await $.env.get('CLAUDE_PROGRESS_FILE')
274  if (explicit !== undefined && explicit !== '') return explicit
275  const config = (await $.env.get('CLAUDE_CONFIG_DIR'))?.replace(/[\\/]+$/, '')
276  if (config !== undefined && config !== '') return [config, 'progress.md'].join(config.includes('\\') ? '\\' : '/')
277  const home = ((await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME')) ?? '').replace(/[\\/]+$/, '')
278  const sep = home.includes('\\') ? '\\' : '/'
279
280  return [home, '.claude', 'progress.md'].join(sep)
281}
282
283// One archive file per month (progress-archive-2026-10.md), so none grows past what one
284// read can hold.
285
286// Where other agents' hooks leave their turns: a folder beside progress.md.
287const inboxPath = (path: string): string => {
288  const sep = path.includes('\\') ? '\\' : '/'
289
290  return `${path.replace(/[\\/][^\\/]*$/, '')}${sep}progress-inbox`
291}
292
293// Windows decides which shell runs a command and which reporter script an agent gets; the
294// path alone does not say (`CLAUDE_PROGRESS_FILE=C:/Users/me/progress.md`).
295const isWindows = async ($: EngineInterface, path: string): Promise<boolean> => isWindowsHost(await $.env.get('OS'), path)
296
297const sepOf = (path: string): string => (path.includes('\\') ? '\\' : '/')
298
299// The engine's file calls cannot rename or delete; the system's own commands do. A file another
300// session already removed must not stop the rest of the batch (Remove-Item would stop at it with
301// -ErrorAction Stop). Windows ignores the command's exit code, including a missing path's error;
302// the look at the files afterwards says what went. POSIX still checks rm -f's exit code.
303const removeFiles = async ($: EngineInterface, files: readonly string[], isWin: boolean): Promise<boolean> => {
304  if (files.length === 0) return true
305  const argv = isWin
306    ? ['powershell', '-NoProfile', '-Command', `Remove-Item -Force -LiteralPath ${files.map(psQuote).join(',')} -ErrorAction SilentlyContinue; exit 0`]
307    : ['rm', '-f', ...files]
308  const ran = await $.process.run(argv, { timeoutMs: 10000 }).catch(() => null)
309  if (ran === null || (!isWin && ran.exitCode !== 0)) return false
310  for (const file of files) if (await $.fs.exists(file)) return false
311
312  return true
313}
314
315// A rename either happens or fails as a whole, so two sessions renaming one file cannot both
316// succeed: true for the one that did.
317const moveFile = async ($: EngineInterface, from: string, to: string, isWin: boolean): Promise<boolean> => {
318  const argv = isWin
319    ? ['powershell', '-NoProfile', '-Command', `Move-Item -LiteralPath ${psQuote(from)} -Destination ${psQuote(to)} -ErrorAction Stop`]
320    : ['mv', from, to]
321  const ran = await $.process.run(argv, { timeoutMs: 10000 }).catch(() => null)
322
323  return ran !== null && ran.exitCode === 0
324}
325
326// A short Node child avoids a cold PowerShell start on Windows. Linking this session's own file
327// refuses an existing target, so another session's ledger is never replaced. The move command
328// stays for volumes without hard links and computers without Node on their path. `mv -n` also
329// answers 0 when it skips a target, so success requires the source to be gone.
330const placeFile = async ($: EngineInterface, from: string, to: string, isWin: boolean): Promise<boolean> => {
331  const linked = await $.process.run(['node', '-e', LEDGER_LINK, '--', from, to], { timeoutMs: 10000 }).catch(() => null)
332  if (linked?.exitCode === 0) return !(await $.fs.exists(from))
333  // Codes 70 to 72 are also common exit codes of other programs, so a `node` wrapper failing on
334  // its own reaches the move command unless the ledger is really there.
335  if (linked?.exitCode === LINK_EXISTS && await $.fs.exists(to)) return false
336  // The ledger is in place but this session's temp could not be removed at once (another program
337  // held it open). It is only a second name for the same bytes, so removing it again is safe.
338  if (linked?.exitCode === LINK_KEPT_SOURCE && await $.fs.exists(to)) return await removeFiles($, [from], isWin)
339
340  const argv = isWin
341    ? ['powershell', '-NoProfile', '-Command', `Move-Item -LiteralPath ${psQuote(from)} -Destination ${psQuote(to)} -ErrorAction Stop`]
342    : ['mv', '-n', from, to]
343  const ran = await $.process.run(argv, { timeoutMs: 10000 }).catch(() => null)
344
345  return ran !== null && ran.exitCode === 0 && !(await $.fs.exists(from))
346}
347
348// The temp file is complete before it replaces the ledger, so readers never see half a write. On
349// Windows a reader can still find no ledger for a moment while the replace runs.
350const replaceFile = async ($: EngineInterface, from: string, to: string, isWin: boolean): Promise<boolean> => {
351  const argv = isWin
352    ? ['powershell', '-NoProfile', '-Command', `Move-Item -Force -LiteralPath ${psQuote(from)} -Destination ${psQuote(to)} -ErrorAction Stop`]
353    : ['mv', '-f', from, to]
354  const ran = await $.process.run(argv, { timeoutMs: 10000 }).catch(() => null)
355
356  return ran !== null && ran.exitCode === 0
357}
358
359type Completion = Awaited<ReturnType<EngineInterface['model']['complete']>>
360
361// A model call that gives up after MODEL_MS, answering undefined as a failed call does.
362const complete = ($: EngineInterface, request: Parameters<EngineInterface['model']['complete']>[0]): Promise<Completion | undefined> =>
363  new Promise(resolve => {
364    $.clock.after(MODEL_MS, () => resolve(undefined))
365    $.model.complete(request).then(resolve, () => resolve(undefined))
366  })
367
368// Another session on this machine, or the board script, may mark a question answered: its file
369// is a stat per poll too. A read that fails, or that catches a write half-way, is tried again on
370// the next poll, and the list in hand stays until then.
371// Null until the first look, so a list kept in the store is checked against the file even when
372// the file is gone.
373let answeredSeen: string | null = null
374const pollAnswered = async ($: EngineInterface, path: string): Promise<void> => {
375  const file = answeredPath(path)
376  const stat = await $.fs.stat(file).catch(() => undefined)
377  const seen = stat === undefined ? '' : `${stat.mtimeMs}:${stat.size}`
378  if (seen === answeredSeen) return
379  const now = await $.clock.now()
380  const list = stat === undefined ? [] : await $.fs.read(file).then(text => parseAnswered(text, now - stat.mtimeMs), () => null)
381  if (list === null) return
382  answeredSeen = seen
383  await update($, hiddenAsks, () => list)
384  // Before progress.md is first read the snapshot is empty; that first read draws both.
385  if ((await read($, snapshot)).path !== path) return
386  await refreshStatus($)
387  await writeDashboard($, true)
388}
389
390// Marks whose question has left the file go, so the same words asked again later show. Run once
391// the file has held still for a poll, so a write caught half-way never drops one, and only while
392// it still holds still (`mtimeMs`, `size`), on disk as well as in this session's last look: a
393// file another session or the board changed meanwhile waits for its own turn.
394let isPrunePending = false
395let questionWrite = 0
396let questionFailures = 0
397let questionRaces = 0
398let questionFailedSince: number | null = null
399// Board writes and prompt receipts are already committed when settling can race.
400// Keep their evidence through ordinary polls rather than replaying intake without it.
401const answeredPending = new Map<string, QuestionAnswerEvidence>()
402// When a successful pass first carried each entry. Settling is safe to repeat, so evidence stays
403// ten minutes after that instead of until any inbox record of its session shows up: a record
404// another session writes late still settles, and sessions that never ask do not pile up.
405const answeredKeptSince = new Map<string, number>()
406// How many answers were ever added or updated. The idle question key holds this count rather than
407// the answers themselves, so a new answer moves the key and one aging out does not: the pass before
408// already carried it, and dropping it gives a pass nothing new to do (#416).
409let answersAdded = 0
410const rememberAnswered = (turns: readonly AgentTurn[]): void => {
411  for (const turn of turns) {
412    if ((turn.event !== 'prompt' && (turn.event !== 'turn' || turn.answer.trim() === '')) || turn.at === undefined || !Number.isFinite(turn.at)) continue
413    const key = JSON.stringify([turn.agent, turn.id])
414    if ((answeredPending.get(key)?.at ?? -Infinity) >= turn.at) continue
415    answeredPending.set(key, { agent: turn.agent, id: turn.id, event: turn.event, at: turn.at })
416    answeredKeptSince.delete(key)
417    answersAdded += 1
418  }
419}
420const pendingQuestionProofs = new Map<string, { path: string; kind: keyof QuestionProofs; hash: string }>()
421// A capture or proof file is named by the hash of what it holds, so a name never changes
422// meaning: each is read and checked the first time it is listed, and again only if its size or
423// time moves (#156). A file that fails the check is not remembered, so it fails as it always has
424// and a write caught half-way is looked at again.
425const capturesRead = new Map<string, { stamp: string; capture: QuestionCapture }>()
426const proofsRead = new Map<string, string>()
427const stampOf = (entry: { size: number; mtimeMs: number }): string => `${entry.size}:${entry.mtimeMs}`
428// What the listing did not show is forgotten, so the memory follows the folder.
429const forgetUnlisted = (known: Map<string, unknown>, family: string, listed: ReadonlySet<string>): void => {
430  for (const file of known.keys()) if (file.startsWith(family) && !listed.has(file)) known.delete(file)
431}
432const heldCaptures = async ($: EngineInterface, path: string): Promise<HeldCapture[] | null> => {
433  try {
434    const held: HeldCapture[] = []
435    const listed = new Set<string>()
436    for (const entry of await $.fs.list(path.replace(/[\\/][^\\/]*$/, ''))) {
437      const hash = captureHashOf(path, entry.name)
438      if (hash === null) continue
439      const file = capturePath(path, hash)
440      listed.add(file)
441      const remembered = capturesRead.get(file)
442      let capture = entry.kind === 'file' && remembered?.stamp === stampOf(entry) ? remembered.capture : null
443      if (capture === null) {
444        capture = entry.kind === 'file' ? parseCapture(await $.fs.read(file), hash) : null
445        if (capture === null) return null
446        capturesRead.set(file, { stamp: stampOf(entry), capture })
447      }
448      held.push({ hash, mtimeMs: entry.mtimeMs, capture })
449    }
450    forgetUnlisted(capturesRead, `${path.replace(/\.md$/i, '')}-questions-source-`, listed)
451    return held
452  } catch {
453    return null
454  }
455}
456const questionCapturesFor = async ($: EngineInterface, path: string): Promise<QuestionCapture[] | null> => (await heldCaptures($, path))?.map(one => one.capture) ?? null
457const keepQuestionCapture = async ($: EngineInterface, path: string, record: QuestionRecord, disposition: QuestionCapture['disposition']): Promise<void> => {
458  const text = captureJson(record, disposition)
459  const file = capturePath(path, sha256(text))
460  if (!(await $.fs.exists(file))) await $.fs.write(file, text)
461  if (await $.fs.read(file) !== text) throw new Error('QUESTION_RETENTION_FAILED')
462}
463const heldProofs = async ($: EngineInterface, path: string): Promise<HeldProof[] | null> => {
464  try {
465    const held: HeldProof[] = []
466    const listed = new Set<string>()
467    for (const entry of await $.fs.list(path.replace(/[\\/][^\\/]*$/, ''))) {
468      const proof = questionProofOf(path, entry.name)
469      if (proof === null) continue
470      const file = questionProofPath(path, proof.kind, proof.hash)
471      listed.add(file)
472      if (entry.kind !== 'file') return null
473      if (proofsRead.get(file) !== stampOf(entry)) {
474        if (!isQuestionProof(await $.fs.read(file), proof.kind, proof.hash)) return null
475        proofsRead.set(file, stampOf(entry))
476      }
477      held.push({ ...proof, mtimeMs: entry.mtimeMs })
478    }
479    forgetUnlisted(proofsRead, `${path.replace(/\.md$/i, '')}-questions-`, listed)
480    return held
481  } catch {
482    return null
483  }
484}
485const questionProofsFor = async ($: EngineInterface, path: string): Promise<QuestionProofs | null> => {
486  if ([...pendingQuestionProofs.values()].some(proof => proof.path === path)) return null
487  const held = await heldProofs($, path)
488  if (held === null) return null
489  const proofs: QuestionProofs = { retired: [], consumed: [] }
490  for (const one of held) proofs[one.kind].push(one.hash)
491
492  return proofs
493}
494
495const keepQuestionProof = async ($: EngineInterface, path: string, kind: keyof QuestionProofs, hash: string): Promise<void> => {
496  const file = questionProofPath(path, kind, hash)
497  pendingQuestionProofs.set(file, { path, kind, hash })
498  const text = questionProofJson(kind, hash)
499  if (await $.fs.exists(file)) {
500    if (!isQuestionProof(await $.fs.read(file), kind, hash)) throw new Error('QUESTION_PROOF_UNREADABLE')
501  } else {
502    await $.fs.write(file, text)
503  }
504  if (await $.fs.read(file) !== text) throw new Error('QUESTION_PROOF_NOT_KEPT')
505  pendingQuestionProofs.delete(file)
506}
507// The capture and proof files grow with every question, so the archive sweep (session start,
508// hourly, tidy; never the poll) trims them to the newest 16 of each kind (#156). Which of the
509// older ones can go is decided in `captureCovers` and `prunableProofs`: only what the ledger
510// file or a newer file already holds. The listing alone says whether there is anything to trim,
511// so an hour with nothing over the floor reads no file. The choice is made outside the write
512// queue from what the polls remember of each file by its size and time, so it never holds up the
513// inbox and a sweep with nothing to remove reads nothing (#416). Before anything goes, each file
514// to remove and the file that lets it go (the retirement proof, or the newest newer copy that
515// holds what it holds) is read again from disk; one that fails its check stops the sweep and is
516// forgotten, so the next look reads it afresh. Only the removal goes through the queue, and only
517// while the ledger and the acknowledgement list are still the ones the choice was made from.
518// The 200 removals per sweep are shared with torn ledgers and temporary copies, which go first,
519// in the write queue; a failed leftover sweep ends the sweep because it may already have removed
520// files.
521const PRUNED_PER_SWEEP = 200
522const pruneQuestionFiles = async ($: EngineInterface, path: string): Promise<void> => {
523  if ([...pendingQuestionProofs.values()].some(proof => proof.path === path)) return
524  const dir = path.replace(/[\\/][^\\/]*$/, '')
525  const entries = await $.fs.list(dir)
526  // A sweep with no leftover in the listing takes no turn in the write queue.
527  const leftovers = ledgerLeftovers(path, entries, await $.clock.now())
528  const left = leftovers.torn.length === 0 && leftovers.temps.length === 0 ? 0 : await enqueue(() => sweepLedgerLeftovers($, path, dir, entries)).catch(() => PRUNED_PER_SWEEP)
529  if (left >= PRUNED_PER_SWEEP) return
530  const allowance = PRUNED_PER_SWEEP - left
531  const names = entries.map(entry => entry.name)
532  const proofsOf = (kind: keyof QuestionProofs): number => names.filter(name => questionProofOf(path, name)?.kind === kind).length
533  if (names.filter(name => captureHashOf(path, name) !== null).length <= QUESTION_FILES_KEPT && proofsOf('retired') <= QUESTION_FILES_KEPT && proofsOf('consumed') <= QUESTION_FILES_KEPT) return
534  const captures = await heldCaptures($, path)
535  const proofs = await heldProofs($, path)
536  if (captures === null || proofs === null) return
537  const file = questionsPath(path)
538  const text = await readText($, file)
539  const ledger = parseQuestions(text)
540  const answeredText = await readText($, answeredPath(path))
541  const answered = parseAnswered(answeredText)
542  if (ledger === null || answered === null) return
543  const covers = captureCovers(ledger, captures, proofs.filter(one => one.kind === 'retired').map(one => one.hash))
544  const doomedProofs = prunableProofs(ledger, captures.map(one => one.capture), answered, proofs)
545  const sweep = [
546    ...covers.map(one => capturePath(path, one.hash)),
547    ...doomedProofs.map(one => questionProofPath(path, one.kind, one.hash)),
548  ].slice(0, allowance)
549  if (sweep.length === 0) return
550  const checks = new Map<string, { hash: string; kind: keyof QuestionProofs | null }>()
551  for (const one of covers.slice(0, allowance)) {
552    checks.set(capturePath(path, one.hash), { hash: one.hash, kind: null })
553    if (one.cover !== null) checks.set(capturePath(path, one.cover), { hash: one.cover, kind: null })
554    if (one.retired !== null) checks.set(questionProofPath(path, 'retired', one.retired), { hash: one.retired, kind: 'retired' })
555  }
556  for (const proof of doomedProofs.slice(0, Math.max(0, allowance - covers.length))) {
557    checks.set(questionProofPath(path, proof.kind, proof.hash), { hash: proof.hash, kind: proof.kind })
558  }
559  // Each read is a trip to the host, so the mod answers its heartbeat between any two of them.
560  for (const [checked, check] of checks) {
561    const body = await $.fs.read(checked).catch(() => null)
562    const isValid = body !== null && (check.kind === null ? parseCapture(body, check.hash) !== null : isQuestionProof(body, check.kind, check.hash))
563    if (!isValid) {
564      capturesRead.delete(checked)
565      proofsRead.delete(checked)
566      return
567    }
568  }
569  const isWin = await isWindows($, file)
570  await enqueue(async () => {
571    if ([...pendingQuestionProofs.values()].some(proof => proof.path === path) || await readText($, file) !== text || await readText($, answeredPath(path)) !== answeredText) return
572    // A bounded sweep lets waiting writes through before the next trim; a few dozen paths to
573    // each process also keeps its command line far below the system's limit.
574    for (let from = 0; from < sweep.length; from += 50) if (!(await removeFiles($, sweep.slice(from, from + 50), isWin))) return
575  })
576}
577// Trims serialize separately so a tidy waits for an earlier trim without holding the write queue.
578const trimQuestionFiles = ($: EngineInterface): Promise<void> => {
579  const run = questionTrims.then(async () => pruneQuestionFiles($, await progressPath($)))
580  questionTrims = run.catch(() => undefined)
581
582  return run
583}
584// Torn ledgers past the newest few or over a week old, and temporary copies of a ledger write a
585// day old (#156), sharing the sweep's 200 removals with captures and proofs. The
586// ledger is read only when a temporary copy is old enough to go, since one goes only while the
587// ledger itself reads. A torn file is read first: when putting a moved-aside ledger back failed,
588// a whole ledger keeps the torn name.
589const sweepLedgerLeftovers = async ($: EngineInterface, path: string, dir: string, entries: ReadonlyArray<{ name: string; mtimeMs: number }>): Promise<number> => {
590  // The shared allowance keeps thousands of leftovers from holding the write queue or being
591  // read all at once; the rest goes at the next sweep.
592  const leftovers = ledgerLeftovers(path, entries, await $.clock.now())
593  const torn = leftovers.torn.slice(0, PRUNED_PER_SWEEP)
594  const temps = leftovers.temps.slice(0, PRUNED_PER_SWEEP - torn.length)
595  const fileOf = (name: string): string => `${dir}${sepOf(dir)}${name}`
596  const isLedgerRead = temps.length > 0 && await readText($, questionsPath(path)).then(text => parseQuestions(text) !== null, () => false)
597  const tornText = await Promise.all(torn.map(name => readText($, fileOf(name)).then(text => isTornLedger(text), () => false)))
598  const doomed = [...torn.filter((_, n) => tornText[n]), ...(isLedgerRead ? temps : [])].map(fileOf)
599  if (doomed.length === 0) return 0
600  const isWin = await isWindows($, path)
601  for (let from = 0; from < doomed.length; from += 50) if (!(await removeFiles($, doomed.slice(from, from + 50), isWin))) return doomed.length
602  return doomed.length
603}
604const pruneAnswered = async ($: EngineInterface, path: string, projects: readonly ProgressProject[], mtimeMs: number, size: number): Promise<void> => {
605  // The ledger retires identities; old acknowledgement bytes remain available for migration.
606  const disk = await $.fs.stat(path).catch(() => undefined)
607  if (disk?.mtimeMs !== mtimeMs || disk.size !== size) return
608  await pollQuestions($, path, projects, true)
609}
610
611let questionsRunning: Promise<boolean> | null = null
612const passQuestions = async ($: EngineInterface, path: string, projects: readonly ProgressProject[], isSettled = false, captured: readonly PendingTurn[] = [], answeredTurns: readonly QuestionAnswerEvidence[] = [], resolveId?: string): Promise<boolean> => {
613  const file = questionsPath(path)
614  let outcome: 'ok' | 'race' | 'fault' = 'fault'
615  let at = 0
616  try {
617    at = await $.clock.now()
618    const mirror = await read($, mine)
619    const answered = await read($, hiddenAsks)
620    const pending = [...(await pendingTurns($)), ...captured]
621    // A delayed save keeps the turn's age, including when a normal poll saw its block first. Each
622    // turn is serialised once, not once for every record (#416).
623    const capturedAt = new Map<string, number>()
624    for (const turn of captured) {
625      const time = turn.sourceAt ?? turn.at
626      if (!Number.isFinite(time) || time < 0) continue
627      const json = JSON.stringify(turn)
628      capturedAt.set(json, Math.min(capturedAt.get(json) ?? time, time))
629    }
630    const observe = (current: ReturnType<typeof emptyQuestions>, proofs: QuestionProofs | undefined): ReturnType<typeof emptyQuestions> => {
631      const observed = observeQuestions(current, projects, mirror, answered, at, isSettled, incarnation, proofs, pending, answeredTurns, frozen).ledger
632      return { ...observed, records: observed.records.map(record => {
633        // A running minimum: a record can hold more sources than a call takes arguments.
634        let first = record.firstObserved
635        for (const source of record.producerSources ?? []) first = Math.min(first, capturedAt.get(source) ?? first)
636        return first === record.firstObserved ? record : { ...record, firstObserved: first }
637      }) }
638    }
639    const session = await $.session.id()
640    const incarnation = `${session}:${at}:${questionWrite++}`
641    const isWin = await isWindows($, file)
642    // A ledger that is not JSON at all was cut off by a write that never finished: moved aside,
643    // it is rebuilt from the source copies as a missing one is. JSON in a shape this version does
644    // not know may be a newer version's, so parseQuestions leaves that alone.
645    const quarantine = async (): Promise<boolean> => {
646      const aside = `${file}.torn-${at}-${session}`
647      if (!(await moveFile($, file, aside, isWin))) {
648        // Another session moved it aside first or wrote a good one: that is a race, not a fault.
649        if (!isTornLedger(await readText($, file))) outcome = 'race'
650        return false
651      }
652      if (isTornLedger(await readText($, aside))) {
653        return true
654      }
655      // A good ledger another session wrote between the read and the move goes back, unless a
656      // rebuild already took its place; the rebuild comes from the same source copies.
657      await placeFile($, aside, file, isWin)
658      outcome = 'race'
659      return false
660    }
661    if (isTornLedger(await readText($, file)) && !(await quarantine())) return false
662    for (const proof of [...pendingQuestionProofs.values()]) {
663      if (proof.path === path) await keepQuestionProof($, path, proof.kind, proof.hash)
664    }
665    let proofs = await questionProofsFor($, path)
666    if (proofs === null) {
667      outcome = 'race'
668      return false
669    }
670    const source = await readText($, path)
671    const isCutSettled = progressChangedAt !== null && at - progressChangedAt >= CUT_SETTLE_MS
672    if (!isQuestionSnapshot(source, isCutSettled) || JSON.stringify(parseProgress(source)) !== JSON.stringify(projects)) {
673      outcome = 'race'
674      return false
675    }
676    const frozen = cutOffScopes(projects, cutOffSpans(source, isCutSettled))
677    let expected = ''
678    let wasMissing = false
679    await rewrite($, file, async before => {
680      if (await readText($, path) !== source) return AGAIN
681      wasMissing = before.trim() === '' && !(await $.fs.exists(file))
682      const parsed = parseQuestions(before, wasMissing)
683      if (parsed === null && isTornLedger(before)) {
684        if (!(await quarantine())) throw new Error('QUESTION_LEDGER_MOVE_FAILED')
685        return AGAIN
686      }
687      const captures = await questionCapturesFor($, path)
688      if (parsed === null || captures === null) return AGAIN
689      const current = withCaptures(parsed, captures)
690      const baseline = resolveId === undefined ? current : resolveQuestion(current, resolveId, at)
691      let observed = observe(baseline, proofs ?? undefined)
692      await breathe($)
693      for (const copy of copiesToKeep(current, observed, captures)) await keepQuestionCapture($, path, copy.record, copy.disposition)
694      for (const id of identitiesToRetire(current, observed, resolvedQuestionsToRetire(baseline, projects, mirror, pending, frozen), proofs?.retired)) await keepQuestionProof($, path, 'retired', sha256(id))
695      proofs = await questionProofsFor($, path)
696      if (proofs === null) return AGAIN
697      const keptCaptures = await questionCapturesFor($, path)
698      if (keptCaptures === null) return AGAIN
699      // The retirement proof must not outrun the resolution just captured above.
700      const kept = withCaptures(current, keptCaptures)
701      observed = observe(resolveId === undefined ? kept : resolveQuestion(kept, resolveId, at), proofs)
702      await breathe($)
703      expected = questionsJson(observed)
704      return questionsJson(observed) === questionsJson(parsed) ? null : questionsJson(observed)
705    }, undefined, async (target, text) => {
706      const temporary = `${target}.tmp-${session}-${questionWrite++}`
707      await $.fs.write(temporary, text)
708      // A ledger rebuilt because none was there never replaces one another session put in place
709      // meanwhile (on Windows the ledger is also missing for a moment while a replace runs): the
710      // read back then sees theirs, and the change is made again on top of it.
711      if (wasMissing ? await placeFile($, temporary, target, isWin) : await replaceFile($, temporary, target, isWin)) return
712      // A failed replace on Windows may already have deleted the old ledger: the new one takes its place.
713      const isGone = !(await $.fs.exists(target))
714      if (isGone && await placeFile($, temporary, target, isWin)) return
715      // With no ledger left, the temp file is the only complete copy, so it stays.
716      if (isGone) throw new Error('QUESTION_LEDGER_WRITE_FAILED')
717      await removeFiles($, [temporary], isWin)
718      if (!wasMissing) throw new Error('QUESTION_LEDGER_WRITE_FAILED')
719    })
720    const parsed = parseQuestions(await readText($, file), !(await $.fs.exists(file)))
721    const captures = await questionCapturesFor($, path)
722    const kept = parsed === null || captures === null ? null : withCaptures(parsed, captures)
723    if (parsed === null || kept === null) return false
724    if (expected !== questionsJson(parsed)) {
725      outcome = 'race'
726      return false
727    }
728    await update($, questionLedger, () => kept)
729    if (isSettled) {
730      await rewrite($, answeredPath(path), async before => {
731        const stat = await $.fs.stat(answeredPath(path)).catch(() => undefined)
732        const current = parseAnswered(before, stat === undefined ? 0 : at - stat.mtimeMs)
733        if (current === null) return AGAIN
734        const latestProofs = await questionProofsFor($, path)
735        if (latestProofs === null) return AGAIN
736        const next = migratedAnswered(current, kept, latestProofs.consumed)
737        const nextKeys = new Set(next)
738        const consumed = new Set(latestProofs.consumed)
739        for (const key of current) {
740          if (nextKeys.has(key) && Object.hasOwn(kept.legacy, key) && !consumed.has(sha256(key))) await keepQuestionProof($, path, 'consumed', sha256(key))
741        }
742        return answeredJson(next) === answeredJson(current) ? null : answeredJson(next)
743      })
744      const current = parseAnswered(await readText($, answeredPath(path)))
745      if (current !== null) await update($, hiddenAsks, () => current)
746    }
747    outcome = 'ok'
748    return true
749  } catch {
750    return false
751  } finally {
752    // Another session's write landing between two reads is not a fault: only a ledger that stays
753    // unreadable or unwritable for ten minutes and at least three faults (or fifteen races) is
754    // worth telling the person about.
755    if (outcome === 'ok') {
756      questionFailures = 0
757      questionRaces = 0
758      questionFailedSince = null
759    } else {
760      // A clock moved back starts the ten-minute wait again from now.
761      if (at > 0 && (questionFailedSince === null || at < questionFailedSince)) questionFailedSince = at
762      if (outcome === 'race') questionRaces += 1
763      else questionFailures += 1
764    }
765    // A pass that did not go through, from any caller, is made again at the next poll.
766    if (outcome !== 'ok') questionsSeen = null
767    // Written only when it changes, so a quiet poll does not redraw every pane.
768    const wasProblem = await read($, hasQuestionsProblem)
769    const isProblem = outcome !== 'ok' && (wasProblem || ((questionFailures >= 3 || questionRaces >= 15) && at - (questionFailedSince ?? at) >= 10 * 60 * 1000))
770    if (isProblem !== wasProblem) await update($, hasQuestionsProblem, () => isProblem)
771  }
772}
773
774const pollQuestions = async ($: EngineInterface, path: string, projects: readonly ProgressProject[], isSettled = false, captured: readonly PendingTurn[] = [], answeredTurns: readonly QuestionAnswerEvidence[] = [], resolveId?: string): Promise<boolean> => {
775  const deadline = (await $.clock.now()) + HUNG_MS
776  while (questionsRunning !== null) {
777    const waiting = questionsRunning
778    // Successive passes share this caller's wait, rather than each starting it over.
779    const remaining = deadline - (await $.clock.now())
780    if (remaining <= 0 || !(await isDoneWithin($, waiting, remaining))) {
781      questionsSeen = null
782      return false
783    }
784  }
785  const run = passQuestions($, path, projects, isSettled, captured, answeredTurns, resolveId)
786  questionsRunning = run
787  // The run keeps the gate after its caller gives up, including when its finally rejects.
788  const release = (): void => {
789    if (questionsRunning === run) questionsRunning = null
790  }
791  run.then(release, release)
792  // A timer callback that rejects would go unhandled in the worker, so the store calls end in a catch.
793  $.clock.after(HUNG_MS, () => {
794    if (questionsRunning !== run) return
795    read($, hasQuestionsProblem)
796      .then(async wasProblem => {
797        if (questionsRunning === run && !wasProblem) await update($, hasQuestionsProblem, () => true)
798      })
799      .catch(() => undefined)
800  })
801  if (!(await isDoneWithin($, run, HUNG_MS))) {
802    questionsSeen = null
803    return false
804  }
805  return await run
806}
807
808// What an idle question pass is made from (#156): the files beside progress.md it reads
809// (`questionFilesKey`), the inbox turns, the questions of this session's list
810// (`ownQuestionsKey`), the asks it hides and how many answers were added. A pass that went
811// through cleanly is not made again until one of them moves, so a quiet poll lists two folders
812// instead of reading the question files. Null, so the pass is made, while a file there was just
813// written or the folder does not list: a pass that cannot list still counts toward the questions
814// problem, as before.
815let questionsSeen: string | null = null
816const questionsKey = async ($: EngineInterface, path: string, added: number): Promise<string | null> => {
817  const at = await $.clock.now()
818  const entries = await $.fs.list(path.replace(/[\\/][^\\/]*$/, '')).catch(() => null)
819  const files = entries === null ? null : questionFilesKey(path, answeredPath(path), entries, at, POLL_MS)
820  if (files === null) return null
821
822  return JSON.stringify([path, files, await pendingTurns($), ownQuestionsKey(await read($, mine)), await read($, hiddenAsks), added])
823}
824
825const pollAnsweredQuestions = async ($: EngineInterface, path: string, projects: readonly ProgressProject[] | undefined, evidence: readonly AgentTurn[] = []): Promise<void> => {
826  rememberAnswered(evidence)
827  if (evidence.length > 0 && questionsRunning !== null) return
828  try {
829    const matches = (record: QuestionRecord, turn: QuestionAnswerEvidence): boolean => record.source?.kind === 'inbox' && record.source.agent === turn.agent && record.source.session === turn.id
830    const batch = [...answeredPending.entries()]
831    // Counted with the batch, so an answer remembered while the key is read is not taken as carried.
832    const added = answersAdded
833    if (evidence.length > 0) {
834      if (batch.length === 0) return
835      const ledger = await read($, questionLedger)
836      if (!batch.some(([, turn]) => ledger.records.some(record => record.isUnresolved && matches(record, turn)))) return
837    }
838    // Taken before the pass, so a write that lands while it runs makes the next poll pass again.
839    const key = evidence.length > 0 ? null : await questionsKey($, path, added)
840    // A pass skipped as unchanged had no answer added since the clean pass it stands for, which
841    // carried every answer in this batch, so they still age out below.
842    if (key === null || key !== questionsSeen) {
843      if (!(await pollQuestions($, path, projects ?? parseProgress(await readText($, path)), false, [], batch.map(([, turn]) => turn)))) return
844      questionsSeen = key
845    }
846    const now = await $.clock.now()
847    for (const [key, turn] of batch) {
848      if (answeredPending.get(key) !== turn) continue
849      const since = answeredKeptSince.get(key) ?? now
850      answeredKeptSince.set(key, since)
851      if (now - since > 10 * 60_000) {
852        answeredPending.delete(key)
853        answeredKeptSince.delete(key)
854      }
855    }
856  } catch {
857    // Intake is committed already. A failed read or settlement retries on an ordinary poll.
858  }
859}
860
861// Marks one question answered, or lists it again: only progress-answered.json changes, never a
862// block, and nothing is sent to any session. Read, changed, written and read back in the write
863// queue, so a press in another session or the board script is kept. A failure says why it did not stick.
864const setAnswered = async ($: EngineInterface, item: WaitingItem, isHidden: boolean): Promise<{ isDone: true } | { isDone: false; reason: 'notFound' | 'progressUnreadable' | 'ledgerUnreadable' | 'writeFailed' }> => {
865  if (item.hideKey === '') return { isDone: false, reason: 'notFound' }
866  const snap = await read($, snapshot)
867  try {
868    await readText($, snap.path || (await progressPath($)))
869  } catch {
870    return { isDone: false, reason: 'progressUnreadable' }
871  }
872  if (!(await pollQuestions($, snap.path || (await progressPath($)), snap.projects))) return { isDone: false, reason: 'ledgerUnreadable' }
873  const ledger = await read($, questionLedger)
874  const currentView = buildView(snap.projects, [], await read($, mine), await $.clock.now(), 0, {}, await wordsOf($), await read($, hiddenAsks), {}, ledger, await questionProofsFor($, snap.path || (await progressPath($))), await pendingTurns($), cutOffScopes(snap.projects, snap.cutSpans ?? []))
875  if (![...currentView.waiting, ...(currentView.reminder ?? []), ...(currentView.older ?? []), ...currentView.hidden].some(one => (!isHidden || one.canHide !== false) && one.hideKey === item.hideKey && one.project === item.project && one.text === item.text && one.note === item.note)) return { isDone: false, reason: 'notFound' }
876  const file = answeredPath(snap.path || (await progressPath($)))
877  let reason: 'notFound' | 'progressUnreadable' | 'ledgerUnreadable' | 'writeFailed' = 'writeFailed'
878  const list = await enqueue(async () => {
879    let fresh: ProgressProject[]
880    let source: string
881    try {
882      source = await readText($, snap.path || (await progressPath($)))
883      fresh = parseProgress(source)
884    } catch {
885      reason = 'progressUnreadable'
886      return null
887    }
888    if (!(await pollQuestions($, snap.path || (await progressPath($)), fresh))) {
889      reason = 'ledgerUnreadable'
890      return null
891    }
892    const latest = await read($, questionLedger)
893    const at = await $.clock.now()
894    const frozen = cutOffScopes(fresh, cutOffSpans(source, progressChangedAt !== null && at - progressChangedAt >= CUT_SETTLE_MS))
895    const current = buildView(fresh, [], await read($, mine), at, 0, {}, await wordsOf($), await read($, hiddenAsks), {}, latest, await questionProofsFor($, snap.path || (await progressPath($))), await pendingTurns($), frozen)
896    if (![...current.waiting, ...(current.reminder ?? []), ...(current.older ?? []), ...current.hidden].some(one => (!isHidden || one.canHide !== false) && one.hideKey === item.hideKey && one.project === item.project && one.text === item.text && one.note === item.note)) { reason = 'notFound'; return null }
897    await rewrite(
898      $,
899      file,
900      async before => {
901        const stat = await $.fs.stat(file).catch(() => undefined)
902        const now = parseAnswered(before, stat === undefined ? 0 : (await $.clock.now()) - stat.mtimeMs)
903        const proofs = await questionProofsFor($, snap.path || (await progressPath($)))
904        if (proofs === null || (isHidden && proofs.retired.includes(sha256(item.hideKey)))) return AGAIN
905        return now === null ? AGAIN : answeredJson(withAnswered(now, isHidden ? [item.hideKey] : acknowledgementKeys(observeQuestions(latest, fresh, await read($, mine), now, await $.clock.now(), true, 'preview', undefined, [], [], frozen).ledger, item.hideKey), isHidden))
906      },
907      check => parseAnswered(check)?.includes(item.hideKey) === isHidden,
908    )
909    return parseAnswered(await readText($, file))
910  }).catch(() => null)
911  if (list === null || list.includes(item.hideKey) !== isHidden) return { isDone: false, reason }
912  await update($, hiddenAsks, () => list)
913  await refreshStatus($)
914  await writeDashboard($, true)
915  return { isDone: true }
916}
917
918// A task with no time of its own is aged from when its words were first seen, kept in
919// progress-seen.json beside progress.md, which the board script keeps too. It is read when it
920// changed (a stat per poll, as the answered list is) and written when progress.md changed. A
921// failure leaves the record empty, so an undated task shows rather than hides; a record caught
922// half-written keeps the one in hand, which is safe because changed words make a new key.
923// Tasks gone from progress.md leave the record only once that file has held still for a poll
924// (`still`), and only while it still does, on disk as well (as answered marks do).
925// A write that fails is tried again on a later change, or after a minute.
926const SEEN_RETRY_MS = 60_000
927let seenStat: string | null = null
928let hasSeenPending = false
929let seenRetryAt = 0
930const pollSeen = async (
931  $: EngineInterface,
932  path: string,
933  projects: readonly ProgressProject[],
934  isChanged: boolean,
935  still: { mtimeMs: number; size: number } | null = null,
936): Promise<void> => {
937  const file = seenPath(path)
938  const before = await read($, firstSeen)
939  const set = async (seen: Record<string, number>): Promise<void> => {
940    if (seenJson(seen) === seenJson(before)) return
941    await update($, firstSeen, () => seen)
942    // A changed progress.md redraws both after this; otherwise only the record moved.
943    if (isChanged || (await read($, snapshot)).path !== path) return
944    await refreshStatus($)
945    await writeDashboard($, true)
946  }
947  let at = 0
948  let record: Record<string, number> | null = null
949  try {
950    at = await $.clock.now()
951    const stat = await $.fs.stat(file).catch(() => undefined)
952    const key = stat === undefined ? '' : `${stat.mtimeMs}:${stat.size}`
953    const isRetry = hasSeenPending && at >= seenRetryAt
954    if (!isChanged && !isRetry && still === null && key === seenStat) return
955    // Read again at the next poll after anything but a clean read with nothing to write.
956    seenStat = null
957    record = parseSeen(await readText($, file), stat === undefined ? 0 : at - stat.mtimeMs)
958    if (record === null) {
959      hasSeenPending = hasSeenPending || isChanged
960      return
961    }
962    const isStale = still !== null && projects.length > 0 && seenJson(withSeen(record, projects, at, {}, true)) !== seenJson(withSeen(record, projects, at))
963    if (isChanged || isRetry || isStale) {
964      hasSeenPending = true
965      let observed = withSeen(record, projects, at)
966      await rewrite($, file, async text => {
967        const disk = await $.fs.stat(file).catch(() => undefined)
968        const current = parseSeen(text, disk === undefined ? 0 : at - disk.mtimeMs)
969        if (current === null) return AGAIN
970        const held = still === null ? undefined : await $.fs.stat(path).catch(() => undefined)
971        const isSettled = isStale && held !== undefined && held.mtimeMs === still?.mtimeMs && held.size === still.size
972        observed = withSeen(current, projects, at, observed, isSettled)
973        return seenJson(observed) === seenJson(current) ? null : seenJson(observed)
974      })
975      const kept = parseSeen(await readText($, file))
976      if (kept === null || seenJson(kept) !== seenJson(observed)) {
977        seenRetryAt = at + SEEN_RETRY_MS
978        // Another writer's record is as good as ours: its new tasks show until ours lands.
979        if (kept !== null) await set(kept)
980        return
981      }
982      hasSeenPending = false
983      await set(kept)
984    } else {
985      seenStat = key
986      await set(record)
987    }
988  } catch {
989    hasSeenPending = hasSeenPending || isChanged
990    seenRetryAt = at + SEEN_RETRY_MS
991    // A record read but not written still holds the times it had; one not read holds none.
992    await set(record ?? {})
993  }
994}
995
996// No file call has a time limit of its own, and one that never returns (a drive that went away,
997// say) held `isPolling` for good, so the pane stopped redrawing (#156). A poll still running
998// after HUNG_MS is taken as hung: the next one starts in its place, and what the hung one would
999// still write is dropped. While a poll that was taken over is still held, no other is taken over.
1000// Every caller shares one question pass at a time; the poll and the timer's retain skip while
1001// one runs. A press, the drain's question or the archive waits behind other passes for HUNG_MS
1002// in total, then gives its own pass HUNG_MS. Giving up releases the caller and the write queue,
1003// while the run keeps the gate until it settles. A run held for HUNG_MS shows the question-list
1004// error; its finally clears it after success. Pending inbox questions stay saved until the
1005// ledger records their source and words, while the status line and progress.html still draw.
1006const HUNG_MS = 15 * 1000
1007let pollRun = 0
1008let pollStartedAt = 0
1009let replacedPoll: number | null = null
1010let progressChangedAt: number | null = null
1011let progressCutSettled = false
1012let questionPassing: Promise<void> | null = null
1013
1014// True when the work settled within `ms`; false when it is still running then, which it may go on doing.
1015const isDoneWithin = ($: EngineInterface, work: Promise<unknown>, ms: number): Promise<boolean> =>
1016  new Promise(resolve => {
1017    $.clock.after(ms, () => resolve(false))
1018    work.then(() => resolve(true), () => resolve(true))
1019  })
1020
1021// The poll skips a running question pass instead of piling up reads behind a held file. Waiting
1022// for at most one poll here lets the pane draw while that pass is held.
1023const questionPass = async ($: EngineInterface, path: string, projects: readonly ProgressProject[]): Promise<void> => {
1024  if (questionPassing !== null || questionsRunning !== null) return
1025  const pass: Promise<void> = pollAnsweredQuestions($, path, projects).finally(() => {
1026    if (questionPassing === pass) questionPassing = null
1027  })
1028  questionPassing = pass
1029  await isDoneWithin($, pass, POLL_MS)
1030}
1031
1032// Re-reads the file only when its mtime or size moved, so the 2s timer is a stat. Only the 2s
1033// timer (`canReplace`) takes a held poll's place: the polls the write queue runs return at once,
1034// as before, so no write waits behind a second held read.
1035const poll = async ($: EngineInterface, canReplace = false): Promise<void> => {
1036  if (isPolling) {
1037    if (!canReplace || replacedPoll !== null) return
1038    const held = pollRun
1039    if ((await $.clock.now()) - pollStartedAt < HUNG_MS || !isPolling || pollRun !== held || replacedPoll !== null) return
1040    replacedPoll = held
1041  }
1042  const run = ++pollRun
1043  const isCurrent = (): boolean => run === pollRun
1044  isPolling = true
1045  // Until this poll has its time, no other takes it for hung.
1046  pollStartedAt = Number.POSITIVE_INFINITY
1047  try {
1048    pollStartedAt = await $.clock.now()
1049    const path = await progressPath($)
1050    await pollAnswered($, path).catch(() => undefined)
1051    const previous = await read($, snapshot)
1052    const stat = await $.fs.stat(path).catch(() => undefined)
1053    if (!isCurrent()) return
1054    if (stat === undefined) {
1055      progressChangedAt = null
1056      progressCutSettled = false
1057      if (Object.keys(await read($, firstSeen)).length > 0) await update($, firstSeen, () => ({}))
1058      if (!previous.isMissing || previous.path !== path) {
1059        await update($, resolveResult, () => null)
1060        await update($, snapshot, () => ({ ...EMPTY, path }))
1061      }
1062      return
1063    }
1064    const isSame = previous.path === path && previous.mtimeMs === stat.mtimeMs && previous.size === stat.size
1065    if (isSame && previous.error === null && !previous.isMissing) {
1066      if (progressChangedAt === null) progressChangedAt = pollStartedAt
1067      if (!progressCutSettled && pollStartedAt - progressChangedAt >= CUT_SETTLE_MS) {
1068        const source = await $.fs.read(path).catch(() => null)
1069        if (!isCurrent()) return
1070        if (source !== null) {
1071          progressCutSettled = true
1072          const cutBlocks = cutOffBlocks(source, true)
1073          const cutSpans = cutOffSpans(source, true)
1074          if (JSON.stringify(cutBlocks) !== JSON.stringify(previous.cutBlocks ?? []) || JSON.stringify(cutSpans) !== JSON.stringify(previous.cutSpans ?? [])) {
1075            await update($, snapshot, snap => ({ ...snap, cutBlocks, cutSpans }))
1076            await refreshStatus($)
1077            await writeDashboard($)
1078          }
1079        }
1080      }
1081      await pollSeen($, path, previous.projects, false, isPrunePending ? { mtimeMs: stat.mtimeMs, size: stat.size } : null)
1082      if (!isCurrent()) return
1083      await questionPass($, path, previous.projects)
1084      if (!isCurrent()) return
1085      if (isPrunePending) {
1086        isPrunePending = false
1087        // Not waited on: poll also runs inside queued jobs, which the prune would queue behind.
1088        pruneAnswered($, path, previous.projects, stat.mtimeMs, stat.size).catch(() => undefined)
1089      }
1090      return
1091    }
1092    try {
1093      const source = await $.fs.read(path)
1094      const projects = parseProgress(source)
1095      // Every view of this change (the pane, the line under the prompt, the reminders) is built
1096      // for the same moment, so it is built once (viewCache).
1097      const changedAt = await $.clock.now()
1098      if (!isCurrent()) return
1099      progressChangedAt = changedAt
1100      progressCutSettled = cutOffSpans(source, true).length === 0
1101      await update($, now, () => changedAt)
1102      await update($, snapshot, () => ({
1103        path,
1104        isMissing: false,
1105        error: null,
1106        mtimeMs: stat.mtimeMs,
1107        size: stat.size,
1108        projects,
1109        tornTail: progressTail(source),
1110        cutBlocks: cutOffBlocks(source),
1111        cutSpans: cutOffSpans(source),
1112      }))
1113      await pollSeen($, path, projects, true)
1114      if (!isCurrent()) return
1115      await questionPass($, path, projects)
1116      if (!isCurrent()) return
1117      const result = await read($, resolveResult)
1118      if (result !== null) {
1119        const at = await $.clock.now()
1120        const frozen = cutOffScopes(projects, cutOffSpans(source, at - changedAt >= CUT_SETTLE_MS))
1121        const view = buildView(projects, [], await read($, mine), at, 0, {}, await wordsOf($), await read($, hiddenAsks), {}, await read($, questionLedger), await questionProofsFor($, path), await pendingTurns($), frozen)
1122        const rows = [...view.stopped, ...view.recent]
1123        const isDrawn = result.kind === 'changed'
1124          ? rows.some(row => result.key.startsWith(`${row.key}::`))
1125          : rows.flatMap(row => row.questions ?? []).some(question => question.questionId === result.questionId)
1126        if (!isDrawn) await update($, resolveResult, () => null)
1127      }
1128      if (!isCurrent()) return
1129      await noteDone($, projects, progressTail(source) === null)
1130      isPrunePending = true
1131      await refreshStatus($)
1132      await writeDashboard($)
1133      await remind($)
1134    } catch (error) {
1135      if (!isCurrent()) return
1136      await update($, resolveResult, () => null)
1137      await update($, snapshot, () => ({ ...EMPTY, path, isMissing: false, error: messageOf(error) }))
1138    }
1139  } finally {
1140    if (isCurrent()) isPolling = false
1141    if (run === replacedPoll) replacedPoll = null
1142  }
1143}
1144
1145const dashboardPath = (path: string): string => `${path.replace(/\.md$/i, '')}.html`
1146
1147const pad2 = (n: number): string => String(n).padStart(2, '0')
1148
1149// Pending turns are optional: an unavailable inbox must not stop the board or its reminders.
1150let pendingCache: { key: string; turns: PendingTurn[] } | null = null
1151const pendingTurns = async ($: EngineInterface): Promise<PendingTurn[]> => {
1152  try {
1153    const path = await progressPath($)
1154    const dir = inboxPath(path)
1155    const done = await readInboxRecord($, 'inboxDone').catch(() => ({} as Record<string, string>))
1156    const entries = (await $.fs.list(dir))
1157      .filter(entry => entry.kind === 'file' && isInboxFile(entry.name) && done[entry.name] === undefined)
1158      .sort((a, b) => b.mtimeMs - a.mtimeMs)
1159      .slice(0, 30)
1160    // What the turns are made from: the listing (names, times, sizes) and which files are taken
1161    // already. The time of day is not part of it, so an inbox that did not change reads no file (#156).
1162    const cacheKey = JSON.stringify([path, entries.map(entry => [entry.name, entry.mtimeMs, entry.size]), done])
1163    if (pendingCache?.key === cacheKey) return pendingCache.turns
1164    const files: Array<{ name: string; body: string; at: number }> = []
1165    let isComplete = true
1166    for (const entry of entries) {
1167      const body = await $.fs.read(`${dir}${sepOf(dir)}${entry.name}`).catch(() => {
1168        isComplete = false
1169        return ''
1170      })
1171      files.push({ name: entry.name, body, at: entry.mtimeMs })
1172    }
1173    const home = ((await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME')) ?? '').replace(/[\\/]+$/, '')
1174    const isWin = await isWindows($, path)
1175    const turns = peekInbox(files, () => '', text => redact(text, home, isWin))
1176    // A file that could not be read is tried again at the next look.
1177    if (isComplete) pendingCache = { key: cacheKey, turns }
1178    return turns
1179  } catch {
1180    return []
1181  }
1182}
1183
1184const views = viewCache()
1185// The store file's size at the last sweep (sweepFiles), for the warning above the board.
1186let measuredStore = 0
1187// What the board cleared in the last 7 days, read at the last sweep and after each prune (refreshPruned).
1188let measuredPruned = { agent: 0, mirror: 0, turns: 0 }
1189
1190// The moment a view is built for: the pane's, which the 30 s tick and each change of the file move.
1191const viewTime = async ($: EngineInterface): Promise<number> => (await read($, now)) || (await $.clock.now())
1192
1193// What the pane and the page say above the board when the file is cut off or too big, the line
1194// saying what the board cleared lately (its own, since it is not a warning), and the health
1195// section at the bottom when a block has no end marker.
1196const warningOf = (snap: { size: number; tornTail?: { tasks: number } | null; cutBlocks?: readonly CutBlock[] }, s: Strings): { warning?: string; pruned?: string; health?: string[] } => {
1197  const lines = [...(snap.tornTail ? [s.progressIncomplete(snap.tornTail.tasks)] : []), ...sizeWarnings({ progress: snap.size, store: measuredStore }, s)]
1198  const pruned = prunedNote(measuredPruned, s)
1199  const health = cutBlockLines(snap.cutBlocks ?? [], s)
1200  return { ...(lines.length === 0 ? {} : { warning: lines.join(' · ') }), ...(pruned === null ? {} : { pruned }), ...(health.length === 0 ? {} : { health }) }
hooks/connect-pure.ts 1122 lines
1import { boardPromptHelper, boardRequestWords, boardRun, geminiBoardCommand, normalizeCommandPath, opencodeBoardCommand } from './board-commands'
2import { HOOK_VERSION, hookGuardSource, hostHookTimeout } from './hook-safety'
3import { INBOX_DROPPED_COPY, INBOX_DROPPED_FILE, INBOX_DROPPED_FOLD_BYTES, INBOX_DROPPED_KEEP_DAYS, INBOX_DROPPED_STALE_MINUTES, INBOX_MAX_BYTES, INBOX_MAX_FILES } from './hook-reporters'
4import { antigravityCommand, antigravityHooks, directHookArgs, encodedCommandText, hookCommandArgs } from './local-agents'
5import { legacyOpenClawPlugin, legacyOpenCodePlugin } from './legacy-hook-plugins'
6import { LANGS, ZH_TW } from './i18n'
7import type { Strings } from './i18n'
8import { RELEASED } from './released'
9import type { ReleasedKind } from './released'
10import { sha256 } from './sha256'
11import { STRINGS } from './strings'
12
13const BOARD_WORDS = boardRequestWords(Object.values(STRINGS))
14const BOARD_PROMPT_HELPER = boardPromptHelper(BOARD_WORDS)
15
16// The inbox cap (#373) for the plugins that save turn files themselves, as the reporter scripts
17// keep it: once a turn file is saved, the oldest go past INBOX_MAX_FILES files or INBOX_MAX_BYTES
18// bytes, and each removal is counted in INBOX_DROPPED_FILE. A failure here never loses the turn
19// just saved.
20const INBOX_CAP = `const capInbox = saved => {
21  try {
22    const turns = readdirSync(INBOX).filter(name => /^[a-z][a-z0-9]*-(prompt|turn|end)-[\\w.-]+\\.json$/.test(name)).flatMap(name => {
23      try {
24        const stat = statSync(join(INBOX, name))
25        return stat.isFile() ? [{ name, size: stat.size, at: stat.mtimeMs }] : []
26      } catch { return [] }
27    }).sort((a, b) => a.at - b.at)
28    let files = turns.length, bytes = turns.reduce((sum, one) => sum + one.size, 0), removed = 0
29    for (const one of turns) {
30      if (files <= ${INBOX_MAX_FILES} && bytes <= ${INBOX_MAX_BYTES}) break
31      if (one.name === saved) continue
32      try { unlinkSync(join(INBOX, one.name)); removed += 1 } catch {}
33      files -= 1; bytes -= one.size
34    }
35    if (removed === 0) return
36    const log = join(INBOX, '${INBOX_DROPPED_FILE}')
37    appendFileSync(log, new Date().toISOString().slice(0, 19) + 'Z ' + removed + ' cap\\n')
38    // A plugin keeps one process id for hours: each fold's copy is named for its time as well, so a
39    // copy an earlier fold of this process kept is never written over and is claimed like any other.
40    const aside = log + '.' + process.pid + String(Date.now())
41    // Claim only old, unclaimed copies left by a stopped writer. One that cannot be added back goes
42    // back under its own name, for the next writer to claim.
43    for (const name of readdirSync(INBOX)) {
44      try {
45        const copy = join(INBOX, name)
46        if (copy === aside || !/${INBOX_DROPPED_COPY}/.test(name)) continue
47        const stat = statSync(copy)
48        if (!stat.isFile() || stat.mtimeMs >= Date.now() - ${INBOX_DROPPED_STALE_MINUTES * 60000}) continue
49        const claimed = copy + '.' + process.pid
50        renameSync(copy, claimed)
51        try {
52          appendFileSync(log, readFileSync(claimed, 'utf8'))
53        } catch {
54          renameSync(claimed, copy)
55          continue
56        }
57        unlinkSync(claimed)
58      } catch {}
59    }
60    // Fold counts by UTC day and reason, keeping ${INBOX_DROPPED_KEEP_DAYS} days back.
61    if (statSync(log).size > ${INBOX_DROPPED_FOLD_BYTES}) {
62      renameSync(log, aside)
63      const cutoff = new Date(Date.now() - ${INBOX_DROPPED_KEEP_DAYS} * 86400000).toISOString().slice(0, 10)
64      const counts = new Map()
65      for (const line of readFileSync(aside, 'utf8').split('\\n')) {
66        const match = /^(\\d{4}-\\d\\d-\\d\\d)T\\d\\d:\\d\\d:\\d\\dZ (\\d+) (age|cap)\\r?$/.exec(line)
67        if (match === null || match[1] < cutoff) continue
68        const key = match[1] + ' ' + match[3]
69        counts.set(key, (counts.get(key) || 0) + Number(match[2]))
70      }
71      const folded = [...counts.keys()].sort().map(key => key.slice(0, 10) + 'T00:00:00Z ' + counts.get(key) + ' ' + key.slice(11) + '\\n').join('')
72      appendFileSync(log, folded)
73      unlinkSync(aside)
74    }
75  } catch {}
76}
77`
78
79export { reporterSh, reporterPs1 } from './hook-reporters'
80
81export const shellQuote = (text: string): string => `'${text.replace(/'/g, `'\\''`)}'`
82
83export const psQuote = (text: string): string => `'${text.replace(/'/g, "''")}'`
84
85// The command line a hook runs for one agent and event. When the script is gone (the
86// person deleted ~/.claude, or uninstalled without `/progress connect remove`), sh exits 2,
87// which Gemini CLI, Codex, Grok Build and Muse Code read as "block" and run the agent
88// again, for ever; `|| true` keeps it at 0. PowerShell exits 64 for a missing file, which
89// they only warn about.
90export const reportCommand = (script: string, agent: string, event: 'prompt' | 'turn' | 'end', isWindows: boolean): string =>
91  isWindows
92    ? `powershell -NoProfile -ExecutionPolicy Bypass -File "${script}" ${agent} ${event}`
93    : `sh ${shellQuote(script)} ${agent} ${event} || true`
94
95// A hook command this plugin wrote: it runs the reporter script by its path, so a person's
96// own `my-progress-report.sh` is not taken for ours.
97const OURS = /(?:^|[\\/'"\s])progress-report\.(?:sh|ps1)(?:\\?['"])?(?:\s|$)/
98
99// Whether a config already runs our reporter, whatever else taking our hooks out would tidy.
100export const hasOurHook = (text: string): boolean => OURS.test(text)
101
102// Whether the computer is Windows: Windows sets OS=Windows_NT in every shell (Git Bash too),
103// and a path on a drive letter says so even when written with forward slashes
104// (`C:/Users/me/progress.md`). Backslashes alone are no sign either way.
105export const isWindowsHost = (os: string | undefined, path: string): boolean => os === 'Windows_NT' || /^[A-Za-z]:[\\/]/.test(path)
106
107type Json = Record<string, unknown>
108
109const isRecord = (value: unknown): value is Json => value !== null && typeof value === 'object' && !Array.isArray(value)
110
111const commandOf = (entry: unknown): string => {
112  if (!isRecord(entry)) return ''
113  if (typeof entry.command === 'string') return entry.command
114  if (Array.isArray(entry.command)) return entry.command.join(' ')
115
116  return ''
117}
118
119// Hooks grouped the way Claude Code writes them (Codex, Gemini CLI, Grok Build and Muse
120// Code take the same shape): `hooks.<Event>` is a list of `{ matcher?, hooks: [{ type,
121// command }] }`. Ours replaces any earlier one of ours; everything else stays.
122export const withGroupedHook = (config: Json, event: string, command: string, extra: Json = {}): Json => {
123  const hooks = isRecord(config.hooks) ? config.hooks : {}
124  const groups = Array.isArray(hooks[event]) ? (hooks[event] as unknown[]) : []
125  const ours = { hooks: [{ ...(typeof extra.name === 'string' ? { name: extra.name } : {}), type: 'command', command, ...extra }] }
126  let placed = false
127  const kept = groups.flatMap(group => {
128    if (!isRecord(group) || !Array.isArray(group.hooks)) return [group]
129    const previous = group.hooks
130    const entries: unknown[] = []
131    for (const entry of previous) {
132      if (!OURS.test(commandOf(entry))) entries.push(entry)
133      else if (!placed) { entries.push(ours.hooks[0]); placed = true }
134    }
135    return entries.length === previous.length && entries.every((entry, index) => entry === previous[index]) ? [group] : entries.length === 0 ? [] : [{ ...group, hooks: entries }]
136  })
137
138  return { ...config, hooks: { ...hooks, [event]: placed ? kept : [...kept, ours] } }
139}
140
141// Cursor's shape: `{ version: 1, hooks: { <event>: [{ command }] } }`.
142export const withFlatHook = (config: Json, event: string, command: string, extra: Json = {}): Json => {
143  const hooks = isRecord(config.hooks) ? config.hooks : {}
144  const list = Array.isArray(hooks[event]) ? (hooks[event] as unknown[]) : []
145  const kept = list.filter(one => !OURS.test(commandOf(one)))
146
147  return { version: 1, ...config, hooks: { ...hooks, [event]: [...kept, { command, ...extra }] } }
148}
149
150// Every hook of ours taken out, in either shape; an event left empty goes too.
151export const withoutOurHooks = (config: Json): Json => {
152  if (!isRecord(config.hooks)) return config
153  const hooks: Json = {}
154  for (const [event, value] of Object.entries(config.hooks)) {
155    if (!Array.isArray(value)) {
156      hooks[event] = value
157      continue
158    }
159    const kept = value
160      .map(entry => {
161        if (isRecord(entry) && Array.isArray(entry.hooks)) {
162          const inner = entry.hooks.filter(one => !OURS.test(commandOf(one)))
163          return inner.length === 0 ? null : { ...entry, hooks: inner }
164        }
165        return OURS.test(commandOf(entry)) ? null : entry
166      })
167      .filter(entry => entry !== null)
168    if (kept.length > 0) hooks[event] = kept
169  }
170
171  return { ...config, hooks }
172}
173
174// A number's significant digits, without sign, point, exponent or the zeros around them.
175const digitsOf = (number: string): string => number.replace(/[eE].*$/, '').replace(/[-.]/g, '').replace(/^0+/, '').replace(/0+$/, '')
176
177// A number JSON.parse cannot hold as written: an integer past 2^53 (a Discord or Telegram
178// id), one past a double's range (1e400, 1e-400), or more digits than a double keeps.
179// Written back, it would come out as a different number: the digits a double prints for
180// itself are not the ones written.
181const losesNumbers = (text: string): boolean =>
182  (text.replace(/"(?:[^"\\]|\\.)*"/g, '""').match(/-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?/g) ?? []).some(literal => {
183    const value = Number(literal)
184    return !Number.isFinite(value) || digitsOf(value.toExponential()) !== digitsOf(literal)
185  })
186
187// A config file read as JSON: `{}` when it is missing or empty, null when it holds
188// something that is not a JSON object (comments, a typo) or a number it would change, which
189// is then left alone.
190export const readConfig = (text: string): Json | null => {
191  if (text.trim() === '') return {}
192  try {
193    const parsed: unknown = JSON.parse(text)
194    return isRecord(parsed) && !losesNumbers(text) ? parsed : null
195  } catch {
196    return null
197  }
198}
199
200// The config written back, or the text as it was when nothing in it changed, so a file the
201// person or the agent formats its own way is not rewritten (and announced) at every start.
202const writeChanged = (text: string, before: Json, after: Json): string =>
203  JSON.stringify(after) === JSON.stringify(before) ? text : writeConfig(after)
204
205export const writeConfig = (config: Json): string => `${JSON.stringify(config, null, 2)}\n`
206
207// The section setup --agents puts in an agent's instruction file, under the plugin's name or
208// the one it had before (claude-progress-pane). A hook replaces it, so the agent's work does
209// not show twice.
210const SECTION = /\n*<!-- (agent-board|claude-progress-pane):start -->[\s\S]*?<!-- \1:end -->\n*/g
211
212export const withoutSection = (text: string): string => {
213  const after = text.replace(SECTION, '\n')
214
215  return after === text ? text : after.replace(/^\n+/, '')
216}
217
218// Where each agent keeps what `/progress connect` changes, and how. `dir` says the agent is
219// installed (its folder under the home directory); `edit` turns the file's text into the
220// new text, or null when the file cannot be read as JSON and is left alone.
221// `node` and `progress` build Antigravity's commands; only a connect that writes them passes them.
222export type ConnectContext = { home: string; sep: string; isWindows: boolean; script: string; inbox: string; codexHome: string; grokHome: string; hermesHome: string; openclawHome: string; opencodeHome?: string; board?: string; node?: string; antigravityNode?: string; progress?: string; s?: Strings }
223
224export type AgentHook = {
225  agent: string
226  // '' for a file that only shows the board (a board command, the shared skill): it is
227  // written, but connecting an agent means its turns reach the board, so it is never named.
228  label: string
229  dir: string
230  // Any of these says the agent is installed, when there is more than one place to look.
231  dirs?: string[]
232  file: string
233  // The file an earlier name of the plugin wrote instead, taken away when connecting or removing.
234  old?: string
235  generated?: boolean
236  edit: (text: string, remove: boolean, previous?: string) => string | null | typeof DELETE | typeof COLLISION
237}
238
239// What `edit` answers for a file of ours that removing takes away whole.
240export const DELETE = Symbol('delete')
241export const COLLISION = Symbol('collision')
242
243// Whole generated files are replaced only when their exact bytes are known. The stored
244// previous output permits upgrades; legacy templates (a list, or a check such as
245// `isReleased`) permit adoption before that record existed. A familiar path or a copied
246// header is not ownership of the rest of a file.
247export const generatedFile = (content: string, legacy: readonly string[] | ((text: string) => boolean) = []): AgentHook['edit'] => (text, remove, previous) => {
248  const isKnown = (): boolean => (typeof legacy === 'function' ? legacy(text) : legacy.includes(text))
249  if (text !== '' && text !== content && text !== previous && !isKnown()) return COLLISION
250
251  return remove ? (text === '' ? null : DELETE) : content
252}
253
254const join = (ctx: ConnectContext, ...parts: string[]): string => [ctx.home, ...parts].join(ctx.sep)
255
256// Other tools (Orca among them) put hooks in ~/.grok/hooks for a Grok Build that may never
257// be installed, so the folder alone is no sign of it: Grok's own config or sessions are.
258const grokSigns = (ctx: ConnectContext): string[] => ['config.toml', 'sessions', '.metadata_version', 'agent_id'].map(name => [ctx.grokHome, name].join(ctx.sep))
259
260// The Antigravity app and `agy` each leave a folder of their own; both read ~/.gemini/config.
261const antigravitySigns = (ctx: ConnectContext): string[] => ['antigravity', 'antigravity-cli', 'antigravity-ide'].map(name => join(ctx, '.gemini', name))
262
263// Antigravity's definition of ours in ~/.gemini/config/hooks.json, and the hook worker its
264// commands run, next to the reporter. The installer's are agent-board-local and
265// agent-board-hook.mjs, so the two never take each other's (#285).
266export const ANTIGRAVITY_HOOK = 'agent-board'
267const INSTALLER_ANTIGRAVITY_HOOK = 'agent-board-local'
268export const HOOK_WORKER = 'progress-hook.mjs'
269
270export const hasAntigravityHook = (text: string): boolean => {
271  const config = readConfig(text)
272  return config !== null && Object.hasOwn(config, ANTIGRAVITY_HOOK)
273}
274
275// setup-agent's own Antigravity hooks, which the connect leaves to report alone.
276export const hasInstallerAntigravityHook = (text: string): boolean => {
277  const config = readConfig(text)
278  return config !== null && Object.hasOwn(config, INSTALLER_ANTIGRAVITY_HOOK)
279}
280
281const jsonEdit =
282  (add: (config: Json) => Json, preservePositions = false) =>
283  (text: string, remove: boolean): string | null => {
284    const config = readConfig(text)
285    if (config === null) return null
286    return writeChanged(text, config, remove ? withoutOurHooks(config) : add(preservePositions ? config : withoutOurHooks(config)))
287  }
288
289// Hermes Agent keeps its hooks in YAML: `hooks.<event>` is a list of `{ command, timeout }`.
290// With no YAML parser here, ours go in as one flow-style line each, found again by the
291// reporter script's name, and a `hooks:` written in a shape this cannot follow (flow style)
292// is left alone. The empty map `hooks: {}` is the one flow-style value it can follow: Hermes
293// writes that into a new config.yaml, and it holds nothing to keep.
294// A comment runs to the line end, a Windows `\r` included.
295const HERMES_KEY = /^\s+(?:pre_llm_call|post_llm_call|on_session_finalize)\s*:\s*(?:#[^\n]*)?$/
296const TOP_HOOKS = /^hooks\s*:\s*(?:#[^\n]*)?$/
297const EMPTY_HOOKS = /^hooks\s*:\s*\{\s*\}\s*(?:#[^\n]*)?$/
298
299const indentOf = (line: string): number => line.length - line.trimStart().length
300
301const isContent = (line: string): boolean => line.trim() !== '' && !line.trimStart().startsWith('#')
302
303// Whether the key on line `at` has anything under it (YAML lets a list sit at the key's own
304// indent).
305const hasChildren = (lines: string[], at: number): boolean => {
306  const indent = indentOf(lines[at] ?? '')
307  const next = lines.slice(at + 1).find(isContent)
308  if (next === undefined) return false
309
310  return indentOf(next) > indent || (indentOf(next) === indent && next.trimStart().startsWith('- '))
311}
312
313// Adding ours again keeps a `hooks:` that only held ours, so they go back where they were
314// rather than to the end of the file.
315// Our list items, each with the lines that continue it: a YAML dumper (Hermes' own setup,
316// or a person tidying the file) writes our flow-style item in block style, its timeout on a
317// line of its own. A comment or another line that names the script is no item of ours.
318const hermesItems = (lines: readonly string[]): Array<[number, number]> => {
319  const items: Array<[number, number]> = []
320  lines.forEach((line, at) => {
321    if (!line.trimStart().startsWith('- ')) return
322    const indent = indentOf(line)
323    let end = at + 1
324    while (end < lines.length && isContent(lines[end] ?? '') && indentOf(lines[end] ?? '') > indent) end += 1
325    if (lines.slice(at, end).some(one => isContent(one) && OURS.test(one))) items.push([at, end])
326  })
327
328  return items
329}
330
331const stripHermesHooks = (text: string, isKeepingTop: boolean): string => {
332  const lines = text.split('\n')
333  const drop = new Set<number>()
334  for (const [start, end] of hermesItems(lines)) for (let index = start; index < end; index += 1) drop.add(index)
335  const kept = lines.filter((_, at) => !drop.has(at))
336  if (kept.length === lines.length) return text
337  const keys = kept.filter((line, at) => !(HERMES_KEY.test(line) && !hasChildren(kept, at)))
338  const rest = isKeepingTop ? keys : keys.filter((line, at) => !(TOP_HOOKS.test(line) && !hasChildren(keys, at)))
339
340  return `${rest.join('\n').replace(/\n{3,}/g, '\n\n').replace(/\n+$/, '')}\n`.replace(/^\n$/, '')
341}
342
343export const withoutHermesHooks = (text: string): string => stripHermesHooks(text, false)
344
345export const withHermesHooks = (text: string, events: Array<[string, string]>): string | null => {
346  // Ours all there already, in whatever style Hermes last wrote them: nothing to change, so
347  // a session start neither rewrites the file nor says it connected Hermes again.
348  const lines0 = text.split('\n')
349  const items = hermesItems(lines0).map(([start, end]) => lines0.slice(start, end).join('\n'))
350  // A dumper folds a long command onto the next line at a space (PyYAML at 80 columns, the
351  // way Hermes saves config.yaml, splits the Windows command), so a run of spaces and line
352  // breaks counts as one space.
353  const flat = (one: string): string => one.replace(/\s+/g, ' ')
354  const isItemOf = (item: string, command: string): boolean =>
355    [command, JSON.stringify(command), `'${command.replace(/'/g, "''")}'`].some(form => flat(item).includes(flat(form)))
356  const hasTimeout = (item: string): boolean => [...item.matchAll(/(?:^|[,{\n])\s*timeout\s*:\s*(\d+)/g)].some(match => Number(match[1]) >= hostHookTimeout('hermes'))
357  if (items.length === events.length && events.every(([, command]) => items.some(item => isItemOf(item, command) && hasTimeout(item)))) return text
358  const base = stripHermesHooks(text, true)
359  const item = (command: string): string => `- {command: ${JSON.stringify(command)}, timeout: ${hostHookTimeout('hermes')}}`
360  // Lines of ours end the way the file's own lines do.
361  const eol = text.includes('\r\n') ? '\r' : ''
362  // An empty `hooks: {}` becomes a bare `hooks:` that ours go under.
363  const lines = base
364    .replace(/\n+$/, '')
365    .split('\n')
366    .map(line => (EMPTY_HOOKS.test(line) ? `${line.replace(/\{\s*\}\s*/, '').replace(/\s+(#|\r|$)/, ' $1').trimEnd()}${line.endsWith('\r') ? '\r' : ''}` : line))
367  const at = lines.findIndex(line => /^hooks\s*:/.test(line))
368  if (at === -1) {
369    const added = ['hooks:', ...events.flatMap(([event, command]) => [`  ${event}:`, `    ${item(command)}`])].join(`${eol}\n`)
370    return base.trim() === '' ? `${added}${eol}\n` : `${base.replace(/(?:\r?\n)+$/, '')}${eol}\n${eol}\n${added}${eol}\n`
371  }
372  if (!TOP_HOOKS.test(lines[at] ?? '')) return null
373  let end = lines.findIndex((line, index) => index > at && isContent(line) && indentOf(line) === 0)
374  if (end === -1) end = lines.length
375  let insert = end
376  while (insert - 1 > at && !isContent(lines[insert - 1] ?? '')) insert -= 1
377  const firstChild = lines.slice(at + 1, end).find(isContent)
378  const indent = firstChild === undefined ? 2 : indentOf(firstChild)
379  for (const [event, command] of events) {
380    const key = lines.findIndex((line, index) => index > at && index < end && indentOf(line) === indent && new RegExp(`^\\s*${event}\\s*:`).test(line))
381    if (key === -1) {
382      lines.splice(insert, 0, `${' '.repeat(indent)}${event}:${eol}`, `${' '.repeat(indent + 2)}${item(command)}${eol}`)
383      insert += 2
384      end += 2
385      continue
386    }
387    const after = (lines[key] ?? '').replace(/^[^:]*:/, '').trim()
388    if (after !== '' && !after.startsWith('#')) return null
389    const next = lines.slice(key + 1, end).find(isContent)
390    const nested = next !== undefined && (indentOf(next) > indent || (indentOf(next) === indent && next.trimStart().startsWith('- ')))
391    lines.splice(key + 1, 0, `${' '.repeat(nested && next !== undefined ? indentOf(next) : indent + 2)}${item(command)}${eol}`)
392    if (key < insert) insert += 1
393    end += 1
394  }
395
396  return `${lines.join('\n')}\n`
397}
398
399export const agentHooks = (ctx: ConnectContext): AgentHook[] => {
400  const opencodeHome = ctx.opencodeHome ?? join(ctx, '.config', 'opencode')
401  const turn = (agent: string): string => reportCommand(ctx.script, agent, 'turn', ctx.isWindows)
402  const end = (agent: string): string => reportCommand(ctx.script, agent, 'end', ctx.isWindows)
403  const prompt = (agent: string): string => reportCommand(ctx.script, agent, 'prompt', ctx.isWindows)
404
405  return [
406    {
407      // Stop carries last_assistant_message; Codex asks the person to approve a new hook in
408      // its /hooks before it runs.
409      agent: 'codex',
410      label: 'Codex',
411      dir: ctx.codexHome,
412      file: [ctx.codexHome, 'hooks.json'].join(ctx.sep),
413      edit: jsonEdit(config =>
414        withGroupedHook(withGroupedHook(withGroupedHook(config, 'Stop', turn('codex'), { timeout: hostHookTimeout('codex', 'Stop') }), 'SessionEnd', end('codex'), { timeout: hostHookTimeout('codex', 'SessionEnd') }), 'UserPromptSubmit', prompt('codex'), { timeout: hostHookTimeout('codex', 'UserPromptSubmit') }),
415        true,
416      ),
417    },
418    {
419      // AfterAgent carries prompt and prompt_response. Hooks are on by default; timeout is in ms.
420      agent: 'gemini',
421      label: 'Gemini CLI',
422      dir: join(ctx, '.gemini'),
423      file: join(ctx, '.gemini', 'settings.json'),
424      edit: jsonEdit(config =>
425        withGroupedHook(withGroupedHook(
426          withGroupedHook(config, 'AfterAgent', turn('gemini'), { name: 'agent-board', timeout: hostHookTimeout('gemini', 'AfterAgent') }),
427          'SessionEnd',
428          end('gemini'),
429          { name: 'agent-board-end', timeout: hostHookTimeout('gemini', 'SessionEnd') },
430        ), 'BeforeAgent', prompt('gemini'), { name: 'agent-board-prompt', timeout: hostHookTimeout('gemini', 'BeforeAgent') }),
431      ),
432    },
433    {
434      // afterAgentResponse carries the reply's text; stop carries none, but fires where the
435      // CLI skips the other; sessionEnd ends the block.
436      agent: 'cursor',
437      label: 'Cursor',
438      dir: join(ctx, '.cursor'),
439      file: join(ctx, '.cursor', 'hooks.json'),
440      edit: jsonEdit(config =>
441        withFlatHook(withFlatHook(withFlatHook(withFlatHook(config, 'afterAgentResponse', turn('cursor'), { timeout: hostHookTimeout('cursor') }), 'stop', turn('cursor'), { timeout: hostHookTimeout('cursor') }), 'sessionEnd', end('cursor'), { timeout: hostHookTimeout('cursor') }), 'beforeSubmitPrompt', prompt('cursor'), { timeout: hostHookTimeout('cursor') }),
442      ),
443    },
444    {
445      // A file of its own in ~/.grok/hooks; Stop carries lastAssistantMessage. Timeout in s: on
446      // Windows PowerShell alone takes most of a second to start, so the end hook gets 5.
447      agent: 'grok',
448      label: 'Grok Build',
449      dir: ctx.grokHome,
450      dirs: grokSigns(ctx),
451      file: [ctx.grokHome, 'hooks', 'agent-board.json'].join(ctx.sep),
452      old: [ctx.grokHome, 'hooks', 'progress-pane.json'].join(ctx.sep),
453      generated: true,
454      edit: generatedFile(
455        writeConfig(withGroupedHook(withGroupedHook(withGroupedHook({}, 'Stop', turn('grok'), { timeout: hostHookTimeout('grok', 'Stop') }), 'SessionEnd', end('grok'), { timeout: hostHookTimeout('grok', 'SessionEnd') }), 'UserPromptSubmit', prompt('grok'), { timeout: hostHookTimeout('grok', 'UserPromptSubmit') })),
456        [1, 5].map(timeout => writeConfig(withGroupedHook(withGroupedHook({}, 'Stop', turn('grok'), { timeout: 10 }), 'SessionEnd', end('grok'), { timeout }))),
457      ),
458    },
459    {
460      // Muse wants schema_version 1 and no handler keys beyond type, command, timeout. The end
461      // hook gets 5 s for the same reason as Grok's.
462      agent: 'muse',
463      label: 'Muse Code',
464      dir: join(ctx, '.config', 'muse'),
465      file: join(ctx, '.config', 'muse', 'settings.json'),
466      edit: jsonEdit(config =>
467        withGroupedHook(withGroupedHook(withGroupedHook({ schema_version: 1, ...config }, 'Stop', turn('muse'), { timeout: hostHookTimeout('muse', 'Stop') }), 'SessionEnd', end('muse'), { timeout: hostHookTimeout('muse', 'SessionEnd') }), 'UserPromptSubmit', prompt('muse'), { timeout: hostHookTimeout('muse', 'UserPromptSubmit') }),
468      ),
469    },
470    {
471      // post_llm_call carries the turn's words under `extra`. Its on_session_end fires after
472      // every turn, so the end is on_session_finalize. Hermes asks once before it runs a new
473      // hook (or takes --accept-hooks). Timeout in s.
474      agent: 'hermes',
475      label: 'Hermes Agent',
476      dir: ctx.hermesHome,
477      file: [ctx.hermesHome, 'config.yaml'].join(ctx.sep),
478      edit: (text, remove) =>
479        remove
480          ? withoutHermesHooks(text)
481          : withHermesHooks(text, [
482              ['post_llm_call', turn('hermes')],
483              ['on_session_finalize', end('hermes')],
484              ['pre_llm_call', prompt('hermes')],
485            ]),
486    },
487    {
488      // OpenClaw takes plugins, not commands. A standalone file goes in plugins.load.paths and
489      // takes its file name as its id; it must come before the config entry that loads it.
490      agent: 'openclaw',
491      label: 'OpenClaw',
492      dir: ctx.openclawHome,
493      file: [ctx.openclawHome, OPENCLAW_FILE].join(ctx.sep),
494      generated: true,
495      edit: generatedFile(openclawPlugin(ctx), [openclawPlugin(ctx, false), legacyOpenClawPlugin(ctx), legacyOpenClawPlugin(ctx, false), legacyOpenClawPlugin(ctx, true, false)]),
496    },
497    {
498      agent: 'openclaw',
499      label: 'OpenClaw',
500      dir: ctx.openclawHome,
501      file: [ctx.openclawHome, 'openclaw.json'].join(ctx.sep),
502      edit: (text, remove) => {
503        const config = readConfig(text)
504        if (config === null) return null
505        const file = [ctx.openclawHome, OPENCLAW_FILE].join(ctx.sep)
506        // Nothing of ours to take out: the person's file stays byte for byte.
507        if (remove && !hasOpenclawPlugin(config, file)) return text
508        return writeChanged(text, config, remove ? withoutOpenclawPlugin(config, file) : withOpenclawPlugin(config, file))
509      },
510    },
511    {
512      agent: 'opencode',
513      label: 'OpenCode',
514      dir: opencodeHome,
515      file: [opencodeHome, 'plugin', 'agent-board.js'].join(ctx.sep),
516      old: [opencodeHome, 'plugin', 'progress-pane.js'].join(ctx.sep),
517      generated: true,
518      edit: generatedFile(opencodePlugin(ctx), [opencodePlugin(ctx, false), legacyOpenCodePlugin(ctx), legacyOpenCodePlugin(ctx, false), legacyOpenCodePlugin(ctx, true, false)]),
519    },
520    {
521      // Antigravity groups its events under one named definition. Its commands are the
522      // installer's: the hook worker runs the board within Antigravity's limit (#241, #285).
523      agent: 'antigravity',
524      label: 'Antigravity',
525      dir: join(ctx, '.gemini', 'antigravity'),
526      dirs: antigravitySigns(ctx),
527      file: join(ctx, '.gemini', 'config', 'hooks.json'),
528      edit: (text, remove) => {
529        const config = readConfig(text)
530        if (config === null) return null
531        const { [ANTIGRAVITY_HOOK]: _, ...others } = config
532        // Where the installer (setup-agent) hooked Antigravity, its hooks report: a second set
533        // would report every event twice.
534        if (remove || Object.hasOwn(config, INSTALLER_ANTIGRAVITY_HOOK)) return writeChanged(text, config, others)
535        const folder = ctx.script.replace(/[\\/][^\\/]*$/, '')
536        const progress = ctx.progress ?? [folder, 'progress.md'].join(ctx.sep)
537        const command = (event: string, state: string): string =>
538          antigravityCommand(hookCommandArgs(ctx.node ?? 'node', ctx.home, 'antigravity', [folder, HOOK_WORKER].join(ctx.sep), progress, event, state), ctx.isWindows, shellQuote, ctx.antigravityNode)
539        return writeChanged(text, config, { ...config, [ANTIGRAVITY_HOOK]: antigravityHooks(command) })
540      },
541    },
542    ...boardCommands(ctx),
543  ]
544}
545
546// OpenCode takes plugins, not commands: this one writes the session's last exchange into the
547// inbox when the session goes idle (a subagent's session aside), and an end when it is
548// deleted. Every export of a plugin file must be a function.
549export const opencodePlugin = (ctx: ConnectContext, withPrompt = true, flagsBoardRequests = true): string => `// Written by the Agent Board plugin (/progress connect): reports each finished turn to the Agent Board
550// pane.
551import { appendFileSync, closeSync, mkdirSync, openSync, readdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs'
552import { join } from 'node:path'
553${hookGuardSource(ctx.home, 'opencode')}
554
555${withPrompt && flagsBoardRequests ? BOARD_PROMPT_HELPER + '\n' : ''}const INBOX = ${JSON.stringify(ctx.inbox)}
556
557${INBOX_CAP}
558const save = (event, payload, current) => {
559  let temporary, owned = false
560  try {
561    current()
562    mkdirSync(INBOX, { recursive: true })
563    const name = \`opencode-\${event}-\${Date.now()}-\${process.pid}\`
564    temporary = join(INBOX, name + '.tmp')
565    const output = openSync(temporary, 'wx', 0o600)
566    owned = true
567    try { writeFileSync(output, JSON.stringify(payload)) }
568    finally { closeSync(output) }
569    current()
570    renameSync(temporary, join(INBOX, name + '.json'))
571    owned = false
572    capInbox(name + '.json')
573  } catch (error) { hookError(error?.message === 'HOOK_TIMEOUT' ? 'REPORTER_TIMEOUT' : 'REPORTER_FAILED') }
574  finally { if (owned) { try { unlinkSync(temporary) } catch {} } }
575}
576
577const textOf = message =>
578  (message?.parts ?? [])
579    .filter(part => part.type === 'text' && !part.synthetic && !part.ignored)
580    .map(part => part.text)
581    .join('\\n')
582
583export const AgentBoard = async ({ client, directory }) => ({
584${withPrompt ? `  'chat.message': async ({ sessionID }${flagsBoardRequests ? ', output' : ''}) => withHook(async current => {
585      const session = current(await client.session.get({ path: { id: sessionID } }))
586      if (session.error) throw Error('HOST_METADATA_FAILED')
587      if (!session.data || session.data.id !== sessionID || session.data.parentID) return
588      save('prompt', { session_id: sessionID, cwd: directory${flagsBoardRequests ? ', ...(isBoardRequest(output) ? { board_request: true } : {})' : ''} }, current)
589  }),
590` : ''}\
591  event: async ({ event }) => withHook(async current => {
592      if (event.type === 'session.idle') {
593        const id = event.properties.sessionID
594        const session = current(await client.session.get({ path: { id } }))
595        if (session.error) throw Error('HOST_METADATA_FAILED')
596        if (session.data?.parentID) return
597        const listed = current(await client.session.messages({ path: { id } }))
598        if (listed.error) throw Error('HOST_METADATA_FAILED')
599        const messages = listed.data ?? []
600        const last = role => [...messages].reverse().find(message => message.info?.role === role)
601        save('turn', { session_id: id, cwd: directory, prompt: textOf(last('user')), last_assistant_message: textOf(last('assistant')) }, current)
602      } else if (event.type === 'session.deleted') {
603        const id = event.properties.info?.id
604        if (id) save('end', { session_id: id, cwd: directory }, current)
605      }
606  }),
607})
608`
609
610const OPENCLAW_ID = 'agent-board'
611const OPENCLAW_FILE = `${OPENCLAW_ID}.mjs`
612
613// The first OpenClaw whose config check takes plugins.entries.<id>.hooks.allowConversationAccess
614// (its changelog, #71221). An older one will not start with our entry in its config. Seen live
615// on 2026.3.8, which also wants a manifest beside a plugin file; by that changelog, 2026.4.23
616// asks for the grant before agent_end but still refuses the key.
617export const OPENCLAW_MIN = '2026.4.24'
618
619// A version the way OpenClaw reads one (2026.4.24, v2026.4.24, 2026.4.24-beta.1) as one number
620// to compare; null when it is not one.
621const calver = (version: string): number | null => {
622  const parts = /^v?(\d+)\.(\d+)\.(\d+)/.exec(version.trim())
623
624  return parts === null ? null : Number(parts[1]) * 10000 + Number(parts[2]) * 100 + Number(parts[3])
625}
626
627// The version, when it is older than OPENCLAW_MIN; null when it is that or newer, or is not a
628// version at all.
629export const openclawTooOld = (version: string): string | null => {
630  const at = calver(version)
631  const min = calver(OPENCLAW_MIN)
632
633  return at !== null && min !== null && at < min ? version.trim() : null
634}
635
636// The version that last saved an OpenClaw config (meta.lastTouchedVersion). Updating OpenClaw
637// leaves it as it was until OpenClaw saves the file again.
638export const openclawSavedBy = (text: string): string | null => {
639  const config = readConfig(text)
640  const version = config !== null && isRecord(config.meta) ? config.meta.lastTouchedVersion : undefined
641
642  return typeof version === 'string' ? version : null
643}
644
645// The version `openclaw --version` prints: `OpenClaw 2026.3.8 (3caab92)`.
646export const openclawPrinted = (stdout: string): string | null => /\bv?(\d+\.\d+\.\d+\S*)/.exec(stdout)?.[1] ?? null
647
648const hasOpenclawPlugin = (config: Json, file: string): boolean => {
649  const plugins = isRecord(config.plugins) ? config.plugins : {}
650  const load = isRecord(plugins.load) ? plugins.load : {}
651  const entries = isRecord(plugins.entries) ? plugins.entries : {}
652
653  return (Array.isArray(load.paths) && load.paths.includes(file)) || OPENCLAW_ID in entries || (Array.isArray(plugins.allow) && plugins.allow.includes(OPENCLAW_ID))
654}
655
656// OpenClaw loads a plugin that is not bundled only when enabled, and passes it a turn's
657// words (agent_end) only with allowConversationAccess. A restrictive plugins.allow list
658// still wins, so ours joins it when there is one. OpenClaw writes this file as plain JSON;
659// one hand-written as JSON5 is left alone.
660export const withOpenclawPlugin = (config: Json, file: string): Json => {
661  const plugins = isRecord(config.plugins) ? config.plugins : {}
662  const load = isRecord(plugins.load) ? plugins.load : {}
663  const paths = Array.isArray(load.paths) ? (load.paths as unknown[]) : []
664  const entries = isRecord(plugins.entries) ? plugins.entries : {}
665  const entry = isRecord(entries[OPENCLAW_ID]) ? (entries[OPENCLAW_ID] as Json) : {}
666  const hooks = isRecord(entry.hooks) ? entry.hooks : {}
667  const allow = Array.isArray(plugins.allow) && plugins.allow.length > 0 && !plugins.allow.includes(OPENCLAW_ID) ? { allow: [...plugins.allow, OPENCLAW_ID] } : {}
668
669  return {
670    ...config,
671    plugins: {
672      ...plugins,
673      ...allow,
674      load: { ...load, paths: paths.includes(file) ? paths : [...paths, file] },
675      entries: { ...entries, [OPENCLAW_ID]: { ...entry, enabled: true, hooks: { ...hooks, allowConversationAccess: true } } },
676    },
677  }
678}
679
680export const withoutOpenclawPlugin = (config: Json, file: string): Json => {
681  if (!isRecord(config.plugins)) return config
682  const { load, entries, allow, ...rest } = config.plugins
683  const plugins: Json = { ...rest }
684  if (isRecord(load)) {
685    const paths = Array.isArray(load.paths) ? (load.paths as unknown[]).filter(path => path !== file) : undefined
686    const { paths: _, ...other } = load
687    const kept = paths === undefined || paths.length === 0 ? other : { ...other, paths }
688    if (Object.keys(kept).length > 0) plugins.load = kept
689  } else if (load !== undefined) {
690    plugins.load = load
691  }
692  if (isRecord(entries)) {
693    const { [OPENCLAW_ID]: _, ...other } = entries
694    if (Object.keys(other).length > 0) plugins.entries = other
695  } else if (entries !== undefined) {
696    plugins.entries = entries
697  }
698  if (Array.isArray(allow)) {
699    const kept = allow.filter(id => id !== OPENCLAW_ID)
700    if (kept.length > 0) plugins.allow = kept
701  } else if (allow !== undefined) {
702    plugins.allow = allow
703  }
704
705  return { ...config, plugins }
706}
707
708// Writes each finished turn into the inbox itself, as the OpenCode plugin does, so it needs
709// no shell. Heartbeat and memory runs are OpenClaw talking to itself, not the person's work;
710// a compaction or a restart does not end the session.
711export const openclawPlugin = (ctx: ConnectContext, withPrompt = true, flagsBoardRequests = true): string => `// Written by the Agent Board plugin (/progress connect): reports each finished turn to the Agent Board
712// pane.
713import { appendFileSync, closeSync, mkdirSync, openSync, readdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs'
714import { join } from 'node:path'
715${hookGuardSource(ctx.home, 'openclaw')}
716
717${withPrompt && flagsBoardRequests ? BOARD_PROMPT_HELPER + '\n' : ''}const INBOX = ${JSON.stringify(ctx.inbox)}
718
719${INBOX_CAP}
720const save = (event, payload, current) => {
721  let temporary, owned = false
722  try {
723    current()
724    mkdirSync(INBOX, { recursive: true })
725    const name = \`openclaw-\${event}-\${Date.now()}-\${process.pid}\`
726    temporary = join(INBOX, name + '.tmp')
727    const output = openSync(temporary, 'wx', 0o600)
728    owned = true
729    try { writeFileSync(output, JSON.stringify(payload)) }
730    finally { closeSync(output) }
731    current()
732    renameSync(temporary, join(INBOX, name + '.json'))
733    owned = false
734    capInbox(name + '.json')
735  } catch (error) { hookError(error?.message === 'HOOK_TIMEOUT' ? 'REPORTER_TIMEOUT' : 'REPORTER_FAILED') }
736  finally { if (owned) { try { unlinkSync(temporary) } catch {} } }
737}
738
739const textOf = message =>
740  typeof message?.content === 'string'
741    ? message.content
742    : Array.isArray(message?.content)
743      ? message.content.filter(part => part?.type === 'text').map(part => part.text).join('\\n')
744      : ''
745
746export default {
747  id: '${OPENCLAW_ID}',
748  name: 'Agent Board',
749  register(api) {
750${withPrompt ? `    api.on('before_prompt_build', (_event, ctx) => withHook(current => {
751      try {
752        if (!ctx?.sessionId || ctx.inputProvenance?.kind !== 'external_user') return
753        save('prompt', { session_id: ctx.sessionId, cwd: ctx.workspaceDir ?? ''${flagsBoardRequests ? ', ...(isBoardRequest(_event) ? { board_request: true } : {})' : ''} }, current)
754      } catch { hookError('REPORTER_FAILED') }
755    }))
756` : ''}\
757    api.on('agent_end', (event, ctx) => withHook(current => {
758      try {
759        if (!ctx?.sessionId || ctx.trigger === 'heartbeat' || ctx.trigger === 'memory') return
760        const messages = Array.isArray(event?.messages) ? event.messages : []
761        const last = role => [...messages].reverse().find(message => message?.role === role)
762        const answer = textOf(last('assistant'))
763        if (answer.trim() === '') return
764        save('turn', { session_id: ctx.sessionId, cwd: ctx.workspaceDir ?? '', prompt: textOf(last('user')), last_assistant_message: answer }, current)
765      } catch { hookError('REPORTER_FAILED') }
766    }))
767    api.on('session_end', (event, ctx) => withHook(current => {
768      try {
769        if (event?.reason === 'compaction' || event?.reason === 'restart') return
770        const id = ctx?.sessionId ?? event?.sessionId
771        if (id) save('end', { session_id: id, cwd: ctx?.workspaceDir ?? '' }, current)
772      } catch { hookError('REPORTER_FAILED') }
773    }))
774  },
775}
776`
777
778// The standalone board, with the progress file and language of the session that wrote it
779// set on its first line (an ES module's imports run first either way).
780// It declares its hook version first, which the hook worker reads before running it (#285).
781export const boardScriptFor = (path: string, lang: string, code: string): string => `globalThis.AGENT_BOARD = ${JSON.stringify({ hookVersion: HOOK_VERSION, path, lang })}\n${code}`
782
783// The language a board script already written prints in, from its first line; '' when it
784// names none.
785export const boardLangOf = (code: string): string => {
786  const head = /^globalThis\.AGENT_BOARD = ([^\n]*)/.exec(code)
787  try {
788    const lang = (JSON.parse(head?.[1] ?? '') as { lang?: unknown } | null)?.lang
789    return typeof lang === 'string' ? lang : ''
790  } catch {
791    return ''
792  }
793}
794
795// What stands for this machine's paths in the hashes of hooks/released.ts. No file of ours
796// holds a NUL, so a file only matches once its own paths are put back.
797export const RELEASE_PATHS = { board: '\u0000board\u0000', inbox: '\u0000inbox\u0000' } as const
798
799// A file an earlier release wrote, before the plugin kept a record of its output: with this
800// machine's paths swapped for the placeholders, its bytes hash to one that release generated.
801// An installation from any tagged release (0.17.1 on) upgrades silently this way, while a file the person
802// edited by so much as a byte is still theirs.
803export const isReleased = (kind: ReleasedKind, text: string, paths: { board?: string; inbox?: string } = {}): boolean => {
804  let normal = text
805  if (paths.board) normal = normalizeCommandPath(normal, paths.board, RELEASE_PATHS.board)
806  if (paths.inbox) normal = normal.split(paths.inbox).join(RELEASE_PATHS.inbox)
807  const hash = sha256(normal)
808
809  return RELEASED.some(release => release[kind].includes(hash))
810}
811
812// The board script beside progress.md: its first line names this progress.md in one of the
813// languages, and the rest is a released bundle.
814export const isReleasedBoard = (text: string, path: string): boolean => {
815  const cut = text.indexOf('\n')
816  if (cut < 0) return false
817  const head = text.slice(0, cut)
818  // Since #285 the line names the hook version first; a board of any hook version is ours.
819  const heads = LANGS.flatMap(lang => [{ path, lang }, ...Array.from({ length: HOOK_VERSION }, (_, at) => ({ hookVersion: at + 1, path, lang }))])
820  if (!heads.some(one => head === `globalThis.AGENT_BOARD = ${JSON.stringify(one)}`)) return false
821
822  return isReleased('board', text.slice(cut + 1))
823}
824
825// The board script needs this Node.js or newer (the README says so): its commands run it, and
826// so does the terminal board. The reporting hooks do not.
827export const NODE_MIN = 18
828// What `node --version` printed, when it is a version.
829export const nodeVersionOf = (printed: string): string | null => {
830  const version = printed.trim()
831  return /^v\d+\.\d+\.\d+(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/.test(version) ? version : null
832}
833export const isNodeSupported = (version: string): boolean => Number(/^v(\d+)\./.exec(version)?.[1] ?? 0) >= NODE_MIN
834
835export const connectPath = (path: string, isWindows: boolean): string => {
836  const parts: string[] = []
837  for (const part of path.replace(/\\/g, '/').split('/')) {
838    if (part === '.' || part === '') continue
839    if (part === '..' && parts.length > 0 && parts.at(-1) !== '..') parts.pop()
840    else parts.push(part)
841  }
842  const normalized = (path.startsWith('/') || path.startsWith('\\') ? '/' : '') + parts.join('/')
843  return isWindows ? normalized.toLowerCase() : normalized
844}
845
846// Another folder's unfinished connection whose file stays unreadable names that file error.
847export const recoveryUnreadableCode = (problem: string): string | undefined => /^RECOVERY_UNREADABLE_(E[A-Z]{2,15})$/.exec(problem)?.[1]
848export const connectRecordPath = (home: string): string => [home.replace(/[\\/]+$/, ''), '.agent-board', 'legacy-connect.json'].join(home.includes('\\') ? '\\' : '/')
849
850export const hasOwnConnectData = (home: string, config: string, progress: string, isWindows: boolean, connectedProgress?: string): boolean => {
851  const folder = connectPath(`${home}/.claude`, isWindows)
852  const path = connectPath(progress, isWindows)
853  return (config !== '' && connectPath(config, isWindows) !== folder)
854    || path !== `${folder}/progress.md` && (connectedProgress === undefined || path !== connectPath(connectedProgress, isWindows))
855}
856
857// Hermes saves Unicode paths and wrapped commands with YAML escapes that JSON cannot read.
858const yamlDoubleQuoted = (quoted: string): string => quoted.slice(1, -1)
859  .replace(/\\(\r?\n[ \t]*|x[0-9A-Fa-f]{2}|u[0-9A-Fa-f]{4}|U[0-9A-Fa-f]{8}|[\s\S])|[ \t]*\r?\n[ \t]*/g, (_, escaped: string | undefined) => {
860    if (escaped === undefined) return ' '
861    if (/^\r?\n/.test(escaped)) return ''
862    if (escaped.length > 1) {
863      const point = parseInt(escaped.slice(1), 16)
864      return point <= 0x10ffff ? String.fromCodePoint(point) : ''
865    }
866    return ({ n: '\n', t: '\t', r: '\r', '0': '\0', a: '\x07', b: '\b', e: '\x1b', f: '\f', v: '\v', N: '\x85', _: '\xa0', L: '\u2028', P: '\u2029' } as Record<string, string>)[escaped] ?? escaped
867  })
868
869// Read path data in the formats we write, including JSON-escaped command strings and
870// the numeric literal paths used for board commands with shell-sensitive names.
871export const connectDataRoots = (text: string, isNearestOnly = false): string[] => {
872  const strings = [text, text.replace(/\r?\n[ \t]*/g, ' ')]
873  for (const source of [...strings]) for (const match of source.matchAll(/(?<=^|[\s:\-\[,{=(])"(?:\\[\s\S]|[^"\\])*"/g)) {
874    try { const value: unknown = JSON.parse(match[0]); if (typeof value === 'string') strings.push(value) }
875    catch { strings.push(yamlDoubleQuoted(match[0])) }
876  }
877  const roots = new Set<string>()
878  for (const source of [...strings]) {
879    const args = directHookArgs(source)
880    if (!args) continue
881    strings.push(...args)
882    for (const arg of args) if (/^(?:[A-Za-z]:[\\/]|\/)[^\r\n]*[\\/]progress-hook\.mjs$/.test(arg)) roots.add(arg.replace(/[\\/][^\\/]+$/, ''))
883  }
884  // Antigravity's command names its folder by the hook worker, quoted whole; on Windows that
885  // command is PowerShell's encoded text, which doubles an apostrophe, where a POSIX shell
886  // closes and reopens the quote instead (#285).
887  const decoded: string[] = []
888  for (const source of strings) for (const match of source.matchAll(/-EncodedCommand\s+([A-Za-z0-9+/]+={0,2})/g)) {
889    try { decoded.push(encodedCommandText(match[1]!)) } catch {}
890  }
891  for (const source of decoded) for (const match of source.matchAll(/'((?:[A-Za-z]:[\\/]|\/)(?:[^'\r\n]|'')*?)[\\/]progress-hook\.mjs'/g)) {
892    roots.add(match[1]!.replace(/''/g, "'"))
893  }
894  for (const source of strings) for (const match of source.matchAll(/'((?:[A-Za-z]:[\\/]|\/)(?:[^'\r\n]|'\\'')*?)[\\/]progress-hook\.mjs'/g)) {
895    roots.add(match[1]!.replace(/'\\''/g, "'"))
896  }
897  strings.push(...decoded)
898  for (const value of [...strings]) {
899    for (const match of value.matchAll(/String\.fromCodePoint\((\d+(?:,\d+)*)\)/g)) {
900      const points = match[1]!.split(',').map(Number)
901      if (points.length < 32768 && points.every(point => point <= 0x10ffff)) strings.push(String.fromCodePoint(...points))
902    }
903  }
904  for (const source of strings) {
905    // POSIX commands escape an apostrophe by closing and reopening the quote;
906    // YAML single-quoted scalars double it instead.
907    for (const value of new Set([
908      source, source.replace(/'\\''/g, "'"), source.replace(/''/g, "'"),
909      source.replace(/''/g, "'").replace(/'\\''/g, "'"),
910    ])) {
911      for (const match of value.matchAll(/(?:[A-Za-z]:[\\/]|\/)[^"`\r\n]*?[\\/](?:progress-report\.(?:ps1|sh)|agent-board\.mjs|progress-inbox)(?=["'`\s,;)]|$)/g)) {
912        const found = match[0]
913        const starts = [...found.matchAll(/(?<=^|[\s'"=(])(?:[A-Za-z]:[\\/]|\/)/g)]
914        for (const start of isNearestOnly ? starts.slice(-1) : starts) {
915          roots.add(found.slice(start.index).replace(/[\\/][^\\/]+$/, ''))
916        }
917      }
918    }
919  }
920  return [...roots]
921}
922
923// What a session start found for each agent here, so a written hook is never taken for a
924// working board, and `/progress connect status` can say it again later.
925export type HookState = 'configured' | 'failed' | 'unreadable' | 'tooOld'
926export type BoardState = 'ready' | 'runtime' | 'failed' | 'none'
927export type AgentSetup = { agent: string; label: string; hook: HookState | null; board: BoardState }
928type ProblemArgs<T> = {
929  [K in keyof T]: T[K] extends (...args: infer A) => string
930    ? { readonly [I in keyof A]: A[I] extends string ? 'string' : A[I] extends number ? 'number' : never }
931    : never
932}
933export const PROBLEM_ARGS = {
934  connectOtherData: ['string'],
935  connectOtherRecovery: [],
936  connectCheckUnreadable: ['string'],
937  connectPreserved: ['string'],
938  connectRemoveFailed: ['string'],
939  connectBoardRetained: ['string'],
940  connectBoardReadFailed: ['string'],
941  connectBoardFailed: ['string'],
942  connectReporterFailed: ['string'],
943  connectReadFailed: ['string', 'string'],
944  connectSkipped: ['string', 'string'],
945  connectFailed: ['string', 'string'],
946  connectTooOld: ['string', 'string', 'string'],
947  setupCodexApproval: [],
948  setupNodeMissing: ['number'],
949  setupNodeOld: ['string', 'number'],
950} as const satisfies Partial<ProblemArgs<Strings>>
951export type SetupNotice = {
952  [K in keyof typeof PROBLEM_ARGS]: { message: K; args: Parameters<Strings[K]> }
953}[keyof typeof PROBLEM_ARGS]
954export type SetupProblem = { key: string } & (SetupNotice | { text: string })
955export type SetupState = { agents: AgentSetup[]; problems: SetupProblem[]; node: string | null; codexTrusted?: boolean }
956type StoredSetupState = Omit<SetupState, 'problems'> & { problems: unknown[] }
957
958const isSetupProblem = (value: unknown): value is SetupProblem => {
959  if (typeof value !== 'object' || value === null) return false
960  const one = value as Record<string, unknown>
961  if (typeof one.key !== 'string') return false
962  if (typeof one.text === 'string') return true
963  if (typeof one.message !== 'string' || !Object.prototype.hasOwnProperty.call(PROBLEM_ARGS, one.message) || !Array.isArray(one.args)) return false
964  const types: readonly string[] = PROBLEM_ARGS[one.message as keyof typeof PROBLEM_ARGS]
965  return one.args.length === types.length && types.every((type, index) => typeof (one.args as unknown[])[index] === type)
966}
967
968// Earlier releases stored the words themselves; new records follow the session's language.
969export const setupProblemText = (problem: SetupProblem, s: Strings = ZH_TW): string => {
970  if ('text' in problem) return problem.text
971  // The repeated branches exist only so the type checker sees each call's argument types.
972  switch (problem.message) {
973    case 'connectOtherRecovery':
974    case 'setupCodexApproval': return s[problem.message]()
975    case 'setupNodeMissing': return s[problem.message](...problem.args)
976    case 'setupNodeOld': return s[problem.message](...problem.args)
977    case 'connectTooOld': return s[problem.message](...problem.args)
978    case 'connectReadFailed':
979    case 'connectSkipped':
980    case 'connectFailed': return s[problem.message](problem.args[0], problem.args[1])
981    default: return s[problem.message](problem.args[0])
982  }
983}
984
985// Of an agent's files, the worst one says how its hook came out: OpenClaw takes two.
986const HOOK_RANK: Record<HookState, number> = { configured: 0, tooOld: 1, unreadable: 2, failed: 3 }
987export const worseHook = (one: HookState, other: HookState): HookState => (HOOK_RANK[other] > HOOK_RANK[one] ? other : one)
988
989// The agents each board file (`boardCommands`, by its agent) shows the board in.
990export const BOARD_FOR: Record<string, readonly string[]> = {
991  skills: ['codex', 'cursor', 'grok', 'openclaw', 'gemini', 'opencode', 'muse'],
992  gemini: ['gemini'],
993  opencode: ['opencode'],
994  hermes: ['hermes'],
995  antigravity: ['antigravity'],
996}
997
998// An agent's board command is ready when a board file it reads is in place and Node.js runs;
999// one that could not be written, with none in place, failed. Antigravity with a board file and
1000// no hook state (a status saved before #285) is shown with no hook.
1001export const setupAgents = (
1002  hooks: readonly Omit<AgentSetup, 'board'>[],
1003  boards: Partial<Record<string, 'ok' | 'failed'>>,
1004  isNodeReady: boolean,
1005): AgentSetup[] => {
1006  const all = boards.antigravity === undefined || hooks.some(one => one.agent === 'antigravity') ? [...hooks] : [...hooks, { agent: 'antigravity', label: 'Antigravity', hook: null }]
1007
1008  return all.map(one => {
1009    const read = Object.keys(BOARD_FOR).filter(file => BOARD_FOR[file]?.includes(one.agent)).map(file => boards[file])
1010    const board: BoardState = read.includes('ok') ? (isNodeReady ? 'ready' : 'runtime') : read.includes('failed') ? 'failed' : 'none'
1011    return { ...one, board }
1012  })
1013}
1014
1015// The status, agent by agent: its hook, what the agent still asks of the person (Codex's
1016// approval, Hermes Agent's question, OpenClaw's restart), its board command, then the problems.
1017export const setupLines = (state: StoredSetupState, s: Strings = ZH_TW, withProblems = true): string[] => {
1018  const problems = state.problems.filter(isSetupProblem)
1019  const hookWords = (one: AgentSetup): string[] => {
1020    // Its skill opens the board, but none of its turns reach it (#285).
1021    if (one.hook === null) return one.board === 'ready' || one.board === 'runtime' ? [s.setupSkillOnly] : []
1022    if (one.hook !== 'configured') return [one.hook === 'unreadable' ? s.setupHookUnreadable : one.hook === 'tooOld' ? s.setupHookTooOld : s.setupHookFailed]
1023    const needs = one.agent === 'codex' ? (state.codexTrusted === true ? [] : [s.setupCodexTrust]) : one.agent === 'hermes' ? [s.setupHermesTrust] : one.agent === 'openclaw' ? [s.setupOpenclawRestart] : []
1024    return [s.setupHookConfigured, ...needs]
1025  }
1026  const boardWords = (one: AgentSetup): string[] =>
1027    one.board === 'ready' ? [s.setupBoardReady] : one.board === 'runtime' ? [s.setupBoardNeedsNode(NODE_MIN)] : one.board === 'failed' ? [s.setupBoardFailed] : []
1028
1029  return [
1030    s.setupStatusTitle,
1031    ...(state.agents.length === 0 ? (withProblems && problems.length > 0 ? [] : [s.connectNone]) : state.agents.map(one => s.setupAgent(one.label, [...hookWords(one), ...boardWords(one)]))),
1032    ...(withProblems ? problems.map(one => setupProblemText(one, s)) : []),
1033  ]
1034}
1035
1036// The store holds what an earlier version, or a damaged write, may have left.
1037export const isSetupState = (value: unknown): value is StoredSetupState => {
1038  if (typeof value !== 'object' || value === null) return false
1039  const { agents, problems } = value as { agents?: unknown; problems?: unknown }
1040  const isObject = (one: unknown): one is Record<string, unknown> => typeof one === 'object' && one !== null
1041  const hooks: unknown[] = [null, 'configured', 'failed', 'unreadable', 'tooOld']
1042  const boards: unknown[] = ['ready', 'runtime', 'failed', 'none']
1043  return (
1044    Array.isArray(agents) &&
1045    agents.every(one => isObject(one) && typeof one.agent === 'string' && typeof one.label === 'string' && hooks.includes(one.hook) && boards.includes(one.board)) &&
1046    Array.isArray(problems)
1047  )
1048}
1049
1050// Only recognized display metadata crosses the migration boundary. In particular,
1051// unknown fields and old free-form prose are not copied into worker argv plans.
1052export const setupSeed = (value: unknown): SetupState | undefined => {
1053  if (!isSetupState(value)) return undefined
1054  return {
1055    agents: value.agents.map(({ agent, label, hook, board }) => ({ agent, label, hook, board })),
1056    problems: value.problems.filter(isSetupProblem).filter((one): one is { key: string } & SetupNotice => !('text' in one)).map(({ key, message, args }) => ({ key, message, args: [...args] }) as SetupProblem),
1057    node: typeof value.node === 'string' ? value.node : null,
1058  }
1059}
1060
1061// Each agent's way to show the board, so the person sees every agent's work from whichever
1062// one they are in. Gemini CLI and OpenCode paste a command's output into the prompt before
1063// the model reads it; the others read skills, whose model runs the command. Most of them
1064// read ~/.agents/skills, so one skill there reaches Codex, Cursor, Grok Build, OpenClaw,
1065// Gemini CLI, OpenCode and Muse Code; Hermes Agent and Antigravity keep their own folder.
1066const SKILL = 'agent-board'
1067
1068// The skill setup-agent writes, in any language.
1069export const isInstallerSkill = (text: string): boolean =>
1070  Object.values(STRINGS).some(s => text.startsWith(`---\nname: ${SKILL}\ndescription: ${JSON.stringify(s.localAgentSkillDescription)}\n---\n\n`))
1071
1072export const boardCommands = (ctx: ConnectContext): AgentHook[] => {
1073  if (ctx.board === undefined) return []
1074  const opencodeHome = ctx.opencodeHome ?? join(ctx, '.config', 'opencode')
1075  const run = boardRun(ctx.board)
1076  // JSON is YAML too, so a description with a colon in it stays one value.
1077  const contents = (s: Strings) => {
1078    const description = JSON.stringify(s.boardCommandDescription)
1079    const skill = (prompt: string): string => `---\nname: ${SKILL}\ndescription: ${description}\n---\n\n${prompt}\n`
1080    return {
1081      skill: skill(s.boardSkillPrompt(run)),
1082      // Hermes Agent answers in a chat app (Telegram), where a link to this computer does not open.
1083      hermes: skill(s.boardChatSkillPrompt(run)),
1084      gemini: geminiBoardCommand(s.boardCommandDescription, s.boardCommandPrompt, run),
1085      opencode: opencodeBoardCommand(s.boardCommandDescription, s.boardCommandPrompt, run),
1086    }
1087  }
1088  const current = contents(ctx.s ?? ZH_TW)
1089  // Before ownership records existed, a different session could have generated these
1090  // exact files in another supported language, or an earlier release could have. No other
1091  // bytes may differ.
1092  const known = Object.values(STRINGS).map(contents)
1093  const file = (kind: keyof typeof current) =>
1094    generatedFile(current[kind], text => known.some(one => one[kind] === text) || isReleased(kind === 'hermes' ? 'skill' : kind, text, { board: ctx.board ?? '' }))
1095
1096  return [
1097    {
1098      agent: 'skills',
1099      label: '',
1100      dir: ctx.codexHome,
1101      dirs: [ctx.codexHome, join(ctx, '.cursor'), ...grokSigns(ctx), ctx.openclawHome, join(ctx, '.gemini'), opencodeHome, join(ctx, '.config', 'muse')],
1102      file: join(ctx, '.agents', 'skills', SKILL, 'SKILL.md'),
1103      generated: true,
1104      edit: file('skill'),
1105    },
1106    { agent: 'gemini', label: '', dir: join(ctx, '.gemini'), file: join(ctx, '.gemini', 'commands', 'board.toml'), generated: true, edit: file('gemini') },
1107    { agent: 'opencode', label: '', dir: opencodeHome, file: [opencodeHome, 'commands', 'board.md'].join(ctx.sep), generated: true, edit: file('opencode') },
1108    { agent: 'hermes', label: '', dir: ctx.hermesHome, file: [ctx.hermesHome, 'skills', SKILL, 'SKILL.md'].join(ctx.sep), generated: true, edit: file('hermes') },
1109    {
1110      // Antigravity reads its skills here; its hook is in agentHooks. The installer
1111      // (setup-agent) takes this file over, and its skill is left as it is (#285).
1112      agent: 'antigravity',
1113      label: '',
1114      dir: join(ctx, '.gemini', 'antigravity'),
1115      dirs: antigravitySigns(ctx),
1116      file: join(ctx, '.gemini', 'config', 'skills', SKILL, 'SKILL.md'),
1117      generated: true,
1118      edit: (text, remove, previous) => isInstallerSkill(text) ? text : file('skill')(text, remove, previous),
1119    },
1120  ]
1121}
1122
hooks/sha256.ts 54 lines
1// SHA-256 of a string's UTF-8 bytes, as lower-case hex. Pure and with no dependency, so the
2// plugin can tell a file an earlier release wrote by its hash in hooks/released.ts, which
3// scripts/build-released.ts computes with node:crypto and checks against this one.
4
5const K = [
6  0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe,
7  0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7,
8  0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b,
9  0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
10  0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
11]
12
13const rotate = (x: number, n: number): number => (x >>> n) | (x << (32 - n))
14
15export const sha256 = (text: string): string => {
16  const bytes = new TextEncoder().encode(text)
17  const length = bytes.length
18  // The message, a 1 bit, zeros, and its length in bits, in 512-bit blocks of 16 words.
19  const words = new Uint32Array(((length + 8) >> 6) * 16 + 16)
20  for (let i = 0; i < length; i++) words[i >> 2] = (words[i >> 2] ?? 0) | ((bytes[i] ?? 0) << (24 - (i % 4) * 8))
21  words[length >> 2] = (words[length >> 2] ?? 0) | (0x80 << (24 - (length % 4) * 8))
22  words[words.length - 2] = Math.floor(length / 0x20000000)
23  words[words.length - 1] = length * 8
24  const hash = [0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19]
25  const w = new Uint32Array(64)
26  for (let block = 0; block < words.length; block += 16) {
27    for (let t = 0; t < 16; t++) w[t] = words[block + t] ?? 0
28    for (let t = 16; t < 64; t++) {
29      const early = w[t - 15] ?? 0
30      const late = w[t - 2] ?? 0
31      const s0 = rotate(early, 7) ^ rotate(early, 18) ^ (early >>> 3)
32      const s1 = rotate(late, 17) ^ rotate(late, 19) ^ (late >>> 10)
33      w[t] = (w[t - 16] ?? 0) + s0 + (w[t - 7] ?? 0) + s1
34    }
35    let [a, b, c, d, e, f, g, h] = hash as [number, number, number, number, number, number, number, number]
36    for (let t = 0; t < 64; t++) {
37      const t1 = (h + (rotate(e, 6) ^ rotate(e, 11) ^ rotate(e, 25)) + ((e & f) ^ (~e & g)) + (K[t] ?? 0) + (w[t] ?? 0)) | 0
38      const t2 = ((rotate(a, 2) ^ rotate(a, 13) ^ rotate(a, 22)) + ((a & b) ^ (a & c) ^ (b & c))) | 0
39      h = g
40      g = f
41      f = e
42      e = (d + t1) | 0
43      d = c
44      c = b
45      b = a
46      a = (t1 + t2) | 0
47    }
48    const next = [a, b, c, d, e, f, g, h]
49    for (let i = 0; i < 8; i++) hash[i] = ((hash[i] ?? 0) + (next[i] ?? 0)) | 0
50  }
51
52  return hash.map(word => (word >>> 0).toString(16).padStart(8, '0')).join('')
53}
54
hooks/connect-worker.ts 69 lines
1// These short native launchers need only process.run argv. The engine writes code
2// into a private native-allocated payload file; configuration bodies never travel
3// through the staging protocol, and the verified bytes are the executed bytes.
4export const CONNECT_BOOTSTRAP = `
5const fs = require('node:fs'), os = require('node:os'), path = require('node:path'), crypto = require('node:crypto')
6if (Number(process.versions.node.split('.')[0]) < 18) throw new Error('NODE_REQUIRED')
7const digest = process.argv[1]
8if (!/^[a-f0-9]{64}$/.test(digest)) throw new Error('INVALID_WORKER_DIGEST')
9const directory = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'agent-board-worker-')))
10fs.chmodSync(directory, 0o700)
11const token = crypto.randomBytes(16).toString('hex'), file = path.join(directory, digest + '.mjs')
12fs.writeFileSync(path.join(directory, '.owner'), token, { flag: 'wx', mode: 0o600 })
13fs.writeFileSync(file, '', { flag: 'wx', mode: 0o600 })
14process.stdout.write(JSON.stringify({ protocol: 1, directory, file, token }))
15`
16
17export const CONNECT_EXECUTE = `
18import fs from 'node:fs'
19import os from 'node:os'
20import path from 'node:path'
21import crypto from 'node:crypto'
22if (Number(process.versions.node.split('.')[0]) < 18) throw new Error('NODE_REQUIRED')
23const digest = process.argv[1], stage = JSON.parse(process.argv[2]), request = process.argv[3]
24if (!/^[a-f0-9]{64}$/.test(digest) || stage.protocol !== 1 || !/^[a-f0-9]{32}$/.test(stage.token)) throw new Error('INVALID_WORKER_STAGE')
25const directory = path.resolve(stage.directory), temporary = fs.realpathSync(os.tmpdir())
26const relative = path.relative(temporary, directory)
27if (!relative || relative.startsWith('..') || path.isAbsolute(relative) || relative.includes(path.sep) || !path.basename(directory).startsWith('agent-board-worker-') || fs.lstatSync(directory).isSymbolicLink() || fs.realpathSync(directory) !== directory) throw new Error('INVALID_WORKER_STAGE')
28const file = path.join(directory, digest + '.mjs'), marker = path.join(directory, '.owner')
29if (stage.file !== file || fs.lstatSync(file).isSymbolicLink() || fs.lstatSync(marker).isSymbolicLink() || fs.readFileSync(marker, 'utf8') !== stage.token) throw new Error('WORKER_OWNERSHIP_LOST')
30const code = fs.readFileSync(file, 'utf8')
31if (crypto.createHash('sha256').update(code).digest('hex') !== digest) throw new Error('WORKER_DIGEST_CHANGED')
32try {
33  globalThis.AGENT_BOARD_WORKER_SOURCE = code
34  process.argv = [process.execPath, file, '--legacy-connect-request', request]
35  await import('data:text/javascript;base64,' + Buffer.from(code).toString('base64'))
36} catch (error) {
37  // Node's uncaught stack can repeat the entire data URL and exhaust the host's
38  // output buffer before its useful error. Report only a bounded native code.
39  const reason = typeof error?.code === 'string' ? error.code : error?.message
40  process.stderr.write((typeof reason === 'string' && /^[A-Z][A-Z0-9_]{0,63}$/.test(reason) ? reason : 'WORKER_EXECUTION_FAILED') + '\\n')
41  process.exitCode = 1
42} finally {
43  if (fs.realpathSync(directory) !== directory || fs.lstatSync(directory).isSymbolicLink() || fs.lstatSync(marker).isSymbolicLink() || fs.readFileSync(marker, 'utf8') !== stage.token) throw new Error('WORKER_OWNERSHIP_LOST')
44  fs.rmSync(directory, { recursive: true, force: true })
45}
46`
47
48export type WorkerStage = { protocol: 1; directory: string; file: string; token: string }
49export const workerStage = (value: unknown, digest: string): WorkerStage | null => {
50  if (value === null || typeof value !== 'object') return null
51  const stage = value as Partial<WorkerStage>
52  if (stage.protocol !== 1 || typeof stage.directory !== 'string' || !/^(?:[a-zA-Z]:[\\/]|\/)/.test(stage.directory) || !/[\\/]agent-board-worker-[^\\/]+$/.test(stage.directory) || typeof stage.file !== 'string' || typeof stage.token !== 'string' || !/^[a-f0-9]{32}$/.test(stage.token) || !/^[a-f0-9]{64}$/.test(digest)) return null
53  const normalized = stage.directory.replace(/\\/g, '/') + '/' + digest + '.mjs'
54  if (stage.file.replace(/\\/g, '/') !== normalized) return null
55  return stage as WorkerStage
56}
57
58export const workerRequest = (value: unknown): string | null => {
59  if (value === null || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).some(key => !['mode', 'home', 'progress', 'lang', 'node', 'openclawVersion', 'codexExecutable', 'boardLang', 'dirs', 'seed', 'acknowledgement'].includes(key))) return null
60  const seed = (value as { seed?: unknown }).seed
61  if (seed !== undefined) {
62    if (seed === null || typeof seed !== 'object' || Array.isArray(seed) || Object.keys(seed).some(key => !['owned', 'disabled', 'notified', 'boardLang', 'setup'].includes(key))) return null
63    const owned = (seed as { owned?: unknown }).owned
64    if (owned !== undefined && (owned === null || typeof owned !== 'object' || Array.isArray(owned) || Object.values(owned).some(hash => typeof hash !== 'string' || !/^[a-f0-9]{64}$/.test(hash)))) return null
65  }
66  const text = JSON.stringify(value)
67  return typeof text === 'string' && text.length <= 12000 ? text : null
68}
69
hooks/ledger-move.ts 25 lines
1// Node uses exit codes 1 to 14 for its own failures (5 is a fatal error), so these stay above that
2// range: a Node failure must reach the move-command fallback, not read as "another ledger is there".
3export const LINK_EXISTS = 70
4export const LINK_UNSUPPORTED = 71
5export const LINK_KEPT_SOURCE = 72
6
7// The source belongs to this session, so removing it cannot delete another writer's ledger.
8// Linking refuses an existing target and avoids a cold PowerShell start on Windows.
9// Run it as `node -e LEDGER_LINK -- <from> <to>`: without `--`, a path that starts with a dash is
10// read as a Node option. Node drops the `--`, so the two paths are argv[1] and argv[2].
11export const LEDGER_LINK = `
12const { linkSync, unlinkSync } = require('node:fs')
13const [from, to] = process.argv.slice(1)
14try {
15  linkSync(from, to)
16} catch (error) {
17  process.exit(error?.code === 'EEXIST' ? ${LINK_EXISTS} : ${LINK_UNSUPPORTED})
18}
19try {
20  unlinkSync(from)
21} catch {
22  process.exit(${LINK_KEPT_SOURCE})
23}
24`
25
scripts/connect-transaction.ts 486 lines
1import { closeSync, existsSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, rmdirSync, statSync, unlinkSync, writeFileSync } from 'node:fs'
2import { createHash, randomBytes } from 'node:crypto'
3import { dirname, isAbsolute, join, resolve } from 'node:path'
4import type { Lang, Strings } from '../hooks/i18n'
5import { BOARD_FOR, agentHooks, connectDataRoots, connectPath, isNodeSupported, isSetupState, nodeVersionOf, setupSeed, setupProblemText, withoutSection } from '../hooks/connect-pure'
6import { managedCommandLines } from '../hooks/codex-fallback'
7import { codexBinaryOf, codexConfigHash, findCodexExecutable, inspectCodexHooks } from './codex-host'
8import { codexApproval } from './codex-approval'
9import type { CodexApproval, CodexCheck, CodexFound } from './codex-approval'
10import type { ConnectContext, SetupState } from '../hooks/connect-pure'
11import { langOf, STRINGS } from '../hooks/strings'
12import { installControlRoot, withInstallLock } from './install-control'
13import { connectLegacy } from './legacy-connect'
14import { windowsHookNode } from './windows-hook-node'
15
16export type LegacyConnectRequest = {
17  mode: 'auto' | 'connect' | 'remove' | 'status' | 'ack' | 'language'
18  home: string
19  progress: string
20  lang: Lang
21  node: string | null
22  openclawVersion?: string
23  codexExecutable?: string
24  boardLang?: Lang
25  acknowledgement?: { revision: number; keys: string[] }
26  dirs?: Partial<Record<'codex' | 'grok' | 'hermes' | 'openclaw' | 'opencode', string>>
27  seed?: { owned?: Record<string, string>; disabled?: boolean; notified?: string[]; boardLang?: Lang; setup?: SetupState }
28}
29type Step = { file: string; before: string | null; after: string | null; generated: boolean }
30type Pending = { request: LegacyConnectRequest; bundle: string; step?: Step }
31export type ConnectState = {
32  version: 1
33  revision: number
34  disabled: boolean
35  owned: Record<string, string>
36  notified: string[]
37  boardLang?: Lang
38  setup?: SetupState
39  codexApproval?: CodexApproval
40  connectedProgress?: string
41  pending?: Pending
42}
43const hash = (text: string): string => createHash('sha256').update(text).digest('hex')
44export const readLocal = (file: string): string => {
45  try { return readFileSync(file, 'utf8') } catch (error) {
46    if ((error as { code?: string }).code === 'ENOENT') return ''
47    throw error
48  }
49}
50export const readMaybe = (file: string): string | null => {
51  try { return readLocal(file) } catch { return null }
52}
53const fingerprint = (file: string): string | null => existsSync(file) ? hash(readLocal(file)) : null
54const atomic = (file: string, text: string): void => {
55  mkdirSync(dirname(file), { recursive: true })
56  const temporary = `${file}.${process.pid}.${randomBytes(8).toString('hex')}.tmp`
57  const mode = existsSync(file) ? statSync(file).mode & 0o777 : 0o600
58  const fd = openSync(temporary, 'wx', mode)
59  try { writeFileSync(fd, text) } finally { closeSync(fd) }
60  try { renameSync(temporary, file) } finally { if (existsSync(temporary)) unlinkSync(temporary) }
61}
62
63export class ConnectTransaction {
64  readonly file: string
65  readonly state: ConnectState
66  private readonly observed = new Map<string, string | null>()
67
68  constructor(home: string, seed: LegacyConnectRequest['seed']) {
69    this.file = join(installControlRoot(home), 'legacy-connect.json')
70    const authority = join(installControlRoot(home), 'legacy-connect-authority')
71    if (existsSync(authority) && readLocal(authority) !== '1\n') throw new Error('INVALID_CONNECT_AUTHORITY')
72    if (existsSync(authority) && !existsSync(this.file)) throw new Error('MISSING_CONNECT_STATE')
73    if (existsSync(this.file)) {
74      const state = JSON.parse(readLocal(this.file)) as ConnectState
75      if (state.codexApproval !== undefined && (state.codexApproval === null || typeof state.codexApproval !== 'object' || Array.isArray(state.codexApproval) || typeof state.codexApproval.source !== 'string' || !isAbsolute(state.codexApproval.source) || typeof state.codexApproval.sourceHash !== 'string' || !/^[a-f0-9]{64}$/.test(state.codexApproval.sourceHash) || state.codexApproval.configHash !== undefined && state.codexApproval.configHash !== null && (typeof state.codexApproval.configHash !== 'string' || !/^[a-f0-9]{64}$/.test(state.codexApproval.configHash)))) throw new Error('INVALID_CONNECT_STATE')
76      const binary = state.codexApproval?.binary
77      if (binary !== undefined && (binary === null || typeof binary !== 'object' || Array.isArray(binary) || typeof binary.path !== 'string' || !isAbsolute(binary.path) || !Number.isSafeInteger(binary.size) || binary.size < 0 || typeof binary.mtime !== 'number' || !Number.isFinite(binary.mtime))) throw new Error('INVALID_CONNECT_STATE')
78      if (state.connectedProgress !== undefined && (typeof state.connectedProgress !== 'string' || !isAbsolute(state.connectedProgress))) throw new Error('INVALID_CONNECT_STATE')
79      if (state.version !== 1 || !Number.isSafeInteger(state.revision) || state.revision < 0 || typeof state.disabled !== 'boolean' || state.owned === null || typeof state.owned !== 'object' || Array.isArray(state.owned) || Object.entries(state.owned).some(([file, value]) => !isAbsolute(file) || typeof value !== 'string' || !/^[a-f0-9]{64}$/.test(value)) || !Array.isArray(state.notified) || state.notified.some(value => typeof value !== 'string') || state.boardLang !== undefined && langOf(state.boardLang) !== state.boardLang || state.setup !== undefined && !isSetupState(state.setup)) throw new Error('INVALID_CONNECT_STATE')
80      if (state.pending !== undefined) {
81        if (state.pending === null || typeof state.pending !== 'object' || typeof state.pending.bundle !== 'string' || !/^[a-f0-9]{64}$/.test(state.pending.bundle)) throw new Error('INVALID_CONNECT_STATE')
82        const pending = checkedRequest(state.pending.request)
83        if (pending.mode === 'status' || pending.mode === 'ack' || pending.mode === 'language') throw new Error('INVALID_CONNECT_STATE')
84        const step = state.pending.step
85        if (step !== undefined && (step === null || typeof step !== 'object' || typeof step.file !== 'string' || !isAbsolute(step.file) || typeof step.generated !== 'boolean' || [step.before, step.after].some(value => value !== null && (typeof value !== 'string' || !/^[a-f0-9]{64}$/.test(value))))) throw new Error('INVALID_CONNECT_STATE')
86      }
87      this.state = state
88    } else {
89      const owned = Object.fromEntries(Object.entries(seed?.owned ?? {}).filter(([file, value]) => isAbsolute(file) && /^[a-f0-9]{64}$/.test(value)))
90      this.state = { version: 1, revision: 0, disabled: seed?.disabled === true, owned, notified: seed?.notified?.filter(value => typeof value === 'string') ?? [], ...(seed?.boardLang !== undefined && langOf(seed.boardLang) === seed.boardLang ? { boardLang: seed.boardLang } : {}) }
91      const setup = setupSeed(seed?.setup)
92      if (setup !== undefined) this.state.setup = setup
93    }
94  }
95
96  save(): void { atomic(this.file, `${JSON.stringify(this.state, null, 2)}\n`) }
97
98  read(file: string): string {
99    const text = readLocal(file)
100    if (!this.observed.has(file)) this.observed.set(file, existsSync(file) ? hash(text) : null)
101    return text
102  }
103
104  exists(file: string): boolean { return existsSync(file) }
105
106  maybe(file: string): string | null { try { return this.read(file) } catch { return null } }
107
108  previous(file: string): string | undefined {
109    const text = this.read(file)
110    return hash(text) === this.state.owned[file] ? text : undefined
111  }
112
113  own(file: string, text: string): void {
114    if (fingerprint(file) !== hash(text)) throw new Error('INSTALL_CHANGED')
115    this.state.owned[file] = hash(text)
116    this.save()
117  }
118
119  // Only fingerprints and ownership metadata enter the journal. Host config and
120  // credential bodies are read and merged in this process, never staged in a plan.
121  change(file: string, text: string | null, generated = false): void {
122    const pending = this.state.pending
123    if (pending === undefined) throw new Error('NO_CONNECT_TRANSACTION')
124    const before = this.observed.has(file) ? this.observed.get(file)! : fingerprint(file)
125    if (fingerprint(file) !== before) throw new Error('INSTALL_CHANGED')
126    pending.step = { file, before, after: text === null ? null : hash(text), generated }
127    this.save()
128    try {
129      if (fingerprint(file) !== before) throw new Error('INSTALL_CHANGED')
130      if (text === null) { if (existsSync(file)) unlinkSync(file) }
131      else atomic(file, text)
132      if (fingerprint(file) !== pending.step.after) throw new Error('RECOVERY_CONFLICT')
133    } catch (error) {
134      this.recoverStep()
135      throw error
136    }
137    this.recoverStep()
138    this.observed.set(file, text === null ? null : hash(text))
139  }
140
141  recoverStep(): void {
142    const step = this.state.pending?.step
143    if (step === undefined) return
144    const current = fingerprint(step.file)
145    if (current !== step.before && current !== step.after) throw new Error('RECOVERY_CONFLICT')
146    if (current === step.after) {
147      if (step.after === null) delete this.state.owned[step.file]
148      else if (step.generated) this.state.owned[step.file] = step.after
149    }
150    delete this.state.pending!.step
151    this.save()
152  }
153
154  tidy(folder: string): boolean {
155    if (!existsSync(folder)) return true
156    try { rmdirSync(folder); return true } catch { return readdirSync(folder).length > 0 }
157  }
158
159  dropSection(agent: string, home: string, codexHome: string, opencodeHome: string, sep: string): void {
160    const files: Record<string, string[]> = { codex: [[codexHome, 'AGENTS.md'].join(sep), [codexHome, 'AGENTS.override.md'].join(sep)], gemini: [[home, '.gemini', 'GEMINI.md'].join(sep)], opencode: [[opencodeHome, 'AGENTS.md'].join(sep)] }
161    for (const file of files[agent] ?? []) {
162      const before = this.read(file)
163      const after = withoutSection(before)
164      if (after !== before) this.change(file, after)
165    }
166  }
167}
168
169const checkedRequest = (value: unknown): LegacyConnectRequest => {
170  if (value === null || typeof value !== 'object' || Array.isArray(value)) throw new Error('INVALID_CONNECT_REQUEST')
171  const request = value as LegacyConnectRequest
172  if (!['auto', 'connect', 'remove', 'status', 'ack', 'language'].includes(request.mode) || typeof request.home !== 'string' || !isAbsolute(request.home) || typeof request.progress !== 'string' || !isAbsolute(request.progress) || langOf(request.lang) !== request.lang || request.node !== null && typeof request.node !== 'string' || request.boardLang !== undefined && langOf(request.boardLang) !== request.boardLang || request.openclawVersion !== undefined && typeof request.openclawVersion !== 'string' || request.dirs !== undefined && (request.dirs === null || typeof request.dirs !== 'object' || Array.isArray(request.dirs) || Object.values(request.dirs).some(path => typeof path !== 'string' || !isAbsolute(path)))) throw new Error('INVALID_CONNECT_REQUEST')
173  if (request.mode === 'ack' && (request.acknowledgement === undefined || !Number.isSafeInteger(request.acknowledgement.revision) || !Array.isArray(request.acknowledgement.keys) || request.acknowledgement.keys.some(key => typeof key !== 'string'))) throw new Error('INVALID_CONNECT_REQUEST')
174  if (request.codexExecutable !== undefined && (typeof request.codexExecutable !== 'string' || !isAbsolute(request.codexExecutable))) throw new Error('INVALID_CONNECT_REQUEST')
175  return { mode: request.mode, home: resolve(request.home), progress: resolve(request.progress), lang: request.lang, node: request.node, ...(request.codexExecutable === undefined ? {} : { codexExecutable: request.codexExecutable }), ...(request.openclawVersion === undefined ? {} : { openclawVersion: request.openclawVersion }), ...(request.boardLang === undefined ? {} : { boardLang: request.boardLang }), ...(request.dirs === undefined ? {} : { dirs: request.dirs }), ...(request.seed === undefined ? {} : { seed: request.seed }), ...(request.acknowledgement === undefined ? {} : { acknowledgement: request.acknowledgement }) }
176}
177
178const codexBinary = (): string | undefined => findCodexExecutable(process.env.PATH ?? '', process.platform, process.arch)
179const mtimeOf = (file: string): number | undefined => { try { return statSync(file).mtimeMs } catch { return undefined } }
180
181// The connect worker has a 10 s process.run limit in register.tsx. On Windows,
182// other connect work takes 0.4–1.1 s and app-server startup takes 0.25 s to over 1 s.
183const CODEX_CHECK_BUDGET_MS = 5000
184const CODEX_CHECK_REQUEST_MS = 2000
185// A cold app-server start can take over 2 s; the first answer may use more of the budget.
186const CODEX_CHECK_INITIALIZE_MS = 3500
187
188const codexObservation = async (request: LegacyConnectRequest, executable: string | undefined): Promise<CodexCheck | undefined> => {
189  if (executable === undefined) return undefined
190  const deadline = performance.now() + CODEX_CHECK_BUDGET_MS
191  const home = request.home, folder = dirname(request.progress), codexHome = request.dirs?.codex ?? join(home, '.codex')
192  const hook = agentHooks({ home, sep: process.platform === 'win32' ? '\\' : '/', isWindows: process.platform === 'win32', script: join(folder, process.platform === 'win32' ? 'progress-report.ps1' : 'progress-report.sh'), inbox: join(folder, 'progress-inbox'), codexHome, grokHome: '', hermesHome: '', openclawHome: '' }).find(one => one.agent === 'codex' && one.label !== '')!
193  try {
194    const config = JSON.parse(hook.edit('{}', false) as string) as { hooks: Record<string, { hooks: { command: string }[] }[]> }
195    const expected = Object.entries(config.hooks).map(([event, groups]) => ({ event: event[0]!.toLowerCase() + event.slice(1), command: groups[0]!.hooks[0]!.command }))
196    const before = readFileSync(hook.file)
197    // An answer counts only for the config.toml Codex read; codexApproval compares it again at the decision.
198    const configHash = codexConfigHash(codexHome)
199    if (configHash === undefined) return undefined
200    for (let attempt = 0; attempt < 2; attempt++) {
201      try { return { ...await inspectCodexHooks(executable, hook.file, expected, CODEX_CHECK_REQUEST_MS, deadline, CODEX_CHECK_INITIALIZE_MS), configHash } } catch (error) {
202        // Codex answered, but our hooks are turned off in /hooks or are not the ones it lists:
203        // that is a no, not a failed look, so a remembered approval must not outlive it.
204        // A hash format Codex has changed is not a no (HOOK_FORMAT_UNSUPPORTED).
205        if (error instanceof Error && ['HOOK_DISABLED', 'OWNED_HOOKS_NOT_FOUND'].includes(error.message)) {
206          return before.equals(readFileSync(hook.file)) ? { source: resolve(hook.file), sourceHash: createHash('sha256').update(before).digest('hex'), trusted: false, hooks: [], configHash } : undefined
207        }
208        if (!(error instanceof Error) || !['CODEX_CONTROL_TIMEOUT', 'CODEX_CONTROL_UNAVAILABLE'].includes(error.message) || deadline - performance.now() < 1000) return undefined
209      }
210    }
211  } catch { return undefined }
212  return undefined
213}
214
215export const connectContext = (request: LegacyConnectRequest): ConnectContext => {
216  const isWindows = process.platform === 'win32'
217  const folder = dirname(request.progress)
218  return {
219    home: request.home,
220    sep: isWindows ? '\\' : '/',
221    isWindows,
222    script: join(folder, isWindows ? 'progress-report.ps1' : 'progress-report.sh'),
223    inbox: join(folder, 'progress-inbox'),
224    board: join(folder, 'agent-board.mjs'),
225    codexHome: request.dirs?.codex ?? join(request.home, '.codex'),
226    grokHome: request.dirs?.grok ?? join(request.home, '.grok'),
227    hermesHome: request.dirs?.hermes ?? join(request.home, '.hermes'),
228    openclawHome: request.dirs?.openclaw ?? join(request.home, '.openclaw'),
229    opencodeHome: request.dirs?.opencode ?? join(request.home, '.config', 'opencode'),
230  }
231}
232
233export const unreadableProblem = (label: string): SetupState['problems'][number] => ({
234  key: `checkUnreadable:${label}`, message: 'connectCheckUnreadable', args: [label],
235})
236
237// Read command data without evaluating any of the host's JavaScript or shell.
238const boardCommandPaths = (text: string): string[] => {
239  const strings = [text]
240  const collect = (value: unknown): void => {
241    if (typeof value === 'string') strings.push(value)
242    else if (Array.isArray(value)) value.forEach(collect)
243    else if (value !== null && typeof value === 'object') Object.values(value).forEach(collect)
244  }
245  try { collect(JSON.parse(text)) } catch {}
246  // Gemini stores its command inside a JSON-quoted TOML prompt.
247  for (const match of text.matchAll(/^prompt\s*=\s*(".*")\s*$/gm)) {
248    try { collect(JSON.parse(match[1]!)) } catch {}
249  }
250  const paths = new Set<string>()
251  for (const value of strings) {
252    for (const match of value.matchAll(/(?:\bnode(?:\.exe)?|["'][^"'\r\n]*[\\/]node(?:\.exe)?["'])\s+(?:"([^"\r\n]+)"|'([^'\r\n]+)')/g)) {
253      const path = match[1] ?? match[2]!
254      // A relative, ~ or variable path depends on where the session started, and
255      // Agent Board never writes one, so only literal absolute paths are checked.
256      if (/[\\/]agent-board\.mjs$/.test(path) && isAbsolute(path) && !/[$%]|^~/.test(path)) paths.add(path)
257    }
258    for (const match of value.matchAll(/const p=String\.fromCodePoint\(([\d,]+)\);process\.argv\.splice\(1,0,p\)/g)) {
259      try {
260        const path = match[1]!.split(',').map(point => String.fromCodePoint(Number(point))).join('')
261        if (/[\\/]agent-board\.mjs$/.test(path) && isAbsolute(path)) paths.add(path)
262      } catch {}
263    }
264  }
265  return [...paths]
266}
267
268const missingBoardHosts = (transaction: ConnectTransaction, context: ConnectContext): string[] => {
269  const hooks = agentHooks(context)
270  const installed = [...new Set(hooks.filter(hook => BOARD_FOR.skills!.includes(hook.agent)
271    && (hook.dirs ?? [hook.dir]).some(dir => transaction.exists(dir))).map(hook => hook.agent))]
272  // Codex reads one instructions file (legacy-connect.ts picks it the same way), and a
273  // reconnect refreshes only the generated command lines in its managed block there,
274  // so nothing else in either file can raise a warning that reconnecting never clears.
275  const [main, override] = ['AGENTS.md', 'AGENTS.override.md'].map(name => join(context.codexHome, name))
276  const effective = transaction.exists(override!) && transaction.maybe(override!)?.trim() !== '' ? override! : main!
277  const texts = [
278    ...hooks.flatMap(hook => [hook.file, ...(hook.old === undefined ? [] : [hook.old])]
279      .map(file => ({ text: transaction.maybe(file) ?? '', agents: hook.agent === 'skills' ? installed : [hook.agent] }))),
280    { text: managedCommandLines(transaction.maybe(effective) ?? '').join('\n'), agents: ['codex'] },
281  ]
282  const missing = new Set<string>()
283  for (const { text, agents } of texts) {
284    if (boardCommandPaths(text).some(path => !transaction.exists(path))) agents.forEach(agent => missing.add(agent))
285  }
286  return [...missing].filter(agent => agent in STRINGS.en.agentNames)
287}
288
289export const foreignHostCheck = (transaction: ConnectTransaction, context: ConnectContext, folder: string, s: Strings = STRINGS.en) => {
290  const hooks = agentHooks(context)
291  const labels = new Map(hooks.filter(hook => hook.label !== '').map(hook => [hook.agent, hook.label]))
292  labels.set('antigravity', 'Antigravity')
293  const skillLabels = [...new Set(hooks
294    .filter(hook => BOARD_FOR.skills!.includes(hook.agent) && hook.label !== ''
295      && (hook.dirs ?? [hook.dir]).some(dir => existsSync(dir)))
296    .map(hook => hook.label))]
297  const checks = hooks.filter(hook => (hook.dirs ?? [hook.dir]).some(dir => existsSync(dir))).flatMap(hook => {
298    const names = hook.agent === 'skills'
299      ? skillLabels.length > 0 ? skillLabels : ['Codex']
300      : [labels.get(hook.agent) ?? s.setupAgentUnknown]
301    return [hook.file, ...(hook.old === undefined ? [] : [hook.old])]
302      .flatMap(file => names.map(label => ({ file, label })))
303  })
304  if (existsSync(context.codexHome)) {
305    checks.push(...['AGENTS.md', 'AGENTS.override.md'].map(name => ({
306      file: join(context.codexHome, name), label: labels.get('codex') ?? 'Codex',
307    })))
308  }
309  const unreadable = new Set<string>()
310  const other = checks.flatMap(({ file, label }) => {
311    const text = transaction.maybe(file)
312    if (text === null) unreadable.add(label)
313    return connectDataRoots(text ?? '')
314      .filter(root => connectPath(root, context.isWindows) !== connectPath(folder, context.isWindows))
315      .map(root => ({ root, label }))
316  })
317  const mayExist = (file: string): boolean => {
318    try { statSync(file); return true } catch (error) {
319      return !['ENOENT', 'ENOTDIR', 'ENAMETOOLONG', 'EINVAL'].includes((error as { code?: string }).code ?? '')
320    }
321  }
322  const live = other.filter(one => mayExist(join(one.root, 'progress-report.ps1')) || mayExist(join(one.root, 'progress-report.sh')))
323  const state = transaction.state
324  const hasForeignRecovery = state.pending !== undefined
325    && connectPath(dirname(state.pending.request.progress), context.isWindows) !== connectPath(folder, context.isWindows)
326  const hasLiveRecovery = hasForeignRecovery && mayExist(dirname(state.pending!.request.progress))
327  const problems: SetupState['problems'] = [
328    ...[...new Set(live.map(one => one.label))].map(label => ({
329      key: `otherData:${label}`, message: 'connectOtherData' as const, args: [label] as [string],
330    })),
331    ...(hasLiveRecovery ? [{ key: 'otherData:recovery', message: 'connectOtherRecovery' as const, args: [] as [] }] : []),
332    ...[...unreadable].map(unreadableProblem),
333  ]
334  return { live, unreadable, problems, hasForeignRecovery, hasLiveRecovery }
335}
336
337export const legacyConnect = async (value: unknown, bundle: string): Promise<{ state: ConnectState; result: ReturnType<typeof connectLegacy> | null }> => {
338  const request = checkedRequest(value)
339  if (request.node === null || nodeVersionOf(request.node) === null || !isNodeSupported(request.node) || !isNodeSupported(`v${process.versions.node}`)) throw new Error('NODE_REQUIRED')
340  const executable = ['connect', 'auto', 'status'].includes(request.mode) ? request.codexExecutable ?? codexBinary() : undefined
341  // Read before Codex is asked, so an approval names the executable that answered.
342  const binary = codexBinaryOf(executable)
343  const observation = await codexObservation(request, executable)
344  return withInstallLock(request.home, () => {
345    // A 0.23 approval cannot have seen an untrust in /hooks (it writes config.toml) or a
346    // Codex upgrade made after legacy-connect.json was last written. An ack or language
347    // save also writes it without a check, so a change before one of those is missed.
348    // With no saved state the SDK seed carries no codexTrusted, so nothing is seeded anyway.
349    const savedAt = mtimeOf(join(installControlRoot(request.home), 'legacy-connect.json'))
350    const changedAt = [mtimeOf(join(request.dirs?.codex ?? join(request.home, '.codex'), 'config.toml')), binary?.mtime]
351    const found: CodexFound = { binary, isSeedStale: changedAt.some(time => time !== undefined && (savedAt === undefined || time > savedAt)) }
352    const transaction = new ConnectTransaction(request.home, request.seed)
353    const state = transaction.state
354    // Check the host's current path before creating authority, staging writes, or
355    // replaying a journal from another data folder. A skipped auto is read-only.
356    const folder = dirname(request.progress)
357    const context = connectContext(request)
358    const { isWindows } = context
359    const { problems, hasForeignRecovery, hasLiveRecovery } = foreignHostCheck(transaction, context, folder, STRINGS[request.lang])
360    const checkedSetup = (stored: SetupState | undefined): SetupState => {
361      const missing = request.mode === 'status' ? missingBoardHosts(transaction, context) : []
362      const s = STRINGS[request.lang]
363      const source = join(context.codexHome, 'hooks.json')
364      // A missing hooks.json has no hash, so the 0.23 upgrade path cannot remember an empty one.
365      const text = transaction.exists(source) ? transaction.maybe(source) : null
366      const sourceHash = text === null ? null : hash(text)
367      const trusted = request.mode === 'ack' ? stored?.codexTrusted === true
368        : codexApproval(observation, resolve(source), sourceHash, codexConfigHash(context.codexHome), state.codexApproval, stored?.codexTrusted === true && !found.isSeedStale, sourceHash, found.binary).trusted
369      const setup: SetupState = {
370        ...(stored ?? { agents: [], node: request.node }),
371        codexTrusted: trusted,
372        problems: [
373          ...(stored?.problems ?? []).filter(one => one.key !== 'codex:approval' && !one.key.startsWith('missingBoard:')
374            && !/^(?:otherData|checkUnreadable):/.test(one.key)
375            && !problems.some(problem => problem.key === one.key)),
376          ...problems,
377        ],
378      }
379      for (const agent of missing) setup.problems.push({ key: `missingBoard:${agent}`, text: s.setupMissingBoardScript(s.agentNames[agent] ?? agent) })
380      setup.agents = setup.agents.map(one => missing.includes(one.agent) ? { ...one, board: 'failed' } : one)
381      if (setup.agents.some(one => one.agent === 'codex') && !trusted) {
382        setup.problems.push({ key: 'codex:approval', message: 'setupCodexApproval', args: [] })
383      }
384      return setup
385    }
386    // A blocked automatic run does not save; acknowledgement reuses the last saved Codex result.
387    const setup = checkedSetup(state.setup)
388    const hasBlockedAuto = problems.length > 0
389    if (['auto', 'status'].includes(request.mode) && hasBlockedAuto) {
390      const lines = setup.problems.map(problem => setupProblemText(problem, STRINGS[request.lang]))
391      const result = request.mode === 'auto'
392        ? { text: lines.join('\n'), lines, problems: setup.problems, done: [] }
393        : null
394      return { state: { ...state, setup }, result }
395    }
396    // A new remove supersedes an unfinished connect before recovery can write
397    // anything. This durable bit also fences a later auto after this process dies.
398    if (request.mode === 'remove') {
399      state.disabled = true
400      delete state.codexApproval
401      transaction.save()
402    } else if (!existsSync(transaction.file)) transaction.save()
403    const authority = join(installControlRoot(request.home), 'legacy-connect-authority')
404    if (!existsSync(authority)) writeFileSync(authority, '1\n', { flag: 'wx', mode: 0o600 })
405    if (request.mode === 'status') {
406      const hasSetup = state.setup !== undefined || setup.problems.length > 0
407      return { state: { ...state, ...(hasSetup ? { setup } : {}) }, result: null }
408    }
409    if (request.mode === 'ack') {
410      if (request.acknowledgement?.revision === state.revision) {
411        if (state.setup !== undefined || setup.problems.length > 0) state.setup = setup
412        const current = new Set(state.setup?.problems.map(problem => problem.key) ?? [])
413        state.notified = request.acknowledgement.keys.filter(key => current.has(key))
414        transaction.save()
415      }
416      return { state, result: null }
417    }
418    if (request.mode === 'language') {
419      if (request.boardLang === undefined) throw new Error('INVALID_CONNECT_REQUEST')
420      state.boardLang = request.boardLang
421      state.revision += 1
422      transaction.save()
423      return { state, result: null }
424    }
425    const run = (one: LegacyConnectRequest): ReturnType<typeof connectLegacy> => {
426      // Previous local scripts remain in place for in-flight hooks, but a new
427      // scope must not claim them as its own or later remove them.
428      const hosts = new Set(agentHooks(context).map(hook => connectPath(hook.file, isWindows)))
429      for (const file of Object.keys(state.owned)) {
430        const isOtherFolder = connectPath(dirname(file), isWindows) !== connectPath(dirname(one.progress), isWindows)
431        const isReporter = /^(?:agent-board\.mjs|progress-hook\.mjs|progress-report\.(?:sh|ps1))$/.test(file.split(/[\\/]/).at(-1) ?? '')
432        if (!hosts.has(connectPath(file, isWindows)) && isOtherFolder && isReporter) delete state.owned[file]
433      }
434      state.disabled = one.mode === 'remove' ? true : one.mode === 'connect' ? false : state.disabled
435      if (one.boardLang !== undefined) state.boardLang = one.boardLang
436      const { seed: _seed, ...metadata } = one
437      state.pending = { request: metadata, bundle: hash(bundle) }
438      transaction.save()
439      const antigravity = agentHooks(context).find(hook => hook.agent === 'antigravity' && hook.label !== '')
440      const hasAntigravity = antigravity !== undefined && (antigravity.dirs ?? [antigravity.dir]).some(dir => transaction.exists(dir))
441      const directNode = isWindows && hasAntigravity ? windowsHookNode(process.execPath) : undefined
442      const result = connectLegacy(transaction, one, bundle, process.platform === 'win32', observation, found, process.execPath, directNode)
443      state.revision += 1
444      delete state.pending
445      transaction.save()
446      return result
447    }
448    if (state.pending !== undefined) {
449      const step = state.pending.step
450      let hasConflict = false
451      if (hasForeignRecovery && state.disabled && hasLiveRecovery && state.pending.bundle === hash(bundle) && step !== undefined) {
452        try {
453          const current = fingerprint(step.file)
454          hasConflict = current !== step.before && current !== step.after
455        } catch (error) {
456          const code = (error as { code?: string }).code ?? ''
457          // A busy or locked file may be read later: keep the journal and name the error.
458          if (/^E[A-Z]{2,15}$/.test(code) && !['EISDIR', 'ENOTDIR'].includes(code)) throw new Error(`RECOVERY_UNREADABLE_${code}`)
459          if (!['EISDIR', 'ENOTDIR'].includes(code)) throw error
460          // A path that changed its file kind cannot be replayed into this connection.
461          hasConflict = true
462        }
463      }
464      if (hasForeignRecovery && (!state.disabled || !hasLiveRecovery || state.pending.bundle !== hash(bundle) || hasConflict)) {
465        // Superseding a different folder never replays or validates its discarded
466        // file step: that file may have changed or disappeared with the folder.
467        delete state.pending
468        transaction.save()
469      }
470    }
471    if (state.pending !== undefined) {
472      if (state.pending.bundle !== hash(bundle)) throw new Error('RECOVERY_BUNDLE_CHANGED')
473      const previous = state.pending.request
474      transaction.recoverStep()
475      // Read back any applied step into ownership, then clean the original scope
476      // instead of replaying a connect that the person has since disabled.
477      const recovery: LegacyConnectRequest = state.disabled ? { ...previous, mode: 'remove', lang: request.lang } : previous
478      const result = run(recovery)
479      const scope = (one: LegacyConnectRequest): string => JSON.stringify([one.home, one.progress, ...(['codex', 'grok', 'hermes', 'openclaw', 'opencode'] as const).map(agent => one.dirs?.[agent] ?? null)])
480      if (request.mode === 'remove' && recovery.mode === 'remove' && scope(request) === scope(recovery)) return { state, result }
481    }
482    if (request.mode === 'auto' && state.disabled) return { state, result: null }
483    return { state, result: run(request) }
484  }, true)
485}
486
hooks/blocks.ts 733 lines
1import type { CutBlock, ProgressMirror, ProgressMirrorTask, ProgressSessionState, ProgressTask } from '../types'
2import { prWords } from './github'
3import { ZH_TW } from './i18n'
4import type { Strings } from './i18n'
5import { localDay } from './journal'
6import { fallbackTitle, isAnswered, questionToken, retainedCancelledOf } from './mirror'
7import { AUTO_END, AUTO_START, autoOf, doneKey, isCutMarker, isTaskComplete, parseAttrs, parseProgress, projectHead, readable, RESUME, STALE_MS } from './parse'
8import type { QuickEntry } from './quick'
9import type { StepRef, TaskRef } from './view'
10import { sha256 } from './sha256'
11import { oneLine, plain } from './step-words'
12import { isStale, observedTime } from './parse'
13import type { JournalEntry } from './journal'
14
15// Each session's mod owns one block of the file, between its own markers, and rewrites
16// only that block: whatever people or other sessions wrote elsewhere stays byte for byte.
17
18const PROJECT = /^##\s+(.+?)\s*#*\s*$/
19const TASK = /^###\s+(.+?)\s*#*\s*$/
20const STEP = /^([ \t]*[-*+]\s+\[)([ xX~?>!-])(\]\s+)(.*)$/
21const DAY = 24 * 60 * 60 * 1000
22const STOPPED_ARCHIVE_MS = 3 * DAY
23
24export const shortId = (sessionId: string): string => sessionId.slice(0, 8)
25
26export const stateWords = (state: ProgressSessionState, agent: string, s: Strings = ZH_TW): string => {
27  if (state === 'working') {
28    if (agent === '' || agent === 'claude') return s.stateWorking
29    const name = Object.hasOwn(s.agentNames, agent) ? s.agentNames[agent] ?? agent : agent
30    return s.agentWorking(name)
31  }
32  if (state === 'waiting') return s.stateWaiting
33  if (state === 'done') return s.stateDone
34
35  return s.stateEnded
36}
37
38// A project name as a `##` heading: a comment opening or close in it would hide the lines
39// after it, so each becomes a space. Another agent's turn (`readTurn` in inbox.ts) and a pasted
40// block clean their project the same way, so a folder gets one heading whoever writes first.
41const cleanProject = (project: string): string => project.replace(/<!--|-->/g, ' ').replace(/\s+/g, ' ').trim()
42
43// A `##` heading the mod writes, for a folder name (Linux allows `<!--` in one) or a project
44// typed into a quick add. A leading `#` stays (`## #work` reads back as `#work`), and a name
45// left with nothing goes under the uncategorized heading rather than a `## ` the reader skips.
46const headingOf = (project: string, s: Strings): string => cleanProject(project) || s.uncategorized
47
48// The section a project's heading starts, by the cleaned name, else by the name as an earlier
49// version wrote it (`## a-->b`), so a second heading is not added beside it.
50const projectSection = (lines: readonly string[], project: string, heading: string): { heading: number; end: number } | null =>
51  sectionRange(lines, heading) ?? (heading !== project.trim() ? sectionRange(lines, project) : null)
52
53const markOf = (task: ProgressMirrorTask): string => {
54  if (task.handoffTo) return '-'
55  if (task.status === 'completed') return 'x'
56  if (task.blocked !== null) return '!'
57  if (task.waiting === 'you') return '?'
58  if (task.waiting === 'run') return '>'
59
60  return task.status === 'in_progress' ? '~' : ' '
61}
62
63// The marker carries what another session's pane needs: the full id and folder for a
64// resume command, and how the pull request stands.
65const markerOf = (mirror: ProgressMirror): string => {
66  const pr = mirror.pr ?? null
67  const attrs = [
68    `session=${shortId(mirror.sessionId)}`,
69    `agent=${mirror.agent || 'claude'}`,
70    `state=${mirror.state}`,
71    `updated=${new Date(mirror.updated).toISOString()}`,
72    // Another agent's block carries that agent's own session id, for a reply from the board's page.
73    mirror.resume !== undefined && RESUME.test(mirror.resume) ? `resume=${mirror.resume}` : mirror.sessionId.length > 8 ? `resume=${mirror.sessionId}` : '',
74    mirror.cwd ? `dir=${encodeURIComponent(mirror.cwd)}` : '',
75    pr === null ? '' : `pr=${pr.number} ci=${pr.ci}`,
76    mirror.source === 'turns' ? 'source=turns' : '',
77  ]
78
79  return `<!-- auto ${attrs.filter(attr => attr !== '').join(' ')} -->`
80}
81
82// A task's step line after its mark: what the block holds, and what a reader of the file gets
83// back (trimmed). The pane keys a question on it too, so its key is the one every other reader
84// of the block works out.
85export const stepWords = (task: ProgressMirrorTask, s: Strings = ZH_TW): string => {
86  if (task.handoffTo) return `${oneLine(task.subject)} — ${s.transferredTo(oneLine(task.handoffTo))}`
87  const note = task.blocked !== null && task.blocked !== '' ? task.blocked : task.status === 'completed' ? '' : (task.why ?? '')
88  const reason = note === '' ? '' : ` — ${oneLine(note)}`
89
90  return `${oneLine(task.subject)}${reason}`
91}
92
93export const renderAutoBlock = (mirror: ProgressMirror, s: Strings = ZH_TW): string[] => {
94  const id = shortId(mirror.sessionId)
95  const pr = mirror.pr ?? null
96  const meta = [stateWords(mirror.state, mirror.agent, s), mirror.branch, pr === null ? '' : prWords(pr, s)].filter(part => part !== '').join(' · ')
97
98  return [
99    markerOf(mirror),
100    `### ${oneLine(mirror.title) || oneLine(fallbackTitle(mirror)) || s.unnamed}`,
101    `> ${oneLine(meta)}`,
102    ...(mirror.context ? [s.contextNote(oneLine(mirror.context))] : []),
103    ...(mirror.handoff ? [s.handoffNote(oneLine(mirror.handoff))] : []),
104    ...mirror.tasks.flatMap(asked => {
105      // A step the person answered is written as Claude's to do again, not as waiting on them.
106      const task = isAnswered(mirror, asked) ? { ...asked, waiting: null, why: s.answeredNote } : asked
107
108      const token = questionToken(task)
109      return [`- [${markOf(task)}] ${stepWords(task, s)}`, ...(token === '' ? [] : [`<!-- question=${token} -->`])]
110    }),
111    ...retainedCancelledOf(mirror).map(text => `- [-] ${text}`),
112    `<!-- /auto session=${id} -->`,
113  ]
114}
115
116export type BlockSpan = {
117  session: string
118  state: ProgressSessionState
119  updated: number
120  project: string
121  start: number
122  end: number
123  // Ended by its own end marker. A block whose marker went missing (a hand edit) ends at
124  // its first blank line, a second `###`, the next block or the next `##`, and is never
125  // archived: what follows it may be someone's hand-kept text.
126  isClosed: boolean
127}
128
129const eolOf = (text: string): string => (text.includes('\r\n') ? '\r\n' : '\n')
130
131const linesOf = (text: string): string[] => (text === '' ? [] : text.replace(/\r?\n$/, '').split(/\r?\n/))
132
133// The lines as the parser reads them: what sits in a code fence or a comment is never taken
134// for a marker or a heading, so a block pasted there as an example is left alone.
135const HIDDEN = '\u0000'
136
137const seenOf = (lines: readonly string[]): string[] => readable(lines).map(line => line ?? HIDDEN)
138
139const endsBlock = (line: string, session: string): boolean => {
140  if (!AUTO_END.test(line)) return false
141  const named = parseAttrs(line).session
142
143  return named === undefined || named === session
144}
145
146export const scanBlocks = (raws: readonly string[]): BlockSpan[] => {
147  const spans: BlockSpan[] = []
148  const lines = seenOf(raws)
149  let project = ''
150
151  lines.forEach((line, index) => {
152    const heading = PROJECT.exec(line)
153    if (heading?.[1] !== undefined) {
154      project = heading[1]
155      return
156    }
157    const start = AUTO_START.exec(line)
158    const auto = start?.[1] === undefined ? null : autoOf(start[1])
159    if (auto === null) return
160
161    let end = -1
162    for (let at = index + 1; at < lines.length; at += 1) {
163      const next = lines[at] ?? ''
164      if (AUTO_START.test(next) || PROJECT.test(next)) break
165      if (endsBlock(next, auto.session)) {
166        end = at
167        break
168      }
169    }
170    if (end >= 0) {
171      spans.push({ ...auto, project, start: index, end, isClosed: true })
172      return
173    }
174
175    let last = index
176    let headings = 0
177    for (let at = index + 1; at < lines.length; at += 1) {
178      const next = lines[at] ?? ''
179      if ((raws[at] ?? '').trim() === '' || next === HIDDEN || AUTO_START.test(next) || PROJECT.test(next) || AUTO_END.test(next)) break
180      if (TASK.test(next)) {
181        headings += 1
182        if (headings > 1) break
183      }
184      last = at
185    }
186    spans.push({ ...auto, project, start: index, end: last, isClosed: false })
187  })
188
189  return spans
190}
191
192export const CUT_SETTLE_MS = 10 * 1000
193
194// A block with no end marker that a `##` heading, a block start or a block end follows was cut off
195// by a write that never finished, not one still being written: a writer adds to the end of the file
196// or rewrites it whole, never into the middle, and those lines come only after it is done with the
197// block. Other text after it (a step after a blank line, a fence, a comment) may be the rest of a
198// block a reader caught halfway. Once the file has stayed still for CUT_SETTLE_MS, a block with no
199// end marker at the end of the file is cut off too. A cut-off block ends where scanBlocks ends it,
200// so the question pass need not wait for it, and the board notes it in its health section.
201export const cutOffTest = (lines: readonly string[], isSettled = false): ((span: BlockSpan) => boolean) => {
202  if (isSettled) return span => !span.isClosed
203  let last = -1
204  seenOf(lines).forEach((line, index) => {
205    if (PROJECT.test(line) || AUTO_START.test(line) || AUTO_END.test(line)) last = index
206  })
207
208  return span => !span.isClosed && span.end < last
209}
210
211const cutBlockOf = (span: BlockSpan, raw: string): CutBlock => {
212  const attrs = parseAttrs(AUTO_START.exec(raw)?.[1] ?? '')
213  const parsed = attrs.updated === undefined ? Number.NaN : Date.parse(attrs.updated)
214  return {
215    line: span.start + 1,
216    project: span.project,
217    session: span.session,
218    agent: attrs.agent ?? '',
219    updated: Number.isNaN(parsed) ? null : parsed,
220  }
221}
222
223// Every block that ended without its marker, including one whose marker line was itself cut off,
224// so the question ledger keeps the questions of both.
225export const cutOffSpans = (text: string, isSettled = false): CutBlock[] => {
226  const lines = linesOf(text)
227  const isCutOff = cutOffTest(lines, isSettled)
228
229  return scanBlocks(lines).filter(isCutOff).map(span => cutBlockOf(span, lines[span.start] ?? ''))
230}
231
232// A block whose end marker was itself cut has the cut-off line already (`progressTail`), so it is
233// not named a second time.
234export const cutOffBlocks = (text: string, isSettled = false): CutBlock[] => {
235  const lines = linesOf(text)
236  const isCutOff = cutOffTest(lines, isSettled)
237
238  return scanBlocks(lines).filter(span => isCutOff(span) && !lines.slice(span.start + 1, span.end + 2).some(isCutMarker)).map(span => cutBlockOf(span, lines[span.start] ?? ''))
239}
240
241// The board names the first few; more than that is a file to look over as a whole anyway. The
242// agent name comes from the file, so control characters are taken out before any surface shows it.
243export const cutBlockLines = (cut: readonly CutBlock[], s: Strings = ZH_TW): string[] =>
244  cut.slice(0, 3).map(one => {
245    const name = one.agent.replace(/[\u0000-\u001f\u007f-\u009f]/g, '')
246    const key = name.toLowerCase()
247    const agent = name === '' ? '' : Object.hasOwn(s.agentNames, key) ? (s.agentNames[key] ?? name) : name
248    return s.blockCutOff(agent, one.updated === null ? null : localDay(one.updated).slice(5))
249  })
250
251const trimBlankEdges = (lines: string[], at: number): void => {
252  // Collapse the blank lines around `at` to one.
253  let from = at
254  while (from > 0 && lines[from - 1]?.trim() === '') from -= 1
255  let to = at
256  while (to < lines.length && lines[to]?.trim() === '') to += 1
257  if (to - from > 1) lines.splice(from, to - from, '')
258}
259
260const sectionRange = (raws: readonly string[], project: string): { heading: number; end: number } | null => {
261  const lines = seenOf(raws)
262  const head = projectHead(project)
263  // A name that starts with a bracket (`(未分類)`, `(PFC) notes`) has no first word to match
264  // by, so it is matched whole, or every such heading would be taken for one project.
265  const heading = lines.findIndex(line => {
266    const match = PROJECT.exec(line)
267    if (match?.[1] === undefined) return false
268    return head === '' ? match[1].trim() === project.trim() : projectHead(match[1]) === head
269  })
270  if (heading < 0) return null
271  const next = lines.findIndex((line, index) => index > heading && PROJECT.test(line))
272
273  return { heading, end: next < 0 ? lines.length : next }
274}
275
276// New lines (a block, a hand-kept task) at the end of their project's section, below whatever
277// is there; the section is made at the end of the file when missing.
278const placeUnder = (lines: string[], project: string, added: readonly string[], eol: string, s: Strings): string => {
279  const heading = headingOf(project, s)
280  if (lines.every(line => line.trim() === '')) {
281    return [s.fileTitle, '', `## ${heading}`, '', ...added].join(eol) + eol
282  }
283
284  const section = projectSection(lines, project, heading)
285  if (section === null) {
286    while (lines.length > 0 && lines[lines.length - 1]?.trim() === '') lines.pop()
287
288    return [...lines, '', `## ${heading}`, '', ...added].join(eol) + eol
289  }
290
291  let at = section.end
292  while (at > section.heading + 1 && lines[at - 1]?.trim() === '') at -= 1
293  const isLast = section.end >= lines.length
294  lines.splice(at, section.end - at, '', ...added, ...(isLast ? [] : ['']))
295
296  return lines.join(eol) + eol
297}
298
299export const upsertAutoBlock = (text: string, mirror: ProgressMirror, s: Strings = ZH_TW): string => {
300  const eol = eolOf(text)
301  const lines = linesOf(text)
302  const block = renderAutoBlock(mirror, s)
303  const id = shortId(mirror.sessionId)
304  const existing = scanBlocks(lines).find(span => span.session === id)
305
306  if (existing !== undefined) {
307    const end = existing.isClosed ? existing.end : existing.start
308    lines.splice(existing.start, end - existing.start + 1, ...block)
309
310    return lines.join(eol) + eol
311  }
312
313  return placeUnder(lines, mirror.project, block, eol, s)
314}
315
316// Drops `##` sections left with nothing in them, only among `touched` (the projects whose
317// blocks or tasks were just removed): an empty section someone keeps on purpose stays.
318const dropEmptySections = (lines: string[], touched: ReadonlySet<string>): void => {
319  // The lines as read change only when a section goes, so they are read again only then: read
320  // again for every line, archiving on a board of a few thousand lines took seconds (#371).
321  let seen = seenOf(lines)
322  for (let index = lines.length - 1; index >= 0; index -= 1) {
323    const heading = PROJECT.exec(seen[index] ?? '')
324    if (heading?.[1] === undefined || !touched.has(heading[1])) continue
325    let end = index + 1
326    while (end < lines.length && !PROJECT.test(seen[end] ?? '')) end += 1
327    const body = lines.slice(index + 1, end)
328    if (body.every(line => line.trim() === '')) {
329      lines.splice(index, end - index)
330      seen = seenOf(lines)
331    }
332  }
333}
334
335const removeSpans = (text: string, spans: readonly BlockSpan[]): string => {
336  const eol = eolOf(text)
337  const lines = linesOf(text)
338  for (const span of [...spans].sort((a, b) => b.start - a.start)) {
339    const end = span.isClosed ? span.end : span.start
340    lines.splice(span.start, end - span.start + 1)
341    trimBlankEdges(lines, Math.min(span.start, lines.length))
342  }
343  dropEmptySections(lines, new Set(spans.map(span => span.project)))
344  while (lines.length > 0 && lines[lines.length - 1]?.trim() === '') lines.pop()
345
346  return lines.length === 0 ? '' : lines.join(eol) + eol
347}
348
349export const removeAutoBlock = (text: string, sessionId: string): string => {
350  const id = shortId(sessionId)
351  const spans = scanBlocks(linesOf(text)).filter(span => span.session === id)
352
353  return spans.length === 0 ? text : removeSpans(text, spans)
354}
355
356export const extractAutoBlock = (text: string, sessionId: string): string | null => {
357  const lines = linesOf(text)
358  const span = scanBlocks(lines).find(one => one.session === shortId(sessionId))
359
360  return span === undefined ? null : lines.slice(span.start, span.end + 1).join('\n')
361}
362
363// Done blocks leave after half a day; anything else (ended, or a session that stopped
364// writing) after three days without a word, so the file never fills up with old work. A
365// block that gives no time (another tool's, written by hand) is never aged out.
366// `isNow` is the person asking (`/progress tidy`, the pane's archive button): finished work
367// goes at once instead of after half a day; a stopped session still gets its three days.
368export const isArchivable = (span: BlockSpan, now: number, isNow = false): boolean => {
369  if (span.updated <= 0) return false
370  const age = now - span.updated
371  if (span.state === 'done') return isNow || age > DAY / 2
372
373  return age > STOPPED_ARCHIVE_MS
374}
375
376export type Archived = { text: string; entries: string[] }
377
378export type ConfirmRef = TaskRef & { expected: string }
379
380// All words and marks are part of this action, including steps below the next one. The
381// twin count prevents a same-word manual task from taking another task's selected row.
382export const confirmKey = (project: string, task: ProgressTask, twins: number): string =>
383  sha256(JSON.stringify([project, task.title, task.auto, task.notes, task.steps.map(step => step.raw), task.auto === null ? twins : 0]))
384
385export const confirmedTarget = (text: string, ref: ConfirmRef, s: Strings): ProgressTask | null => {
386  const tasks = parseProgress(text).filter(project => (project.name || s.uncategorized) === ref.project).flatMap(project => project.tasks)
387  const twins = tasks.filter(task => task.title === ref.title).length
388  const matches = tasks.filter(task => task.title === ref.title && (task.auto?.session ?? null) === ref.session && confirmKey(ref.project, task, twins) === ref.expected)
389  return matches.find(task => task.line === ref.line) ?? (matches.length === 1 ? matches[0] ?? null : null)
390}
391
392export const confirmMatches = (text: string, ref: ConfirmRef, s: Strings = ZH_TW): boolean => confirmedTarget(text, ref, s) !== null
393
394export type ConfirmedTask = { text: string; ref: ConfirmRef; entries: Array<Omit<JournalEntry, 'at'>>; auto: ProgressTask['auto'] }
395
396// A person's completion changes only the exact task drawn, and only after its agent
397// stopped. Recheck here, inside each caller's read/change/write retry.
398export const confirmTask = (text: string, ref: ConfirmRef, now: number, s: Strings = ZH_TW, seen: Readonly<Record<string, number>> = {}): ConfirmedTask | null => {
399  const task = confirmedTarget(text, ref, s)
400  if (task === null || task.line < 0 || task.auto !== null && task.auto.state !== 'ended' && !isStale(task.auto, now, observedTime(task.auto, now, seen).updated)) return null
401  const lines = linesOf(text)
402  const spans = scanBlocks(lines)
403  const span = ref.session === null ? undefined : spans.find(one => one.session === ref.session)
404  if (ref.session !== null && (span === undefined || !span.isClosed || spans.filter(one => one.session === ref.session).length !== 1)) return null
405  if (ref.session === null && spans.some(one => task.line >= one.start && task.line <= one.end)) return null
406  const open = task.steps.filter(step => step.status !== 'done' && step.status !== 'cancelled')
407  if (open.length === 0) return null
408  for (const step of open) {
409    const raw = lines[step.line] ?? ''
410    // A legal visible step can follow an inline comment or the end of a multiline
411    // comment. Probe only checkbox characters; never tick a hidden example in that line.
412    const match = [...raw.matchAll(/\[[ ~?>!]\]/g)].find(one => {
413      const index = one.index + 1
414      const probe = [...lines]
415      probe[step.line] = raw.slice(0, index) + 'x' + raw.slice(index + 1)
416      return STEP.exec(readable(probe)[step.line] ?? '')?.[2] === 'x'
417    })
418    if (match === undefined) return null
419    const index = match.index + 1
420    lines[step.line] = raw.slice(0, index) + 'x' + raw.slice(index + 1)
421  }
422  if (span !== undefined) {
423    let marker = lines[span.start] ?? ''
424    for (const [name, value] of [['state', 'done'], ['updated', new Date(now).toISOString()]]) {
425      const attr = new RegExp(`\\b${name}=(?:"[^"]*"|'[^']*'|[^\\s]+)`)
426      marker = attr.test(marker) ? marker.replace(attr, `${name}=${value}`) : marker.replace(/\s*-->$/, ` ${name}=${value} -->`)
427    }
428    lines[span.start] = marker
429  }
430  const after = lines.join(eolOf(text)) + (text.endsWith('\n') ? eolOf(text) : '')
431  const changed = parseProgress(after).flatMap(project => project.tasks).find(one => one.line === task.line)
432  if (changed === undefined || changed.steps.some(step => step.status !== 'done' && step.status !== 'cancelled')) return null
433  const twins = parseProgress(after).filter(project => (project.name || s.uncategorized) === ref.project).flatMap(project => project.tasks).filter(one => one.title === ref.title).length
434  return { text: after, ref: { ...ref, expected: confirmKey(ref.project, changed, twins) }, auto: task.auto, entries: open.map(step => ({ project: ref.project, task: ref.title, step: step.text })) }
435}
436
437// The block still asks something of the person: a visible `[?]` step.
438const isAsking = (visible: readonly string[], span: BlockSpan): boolean =>
439  visible.slice(span.start, span.end + 1).some(line => STEP.exec(line ?? '')?.[2] === '?')
440
441// An automatic sweep takes a block that asks only when it has stopped (`ended` or `done`) and has
442// been quiet for the three days a stopped block is given. A `waiting` or `working` block may still
443// get its answer, and its question is a live one that a later poll would have to make again (#174).
444const isStoppedAsking = (span: BlockSpan, now: number): boolean =>
445  (span.state === 'ended' || span.state === 'done') && now - span.updated > STOPPED_ARCHIVE_MS
446
447// The blocks that ask which a sweep may take once the caller has checked that their questions are
448// kept outside the block (#174), each with its lines as they read now (`text`), so a later try can
449// tell the block it checked from one changed since.
450export type AskingBlock = { span: BlockSpan; ref: TaskRef; text: string }
451export const archivableAsking = (text: string, now: number, keep?: string): AskingBlock[] => {
452  if (!text.includes('[?]')) return []
453  const lines = linesOf(text)
454  const visible = readable(lines)
455  const spans = scanBlocks(lines).filter(span => span.isClosed && span.session !== keep && span.updated > 0 && isStoppedAsking(span, now) && isAsking(visible, span))
456  if (spans.length === 0) return []
457  const projects = parseProgress(text)
458
459  return spans.flatMap(span => {
460    // The task the explicit archive would name; a session id shared by two blocks names neither.
461    const tasks = projects.filter(project => project.name === span.project).flatMap(project => project.tasks).filter(task => task.auto?.session === span.session)
462    const task = tasks[0]
463
464    return tasks.length === 1 && task !== undefined ? [{ span, ref: { project: span.project, title: task.title, line: task.line, session: span.session }, text: lines.slice(span.start, span.end + 1).join('\n') }] : []
465  })
466}
467
468// Moves old blocks out; each entry is the block without its markers, under a dated heading,
469// ready to append to progress-archive.md. A block with a visible `[?]` step stays unless it has
470// stopped (`ended` or `done`, three days quiet) and `mayTake` says its questions are kept
471// (`archivableAsking` lists the ones to ask about).
472export const takeArchivable = (text: string, now: number, keep?: string, s: Strings = ZH_TW, isNow = false, mayTake?: (span: BlockSpan) => boolean): Archived => {
473  const lines = linesOf(text)
474  const visible = readable(lines)
475  const spans = scanBlocks(lines).filter(span => span.isClosed && span.session !== keep && isArchivable(span, now, isNow) && (!isAsking(visible, span) || (isStoppedAsking(span, now) && mayTake?.(span) === true)))
476  if (spans.length === 0) return { text, entries: [] }
477  const entries = spans.map(span => {
478    const body = lines.slice(span.start + 1, span.end)
479    const day = localDay(span.updated || now)
480
481    return [`## ${span.project || s.uncategorized} · ${day}`, ...body].join('\n')
482  })
483
484  return { text: removeSpans(text, spans), entries }
485}
486
487// Hand-kept tasks whose every step is done, a day after the pane first saw them done
488// (`seen`, by doneKey): moved out whole, heading to last step, like a session's block.
489// `isNow`: the person asked, so every finished one goes at once, under the day it was seen
490// done, or today when it never was.
491// The task context lets a standalone writer use fingerprint observations while the plugin
492// keeps its existing title-key observations.
493export const takeManualDone = (
494  text: string,
495  now: number,
496  seenAt: (key: string, project: string, task: ProgressTask) => number | undefined,
497  s: Strings = ZH_TW,
498  isNow = false,
499): Archived & { keys: string[] } => {
500  const lines = linesOf(text)
501  const seen = seenOf(lines)
502  const spans: BlockSpan[] = []
503  const entries: string[] = []
504  const keys: string[] = []
505  for (const project of parseProgress(text)) {
506    for (const task of project.tasks) {
507      if (task.auto !== null || task.line < 0 || !isTaskComplete(task)) continue
508      const key = doneKey(project.name, task.title)
509      const at = seenAt(key, project.name, task) ?? (isNow ? now : undefined)
510      if (at === undefined || (!isNow && now - at < DAY)) continue
511      let end = task.line
512      for (let index = task.line + 1; index < lines.length; index += 1) {
513        const line = seen[index] ?? ''
514        if (PROJECT.test(line) || TASK.test(line) || AUTO_START.test(line)) break
515        if ((lines[index] ?? '').trim() !== '') end = index
516      }
517      const last = end
518      // The blank lines after it go too, so the next task moves up into its place.
519      while (end + 1 < lines.length && lines[end + 1]?.trim() === '') end += 1
520      spans.push({ session: '', state: 'done', updated: at, project: project.name, start: task.line, end, isClosed: true })
521      const day = localDay(at)
522      entries.push([`## ${project.name || s.uncategorized} · ${day}`, ...lines.slice(task.line, last + 1)].join('\n'))
523      keys.push(key)
524    }
525  }
526  if (spans.length === 0) return { text, entries: [], keys: [] }
527
528  return { text: removeSpans(text, spans), entries, keys }
529}
530
531// One task out now, at the person's word (a row's 封存): an ended session's block, or a
532// hand-kept task from its heading to its last line, in the entry form takeArchivable uses.
533// null when it cannot be found for sure: a block without its end marker, a heading that is
534// gone or has a twin, a heading inside some session's block, or a session id two blocks
535// share when the row's project and each block's first heading cannot tell them apart.
536export const takeTask = (text: string, ref: TaskRef, now: number, s: Strings = ZH_TW, observations: Readonly<Record<string, number>> = {}): Archived | null => {
537  const lines = linesOf(text)
538  const seen = seenOf(lines)
539  const spans = scanBlocks(lines)
540  const heading = `## ${ref.project || s.uncategorized} · ${localDay(now)}`
541  if (ref.session !== null) {
542    const ownHeading = (one: BlockSpan): number => {
543      for (let at = one.start + 1; at <= one.end; at += 1) {
544        if (TASK.test(seen[at] ?? '')) return at
545        if (STEP.test(seen[at] ?? '')) return -1
546      }
547      return -1
548    }
549    // Two blocks can carry one id (a resumed session, two machines). Only a block's first heading is its own row;
550    // a later ### is a hand-kept task (docs/FORMAT.md), so match that heading alone.
551    const same = spans.filter(one => one.session === ref.session).map(one => {
552      const first = ownHeading(one)
553      return { ...one, heading: first, title: TASK.exec(seen[first] ?? '')?.[1] }
554    })
555    const matches = same.length === 1 ? same : same.filter(one =>
556      (one.project || s.uncategorized) === ref.project &&
557      one.title === ref.title,
558    )
559    const span = matches.find(one => one.heading === ref.line)
560      ?? (matches.length === 1 ? matches[0] : undefined)
561    if (span === undefined || !span.isClosed) return null
562    // Recheck ownership at the write boundary: a stopped row may have resumed since it
563    // was drawn. Unknown clocks cannot prove that a working/waiting session stopped.
564    if (span.state === 'working' || span.state === 'waiting') {
565      const auto = parseProgress(text).flatMap(project => project.tasks).find(task => task.line === span.heading)?.auto ?? null
566      if (auto === null || !isStale(auto, now, observedTime(auto, now, observations).updated)) return null
567    }
568
569    return { text: removeSpans(text, [span]), entries: [[heading, ...lines.slice(span.start + 1, span.end)].join('\n')] }
570  }
571  const isHeading = (line: string | undefined): boolean => TASK.exec(line ?? '')?.[1] === ref.title
572  let at = isHeading(seen[ref.line]) ? ref.line : -1
573  if (at < 0) {
574    const same = seen.flatMap((line, index) => (isHeading(line) ? [index] : []))
575    if (same.length === 1) at = same[0] ?? -1
576  }
577  if (at < 0 || spans.some(span => at >= span.start && at <= span.end)) return null
578  let end = at
579  for (let index = at + 1; index < lines.length; index += 1) {
580    const line = seen[index] ?? ''
581    if (PROJECT.test(line) || TASK.test(line) || AUTO_START.test(line)) break
582    if ((lines[index] ?? '').trim() !== '') end = index
583  }
584  const last = end
585  while (end + 1 < lines.length && lines[end + 1]?.trim() === '') end += 1
586  const span: BlockSpan = { session: '', state: 'done', updated: now, project: ref.project, start: at, end, isClosed: true }
587
588  return { text: removeSpans(text, [span]), entries: [[heading, ...lines.slice(at, last + 1)].join('\n')] }
589}
590
591// A quick add from the pane: a hand-kept task at the end of its project's section (made
592// if missing), below whatever is there.
593export const addManualTask = (text: string, entry: QuickEntry, s: Strings = ZH_TW): string => {
594  const eol = eolOf(text)
595  const lines = linesOf(text)
596  const task = [
597    `### ${oneLine(entry.title)}`,
598    ...(entry.due === null ? [] : [s.dueNote(entry.due)]),
599    ...entry.steps.map(step => `- [ ] ${oneLine(step)}`),
600  ]
601
602  return placeUnder(lines, entry.project, task, eol, s)
603}
604
605// How many tasks in the file carry the heading a quick add of `entry` writes: one more than
606// before means the add is already in.
607export const manualTaskCount = (text: string, entry: QuickEntry): number => {
608  const heading = `### ${oneLine(entry.title)}`
609
610  return seenOf(linesOf(text)).filter(line => line.trimEnd() === heading).length
611}
612
613// Ticks one step of a hand-kept task (a press in the pane): the line the pane showed, if it
614// still reads as it did; else the one line in the file that does, if there is exactly one.
615// Anything else (the step changed, or has a twin) leaves the file alone.
616export const setManualStep = (text: string, ref: StepRef, mark: string): string => {
617  const eol = eolOf(text)
618  const lines = linesOf(text)
619  const seen = seenOf(lines)
620  const isStep = (index: number): boolean => lines[index] === ref.raw && seen[index] !== HIDDEN
621  let at = isStep(ref.line) ? ref.line : -1
622  if (at < 0) {
623    const same = lines.flatMap((_line, index) => (isStep(index) ? [index] : []))
624    if (same.length === 1) at = same[0] ?? -1
625  }
626  const line = lines[at]
627  if (at < 0 || line === undefined || !STEP.test(line)) return text
628  lines[at] = line.replace(STEP, (_all, lead: string, _mark: string, close: string, rest: string) => `${lead}${mark}${close}${rest}`)
629
630  return lines.join(eol) + eol
631}
632
633// A block pasted into the board's page from a web chat (Muse, dot): the chat-relay prompt's
634// block, checked the way scripts/remote-reporter/validate.py checks a fragment, then put in
635// place of the earlier block with its id, or under its `## project` when new. It never takes
636// the place of a Claude block, another agent's block or one whose end marker is missing.
637// Arrival time cannot make a delayed report fresh or give it precedence over newer work.
638export type PasteProblem = 'noBlock' | 'mixed' | 'badBlock' | 'noProject' | 'notYours' | 'badTime' | 'futureTime' | 'oldTime' | 'older' | 'conflict' | 'unknownOrder'
639export type PasteReview = { token: string; current: string; incoming: string }
640export type PasteResult = { text: string; title: string; isUnchanged: boolean }
641  | { problem: Exclude<PasteProblem, 'oldTime'>; review?: PasteReview }
642  | { problem: 'oldTime'; daysBehind: number; review?: PasteReview }
643
644const HEX8 = /^[0-9a-f]{8}$/
645const AGENT_NAME = /^[a-z0-9][a-z0-9_.-]{0,31}$/
646const FENCE = /^\s*(?:```|~~~)/
647
648// Each of these would end the block early, or hide its end marker from the parser.
649const isHiding = (line: string): boolean => FENCE.test(line) || line.includes('<!--') || line.includes('-->') || /^#{1,2}\s/.test(line)
650
651const startAttrs = (line: string | undefined): string => AUTO_START.exec(line ?? '')?.[1] ?? ''
652
653// The relay prompt requires a UTC event time. Date.parse alone accepts local clocks and
654// silently rolls impossible dates forward; neither can establish source ordering.
655const sourceTime = (value: string | undefined): number | null => {
656  if (value === undefined || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?Z$/.test(value)) return null
657  const time = Date.parse(value)
658  const canonical = value.replace(/(?:\.(\d{1,3}))?Z$/, (_all, fraction: string | undefined) => `.${(fraction ?? '').padEnd(3, '0')}Z`)
659  return Number.isFinite(time) && new Date(time).toISOString() === canonical ? time : null
660}
661
662export const mergeBlock = (text: string, pasted: string, now: number, s: Strings = ZH_TW, reviewToken = ''): PasteResult => {
663  const raw = pasted.split(/\r?\n/).map(line => line.trimEnd())
664  const start = raw.findIndex(line => AUTO_START.test(line))
665  const auto = start < 0 ? null : autoOf(startAttrs(raw[start]))
666  const end = raw.findIndex((line, index) => start >= 0 && index > start && AUTO_END.test(line))
667  if (auto === null || end < 0) return { problem: 'noBlock' }
668  if (parseAttrs(raw[end] ?? '').session !== auto.session) return { problem: 'mixed' }
669  const incomingAttrs = parseAttrs(startAttrs(raw[start]))
670  const named = incomingAttrs.agent
671  const body = raw.slice(start + 1, end)
672  const title = TASK.exec(body[0] ?? '')?.[1]
673  // The chat names itself in lower case; a block without an agent would read as Claude's.
674  if (title === undefined || !HEX8.test(auto.session) || named === undefined || named !== auto.agent || !AGENT_NAME.test(named) || auto.agent === 'claude' || body.some(isHiding)) {
675    return { problem: 'badBlock' }
676  }
677  const eol = eolOf(text)
678  const lines = linesOf(text)
679  const matching = scanBlocks(lines).filter(span => span.session === auto.session)
680  if (matching.length > 1) return { problem: 'notYours' }
681  const existing = matching[0]
682  const time = sourceTime(incomingAttrs.updated)
683  // A live report must not arrive already old enough for the stopped-block sweep.
684  if (time !== null && (auto.state === 'working' || auto.state === 'waiting') && now - time > STOPPED_ARCHIVE_MS) {
685    return { problem: 'oldTime', daysBehind: Math.floor((now - time) / DAY) }
686  }
687  if (existing !== undefined) {
688    const owner = autoOf(startAttrs(seenOf(lines)[existing.start]))
689    const attrs = parseAttrs(startAttrs(seenOf(lines)[existing.start]))
690    // A local reporter's binding must survive a pasted block with the same agent and id.
691    if (!existing.isClosed || owner?.agent !== auto.agent || attrs.owner !== undefined || owner.resume !== null || owner.dir !== null) return { problem: 'notYours' }
692    const currentBody = lines.slice(existing.start + 1, existing.end).map(line => line.trimEnd())
693    if (owner.state === auto.state && attrs.updated === incomingAttrs.updated && currentBody.join('\n') === body.join('\n')) return { text, title, isUnchanged: true }
694  }
695  if (time === null) return { problem: 'badTime' }
696  if (time > now) return { problem: 'futureTime' }
697  // Rebuild only checked fields; a remote fragment cannot select a local receiving session,
698  // working folder or pull request. Keep the source time exactly, including its precision.
699  const marker = `<!-- auto session=${auto.session} agent=${auto.agent} state=${auto.state} updated=${incomingAttrs.updated} -->`
700  const block = [marker, ...body, `<!-- /auto session=${auto.session} -->`]
701  if (existing !== undefined) {
702    const oldTime = sourceTime(parseAttrs(startAttrs(lines[existing.start])).updated)
703    if (oldTime === null || oldTime > now) {
704      const current = lines.slice(existing.start, existing.end + 1).join('\n')
705      // A review applies only to this exact pair, not a changed report or newer board state.
706      const token = sha256(JSON.stringify(['relay-clock-review', existing.project, current, block]))
707      if (reviewToken !== token) return { problem: 'unknownOrder', review: {
708        token,
709        current: [parseAttrs(startAttrs(lines[existing.start])).updated ?? '?', ...lines.slice(existing.start + 1, existing.end)].join('\n'),
710        incoming: [incomingAttrs.updated, ...body].join('\n'),
711      } }
712    } else {
713      if (time < oldTime) return { problem: 'older' }
714      if (time === oldTime) return { problem: 'conflict' }
715    }
716    lines.splice(existing.start, existing.end - existing.start + 1, ...block)
717
718    return { text: lines.join(eol) + eol, title, isUnchanged: false }
719  }
720  // The `##` line nearest above the block names its project; it becomes a heading, so no
721  // comment opening goes with it.
722  const project = raw
723    .slice(0, start)
724    .flatMap(line => {
725      const name = PROJECT.exec(line)?.[1]
726      return name === undefined ? [] : [cleanProject(name)]
727    })
728    .at(-1)
729  if (project === undefined || project === '') return { problem: 'noProject' }
730
731  return { text: placeUnder(lines, project, block, eol, s), title, isUnchanged: false }
732}
733
hooks/github.ts 106 lines
1import type { ProgressCi, ProgressPr } from '../types'
2import { ZH_TW } from './i18n'
3import type { Strings } from './i18n'
4
5// The pull request of this session's branch, from `gh pr view --json`; a machine without
6// gh, or a branch without a PR, has none.
7
8export const GH_FIELDS = 'number,url,state,isDraft,statusCheckRollup,reviewDecision'
9
10type Check = {
11  // CheckRun
12  status?: string
13  conclusion?: string
14  // StatusContext
15  state?: string
16}
17
18const FAILED = new Set(['FAILURE', 'ERROR', 'TIMED_OUT', 'CANCELLED', 'ACTION_REQUIRED', 'STARTUP_FAILURE'])
19const PENDING = new Set(['PENDING', 'EXPECTED', 'QUEUED', 'IN_PROGRESS', 'WAITING', 'REQUESTED'])
20
21const outcomeOf = (check: Check): 'pass' | 'fail' | 'pending' => {
22  if (check.state !== undefined) {
23    if (FAILED.has(check.state)) return 'fail'
24    return PENDING.has(check.state) ? 'pending' : 'pass'
25  }
26  if (check.status !== undefined && check.status !== 'COMPLETED') return 'pending'
27  const conclusion = check.conclusion ?? ''
28  if (FAILED.has(conclusion)) return 'fail'
29
30  return conclusion === '' ? 'pending' : 'pass'
31}
32
33export const ciOf = (checks: readonly Check[]): { ci: ProgressCi; failed: number } => {
34  if (checks.length === 0) return { ci: 'none', failed: 0 }
35  const outcomes = checks.map(outcomeOf)
36  const failed = outcomes.filter(one => one === 'fail').length
37  if (failed > 0) return { ci: 'fail', failed }
38
39  return { ci: outcomes.includes('pending') ? 'pending' : 'pass', failed: 0 }
40}
41
42const REVIEW: Record<string, ProgressPr['review']> = {
43  APPROVED: 'approved',
44  CHANGES_REQUESTED: 'changes',
45  REVIEW_REQUIRED: 'required',
46}
47
48export const prFromGh = (stdout: string): ProgressPr | null => {
49  let raw: unknown
50  try {
51    raw = JSON.parse(stdout)
52  } catch {
53    return null
54  }
55  const data = raw as {
56    number?: unknown
57    url?: unknown
58    state?: unknown
59    isDraft?: unknown
60    statusCheckRollup?: unknown
61    reviewDecision?: unknown
62  }
63  if (typeof data.number !== 'number' || typeof data.url !== 'string') return null
64  const state = data.state === 'MERGED' ? 'merged' : data.state === 'CLOSED' ? 'closed' : 'open'
65  const checks = Array.isArray(data.statusCheckRollup) ? (data.statusCheckRollup as Check[]) : []
66
67  return {
68    number: data.number,
69    url: data.url,
70    state,
71    isDraft: data.isDraft === true,
72    ...ciOf(checks),
73    review: typeof data.reviewDecision === 'string' ? (REVIEW[data.reviewDecision] ?? null) : null,
74  }
75}
76
77export const ciWords = (ci: ProgressCi | null, failed = 0, s: Strings = ZH_TW): string => {
78  if (ci === 'pass') return s.ciPass
79  if (ci === 'fail') return s.ciFail(failed)
80  if (ci === 'pending') return s.ciPending
81
82  return ''
83}
84
85const reviewWords = (review: NonNullable<ProgressPr['review']>, s: Strings): string =>
86  review === 'approved' ? s.reviewApproved : review === 'changes' ? s.reviewChanges : s.reviewRequired
87
88// `PR #12 · CI 失敗 2 · 要求修改`, in the words the pane uses.
89export const prWords = (pr: ProgressPr, s: Strings = ZH_TW): string =>
90  [
91    `PR #${pr.number}`,
92    pr.state === 'merged' ? s.prMerged : pr.state === 'closed' ? s.prClosed : pr.isDraft ? s.prDraft : '',
93    pr.state === 'open' ? ciWords(pr.ci, pr.failed, s) : '',
94    pr.state === 'open' && pr.review !== null ? reviewWords(pr.review, s) : '',
95  ]
96    .filter(part => part !== '')
97    .join(' · ')
98
99// A CI run that just finished, for a notification; null when nothing changed that way.
100export const ciFinished = (before: ProgressPr | null, after: ProgressPr | null, s: Strings = ZH_TW): string | null => {
101  if (before === null || after === null || before.number !== after.number) return null
102  if (before.ci !== 'pending' || (after.ci !== 'pass' && after.ci !== 'fail')) return null
103
104  return s.noticeCi(after.number, ciWords(after.ci, after.failed, s))
105}
106
hooks/journal.ts 128 lines
1import type { ProgressMirror, ProgressProject } from '../types'
2import { ZH_TW } from './i18n'
3import type { Strings } from './i18n'
4import { waitsOnYou } from './mirror'
5import { countSteps, currentStepIndex, isTaskComplete, readable } from './parse'
6
7// A line per finished step, one file a month beside progress.md, so "what did I do this
8// week" has an answer after the blocks themselves are archived.
9//   - 2026-10-16 14:32 · persona_framework · 重做進度面板 · 寫測試
10
11export type JournalEntry = { at: string; project: string; task: string; step: string }
12
13const pad = (value: number): string => String(value).padStart(2, '0')
14
15// Local time: the journal is read by a person, in their own day.
16export const localDay = (now: number): string => {
17  const date = new Date(now)
18  return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
19}
20
21const localStamp = (now: number): string => {
22  const date = new Date(now)
23  return `${localDay(now)} ${pad(date.getHours())}:${pad(date.getMinutes())}`
24}
25
26// This month's archive beside progress.md, where finished work moves.
27export const archivePath = (progressPath: string, now: number): string => `${progressPath.replace(/\.md$/i, '')}-archive-${localDay(now).slice(0, 7)}.md`
28
29export const journalPath = (progressPath: string, now: number): string =>
30  `${progressPath.replace(/\.md$/i, '')}-journal-${localDay(now).slice(0, 7)}.md`
31
32// A retry may find the whole entry already saved, but a shorter task must never match
33// the beginning of an older, longer one. Fenced/commented headings are part of the body.
34export const hasArchiveEntry = (past: string, entry: string): boolean => {
35  const lines = past.replace(/\r\n/g, '\n').split('\n')
36  const starts = readable(lines).flatMap((line, index) => line !== null && /^##\s/.test(line) ? [index] : [])
37  const wanted = entry.replace(/\r\n/g, '\n').replace(/\n+$/, '')
38  return starts.some((start, index) =>
39    lines.slice(start, starts[index + 1] ?? lines.length).join('\n').replace(/\n+$/, '') === wanted,
40  )
41}
42
43// The month before, so a week that spans two months reads both files.
44export const previousMonth = (now: number): number => {
45  const date = new Date(now)
46  return new Date(date.getFullYear(), date.getMonth(), 0, 12).getTime()
47}
48
49// ` · ` separates the fields, so a field never holds one.
50const field = (text: string, s: Strings): string => text.replace(/\s+/g, ' ').replace(/\s·\s/g, '・').trim() || s.unnamed
51
52export const journalLine = (entry: Omit<JournalEntry, 'at'>, now: number, s: Strings = ZH_TW): string =>
53  `- ${localStamp(now)} · ${field(entry.project, s)} · ${field(entry.task, s)} · ${field(entry.step, s)}`
54
55export const appendJournal = (text: string, lines: readonly string[], s: Strings = ZH_TW): string =>
56  lines.length === 0 ? text : `${text.trim() === '' ? `${s.journalTitle}\n` : text.replace(/\s*$/, '\n')}${lines.join('\n')}\n`
57
58const LINE = /^- (\d{4}-\d{2}-\d{2}) \d{2}:\d{2} · (.+?) · (.+?) · (.+)$/
59
60// Steps finished on the day `now` falls on, for the count at the top of the pane.
61export const countDay = (text: string, now: number): number => {
62  const day = localDay(now)
63  return parseJournal(text).filter(entry => entry.at === day).length
64}
65
66export const parseJournal = (text: string): JournalEntry[] =>
67  text.split(/\r?\n/).flatMap(line => {
68    const match = LINE.exec(line)
69    if (match === null) return []
70    const [, at = '', project = '', task = '', step = ''] = match
71    return [{ at, project, task, step }]
72  })
73
74// What finished in the last `days` days, what is open, and what waits on you: plain text a
75// person pastes into a weekly report, a standup or a message to an advisor.
76export const progressReport = (
77  entries: readonly JournalEntry[],
78  projects: readonly ProgressProject[],
79  mine: ProgressMirror | null,
80  now: number,
81  days: number,
82  s: Strings = ZH_TW,
83): string => {
84  const from = localDay(now - (days - 1) * 24 * 60 * 60 * 1000)
85  const to = localDay(now)
86  const heading = days === 1 ? s.reportToday(to.slice(5)) : s.reportDays(days, from.slice(5), to.slice(5))
87
88  const done = new Map<string, string[]>()
89  for (const entry of entries) {
90    if (entry.at < from || entry.at > to) continue
91    const key = `${entry.project} · ${entry.task}`
92    const steps = done.get(key) ?? []
93    if (!steps.includes(entry.step)) steps.push(entry.step)
94    done.set(key, steps)
95  }
96
97  const open: string[] = []
98  const waiting: string[] = []
99  if (mine !== null && mine.tasks.some(task => task.status !== 'completed')) {
100    const left = mine.tasks.filter(task => task.status !== 'completed')
101    const finished = mine.tasks.length - left.length
102    open.push(`- ${mine.project} · ${mine.title || left[0]?.subject || ''}${s.pairSep}${s.reportNext(left[0]?.subject ?? '')} (${finished}/${mine.tasks.length})`)
103    for (const task of left) if (waitsOnYou(mine, task)) waiting.push(`- ${mine.project} · ${task.subject}`)
104  }
105  const ownId = mine === null ? '' : mine.sessionId.slice(0, 8)
106  for (const project of projects) {
107    for (const task of project.tasks) {
108      if (task.auto?.session === ownId || task.auto?.state === 'done' || isTaskComplete(task)) continue
109      const { done: finished, total } = countSteps(task)
110      const next = task.steps[currentStepIndex(task)]?.text ?? ''
111      open.push(`- ${project.name} · ${task.title}${next === '' ? '' : `${s.pairSep}${s.reportNext(next)}`} (${finished}/${total})`)
112      for (const step of task.steps) if (step.status === 'waiting') waiting.push(`- ${project.name} · ${step.text}`)
113    }
114  }
115
116  const finishedLines = [...done].map(([key, steps]) => `- ${key}${s.pairSep}${steps.join(s.listSep)}`)
117  const sections = [
118    heading,
119    '',
120    s.reportDone,
121    ...(finishedLines.length === 0 ? [s.reportNone] : finishedLines),
122    ...(open.length === 0 ? [] : ['', s.reportOpen, ...open]),
123    ...(waiting.length === 0 ? [] : ['', s.reportWaiting, ...waiting]),
124  ]
125
126  return sections.join('\n')
127}
128
hooks/mirror.ts 508 lines
1import type { ProgressMirror, ProgressMirrorTask, ProgressSessionState, ProgressTask } from '../types'
2import { ZH_TW } from './i18n'
3import type { Strings } from './i18n'
4import { sha256 } from './sha256'
5import { normalizeTurnOrder } from './inbox'
6import { renewQuestion } from './step-words'
7export { renewQuestion } from './step-words'
8
9// Claude's own task list, mirrored from the calls it makes (TaskCreate, TaskUpdate,
10// TodoWrite). The list is the source of truth; the file is a view of it.
11
12type Status = ProgressMirrorTask['status']
13
14export const confirmMirror = (mirror: ProgressMirror, now: number): ProgressMirror => ({
15  ...mirror,
16  state: 'done',
17  updated: now,
18  confirmedAt: now,
19  autoAsk: null,
20  tasks: mirror.tasks.map(task => ({ ...task, status: 'completed', waiting: null, blocked: null, blockedBy: [] })),
21})
22
23export type CompletionOverlay = {
24  owner: string
25  source: string
26  sourceOrder?: number
27  sourceMissing?: true
28  updated: number
29  completed: ProgressMirror
30}
31
32export const completionPrefix = (owner: string): string => `completion:${sha256(owner)}:`
33export const completionOverlay = (owner: string, source: ProgressMirror, now: number): CompletionOverlay => ({
34  owner,
35  source: sha256(JSON.stringify(source)),
36  ...(normalizeTurnOrder(source.turnOrder) === undefined ? {} : { sourceOrder: normalizeTurnOrder(source.turnOrder) }),
37  updated: now,
38  completed: confirmMirror(source, now),
39})
40export const completionKey = (overlay: CompletionOverlay): string => `${completionPrefix(overlay.owner)}${overlay.source}:${overlay.updated}`
41
42const isRetainedCancelled = (value: unknown): value is string[] =>
43  Array.isArray(value) && value.length <= 256 && Array.from(value).every(text => typeof text === 'string' && text.length <= 4096 && !/[\r\n]/.test(text))
44
45export const retainedCancelledOf = (mirror: ProgressMirror): readonly string[] =>
46  isRetainedCancelled(mirror.retainedCancelled) ? mirror.retainedCancelled : []
47
48// An externally written block can predate its first inbox delivery. Keep its exact
49// selected fingerprint and completed steps without creating a mutable owner record.
50export const completionOfBlock = (owner: string, project: string, task: ProgressTask, fingerprint: string, now: number): CompletionOverlay | null => {
51  const auto = task.auto
52  if (auto === null || !/^[a-f0-9]{64}$/.test(fingerprint) || owner !== `agent:${auto.session}` && !(auto.agent === 'claude' && owner === `mirror:${auto.resume ?? auto.session}`)) return null
53  const sessionId = owner.slice(owner.indexOf(':') + 1)
54  const retainedCancelled = task.steps.filter(step => step.status === 'cancelled').map(step => step.text)
55  if (!isRetainedCancelled(retainedCancelled)) return null
56  const tasks: ProgressMirrorTask[] = task.steps.filter(step => step.status !== 'cancelled').map((step, index) => ({
57    id: String(index + 1), subject: step.text, status: 'completed', activeForm: '', waiting: null, why: null, ask: null, options: [], command: null, link: null, blocked: null, blockedBy: [],
58  }))
59  const completed = confirmMirror({ ...emptyMirror(sessionId, project, auto.updated, auto.dir ?? '', auto.agent), title: task.title, isTitleFinal: true, source: owner.startsWith('agent:') ? 'turns' : 'tasks', tasks, ...(retainedCancelled.length === 0 ? {} : { retainedCancelled }), ...(auto.resume === null ? {} : { resume: auto.resume }) }, now)
60  return { owner, source: fingerprint, sourceMissing: true, updated: now, completed }
61}
62
63export const isCompletionOverlay = (value: unknown, owner: string): value is CompletionOverlay => {
64  if (value === null || typeof value !== 'object') return false
65  const one = value as Partial<CompletionOverlay>
66  if (one.completed?.retainedCancelled !== undefined && !isRetainedCancelled(one.completed.retainedCancelled)) return false
67  return one.owner === owner && typeof one.source === 'string' && /^[a-f0-9]{64}$/.test(one.source) && typeof one.updated === 'number' && Number.isFinite(one.updated) && one.updated > 0 && (one.sourceMissing === undefined || one.sourceMissing === true) && (one.sourceOrder === undefined || Number.isSafeInteger(one.sourceOrder) && one.sourceOrder >= 0) && one.completed?.state === 'done' && one.completed.confirmedAt === one.updated && typeof one.completed.sessionId === 'string' && (owner === `agent:${one.completed.sessionId}` || owner === `mirror:${one.completed.sessionId}`) && typeof one.completed.agent === 'string' && Array.isArray(one.completed.tasks) && one.completed.tasks.every(task => task !== null && typeof task === 'object' && task.status === 'completed')
68}
69
70// Completion never writes the owner's mutable record. Exact source snapshots and
71// provably older ordered events can be projected complete; fresh work keeps its record.
72export const applyCompletionOverlay = (source: ProgressMirror, overlay: CompletionOverlay): ProgressMirror => {
73  if (source.sessionId !== overlay.completed.sessionId || source.agent !== overlay.completed.agent) return source
74  const order = normalizeTurnOrder(source.turnOrder)
75  if (overlay.sourceMissing === true) return order !== undefined && order < overlay.updated * 2 ? overlay.completed : source
76  const older = order !== undefined && overlay.sourceOrder !== undefined && order < overlay.sourceOrder && source.updated <= overlay.updated
77  return sha256(JSON.stringify(source)) === overlay.source || older ? overlay.completed : source
78}
79
80export type TaskCreateInput = { subject: string; activeForm?: string | undefined; metadata?: object | undefined }
81
82export type TaskUpdateInput = {
83  taskId: string
84  subject?: string | undefined
85  activeForm?: string | undefined
86  status?: Status | 'deleted' | undefined
87  addBlockedBy?: readonly string[] | undefined
88  metadata?: object | undefined
89}
90
91export type TodoInput = { content: string; status: Status; activeForm: string }
92
93export const emptyMirror = (sessionId: string, project: string, now: number, cwd = '', agent = 'claude'): ProgressMirror => ({
94  sessionId,
95  agent,
96  project,
97  cwd,
98  branch: '',
99  pr: null,
100  title: '',
101  context: '',
102  isTitleFinal: false,
103  firstPrompt: '',
104  state: 'working',
105  tasks: [],
106  updated: now,
107  source: 'tasks',
108  autoAsk: null,
109  handoff: '',
110  namedSteps: 0,
111  lastAnswer: '',
112  answered: {},
113})
114
115// A short key for a step's words (FNV-1a, 32 bits, in hex): an answer is kept for the words
116// it answered, so the same step asking something new waits on the person again.
117const stepKey = (text: string): string => {
118  let hash = 0x811c9dc5
119  for (let index = 0; index < text.length; index += 1) {
120    hash ^= text.charCodeAt(index)
121    hash = Math.imul(hash, 0x01000193)
122  }
123
124  return (hash >>> 0).toString(16).padStart(8, '0')
125}
126
127// The words a step asks with, as the person saw them: its subject and its note.
128const askedWords = (task: ProgressMirrorTask): string => `${task.subject}\n${task.why ?? ''}`
129
130// An open step that asked the person, answered since, and still asking the same.
131export const isAnswered = (mirror: ProgressMirror, task: ProgressMirrorTask): boolean =>
132  task.status !== 'completed' && task.waiting === 'you' && mirror.answered?.[task.id] === stepKey(askedWords(task))
133
134// A step that waits on the person: asked, open, and not answered since.
135export const waitsOnYou = (mirror: ProgressMirror, task: ProgressMirrorTask): boolean =>
136  task.status !== 'completed' && task.waiting === 'you' && !isAnswered(mirror, task)
137
138// The person answered: the steps that wait on them (all, or the ones `only` names) are marked
139// answered with the words they answered. What the steps asked stays, for Claude's next call.
140export const markAnswered = (mirror: ProgressMirror, only?: readonly string[]): ProgressMirror => {
141  const answered: Record<string, string> = {}
142  for (const task of mirror.tasks) {
143    if (task.status === 'completed' || task.waiting !== 'you') continue
144    if (isAnswered(mirror, task) || (waitsOnYou(mirror, task) && (only === undefined || only.includes(task.id)))) {
145      answered[task.id] = stepKey(askedWords(task))
146    }
147  }
148
149  return { ...mirror, answered }
150}
151
152// Marks without the steps they name: a step asked again, gone, or renumbered.
153export const withoutMarks = (mirror: ProgressMirror, ids: readonly string[]): Record<string, string> =>
154  Object.fromEntries(Object.entries(mirror.answered ?? {}).filter(([id]) => !ids.includes(id)))
155
156const WAITING_YOU = new Set(['you', 'user', 'me', 'human', 'person', '使用者', '用户', '我'])
157
158const waitingOf = (value: unknown, current: ProgressMirrorTask['waiting']): ProgressMirrorTask['waiting'] => {
159  if (value === undefined) return current
160  if (value === null || value === false || value === '') return null
161  if (typeof value === 'string' && WAITING_YOU.has(value.toLowerCase())) return 'you'
162
163  return 'run'
164}
165
166const blockedOf = (value: unknown, current: string | null): string | null => {
167  if (value === undefined) return current
168  if (value === null || value === false || value === '') return null
169
170  return value === true ? '' : String(value)
171}
172
173// The one line that says what a waiting step needs: `why`, or the words Claude reaches for.
174const WHY_KEYS = ['why', 'reason', 'question', 'note'] as const
175
176const whyOf = (meta: Record<string, unknown>, waiting: ProgressMirrorTask['waiting'], current: string | null): string | null => {
177  if (waiting === null) return null
178  for (const key of WHY_KEYS) {
179    const value = meta[key]
180    if (value === null || value === '') return null
181    if (typeof value === 'string') return value.replace(/\s+/g, ' ').trim()
182  }
183
184  return current
185}
186
187const textOr = (value: unknown, current: string | null): string | null => {
188  if (value === undefined) return current
189  return typeof value === 'string' && value.trim() !== '' ? value.trim() : null
190}
191
192// What the person is asked for, kept while the step waits and dropped when it stops.
193const askFields = (
194  meta: Record<string, unknown>,
195  waiting: ProgressMirrorTask['waiting'],
196  task: ProgressMirrorTask,
197): Pick<ProgressMirrorTask, 'ask' | 'options' | 'command' | 'link'> => {
198  if (waiting === null) return { ask: null, options: [], command: null, link: null }
199  const options = Array.isArray(meta.options)
200    ? meta.options.filter((one): one is string => typeof one === 'string' && one.trim() !== '').map(one => one.trim())
201    : meta.options === null
202      ? []
203      : (task.options ?? [])
204
205  return {
206    ask: textOr(meta.ask, task.ask ?? null),
207    options,
208    command: textOr(meta.command, task.command ?? null),
209    link: textOr(meta.link, task.link ?? null),
210  }
211}
212
213// `metadata: { waiting: "you" | "run", why: "what it needs", ask: "confirm", options: [...],
214// command: "...", link: "...", blocked: "why" }`; a key set to null clears it, and clearing
215// `waiting` clears what it asked.
216const withMetadata = (task: ProgressMirrorTask, metadata: object | undefined): ProgressMirrorTask => {
217  if (metadata === undefined) return task
218  const meta = metadata as Record<string, unknown>
219  const waiting = waitingOf(meta.waiting, task.waiting)
220  const handoffTo = textOr(meta.handoffTo, task.handoffTo ?? null)?.replace(/\s+/g, ' ').slice(0, 120)
221  if (handoffTo) return { ...task, status: 'completed', waiting: null, why: null, ...NO_ASK, blocked: null, handoffTo }
222
223  return {
224    ...task,
225    handoffTo: undefined,
226    waiting,
227    why: whyOf(meta, waiting, task.why ?? null),
228    ...askFields(meta, waiting, task),
229    blocked: blockedOf(meta.blocked, task.blocked),
230  }
231}
232
233const NO_ASK = { ask: null, options: [], command: null, link: null }
234
235export const questionToken = (task: ProgressMirrorTask): string =>
236  /^[1-9]\d*$/.test(task.questionRevision ?? '') ? `${sha256(task.id)}:${task.questionRevision}` : ''
237
238export const applyTaskCreate = (
239  mirror: ProgressMirror,
240  input: TaskCreateInput,
241  id: string,
242  now: number,
243): ProgressMirror => {
244  const previous = mirror.tasks.find(one => one.id === id)
245  const given = withMetadata(
246    {
247      id,
248      subject: input.subject,
249      status: 'pending',
250      activeForm: input.activeForm ?? '',
251      waiting: null,
252      why: null,
253      ...NO_ASK,
254      blocked: null,
255      blockedBy: [],
256      ...(previous?.questionRevision === undefined ? {} : { questionRevision: previous.questionRevision }),
257    },
258    input.metadata,
259  )
260  const task = given.waiting === 'you' ? renewQuestion(given, mirror.questionSequence) : given
261  // Steps summed up from turns give way to the real list the moment Claude starts one.
262  const kept = mirror.source === 'turns' ? [] : mirror.tasks
263  const tasks = [...kept.filter(one => one.id !== id), task]
264
265  return {
266    ...mirror,
267    tasks,
268    ...(given.waiting === 'you' ? { questionSequence: task.questionRevision } : {}),
269    source: 'tasks',
270    autoAsk: mirror.source === 'turns' ? null : mirror.autoAsk,
271    answered: mirror.source === 'turns' ? {} : withoutMarks(mirror, [id]),
272    updated: now,
273  }
274}
275
276// Whether a TaskUpdate's metadata asks the person (again).
277const asksYou = (metadata: object | undefined): boolean =>
278  metadata !== undefined && waitingOf((metadata as Record<string, unknown>).waiting, null) === 'you'
279
280export const applyTaskUpdate = (mirror: ProgressMirror, input: TaskUpdateInput, now: number): ProgressMirror => {
281  if (input.status === 'deleted') {
282    return { ...mirror, tasks: mirror.tasks.filter(one => one.id !== input.taskId), answered: withoutMarks(mirror, [input.taskId]), updated: now }
283  }
284  const status = input.status
285  const tasks = mirror.tasks.map(task => {
286    if (task.id !== input.taskId) return task
287    const next: ProgressMirrorTask = {
288      ...task,
289      // An explicit new owner update can reclaim the task. A passive TaskList cannot.
290      handoffTo: input.status === 'pending' || input.status === 'in_progress' || (input.subject !== undefined && input.subject !== task.subject) ? undefined : task.handoffTo,
291      subject: input.subject ?? task.subject,
292      activeForm: input.activeForm ?? task.activeForm,
293      status: status ?? task.status,
294      blockedBy: [...new Set([...task.blockedBy, ...(input.addBlockedBy ?? [])])],
295    }
296    // Finishing a step clears what it was waiting on.
297    const done = next.status === 'completed' ? { ...next, waiting: null, why: null, ...NO_ASK, blocked: null } : next
298
299    const meta = input.metadata as Record<string, unknown> | undefined
300    const completedMeta = meta !== undefined && Object.hasOwn(meta, 'handoffTo') ? { handoffTo: meta.handoffTo } : undefined
301    const updated = withMetadata(done, next.status === 'completed' ? completedMeta : input.metadata)
302    // Handoff metadata can close a task that was pending at the start of this
303    // update. Only the final open question is a new producer ask.
304    return updated.status !== 'completed' && updated.waiting === 'you' && asksYou(input.metadata) ? renewQuestion(updated, mirror.questionSequence) : updated
305  })
306  // Claude asking the person again brings the step back, even in the words they answered.
307  const updated = tasks.find(task => task.id === input.taskId)
308  const isAskedAgain = updated !== undefined && updated.status !== 'completed' && updated.waiting === 'you' && asksYou(input.metadata)
309
310  return { ...mirror, tasks, ...(isAskedAgain ? { questionSequence: tasks.find(task => task.id === input.taskId)?.questionRevision ?? mirror.questionSequence } : {}), answered: isAskedAgain ? withoutMarks(mirror, [input.taskId]) : mirror.answered, updated: now }
311}
312
313export const applyTodoWrite = (mirror: ProgressMirror, todos: readonly TodoInput[], now: number): ProgressMirror => ({
314  ...mirror,
315  source: 'tasks',
316  autoAsk: null,
317  answered: {},
318  tasks: todos.map((todo, index) => {
319    const previous = mirror.tasks.find(one => one.id === String(index + 1))
320    return {
321      id: String(index + 1),
322      subject: todo.content,
323      status: todo.status,
324      activeForm: todo.activeForm,
325      waiting: null,
326      why: null,
327      ...NO_ASK,
328      blocked: null,
329      blockedBy: [],
330      ...(previous?.questionRevision === undefined ? {} : { questionRevision: previous.questionRevision }),
331    }
332  }),
333  updated: now,
334})
335
336export type ListedTask = { id: string; subject: string; status: Status; blockedBy: readonly string[] }
337
338// TaskList after a reload or a resume: keeps what the mirror knew of each task.
339export const fromTaskList = (mirror: ProgressMirror, listed: readonly ListedTask[], now: number): ProgressMirror =>
340  mirror.source === 'turns' && listed.length === 0 ? mirror : readList(mirror, listed, now)
341
342const readList = (mirror: ProgressMirror, listed: readonly ListedTask[], now: number): ProgressMirror => ({
343  ...mirror,
344  source: 'tasks',
345  // Marks of steps no longer listed go, and the turns' steps had none of Claude's.
346  answered: mirror.source === 'turns' ? {} : Object.fromEntries(Object.entries(mirror.answered ?? {}).filter(([id]) => listed.some(item => item.id === id))),
347  tasks: listed.map(item => {
348    const known = mirror.tasks.find(task => task.id === item.id)
349    const handoffTo = known?.handoffTo && known.subject === item.subject ? known.handoffTo : undefined
350    const status = handoffTo ? 'completed' : item.status
351
352    return {
353      id: item.id,
354      subject: item.subject,
355      status,
356      ...(handoffTo ? { handoffTo } : {}),
357      activeForm: known?.activeForm ?? '',
358      waiting: status === 'completed' ? null : (known?.waiting ?? null),
359      why: status === 'completed' ? null : (known?.why ?? null),
360      ask: status === 'completed' ? null : (known?.ask ?? null),
361      options: status === 'completed' ? [] : (known?.options ?? []),
362      command: status === 'completed' ? null : (known?.command ?? null),
363      link: status === 'completed' ? null : (known?.link ?? null),
364      blocked: status === 'completed' ? null : (known?.blocked ?? null),
365      blockedBy: [...item.blockedBy],
366      ...(known?.questionRevision === undefined ? {} : { questionRevision: known.questionRevision }),
367    }
368  }),
369  updated: now,
370})
371
372export const isAllDone = (mirror: ProgressMirror): boolean =>
373  mirror.tasks.length > 0 && mirror.tasks.every(task => task.status === 'completed')
374
375// Steps that finished between two states of the mirror, for the journal. Matched by
376// subject, since TodoWrite numbers its list afresh on every call.
377export const completedSince = (before: ProgressMirror, after: ProgressMirror): ProgressMirrorTask[] =>
378  after.tasks.filter(
379    task => task.status === 'completed' && !task.handoffTo && !before.tasks.some(old => old.status === 'completed' && old.subject === task.subject),
380  )
381
382// What deserves a desktop notification between two states of the mirror: a step that now
383// needs the person, and the whole list finishing.
384export const noticesOf = (before: ProgressMirror, after: ProgressMirror, s: Strings = ZH_TW): string[] => {
385  const needs = after.tasks.filter(task => waitsOnYou(after, task) && !before.tasks.some(old => old.id === task.id && waitsOnYou(before, old)))
386  const title = after.title || fallbackTitle(after)
387
388  return [
389    ...needs.map(task => s.noticeNeedsYou(task.subject)),
390    ...(isAllDone(after) && !isAllDone(before) && completedSince(before, after).length > 0 ? [s.noticeDone(title, after.tasks.filter(task => !task.handoffTo).length)] : []),
391  ]
392}
393
394export const stateOf = (mirror: ProgressMirror, isTurnRunning: boolean): ProgressSessionState => {
395  if ((mirror.backgroundJobs ?? 0) > 0) return !isTurnRunning && mirror.tasks.some(task => waitsOnYou(mirror, task)) ? 'waiting' : 'working'
396  if (isAllDone(mirror)) return 'done'
397
398  return isTurnRunning ? 'working' : 'waiting'
399}
400
401export const doingTask = (mirror: ProgressMirror): ProgressMirrorTask | undefined =>
402  mirror.tasks.find(task => task.status === 'in_progress' && (task.waiting === null || isAnswered(mirror, task)) && task.blocked === null) ??
403  mirror.tasks.find(task => task.status === 'in_progress')
404
405// A CJK or full-width character takes two terminal cells, anything else one.
406const WIDE = /[ᄀ-ᅟ⺀-꓏가-힣豈-﫿︰-﹏＀-⦆¢-₩]/u
407const cells = (char: string): number => (WIDE.test(char) || char.length > 1 ? 2 : 1)
408
409// Text cut to `width` cells with `…`, at a space when the text is words, so a limit sized
410// for Chinese does not chop English mid-word ("Add README describing in").
411export const clip = (text: string, width: number): string => {
412  const chars = [...text]
413  let used = 0
414  let fits = 0
415  for (const char of chars) {
416    if (used + cells(char) > width) break
417    used += cells(char)
418    fits += 1
419  }
420  if (fits === chars.length) return text
421  const room = chars.slice(0, Math.max(0, fits - 1))
422  const space = room.lastIndexOf(' ')
423  const kept = space > room.length / 2 ? room.slice(0, space) : room
424
425  return `${kept.join('').replace(/[\s,.;:,、;:]+$/, '')}…`
426}
427
428// A title until the model names the task: the first line of the request, or the first step.
429export const fallbackTitle = (mirror: ProgressMirror): string => {
430  const prompt = mirror.firstPrompt.split(/\r?\n/).find(line => line.trim() !== '')?.trim() ?? ''
431  const source = prompt !== '' ? prompt : (mirror.tasks[0]?.subject ?? '')
432
433  return clip(source, 38)
434}
435
436// What the model answered, made into a title: one line, no quotes or end punctuation.
437export const cleanTitle = (answer: string): string => {
438  const line = answer.split(/\r?\n/).find(one => one.trim() !== '')?.trim() ?? ''
439  const bare = line.replace(/^["'「『《【\s]+|["'」』》】。..!!??\s]+$/g, '').replace(/^(任務名稱|名稱|Title)[::]\s*/i, '')
440
441  return clip(bare, 48)
442}
443
444// The model's second line, made into one line of background.
445export const cleanContext = (answer: string): string => {
446  const line = answer
447    .replace(/^["'「『\s]+|["'」』\s]+$/g, '')
448    .replace(/^(背景|Background|Context)\s*[::]\s*/i, '')
449    .replace(/\s+/g, ' ')
450
451  return clip(line.trim(), 120)
452}
453
454// Text from progress.md, which any program can write, made fit to show Claude as data: one
455// line, no backtick to close the fence it sits in, no comment or marker opening, at most
456// `limit` characters.
457export const asData = (text: string, limit = 120): string => {
458  const line = text
459    .replace(/`/g, "'")
460    .replace(/<!--|-->/g, ' ')
461    .replace(/\s+/g, ' ')
462    .trim()
463  const chars = [...line]
464
465  return chars.length <= limit ? line : `${chars.slice(0, limit - 1).join('')}…`
466}
467
468// Lines of file text for Claude, inside a fence and after a sentence saying they are data,
469// not instructions; each line should already have been through asData.
470export const dataBlock = (lines: readonly string[], s: Strings = ZH_TW): string[] =>
471  lines.length === 0 ? [] : [s.dataNote, '```text', ...lines, '```']
472
473// The note the conversation gets after a compaction: where this session's task stands.
474export const resumeNote = (mirror: ProgressMirror, s: Strings = ZH_TW): string | null => {
475  const open = mirror.tasks.filter(task => task.status !== 'completed')
476  if (open.length === 0) return null
477  const total = mirror.tasks.filter(task => !task.handoffTo).length
478  const done = total - open.length
479  const doing = doingTask(mirror)
480  const next = open.filter(task => task !== doing).slice(0, 4)
481  const waiting = open.filter(task => waitsOnYou(mirror, task) || task.blocked !== null)
482  const facts = [
483    s.resumeGoal(asData(mirror.title || fallbackTitle(mirror)), asData(mirror.project)),
484    mirror.context === '' || mirror.context === undefined ? '' : s.resumeContext(asData(mirror.context)),
485    s.resumeDone(done, total),
486    doing === undefined ? '' : s.resumeDoing(asData(doing.subject)),
487    next.length === 0 ? '' : s.resumeNext(next.map(task => asData(task.subject)).join(s.stepSep)),
488    waiting.length === 0
489      ? ''
490      : s.resumeWaiting(waiting.map(task => asData(task.blocked ? `${task.subject} (${task.blocked})` : task.subject)).join(s.stepSep)),
491  ].filter(line => line !== '')
492
493  return [s.resumeHead, ...dataBlock(facts, s), s.resumeTail].join('\n')
494}
495
496// Only the mod's own per-session records age out of the store; a setting the person made
497// (closing the pane by hand) stays until they change it.
498// Prompt and completion fences outlive paused writers. Age alone cannot prove
499// that an old mirror will never return; new ordered work supersedes them on read.
500export const isAgingStoreKey = (key: string): boolean => ['mirror:', 'hinted:', 'done:', 'agent:'].some(prefix => key.startsWith(prefix))
501
502export const isStaleStoreKey = (key: string, value: unknown, now: number, maxAge: number): boolean => {
503  if (!isAgingStoreKey(key)) return false
504  const stamp = typeof value === 'number' ? value : ((value as { updated?: number } | undefined)?.updated ?? 0)
505
506  return now - stamp > maxAge
507}
508
hooks/parse.ts 356 lines
1import type {
2  ProgressAuto,
3  ProgressCi,
4  ProgressProject,
5  ProgressSessionState,
6  ProgressStep,
7  ProgressStepStatus,
8  ProgressTask,
9  ProgressTail,
10} from '../types'
11import { sha256 } from './sha256.ts'
12
13// `## project` / `### task` / `> note` / `- [x] step`, plus the auto markers a session's
14// mod writes around its own block. Everything else is ignored, so free text never breaks
15// the pane.
16const PROJECT = /^##\s+(.+?)\s*#*\s*$/
17const TASK = /^###\s+(.+?)\s*#*\s*$/
18const NOTE = /^\s*>\s?(.*)$/
19const STEP = /^([ \t]*)[-*+]\s+\[([ xX~?>!-])\]\s+(.*)$/
20const FENCE = /^\s*(```|~~~)/
21const MARKER_PREFIXES = ['auto session=', '/auto session=', 'question=']
22export const isCutMarker = (line: string): boolean => {
23  if (!line.startsWith('<!--') || line.includes('-->')) return false
24  // A loop, not a trailing-whitespace regex: a long run of spaces would make the regex quadratic.
25  let end = line.length
26  while (end > 0 && (line[end - 1] === ' ' || line[end - 1] === '\t' || line[end - 1] === '\r')) end--
27  const marker = line.slice(0, end).replace(/^<!--[ \t]*/, '')
28  // A bare comment opening is still how people hide examples.
29  return marker !== '' && MARKER_PREFIXES.some(prefix => prefix.startsWith(marker) || marker.startsWith(prefix))
30}
31export const AUTO_START = /^<!--\s*auto\s+(.*?)\s*-->\s*$/
32export const AUTO_END = /^<!--\s*\/auto\b.*?-->\s*$/
33export const QUESTION = /^<!--\s*question=([a-f0-9]{64}:[1-9]\d*)\s*-->\s*$/
34const DUE = /(?:截止|\bdue)[::\s]*((?:\d{4}[-/])?\d{1,2}[-/]\d{1,2})/i
35
36const STATUS: Record<string, ProgressStepStatus> = {
37  ' ': 'todo',
38  x: 'done',
39  X: 'done',
40  '~': 'doing',
41  '?': 'waiting',
42  '>': 'running',
43  '!': 'blocked',
44  '-': 'cancelled',
45}
46
47const SESSION_STATES: readonly ProgressSessionState[] = ['working', 'waiting', 'done', 'ended']
48const CI_STATES: readonly ProgressCi[] = ['none', 'pending', 'pass', 'fail']
49
50// `dir=` is written percent-encoded, so a folder with spaces stays one attribute.
51const decoded = (value: string | undefined): string | null => {
52  if (value === undefined || value === '') return null
53  try {
54    return decodeURIComponent(value)
55  } catch {
56    return null
57  }
58}
59
60export const parseAttrs = (raw: string): Record<string, string> => {
61  const attrs: Record<string, string> = {}
62  for (const match of raw.matchAll(/([\w-]+)=(\S+)/g)) {
63    const [, key, value] = match
64    if (key !== undefined && value !== undefined) attrs[key] = value
65  }
66
67  return attrs
68}
69
70// `resume=` ends up in a command the person pastes into a terminal, and anyone can write the
71// file: only a plain session id is kept.
72export const RESUME = /^[0-9a-zA-Z][0-9a-zA-Z_-]{5,63}$/
73
74// A folder with a control character (a newline above all) would break the pasted command
75// into two; the quote is escaped where the command is built.
76const folderOf = (value: string | undefined): string | null => {
77  const dir = decoded(value)
78
79  return dir === null || /[\u0000-\u001f\u007f]/.test(dir) ? null : dir
80}
81
82export const autoOf = (raw: string): ProgressAuto | null => {
83  const attrs = parseAttrs(raw)
84  const session = attrs.session
85  if (session === undefined) return null
86  const state = SESSION_STATES.find(one => one === attrs.state) ?? 'waiting'
87  const updated = Date.parse(attrs.updated ?? '')
88  const pr = Number(attrs.pr)
89
90  return {
91    session,
92    agent: (attrs.agent ?? 'claude').toLowerCase(),
93    state,
94    updated: Number.isNaN(updated) ? 0 : updated,
95    resume: attrs.resume !== undefined && RESUME.test(attrs.resume) ? attrs.resume : null,
96    dir: folderOf(attrs.dir),
97    pr: Number.isInteger(pr) && pr > 0 ? pr : null,
98    ci: CI_STATES.find(one => one === attrs.ci) ?? null,
99  }
100}
101
102const indentOf = (lead: string): number => {
103  const width = [...lead].reduce((sum, ch) => sum + (ch === '\t' ? 4 : 1), 0)
104
105  return Math.min(3, Math.floor(width / 2))
106}
107
108const dueOf = (task: ProgressTask): string | null => {
109  for (const text of [task.title, ...task.notes]) {
110    const match = DUE.exec(text)
111    if (match?.[1] !== undefined) return match[1].replace(/\//g, '-')
112  }
113
114  return null
115}
116
117// What the format reads of each line, shared by the parser and the writer so neither sees a
118// block or heading the other skips: null inside a code fence (the fence lines too) or a
119// multi-line HTML comment; else the line with its comments taken out, an auto marker kept
120// whole.
121export const readable = (lines: readonly string[]): Array<string | null> => {
122  let isFenced = false
123  let isComment = false
124
125  return lines.map((raw, index) => {
126    if (FENCE.test(raw)) {
127      isFenced = !isFenced
128      return null
129    }
130    if (isFenced) return null
131    let line = raw
132    if (isComment) {
133      const end = line.indexOf('-->')
134      if (end < 0) return null
135      isComment = false
136      line = line.slice(end + 3)
137    } else if (AUTO_START.test(raw) || AUTO_END.test(raw) || QUESTION.test(raw)) {
138      return raw
139    }
140    if (isCutMarker(raw)) {
141      // A later board boundary before the comment close belongs to the next writer.
142      let hasBoundary = false
143      let hasClose = false
144      for (let nextIndex = index + 1; nextIndex < lines.length; nextIndex++) {
145        const next = lines[nextIndex] ?? ''
146        if (AUTO_START.test(next) || AUTO_END.test(next) || PROJECT.test(next)) hasBoundary = true
147        if (next.includes('-->')) {
148          hasClose = true
149          break
150        }
151      }
152      if (hasBoundary || !hasClose) return raw
153    }
154    line = line.replace(/<!--.*?-->/g, '')
155    const open = line.indexOf('<!--')
156    if (open >= 0) {
157      isComment = true
158      line = line.slice(0, open)
159    }
160
161    return line
162  })
163}
164
165// A cut marker is damage, not a comment around the next writer's complete blocks.
166// Keep its boundary in the original string; writers must preserve the prefix exactly.
167export const progressTail = (markdown: string): ProgressTail | null => {
168  const raws = markdown.split('\n')
169  const lines = readable(raws)
170  let offset = 0
171  let open: { session: string; tasks: number } | null = null
172  let torn: ProgressTail | null = null
173  lines.forEach((line, index) => {
174    if (line !== null) {
175      const start = AUTO_START.exec(line)
176      const auto = start?.[1] === undefined ? null : autoOf(start[1])
177      if (auto !== null) open = { session: auto.session, tasks: 0 }
178      else if (AUTO_END.test(line)) {
179        const ends = parseAttrs(line).session
180        if (ends === undefined || ends === open?.session) open = null
181      } else if (PROJECT.test(line)) open = null
182      else if (TASK.test(line) && open !== null) open.tasks += 1
183      else if (isCutMarker(line) && torn === null) {
184        const close = open !== null && `<!-- /auto session=${open.session} -->`.startsWith(line.replace(/\r$/, '')) ? open.session : null
185        torn = { offset, tasks: Math.max(1, open?.tasks ?? 0), close }
186      }
187    }
188    offset += (raws[index]?.length ?? 0) + 1
189  })
190  return torn
191}
192
193export const parseProgress = (markdown: string): ProgressProject[] => {
194  const projects: ProgressProject[] = []
195  let project: ProgressProject | undefined
196  let task: ProgressTask | undefined
197  let pendingAuto: ProgressAuto | null = null
198  let openSession: string | null = null
199
200  const ensureProject = (): ProgressProject => {
201    if (project === undefined) {
202      project = { name: '', tasks: [] }
203      projects.push(project)
204    }
205
206    return project
207  }
208
209  const ensureTask = (): ProgressTask => {
210    if (task === undefined) {
211      task = { title: '', line: -1, notes: [], steps: [], auto: pendingAuto, due: null }
212      pendingAuto = null
213      ensureProject().tasks.push(task)
214    }
215
216    return task
217  }
218
219  const raws = markdown.split(/\r?\n/)
220  readable(raws).forEach((line, lineIndex) => {
221    if (line === null) return
222    const raw = raws[lineIndex] ?? ''
223
224    const autoStart = AUTO_START.exec(line)
225    if (autoStart?.[1] !== undefined) {
226      pendingAuto = autoOf(autoStart[1])
227      openSession = pendingAuto?.session ?? null
228      task = undefined
229      return
230    }
231    if (AUTO_END.test(line)) {
232      // An end marker closes only its own block (or any, when it names none).
233      const ends = parseAttrs(line).session
234      if (openSession !== null && (ends === undefined || ends === openSession)) {
235        openSession = null
236        pendingAuto = null
237        task = undefined
238      }
239      return
240    }
241    if (line.trim() === '') return
242    const question = QUESTION.exec(line)?.[1]
243    if (question !== undefined) {
244      const previous = task?.steps.at(-1)
245      if (openSession !== null && task?.auto?.session === openSession && previous !== undefined) previous.questionToken = question
246      return
247    }
248
249    const projectMatch = PROJECT.exec(line)
250    // A session's block never holds a `##`, so one always starts a project, and ends a block
251    // whose end marker went missing.
252    if (projectMatch?.[1] !== undefined) {
253      openSession = null
254      pendingAuto = null
255      project = { name: projectMatch[1], tasks: [] }
256      projects.push(project)
257      task = undefined
258      return
259    }
260
261    const taskMatch = TASK.exec(line)
262    if (taskMatch?.[1] !== undefined) {
263      task = { title: taskMatch[1], line: lineIndex, notes: [], steps: [], auto: pendingAuto, due: null }
264      pendingAuto = null
265      ensureProject().tasks.push(task)
266      return
267    }
268
269    const stepMatch = STEP.exec(line)
270    if (stepMatch !== null) {
271      const [, lead = '', mark = ' ', text = ''] = stepMatch
272      const step: ProgressStep = {
273        text: text.trim(),
274        status: STATUS[mark] ?? 'todo',
275        depth: indentOf(lead),
276        line: lineIndex,
277        raw,
278      }
279      ensureTask().steps.push(step)
280      return
281    }
282
283    const noteMatch = NOTE.exec(line)
284    if (noteMatch?.[1] !== undefined && task !== undefined) {
285      task.notes.push(noteMatch[1].trim())
286    }
287  })
288
289  for (const one of projects) {
290    for (const each of one.tasks) each.due = dueOf(each)
291  }
292
293  return projects.filter(one => one.name !== '' || one.tasks.length > 0)
294}
295
296export type TaskCount = { done: number; total: number }
297
298// Cancelled steps count as neither done nor open.
299export const countSteps = (task: ProgressTask): TaskCount => {
300  const counted = task.steps.filter(step => step.status !== 'cancelled')
301
302  return { done: counted.filter(step => step.status === 'done').length, total: counted.length }
303}
304
305export const isMarksComplete = (statuses: readonly ProgressStepStatus[]): boolean => {
306  const counted = statuses.filter(status => status !== 'cancelled')
307
308  return counted.length > 0 ? counted.every(status => status === 'done') : statuses.length > 0 && statuses.every(status => status === 'cancelled')
309}
310
311export const isTaskComplete = (task: ProgressTask): boolean => isMarksComplete(task.steps.map(step => step.status))
312
313// The step to highlight: the explicit `[~]`, else the first open one.
314export const currentStepIndex = (task: ProgressTask): number => {
315  const doing = task.steps.findIndex(step => step.status === 'doing')
316  if (doing >= 0) return doing
317
318  return task.steps.findIndex(step => step.status !== 'done' && step.status !== 'cancelled')
319}
320
321// A block whose session stopped writing to it (the terminal closed without the session
322// ending, a crash) still says working or waiting; after half a day it is treated as ended.
323export const STALE_MS = 12 * 60 * 60 * 1000
324export const FUTURE_SKEW_MS = 5 * 60 * 1000
325
326export const futureTimeKey = (auto: ProgressAuto): string =>
327  `future:${sha256(JSON.stringify(['future-time', auto.agent, auto.session, auto.updated]))}`
328
329// Keep the writer's stamp intact. The local observation belongs to that exact update,
330// and remains authoritative when the local clock eventually catches up with it.
331export const observedTime = (auto: ProgressAuto | null, now: number, seen: Readonly<Record<string, number>>): { updated: number; isFuture: boolean } => {
332  const updated = auto?.updated ?? 0
333  if (auto === null || !Number.isFinite(updated) || updated <= 0) return { updated, isFuture: false }
334  const first = seen[futureTimeKey(auto)]
335  const isObserved = typeof first === 'number' && Number.isFinite(first) && first > 0
336  const isFuture = isObserved || updated - now > FUTURE_SKEW_MS
337  return { updated: isObserved ? first : updated, isFuture }
338}
339
340export const isStale = (auto: ProgressAuto | null, now: number, updated = auto?.updated ?? 0): boolean =>
341  auto !== null && (auto.state === 'working' || auto.state === 'waiting') && updated > 0 && now - updated > STALE_MS
342
343// The key a finished hand-kept task is remembered by, to archive it a day later.
344export const doneKey = (project: string, title: string): string => `done:${project}::${title}`
345
346export const baseName = (path: string): string => path.replace(/[\\/]+$/, '').split(/[\\/]/).pop() ?? ''
347
348// `## persona_framework (PFC)` belongs to the session whose repo is persona_framework.
349export const projectHead = (name: string): string => (name.trim().split(/[\s((·—]/)[0] ?? '').toLowerCase()
350
351export const isHereProject = (name: string, here: readonly string[]): boolean => {
352  const head = projectHead(name)
353
354  return head !== '' && here.some(one => one.toLowerCase() === head)
355}
356
hooks/dashboard.ts 280 lines
1import type { ProgressStepStatus } from '../types'
2import type { PendingTurn } from './board'
3import { clip } from './mirror'
4import { askRole, stepRole, toneRole } from './palette'
5import type { Palette } from './palette'
6import { agentMark, agentMarkCss, boardMark, pageBaseCss, pageTokens, themeHead, themeSwitch } from './page-look'
7import type { Strings } from './i18n'
8import type { Focus, TaskRow, View, ViewStep, WaitingItem } from './view'
9
10// progress.html: the same view as the pane, as a page any browser keeps open in a window of
11// its own, beside progress.md. Built from the file and pending inbox turns, without a session's
12// own card, so every agent's work is on it. It reloads itself every 30
13// seconds and keeps the sections the person opened and their chosen page theme.
14
15const GLYPH: Record<ProgressStepStatus, string> = {
16  done: '✓',
17  doing: '▸',
18  todo: '•',
19  waiting: '●',
20  running: '◐',
21  blocked: '!',
22  cancelled: '–',
23}
24
25const LEGEND: readonly ProgressStepStatus[] = ['done', 'doing', 'todo', 'waiting', 'running', 'blocked', 'cancelled']
26
27const esc = (text: string): string =>
28  text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
29
30// Each colour role as a CSS variable, light by default and dark under the system's choice.
31export const vars = (palette: Palette): string =>
32  Object.entries(palette)
33    .map(([key, value]) => `--${key}:${value}`)
34    .join(';')
35
36export const htmlLang = (s: Strings): string => (s.lang === 'en' ? 'en' : s.lang === 'zh-CN' ? 'zh-Hans' : 'zh-Hant')
37
38const steps = (list: readonly ViewStep[]): string =>
39  `<ol class="steps">${list
40    .map(
41      step =>
42        `<li class="${step.isCurrent ? 'now' : ''}"><span class="n">${step.number ?? ''}</span>` +
43        `<span class="g" style="color:var(--${roleOf(step.status)})">${GLYPH[step.status]}</span>` +
44        `<span class="t${step.status === 'done' || step.status === 'cancelled' ? ' quiet' : ''}">${esc(step.text)}</span>` +
45        (step.note === '' ? '' : `<span class="note" style="color:var(--${roleOf(step.status)})">${esc(step.note)}</span>`) +
46        '</li>',
47    )
48    .join('')}</ol>`
49
50const roleOf = stepRole
51const toneVar = toneRole
52const askVar = askRole
53
54const bar = (done: number, total: number): string => {
55  const percent = total === 0 ? 0 : Math.round((done / total) * 100)
56  return `<span class="bar"><span style="width:${percent}%;background:var(--${done === total && total > 0 ? 'done' : 'accent'})"></span></span>`
57}
58
59const waitingItem = (item: WaitingItem, s: Strings): string =>
60  `<div class="ask"><div><span class="chip" style="color:var(--${askVar(item.kind)})">${esc(s.ask[item.kind])}</span>` +
61  `<b>${esc(item.text)}</b></div>` +
62  `<div class="where">${agentMark(item.agent, item.where === 'manual')}${esc(item.project)} › ${esc(item.task)}${item.agent === '' ? '' : ` · ${esc(item.agent)}`}</div>` +
63  (item.note === '' ? (item.context === '' ? '' : `<div class="quiet">${esc(s.contextLine(item.context))}</div>`) : `<div>${esc(item.note)}</div>`) +
64  // A backticked word in another kind of question is a name, not something to run.
65  (item.command === null || item.kind !== 'do' ? '' : `<code>${esc(item.command)}</code>`) +
66  (item.link === null ? '' : `<a href="${esc(item.link)}">${esc(s.openLink)}</a>`) +
67  `<div class="quiet">${esc(item.where === 'manual' ? '' : s.replyElsewhere(item.task, '', item.agent))}${item.when === '' ? '' : ` · ${esc(item.when)}`}</div>` +
68  '</div>'
69
70const row = (it: TaskRow, s: Strings, showProject: boolean): string => {
71  const meta = [it.due === null ? '' : it.due.date, showProject ? it.project : '', it.agent, it.status, it.pr, it.when].filter(
72    part => part !== '',
73  )
74  return (
75    `<details class="row" data-key="${esc(it.key)}"><summary><span class="title">${esc(it.title)}</span>` +
76    `<span class="tail" style="color:var(--${it.due === null ? 'muted' : toneVar(it.due.tone)})">${esc(it.due === null ? `${it.done}/${it.total}` : it.due.left)}</span>` +
77    `<span class="meta" style="color:var(--${toneVar(it.tone)})">${agentMark(it.agent, it.auto === null)}${bar(it.done, it.total)} ${esc(meta.join(' · '))}</span></summary>` +
78    (it.context === '' ? '' : `<p class="quiet">${esc(s.contextLine(it.context))}</p>`) +
79    steps(it.steps) +
80    '</details>'
81  )
82}
83
84const focus = (it: Focus | null, s: Strings): string => {
85  if (it === null) return `<section class="focus calm">${esc(s.nothingForYou)}</section>`
86  const body =
87    it.type === 'ask'
88      ? waitingItem(it.item, s)
89      : `<div><b style="color:var(--alert)">${esc(it.type === 'due' ? `⚑ ${it.row.due?.left ?? ''}` : s.stuckLead)}</b> <b>${esc(it.row.title)}</b></div>` +
90        `<div class="where">${esc(it.row.project)}</div><div>${esc(it.row.status)}</div>`
91
92  return `<section class="focus"><div class="label">${esc(s.mostNeeded)}</div>${body}</section>`
93}
94
95const section = (id: string, title: string, count: number, tone: string, hint: string, body: string, isOpen: boolean): string =>
96  count === 0
97    ? ''
98    : `<details class="section" data-key="section:${id}"${isOpen ? ' open' : ''}><summary><span class="title">${esc(title)}</span>` +
99      `<span class="count" style="color:var(--${tone})">${count}</span>${hint === '' ? '' : `<span class="quiet">${esc(hint)}</span>`}</summary>${body}</details>`
100
101export const dashboardHtml = (view: View, s: Strings, path: string, stamp: string, pending: readonly PendingTurn[] = []): string => {
102  pending = view.hasPendingQuestions ? pending.filter(turn => !turn.isQuestion) : pending
103  const { active, due, today } = view.overview
104  const waiting = view.overview.waiting + pending.filter(turn => turn.isQuestion).length
105  const nearest = view.due[0]
106  const counts = [
107    ['●', waiting, s.countWaiting, 'waiting'],
108    ['▸', active, s.countActive, 'accent'],
109    ['⚑', due, s.countDue, 'soon'],
110    ['✓', today, s.countToday, 'done'],
111  ] as const
112
113  const sections = [
114    ['inbox', s.boardInbox, pending.length],
115    ['waiting', s.sectionWaiting, view.waiting.length],
116    ['reminder', s.sectionQuestionReminders, view.reminder?.length ?? 0],
117    ['older', s.sectionOlderQuestions, view.older?.length ?? 0],
118    ['idle', s.idleCount(view.idle.length), view.idle.length],
119    ['stopped', s.viewStopped, view.stopped.length],
120    ['due', s.sectionDue, view.due.length],
121    ['others', s.sectionActive, view.active.length],
122    ['recent', s.sectionRecent, view.recent.length],
123  ] as const
124
125  return `<!doctype html>
126<html lang="${htmlLang(s)}">
127<head>
128<meta charset="utf-8">
129<meta name="viewport" content="width=device-width,initial-scale=1">
130<title>${esc(s.paneTitle)} · Agent Board</title>
131<script>${themeHead}</script>
132<style>
133${pageTokens()}
134*{box-sizing:border-box}
135body{margin:0;background:var(--ground);color:var(--ink);font:15px/1.6 "Hiragino Sans","Noto Sans TC","Noto Sans SC","Microsoft JhengHei","Microsoft YaHei",system-ui,sans-serif}
136main{max-width:760px;margin:0 auto;padding:20px max(16px,env(safe-area-inset-left)) 40px max(16px,env(safe-area-inset-right));display:grid;gap:14px}
137header{display:flex;flex-wrap:wrap;align-items:baseline;gap:4px 12px}
138h1{font-size:1.4rem;margin:0;letter-spacing:.04em}
139.meta,.quiet{color:var(--muted)}
140.counts{display:flex;flex-wrap:wrap;gap:6px 18px;font-variant-numeric:tabular-nums}
141.focus{border:1px solid var(--line);border-radius:10px;padding:12px 14px;display:grid;gap:4px}
142.focus.calm{color:var(--done)}
143.label{color:var(--muted);font-size:.85rem;letter-spacing:.06em}
144.chip{margin-right:6px}
145.where{color:var(--project)}
146code{display:block;overflow-x:auto;font:13px/1.5 ui-monospace,"JetBrains Mono",Consolas,monospace;padding:6px 8px;border:1px solid var(--line);border-radius:6px;color:var(--accent)}
147a{color:var(--accent)}
148details.section{border-top:1px solid var(--line);padding-top:8px}
149summary{cursor:pointer;display:flex;flex-wrap:wrap;align-items:baseline;gap:4px 10px;list-style:none}
150summary::before{content:"▸";color:var(--muted)}
151details[open]>summary::before{content:"▾"}
152summary:focus-visible{outline:1px dotted var(--accent);outline-offset:3px}
153.section>summary .title{font-weight:700}
154.group{margin:10px 0 0 4px}
155.group h3{margin:0;font-size:.95rem;color:var(--project);display:flex;flex-wrap:wrap;gap:4px 10px}
156.group h3 span{font-weight:400;color:var(--muted)}
157details.row{margin:6px 0 0 8px}
158.row .title{flex:1 1 12em;min-width:0}
159.row .meta{flex-basis:100%;padding-left:1.2em;display:flex;align-items:center;gap:8px;min-width:0}
160.bar{display:inline-block;width:52px;height:4px;border-radius:2px;background:var(--frame);overflow:hidden;flex:none}
161.bar span{display:block;height:100%}
162.steps{list-style:none;margin:6px 0;padding:0 0 0 1.2em;display:grid;gap:2px}
163.steps li{display:grid;grid-template-columns:2ch 1.4em 1fr;column-gap:4px}
164.steps .n{color:var(--muted);text-align:right;font-variant-numeric:tabular-nums}
165.steps .now .t{font-weight:700}
166.steps .note{grid-column:3}
167.ask{display:grid;gap:2px;margin:8px 0 0 4px}
168footer{color:var(--muted);font-size:.85rem;display:grid;gap:4px;border-top:1px solid var(--line);padding-top:10px}
169.legend{display:flex;flex-wrap:wrap;gap:2px 14px}
170@media (prefers-reduced-motion:reduce){*{scroll-behavior:auto}}
171${pageBaseCss}
172${agentMarkCss()}
173:root{--shadow:0 1px 2px rgba(0,0,0,.05),0 2px 8px -4px rgba(0,0,0,.08)}
174.dashboard-shell{display:grid;grid-template-columns:200px minmax(0,780px);column-gap:36px;align-items:start}
175main{max-width:780px;min-width:0;margin:0;padding:18px 0 40px;gap:18px}
176.dash-nav{position:sticky;top:var(--head-h,70px);display:grid;gap:4px;padding:20px 0;max-height:calc(100dvh - var(--head-h,70px));overflow:auto}
177.dash-nav h2{font-size:11.5px;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin:0 0 6px;padding:0 10px}
178.dash-nav button{display:flex;align-items:center;justify-content:space-between;gap:10px;min-height:36px;padding:6px 10px;border:1px solid transparent;border-radius:9px;text-align:left;background:transparent;font-size:14px;cursor:pointer}
179.dash-nav button:hover{background:var(--surface);border-color:var(--line)}.dash-nav .ct{color:var(--muted);font-size:12px;font-variant-numeric:tabular-nums}
180.where .ag{margin-right:6px}.head .meta{font-size:12px}.head .brand{flex-wrap:wrap}.head .brand .meta{margin-left:auto}
181.counts{display:grid;grid-template-columns:repeat(4,1fr);gap:0;border:1px solid var(--line);border-radius:14px;background:var(--surface);overflow:hidden}.counts>span{padding:12px;border-left:1px solid var(--line)}.counts>span:first-child{border-left:0}
182.focus{border:1px solid var(--line);border-radius:14px;background:var(--surface);padding:16px;box-shadow:var(--shadow)}
183details.section{border:1px solid var(--line);padding:0;border-radius:14px;background:var(--surface);box-shadow:var(--shadow);overflow:clip;scroll-margin-top:calc(var(--head-h,70px) + 12px)}
184.section>summary{padding:12px 16px}.section>.ask,.section>.quiet,.section>.steps,.section>p{margin:12px 16px}.section>.group{margin:0;padding:12px 16px}.group h3{font-weight:650}
185details.row{margin:0;border-top:1px solid var(--line);padding:0}.row>summary{padding:11px 16px}.row>p,.row>.steps{margin:10px 16px 16px}.row .title{font-weight:600}.row .meta{flex-wrap:wrap;gap:6px}
186.ask{gap:8px;padding:8px 0}.chip{border:1px solid currentColor;border-radius:999px;padding:3px 8px;font-size:12px}.steps li{gap:4px 8px;padding:5px 8px;border-radius:8px}.steps .g{width:22px;height:22px;display:grid;place-items:center;font-size:inherit;cursor:default;user-select:none}.steps li{grid-template-columns:2ch 22px minmax(0,1fr)}.steps .now{background:var(--inset)}
187footer{font-size:12.5px;overflow-wrap:anywhere}code{background:var(--inset);border:1px solid var(--line);border-radius:8px}
188@media (max-width:760px){.dashboard-shell{grid-template-columns:minmax(0,1fr)}.dash-nav{position:static;display:flex;flex-wrap:wrap;gap:6px;padding:16px 0 0;max-height:none}.dash-nav h2{flex-basis:100%}.dash-nav button{background:var(--surface);border-color:var(--line)}main{padding-top:16px}}
189@media (max-width:600px){.counts{grid-template-columns:repeat(2,1fr)}.counts>span:nth-child(3){border-left:0}.counts>span:nth-child(n+3){border-top:1px solid var(--line)}.head .brand .meta{margin-left:0;flex-basis:100%}.head .brand{column-gap:8px}}
190</style>
191</head>
192<body>
193<header class="head" id="head"><div class="wide topbar"><div class="brand">${boardMark}<h1>${esc(s.paneTitle)}</h1><span class="meta">${esc(stamp)}</span></div>${themeSwitch(s)}</div></header>
194<div class="wide dashboard-shell"><nav class="dash-nav" aria-label="${esc(s.dashSections)}"><h2>${esc(s.dashSections)}</h2>${sections.filter(([, , count]) => count > 0).map(([id, title, count]) => `<button type="button" data-section="dash-${id}"><span>${esc(title)}</span>${id === 'idle' ? '' : `<span class="ct">${count}</span>`}</button>`).join('')}</nav>
195<main>
196${view.warning ? `<p role="status" style="color:var(--alert)">${esc(view.warning)}</p>` : ''}
197${view.pruned ? `<p role="status" class="quiet">${esc(view.pruned)}</p>` : ''}
198<section class="counts">${counts
199    .map(([glyph, n, word, role]) => `<span><span style="color:var(--${n > 0 ? role : 'muted'})">${glyph} ${n}</span> ${esc(word)}</span>`)
200    .join('')}</section>
201${view.focus === null && pending.some(turn => turn.isQuestion) ? '' : focus(view.focus, s)}
202${section(
203  'inbox',
204  s.boardInbox,
205  pending.length,
206  'accent',
207  '',
208  `<p class="quiet">${esc(s.dashInboxHint)}</p>` + pending.map(turn =>
209    `<div class="ask"><div class="where">${agentMark(turn.agent)}${esc([turn.project, turn.agent].filter(part => part !== '').join(' · '))}</div>` +
210    `<div><span style="color:var(--${turn.isQuestion ? 'waiting' : 'accent'})">${turn.isQuestion ? '●' : '◐'}</span> ${turn.isQuestion ? esc(clip(turn.text, 200)) : ''}</div></div>`,
211  ).join(''),
212  true,
213)}
214${section('waiting', s.sectionWaiting, view.waiting.length, 'waiting', '', view.waiting.map(item => waitingItem(item, s)).join(''), true)}
215${section('reminder', s.sectionQuestionReminders, view.reminder?.length ?? 0, 'muted', s.questionAgingHint, (view.reminder ?? []).map(item => waitingItem(item, s)).join(''), false)}
216${section('older', s.sectionOlderQuestions, view.older?.length ?? 0, 'muted', s.questionAgingHint, (view.older ?? []).map(item => waitingItem(item, s)).join(''), false)}
217${view.hidden.length === 0 ? '' : `<p class="quiet">${esc(s.hiddenCount(view.hidden.length))}</p>`}
218${view.idle.length === 0 ? '' : `<details class="section" data-key="section:idle"><summary><span class="quiet">${esc(s.idleCount(view.idle.length))}</span></summary>${view.idle.map(it => row(it, s, true)).join('')}</details>`}
219${view.stopped.length === 0 ? '' : `<details class="section" data-key="section:stopped"><summary><span class="quiet">${esc(s.stoppedCount(view.stopped.length))}</span></summary>${view.stopped.map(it => row(it, s, true)).join('')}</details>`}
220${section('due', s.sectionDue, view.due.length, 'soon', nearest === undefined ? '' : s.nearestDue(nearest.title, nearest.due?.left ?? ''), view.due.map(it => row(it, s, true)).join(''), false)}
221${section(
222  'others',
223  s.sectionActive,
224  view.active.length,
225  'accent',
226  view.groups.map(group => group.project).join(s.listSep),
227  view.groups
228    .map(group => `<div class="group"><h3>${esc(group.project)}<span>${esc(group.summary)}</span></h3>${group.rows.map(it => row(it, s, false)).join('')}</div>`)
229    .join(''),
230  true,
231)}
232${section(
233  'recent',
234  s.sectionRecent,
235  view.recent.length,
236  'done',
237  '',
238  `<ul class="steps">${view.recent.map(item => `<li><span></span><span style="color:var(--done)">✓</span><span class="quiet">${esc([item.project, item.title, item.when].filter(part => part !== '').join(' · '))}${(item.questions ?? []).map(question => `<br>${esc([question.text, question.note].filter(Boolean).join(' — '))}`).join('')}</span></li>`).join('')}</ul>`,
239  false,
240)}
241<footer><div class="legend">${LEGEND.map((status, index) => `<span><span style="color:var(--${roleOf(status)})">${GLYPH[status]}</span> ${esc(s.legendWords[index] ?? status)}</span>`).join('')}<span>${esc(s.legendDue)}</span></div>
242<div>${esc(s.legendCleanup)}</div><div>${esc(s.progressFileShown)}</div></footer>
243${(view.health?.length ?? 0) === 0 ? '' : `<details class="section sec" data-key="section:health"><summary><span class="title quiet">${esc(s.sectionHealth)}</span><span class="count" style="color:var(--muted)">${view.health!.length}</span></summary>${view.health!.map(line => `<p class="quiet">${esc(line)}</p>`).join('')}</details>`}
244</main></div>
245<script>
246(function () {
247  var key = 'progress-open';
248  var open = {};
249  try { open = JSON.parse(localStorage.getItem(key) || '{}'); } catch (e) {}
250  document.querySelectorAll('details[data-key]').forEach(function (el) {
251    var id = el.getAttribute('data-key');
252    if (id.indexOf('section:') === 0) el.id = 'dash-' + id.slice(8);
253    if (id in open) el.open = open[id];
254    el.addEventListener('toggle', function () {
255      open[id] = el.open;
256      try { localStorage.setItem(key, JSON.stringify(open)); } catch (e) {}
257    });
258  });
259  document.querySelectorAll('button[data-section]').forEach(function (button) {
260    button.addEventListener('click', function () {
261      var section = document.getElementById(button.getAttribute('data-section'));
262      if (section) { section.open = true; section.scrollIntoView({ block: 'start' }); }
263    });
264  });
265  if (typeof ResizeObserver === 'function') {
266    var head = document.getElementById('head');
267    new ResizeObserver(function () { document.documentElement.style.setProperty('--head-h', head.offsetHeight + 'px'); }).observe(head);
268  }
269  try { var y = sessionStorage.getItem('progress-y'); if (y) window.scrollTo(0, +y); } catch (e) {}
270  setTimeout(function () {
271    try { sessionStorage.setItem('progress-y', String(window.scrollY)); } catch (e) {}
272    location.reload();
273  }, 30000);
274})();
275</script>
276</body>
277</html>
278`
279}
280