SLOPSHOPPER

tasks-pane

Google Tasksのagents / main / big / repeat / projectを横のパネルに出し、agentsのタスクはボタンで依頼文を入力欄に入れる

paneguardcommandprompttimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · tasks-pane
│ ┃ Tasks ✕ › fix the failing auth test and add an audit log call │ ┃ ▣ client module ./board.tsx │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /todo │ ⎿ tasks-pane: Tasksパネルを更新した。 │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Tasks
▣ client module ./board.tsx
README

obsidian-ai-knowledge-lake

Obsidian の保管庫を「AI と共有する自分のナレッジレイク」にして、作業の多くを AI(Claude Code / Codex)に任せられるようにするための一式。 作者が自分の保管庫で実際に運用している仕組みから、個人の中身を抜いて汎用化したもの。

中身

フォルダ何か最初に読むもの
knowledge-lake-kit/保管庫の器。 フォルダ構成、AI への指示書(AGENTS.md)、各フォルダの README、テンプレート、運用の考え方START_HERE.md(人間)/ SETUP_FOR_AI.md(AI)
agent-tooling/AI に持たせる道具。 スキル(/inbox・/prioritize など)、MCP(Gmail 横断・Google Tasks)、Claude Code の mod、フック、CLI、定期実行README.md(人間)/ FOR_AI.md(AI)

使い方

git clone https://github.com/KaiMiyazawa/obsidian-ai-knowledge-lake.git
  1. 保管庫を作る: knowledge-lake-kit/START_HERE.md を上から順に進める。最後は AI がインタビューしながら保管庫を組み立てる。
  2. 道具を入れる(任意): 保管庫ができて慣れてきたら、保管庫で AI に次のように頼む。 > <クローンした場所>/agent-tooling/FOR_AI.md を読んで、この道具を私の保管庫に入れるのを手伝って

どちらも「AI が読んで、利用者に確認しながら進める」前提で書いてある。全部を一度に入れなくてよい。

注意

  • 秘密の値(トークン・パスワード・Webhook など)は入っていない。作者の個人情報は伏せ字(you@gmail.com、<MAIN_LIST_ID> など)にしてある。
  • 自分の保管庫は private リポジトリで管理する。 このリポジトリを fork して中身を書き足し、公開したままにしない。
  • 道具は作者の環境(macOS、Claude Code・Codex の特定の版)で作ったもの。そのまま動く保証はなく、利用者の環境に合わせて AI と直しながら使う。

ライセンス

MIT。ただし agent-tooling/outside-vault/gtasks-mcp/ は alvinjchoi/gtasks-mcp(MIT)を改造したもので、同フォルダの LICENSE に従う。

