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

<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.
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:
claude plugin marketplace add WenyuChiou/agent-board, then claude plugin install agent-board@agent-board./hooks (how).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.
| | 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.
| Where | Command |
|---|---|
| Any terminal | node ~/.agent-board/agent-board.mjs --watch (--html opens it in the browser) |
| A browser | node ~/.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 setup | node ~/.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:
| Key | Does |
|---|---|
| ↑ ↓, Enter | Pick a line, open it |
x | Tick the next step of your own task; it goes into the work log |
d | Confirm one of your own tasks, or a stopped session's work, done |
a | Archive one of your own tasks, or a stopped session's block |
1–9, y / n | Copy the answer to another agent's question, to paste in that agent |
c | Copy a command, or the command that reopens the session that asked |
h, u | Mark another agent's question already answered: hidden on this computer, nothing sent; show the hidden ones again |
i | Show or fold the tasks idle more than 7 days |
o, q | Open 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.
| | 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.
| Agent | Checked |
|---|---|
| Claude Code | Claude Code 2.1.301 in CI |
| Codex | 0.163.0-alpha.10.2 app-server (record); CLI in the 0.23.5 live run; App untested |
| Antigravity | CLI on Windows (record); the App opens the board but does not report a question yet (#285); IDE untested |
| OpenCode | Plugin SDK 1.19.3 (record) |
| Cursor | Untested |
| Grok Build | 1.0.62 with a mock model |
| Muse Code | Untested |
| Hermes Agent | 0.19.0 with a mock model |
| OpenClaw | Source contract and synthetic callbacks (record); app untested |
| DeepSeek Harness | Plugin loader (record); app untested |
| dot, other web chats | Untested |
"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).
--messages-dir), Send reaches a session that has a live connection; otherwise Copy gives you the reply to paste into its chat.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 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:
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.
<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 简体中文.
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 see | Try |
|---|---|
| An agent is not on the board | Start a new session of it; one that was already open is not connected. Codex also needs its hooks trusted in /hooks. |
| Nothing changed after installing | Run 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 running | A running session keeps the version it loaded: start a new one. To update, see the guide. |
| An agent shows up twice | It 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 away | Answer 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 |
| Read | For |
|---|---|
| This README | The first five minutes |
| Guide | Daily use, keys, the page, update and uninstall, FAQ, troubleshooting |
| Setup | What the installer writes for each agent |
| Codex · Messaging · File format · Local exchange · Remote reporters | Reference |
| AGENTS.md · Contributing · Security | Working on the code; how a non-Claude agent reports by hand |
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.
hooks/register.tsx 4259 lines1import { 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 lines1import { 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}
1122hooks/sha256.ts 54 lines1// 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}
54hooks/connect-worker.ts 69 lines1// 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}
69hooks/ledger-move.ts 25 lines1// 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`
25scripts/connect-transaction.ts 486 lines1import { 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}
486hooks/blocks.ts 733 lines1import 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}
733hooks/github.ts 106 lines1import 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}
106hooks/journal.ts 128 lines1import 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}
128hooks/mirror.ts 508 lines1import 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}
508hooks/parse.ts 356 lines1import 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}
356hooks/dashboard.ts 280 lines1import 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
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