Resident daemon beside Claude Code sessions that does what a session cannot do from inside: resume stopped sessions, aggregate state across sessions, clean up…

Claude Code のセッション群のそばに常駐し、セッションの中からはできないことを背後で担うデーモンと、 それを配線する Claude Code plugin。端末多重化ツール rex の block 越しにセッションを見て、働きかける。
Claude Code のセッションは自分が止まっている間は何もできず、自分以外のセッションの状態を知らず、 時間が経っても勝手には動かず、自分の pane の外の端末を触れない。agents-daemon はセッションの外に 1 プロセスだけ常駐し、この「中からはできないこと」を引き受ける。機能を足すときは下の表の区分のどれかに 属するかで判断する。
| 区分 | 今ある機能 |
|---|---|
| 止まったセッションへの働きかけ | 5 時間ウィンドウの利用上限で止まったセッションを、解除時刻に再開させる (internal/monitor) |
| セッションを跨いだ状態の集約 | statusline の stdin から利用上限・context・cost をセッション別の state file に落とし、daemon とコマンドが読む (internal/sessionstate、internal/ratelimit、internal/sessioncontext、internal/statusline/spend)。Stop のたびに transcript の要約を集め、記憶の consolidation の材料にする (internal/dream) |
| 時間で動く掃除 | ログと state file のうち、保持期間を過ぎたものを消す (internal/daemon/housekeeping.go)。foreground セッションが keep したまま 1 週間触っていない worktree を、SessionStart のたびに今開いたリポジトリの分だけ回収する (internal/worktree) |
| 多重化ツールへの操作 | rex の block を列挙し、画面を読み、キーと文面を送る (internal/multiplexer/rexcli) |
境界は 1 つ。Claude Code 本体が自分でできることは本体に任せる。auto-continue が再開を予約している間は 手を出さず、本体の auto-continue が降りた場面だけを拾う。context の compact と prompt cache の失効も本体 (autocompact、idle compaction、失効後の footer の案内) に任せる。本体が同じことをするようになった機能は、 この境界に従って外す。
plugin が持つもの:
| 部品 | 役目 | ||||
|---|---|---|---|---|---|
| SessionStart hook | daemon のバイナリを用意し、daemon --ensure で起こす。続けて prune-worktrees --hook で放置 worktree を回収する | ||||
| Stop hook | dream harvest を起こす | ||||
| PostToolUse hook | Task 系ツール (TaskCreate / TaskUpdate / TaskList) と TodoWrite の呼び出しを ingest-task に渡し、セッションのタスクを記録する (#121) | ||||
agents-daemon:agents-daemon skill | 運用と切り分けの手引き (症状から引く) | ||||
agents-daemon:setup skill | 前提の確認と statusline の配線、peer messaging の切り分け | ||||
agents-daemon:peer-sessions skill | 別セッション (peer) へ作業を渡す手順と、rex の block 操作 | ||||
agents-daemon:dream skill | 記憶階層の consolidation の手引き | ||||
agents-daemon statusline | statusLine.command に向ける statusline。描画と、daemon が読む state file の書き込みを同じプロセスで行う | ||||
agents-daemon ingest-statusline | 自前の statusline から stdin を渡して state file だけを書かせるサブコマンド | ||||
agents-daemon dream | consolidation の実行系 (harvest / mine / stage / adopt / forget) | ||||
agents-daemon prune-worktrees | 誰も使っていない Claude Code の worktree の回収 (SessionStart hook が --hook 付きで呼ぶ。手で叩く形は skills/agents-daemon/references/operations.md) | ||||
/deps コマンド (mod、hooks/deps.tsx) | 依存図: セッションをまたぐタスクの依存を、辺をレーンで描いた行 (git log --graph の形、上流が上) で会話の横の pane に描く。fullscreen のレイアウトでだけ開く。生きているセッションとタスクをすべて描き、ノードを選ぶと辺を外す・足す、そのタスクのセッションへ指示を送れる (#134) | ||||
agents-daemon deps | mod が読む依存図の JSON (タスクの記録、status.json、辺、メモを組み直したもの) を出す | ||||
agents-daemon ingest-task | PostToolUse hook の stdin からタスクを tasks/<session_id>/ に記録する | ||||
| `agents-daemon park add\ | set\ | list\ | discard\ | adopt` | パーキングロット (タスクの途中で見つけた脇のメモ) を parking/ に置く・status を書き換える・一覧する・古い open のメモをまとめて捨てる・終了したセッションのタスクをメモへ移す。同じメモは 1 つにまとめる (#121) |
| `agents-daemon edge add\ | rm\ | list` | 作業を渡す側が、セッションをまたぐ作業の依存 (辺) を edges/ に書く・消す・一覧する (#134。使い方は skills/agents-daemon/references/operations.md) | ||
park tool (mod、hooks/park.tsx) | model が聞かずに脇のことをメモとしてパーキングロットへ置く tool (#121) | ||||
ファイルのメモの通知 (mod、hooks/notice.ts) | model が Read / Edit / Write で触ったファイルを files に持つ、ほかのセッションが置いたメモを、その tool の結果に添えて model へ 1 回だけ伝える (#89) | ||||
渡した宛先の辺の確かめ (mod、hooks/handoff-notice.ts) | model が SendMessage で送った生きているセッションに依存の辺が無ければ、その tool の結果に辺の書き方を添えて model へ伝える (#172) |
rex ls / block call / capture / send) で block を見て送信する。 rex の外で動く Claude Code には何も届かず、REX_SESSION の無いセッションの SessionStart では daemon を起こさない (statusline は rex の外でも動く)。server の socket は ~/Library/Application Support/rex/server.sock。 sandbox の中の Bash から doctor や setup skill で rex を叩くなら、 sandbox.network.allowUnixSockets に ~/Library/Application Support/rex を足す。 rex 0.0.1791570264-g7b5bf3 と Claude Code 2.1.296 で測った (2026-10-10)。statusLine.command をこの plugin のバイナリの statusline に向けるか、自前の statusline から ingest-statusline へ stdin を渡す 1 行を足す (下の「セットアップ」で行う)。 block と session の対応も statusline が書く記録から引くので、必須になる。go build でバイナリを建てる。 hook は Claude Code を起動したシェルの PATH で go を探す。無ければ brew install go などで入れる。 初回の build では Go module のダウンロードにネットワークを使う。PATH の go が go.mod の toolchain 行 (go1.26.10) より古いと、GOTOOLCHAIN が既定の auto なら go がその版を取得してビルドする。claude plugin marketplace add usadamasa/agents-marketplace
claude plugin install agents-daemon@usadamasa
次のセッションの SessionStart で、hook が go build をバックグラウンドで回す。 セッションの開始は待たせず、build が終わると daemon が起きる。 バイナリは ${XDG_CACHE_HOME:-~/.cache}/agents-daemon/bin/agents-daemon に置かれ、 plugin を更新すると次の SessionStart で建て直される。 動作中の daemon は実行ファイルの差し替えを検知して自分で入れ替わる。 セッションを開き直さずに入れ替える手順は skills/agents-daemon/references/operations.md の 「plugin の更新後に daemon を入れ替える」にある。
build の経過は ${XDG_STATE_HOME:-~/.local/state}/agents-daemon/logs/build.log に残る。
install 後に rex の block の中で Claude Code を起動し、/agents-daemon:setup を実行する。 前提がそろっているかを確かめ、statusline への配線が無ければ足す案を出す。
${XDG_CONFIG_HOME:-~/.config}/agents-daemon/config.json。ファイルが無ければ全て既定値で動く。 daemon は毎 tick 読み直すので、編集は数秒で反映される。 設定キーの一覧は skills/agents-daemon/references/operations.md にある。
バイナリは PATH に入らないので、絶対パスで呼ぶかそのディレクトリを PATH に足す。
agents-daemon status # 稼働状況、監視中の pane、ログの末尾
agents-daemon logs -n 80 # daemon.log の末尾
agents-daemon logs --events # 依存図とパーキングロットの経路の出来事のログの末尾
agents-daemon doctor # 前提条件の診断
agents-daemon --json doctor # 同じ診断を JSON で出す (--json は doctor と status に効く)
agents-daemon inspect --pane <block id> # 生きた block の画面を分類する
agents-daemon daemon --foreground --dry-run # 送信せずに判定だけ回す
agents-daemon stop # 停止
COLUMNS=200 agents-daemon statusline < in.json # statusline を描画し、state file も書く (statusLine.command が呼ぶ)
agents-daemon ingest-statusline < in.json # statusline の stdin から state file だけを書く (自前の statusline が呼ぶ)
agents-daemon dream harvest --incremental # 直近の transcript を集める (Stop hook が --detach 付きで呼ぶ)
agents-daemon dream mine --project <canonical> # memory と skill を突き合わせて候補を出す
agents-daemon dream stage --project <canonical> # 候補の承認レポートを ./tmp/dream/ に書く
agents-daemon dream forget <memory-name>... # auto memory を消す (sandbox の外で動かす)
agents-daemon prune-worktrees --dry-run # 回収対象の worktree を表示する (SessionStart hook が --hook 付きで呼ぶ)
agents-daemon spawn --repo /path/to/repo < prompt.txt # stdin を最初のターンにした Claude Code を repo の rex window に起こす (依存図の pane が呼ぶ)
task build # ./bin/ へビルド
task test # go test と bats tests/
task lint # Go の静的解析 (devShell のツールを使う)
task vulncheck # govulncheck の symbol モード (lint が呼び、vulncheck workflow が週次で回す)
task install # hook と同じ cache のパスへビルドする
claude plugin validate --strict .
開発には flakes を有効にした Nix と、direnv と nix-direnv を使う。Go・task・golangci-lint・shellcheck・bats・jq などは flake.nix の devShell で揃え、版は flake.lock が固定する nixpkgs で決まる。.envrc が use flake で devShell を 読み込むので direnv allow する。CI も同じ flake を使う。plugin を使うだけなら Nix は要らない。
clone をそのまま読み込ませると、hook と skill の編集は install し直さずに反映される。 このリポジトリは marketplace.json を持たないので、--plugin-dir でセッション単位に読み込む。 install 済みの版と二重に hook が走らないよう、確認中は install 済みの plugin を無効にしておく。
claude plugin disable agents-daemon@usadamasa
claude --plugin-dir "$PWD"
MIT
hooks/register.ts 22 lines1import type { Register } from 'claude-code'
2
3import { registerDeps, startDeps } from './deps'
4import { registerHandoffNotice } from './handoff-notice'
5import { registerNotice } from './notice'
6import { registerPark, startPark } from './park'
7
8// hooks.json の modules は 1 つしか書けず、matcher の無い hook は 1 つの plugin で 1 つの event に 1 つしか
9// 置けない。mod ごとの hook をここで束ね、session.start はここで持つ。
10export const register: Register = (on, options) => {
11 on('session.start', async ($, e, next) => {
12 await startDeps($)
13 await startPark($)
14
15 return next(e)
16 })
17 registerDeps(on, options)
18 registerPark(on, options)
19 registerNotice(on, options)
20 registerHandoffNotice(on, options)
21}
22hooks/deps.tsx 525 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { DepsData, DepsRepo, GraphLink, GraphNode, GraphRow, ParkingCard } from '../types'
5import { daemonBin } from './daemon-bin'
6import { loadDeps } from './deps-data'
7import { CHAIN_COLOR, COLOR, Graph, ICON, MEMO, candidates, cells, chain, fit, legendRows, pieces, splitLanes, summary, tags } from './graph'
8import { logChange, logEvent, settle } from './log'
9import type { Fields } from './log'
10import {
11 adopt,
12 canAdopt,
13 destinationKey,
14 destinationLabel,
15 discard,
16 discardStale,
17 handOff,
18 isStale,
19 issueDestinations,
20 issueText,
21 lastPlacedAt,
22 memoLabel,
23 memoLines,
24 pathDestination,
25 spawnDestinations,
26 spawnText,
27 stamp,
28 taskDestinations,
29 taskText,
30} from './parking'
31import type { Destination, Intent } from './parking'
32
33// 依存図の pane: 依存の DAG (#134) の辺をレーンで描いた行 (graph.rows、上流が上) を dock の pane に描き、選んだノードの
34// 詳細と操作、repo ごとのメモの節を出す mod。中身は `agents-daemon deps` (internal/deps) の graph と repos (メモと
35// その送り先)。ここは描くことと送ることだけを持つ。
36// pane は会話の横 (dock) に置かれるときだけ出す。入力欄の上 (inline) には出さない。
37// cc-premise: 2.1.303 mods pane の placement (dock / inline)、command.run の presentation.isFullscreen、ui.close
38// cc-premise: 2.1.302 mods pane / clock.every / process.run / session.send / session.cwd の API で書いた
39// cc-premise: 2.1.303 mods Button の plain は端末で [ ] を描かず、variant より優先される。行は plain、操作は枠付き
40
41const PANE = 'agents-daemon-deps'
42const TITLE = 'Tasks'
43// daemon は tick ごとに status.json を書き直し、タスクの記録は Task 系ツールのたびに変わる。
44// それより細かく読んでも変わらないので数秒おきにする。
45const POLL_MS = 3000
46// 行頭の印。選んだ行だけに › を出し、ほかの行は同じ幅の空白で揃える。
47const GUTTER = 2
48
49const deps = atom({ plugin: 'agents-daemon', key: 'deps' } as const, null)
50const error = atom({ plugin: 'agents-daemon', key: 'error' } as const, '')
51// target は選んだノードの Key かメモの Key (park:<id>)。
52const target = atom({ plugin: 'agents-daemon', key: 'target' } as const, '')
53const notice = atom({ plugin: 'agents-daemon', key: 'notice' } as const, '')
54const showCompleted = atom({ plugin: 'agents-daemon', key: 'showCompleted' } as const, false)
55const picking = atom({ plugin: 'agents-daemon', key: 'picking' } as const, false)
56const spawning = atom({ plugin: 'agents-daemon', key: 'spawning' } as const, '' as '' | Intent)
57
58// timer は依存図の読み直し。pane を開いている間だけ動かす。
59let timer: Timer | undefined
60// closing は inline に描かれて閉じにかかったか。閉じるまでに描画がもう一度走っても、知らせを重ねない。
61let closing = false
62
63// startDeps は session.start で /deps を登録する (session.start の hook は hooks/register.ts が持つ)。
64export async function startDeps($: EngineInterface) {
65 await $.command.register({
66 name: 'deps',
67 description: 'agents-daemon の依存図を会話の横に開き、選んだタスクのセッションへ指示を送る',
68 })
69}
70
71export const registerDeps: Register = on => {
72 on('command.run', { command: 'deps' }, async ($, e) => {
73 if (!e.presentation.isFullscreen) {
74 return { text: '依存図は fullscreen のレイアウトで、会話の横 (dock) にだけ開きます。この画面では開きません。' }
75 }
76 closing = false
77 await refresh($, true)
78 timer?.cancel()
79 timer = $.clock.every(POLL_MS, () => void refresh($))
80 await $.ui.open({ id: PANE, title: TITLE })
81
82 return { text: 'agents-daemon の依存図を開きました。' }
83 })
84
85 on('ui.close', async ($, e, next) => {
86 if (e.id === PANE) {
87 timer?.cancel()
88 timer = undefined
89 }
90
91 return next(e)
92 })
93
94 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
95 const elements = $.ui.resolve(e)
96 const { Box, Text, Button } = elements
97 // 端末を狭めて入力欄の上に置かれたら閉じる。
98 if (e.props.placement === 'inline') {
99 if (!closing) {
100 closing = true
101 await $.ui.close({ id: PANE })
102 // 閉じた pane の中の文面は誰にも見えないので、toast で知らせる。
103 await $.ui.toast('会話の横に置けないため、依存図を閉じました (端末を 110 桁以上にすると開けます)')
104 }
105 return <Text dimColor>会話の横に置けないため、依存図を閉じました。</Text>
106 }
107 // mobile には入力欄が無い。依存図は見せて、送信だけ出さない。
108 const Input = 'Input' in elements ? elements.Input : undefined
109 const data = await read($, deps)
110 const failure = await read($, error)
111 const chosen = await read($, target)
112 const sent = await read($, notice)
113 const withCompleted = await read($, showCompleted)
114 const isPicking = await read($, picking)
115 const isSpawning = await read($, spawning)
116 const self = await $.session.id()
117
118 if (data === null) {
119 return (
120 <Box flexDirection="column">
121 {failure !== '' && <Text color="red">依存図を読めません: {failure}</Text>}
122 {failure === '' && <Text dimColor>読み込み中…</Text>}
123 </Box>
124 )
125 }
126
127 const g = new Graph(data.graph)
128 const memos = data.repos.flatMap(repo => repo.memos)
129 const now = await $.clock.now()
130 // 「古い」メモの日数はバイナリが渡す (internal/parking の StaleAfter)。
131 const staleDays = data.stale_memo_days
132 const stale = memos.filter(memo => isStale(memo, now, staleDays)).length
133 const selected = g.node(chosen)
134 const chosenMemo = findMemo(data.repos, chosen)
135 const rows = withCompleted ? data.graph.rows_with_completed : data.graph.rows
136
137 const choose = (key: string) => {
138 void update($, target, () => key)
139 void update($, notice, () => '')
140 void update($, picking, () => false)
141 void update($, spawning, () => '')
142 }
143 // act は操作を走らせ、結果を notice に出して依存図を読み直す。
144 const act = async (run: () => Promise<string>) => {
145 const message = await run()
146 await update($, notice, () => message)
147 await refresh($)
148 }
149 const send = async (node: GraphNode, text: string) => {
150 if (!canSend(node, self) || text.trim() === '') return
151 await update($, notice, () => '')
152 await act(() => deliver($, node, instruction(node, text), 'instruction'))
153 }
154
155 // 行は枠なし (plain) の Button にして、枠付きの Button は操作だけに使う。レーンの罫線は薄く描き、ノードの記号の
156 // 左右に分けて置く。辺をつなぐだけの行は罫線だけ。
157 // lanes は罫線の区間を描く。longest chain の辺の桁 (marks) だけ色を付け、ほかは薄く描く。
158 const lanes = (r: GraphRow, from: number, to: number) =>
159 pieces(r.lanes, r.marks, from, to).map((p, k) =>
160 p.marked ? (
161 <Text key={String(k)} color={CHAIN_COLOR}>
162 {p.text}
163 </Text>
164 ) : (
165 <Text key={String(k)} dimColor>
166 {p.text}
167 </Text>
168 ),
169 )
170 const row = (r: GraphRow, i: number) => {
171 const { left, right } = splitLanes(r.lanes)
172 const n = r.node === undefined ? undefined : g.node(r.node)
173 if (n === undefined) {
174 return (
175 <Box key={`box:lane:${i}`} flexDirection="row">
176 <Text>{' '.repeat(GUTTER)}</Text>
177 {lanes(r, 0, left.length)}
178 </Box>
179 )
180 }
181 const isSel = n.key === chosen
182 const tag = tags(n, n.stuck)
183 // 行頭の印、レーン、記号と空白を除いた幅に title を収める。
184 const room = Math.max(4, e.props.bodyColumns - GUTTER - cells(left) - 1 - cells(right) - 1 - cells(tag))
185 return (
186 <Box key={`box:row:${n.key}`} flexDirection="row">
187 <Text>{isSel ? '› ' : ' '.repeat(GUTTER)}</Text>
188 {lanes(r, 0, left.length)}
189 <Button key={`row:${n.key}`} plain onPress={() => choose(isSel ? '' : n.key)}>
190 <Text color={COLOR[n.state]}>{ICON[n.state]}</Text>
191 {lanes(r, left.length + 1, r.lanes.length)}
192 <Text color={n.halted ? COLOR[n.state] : undefined} bold={isSel ? true : undefined}>{` ${fit(n.title, room)}${tag}`}</Text>
193 </Button>
194 </Box>
195 )
196 }
197
198 // 選んだノードの詳細は、その行の下に、行のレーンの字下げに合わせた枠で出す。
199 // 操作のボタンは枠の最後の行にまとめ、どのノードでも同じ位置に出す。
200 const detail = (n: GraphNode, prefix: string) => {
201 const links = g.blockers(n.key)
202 const canLink = n.endpoint && n.cwd !== undefined
203 const actions = [
204 canLink && !isPicking && (
205 <Button key="add-blocker" onPress={() => void update($, picking, () => true)}>
206 + Add blocker
207 </Button>
208 ),
209 isPicking && (
210 <Button key="cancel" dimColor onPress={() => void update($, picking, () => false)}>
211 Cancel
212 </Button>
213 ),
214 canAdopt(n, self, data) && (
215 <Button key="adopt" onPress={() => void act(() => adopt($, n))}>
216 To parking
217 </Button>
218 ),
219 ].filter(Boolean)
220 return (
221 <Box key={`detail:${n.key}`} flexDirection="column" borderStyle="round" borderDimColor paddingX={1} marginLeft={GUTTER + cells(prefix)}>
222 <Text dimColor>
223 {heading(n)}
224 {n.state === 'limit' && n.detail ? ` (再開 ${n.detail})` : ''}
225 </Text>
226 <Text>blocked by: {chain(g, n)}</Text>
227 {n.memos.map(m => (
228 <Text key={`memo:${m.id}`}>{`◇ ${m.title} (${m.status})`}</Text>
229 ))}
230 {links.map(link => blockerRow(n, link))}
231 {!canLink && <Text dimColor>辺の端点にできないタスクです (TodoWrite の項目)</Text>}
232 {!canSend(n, self) && <Text dimColor>{whyNot(n, self)}</Text>}
233 {canSend(n, self) && Input === undefined && <Text dimColor>この画面では指示を入力できません</Text>}
234 {isPicking && <Text dimColor>先に済ませるタスクを選ぶ:</Text>}
235 {isPicking &&
236 candidates(g, n).map(c => (
237 <Button key={`blocker:${c.key}`} onPress={() => void act(() => addBlocker($, c, n))}>
238 <Text color={COLOR[c.state]}>{ICON[c.state]}</Text>
239 {` ${c.title}`}
240 </Button>
241 ))}
242 {canSend(n, self) && Input !== undefined && (
243 <Input
244 key="instruction"
245 label={`${n.session} への指示`}
246 placeholder="送る文面"
247 submitLabel="Send"
248 onSubmit={(text: string) => void send(n, text)}
249 />
250 )}
251 {actions.length > 0 && (
252 <Box key={`actions:${n.key}`} flexDirection="row" flexWrap="wrap" gap={1}>
253 {actions}
254 </Box>
255 )}
256 </Box>
257 )
258 }
259
260 // blockerRow は選んだノードを止めている辺 1 本。edge の辺は記録を消せる。同じセッションの blockedBy は daemon から
261 // 外せない (TaskUpdate にも外す引数が無い) ので、そのセッションへ解消を頼む。
262 const blockerRow = (n: GraphNode, link: GraphLink) => {
263 const before = g.node(link.before)
264 if (before === undefined) return null
265 return (
266 <Box key={`blocker-row:${link.before}`} flexDirection="row">
267 <Text>
268 ← <Text color={COLOR[before.state]}>{ICON[before.state]}</Text> {before.title}{' '}
269 </Text>
270 {link.kind === 'edge' && link.edge !== undefined && (
271 <Button key={`unlink:${link.before}`} onPress={() => void act(() => unlink($, link))}>
272 Unlink
273 </Button>
274 )}
275 {link.kind === 'task' && canSend(n, self) && (
276 <Button key={`ask:${link.before}`} onPress={() => void act(() => deliver($, n, unlinkText(before, n), 'ask-unlink'))}>
277 Ask to unlink → {n.session ?? ''}
278 </Button>
279 )}
280 </Box>
281 )
282 }
283
284 // メモの行は最後に置かれた時刻 (JST) を先頭に置き、どのメモが古いかを列で見比べられるようにする。
285 const memoRow = (memo: ParkingCard) => {
286 const key = memoKey(memo)
287 const during = memo.sources.flatMap(s => (s.task_ref ? (g.node(s.task_ref) ?? []) : []))[0]
288 const isSel = key === chosen
289 return (
290 <Box key={`box:${key}`} flexDirection="row">
291 <Text>{isSel ? '› ' : ' '.repeat(GUTTER)}</Text>
292 <Button key={key} plain onPress={() => choose(isSel ? '' : key)}>
293 <Text dimColor>{`${stamp(lastPlacedAt(memo))} `}</Text>
294 <Text bold={isSel ? true : undefined}>{`${memoLabel(memo)}${during ? ` ← ${during.title}` : ''}`}</Text>
295 </Button>
296 </Box>
297 )
298 }
299
300 // メモの詳細もノードと同じ枠で出し、操作のボタンを枠の最後の行にまとめる。生きているセッションの無い repo へは
301 // 「new session…」で repo の候補を開き、新しいセッションを起こして渡す (#162)。
302 const memoDetail = ({ memo, repo }: { memo: ParkingCard; repo: DepsRepo }) => {
303 const tasks = taskDestinations(data, repo, self)
304 // spawn は候補の repo で新しいセッションを起こし、渡したら候補を畳む。
305 const spawn = async (to: Destination, intent: Intent) => {
306 if (to.route !== 'spawn') return
307 await act(() => handOff($, memo, to, spawnText(memo, to, intent)))
308 await update($, spawning, () => '')
309 }
310 const spawnAt = (path: string, intent: Intent) => {
311 const to = pathDestination(path)
312 if ('failure' in to) return void update($, notice, () => to.failure)
313 void spawn(to, intent)
314 }
315 return (
316 <Box key="memo" flexDirection="column" borderStyle="round" borderDimColor paddingX={1} marginLeft={GUTTER}>
317 {memoLines(memo).map((line, i) => (
318 <Text key={`memo-line-${i}`} dimColor>
319 {line}
320 </Text>
321 ))}
322 {tasks.length === 0 && <Text dimColor>この repo に送り先のセッションがありません (タスクにするには同じ repo のセッションが要ります)</Text>}
323 <Box key="actions:memo" flexDirection="row" flexWrap="wrap" gap={1}>
324 <Button key="discard" onPress={() => void act(() => discard($, memo))}>
325 Discard
326 </Button>
327 {tasks.map(to => (
328 <Button key={`task:${destinationKey(to)}`} onPress={() => void act(() => handOff($, memo, to, taskText(memo)))}>
329 To task → {destinationLabel(to)}
330 </Button>
331 ))}
332 <Button key="spawn-task" onPress={() => void update($, spawning, () => 'task')}>
333 To task → new session…
334 </Button>
335 {issueDestinations(data, repo.sessions, self).map(to => (
336 <Button key={`issue:${destinationKey(to)}`} onPress={() => void act(() => handOff($, memo, to, issueText(memo, repo.title)))}>
337 Ask for issue → {destinationLabel(to)}
338 </Button>
339 ))}
340 <Button key="spawn-issue" onPress={() => void update($, spawning, () => 'issue')}>
341 Ask for issue → new session…
342 </Button>
343 </Box>
344 {isSpawning !== '' && (
345 <Box key="spawn" flexDirection="column">
346 <Text dimColor>{isSpawning === 'task' ? 'タスクにする' : 'issue にするよう頼む'}新しいセッションを起こす repo を選ぶ:</Text>
347 <Box flexDirection="row" flexWrap="wrap" gap={1}>
348 {spawnDestinations(data, repo).map(to => (
349 <Button key={`spawn:${destinationKey(to)}`} onPress={() => void spawn(to, isSpawning)}>
350 {destinationLabel(to)}
351 </Button>
352 ))}
353 <Button key="spawn-cancel" dimColor onPress={() => void update($, spawning, () => '')}>
354 Cancel
355 </Button>
356 </Box>
357 {Input !== undefined && (
358 <Input key="spawn-path" label="別の repo の絶対パス" placeholder="/path/to/repo" submitLabel="Spawn" onSubmit={(text: string) => spawnAt(text, isSpawning)} />
359 )}
360 </Box>
361 )}
362 </Box>
363 )
364 }
365
366 // root は中身と凡例の 2 つだけを持つ。pane の高さに満たないときは凡例を底に寄せ、はみ出すときは中身の後に続く。
367 return (
368 <Box key="root" flexDirection="column" minHeight={e.props.scroll.bodyRows} justifyContent="space-between">
369 <Box key="content" flexDirection="column">
370 {failure !== '' && <Text color="red">依存図を読めません: {failure}</Text>}
371 {!data.daemon_running && <Text dimColor>agents-daemon が動いていないため、セッションの状態は出ません</Text>}
372 <Text>{summary(g, memos.length)}</Text>
373 {g.nodes.length === 0 && memos.length === 0 && <Text dimColor>タスクはまだありません</Text>}
374 {rows.flatMap((r, i) => {
375 const out = [row(r, i)]
376 if (r.node === chosen && selected !== undefined) out.push(detail(selected, splitLanes(r.lanes).left))
377 return out
378 })}
379 {memos.length > 0 && (
380 <Box key="section:parking" flexDirection="column">
381 <Box flexDirection="row" gap={1}>
382 <Text bold>
383 {MEMO} parking {String(memos.length)}
384 </Text>
385 {/* 古いメモをまとめて捨てる。該当があるときだけ出し、日数はこの mod が決める (park discard --days)。 */}
386 {stale > 0 && (
387 <Button key="discard-stale" onPress={() => void act(() => discardStale($, staleDays))}>
388 Discard {String(stale)} older than {String(staleDays)}d
389 </Button>
390 )}
391 </Box>
392 {memos.map(memo => memoRow(memo))}
393 {chosenMemo !== undefined && memoDetail(chosenMemo)}
394 </Box>
395 )}
396 {selected !== undefined && !rows.some(r => r.node === chosen) && <Text dimColor>選んだタスクは今の図に出ていません (完了を畳んでいる)</Text>}
397 {sent !== '' && <Text dimColor>{sent}</Text>}
398 </Box>
399 {/* 凡例は詳細と同じ枠で囲む。枠と詰めの分 (左右 2 桁ずつ) を引いた幅で行を組む。完了の出し入れは
400 図の見せ方の切り替えなので、数の行に置かず凡例の枠の最後に枠なしで置く。 */}
401 <Box key="legend" flexDirection="column" borderStyle="round" borderDimColor paddingX={1} marginTop={1}>
402 {legendRows(e.props.bodyColumns - 4).map((line, i) => (
403 <Box key={`legend:${i}`}>
404 <Text dimColor>{line}</Text>
405 </Box>
406 ))}
407 {g.nodes.some(n => n.state === 'completed') && (
408 <Button key="fold" plain onPress={() => void update($, showCompleted, () => !withCompleted)}>
409 <Text dimColor>{withCompleted ? '› Hide completed' : '› Show completed'}</Text>
410 </Button>
411 )}
412 </Box>
413 </Box>
414 )
415 })
416}
417
418// refresh は依存図を読み直す (hooks/deps-data.ts)。開いたとき (opened) は数をログに書き、それからは数か失敗の理由が
419// 変わったときだけ書く。
420async function refresh($: EngineInterface, opened = false) {
421 const loaded = await loadDeps($)
422 if ('failure' in loaded) {
423 await logChange($, 'deps', 'mod.deps.failed', { reason: loaded.failure })
424 return
425 }
426 if (opened) await settle($, 'deps')
427 await logChange($, 'deps', opened ? 'mod.deps.open' : 'mod.deps.changed', counts(loaded.data))
428}
429
430// counts は依存図の大きさ。タスクの title とメモの title は書かない。
431function counts(data: DepsData): Fields {
432 return {
433 repos: data.repos.length,
434 sessions: data.repos.reduce((n, repo) => n + repo.sessions.length, 0),
435 nodes: data.graph.nodes.length,
436 memos: data.repos.reduce((n, repo) => n + repo.memos.length, 0),
437 daemon_running: data.daemon_running,
438 }
439}
440
441// edge は `agents-daemon edge ...` を走らせ、失敗の理由を返す (成功なら空)。
442async function edge($: EngineInterface, args: string[]): Promise<string> {
443 try {
444 const { exitCode, stderr } = await $.process.run([await daemonBin($), 'edge', ...args], { timeoutMs: 10000 })
445 return exitCode === 0 ? '' : stderr.trim() || `exit ${exitCode}`
446 } catch (err) {
447 return String(err)
448 }
449}
450
451// unlink は辺の元の記録を消す。session: の端点の辺は複数の辺になって描かれるので、まとめて消える。
452async function unlink($: EngineInterface, link: GraphLink): Promise<string> {
453 const e = link.edge
454 if (e === undefined) return ''
455 const failure = await edge($, ['rm', '--before', e.before.ref, '--before-cwd', e.before.cwd, '--after', e.after.ref, '--after-cwd', e.after.cwd])
456 return failure === '' ? `辺を外しました: ${e.before.ref} → ${e.after.ref}` : `辺を外せませんでした: ${failure}`
457}
458
459// addBlocker は before が済まないと after を始められない辺を書く。端点はノードの key そのもの。
460async function addBlocker($: EngineInterface, before: GraphNode, after: GraphNode): Promise<string> {
461 const failure = await edge($, ['add', '--before', before.key, '--before-cwd', before.cwd ?? '', '--after', after.key, '--after-cwd', after.cwd ?? ''])
462 return failure === '' ? `辺を足しました: ${before.title} → ${after.title}` : `辺を足せませんでした: ${failure}`
463}
464
465// deliver はノードのセッションへ text を送り、結果の文面を返す。送った事実だけをログに書く (文面は書かない)。
466async function deliver($: EngineInterface, node: GraphNode, text: string, via: string): Promise<string> {
467 const to = node.session ?? node.session_id ?? ''
468 const event = { via, to: node.session_id ?? '' }
469 try {
470 const result = await $.session.send({ to: { sessionId: node.session_id ?? '' }, text })
471 await logEvent($, 'mod.send', result.isDelivered ? { ...event, delivered: true } : { ...event, delivered: false, reason: result.reason })
472 return result.isDelivered ? `${to} へ送りました` : `${to} へ送れませんでした: ${result.reason}`
473 } catch (err) {
474 await logEvent($, 'mod.send', { ...event, delivered: false, reason: String(err) })
475 return `${to} へ送れませんでした: ${String(err)}`
476 }
477}
478
479// memoKey は pane でメモを選ぶ key。ノードの key (Ref) の種類に無い前置き park: を付けて重ならないようにする。
480function memoKey(memo: ParkingCard): string {
481 return `park:${memo.id}`
482}
483
484function findMemo(repos: DepsRepo[], key: string): { memo: ParkingCard; repo: DepsRepo } | undefined {
485 for (const repo of repos) {
486 const memo = repo.memos.find(m => memoKey(m) === key)
487 if (memo) return { memo, repo }
488 }
489 return undefined
490}
491
492// heading は詳細の 1 行目。タスクの無いセッションのノードは key の代わりに名前を出し、同名のセッションを
493// 見分けられるよう session ID の先頭を添える。
494function heading(n: GraphNode): string {
495 if (n.kind === 'session') return `${n.session ?? '-'} · ${(n.session_id ?? '').slice(0, 8)}`
496 return `${n.key} · ${n.session ?? '-'}`
497}
498
499// canSend はノードのセッションへ指示を送れるか。終了したセッション、セッションの無い置き場のノード、自分自身には送らない。
500function canSend(node: GraphNode, self: string): boolean {
501 return node.live && node.session_id !== undefined && node.session_id !== '' && node.session_id !== self
502}
503
504function whyNot(node: GraphNode, self: string): string {
505 if (node.session_id === self) return 'このセッションのタスクです'
506 if (node.session_id === undefined || node.session_id === '') return 'まだ受け持つセッションがありません'
507 return 'このセッションは終了しています'
508}
509
510// instruction は送る文面。タスクのノードなら、どのタスクについての指示かを title で添える。
511function instruction(node: GraphNode, text: string): string {
512 if (node.kind !== 'task') return text
513 return `タスク「${node.title}」について: ${text}`
514}
515
516// unlinkText は同じセッションの blockedBy の解消を頼む文面。TaskUpdate に blockedBy を外す引数が無いので、
517// 待たれている側を片付けるか、待っている側を作り直すよう頼む。
518function unlinkText(before: GraphNode, after: GraphNode): string {
519 const id = (n: GraphNode) => n.task_id ?? ''
520 return [
521 `タスク #${id(after)}「${after.title}」が #${id(before)}「${before.title}」の完了待ち (blockedBy) になっています。`,
522 `この待ちが要らなければ、#${id(before)} を completed か deleted にするか、#${id(after)} を blockedBy なしで作り直してください。`,
523 ].join('')
524}
525hooks/handoff-notice.ts 63 lines1import type { EngineInterface, Register, ToolCallResult } from 'claude-code'
2
3import { loadDeps } from './deps-data'
4import { daemonBin } from './daemon-bin'
5import { Graph, unlinked } from './graph'
6import { logChange, logEvent, reason, settle } from './log'
7
8// model が SendMessage で渡した宛先に依存の辺が無ければ、その tool の結果の context で辺の書き方を model に伝える (#172)。
9// 渡す側が辺を書くのは agents-daemon:peer-sessions skill の手順で、書き忘れに気付けるのは送った model だけなので、
10// 人ではなく model に伝える。
11// - 宛先は依存図の生きている済んでいないセッションで、`edge add` の辺がどの端点にも無いもの (hooks/graph.ts の unlinked)
12// - 送信がエラーになったもの、文面の無いもの (notify_when_idle だけ、shutdown などの要求)、subagent の送信は見ない
13// - mod 自身の送信 ($.session.send) は tool.call を通らないので、ここには来ない
14// cc-premise: 2.1.310 mods SendMessage の tool.call は e.to (ListAgents の名前、同名なら末尾に ` [ref]`) と e.message を持ち、結果の context は model だけが読む
15// cc-premise: 2.1.310 mods 届かなかった SendMessage は isError の結果になるとみなす (型は result を unknown とし、確かめていない)
16
17export const registerHandoffNotice: Register = on => {
18 on('tool.call', { tool: 'SendMessage' }, async ($, e, next) => {
19 const sent = await next(e)
20 if (sent.deny !== undefined || sent.isError || e.agentId !== undefined) return sent
21 if (typeof e.to !== 'string' || typeof e.message !== 'string' || e.message === '') return sent
22
23 return nudge($, recipient(e.to), sent)
24 })
25}
26
27// recipient は SendMessage の宛先からセッション名を取る。同名が複数あるときに付く末尾の ` [ref]` を落とす。
28function recipient(to: string): string {
29 return to.replace(/\s+\[[^\]]*\]$/, '').trim()
30}
31
32// nudge は name に辺が無ければ、辺の書き方を sent の context に添える。伝えるのは補助なので、依存図を読めなくても
33// 送信の結果はそのまま返す。
34async function nudge<R extends ToolCallResult>($: EngineInterface, name: string, sent: R): Promise<R> {
35 try {
36 const loaded = await loadDeps($)
37 if ('failure' in loaded) {
38 await logChange($, 'handoff', 'mod.handoff.failed', { reason: loaded.failure })
39 return sent
40 }
41 await settle($, 'handoff')
42 const g = new Graph(loaded.data.graph)
43 if (unlinked(g, [name]).length === 0) return sent
44
45 const peer = g.nodes.find(n => n.session_name === name && n.live && n.state !== 'completed')
46 await logEvent($, 'mod.handoff.unlinked', { session: peer?.session_id ?? '' })
47 return { ...sent, context: [...(sent.context ?? []), describe(name, peer?.cwd, await daemonBin($))] }
48 } catch (err) {
49 await logChange($, 'handoff', 'mod.handoff.failed', { reason: reason(err, 'handoff') })
50 return sent
51 }
52}
53
54function describe(name: string, cwd: string | undefined, bin: string): string {
55 return [
56 `${name} へ送りましたが、${name} を端点に持つ依存の辺がありません。`,
57 `作業を渡していて、${name} がほかの作業 (別の peer の作業、マージ待ちの PR) の後でないと始められないなら、`,
58 'agents-daemon:peer-sessions skill の「依存の辺を書く」に従って辺を書いてください。',
59 ` ${bin} edge add --before <待つ相手の参照> --before-cwd <その cwd> --after session:${name} --after-cwd ${cwd ?? '<その cwd>'} --note "<何の後か>"`,
60 '返事や報告のように依存の無い送信なら、辺は要りません。',
61 ].join('\n')
62}
63hooks/notice.ts 112 lines1import { atom, update } from 'claude-code'
2import type { EngineInterface, Register, ToolCallResult } from 'claude-code'
3
4import { daemonBin } from './daemon-bin'
5import { logChange, logEvent, reason, settle } from './log'
6import { kindLabel } from './parking'
7
8// model がファイルを読む・書いたら、そのファイルを files に持つパーキングのメモを tool の結果の context で model に
9// 伝える (#89)。人には出さない (メモの一覧は依存図の pane が持つ)。
10// - メモは tool の結果のたびに `park list --status open --here` (この repo の open のメモ) を読んで選ぶ
11// - このセッションだけが置いたメモは model が自分で置いたものなので伝えない
12// - 伝えるのは 1 つの context の中で 1 回。選ぶのと told に足すのを 1 回の update で行い、並列の tool の hook が
13// 同じメモを 2 回添えないようにする (models/handoff/FileNotice.tla の AtMostOnce)
14// - main の会話が compact されたら told を空にし、compact の後に触ったファイルのメモを伝え直す (#273、同じモデルの
15// Informed)。添えたメモが要約に残るかは決まらないので、残らない側に倒す
16// - subagent の tool は main の model に届かないので、伝えず told にも足さない
17// cc-premise: 2.1.310 mods tool.call の結果の context は model だけが読み、e.agentId は subagent の呼び出しにだけ付く
18// cc-premise: 2.1.310 mods session.compact の next(e) は置き換えた会話 (messages) か skip を返す。precompute は何も置き換えず、e.agentId は subagent の compact にだけ付く
19
20const told = atom({ plugin: 'agents-daemon', key: 'told' } as const, [])
21
22// Memo は `park list` が出すメモ (internal/parking の Item) のうち、ここで読む欄。
23type Memo = {
24 id: string
25 repo: string
26 kind: string
27 origin?: string
28 title: string
29 detail?: string
30 files?: string[]
31 default_taken?: string
32 sources: { session_id: string }[]
33}
34
35// repo の下に置く worktree (Claude Code の EnterWorktree の置き場)。
36const WORKTREES = '/.claude/worktrees/'
37
38export const registerNotice: Register = on => {
39 on('tool.call', { tool: 'Read' }, async ($, e, next) => notice($, e.tool, e.file_path, e.agentId, await next(e)))
40 on('tool.call', { tool: 'Edit' }, async ($, e, next) => notice($, e.tool, e.file_path, e.agentId, await next(e)))
41 on('tool.call', { tool: 'Write' }, async ($, e, next) => notice($, e.tool, e.file_path, e.agentId, await next(e)))
42
43 on('session.compact', async ($, e, next) => {
44 const compacted = await next(e)
45 if (compacted.skip === undefined && e.agentId === undefined && e.trigger !== 'precompute') {
46 await update($, told, () => [])
47 }
48
49 return compacted
50 })
51}
52
53// notice は tool の結果に path のメモを添える。通知は補助なので、失敗しても tool の結果はそのまま返す。
54async function notice<R extends ToolCallResult>($: EngineInterface, tool: string, path: string, agentId: string | undefined, ran: R): Promise<R> {
55 if (ran.deny !== undefined || ran.isError || agentId !== undefined) return ran
56 try {
57 const memos = await claim($, path)
58 await settle($, 'notice')
59 if (memos.length === 0) return ran
60 await logEvent($, 'mod.notice', { tool, memos: memos.map(m => m.id).join(',') })
61 return { ...ran, context: [...(ran.context ?? []), describe(path, memos)] }
62 } catch (err) {
63 await logChange($, 'notice', 'mod.notice.failed', { reason: reason(err, 'park list') })
64 return ran
65 }
66}
67
68// claim は path に関係するメモのうち、まだ伝えていないものを told に足して返す。一覧は update の前に読み、選ぶのと
69// 足すのは update の中で行う。
70async function claim($: EngineInterface, path: string): Promise<Memo[]> {
71 const self = await $.session.id()
72 const relevant = (await openMemos($, self)).filter(
73 memo => memo.sources.some(s => s.session_id !== self) && (memo.files ?? []).some(f => inRepo(f, memo.repo) === inRepo(path, memo.repo)),
74 )
75 let fresh: Memo[] = []
76 await update($, told, prev => {
77 fresh = relevant.filter(memo => !prev.includes(memo.id))
78 return fresh.length === 0 ? prev : [...prev, ...fresh.map(memo => memo.id)]
79 })
80 return fresh
81}
82
83// openMemos はこのセッションの repo の open のメモを読む。読めなければ投げる。
84async function openMemos($: EngineInterface, self: string): Promise<Memo[]> {
85 const argv = [await daemonBin($), 'park', 'list', '--status', 'open', '--here', '--session', self, '--cwd', await $.session.cwd()]
86 const { exitCode, stdout, stderr } = await $.process.run(argv, { timeoutMs: 10000 })
87 if (exitCode !== 0) throw new Error(stderr.trim() || `park list: exit ${exitCode}`)
88 return JSON.parse(stdout) as Memo[]
89}
90
91// inRepo はパスを repo からの相対パスにする。repo の下の worktree の中なら worktree からの相対にする。
92// repo の外の絶対パスはそのまま返す。
93function inRepo(path: string, repo: string): string {
94 if (path.startsWith(repo + WORKTREES)) {
95 const rest = path.slice(repo.length + WORKTREES.length)
96 const slash = rest.indexOf('/')
97 return slash < 0 ? '' : rest.slice(slash + 1)
98 }
99 if (path.startsWith(repo + '/')) return path.slice(repo.length + 1)
100 return path.replace(/^(\.\/)+/, '')
101}
102
103function describe(path: string, memos: Memo[]): string {
104 const lines = [`${path} に関係するパーキングのメモ (ほかのセッションが置いた脇のこと)。今の作業の範囲は変えず、関わるときだけ考慮する。`]
105 for (const memo of memos) {
106 lines.push(`- [${kindLabel(memo)}] ${memo.title} (${memo.id})`)
107 if (memo.detail) lines.push(` ${memo.detail}`)
108 if (memo.default_taken) lines.push(` 置いた側の既定: ${memo.default_taken}`)
109 }
110 return lines.join('\n')
111}
112hooks/park.tsx 79 lines1import type { EngineInterface, Register } from 'claude-code'
2
3import { daemonBin } from './daemon-bin'
4import { logEvent } from './log'
5
6// パーキングロット (#89) の置く側 (#121 の段 5)。model が呼べる park の tool を登録し、呼ばれたら
7// `agents-daemon park add` に JSON を stdin で渡す。repo はバイナリが --cwd から決める。
8// cc-premise: 2.1.302 mods tool.register / tool.call の API で書いた
9
10const TOOL = 'park'
11
12const DESCRIPTION = `作業の途中で見つけた脇のことを、ユーザーに聞かずにメモとしてパーキングロットへ置き、今の作業を続けるための tool。
13置いたメモはセッションが終わっても残り、後で依存図の pane から取り出される。
14
15置けるのは次の 3 種類だけ (kind):
16- aside: 脇で見つけたもの。例「関係ないが、ここにバグがある」
17- scope: 範囲の相談。例「ついでに X も直すか」。既定は「やらない」で進める
18- preference: 好みの確認。例「命名は A と B のどちらか」。仮に決めて進め、確認だけを置く
19
20次のものは置かずに、ユーザーに聞く:
21- 進めるのに要る判断。例「この API の仕様がわからない」。答えが無いと今の作業が正しく終わらないもの
22- 危ない操作の確認。例「削除してよいか」「force push してよいか」
23
24default_taken には、置いたメモについて今どうしたか (「やらない」、仮に決めた内容) を書く。`
25
26const INPUT_SCHEMA = {
27 type: 'object',
28 properties: {
29 kind: { type: 'string', enum: ['aside', 'scope', 'preference'], description: 'メモの種類' },
30 title: { type: 'string', description: 'メモを 1 行で。同じ repo に同じ title の open のメモがあれば 1 つにまとまる' },
31 detail: { type: 'string', description: '後で取り出した人が文脈を復元できる説明' },
32 files: { type: 'array', items: { type: 'string' }, description: '関係するファイルのパス (repo からの相対か絶対)。別のセッションがそのファイルを触ったとき、その model に伝わる' },
33 default_taken: { type: 'string', description: '今どうしたか (「やらない」、仮に決めた内容)' },
34 },
35 required: ['kind', 'title'],
36}
37
38// model が書ける欄。session_id は model に書かせず、このセッションの ID を入れる。
39const FIELDS = ['kind', 'title', 'detail', 'files', 'default_taken'] as const
40
41type Added = { created: boolean; item: { id: string } }
42
43// startPark は session.start で park の tool を登録する (session.start の hook は hooks/register.ts が持つ)。
44// 登録の失敗は engine が画面に一度出すだけなので、理由をログに残してから投げ直す (hook を飛ばす扱いは engine に任せる)。
45export async function startPark($: EngineInterface) {
46 try {
47 await $.tool.register({ name: TOOL, description: DESCRIPTION, inputSchema: INPUT_SCHEMA, isDeferred: false })
48 } catch (err) {
49 await logEvent($, 'mod.tool-register.failed', { reason: String(err) })
50 throw err
51 }
52}
53
54export const registerPark: Register = on => {
55 // matcher は validate が読めるよう文字列のまま書く (TOOL と plugin 名から決まる名前)。
56 on('tool.call', { tool: 'mcp__agents-daemon__park' }, ($, e) =>
57 place($, e as unknown as Record<string, unknown>).catch(err => ({ deny: `メモを置けませんでした: ${String(err)}` })),
58 )
59}
60
61// place は model が書いた欄にこのセッションの ID を足して park add に渡し、置いた結果を model への文面にする。
62// 置いたメモは、開いている pane の次の読み直しで出る。
63async function place($: EngineInterface, args: Record<string, unknown>): Promise<{ result: string } | { deny: string }> {
64 const req: Record<string, unknown> = {}
65 for (const field of FIELDS) {
66 if (args[field] !== undefined) req[field] = args[field]
67 }
68 req.session_id = await $.session.id()
69
70 const argv = [await daemonBin($), 'park', 'add', '--cwd', await $.session.cwd()]
71 const { exitCode, stdout, stderr } = await $.process.run(argv, { stdin: JSON.stringify(req), timeoutMs: 10000 })
72 if (exitCode !== 0) return { deny: `メモを置けませんでした: ${stderr.trim() || `exit ${exitCode}`}` }
73
74 const added = JSON.parse(stdout) as Added
75 const proceed = typeof req.default_taken === 'string' && req.default_taken !== '' ? `既定 (${req.default_taken}) で進めてください。` : '既定で進めてください。'
76 if (added.created) return { result: `メモを置きました (${added.item.id})。${proceed}` }
77 return { result: `既に置かれていたメモ (${added.item.id}) にまとめました。${proceed}` }
78}
79hooks/daemon-bin.ts 9 lines1import type { EngineInterface } from 'claude-code'
2
3// daemonBin は hooks/lib/daemon-bin.sh と同じ規則でバイナリの置き場を返す (AGENTS.md「パス定数の対」)。
4export async function daemonBin($: EngineInterface): Promise<string> {
5 const cache = (await $.env.get('XDG_CACHE_HOME')) || `${await $.env.get('HOME')}/.cache`
6
7 return `${cache}/agents-daemon/bin/agents-daemon`
8}
9hooks/deps-data.ts 37 lines1import { atom, update } from 'claude-code'
2import type { EngineInterface } from 'claude-code'
3
4import type { DepsData } from '../types'
5import { daemonBin } from './daemon-bin'
6import { reason } from './log'
7
8// `agents-daemon deps` を読んで deps に置く。依存図の pane (hooks/deps.tsx) と、渡した宛先の辺を確かめる
9// hooks/handoff-notice.ts が同じ依存図を読むので、読み込みはここ 1 箇所にする。ログの書き方は読み手ごとに違うので
10// 呼び出し側が持つ。
11
12// atom は読み書きする module ごとに書く (state の scan が import を辿らない)。
13const deps = atom({ plugin: 'agents-daemon', key: 'deps' } as const, null)
14const error = atom({ plugin: 'agents-daemon', key: 'error' } as const, '')
15
16// loadDeps は依存図を読み直して deps に置き、失敗の理由を返す (読めたら空)。失敗は error に置き、前の依存図は残す。
17// 開いたセッションの cwd を渡し、その repo を here にさせる。
18export async function loadDeps($: EngineInterface): Promise<{ data: DepsData } | { failure: string }> {
19 const failed = async (failure: string) => {
20 await update($, error, () => failure)
21 return { failure }
22 }
23 try {
24 const argv = [await daemonBin($), 'deps', '--cwd', await $.session.cwd()]
25 const { exitCode, stdout, stderr } = await $.process.run(argv, { timeoutMs: 10000 })
26 if (exitCode !== 0) return failed(stderr.trim() || `exit ${exitCode}`)
27 const data = JSON.parse(stdout) as DepsData
28 if (!Array.isArray(data.graph?.nodes)) return failed('deps の JSON に graph が無い')
29 await update($, deps, () => data)
30 await update($, error, () => '')
31 return { data }
32 } catch (err) {
33 // バイナリがまだ build されていない間もここに来る。
34 return failed(reason(err, 'deps'))
35 }
36}
37hooks/graph.ts 282 lines1import type { DepsGraph, GraphLink, GraphNode } from '../types'
2
3// 依存の DAG (#134、`agents-daemon deps` の graph) の見せ方。記号、数の行、レーンの行の分け方、Add blocker の候補、
4// 辺の書き忘れを疑う渡し先を持つ。段・下流の数・原因の鎖・レーンの行はバイナリ (internal/deps/graph、internal/deps/dag) が
5// 数えて渡すので、ここは並べるだけにする。描くのは hooks/deps.tsx (依存図の pane)。
6
7// 記号は絵文字を避け、端末の等幅で 1 桁に収まるもの。語は OSC 7501 の state / kind と Task 系ツールの status。
8export const ICON: Record<GraphNode['state'], string> = {
9 working: '▸',
10 done: '↵',
11 unknown: '·',
12 question: '?',
13 permission: '!',
14 error: '✗',
15 limit: '⧗',
16 pending: '○',
17 completed: '✓',
18 exited: '⊘',
19}
20
21// 色 (テーマの鍵) は 1 つの鍵に 1 つの意味だけを持たせる。
22// suggestion 進行中 / warning 人の返事待ち (question、permission) / permission 時間待ち (limit) /
23// error 失敗 / success longest chain の辺の罫線
24// ほかの状態、レーンの罫線、メモ (◇) には色を使わない (メモの見出しは太字)。
25
26// COLOR は記号の色。無い状態は既定の色。
27export const COLOR: Partial<Record<GraphNode['state'], string>> = {
28 working: 'suggestion',
29 question: 'warning',
30 permission: 'warning',
31 limit: 'permission',
32 error: 'error',
33}
34
35export const STOP = '■'
36export const MEMO = '◇'
37export const CYCLE = '⟳'
38
39// LEGEND は依存図の pane の底に置く凡例。数の行の記号、行だけに出る状態、行の末尾の印の 3 群に分ける。語は記号と同じく
40// OSC 7501 の state と Task 系ツールの status、それ以外は graph の JSON の欄の名前 (blocked_by、parked、downstream、
41// deadlock)。↵ だけは state の done ではなく idle と書く。数の行の ↵ は isIdle (done と unknown) を数え、
42// 隣の completed と並ぶと done は「終わった」と読めて、親の確認を待つセッションを見落とすため。
43const LEGEND: readonly (readonly string[])[] = [
44 [`${STOP} blocked`, `${ICON.pending} pending`, `${ICON.working} working`, `${ICON.done} idle`, `${ICON.completed} completed`, `${MEMO} parked`],
45 [`${ICON.question} question`, `${ICON.permission} permission`, `${ICON.limit} limit`, `${ICON.error} error`, `${ICON.exited} exited`, `${ICON.unknown} unknown`],
46 ['↓n downstream', `${CYCLE} deadlock`],
47]
48
49// LEGEND_GAP は凡例の列の間の桁数。
50const LEGEND_GAP = 2
51
52// legendRows は凡例を width 桁に収まる行にしたもの。群ごとに項目を格子で並べ、群の間は width 桁の罫線で分ける。
53// 列の幅は全部の群で一番長い項目に揃え、列の数は幅から決める。折り返しに任せると項目の途中や行ごとに見た目が
54// 変わるので、行は自分で組む。
55export function legendRows(width: number): string[] {
56 const cell = Math.max(...LEGEND.flatMap(g => g.map(cells)))
57 const columns = Math.max(1, Math.floor((width + LEGEND_GAP) / (cell + LEGEND_GAP)))
58 const pad = (text: string) => text + ' '.repeat(cell + LEGEND_GAP - cells(text))
59 return LEGEND.flatMap((g, k) => {
60 const rows: string[] = k === 0 ? [] : ['─'.repeat(Math.max(0, width))]
61 for (let i = 0; i < g.length; i += columns) {
62 const line = g.slice(i, i + columns)
63 rows.push(line.map((item, j) => (j === line.length - 1 ? item : pad(item))).join(''))
64 }
65 return rows
66 })
67}
68
69// Graph は graph の JSON を Key で引けるようにしたもの。
70export class Graph {
71 readonly nodes: GraphNode[]
72 readonly links: GraphLink[]
73 private readonly byKey = new Map<string, GraphNode>()
74 private readonly succs = new Map<string, GraphLink[]>()
75 private readonly preds = new Map<string, GraphLink[]>()
76
77 constructor(data: DepsGraph) {
78 this.nodes = data.nodes
79 this.links = data.links
80 for (const n of data.nodes) this.byKey.set(n.key, n)
81 for (const l of data.links) {
82 this.succs.set(l.before, [...(this.succs.get(l.before) ?? []), l])
83 this.preds.set(l.after, [...(this.preds.get(l.after) ?? []), l])
84 }
85 }
86
87 node(key: string): GraphNode | undefined {
88 return this.byKey.get(key)
89 }
90
91 // after は key の下流の直近 (key が済まないと始められないノード)。
92 after(key: string): GraphNode[] {
93 return (this.succs.get(key) ?? []).flatMap(l => this.byKey.get(l.after) ?? [])
94 }
95
96 // blockers は key を止めている辺 (上流の直近)。
97 blockers(key: string): GraphLink[] {
98 return this.preds.get(key) ?? []
99 }
100
101 // reach は key から下流へたどれるノードの Key (key 自身は含めない)。
102 reach(key: string): Set<string> {
103 return this.walk(key, k => this.after(k).map(n => n.key))
104 }
105
106 // upstream は key から上流へたどれるノードの Key (key 自身は含めない)。
107 upstream(key: string): Set<string> {
108 return this.walk(key, k => this.blockers(k).map(l => l.before))
109 }
110
111 private walk(key: string, next: (k: string) => string[]): Set<string> {
112 const seen = new Set<string>()
113 const stack = next(key)
114 while (stack.length > 0) {
115 const k = stack.pop() as string
116 if (k === key || seen.has(k)) continue
117 seen.add(k)
118 stack.push(...next(k))
119 }
120 return seen
121 }
122}
123
124// isIdle はターンを終えて待っているか、状態を分類できない (作業中とは限らない) ノードか。
125function isIdle(node: GraphNode): boolean {
126 return node.state === 'done' || node.state === 'unknown'
127}
128
129// blockedRoots は数の行の ■: 止まっているノード (stuck)。セッション自身は止まっていない (halted でない) 待ちの輪の
130// ノードは、既に数えたノードの下流にいるものを数えない (輪の 1 周を 1 つに数える)。受け取った順 (下流の多い順)。
131export function blockedRoots(g: Graph): GraphNode[] {
132 const blocked: GraphNode[] = []
133 const covered = new Set<string>()
134 for (const n of g.nodes) {
135 if (!n.stuck) continue
136 if (!n.halted && covered.has(n.key)) continue
137 blocked.push(n)
138 covered.add(n.key)
139 for (const k of g.reach(n.key)) covered.add(k)
140 }
141 return blocked
142}
143
144// behind は止まっているノード (■) の下流にいるノードの数 (■ は数えない)。
145export function behind(g: Graph, blocked: GraphNode[]): number {
146 const roots = new Set(blocked.map(n => n.key))
147 const seen = new Set<string>()
148 for (const n of blocked) for (const k of g.reach(n.key)) if (!roots.has(k)) seen.add(k)
149 return seen.size
150}
151
152// summary は数の 1 行: 止まっているノード、その下流、進行中、待っている (分類できないものを含む)、完了、メモ。
153export function summary(g: Graph, memos: number): string {
154 const blocked = blockedRoots(g)
155 const count = (test: (n: GraphNode) => boolean) => g.nodes.filter(test).length
156 return [
157 `${STOP} ${blocked.length}`,
158 `${ICON.pending} ${behind(g, blocked)}`,
159 `${ICON.working} ${count(n => n.state === 'working')}`,
160 `${ICON.done} ${count(isIdle)}`,
161 `${ICON.completed} ${count(n => n.state === 'completed')}`,
162 `${MEMO} ${memos}`,
163 ].join(' ')
164}
165
166// CHAIN_COLOR は longest chain の辺の罫線の色。止まっているノードの記号 (warning など) と別の色にし、
167// 「止まっている」と「先に解く道筋」を色で取り違えないようにする。
168export const CHAIN_COLOR = 'success'
169
170export type Piece = { text: string; marked: boolean }
171
172// pieces は lanes の [from, to) を印 (marks、internal/deps/dag の Row.Marks) の有無で切った区間。罫線は 1 桁 1 文字
173// (BMP) なので、文字列の位置がそのまま桁になる。区間が無くても 1 つ返す (描く Text の数を行で揃える)。
174export function pieces(lanes: string, marks: readonly number[] | undefined, from: number, to: number): Piece[] {
175 const marked = new Set(marks ?? [])
176 const out: Piece[] = []
177 for (let i = from; i < to; i++) {
178 const m = marked.has(i)
179 const tail = out.at(-1)
180 const c = lanes.charAt(i)
181 if (tail !== undefined && tail.marked === m) tail.text += c
182 else out.push({ text: c, marked: m })
183 }
184 return out.length === 0 ? [{ text: '', marked: false }] : out
185}
186
187// splitLanes はレーンの行の罫線を、ノードの記号の左右に分ける。`*` がノードの位置 (internal/deps/dag の Rows)。
188// `*` が無い行 (辺をつなぐだけの行) は全部が left。
189export function splitLanes(lanes: string): { left: string; right: string } {
190 const at = lanes.indexOf('*')
191 if (at < 0) return { left: lanes, right: '' }
192 return { left: lanes.slice(0, at), right: lanes.slice(at + 1) }
193}
194
195// candidates は selected の Add blocker の候補: 辺の端点にできる (endpoint の) ノードのうち、自分、既にある上流、
196// 下流 (足すと輪になる)、完了と終了したものを除く。
197export function candidates(g: Graph, selected: GraphNode): GraphNode[] {
198 const down = g.reach(selected.key)
199 const already = new Set(g.blockers(selected.key).map(l => l.before))
200 return g.nodes.filter(
201 n =>
202 n.endpoint &&
203 n.key !== selected.key &&
204 !already.has(n.key) &&
205 !down.has(n.key) &&
206 n.state !== 'completed' &&
207 n.state !== 'exited',
208 )
209}
210
211// unlinked は handed (model が SendMessage で送った宛先の名前、hooks/handoff-notice.ts) のうち、辺の書き忘れを
212// 疑うもの: 依存図に生きていて済んでいないノードを持つセッションで、`edge add` の辺 (kind edge) がどの端点にも
213// 無いもの。辺はノードへ解決された端点 (そのセッションのタスク) と、元の記録の参照 (`session:<名前>`) のどちらで
214// 当たってもよい。handed の順を保つ。
215// 名前は表示名 (session、同名なら ID の先頭が付く) ではなく session_name (SendMessage の宛先に書く形) で当てる。
216export function unlinked(g: Graph, handed: string[]): string[] {
217 const sessionOf = (key: string) => g.node(key)?.session_name ?? ''
218 return handed.filter(name => {
219 const present = g.nodes.some(n => n.session_name === name && n.live && n.state !== 'completed')
220 if (!present) return false
221 return !g.links.some(
222 l =>
223 l.kind === 'edge' &&
224 (sessionOf(l.before) === name || sessionOf(l.after) === name || l.edge?.before.ref === `session:${name}` || l.edge?.after.ref === `session:${name}`),
225 )
226 })
227}
228
229// tags は行の末尾の印: 下流の数 (showDown のとき)、途中で置いたメモの数、待ちの輪。pane は止まっているノードに
230// 下流の数を出す。
231export function tags(node: GraphNode, showDown: boolean): string {
232 const parts: string[] = []
233 if (showDown && node.downstream > 0) parts.push(`↓${node.downstream}`)
234 if (node.parked > 0) parts.push(`${MEMO}${node.parked}`)
235 if (node.deadlock) parts.push(CYCLE)
236 return parts.length === 0 ? '' : ` ${parts.join(' ')}`
237}
238
239// cells は端末に描いたときの桁数。全角 (CJK、ハングル、全角形) は 2 桁、それ以外は 1 桁と数える。記号 (○ ▸ ⊘) は
240// 1 桁に収まるものを選んでいる。
241export function cells(text: string): number {
242 let n = 0
243 for (const ch of text) n += wide(ch.codePointAt(0) ?? 0) ? 2 : 1
244 return n
245}
246
247function wide(cp: number): boolean {
248 return (
249 (cp >= 0x1100 && cp <= 0x115f) ||
250 (cp >= 0x2e80 && cp <= 0xa4cf) ||
251 (cp >= 0xac00 && cp <= 0xd7a3) ||
252 (cp >= 0xf900 && cp <= 0xfaff) ||
253 (cp >= 0xfe30 && cp <= 0xfe4f) ||
254 (cp >= 0xff00 && cp <= 0xff60) ||
255 (cp >= 0xffe0 && cp <= 0xffe6)
256 )
257}
258
259// fit は text を max 桁に収める。はみ出すときは末尾を「…」にする。折り返すとレーンの行が崩れるため、行は 1 行に
260// 切り詰める。
261export function fit(text: string, max: number): string {
262 if (cells(text) <= max) return text
263 let out = ''
264 let n = 0
265 for (const ch of text) {
266 const w = wide(ch.codePointAt(0) ?? 0) ? 2 : 1
267 if (n + w > max - 1) break
268 out += ch
269 n += w
270 }
271 return `${out}…`
272}
273
274// chain は原因の鎖 (blocked_by) を記号と title でつないだもの。
275export function chain(g: Graph, node: GraphNode): string {
276 const parts = node.blocked_by.flatMap(k => {
277 const n = g.node(k)
278 return n === undefined ? [] : [`${ICON[n.state]} ${n.title}`]
279 })
280 return parts.length === 0 ? 'none' : parts.join(' ← ')
281}
282hooks/log.ts 50 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface } from 'claude-code'
3
4import { daemonBin } from './daemon-bin'
5
6// mod の出来事を出来事のログ (internal/eventlog) に残す。mod はファイルを直接書けないので
7// `agents-daemon event-log` に渡し、このセッションの ID を添える。
8// 出来事の名前と欄の一覧は internal/eventlog の package doc が持つ。subject、title、送った文面は渡さない。
9
10// logged は slot ごとに最後に書いた中身。同じ失敗や同じ数が続く間は書き直さない (依存図は数秒ごとに読み直す)。
11const logged = atom({ plugin: 'agents-daemon', key: 'logged' } as const, {})
12
13export type Fields = Record<string, string | number | boolean>
14
15// logEvent は出来事を 1 行書く。書けなくても (バイナリがまだ無いなど) 呼び出し元を止めない。
16export async function logEvent($: EngineInterface, event: string, fields: Fields = {}) {
17 try {
18 const args = Object.entries(fields).map(([key, value]) => `${key}=${value}`)
19 const argv = [await daemonBin($), 'event-log', '--session', await $.session.id(), '--', event, ...args]
20 await $.process.run(argv, { timeoutMs: 10000 })
21 } catch {
22 // ログは補助なので、書けなかったことはどこにも出さない。
23 }
24}
25
26// logChange は slot の中身 (fields) が前に書いたものと違うときだけ書く。event は見ないので、開いたときと同じ数の
27// 読み直しは「変わった」として書かない。
28export async function logChange($: EngineInterface, slot: string, event: string, fields: Fields = {}) {
29 const key = JSON.stringify(fields)
30 if ((await read($, logged))[slot] === key) return
31 await update($, logged, prev => ({ ...prev, [slot]: key }))
32 await logEvent($, event, fields)
33}
34
35// settle は slot を戻す。読めるようになった後に同じ失敗が起きたら、また書く。
36export async function settle($: EngineInterface, slot: string) {
37 if ((await read($, logged))[slot] === undefined) return
38 await update($, logged, prev => {
39 const { [slot]: _, ...rest } = prev
40 return rest
41 })
42}
43
44// reason は失敗をログに書く理由にする。JSON.parse の失敗の文面は読んだ中身 (メモの title など) を含みうるので、
45// 固定の文面にする。
46export function reason(err: unknown, what: string): string {
47 if (err instanceof SyntaxError) return `${what} の JSON を読めない`
48 return String(err)
49}
50hooks/parking.ts 323 lines1import type { EngineInterface } from 'claude-code'
2
3import type { DepsData, DepsRepo, DepsSession, GraphNode, ParkingCard, ParkingSource } from '../types'
4import { daemonBin } from './daemon-bin'
5import { fit } from './graph'
6import { logEvent, reason } from './log'
7
8// パーキングのメモの見せ方と、依存図の pane からの操作。DAG の描き方には依存させない。描くのは hooks/deps.tsx。
9
10// lastPlacedAt はメモが最後に置かれた時刻 (sources は新しい順)。
11export function lastPlacedAt(memo: ParkingCard): string {
12 return memo.sources[0]?.at ?? ''
13}
14
15const DAY_MS = 24 * 60 * 60 * 1000
16
17// isStale はメモが最後に置かれてから days 日より経ったか (now はミリ秒)。`park discard --days` と同じ境界。
18export function isStale(memo: ParkingCard, now: number, days: number): boolean {
19 const ms = Date.parse(lastPlacedAt(memo))
20 return !Number.isNaN(ms) && now - ms > days * DAY_MS
21}
22
23// Destination はメモを渡す先。peer は生きているほかのセッション (`$.session.send`)、self は開いたセッション自身
24// (peer メッセージではなく自分の入力へ渡す。#190)、spawn は repo で新しく起こすセッション (`agents-daemon spawn`。#162)。
25// peer の subject はそのセッションの subject (進行中のタスクか今の作業)。自動で付いたセッション名だけでは
26// 何をしているセッションか分からないので、宛先の名前に添える (#191)。
27export type Destination =
28 | { route: 'peer'; session_id: string; session: string; subject: string }
29 | { route: 'self'; session_id: string; session: string }
30 | { route: 'spawn'; repo: string; title: string }
31
32// destinationKey は宛先の描画の key。セッションは ID、起こす先は repo のパス。
33export function destinationKey(to: Destination): string {
34 return to.route === 'spawn' ? to.repo : to.session_id
35}
36
37// SUBJECT_CELLS は宛先の名前に添える subject の桁数の上限。ボタンが 1 行に収まる長さにする。
38const SUBJECT_CELLS = 24
39
40// destinationLabel は宛先の名前。
41export function destinationLabel(to: Destination): string {
42 if (to.route === 'spawn') return to.title
43 if (to.route === 'peer' && to.subject !== '') return `${to.session} (${fit(to.subject, SUBJECT_CELLS)})`
44 return to.session
45}
46
47// SELF_LABEL は自セッションの宛先の名前。セッション名は sessions に頼らず固定にする (自分が生きているのは自明で、
48// sessions に無くても宛先に出す)。
49const SELF_LABEL = 'このセッション'
50
51// Intent はメモを渡した先に頼むこと。
52export type Intent = 'task' | 'issue'
53
54// kindLabel はメモの種類の語 (park tool の kind の語のまま)。終了したセッションから移したメモ (origin adopted) は
55// adopted を添える。hooks/notice.ts も使う。
56export function kindLabel(memo: { kind: string; origin?: string }): string {
57 return memo.origin === 'adopted' ? `${memo.kind}・adopted` : memo.kind
58}
59
60// memoLabel はメモの 1 行: 種類、title、置かれた回数 (2 回以上のとき)。
61export function memoLabel(memo: ParkingCard): string {
62 const count = memo.sources.length > 1 ? ` ×${memo.sources.length}` : ''
63 return `[${kindLabel(memo)}] ${memo.title}${count}`
64}
65
66// memoLines は選んだメモの詳細の行。sources は受け取った順 (新しい順) に出す。
67export function memoLines(memo: ParkingCard): string[] {
68 const lines: string[] = []
69 if (memo.detail) lines.push(memo.detail)
70 if (memo.default_taken) lines.push(`既定: ${memo.default_taken}`)
71 if (memo.files?.length) lines.push(`ファイル: ${memo.files.join(', ')}`)
72 for (const source of memo.sources) lines.push(sourceLine(source))
73 return lines
74}
75
76function sourceLine(source: ParkingSource): string {
77 return [source.session, stamp(source.at), source.detail].filter(Boolean).join(' ')
78}
79
80const JST_OFFSET_MS = 9 * 60 * 60 * 1000
81
82// stamp は RFC 3339 の時刻を JST の「MM/DD HH:MM」にする。記録の offset にも mod の時刻帯にも頼らない
83// (parking.md「時刻の書き方」)。
84export function stamp(at: string): string {
85 const ms = Date.parse(at)
86 if (Number.isNaN(ms)) return at
87 const jst = new Date(ms + JST_OFFSET_MS)
88 const two = (n: number) => String(n).padStart(2, '0')
89 return `${two(jst.getUTCMonth() + 1)}/${two(jst.getUTCDate())} ${two(jst.getUTCHours())}:${two(jst.getUTCMinutes())}`
90}
91
92// taskDestinations は「タスクにする」の宛先: メモの repo が開いたセッションの repo (here) なら自セッションを先に、
93// 続けて同じ repo の生きているほかのセッション。
94export function taskDestinations(data: DepsData, repo: DepsRepo, self: string): Destination[] {
95 const own = repo.repo === data.here ? [selfDestination(self)] : []
96 return [...own, ...uniqueLive(repo.sessions, self)]
97}
98
99// issueDestinations は「issue にするよう頼む」の宛先。自セッションを先に、同じ repo のセッション、
100// ほかの repo の生きているセッションの順に並べる。
101export function issueDestinations(data: DepsData, sessions: DepsSession[], self: string): Destination[] {
102 return [selfDestination(self), ...uniqueLive([...sessions, ...data.repos.flatMap(repo => repo.sessions)], self)]
103}
104
105function selfDestination(self: string): Destination {
106 return { route: 'self', session_id: self, session: SELF_LABEL }
107}
108
109// uniqueLive は sessions を受け取った順に宛先にする。自分と、同じセッションの 2 回目は除く (subject は最初のもの)。
110function uniqueLive(sessions: DepsSession[], self: string): Destination[] {
111 const seen = new Set<string>()
112 const out: Destination[] = []
113 for (const s of sessions) {
114 if (!s.live || s.session_id === '' || s.session_id === self || seen.has(s.session_id)) continue
115 seen.add(s.session_id)
116 out.push({ route: 'peer', session_id: s.session_id, session: s.session, subject: s.subject })
117 }
118 return out
119}
120
121// spawnDestinations は「新しいセッションを起こして渡す」の repo の候補: メモの repo、開いたセッションの repo (here)、
122// ほかの repo の順 (重複は除く)。候補に無い repo は pathDestination で指定する。
123export function spawnDestinations(data: DepsData, repo: DepsRepo): Destination[] {
124 const seen = new Set<string>()
125 const out: Destination[] = []
126 const add = (repo: string, title: string) => {
127 if (repo === '' || seen.has(repo)) return
128 seen.add(repo)
129 out.push({ route: 'spawn', repo, title })
130 }
131 add(repo.repo, repo.title)
132 add(data.here, data.repos.find(r => r.repo === data.here)?.title ?? basename(data.here))
133 for (const r of data.repos) add(r.repo, r.title)
134 return out
135}
136
137// pathDestination は入力されたパスを起こす先にする。絶対パスでなければ理由を返す (バイナリも同じ検査をするが、
138// 起こす前に手元で弾いて知らせる)。
139export function pathDestination(path: string): Destination | { failure: string } {
140 const repo = path.trim()
141 if (!repo.startsWith('/')) return { failure: `repo は絶対パスで指定してください: ${repo}` }
142 return { route: 'spawn', repo, title: basename(repo) }
143}
144
145function basename(path: string): string {
146 return path.replace(/\/+$/, '').split('/').pop() ?? path
147}
148
149// canAdopt はタスクのノードをパーキングへ移せるか。終了したセッションの未完了のタスクだけ。daemon が status.json を
150// 書いていない (daemon_running でも updated_at が無い) 依存図は、生きているセッションも exited と描くので出さない。
151// 押した後の判定はバイナリが status.json で改めて行う (`park adopt`)。
152export function canAdopt(node: GraphNode, self: string, data: DepsData): boolean {
153 return data.updated_at !== undefined && node.kind === 'task' && node.state === 'exited' && node.session_id !== undefined && node.session_id !== self
154}
155
156// taskText は「タスクにする」で送る文面。着手の時期は受け手の計画に任せる。依存図はタスクの description に
157// 書かれた id でメモと照合する (internal/deps/graph)。
158export function taskText(memo: ParkingCard): string {
159 return [
160 `パーキングロットのメモ「${memo.title}」(種類: ${kindLabel(memo)}) をタスクにしてください。` +
161 `TaskCreate で作り、タスクの description にメモの id (${memo.id}) を入れてください。` +
162 `既にこのメモを扱うタスクがあれば、新しく作らずにその description に id を書き足すだけで構いません。` +
163 `着手の時期は今の計画 (タスクの依存) に従ってください。`,
164 ...context(memo),
165 ].join('\n')
166}
167
168// issueText は「issue にするよう頼む」で送る文面。
169export function issueText(memo: ParkingCard, repo: string): string {
170 return [
171 `パーキングロットのメモ「${memo.title}」を GitHub の issue にしてください。repo: ${repo}。起票したら URL を返してください。`,
172 ...context(memo),
173 ].join('\n')
174}
175
176// spawnText は新しく起こすセッションの最初のターンの文面。起こした経緯を 1 行置き、続けて peer へ送るものと同じ文面。
177// issue はメモの repo ではなく起こした repo を起票先にする (生きているセッションの無い repo へ渡すための経路なので)。
178export function spawnText(memo: ParkingCard, to: { repo: string; title: string }, intent: Intent): string {
179 const body = intent === 'task' ? taskText(memo) : issueText(memo, to.title)
180 return `このメモを渡すため、${to.title} (${to.repo}) で新しいセッションとして起こしました。\n${body}`
181}
182
183function context(memo: ParkingCard): string[] {
184 const lines: string[] = []
185 if (memo.detail) lines.push(`詳細: ${memo.detail}`)
186 for (const source of memo.sources) {
187 if (source.detail) lines.push(`補足 (${source.session}): ${source.detail}`)
188 }
189 if (memo.files?.length) lines.push(`関係するファイル: ${memo.files.join(', ')}`)
190 if (memo.default_taken) lines.push(`置いたときの既定: ${memo.default_taken}`)
191 lines.push(`(メモ ${memo.id})`)
192 return lines
193}
194
195// discard は open のメモを捨て、結果の文面を返す。ほかのセッションが先に取ったメモは捨てない (`--from open`)。
196export async function discard($: EngineInterface, memo: ParkingCard): Promise<string> {
197 const failure = await park($, ['set', memo.id, '--status', 'discarded', '--from', 'open'])
198 return failure === '' ? `メモ「${memo.title}」を捨てました` : `メモを捨てられませんでした: ${failure}`
199}
200
201// discardStale は最後に置かれてから days 日より経った open のメモをまとめて捨て、結果の文面を返す。捨てたメモは
202// 7 日残り (internal/parking の ClosedRetention)、id (`logs --events` の park.discard に残る) で戻せる。
203export async function discardStale($: EngineInterface, days: number): Promise<string> {
204 const ran = await run($, ['discard', '--days', String(days)])
205 if ('failure' in ran) return `古いメモを捨てられませんでした: ${ran.failure}`
206 let count: number
207 try {
208 count = (JSON.parse(ran.stdout) as unknown[]).length
209 } catch (err) {
210 return `古いメモを捨てましたが、結果を読めませんでした: ${reason(err, 'park discard')}`
211 }
212 return `${days} 日より前のメモ ${count} つを捨てました (戻すには park set <id> --status open、id は logs --events に残る)`
213}
214
215// destinationArgs は取るときに宛先を書く `park set` の引数。セッションは ID、起こす先は repo のパスを渡し、宛先の形
216// (sid: / repo:) はバイナリが組む。起こしたセッションの ID は渡した後に分かるので、起こす先は repo のまま書く。
217function destinationArgs(to: Destination): string[] {
218 return to.route === 'spawn' ? ['--to-repo', to.repo] : ['--to-session', to.session_id]
219}
220
221// handOff は text を to へ渡す。送る前にメモを取り (open のときだけ handed にし、宛先を同じ park set で書く。取れるのは
222// 1 つのセッションだけ)、届かなければ open に戻す (宛先も空になる)。取れなかった (ほかのセッションが先に渡した、捨てた)
223// メモは送らない。手順の交錯で破れないことは models/handoff の Handoff.tla (Claim → Send / Spawn → Revert) が持つ。
224export async function handOff($: EngineInterface, memo: ParkingCard, to: Destination, text: string): Promise<string> {
225 const sent = { via: 'memo', memo: memo.id, ...(to.route === 'spawn' ? { repo: to.repo } : {}) }
226 const claim = await park($, ['set', memo.id, '--status', 'handed', '--from', 'open', ...destinationArgs(to)])
227 if (claim !== '') {
228 await logEvent($, 'mod.send', { ...sent, to: '', delivered: false, reason: `claim: ${claim}` })
229 return `メモを取れませんでした (ほかのセッションが先に渡したか、捨てたかもしれません): ${claim}`
230 }
231 const delivered = await deliver($, to, text)
232 if ('unknown' in delivered) {
233 // 起きたか分からないメモは戻さない。戻すと押し直しで 2 つ目のセッションが起きる。
234 await logEvent($, 'mod.send', { ...sent, to: delivered.to, delivered: false, reason: delivered.unknown })
235 return `${destinationLabel(to)} で window を作りましたが、claude が前景になりませんでした: ${delivered.unknown}。メモは handed のまま残してあります (起きていなければ park set ${memo.id} --status open で戻す)`
236 }
237 if ('failure' in delivered) {
238 await logEvent($, 'mod.send', { ...sent, to: delivered.to, delivered: false, reason: delivered.failure })
239 return `${said(to, false)}: ${delivered.failure}。${await revert($, memo)}`
240 }
241 await logEvent($, 'mod.send', { ...sent, to: delivered.to, delivered: true })
242 return `${said(to, true)}、メモを渡しました`
243}
244
245// revert は届かなかったメモを open に戻し、結果の文面を返す。戻せなければ handed のまま残るので、手で戻す形を添える。
246async function revert($: EngineInterface, memo: ParkingCard): Promise<string> {
247 const failure = await park($, ['set', memo.id, '--status', 'open', '--from', 'handed'])
248 return failure === '' ? 'メモは open に戻しました' : `メモを open に戻せませんでした (park set ${memo.id} --status open で戻す): ${failure}`
249}
250
251// said は結果の文面の前半。起こす先は「起こし (た)」、セッションは「送り (送った)」。
252function said(to: Destination, ok: boolean): string {
253 const name = destinationLabel(to)
254 if (to.route === 'spawn') return ok ? `${name} で新しいセッションを起こし` : `${name} で新しいセッションを起こせませんでした`
255 return ok ? `${name} へ送り` : `${name} へ送れませんでした`
256}
257
258// Delivered は届いた先のセッション ID か、届かなかった理由 (failure)、届いたか分からない理由 (unknown。spawn が window を
259// 作ったが claude が前景にならなかった。pane は残る)。
260type Delivered = { to: string } | { to: string; failure: string } | { to: string; unknown: string }
261
262// deliver は text を to へ届ける。
263// - self: 自分へは peer メッセージを送れないので、自分の入力に渡す (`$.prompt.submit`)。入力はセッションが idle に
264// なってから 1 ターンとして入り、drop されたら入っていない (`isDelivered` の false と同じ扱い)
265// - spawn: `agents-daemon spawn` に本文を stdin で渡し、repo を cwd にした新しいセッションの最初のターンにする
266// cc-premise: 2.1.306 mods $.prompt.submit は idle になってから自分のターンとして入り、drop で入らなかったことが分かる
267async function deliver($: EngineInterface, to: Destination, text: string): Promise<Delivered> {
268 try {
269 switch (to.route) {
270 case 'self': {
271 const result = await $.prompt.submit({ text })
272 return result.drop === undefined ? { to: to.session_id } : { to: to.session_id, failure: result.drop }
273 }
274 case 'peer': {
275 const result = await $.session.send({ to: { sessionId: to.session_id }, text })
276 return result.isDelivered ? { to: to.session_id } : { to: to.session_id, failure: result.reason }
277 }
278 case 'spawn':
279 return spawnSession($, to.repo, text)
280 }
281 } catch (err) {
282 return { to: '', failure: String(err) }
283 }
284}
285
286// spawnSession は repo で新しいセッションを起こし、本文を最初のターンにする。claude が前景になるまでバイナリが待つので、
287// park より長い timeout にする。exit 0 で foreground が false なら window はできていて claude が前景にならなかった (unknown)。
288async function spawnSession($: EngineInterface, repo: string, text: string): Promise<Delivered> {
289 const argv = [await daemonBin($), 'spawn', '--repo', repo]
290 const { exitCode, stdout, stderr } = await $.process.run(argv, { stdin: text, timeoutMs: 30000 })
291 if (exitCode !== 0) return { to: '', failure: stderr.trim() || `exit ${exitCode}` }
292 try {
293 const res = JSON.parse(stdout) as { session_id: string; pane_id: string; foreground: boolean; detail?: string }
294 return res.foreground ? { to: res.session_id } : { to: res.session_id, unknown: res.detail ?? `pane ${res.pane_id} で claude が前景にならなかった` }
295 } catch (err) {
296 return { to: '', failure: reason(err, 'spawn') }
297 }
298}
299
300// adopt は終了したセッションのタスクをパーキングへ移す。ノードの key (task: の Ref) をそのまま渡す。
301export async function adopt($: EngineInterface, node: GraphNode): Promise<string> {
302 const failure = await park($, ['adopt', node.key])
303 return failure === '' ? `タスク「${node.title}」をパーキングへ移しました` : `パーキングへ移せませんでした: ${failure}`
304}
305
306// park は開いたセッションの ID と cwd を添えて `agents-daemon park` を走らせ、失敗の理由を返す (成功なら空)。
307// ID は操作したセッションとしてログに残る。
308async function park($: EngineInterface, args: string[]): Promise<string> {
309 const ran = await run($, args)
310 return 'failure' in ran ? ran.failure : ''
311}
312
313// run は park と同じ走らせ方で、成功なら stdout を返す。
314async function run($: EngineInterface, args: string[]): Promise<{ stdout: string } | { failure: string }> {
315 try {
316 const argv = [await daemonBin($), 'park', ...args, '--session', await $.session.id(), '--cwd', await $.session.cwd()]
317 const { exitCode, stdout, stderr } = await $.process.run(argv, { timeoutMs: 10000 })
318 return exitCode === 0 ? { stdout } : { failure: stderr.trim() || `exit ${exitCode}` }
319 } catch (err) {
320 return { failure: String(err) }
321 }
322}
323types/index.d.ts 150 lines1// hooks/deps.tsx、hooks/notice.ts などの mod が $.state に置く値の型。形は `agents-daemon deps` の JSON (internal/deps) に合わせる。
2
3export type ParkingSource = {
4 session_id: string
5 // task_ref は置いたときに進めていたタスクのノードの key (GraphNode.key と同じ Ref)。
6 task_ref?: string
7 session: string
8 live: boolean
9 detail?: string
10 at: string
11}
12
13export type ParkingCard = {
14 id: string
15 kind: 'aside' | 'scope' | 'preference'
16 // origin は出自。placed は park tool で置いたメモ、adopted は終了したセッションの未完了のタスクから移したメモ。
17 origin: 'placed' | 'adopted'
18 title: string
19 detail?: string
20 files?: string[]
21 default_taken?: string
22 sources: ParkingSource[]
23}
24
25// DepsSession は repo で生きているセッション 1 つ (メモを渡す宛先)。subject はそのセッションが何をしているか。
26export type DepsSession = {
27 session_id: string
28 session: string
29 subject: string
30 live: boolean
31}
32
33// DepsRepo は repo 1 つ。sessions は表示名の順、memos は open のメモを置いた順。
34export type DepsRepo = {
35 repo: string
36 title: string
37 sessions: DepsSession[]
38 memos: ParkingCard[]
39}
40
41// GraphNode は依存の DAG のノード 1 つ (タスク 1 つ、タスクの無いセッション、端点の参照だけの置き場)。
42// key は Ref (task:、todo:、sid:、session:、issue:、pr:) で、mod は切り出さずに不透明な文字列として扱う。
43export type GraphNode = {
44 key: string
45 kind: 'task' | 'todo' | 'session' | 'ref'
46 state: 'working' | 'done' | 'unknown' | 'question' | 'permission' | 'error' | 'limit' | 'pending' | 'completed' | 'exited'
47 title: string
48 detail?: string
49 // endpoint は key をそのまま `edge add` の端点に書けるか (TodoWrite の項目は書けない)。
50 endpoint: boolean
51 cwd?: string
52 // task_id は kind task のノードのタスクの ID (TaskUpdate に書く形)。
53 task_id?: string
54 session_id?: string
55 // session は表示名 (同名のセッションがあれば session ID の先頭が付く)、session_name はセッション名そのもの
56 // (SendMessage の宛先に書く形)。
57 session?: string
58 session_name?: string
59 live: boolean
60 depth: number
61 downstream: number
62 parked: number
63 // memos はこのタスクの description に id が書かれたメモ (書かれた順)。status を問わない。
64 memos: MemoRef[]
65 blocked_by: string[]
66 deadlock: boolean
67 // halted はセッション自身が止まっているか (question、permission、limit、error)。stuck は止まっているノードか
68 // (halted か、完了していない待ちの輪)。どちらもバイナリが決め、mod は状態の語から決めない。
69 halted: boolean
70 stuck: boolean
71}
72
73// MemoRef はタスクが扱うメモ 1 つ。
74export type MemoRef = {
75 id: string
76 title: string
77 status: 'open' | 'handed' | 'discarded'
78}
79
80// EdgeEndpoint は辺の記録の端点。bound_from は daemon が session:<名前> を sid:<id> に結びつけたときの元の参照。
81export type EdgeEndpoint = {
82 ref: string
83 cwd: string
84 repo: string
85 bound_from?: string
86}
87
88// GraphLink は辺 1 本。kind task は同じセッションの blockedBy、kind edge は `edge add` で書かれた辺で edge に元の記録を持つ。
89export type GraphLink = {
90 before: string
91 after: string
92 kind: 'task' | 'edge'
93 edge?: { id: string; before: EdgeEndpoint; after: EdgeEndpoint; note?: string }
94}
95
96// GraphRow はレーンの行 1 つ。lanes は行頭の罫線で、ノードの行は `*` がそのノードのレーンの位置。node が無ければ
97// 辺をつなぐだけの行。marks は longest chain の辺を描いた lanes の桁 (昇順)。
98export type GraphRow = {
99 lanes: string
100 node?: string
101 marks?: number[]
102}
103
104export type DepsGraph = {
105 nodes: GraphNode[]
106 links: GraphLink[]
107 longest_chain: string[]
108 // rows は完了したノードを落として組んだレーンの行、rows_with_completed は全部を含めて組んだ行。
109 rows: GraphRow[]
110 rows_with_completed: GraphRow[]
111}
112
113export type DepsData = {
114 daemon_running: boolean
115 updated_at?: string
116 // here は開いたセッションの repo の鍵 (DepsRepo.repo と同じ規則)。
117 here: string
118 // stale_memo_days は open のメモを「古い」とみなす日数 (internal/parking の StaleAfter)。
119 stale_memo_days: number
120 repos: DepsRepo[]
121 graph: DepsGraph
122}
123
124declare module 'claude-code' {
125 interface PluginState {
126 'agents-daemon': {
127 // deps は最後に読めた依存図。読めていなければ null。
128 deps: DepsData | null
129 // error は直近の読み込みの失敗理由。読めたら空にする。
130 error: string
131 // target は依存図の pane で選んだノードの key かメモの key (park:<id>)。未選択なら空。
132 target: string
133 // picking はノードの詳細で Add blocker の候補を出しているか。
134 picking: boolean
135 // spawning はメモの詳細で「新しいセッションを起こして渡す」の repo の候補を出しているか。出しているなら頼むこと
136 // (task / issue)、出していなければ空。
137 spawning: '' | 'task' | 'issue'
138 // notice は送信とメモの操作の結果。
139 notice: string
140 // told はファイルを触った tool の結果で model に伝えたメモの id (hooks/notice.ts)。同じメモを 2 度伝えない。
141 // main の会話が compact されたら空にする。
142 told: string[]
143 // showCompleted は依存図の pane で完了したノードを出すか (graph.rows_with_completed を描く)。既定は畳む。
144 showCompleted: boolean
145 // logged は出来事のログに slot ごとに最後に書いた中身 (hooks/log.ts)。同じものを書き直さないため。
146 logged: Record<string, string>
147 }
148 }
149}
150