Source 3 files
hooks/register.tsx 230 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { BoardMessage, BoardProps, Task, TaskLists } from '../types'
5
6const PANE = 'tasks'
7const SERVER = 'gtasks'
8const REFRESH_MS = 10 * 60 * 1000
9
10// AGENTS.md「タスクリストの使い分け」。agentsはAIに投げればよいもの、ほかは本人が動くもの。
11const LISTS = [
12  { key: 'agents', hotkey: 'a', id: '<AGENTS_LIST_ID>' },
13  { key: 'main', hotkey: 'm', id: '<MAIN_LIST_ID>' },
14  { key: 'big', hotkey: 'b', id: '<BIG_LIST_ID>' },
15  { key: 'repeat', hotkey: 'r', id: '<REPEAT_LIST_ID>' },
16  // 一時的な用件専用のリスト。用件が片付いたらリストごと削除し、この行も消す。
17  { key: 'project', hotkey: 'i', id: '<PROJECT_LIST_ID>' },
18] as const
19
20const lists = atom({ plugin: 'tasks-pane', key: 'lists' } as const, {})
21const selected = atom({ plugin: 'tasks-pane', key: 'selected' } as const, 'agents')
22const fetchedAt = atom({ plugin: 'tasks-pane', key: 'fetchedAt' } as const, null)
23const failure = atom({ plugin: 'tasks-pane', key: 'error' } as const, null)
24
25// gtasks の list_with_tree の1行。子タスクは字下げされ、末尾に " parent:<id>" が付く。
26// "  [ ] タイトル (due: 2026-10-19T00:00:00.000Z) | id:xxx parent:yyy"
27const LINE = /^(\s*)\[( |x)\] (.*?)(?: \(due: (\d{4}-\d{2}-\d{2})[^)]*\))? \| id:(\S+)(?: parent:\S+)?\s*$/
28
29const parse = (text: string): Task[] =>
30  text.split('\n').flatMap(line => {
31    const m = line.match(LINE)
32    if (!m || m[2] === 'x') return []
33    const [, indent = '', , title = '', due, id = ''] = m
34    return [{ depth: Math.floor(indent.length / 2), title, due: due ?? null, id }]
35  })
36
37// $.mcp.call は gtasks を「未接続」と判定することがある(2026-10-05)。そのときはモデルと同じツール呼び出しで取る。
38const fetchTree = async ($: EngineInterface, taskListId: string, key: string) => {
39  const direct = await $.mcp.call(SERVER, 'list_with_tree', { taskListId }).catch(() => undefined)
40  if (direct !== undefined && !direct.isError) {
41    return direct.content.map(b => (b.type === 'text' ? b.text : '')).join('\n')
42  }
43  const viaTool = await $.tool.call({ tool: `mcp__${SERVER}__list_with_tree`, taskListId } as never)
44  if (viaTool.deny !== undefined || viaTool.isError === true || viaTool.text === undefined) {
45    throw new Error(`${key}の取得に失敗(${viaTool.deny ?? viaTool.text ?? '応答なし'})`)
46  }
47  return viaTool.text
48}
49
50// 一度でも取れたか。起動直後の取り直しと、最初のプロンプトでの取り直しを止めるのに使う。
51let hasFetched = false
52
53// quiet のときは失敗をパネルに出さない(起動直後の再試行中は「読み込み中…」のままにする)。
54const refresh = async ($: EngineInterface, { quiet = false } = {}) => {
55  try {
56    const next: TaskLists = {}
57    for (const list of LISTS) {
58      next[list.key] = parse(await fetchTree($, list.id, list.key))
59    }
60    await update($, lists, () => next)
61    await update($, failure, () => null)
62    await update($, fetchedAt, () => Date.now())
63    hasFetched = true
64    return true
65  } catch (err) {
66    if (!quiet) await update($, failure, () => String(err instanceof Error ? err.message : err))
67    return false
68  }
69}
70
71// 起動直後は gtasks の MCP がまだ繋がっておらず、ツールも未登録で失敗する(2026-10-13 確認)。
72// 接続完了を知らせるイベントは無いので、間隔を倍にしながら約2分取り直す。最後の1回だけ失敗を表示する。
73const RETRY_MS = [2000, 4000, 8000, 16000, 32000, 60000]
74
75const fetchOnStart = async ($: EngineInterface, attempt = 0): Promise<void> => {
76  if (hasFetched) return
77  const isLast = attempt >= RETRY_MS.length
78  if ((await refresh($, { quiet: !isLast })) || isLast) return
79  $.clock.after(RETRY_MS[attempt] ?? 60000, () => void fetchOnStart($, attempt + 1))
80}
81
82// 入力欄に入れるだけで送信はしない。本人が「補足:」の後に文脈を足してから送れる。
83// 送られた側には、前提が足りなければ着手前に質問するよう頼んでおく。
84const startPrompt = ($: EngineInterface, list: string, task: Task) => {
85  const ref = `Google Tasks「${list}」の「${task.title}」(id:${task.id})`
86  const ask =
87    list === 'agents'
88      ? `${ref}に着手して。notes と関係ノートを読み、`
89      : `${ref}を進めたい。これは私が動くタスクなので、notes を読んで、AIが先に下調べ・準備できることを提案して。`
90  return $.prompt.fill({
91    text: `${ask}前提が足りない点や判断が要る点があれば、作業に入る前に質問して。\n\n補足: `,
92    mode: 'replace',
93  })
94}
95
96// 本人の操作なしに開くパネルは、ターミナルが144桁(一度開いたものは110桁)以上ないと置かれない(エンジンの仕様、2026-10-05 確認)。
97// 開き直しても幅が足りなければ同じなので、置かれなかったら最初のプロンプト送信時に開く。本人の Enter が契機なら幅を問わず置かれる。
98let isWaiting = false
99
100const openUnasked = async ($: EngineInterface) => {
101  const opened = await $.ui.open({ id: PANE, title: 'Tasks' })
102  if (opened.isPlaced) return
103  isWaiting = true
104  $.ui.log(`tasks-pane: 起動時は置かれなかった(${opened.reason})。次のプロンプトで開く`, { to: 'debug' })
105}
106
107const todayJst = () => new Date(Date.now() + 9 * 3600 * 1000).toISOString().slice(0, 10)
108
109// 期限切れは赤、3日以内は黄、それ以外はうっすら(dim)。
110const dueStyle = (due: string, today: string) => {
111  if (due < today) return { color: 'red' }
112  const days = (Date.parse(due) - Date.parse(today)) / 86400000
113  return days <= 3 ? { color: 'yellow' } : { dimColor: true }
114}
115
116export const register: Register = on => {
117  on('session.start', async ($, e, next) => {
118    // /tasks は組み込みコマンドなので使えない(2026-10-05 に登録を拒否された)。AGENTS.md の「TODO=Google Tasks」に合わせる。
119    await $.command
120      .register({ name: 'todo', description: 'Google Tasks(agents / main / big / repeat / project)のパネルを開いて更新する' })
121      .catch((err: unknown) => $.ui.log(`tasks-pane: /todo を登録できなかった(${String(err)})`, { to: 'debug' }))
122    void fetchOnStart($)
123    $.clock.every(REFRESH_MS, () => void refresh($))
124    void openUnasked($)
125
126    return next(e)
127  })
128
129  on('prompt.submit', async ($, e, next) => {
130    // 再試行がまだ実っていなくても、本人がプロンプトを送る頃には MCP は繋がっている。
131    if (!hasFetched && e.origin.kind === 'composer') void refresh($)
132    if (isWaiting && e.origin.kind === 'composer') {
133      isWaiting = false
134      const isPlaced = (await $.ui.panes()).some(pane => pane.id === PANE && pane.isPlaced)
135      if (!isPlaced) await $.ui.open({ id: PANE, title: 'Tasks' })
136    }
137
138    return next(e)
139  })
140
141  on('command.run', { command: 'todo' }, async $ => {
142    await $.ui.open({ id: PANE, title: 'Tasks' })
143    await refresh($)
144
145    return { text: 'Tasksパネルを更新した。' }
146  })
147
148  // モデルがタスクを書き換えたら、パネルも追従させる。
149  on('tool.call', async ($, e, next) => {
150    const ran = await next(e)
151    const tool = String(e.tool)
152    if (tool.startsWith(`mcp__${SERVER}__`) && !/__(list|search)/.test(tool)) void refresh($)
153
154    return ran
155  })
156
157  on('ui.message', { component: 'Pane', requestId: PANE }, async ($, e) => {
158    const message = e.data as BoardMessage
159    if (message.type === 'select') await update($, selected, () => message.list)
160    else if (message.type === 'start') await startPrompt($, message.list, message.task)
161    else if (message.type === 'refresh') await refresh($)
162
163    return {}
164  })
165
166  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
167    const all = await read($, lists)
168    const current = await read($, selected)
169    const error = await read($, failure)
170    const isLoading = (await read($, fetchedAt)) === null && error === null
171    const today = todayJst()
172
173    // ターミナルは Client で矢印キーを受ける。
174    if (e.surface === 'terminal') {
175      const { Client } = $.ui.resolve(e)
176      const props: BoardProps = {
177        lists: LISTS.map(l => ({ key: l.key, tasks: all[l.key] ?? [] })),
178        current,
179        today,
180        error,
181        isLoading,
182      }
183      // 大きさを渡さないと、描くたびに測り直して落ち着かない。
184      return (
185        <Client
186          key="board"
187          module="./board.tsx"
188          props={props}
189          width={e.props.bodyColumns}
190          height={Math.max(5, e.props.scroll.bodyRows)}
191        />
192      )
193    }
194
195    // ほかの画面はボタンで操作する。
196    const { Box, Button, Text } = $.ui.resolve(e)
197    const tasks = all[current] ?? []
198
199    return (
200      <Box flexDirection="column">
201        <Box flexWrap="wrap" columnGap={1}>
202          {LISTS.map(list => (
203            <Button
204              key={list.key}
205              label={`${list.key} ${all[list.key]?.length ?? '-'}`}
206              hotkey={list.hotkey}
207              variant={list.key === current ? 'primary' : undefined}
208              onPress={() => update($, selected, () => list.key)}
209            />
210          ))}
211          <Button key="refresh" label="更新" hotkey="u" onPress={() => refresh($)} />
212        </Box>
213        {error !== null && <Text color="red">{error}</Text>}
214        {isLoading && <Text dimColor>読み込み中…</Text>}
215        {!isLoading && tasks.length === 0 && <Text dimColor>タスクなし</Text>}
216        {tasks.map(task => (
217          <Box key={task.id} columnGap={1}>
218            <Button key={`go-${task.id}`} label="着手" plain onPress={() => startPrompt($, current, task)} />
219            {task.due !== null && <Text {...dueStyle(task.due, today)}>{task.due.slice(5).replace('-', '/')}</Text>}
220            <Text wrap="truncate-end">
221              {'  '.repeat(task.depth)}
222              {task.title}
223            </Text>
224          </Box>
225        ))}
226      </Box>
227    )
228  })
229}
230
hooks/board.tsx 79 lines
1import type { ClientModule } from 'claude-code'
2
3import type { BoardMessage, BoardProps } from '../types'
4
5type State = { list: number; row: number }
6
7// 期限切れは赤、3日以内は黄、それ以外はうっすら(dim)。
8const dueStyle = (due: string, today: string) => {
9  if (due < today) return { color: 'red' }
10  const days = (Date.parse(due) - Date.parse(today)) / 86400000
11  return days <= 3 ? { color: 'yellow' } : { dimColor: true }
12}
13
14// ターミナルのパネル本体。クリックでフォーカスを渡すと ←→ でリスト、↑↓ でタスク、Enter で着手、u で更新。
15const Board: ClientModule<BoardProps, State> = (props, surface) => {
16  const { Box, Text } = surface.elements
17  const keys = props.lists.map(l => l.key)
18  const initial = Math.max(0, keys.indexOf(props.current))
19  const state = surface.state ?? { list: initial, row: 0 }
20  const list = props.lists[state.list] ?? props.lists[0]
21  const tasks = list?.tasks ?? []
22  const row = Math.min(state.row, Math.max(0, tasks.length - 1))
23
24  surface.onKey(event => {
25    const n = props.lists.length
26    if (event.key === 'left' || event.key === 'right') {
27      const next = (state.list + (event.key === 'left' ? n - 1 : 1)) % n
28      surface.setState({ list: next, row: 0 })
29      surface.post({ type: 'select', list: keys[next] ?? '' } satisfies BoardMessage)
30    } else if (event.key === 'up' || event.key === 'down') {
31      const next = Math.max(0, Math.min(tasks.length - 1, row + (event.key === 'up' ? -1 : 1)))
32      surface.setState({ list: state.list, row: next })
33    } else if (event.key === 'return') {
34      const task = tasks[row]
35      if (list && task) surface.post({ type: 'start', list: list.key, task } satisfies BoardMessage)
36    } else if (event.key === 'u') {
37      surface.post({ type: 'refresh' } satisfies BoardMessage)
38    }
39  })
40
41  // カーソル行が見える範囲だけ描く(ヘッダ2行・フッタ1行を除く)。
42  const room = Math.max(3, (surface.rows || 24) - 3)
43  const start = Math.max(0, Math.min(row - Math.floor(room / 2), tasks.length - room))
44  const visible = tasks.slice(start, start + room)
45
46  return (
47    <Box flexDirection="column" flexGrow={1}>
48      <Box columnGap={1}>
49        {props.lists.map((l, i) => (
50          <Text key={l.key} inverse={i === state.list} bold={i === state.list} dimColor={i !== state.list}>
51            {` ${l.key} ${l.tasks.length} `}
52          </Text>
53        ))}
54      </Box>
55      {props.error !== null && <Text color="red">{props.error}</Text>}
56      {props.isLoading && <Text dimColor>読み込み中…</Text>}
57      {!props.isLoading && tasks.length === 0 && <Text dimColor>タスクなし</Text>}
58      {visible.map((task, i) => {
59        const isCursor = start + i === row
60        return (
61          <Box key={task.id} columnGap={1}>
62            <Text color={isCursor ? 'cyan' : undefined}>{isCursor ? '›' : ' '}</Text>
63            {task.due !== null && (
64              <Text {...dueStyle(task.due, props.today)}>{task.due.slice(5).replace('-', '/')}</Text>
65            )}
66            <Text wrap="truncate-end" bold={isCursor}>
67              {'  '.repeat(task.depth)}
68              {task.title}
69            </Text>
70          </Box>
71        )
72      })}
73      <Text dimColor>クリックで操作 ←→リスト ↑↓移動 Enter着手 u更新</Text>
74    </Box>
75  )
76}
77
78export default Board
79
types/index.d.ts 35 lines
1export type Task = {
2  id: string
3  title: string
4  due: string | null
5  depth: number
6}
7
8export type TaskLists = Record<string, Task[]>
9
10// フック → ターミナルのパネル(board.tsx)へ渡すもの。
11export type BoardProps = {
12  lists: { key: string; tasks: Task[] }[]
13  current: string
14  today: string
15  error: string | null
16  isLoading: boolean
17}
18
19// パネル → フックへ送るもの。
20export type BoardMessage =
21  | { type: 'select'; list: string }
22  | { type: 'start'; list: string; task: Task }
23  | { type: 'refresh' }
24
25declare module 'claude-code' {
26  interface PluginState {
27    'tasks-pane': {
28      lists: TaskLists
29      selected: string
30      fetchedAt: number | null
31      error: string | null
32    }
33  }
34}
35