Claude Code Desktop の入力欄の上に、このセッションをアーカイブしてよいか(Done / Check / Busy)を札で出し、押すと右側の枠に理由と操作を並べる

Claude Code Desktop(Code タブ)の入力欄の上に、そのセッションをアーカイブしてよいかを小さな札で出す mod(Claude Code のプラグイン)。札を押すと右側に枠が開き、理由と、その場でできる操作が並ぶ。
An unofficial Claude Code mod that shows, above the prompt in Claude Code Desktop, whether the session is safe to archive: Done, Check or Busy. Press the badge to open a pane on the right with the reasons and an action for each (mark a follow-up done, ask Claude to commit, push, schedule or start a suggested task, answer a waiting wrap-up question, ask Claude to tidy up the artifacts this session created). It also keeps "follow-ups" — checks that can only be done later — that Claude writes down, so you notice them before archiving.
Anthropic の公式のものではありません。
| 札(en / ja) | 意味 | 何で決まるか |
|---|---|---|
Busy / 作 | アーカイブすると作業を失う | 未コミットの変更 / 今のブランチ(HEAD)の、remote に無いコミット(HEAD がマージ済みの PR の head と同じなら除く)/ 未起動の提案カード(期限切れは除く)/ 動いている背景タスク(run_in_background の Bash・PowerShell と Monitor。終わりの知らせが届くか、TaskStop で止めるまで)/ 動いているエージェント |
Check / 注 | アーカイブしてよいが、先に確かめること | 後続 / このセッションで作った Artifact(片付けを頼むか残すまで)/ 開いた PR の CI が終わっていない / 返事待ち(最後の wrap_up の質問・AskUserQuestion・「新しいセッションか、ここでやるか」)/ タスク一覧の残り / 開いた PR / 材料が読めなかった |
Done / 済 | どれも無い |
guide の Markdown)が読めなかったとき、判定が途中で失敗したときは Done にせず Check にして、枠に何が読めなかったかを出す。git の管理外のフォルダは「失うものが無い」として扱う$.session.messages() には出ず、届いた時点でしか拾えない)札を押すと開き、もう一度押すと閉じる。「アーカイブすると失うもの」「アーカイブする前に確かめること」に分けて並べ、行ごとに操作を付ける。
| 行 | 操作 |
|---|---|
| 後続 | Mark done(mod だけで消す)/ Schedule(予定タスクにするよう Claude に頼む) |
| 作った Artifact | Ask to tidy up(要るかを調べて要らないものを消すよう Claude に頼む)/ Keep(mod だけで枠から外す)/ 1 件なら Open |
| 返事待ちの wrap_up の質問 | 質問の選択肢と同じ返事を送る(commit などアプリの操作は、同じことを Claude に頼む) |
| 未起動の提案カード | Ask to start(起動を Claude に頼む) |
| 未コミットの変更 / remote に無いコミット | Ask to commit / Ask to push |
| 開いた PR | Open(GitHub の PR を開く) |
$.prompt.submit)。Claude が動いている間に押すと、そのターンが終わってから届く「今やる作業は無いが、時間が経たないと結果が分からない確認」(3 日後に反応を見て値下げを決める / 数日使ってから効いたか確かめる など)。mod が登録するツール mcp__archive-check__followup で Claude が書き出し(add / done / list)、いつ書き出すかの指示を mod がシステムプロンプトに 1 節足す。後続はセッションごとに mod の store に置き、CLI が止まって開き直しても残る。
使い終わった claude.ai の Artifact(前後の比較・確認用のページなど)が、要るのか要らないのか分からないまま残り続けるのを、畳む前に拾うための行。 このセッションの Artifact ツールの公開(type_url から作ったものを含む)を会話の記録から拾う。前の会話の Artifact の更新(url を渡した公開)と 資産の上げ下ろしは数えず、このセッションで消したものは外す。読むのはメインの会話の新しい 4096 件までで、エージェントが公開したものは数えない。
delete)。Ask to tidy up は、作った Artifact の URL を並べて「artifact-sweep で調べて、要らないものを消して」と頼む。artifact-sweep は作者の手元の skill で、このリポジトリには入っていない。 無ければ、それぞれがどこから張られているかを調べ、消す前に聞くよう頼む文になっているKeep を押した分は、このセッションの枠に出さなくなる。あとで同じセッションで作った分は、また出る| 項目 | 既定 | 中身 |
|---|---|---|
language | en | en は英語(Done / Check / Busy)、ja は日本語(済 / 注 / 作)。画面の文言・ボタンが送る依頼文・Claude への既定の指示が切り替わる |
guide | 空 | Claude に渡す「後続をいつ・どう書き出すか」の指示を、この Markdown ファイルの中身に差し替える(~ はホーム)。自分の決まり(予定タスクの作り方など)を書く場所。読めないとき・中身が空のときは既定の指示を使い、枠に「読めなかった」を出す(その間、札は Check になる) |
claude plugin install archive-check --marketplace tanmen/claude-tanmen-plugins --config language=ja --config guide=~/archive-check.md
入れたあとに変える(名前は archive-check@tanmen の形で渡す。変えたあとは Claude Code を起動し直すと効く):
echo '{"language":"ja"}' | claude plugin configure archive-check@tanmen --values-stdin
%APPDATA%\Claude\claude-code-sessions\…\<セッション>.json、macOS は ~/Library/Application Support/Claude/claude-code-sessions/…)。このセッションの分だけ。アプリの内部のファイルで、形が変わると読めなくなる~/.claude/sessions/ の各 json(使うのはこの CLI の起動時刻だけ)git rev-parse / git status / git rev-list、このセッションの会話(ツールの呼び出しと結果を含む)とエージェントの一覧、設定 guide の Markdownprompt.submit の origin が task-notification のものの本文(背景タスクやエージェントの終わりのほか、Artifact のコメントや check-in など、Claude Code が差し込む知らせも同じ origin で届く)。拾うのは <task-notification> の中の task-id と status だけで、本文は残さない。ほかの origin のプロンプトは読まず、どれも止めも書き換えもしないgh pr view で引く。このセッションの記録に載った開いている PR ごとに、同じ PR は 3 分に 1 回まで(判定は 1 分ごととツールの呼び出しのたび)。今のブランチに remote に無いコミットがあるときは、マージ済みの PR も引く。資格情報は gh が持ったままで、mod には渡らないlist を呼ぶと読める)gh(ログイン済み)usage-bars と一緒に入れると、札がバーの左に並ぶ(usage-bars 0.4.1 以降。どちらが先に読み込まれても同じ並びになる)。usage-bars のバーを押して詳細を開いている間だけは、読み込みの順で札がバーの横か詳細の横に付く。
claude plugin uninstall archive-check@tanmen
claude plugin validate .
claude plugin test .
作業中の mod は claude --plugin-dir . で読み込める。型チェックは usage-bars の README の「開発」と同じ(一度 --plugin-dir で起動すると .claude-plugin/types/ が置かれ、tsc -p . が通る)。
MIT
hooks/register.tsx 643 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { BackgroundTask, FollowUp, Reason, ReasonAction, Report, Verdict } from '../types'
5import {
6 ARTIFACT_PREFIX,
7 artifactReasons,
8 askReasons,
9 cardReasons,
10 endedInNotification,
11 endedTaskIds,
12 expandHome,
13 FOLLOWUP_PREFIX,
14 followupReasons,
15 isFollowUp,
16 parsePrStatus,
17 pendingAsks,
18 prHref,
19 publishedArtifacts,
20 recordPrs,
21 replayPlan,
22 sortReasons,
23 verdictOf,
24} from './logic'
25import type { MessageLite, PrStatus, RecordPr, SessionRecord } from './logic'
26import { langOf, MESSAGES } from './messages'
27import type { Messages } from './messages'
28
29const report = atom({ plugin: 'archive-check', key: 'report' } as const, null)
30const tasks = atom({ plugin: 'archive-check', key: 'tasks' } as const, [])
31const ended = atom({ plugin: 'archive-check', key: 'ended' } as const, [])
32
33const PANE = 'archive-check'
34// hook の matcher は、このファイルの中の文字列でないと validate が読めない(logic.ts の同名の値と同じ)
35const FOLLOWUP_TOOL = 'mcp__archive-check__followup'
36
37// 設定(register が読み込むたびに入れ直す)
38let m: Messages = MESSAGES.en
39let guideSetting = ''
40
41// SVG は画像として描かれ、アプリのテーマに追従しない。usage-bars と同じ、Desktop のダークテーマに合わせた色
42const TRACK = '#2C2C2A'
43const TONES: Record<Verdict, { fill: string; text: string }> = {
44 ok: { fill: '#1D9E75', text: '#5DCAA5' },
45 caution: { fill: '#BA7517', text: '#EF9F27' },
46 blocked: { fill: '#E24B4A', text: '#F09595' },
47}
48const FONT = "'Segoe UI', 'Yu Gothic UI', 'Hiragino Sans', system-ui, sans-serif"
49const HEIGHT = 19
50
51// PR の状態(gh)はこれより新しければ読み直さない
52const PR_CACHE_MS = 3 * 60_000
53// 枠のボタンの依頼が入ったかを待つ長さ(hook の持ち時間 10 秒より短く)
54const SUBMIT_WAIT_MS = 8_000
55
56function badgeSvg(verdict: Verdict): string {
57 const tone = TONES[verdict]
58 return [
59 `<svg xmlns="http://www.w3.org/2000/svg" width="${m.badgeWidth}" height="${HEIGHT}">`,
60 `<rect width="100%" height="${HEIGHT}" rx="6" fill="${TRACK}"/>`,
61 `<rect width="100%" height="${HEIGHT}" rx="6" fill="${tone.fill}" fill-opacity="0.22"/>`,
62 `<text x="50%" y="14" text-anchor="middle" font-family="${FONT}" font-size="12" font-weight="600" fill="${tone.text}">${m.badge[verdict]}</text>`,
63 `</svg>`,
64 ].join('')
65}
66
67function unread(what: string): Reason {
68 return { level: 'caution', kind: 'unread', text: m.unread(what) }
69}
70
71function asString(value: unknown): string {
72 return typeof value === 'string' ? value.trim() : ''
73}
74
75// ---- 置き場(Windows は %APPDATA%、macOS は ~/Library/Application Support)
76
77type Places = { home: string; sep: string; recordRoot: string | null }
78
79async function places($: EngineInterface): Promise<Places> {
80 const appData = await $.env.get('APPDATA')
81 const home = (await $.env.get('USERPROFILE')) || (await $.env.get('HOME')) || ''
82 if (appData) return { home, sep: '\\', recordRoot: `${appData}\\Claude\\claude-code-sessions` }
83 return { home, sep: '/', recordRoot: home ? `${home}/Library/Application Support/Claude/claude-code-sessions` : null }
84}
85
86// ---- Claude への指示(設定 guide の Markdown があればそれ、無ければ既定の文)
87
88async function readGuide($: EngineInterface): Promise<string | null> {
89 if (guideSetting === '') return null
90 const { home } = await places($)
91 try {
92 const text = await $.fs.read(expandHome(guideSetting, home))
93 return text.trim() === '' ? null : text
94 } catch {
95 return null
96 }
97}
98
99// ---- 後続($.store にセッションごとに置く。CLI が止まって開き直しても残る。全セッション共通)
100
101async function loadFollowupsOf($: EngineInterface, cliId: string): Promise<FollowUp[]> {
102 const value = await $.store.get(`${FOLLOWUP_PREFIX}${cliId}`)
103 return Array.isArray(value) ? value.filter(isFollowUp) : []
104}
105
106async function saveFollowupsOf($: EngineInterface, cliId: string, list: FollowUp[]): Promise<void> {
107 const key = `${FOLLOWUP_PREFIX}${cliId}`
108 if (list.length === 0) await $.store.delete(key)
109 else await $.store.set(key, list)
110}
111
112async function removeFollowupOf($: EngineInterface, cliId: string, id: string): Promise<FollowUp | null> {
113 const list = await loadFollowupsOf($, cliId)
114 const found = list.find(f => f.id === id) ?? null
115 if (found !== null) await saveFollowupsOf($, cliId, list.filter(f => f.id !== id))
116 return found
117}
118
119// ---- このセッションで公開した Artifact のうち、もう枠に出さないもの(「残す」か「片付けを頼む」を押した)。id の並びを置く
120async function loadHandledArtifacts($: EngineInterface, cliId: string): Promise<string[]> {
121 const value = await $.store.get(`${ARTIFACT_PREFIX}${cliId}`)
122 return Array.isArray(value) ? value.filter((v): v is string => typeof v === 'string') : []
123}
124
125async function markArtifactsHandled($: EngineInterface, cliId: string, ids: readonly string[]): Promise<void> {
126 const prev = await loadHandledArtifacts($, cliId)
127 await $.store.set(`${ARTIFACT_PREFIX}${cliId}`, [...new Set([...prev, ...ids])])
128}
129
130// 枠のボタンから Claude に頼む。届いたかを $.store の lastSubmit:<CLI のセッション id> に残す(道具の list で読める)。
131// 依頼文の本文は残さない(store は全セッション共通)。onSubmitted は依頼が入ったときだけ呼ぶ(Claude が動いている間に押すと、あとで呼ばれる)
132async function submitPrompt($: EngineInterface, action: ReasonAction, onSubmitted?: () => Promise<void>): Promise<void> {
133 const at = await $.clock.now()
134 const key = `lastSubmit:${await $.session.id()}`
135 const record = (result: string, detail: string) =>
136 $.store.set(key, { at, label: action.label, result, ...(detail ? { detail: detail.slice(0, 300) } : {}) }).catch(() => undefined)
137 try {
138 const submitting = $.prompt.submit({ text: action.prompt, asUser: true })
139 void submitting.then(
140 async done => {
141 if (done.drop !== undefined) return record('dropped', String(done.drop))
142 await record('submitted', '')
143 if (onSubmitted) await onSubmitted().catch(() => undefined)
144 },
145 err => record('error', String(err)),
146 )
147 const settled = await Promise.race([
148 submitting.then(done => ('drop' in done && done.drop !== undefined ? 'dropped' : 'submitted')),
149 $.clock.sleep(SUBMIT_WAIT_MS).then(() => 'waiting'),
150 ])
151 if (settled === 'waiting') await record('waiting', '')
152 $.ui.toast(settled === 'dropped' ? m.notSent(action.label) : m.asked(action.label))
153 } catch (err) {
154 await record('error', String(err))
155 $.ui.toast(m.notSent(action.label, String(err).slice(0, 120)))
156 }
157}
158
159async function handleFollowupTool($: EngineInterface, input: Record<string, unknown>): Promise<{ result: string }> {
160 const action = asString(input.action)
161 const cliId = await $.session.id()
162 const list = await loadFollowupsOf($, cliId)
163 if (action === 'add') {
164 const what = asString(input.what)
165 if (what === '') return { result: JSON.stringify({ error: 'what is empty' }) }
166 const now = await $.clock.now()
167 const dueAt = asString(input.due_at)
168 const followup: FollowUp = {
169 id: `f${now.toString(36)}${Math.floor(Math.random() * 1296).toString(36)}`,
170 what,
171 when: asString(input.when),
172 how: asString(input.how),
173 ...(dueAt ? { dueAt } : {}),
174 createdAt: now,
175 }
176 list.push(followup)
177 await saveFollowupsOf($, cliId, list)
178 evaluateSoon($, 0)
179 return { result: JSON.stringify({ added: followup, followups: list }) }
180 }
181 if (action === 'done') {
182 const id = asString(input.id)
183 const removed = await removeFollowupOf($, cliId, id)
184 evaluateSoon($, 0)
185 return { result: JSON.stringify(removed === null ? { error: `no follow-up with id ${id}`, followups: list } : { removed, followups: list.filter(f => f.id !== id) }) }
186 }
187 return { result: JSON.stringify({ followups: list, lastSubmit: await $.store.get(`lastSubmit:${cliId}`) }) }
188}
189
190// ---- この CLI のプロセスで起きた背景タスク
191
192async function trackTask($: EngineInterface, e: Record<string, unknown>, result: unknown): Promise<void> {
193 const tool = String(e.tool)
194 const record = (typeof result === 'object' && result !== null ? (result as { result?: unknown }).result : undefined) as
195 | Record<string, unknown>
196 | undefined
197 let task: BackgroundTask | null = null
198 const now = await $.clock.now()
199 if ((tool === 'Bash' || tool === 'PowerShell') && typeof record?.backgroundTaskId === 'string') {
200 const label = asString(e.description) || asString(e.command).slice(0, 80)
201 task = { id: record.backgroundTaskId, kind: 'shell', label, startedAt: now }
202 } else if (tool === 'Monitor' && typeof record?.taskId === 'string') {
203 task = { id: record.taskId, kind: 'monitor', label: asString(e.description), startedAt: now }
204 }
205 if (task !== null) {
206 const added = task
207 await update($, tasks, prev => [...(prev ?? []).filter(t => t.id !== added.id), added].slice(-50))
208 }
209}
210
211// 背景タスクの知らせ(<task-notification>)から、終わったタスクの id を残す。
212// ターン中に届いた知らせは会話の行にならず(添付 queued_command)、$.session.messages() からは読めない
213async function recordEnded($: EngineInterface, text: string): Promise<void> {
214 const ids = endedInNotification(text)
215 if (ids.length === 0) return
216 await update($, ended, prev => [...new Set([...(prev ?? []), ...ids])].slice(-200))
217 evaluateSoon($, 0)
218}
219
220// ---- Desktop の記録(アプリの状態ファイル。読むだけ)
221
222let recordPath: string | null = null
223
224async function findRecordPath($: EngineInterface): Promise<string | null> {
225 if (recordPath !== null) return recordPath
226 const hostId = await $.env.get('CLAUDE_CODE_HOST_SESSION_ID')
227 const { sep, recordRoot } = await places($)
228 if (!hostId || recordRoot === null) return null
229 for (const account of await $.fs.list(recordRoot)) {
230 if (account.kind !== 'dir') continue
231 for (const profile of await $.fs.list(`${recordRoot}${sep}${account.name}`)) {
232 if (profile.kind !== 'dir') continue
233 const path = `${recordRoot}${sep}${account.name}${sep}${profile.name}${sep}${hostId}.json`
234 if (await $.fs.exists(path)) {
235 recordPath = path
236 return path
237 }
238 }
239 }
240 return null
241}
242
243async function readRecord($: EngineInterface): Promise<SessionRecord | null> {
244 try {
245 const path = await findRecordPath($)
246 if (path === null) return null
247 const body: unknown = JSON.parse(await $.fs.read(path))
248 return typeof body === 'object' && body !== null ? (body as SessionRecord) : null
249 } catch {
250 return null
251 }
252}
253
254// この CLI が起きた時刻。~/.claude/sessions/<pid>.json の startedAt(背景タスクの記録から、前のプロセスの分を外す)
255let processStartedAt: number | null = null
256
257async function readProcessStartedAt($: EngineInterface, cliId: string): Promise<number | null> {
258 if (processStartedAt !== null) return processStartedAt
259 const { home, sep } = await places($)
260 if (home === '') return null
261 const dir = `${home}${sep}.claude${sep}sessions`
262 for (const entry of await $.fs.list(dir)) {
263 if (entry.kind !== 'file' || !entry.name.endsWith('.json')) continue
264 try {
265 const body = JSON.parse(await $.fs.read(`${dir}${sep}${entry.name}`)) as { sessionId?: unknown; startedAt?: unknown }
266 if (body.sessionId === cliId && typeof body.startedAt === 'number') {
267 processStartedAt = body.startedAt
268 return processStartedAt
269 }
270 } catch {
271 // 書きかけの記録は飛ばす
272 }
273 }
274 return null
275}
276
277// ---- PR(gh)
278
279const prCache = new Map<string, { at: number; status: PrStatus | null }>()
280
281async function prStatus($: EngineInterface, pr: RecordPr, now: number): Promise<PrStatus | null> {
282 const key = `${pr.repo}#${pr.number}`
283 const cached = prCache.get(key)
284 if (cached !== undefined && now - cached.at < PR_CACHE_MS) return cached.status
285 let status: PrStatus | null = null
286 try {
287 const run = await $.process.run(
288 ['gh', 'pr', 'view', String(pr.number), '--repo', pr.repo, '--json', 'state,headRefOid,statusCheckRollup'],
289 { timeoutMs: 20_000 },
290 )
291 status = run.exitCode === 0 ? parsePrStatus(run.stdout) : null
292 } catch {
293 status = null
294 }
295 prCache.set(key, { at: now, status })
296 return status
297}
298
299// ---- 作業ツリー(未コミット・remote に無いコミット)
300
301async function workTreeReasons(
302 $: EngineInterface,
303 cwd: string,
304 mergedHeads: () => Promise<string[] | null>,
305): Promise<Reason[]> {
306 // 失敗(時間切れ・起動できない)は null。どれも「読めなかった」にして、Done にしない
307 const git = (args: string[]) =>
308 $.process.run(['git', '-C', cwd, ...args], { env: { LC_ALL: 'C', LANG: 'C' } }).catch(() => null)
309
310 const top = await git(['rev-parse', '--show-toplevel'])
311 if (top === null) return [unread(m.unreadWhat.git)]
312 if (top.exitCode !== 0) {
313 // git の管理外のフォルダなら、失う変更もコミットも無い。それ以外の失敗(所有者の不一致など)は読めなかった扱い
314 return /not a git repository/i.test(top.stderr) ? [] : [unread(m.unreadWhat.git)]
315 }
316
317 const reasons: Reason[] = []
318 const status = await git(['status', '--porcelain'])
319 if (status === null || status.exitCode !== 0) {
320 reasons.push(unread(m.unreadWhat.dirty))
321 } else {
322 const dirty = status.stdout.split('\n').filter(line => line.trim() !== '').length
323 if (dirty > 0) {
324 reasons.push({ level: 'blocked', kind: 'dirty', text: m.dirty(dirty), actions: [{ label: m.commitLabel, prompt: m.commitPrompt }] })
325 }
326 }
327
328 const count = await git(['rev-list', '--count', 'HEAD', '--not', '--remotes'])
329 const unpushed = count !== null && count.exitCode === 0 ? Number.parseInt(count.stdout.trim(), 10) : Number.NaN
330 if (Number.isNaN(unpushed)) {
331 reasons.push(unread(m.unreadWhat.unpushed))
332 } else if (unpushed > 0) {
333 // squash マージ済みで、HEAD がマージ済みの PR の head と同じなら失うものは無い
334 const head = (await git(['rev-parse', 'HEAD']))?.stdout.trim() ?? ''
335 const heads = await mergedHeads()
336 if (heads === null) reasons.push(unread(m.unreadWhat.mergedPrs))
337 if (!(heads ?? []).includes(head)) {
338 reasons.push({ level: 'blocked', kind: 'unpushed', text: m.unpushed(unpushed), actions: [{ label: m.pushLabel, prompt: m.pushPrompt }] })
339 }
340 }
341 return reasons
342}
343
344// ---- 判定
345
346async function evaluate($: EngineInterface): Promise<Report> {
347 const now = await $.clock.now()
348 const cwd = await $.session.cwd()
349 const cliId = await $.session.id()
350 const reasons: Reason[] = []
351
352 const record = await readRecord($)
353 if (record === null) reasons.push(unread(m.unreadWhat.record))
354 const prs = record === null ? [] : recordPrs(record)
355 if (record !== null) reasons.push(...cardReasons(record, m, now))
356
357 // 開いている PR と CI。状態は gh で引き直し、読めなければ記録の値で判断する
358 let isPrUnread = false
359 for (const pr of prs.filter(p => p.state === 'OPEN')) {
360 const status = await prStatus($, pr, now)
361 if (status === null) isPrUnread = true
362 const state = status?.state || pr.state
363 if (state !== 'OPEN') continue
364 const name = `${pr.repo}#${pr.number}`
365 const href = prHref(pr.repo, pr.number)
366 reasons.push({ level: 'caution', kind: 'pr', text: m.openPr(name), href })
367 if (status?.isCiPending) reasons.push({ level: 'caution', kind: 'ci', text: m.ciPending(name), href })
368 }
369
370 const mergedHeads = async (): Promise<string[] | null> => {
371 const heads: string[] = []
372 for (const pr of prs.filter(p => p.state === 'MERGED')) {
373 const status = await prStatus($, pr, now)
374 if (status === null) return null
375 heads.push(status.headRefOid)
376 }
377 return heads
378 }
379 reasons.push(...(await workTreeReasons($, cwd, mergedHeads)))
380 if (isPrUnread) reasons.push(unread(m.unreadWhat.prState))
381
382 let messages: MessageLite[] = []
383 try {
384 messages = await $.session.messages()
385 } catch {
386 reasons.push(unread(m.unreadWhat.messages))
387 }
388
389 // 背景タスク: この CLI のプロセスで起きて、まだ終わりの知らせが来ていないもの。
390 // 終わりは届いた知らせ(ターン中のものを含む)・会話の行の知らせ・TaskStop から読む
391 const endedIds = endedTaskIds(messages)
392 for (const id of (await read($, ended)) ?? []) endedIds.add(id)
393 const startedAt = await readProcessStartedAt($, cliId).catch(() => null)
394 for (const task of (await read($, tasks)) ?? []) {
395 if (endedIds.has(task.id) || (startedAt !== null && task.startedAt < startedAt)) continue
396 reasons.push({ level: 'blocked', kind: 'task', text: m.backgroundTask(task.label || task.id) })
397 }
398 try {
399 for (const agent of await $.agent.list()) {
400 if (agent.status === 'running') {
401 reasons.push({ level: 'blocked', kind: 'task', text: m.agent(agent.description || agent.type) })
402 }
403 }
404 } catch {
405 reasons.push(unread(m.unreadWhat.agents))
406 }
407
408 reasons.push(...followupReasons(await loadFollowupsOf($, cliId), m))
409 reasons.push(...artifactReasons(publishedArtifacts(messages), await loadHandledArtifacts($, cliId), m))
410 reasons.push(...askReasons(pendingAsks(messages, m)))
411 const plan = replayPlan(messages)
412 if (plan.left > 0) reasons.push({ level: 'caution', kind: 'plan', text: m.planLeft(plan.left, plan.next) })
413 if (guideSetting !== '' && (await readGuide($)) === null) reasons.push(unread(m.unreadWhat.guide))
414
415 const sorted = sortReasons(reasons)
416 return { verdict: verdictOf(sorted), reasons: sorted, checkedAt: now }
417}
418
419let isEvaluating = false
420let isQueued = false
421let isScheduled = false
422
423async function runEvaluate($: EngineInterface): Promise<void> {
424 if (isEvaluating) {
425 isQueued = true
426 return
427 }
428 isEvaluating = true
429 try {
430 // 判定ごと落ちたら、前の札を残さず「読めなかった」の Check にする
431 const next = await evaluate($).catch(async (): Promise<Report> => ({
432 verdict: 'caution',
433 reasons: [unread(m.unreadWhat.evaluate)],
434 checkedAt: await $.clock.now(),
435 }))
436 const prev = await read($, report)
437 const same =
438 prev !== null && prev.verdict === next.verdict && JSON.stringify(prev.reasons) === JSON.stringify(next.reasons)
439 if (!same) await update($, report, () => next)
440 } finally {
441 isEvaluating = false
442 if (isQueued) {
443 isQueued = false
444 evaluateSoon($, 1_000)
445 }
446 }
447}
448
449function evaluateSoon($: EngineInterface, delayMs: number): void {
450 if (isScheduled) return
451 isScheduled = true
452 void $.clock.after(delayMs, () => {
453 isScheduled = false
454 void runEvaluate($).catch(() => undefined)
455 })
456}
457
458// 帯を押すたびに枠を開く / 閉じる
459async function togglePane($: EngineInterface): Promise<void> {
460 const pane = (await $.ui.panes()).find(p => p.id === PANE)
461 if (pane !== undefined && pane.isShown) {
462 await $.ui.close({ id: PANE })
463 return
464 }
465 evaluateSoon($, 0)
466 const opened = await $.ui.open({ id: PANE, title: m.paneTitle })
467 if (!opened.isPlaced) $.ui.toast(m.paneNotPlaced(opened.reason))
468}
469
470export const register: Register = (on, options) => {
471 m = MESSAGES[langOf(options.language)]
472 guideSetting = typeof options.guide === 'string' ? options.guide.trim() : ''
473
474 on('session.start', async ($, e, next) => {
475 await $.tool
476 .register({
477 name: 'followup',
478 description: m.toolDescription,
479 isDeferred: false,
480 inputSchema: {
481 type: 'object',
482 properties: {
483 action: { type: 'string', enum: ['add', 'done', 'list'] },
484 what: { type: 'string', description: m.toolFields.what },
485 when: { type: 'string', description: m.toolFields.when },
486 how: { type: 'string', description: m.toolFields.how },
487 due_at: { type: 'string', description: m.toolFields.dueAt },
488 id: { type: 'string', description: m.toolFields.id },
489 },
490 required: ['action'],
491 },
492 })
493 .catch(() => undefined)
494 evaluateSoon($, 0)
495 $.clock.every(60_000, () => evaluateSoon($, 0))
496 return next(e)
497 })
498
499 on('tool.call', { tool: FOLLOWUP_TOOL }, ($, e) => handleFollowupTool($, e as unknown as Record<string, unknown>))
500
501 on('tool.call', async ($, e, next) => {
502 const result = await next(e)
503 void trackTask($, e as unknown as Record<string, unknown>, result).catch(() => undefined)
504 evaluateSoon($, 2_000)
505 return result
506 })
507
508 on('turn.complete', ($, e, next) => {
509 evaluateSoon($, 500)
510 return next(e)
511 })
512
513 // 背景タスクの知らせ。idle のときに取り出されたものも、走っているターンに届いたもの(turnId あり)もここを通る。
514 // 知らせは止めも書き換えもしない(失敗しても素通しする)
515 on('prompt.submit', ($, e, next) => {
516 if (e.origin.kind === 'task-notification') void recordEnded($, e.text).catch(() => undefined)
517 return next(e)
518 }).catch(($, e, next) => next(e))
519
520 // 後続を書き出す基準を Claude に渡す。設定 guide の Markdown があればそれ、無ければ既定の文
521 on('prompt.compose', async ($, e, next) => {
522 const composed = await next(e)
523 if (!e.tools.includes(FOLLOWUP_TOOL)) return composed
524 const text = (await readGuide($)) ?? m.guide(FOLLOWUP_TOOL)
525 return { sections: [...composed.sections, { id: 'archive-check:followups', text, scope: 'session' as const }] }
526 })
527
528 // 帯に重ねた hit.ts が、押されたことを知らせてくる
529 on('ui.message', async ($, e, next) => {
530 const data = e.data as { source?: unknown } | null
531 if (typeof data !== 'object' || data === null || data.source !== 'archive-check') return next(e)
532 await togglePane($)
533 return next(e)
534 })
535
536 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
537 // ターミナルには描かない(Desktop の帯だけ)
538 if (e.surface !== 'desktop' || e.props.hasSurvey) return next(e)
539 const below = await next(e)
540 const current = await read($, report)
541 if (current === null) return below
542
543 const { Box, Client, Svg } = $.ui.resolve(e)
544
545 // 札。下の mod(usage-bars など)の木があれば、その左に並べる
546 const badge = (
547 <Box position="relative" flexShrink={0}>
548 <Svg source={badgeSvg(current.verdict)} alt={m.alt[current.verdict]} width={m.badgeWidth} height={HEIGHT} />
549 <Box position="absolute" top={0} left={0} right={0} bottom={0}>
550 <Client key="hit-archive-check" module="./hit.ts" props={{}} width="100%" height="100%" />
551 </Box>
552 </Box>
553 )
554 const hasBelow = (below as { type?: unknown }).type !== 'engine'
555 if (!hasBelow) return badge
556 return (
557 <Box flexDirection="row" gap={1}>
558 {badge}
559 <Box flexDirection="column" flexGrow={1} flexShrink={1}>
560 {below}
561 </Box>
562 </Box>
563 )
564 })
565
566 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
567 const { Box, Button, Link, Text } = $.ui.resolve(e)
568 const current = await read($, report)
569 const cliId = await $.session.id()
570
571 if (current === null) return <Text dimColor>{m.checking}</Text>
572
573 const tag = (r: Reason) => (r.kind === 'followup' || r.kind === 'ci' ? m.tagFollowup : r.kind === 'ask' ? m.tagAsk : null)
574 const row = (r: Reason, i: number) => (
575 <Box flexDirection="row" justifyContent="space-between" gap={2}>
576 <Box flexDirection="column" flexShrink={1}>
577 <Box flexDirection="row" gap={1}>
578 {tag(r) ? <Text dimColor>{tag(r)}</Text> : null}
579 <Text>{r.text}</Text>
580 </Box>
581 {r.note ? <Text dimColor>{r.note}</Text> : null}
582 </Box>
583 <Box flexDirection="row" gap={1} flexShrink={0}>
584 {r.href ? <Link href={r.href} label={m.open} /> : null}
585 {r.followupId ? (
586 <Button
587 key={`done:${r.followupId}`}
588 label={m.markDone}
589 onPress={async () => {
590 await removeFollowupOf($, cliId, r.followupId ?? '')
591 await runEvaluate($)
592 }}
593 />
594 ) : null}
595 {(r.actions ?? []).map((a, j) => (
596 <Button
597 key={`act:${i}:${j}`}
598 label={a.label}
599 onPress={() =>
600 // Artifact の片付けを頼んだら、その分は枠から外す(消すかどうかは Claude と本人が決める)
601 submitPrompt($, a, r.artifactIds ? () => markArtifactsHandled($, cliId, r.artifactIds ?? []).then(() => runEvaluate($)) : undefined)
602 }
603 />
604 ))}
605 {r.artifactIds ? (
606 <Button
607 key="keep:artifacts"
608 label={m.keepLabel}
609 onPress={async () => {
610 await markArtifactsHandled($, cliId, r.artifactIds ?? [])
611 await runEvaluate($)
612 }}
613 />
614 ) : null}
615 </Box>
616 </Box>
617 )
618
619 const blocked = current.reasons.filter(r => r.level === 'blocked')
620 const caution = current.reasons.filter(r => r.level === 'caution')
621 const section = (title: string, color: 'error' | 'warning', list: Reason[], offset: number) =>
622 list.length === 0 ? null : (
623 <Box flexDirection="column">
624 <Text bold color={color}>
625 {title}
626 </Text>
627 {list.map((r, i) => row(r, offset + i))}
628 </Box>
629 )
630
631 return (
632 <Box flexDirection="column" gap={1}>
633 <Text bold color={current.verdict === 'ok' ? 'success' : current.verdict === 'caution' ? 'warning' : 'error'}>
634 {m.verdict[current.verdict]}
635 </Text>
636 {current.reasons.length === 0 ? <Text dimColor>{m.nothingLeft}</Text> : null}
637 {section(m.sectionBlocked, 'error', blocked, 0)}
638 {section(m.sectionCaution, 'warning', caution, blocked.length)}
639 </Box>
640 )
641 })
642}
643hooks/logic.ts 306 lines1// $ を使わない判定。register.tsx が集めた材料から理由を組み立てる(テストはここを直接呼ぶ)
2import type { FollowUp, Reason, ReasonAction, Verdict } from '../types'
3import type { Messages } from './messages'
4
5export type ToolUseLite = { tool: string; input: Record<string, unknown>; text?: string; result?: unknown; isError?: true }
6export type MessageLite = { role: 'user' | 'assistant'; text: string; toolUses: readonly ToolUseLite[] }
7
8export const FOLLOWUP_TOOL = 'mcp__archive-check__followup'
9
10const UPDATE_PLAN = 'mcp__ccd_turn__update_plan'
11const WRAP_UP = 'mcp__ccd_turn__wrap_up'
12const START_SESSION = 'mcp__ccd_session__start_session'
13
14type Step = { content: string; status: string }
15
16// タスク一覧(update_plan)を頭から再生して、残りの手順を数える。wrap_up は進行中の手順を済ませる
17export function replayPlan(messages: readonly MessageLite[]): { left: number; next: string | null } {
18 let steps: Step[] = []
19 for (const m of messages) {
20 for (const u of m.toolUses) {
21 if (u.tool === UPDATE_PLAN) {
22 const input = u.input
23 if (Array.isArray(input.items)) {
24 steps = input.items.map(item => {
25 const i = (typeof item === 'object' && item !== null ? item : {}) as { content?: unknown; status?: unknown }
26 return { content: String(i.content ?? ''), status: String(i.status ?? 'pending') }
27 })
28 }
29 if (Array.isArray(input.done)) {
30 for (const n of input.done) {
31 const step = typeof n === 'number' ? steps[n - 1] : undefined
32 if (step) step.status = 'completed'
33 }
34 }
35 if (input.advance === true) {
36 const current = steps.find(s => s.status === 'in_progress')
37 if (current) current.status = 'completed'
38 const pending = steps.find(s => s.status === 'pending')
39 if (pending) pending.status = 'in_progress'
40 }
41 const start = typeof input.start === 'number' ? steps[input.start - 1] : undefined
42 if (start) start.status = 'in_progress'
43 } else if (u.tool === WRAP_UP) {
44 for (const s of steps) if (s.status === 'in_progress') s.status = 'completed'
45 }
46 }
47 }
48 const left = steps.filter(s => s.status !== 'completed')
49 return { left: left.length, next: left[0]?.content ?? null }
50}
51
52// wrap_up のボタンのうち、アプリの操作(commit など)は mod から押せないので、同じことを Claude に頼む
53function wrapActions(input: Record<string, unknown>, m: Messages): ReasonAction[] {
54 const actions: ReasonAction[] = []
55 for (const raw of Array.isArray(input.actions) ? input.actions : []) {
56 const a = (typeof raw === 'object' && raw !== null ? raw : {}) as { label?: unknown; message?: unknown; action?: unknown }
57 if (typeof a.action === 'string') {
58 const app = m.appActions[a.action]
59 if (app) actions.push(app)
60 continue
61 }
62 const label = typeof a.label === 'string' ? a.label.trim() : ''
63 const message = typeof a.message === 'string' && a.message.trim() !== '' ? a.message.trim() : label
64 if (label !== '') actions.push({ label, prompt: message })
65 }
66 return actions
67}
68
69export type PendingAsk = { text: string; actions: ReasonAction[] }
70
71// 本人の返事を待っているもの: 最後の wrap_up の ask(そのあと本人も Claude も何も書いていない)・
72// 答えを待っている AskUserQuestion・「新しいセッションか、ここでやるか」の offer
73export function pendingAsks(messages: readonly MessageLite[], m: Messages): PendingAsk[] {
74 const asks: PendingAsk[] = []
75 let lastWrap: { index: number; ask: string; actions: ReasonAction[] } | null = null
76 messages.forEach((msg, index) => {
77 for (const u of msg.toolUses) {
78 if (u.tool === WRAP_UP) {
79 lastWrap = { index, ask: typeof u.input.ask === 'string' ? u.input.ask.trim() : '', actions: wrapActions(u.input, m) }
80 } else if (u.tool === 'AskUserQuestion' && u.text === undefined) {
81 asks.push({ text: m.askQuestion, actions: [] })
82 } else if (u.tool === START_SESSION && u.input.initiation === 'offer' && u.text === undefined) {
83 asks.push({ text: m.askOffer(typeof u.input.title === 'string' ? u.input.title : ''), actions: [] })
84 }
85 }
86 })
87 const wrap = lastWrap as { index: number; ask: string; actions: ReasonAction[] } | null
88 if (wrap !== null && wrap.ask !== '') {
89 const isAnswered = messages
90 .slice(wrap.index + 1)
91 .some(msg => msg.role === 'assistant' || msg.text.trim() !== '')
92 if (!isAnswered) asks.unshift({ text: wrap.ask, actions: wrap.actions })
93 }
94 return asks
95}
96
97export function askReasons(asks: readonly PendingAsk[]): Reason[] {
98 return asks.map(a => ({
99 level: 'caution',
100 kind: 'ask',
101 text: a.text,
102 ...(a.actions.length > 0 ? { actions: a.actions } : {}),
103 }))
104}
105
106// 終わった背景タスクの id: <task-notification> の status と、TaskStop で止めたもの
107const RUNNING_STATUSES = new Set(['running', 'started', 'pending', 'event'])
108
109// 知らせの本文から、終わったと書かれた背景タスクの id を拾う
110export function endedInNotification(text: string): string[] {
111 if (!text.includes('<task-notification>')) return []
112 const ids: string[] = []
113 for (const block of text.matchAll(/<task-notification>([\s\S]*?)<\/task-notification>/g)) {
114 const body = block[1] ?? ''
115 const id = /<task-id>([^<]+)<\/task-id>/.exec(body)?.[1]?.trim()
116 const status = /<status>([^<]+)<\/status>/.exec(body)?.[1]?.trim().toLowerCase()
117 if (id && status && !RUNNING_STATUSES.has(status)) ids.push(id)
118 }
119 return ids
120}
121
122// 会話の行に残るのは、idle のときに届いた知らせだけ(ターン中に届いたものは添付になり、行に出ない)
123export function endedTaskIds(messages: readonly MessageLite[]): Set<string> {
124 const ended = new Set<string>()
125 for (const m of messages) {
126 if (m.role === 'user') for (const id of endedInNotification(m.text)) ended.add(id)
127 for (const u of m.toolUses) {
128 if (u.tool === 'TaskStop') {
129 const id = u.input.task_id ?? u.input.shell_id
130 if (typeof id === 'string') ended.add(id)
131 }
132 }
133 }
134 return ended
135}
136
137// Desktop の記録(<host session id>.json)のうち、判定に使う欄
138export type SessionRecord = {
139 backgroundTaskSuggestions?: { id?: unknown; title?: unknown; expiresAt?: unknown }[]
140 prs?: { prNumber?: unknown; repo?: unknown; state?: unknown }[]
141}
142
143export type RecordPr = { number: number; repo: string; state: string }
144
145export function recordPrs(record: SessionRecord): RecordPr[] {
146 const prs: RecordPr[] = []
147 for (const pr of record.prs ?? []) {
148 if (typeof pr.prNumber === 'number' && typeof pr.repo === 'string') {
149 prs.push({ number: pr.prNumber, repo: pr.repo, state: typeof pr.state === 'string' ? pr.state : '' })
150 }
151 }
152 return prs
153}
154
155export function prHref(repo: string, number: number): string {
156 return `https://github.com/${repo}/pull/${number}`
157}
158
159// 未起動の提案カード。期限(expiresAt)を過ぎたカードはもう起動できないので数えない
160export function cardReasons(record: SessionRecord, m: Messages, now: number): Reason[] {
161 const live = (record.backgroundTaskSuggestions ?? []).filter(card => typeof card.expiresAt !== 'number' || card.expiresAt > now)
162 return live.map(card => {
163 const id = typeof card.id === 'string' ? card.id : ''
164 const title = typeof card.title === 'string' ? card.title : id
165 return {
166 level: 'blocked',
167 kind: 'card',
168 text: m.card(title),
169 actions: [{ label: m.launchLabel, prompt: m.launchPrompt(title, id) }],
170 }
171 })
172}
173
174// gh pr view --json state,headRefOid,statusCheckRollup の応答
175export type PrStatus = { state: string; headRefOid: string; isCiPending: boolean }
176
177export function parsePrStatus(stdout: string): PrStatus | null {
178 let body: unknown
179 try {
180 body = JSON.parse(stdout)
181 } catch {
182 return null
183 }
184 if (typeof body !== 'object' || body === null) return null
185 const v = body as { state?: unknown; headRefOid?: unknown; statusCheckRollup?: unknown }
186 const checks = Array.isArray(v.statusCheckRollup) ? v.statusCheckRollup : []
187 const isCiPending = checks.some(c => {
188 const check = (typeof c === 'object' && c !== null ? c : {}) as { status?: unknown; state?: unknown; __typename?: unknown }
189 if (check.__typename === 'StatusContext' || (check.status === undefined && check.state !== undefined)) {
190 return check.state === 'PENDING' || check.state === 'EXPECTED'
191 }
192 return typeof check.status === 'string' && check.status !== 'COMPLETED'
193 })
194 return {
195 state: typeof v.state === 'string' ? v.state : '',
196 headRefOid: typeof v.headRefOid === 'string' ? v.headRefOid : '',
197 isCiPending,
198 }
199}
200
201export function followupReasons(followups: readonly FollowUp[], m: Messages): Reason[] {
202 return followups.map(f => ({
203 level: 'caution',
204 kind: 'followup',
205 text: m.followup(f.what, f.when),
206 ...(f.how ? { note: f.how } : {}),
207 followupId: f.id,
208 actions: [{ label: m.scheduleLabel, prompt: m.schedulePrompt(f, FOLLOWUP_TOOL) }],
209 }))
210}
211
212// このセッションで公開した Artifact。Artifact ツールの公開(action 無し / publish)の結果から拾う
213export type PublishedArtifact = { id: string; url: string; title: string }
214
215const ARTIFACT_URL = /https:\/\/claude\.ai\/(?:code\/)?artifact\/([A-Za-z0-9-]+)/
216
217function artifactIdOf(url: string): string | null {
218 return ARTIFACT_URL.exec(url)?.[1] ?? null
219}
220
221export function publishedArtifacts(messages: readonly MessageLite[]): PublishedArtifact[] {
222 const found = new Map<string, PublishedArtifact>()
223 const deleted = new Set<string>()
224 for (const msg of messages) {
225 for (const u of msg.toolUses) {
226 if (u.tool !== 'Artifact' || u.isError === true) continue
227 const action = u.input.action ?? 'publish'
228 if (action === 'delete') {
229 // 資産(asset)だけを消した呼び出しは Artifact そのものを消さない
230 const id = typeof u.input.url === 'string' && u.input.path === undefined ? artifactIdOf(u.input.url) : null
231 if (id !== null && u.text !== undefined) deleted.add(id)
232 continue
233 }
234 // 資産の上げ下ろしと、前の会話の Artifact の更新(url を渡した公開)は、このセッションで作ったものではない
235 if (action !== 'publish' || u.input.asset === true || u.input.url !== undefined) continue
236 // 結果の記録(url・title)を先に見て、無ければ本文の「Published … at https://claude.ai/artifact/…」から拾う
237 const result = (typeof u.result === 'object' && u.result !== null ? u.result : {}) as { url?: unknown; title?: unknown }
238 const url = typeof result.url === 'string' && artifactIdOf(result.url) !== null ? result.url : ARTIFACT_URL.exec(u.text ?? '')?.[0]
239 const id = url === undefined ? null : artifactIdOf(url)
240 if (url === undefined || id === null) continue
241 const title = typeof result.title === 'string' ? result.title : typeof u.input.title === 'string' ? u.input.title : ''
242 found.set(id, { id, url, title: title || found.get(id)?.title || '' })
243 }
244 }
245 return [...found.values()].filter(a => !deleted.has(a.id))
246}
247
248export function artifactReasons(artifacts: readonly PublishedArtifact[], handled: readonly string[], m: Messages): Reason[] {
249 const left = artifacts.filter(a => !handled.includes(a.id))
250 if (left.length === 0) return []
251 return [
252 {
253 level: 'caution',
254 kind: 'artifact',
255 text: m.artifacts(left.length),
256 note: m.artifactTitles(left.map(a => a.title || a.id)),
257 ...(left.length === 1 ? { href: left[0]?.url } : {}),
258 artifactIds: left.map(a => a.id),
259 actions: [{ label: m.sweepLabel, prompt: m.sweepPrompt(left) }],
260 },
261 ]
262}
263
264export function isFollowUp(value: unknown): value is FollowUp {
265 if (typeof value !== 'object' || value === null) return false
266 const v = value as Partial<FollowUp>
267 return typeof v.id === 'string' && typeof v.what === 'string'
268}
269
270export const FOLLOWUP_PREFIX = 'followups:'
271export const ARTIFACT_PREFIX = 'artifacts:'
272
273// 設定のパスの頭の ~ をホームに置き換える
274export function expandHome(path: string, home: string): string {
275 if (path === '~') return home
276 if (path.startsWith('~/') || path.startsWith('~\\')) return `${home}${path.slice(1)}`
277 return path
278}
279
280export function verdictOf(reasons: readonly Reason[]): Verdict {
281 if (reasons.some(r => r.level === 'blocked')) return 'blocked'
282 return reasons.length > 0 ? 'caution' : 'ok'
283}
284
285// 作を先に、注は後続・承認待ち・その他の順に並べる
286const ORDER: Record<Reason['kind'], number> = {
287 dirty: 0,
288 unpushed: 1,
289 card: 2,
290 task: 3,
291 followup: 4,
292 artifact: 5,
293 ci: 6,
294 ask: 7,
295 plan: 8,
296 pr: 9,
297 unread: 10,
298}
299
300export function sortReasons(reasons: readonly Reason[]): Reason[] {
301 return [...reasons].sort((a, b) => {
302 if (a.level !== b.level) return a.level === 'blocked' ? -1 : 1
303 return ORDER[a.kind] - ORDER[b.kind]
304 })
305}
306hooks/messages.ts 271 lines1// 画面の文言・ボタンが Claude に送る依頼・Claude への既定の指示。設定 language で英語(既定)と日本語を切り替える
2import type { Verdict } from '../types'
3
4export type Lang = 'en' | 'ja'
5
6export type Messages = {
7 // 帯の札の文字と幅(px)、枠の見出し、帯の alt
8 badge: Record<Verdict, string>
9 badgeWidth: number
10 verdict: Record<Verdict, string>
11 alt: Record<Verdict, string>
12 // 枠
13 paneTitle: string
14 checking: string
15 nothingLeft: string
16 sectionBlocked: string
17 sectionCaution: string
18 tagFollowup: string
19 tagAsk: string
20 open: string
21 markDone: string
22 // 理由
23 unread: (what: string) => string
24 unreadWhat: {
25 record: string
26 git: string
27 dirty: string
28 unpushed: string
29 mergedPrs: string
30 prState: string
31 messages: string
32 agents: string
33 guide: string
34 evaluate: string
35 }
36 dirty: (n: number) => string
37 unpushed: (n: number) => string
38 card: (title: string) => string
39 openPr: (pr: string) => string
40 ciPending: (pr: string) => string
41 backgroundTask: (label: string) => string
42 agent: (label: string) => string
43 planLeft: (n: number, next: string | null) => string
44 askQuestion: string
45 askOffer: (title: string) => string
46 followup: (what: string, when: string) => string
47 artifacts: (n: number) => string
48 artifactTitles: (titles: string[]) => string
49 // ボタンと、押すと Claude に送る依頼
50 launchLabel: string
51 launchPrompt: (title: string, id: string) => string
52 commitLabel: string
53 commitPrompt: string
54 pushLabel: string
55 pushPrompt: string
56 scheduleLabel: string
57 schedulePrompt: (f: { id: string; what: string; when: string; how: string; dueAt?: string }, tool: string) => string
58 sweepLabel: string
59 sweepPrompt: (artifacts: { url: string; title: string }[]) => string
60 keepLabel: string
61 appActions: Record<string, { label: string; prompt: string }>
62 // 知らせ
63 asked: (label: string) => string
64 notSent: (label: string, detail?: string) => string
65 paneNotPlaced: (reason: string) => string
66 // Claude へ
67 toolDescription: string
68 toolFields: { what: string; when: string; how: string; dueAt: string; id: string }
69 guide: (tool: string) => string
70}
71
72const en: Messages = {
73 badge: { ok: 'Done', caution: 'Check', blocked: 'Busy' },
74 badgeWidth: 46,
75 verdict: { ok: 'Done', caution: 'Check first', blocked: 'Busy' },
76 alt: {
77 ok: 'Done (safe to archive)',
78 caution: 'Check first (look before archiving)',
79 blocked: 'Busy (archiving loses work)',
80 },
81 paneTitle: 'Archive',
82 checking: 'Checking…',
83 nothingLeft: 'Nothing left',
84 sectionBlocked: 'Lost if you archive',
85 sectionCaution: 'Check before archiving',
86 tagFollowup: 'Follow-up',
87 tagAsk: 'Waiting on you',
88 open: 'Open',
89 markDone: 'Mark done',
90 unread: what => `Couldn't read: ${what}`,
91 unreadWhat: {
92 record: 'Desktop session record',
93 git: 'git',
94 dirty: 'uncommitted changes',
95 unpushed: 'commits not on a remote',
96 mergedPrs: 'merged PRs',
97 prState: 'PR state',
98 messages: 'conversation',
99 agents: 'agents',
100 guide: 'guide file',
101 evaluate: 'what the check needs',
102 },
103 dirty: n => `${n} uncommitted ${n === 1 ? 'change' : 'changes'}`,
104 unpushed: n => `${n} ${n === 1 ? 'commit' : 'commits'} not on a remote`,
105 card: title => `Unstarted suggested task “${title}”`,
106 openPr: pr => `Open PR ${pr}`,
107 ciPending: pr => `CI not finished on PR ${pr}`,
108 backgroundTask: label => `Running background task “${label}”`,
109 agent: label => `Running agent “${label}”`,
110 planLeft: (n, next) => `${n} task list ${n === 1 ? 'step' : 'steps'} left${next ? ` (next: ${next})` : ''}`,
111 askQuestion: 'A question to answer',
112 askOffer: title => `New session or here: “${title}”`,
113 followup: (what, when) => (when ? `${what} (${when})` : what),
114 artifacts: n => `${n} ${n === 1 ? 'artifact' : 'artifacts'} created`,
115 artifactTitles: titles => titles.map(t => `“${t}”`).join(', '),
116 launchLabel: 'Ask to start',
117 launchPrompt: (title, id) => `Start the suggested task “${title}”${id ? ` (${id})` : ''} (from the archive-check pane).`,
118 commitLabel: 'Ask to commit',
119 commitPrompt: "Commit this session's uncommitted changes (from the archive-check pane).",
120 pushLabel: 'Ask to push',
121 pushPrompt: 'Push the commits that are not on a remote (from the archive-check pane).',
122 scheduleLabel: 'Schedule',
123 schedulePrompt: (f, tool) =>
124 [
125 `Turn the follow-up “${f.what}” into a scheduled task (from the archive-check pane).`,
126 `- When: ${f.when || '(not given)'}${f.dueAt ? ` (${f.dueAt})` : ''}`,
127 `- How to check: ${f.how || '(not given)'}`,
128 'The scheduled task runs in a new session, so write the context it needs into its prompt. If the time is unclear, ask before creating it.',
129 `Once it exists, remove the follow-up with ${tool} (action: done, id: ${f.id}).`,
130 ].join('\n'),
131 sweepLabel: 'Ask to tidy up',
132 sweepPrompt: artifacts =>
133 [
134 `Check the ${artifacts.length} ${artifacts.length === 1 ? 'artifact' : 'artifacts'} created in this session with artifact-sweep and delete the ones no longer needed (from the archive-check pane).`,
135 ...artifacts.map(a => `- ${a.title ? `“${a.title}” ` : ''}${a.url}`),
136 "If you don't have artifact-sweep, check where each one is linked from (docs, memory, PR descriptions) and ask me before deleting any.",
137 ].join('\n'),
138 keepLabel: 'Keep',
139 appActions: {
140 commit: { label: 'Commit', prompt: 'Commit' },
141 create_pr: { label: 'Create PR', prompt: 'Create the PR' },
142 merge: { label: 'Merge', prompt: 'Merge the PR' },
143 },
144 asked: label => `Asked: ${label}`,
145 notSent: (label, detail) => `Couldn't send “${label}”${detail ? `: ${detail}` : ''}`,
146 paneNotPlaced: reason => `Couldn't open the archive pane: ${reason}`,
147 toolDescription: [
148 "Add, remove or list this session's follow-ups. A follow-up is a check with nothing to do now that can only be done later",
149 ' (e.g. look at the replies in 3 days and decide on a price cut / try it for a few days and see whether it helped / resume once a wait is over).',
150 ' Follow-ups show above the prompt in Claude Code Desktop, so the person sees them before archiving the session.',
151 ' action: add takes what, when (a time, or what has to happen first) and how (where to look, what decides it); due_at (ISO 8601 with offset) when the time is fixed.',
152 ' action: done takes the id and removes it (checked, no longer needed, or moved to a scheduled task). action: list lists them.',
153 ].join(''),
154 toolFields: {
155 what: 'What to check',
156 when: 'When, or what has to happen first',
157 how: 'How to check (where to look, what decides it)',
158 dueAt: 'ISO 8601 with offset, when the time is fixed',
159 id: 'The id of the follow-up to remove (action: done)',
160 },
161 guide: tool =>
162 [
163 '# Follow-ups (archive-check)',
164 `When this session is left with a check that has nothing to do now and can only be done later (e.g. look at the replies in a few days and decide on a price cut / use it for a while and see whether it helped / resume once a wait is over), add it with ${tool} (action: add) before ending the turn: what, when (or what has to happen first), and how to check.`,
165 "Don't add what only waits for the person's answer, the rest of this turn's work, or CI on an open PR (those are counted already). Remove follow-ups that are checked or no longer needed with action: done.",
166 ].join('\n'),
167}
168
169const ja: Messages = {
170 badge: { ok: '済', caution: '注', blocked: '作' },
171 badgeWidth: 24,
172 verdict: { ok: '作業済み', caution: '注意あり', blocked: '作業中' },
173 alt: {
174 ok: '作業済み(アーカイブしてよい)',
175 caution: '注意あり(アーカイブする前に確かめる)',
176 blocked: '作業中(アーカイブすると作業を失う)',
177 },
178 paneTitle: 'アーカイブ',
179 checking: '確かめています…',
180 nothingLeft: '残っているものはありません',
181 sectionBlocked: 'アーカイブすると失うもの',
182 sectionCaution: 'アーカイブする前に確かめること',
183 tagFollowup: '後続',
184 tagAsk: '承認待ち',
185 open: '開く',
186 markDone: '済みにする',
187 unread: what => `読めなかった: ${what}`,
188 unreadWhat: {
189 record: 'Desktop の記録',
190 git: 'git',
191 dirty: '未コミットの変更',
192 unpushed: 'remote に無いコミット',
193 mergedPrs: 'マージ済みの PR',
194 prState: 'PR の状態',
195 messages: '会話の記録',
196 agents: 'エージェント',
197 guide: '指示の Markdown',
198 evaluate: '判定の材料',
199 },
200 dirty: n => `未コミットの変更 ${n} 件`,
201 unpushed: n => `remote に無いコミット ${n} 本`,
202 card: title => `未起動の提案カード「${title}」`,
203 openPr: pr => `開いている PR ${pr}`,
204 ciPending: pr => `PR ${pr} の CI が終わっていない`,
205 backgroundTask: label => `動いている背景タスク「${label}」`,
206 agent: label => `動いているエージェント「${label}」`,
207 planLeft: (n, next) => `タスク一覧の残り ${n} 手順${next ? `(次: ${next})` : ''}`,
208 askQuestion: '質問への回答',
209 askOffer: title => `新しいセッションか、ここでやるか「${title}」`,
210 followup: (what, when) => (when ? `${what}(${when})` : what),
211 artifacts: n => `作った Artifact ${n} 件`,
212 artifactTitles: titles => titles.map(t => `「${t}」`).join(''),
213 launchLabel: '起動を頼む',
214 launchPrompt: (title, id) => `提案カード「${title}」${id ? `(${id})` : ''}を起動して(archive-check の枠から)。`,
215 commitLabel: 'コミットを頼む',
216 commitPrompt: 'このセッションの未コミットの変更をコミットして(archive-check の枠から)。',
217 pushLabel: 'push を頼む',
218 pushPrompt: 'remote に無いコミットを push して(archive-check の枠から)。',
219 scheduleLabel: '予定に移す',
220 schedulePrompt: (f, tool) =>
221 [
222 `後続「${f.what}」を予定タスクに移して(archive-check の枠から)。`,
223 `- いつ: ${f.when || '(未記入)'}${f.dueAt ? `(${f.dueAt})` : ''}`,
224 `- どう確かめる: ${f.how || '(未記入)'}`,
225 '予定は新しいセッションで動くので、prompt にはこのセッションの前提を書き込む。日時がはっきりしなければ作る前に聞く。',
226 `作れたら ${tool}(action: done, id: ${f.id})で後続から消す。`,
227 ].join('\n'),
228 sweepLabel: '片付けを頼む',
229 sweepPrompt: artifacts =>
230 [
231 `このセッションで作った Artifact ${artifacts.length} 件を artifact-sweep で調べて、要らないものを消して(archive-check の枠から)。`,
232 ...artifacts.map(a => `- ${a.title ? `「${a.title}」 ` : ''}${a.url}`),
233 'artifact-sweep が無ければ、それぞれがどこから張られているか(docs・memory・PR 本文)を調べ、消す前に聞いて。',
234 ].join('\n'),
235 keepLabel: '残す',
236 appActions: {
237 commit: { label: 'コミット', prompt: 'コミットして' },
238 create_pr: { label: 'PR を作る', prompt: 'PR を作って' },
239 merge: { label: 'マージ', prompt: 'PR をマージして' },
240 },
241 asked: label => `「${label}」を頼みました`,
242 notSent: (label, detail) => `「${label}」を送れませんでした${detail ? `: ${detail}` : ''}`,
243 paneNotPlaced: reason => `アーカイブの枠を開けませんでした: ${reason}`,
244 toolDescription: [
245 'このセッションの「後続」を書き出す・消す・一覧する。後続は、今やる作業は無いが時間が経たないと結果が分からない確認',
246 '(例: 3 日後に出品の反応を見て値下げを決める / 数日使ってから効いたか確かめる / 待ち時間が明けてから再開する)。',
247 '書き出した後続は Desktop の入力欄の上の帯に出て、本人がアーカイブする前に気づける。',
248 'action: add は what(何を)・when(いつ、または何が起きたら)・how(どう確かめる)を渡す。日時が決まっていれば due_at(ISO 8601、時差付き)も。',
249 'action: done は id を渡して消す(確かめ終えた・不要になった・予定タスクに移した)。action: list は一覧を返す。',
250 ].join(''),
251 toolFields: {
252 what: '何を確かめるか',
253 when: 'いつ、または何が起きたら確かめるか',
254 how: 'どう確かめるか(見る場所・判断の基準)',
255 dueAt: '日時が決まっていれば ISO 8601(時差付き)',
256 id: 'done で消す後続の id',
257 },
258 guide: tool =>
259 [
260 '# 後続(archive-check)',
261 `今やる作業は無いが時間が経たないと結果が分からない確認がこのセッションに残ったら(例: 数日後に出品の反応を見て値下げを決める / しばらく使ってから効いたか確かめる / 待ち時間が明けてから再開する)、ターンを終える前に ${tool} の add で、何を・いつ(または何が起きたら)・どう確かめるかを書き出す。`,
262 '本人の返事を待つだけのもの・このターンの続きの作業・開いた PR の CI 待ち(自動で数える)は後続にしない。確かめ終えた・不要になった後続は done で消す。',
263 ].join('\n'),
264}
265
266export const MESSAGES: Record<Lang, Messages> = { en, ja }
267
268export function langOf(value: unknown): Lang {
269 return value === 'ja' ? 'ja' : 'en'
270}
271hooks/hit.ts 16 lines1import type { ClientModule } from 'claude-code'
2
3// 状態の帯の上に透明に重ねる領域。何も描かず、押されたことだけを hooks に知らせる。
4// Svg は押せる要素で包めない(Button の中身は文字だけ)ので、押せる面を別に重ねている
5const Hit: ClientModule<Record<string, never>, true> = (_props, surface) => {
6 if (surface.state === undefined) {
7 surface.onPointer(event => {
8 if (event.type === 'up' && event.button === 'left') surface.post({ source: 'archive-check' })
9 })
10 surface.setState(true)
11 }
12 return surface.elements.Box({})
13}
14
15export default Hit
16types/index.d.ts 70 lines1// Busy(作) = 畳むと作業を失う(blocked) / Check(注) = 畳んでよいが確認を忘れずに(caution) / Done(済) = どれも無い(ok)
2export type Verdict = 'ok' | 'caution' | 'blocked'
3
4export type ReasonKind =
5 | 'dirty'
6 | 'unpushed'
7 | 'card'
8 | 'task'
9 | 'followup'
10 | 'artifact'
11 | 'ci'
12 | 'ask'
13 | 'plan'
14 | 'pr'
15 | 'unread'
16
17// 枠のボタンで Claude に頼むこと。押すと prompt をこのセッションに送る
18export type ReasonAction = {
19 label: string
20 prompt: string
21}
22
23export type Reason = {
24 level: 'blocked' | 'caution'
25 kind: ReasonKind
26 text: string
27 // 補足(後続の確かめ方など)。枠で薄い字にする
28 note?: string
29 // 後続なら、その id(枠の「済みにする」「予定に移す」が使う)
30 followupId?: string
31 // このセッションで公開した Artifact の行なら、その id(枠の「残す」と、片付けを頼んだあとに行を外すのに使う)
32 artifactIds?: string[]
33 // 枠のボタン(Claude に頼む)
34 actions?: ReasonAction[]
35 // 枠のリンク(PR など)
36 href?: string
37}
38
39// 時間が経たないと分からない確認。Claude が mcp__archive-check__followup で書き出す
40export type FollowUp = {
41 id: string
42 what: string
43 when: string
44 how: string
45 // 決まっていれば ISO 8601(時差付き)
46 dueAt?: string
47 createdAt: number
48}
49
50// この CLI のプロセスで起きた背景タスク(Bash / PowerShell の背景実行・Monitor)
51export type BackgroundTask = {
52 id: string
53 kind: 'shell' | 'monitor'
54 label: string
55 startedAt: number
56}
57
58export type Report = {
59 verdict: Verdict
60 reasons: Reason[]
61 checkedAt: number
62}
63
64declare module 'claude-code' {
65 interface PluginState {
66 // ended: 知らせ(prompt.submit)で終わりが分かった背景タスクの id
67 'archive-check': { report: Report | null; tasks: BackgroundTask[]; ended: string[] }
68 }
69}
70