Skip to main content
Glama

aide

AIDE(エイド)— 生活情報まわりの共通バックエンド/ハブ。

Claudeアプリ等のLLMクライアントに対しては MCPサーバー として、既存の個人アプリに対しては REST API として、同じデータを提供する。

設計の背景・意思決定は Notion「AIDE アーキテクチャ構想」を正本とする。ここにはコードを読むうえで必要な範囲だけ書く。

責務

AIDEがやること:

  • 外部サービスからのデータ取得(Zaim、GitHub、Google系、Notion 等)

  • 必要な範囲へのフィルタリング

  • サービスごとに異なる形式の共通フォーマットへの整形

  • 複数ソースを1回の呼び出しに畳んだ横断ビューの提供

  • 他のどこからも塞がっている経路に限った書き込み(後述。現在はGitHubのIssue起票と、Zaimへの支出登録の2つ)

AIDEがやらないこと:

  • 高コストなAI推論。意味の解釈・優先順位付け・要約・文章生成は呼び出し側のLLMに渡す。AIDEは取得・選別・整形に徹する

  • 公式MCPと重複する単機能ツールをMCP層に出すこと(後述)

  • 他のアプリが既に持っている書き込み経路の置き換え(後述)

書き込みをどこまで持つか

AIDEは元々取得専用として作った。書き込みを足すかは Issue ごとに判断せず、次の3条件を すべて満たすものに限る(aide#50 で確定。aide#37 以降の判断もこれに従う)。

  1. 他のどこからも塞がっている経路であること。 既存のアプリ・公式MCP・Claude Code から できることは、AIDEに口を作らない(往復が増えるだけになる)

  2. 読み取りとは別の資格情報を使うこと。 取得用のトークンに書き込み権限を足さない

  3. 作成だけを持つこと。 編集・削除・状態の変更は持たない。取り返しのつく操作に限る

現在この基準で入れている書き込みは次の3つ。

経路

条件1

条件2(資格情報)

条件3

aide_create_issue(aide#50)

ClaudeアプリからのGitHub Issue起票

満たす

AIDE_GITHUB_ISSUE_TOKEN(取得用とは別のPAT)

作成のみ

POST /api/zaim/payment(aide#37)

個人アプリからZaimへの支出登録

例外(下記)

Zaim APIの OAuth 1.0a(巡回の storage state とは別)

作成のみ

aide_zaim_payment(aide#135)

外部のClaude CodeからZaimへの支出登録

満たす(下記)

同上(OAuth 1.0a)

作成のみ

POST /api/zaim/payment/web(aide#214)

個人アプリからZaim Web版の入力画面への品目明細の登録

満たす(下記)

ログイン状態(storage state)

作成のみ

POST /api/image-mail/send(aide#230)

Research Desk経由での画像メール送信

例外(下記)

Gmail OAuth(新規。読み取り用の資格情報も無い)

作成のみ

Zaimへの登録は条件1の例外(aide#37)

Zaim の POST /v2/home/money/payment公開APIで、car-care / asset-manager から直接叩ける。 「他のどこからも塞がっている経路」ではないため、条件1は文言どおりには満たしていない。 それでもAIDEへ寄せたのは、塞がっているからではなく Zaimの資格情報を1か所に閉じ込めるため。 各アプリがそれぞれZaimクライアントと認証情報を持つと、読み取り側で起きた重複(asset-manager#191)を 書き込み側でも作ることになる。条件2・3は文言どおり満たしている。

この例外を前例として使わない。 「経路は開いているが資格情報を分散させたくない」という形の要望は 他のサービスでも出うるので、次に持ち込むときはこの節を根拠にせず、Issueで改めて決める。

画像メール送信は条件1の例外だが、Zaimの例外を前例にしていない(aide#230)

Research DeskのブラウザからGmail APIを直接叩くことも技術的には可能で、その場合「他のどこからも 塞がっている経路」ではなくなる。それでもAIDEへ寄せたのは、Zaimの例外(aide#37)と同じく Gmailの資格情報を1か所に閉じ込めるため——Research Deskがメール送信権限を持つと、送信元の 資格情報を持つアプリがまた1つ増える。この判断はZaimの例外を前例として使わず、Issue #230で 改めて行った。 条件2(別の資格情報)・条件3(作成のみ)は文言どおり満たす。

詳細は個人アプリ向けのZaim登録API

MCP経由のZaim登録は条件1を満たす(aide#135)

aide_zaim_payment は同じZaimへの登録だが、上の例外を根拠にしていない(「前例として使わない」と 書いてあるとおり、aide#135 で改めて判断した)。

条件1が見ているのは「その呼び出し元から届く経路が他にあるか」で、外部のClaude Codeから Zaimへ書く手段は現状ゼロ。唯一の口である POST /api/zaim/payment はVPS内の 127.0.0.1 から 叩く前提で、公開URLからは遮断される。Zaimの公開APIを直接叩けるのは 資格情報を持つ側だけで、Claudeはそれを持たない。条件2・3は aide#37 と同じ理由で満たす。

詳細は外部のClaude CodeからのZaim登録

Web版の入力画面からの登録は条件1を満たす(aide#214)

POST /api/zaim/payment/web は同じZaimへの登録だが、aide#37 の例外を根拠にしていない

条件1が問うのは「その経路が他から届くか」で、Zaim Web版の入力画面を操作する経路は他に無い。 公式APIは公開されているが、そのAPIで作った明細はZaimの「レシート置き換え」の候補にならない (品目・出金元・日付・金額がまったく同じでも、分かれ目は作成経路にある。 guchi-apps/asset-manager#300 で実測)。ログイン状態(storage state)とPlaywrightの実行基盤は このリポジトリにしか無く、asset-manager 側にはどちらも無い。

条件2は取得と同じ storage state を使うため文言どおりには満たさない——が、これは 「読み取り用のトークンに書き込み権限を足した」のではない。Zaimのログイン状態は元から 読み書きの区別を持たないCookieで、権限を広げる操作は発生していない。条件3(作成のみ)は満たす。

詳細はWeb版の入力画面からの登録

Related MCP server: mail-cal-drive-mcp

Core と MCP層の境界

ここが設計上いちばん間違えやすい。2つのレイヤーを分けて考える。

レイヤー

対象

方針

Core(src/core/

Notion・Google系・Zaim・GitHub すべて

公式APIを直接叩く。横断ビューとワーカーに必要なのでフルスコープ

MCP層(src/mcp/

横断ビュー + 公開のリモートMCPが無いもの のみ

同じ機能のツールが2セット並ぶと、ツール選択が曖昧になりコンテキストも食う。呼び出し側が公式のリモートMCP(Notion等)へ直接繋げるものは、AIDEに口を作らない

Core をフルスコープで作っておけば、MCP層で「出す/出さない」は後からいくらでも変えられる。判断を先送りできるので、Core は広く、MCP層は狭く始める。

単機能ツールを出している例外がZaimへの登録aide_zaim_master / aide_zaim_payment。aide#135)。 横断ビューではないが、Zaimに公式MCPは無く、外部のClaude Codeから届く経路も他に無い。 判断の根拠は書き込みをどこまで持つかに置いている。

基準は「Claudeアプリにコネクタがあるか」ではない(aide#173)

以前ここには「Claudeアプリには既に公式MCPがあるので、Googleカレンダー等の単機能ツールは出さない」と 書いていた。この基準は呼び出し元をClaudeアプリだけに見立てていて、実態に合っていなかった。

Claude Code や claude.ai で使えるGoogle系・NotionのコネクタはAnthropic製品側の機能で、 Messages APIを直接叩く自前のクライアント(aide-bot)からは 利用できない。aide-bot が繋げるのは公開のリモートMCPサーバーのURLがあるものだけで、 Notion にはそれがあり、Googleカレンダー・Gmailには無い(aide-bot#61)。

したがって見るのは「Anthropicの製品に同じ機能があるか」ではなく、呼び出し元から届く公開の リモートMCPがあるかになる。

公開のリモートMCP

AIDEのMCP層

Notion

ある

出さない(aide-bot から直接繋げる)

Googleカレンダーの予定

無い

出すaide_scheduleコネクタ: DaySpan

Gmail

無い

出していない(下記の理由で見送り)

Gmailを載せていない理由(aide#173)

同じ「公開のリモートMCPが無いもの」だが、Gmailの読み取りだけは見送った。 技術的な不可能さでは なく、認可の運用が重いため。

  • gmail.readonly はGoogleのrestricted スコープにあたり、同意画面の公開ステータスが「テスト」の ままだとリフレッシュトークンが7日で失効する(共有知識 knowledge/common-gotchas.md)。 実質「本番」への切り替えが要り、未審査のままだと警告画面を挟むことになる

  • 予定と違い、代わりに読める既存のアプリが無いため、AIDEがメール本文に届く資格情報を持つことになる

必要になった時点で、この節を根拠にせず改めてIssueで判断する。

送信専用の gmail.send個人アプリ向けの画像メール送信API。 aide#230)はこの節と別判断。 メール本文を読む権限を持たない別のスコープであり、見送ったのは 読み取りだけなので、この節の判断はそのまま当てはまらない——改めてIssue #230で判断した。ただし gmail.send も sensitive scope にあたり、同意画面が「テスト」のままだと7日で失効する制約は 同じなので、そちらも「本番」への切り替えが要る。

取得と提供の分離

Playwright を使うZaim取得のような重い処理は worker が定期実行してキャッシュに書き、MCPサーバー/APIはキャッシュを読むだけにする。

同期リクエスト中にヘッドレスChromiumを起動すると、応答に数十秒かかりメモリも跳ねる。VPSは2GBしかないため、この分離は必須。

  • VPS: MCPサーバー / REST API / DB(軽量・常時・公開)

  • サブPC: Playwright等の重いワーカー(16GB・断続・非公開)→ 結果をHTTPSでVPSへ送る

どこまでを「重い取得」とみなすか

分離が要るのは重い取得であって、あらゆる取得ではない。キャッシュを挟むと必ずジョブ間隔ぶん 古くなるため、鮮度そのものが価値であるデータに機械的に適用すると答えが悪くなる。

分離する

都度叩く

Zaim巡回(Playwright・十数秒・メモリが跳ねる)

ops-dashboard・subscription-lists(localhostへのHTTP GET・数ミリ秒)

判断

同期リクエストに載せるとVPSが持たない

載せても問題なく、キャッシュのほうが害になる

都度叩く場合は短いタイムアウトを必ず掛ける(相手が落ちてもMCPツールが固まらないように)。 到達できなかったことは握りつぶさず、状態として返す。

タイムアウトの長さは相手の作りで決める。 localhost へのHTTP GETでも、相手がそのリクエストの 中で外部APIを叩いていれば数ミリ秒では返らない。DaySpan(Google Calendar と Notion を叩く)だけ 8秒にしてあり、他の3秒と揃えるとその外部APIの遅さがそのままタイムアウトになる (コネクタ: DaySpan)。

軽くても分離せざるを得ないものが1つある。 Claude Code のセッション台帳は取得自体が 数ミリ秒だが、サブPCのファイルシステムにしか無いためVPSのサーバーからは読めない (コネクタ: Claude Code)。重さではなく置き場所が 理由なので、鮮度が落ちる代償を承知のうえで収集時刻を併せて返している。

構成

src/
  server.ts            エントリポイント。/mcp と /api を1プロセスで提供
  mcp/
    transport.ts       Streamable HTTP transport
    registry.ts        ツール登録簿
    tools/             MCPツール
  api/
    ingest.ts          worker からの取得結果の受け口(POST /api/cache/:key)
    read.ts            個人アプリ向けの読み取りAPI(GET /api/money/summary)
    zaim.ts            個人アプリ向けのZaim登録API(POST /api/zaim/payment)
    image-mail.ts      画像メール送信API(POST /api/image-mail/send。#230)
    multipart.ts       multipart/form-data の最小パーサー
    secret.ts          /api 配下の共有シークレット認証
  core/
    connectors/        外部サービスからの取得
      image-mail/       Gmail送信・冪等記録・履歴(#230)
    models/            共通データモデル
    views/             横断ビュー
  web/                 人間向けのHTMLページ(機能一覧・動作状況・共通知識)と共通レイアウト
  worker/              サブPC側で動くもの
    run.ts             定期実行ジョブのエントリポイント
    zaim-web-server.ts Zaim Web版登録の受け口(VPSからの中継先。常駐)

プロセスを1本に絞っているのはメモリ制約のため。パッケージ分割(monorepo化)は規模が育ってから検討する。

開発

Node 24 以降が必要(.ts を型ストリッピングで直接実行するため、トランスパイル不要)。

npm run dev     # node --watch --env-file-if-exists=.env src/server.ts
npm start
npm run typecheck

デフォルトで 127.0.0.1:4747 を listen する。PORT / HOST で変更可。

.env は Node 標準の --env-file-if-exists で読ませている。 AIDE_AUTH_PASSWORD が未設定だと サーバーは起動を拒否するため、読ませないと .env に書いてあっても起動しない(本番のPM2も node_args で同じ指定をしている)。

Claudeアプリから使う

ローカルの4747を、既存の dev-tunnel(Cloudflare Tunnel)経由で公開している。

開発用URL

https://aide-dev.minagu.work/mcp

tunnel設定

~/.cloudflared/config.yml の ingress。catch-all より前に置くこと

起動

cloudflared tunnel run dev-tunnel

ClaudeアプリのカスタムコネクタにこのURLを登録する。末尾の /mcp が要る。

接続元はAnthropicのサーバーであり利用者の端末ではないため、公開到達性が必要。Tailscaleのみのホストには置けない。

トラブル時:

  • エッジが404を返す — cloudflaredが ~/.cloudflared/config.yml を自動で読むため、--url を渡してもingressが優先され catch-all に落ちている。一時的なトンネルなら --config に空のconfigを渡す

  • 登録は成功するのに配送されない — QUIC(UDP 7844)が塞がれている。--protocol http2 で回避

MCPツール

ツール

内容

aide_ping

疎通確認。サーバー時刻とセッションIDを返す

aide_money_summary

資産・残高と月額固定費の現況。残高・保有銘柄はキャッシュを読むだけ(取得時刻と経過分数を併せて返す)、固定費は subscription-lists を都度叩く

aide_ops_status

VPS・サブPCの稼働状況。ops-dashboard の読み取りAPIを都度叩いて「いま異常があるか」の粒度に畳む

aide_room_status

いまの部屋の状態。myroom の読み取りAPIを都度叩き、センサーごとの室温・湿度・気圧・CO2・照度、エアコンの運転状態、屋外との気温差に畳む

aide_daily_briefing

今日1日の見通し。今日の予定・交通・今日と明日の天気を1回に畳む。ソースごとに独立して失敗する(取れたものだけ返る)

aide_schedule

指定した日から数日ぶんの予定・移動・タスク・日付リマインドと空いている時間帯。DaySpan から取得する。「今週の予定」「何時なら空いているか」に答えるためのもので、今日1日の見通しは aide_daily_briefing

aide_dev_status

各リポジトリの開発状況。最新リリース・未リリースの差分・Issue/PR・確認待ち・直近コミット・CIの成否。repo を指定すると1リポジトリの詳細(起票に使えるラベルの候補を含む)

aide_create_issue

GitHubのIssueを新規作成する。書き込みツール(作成のみ。編集・close・コメントは持たない)

aide_claude_sessions

サブPCで動作中の Claude Code セッションの一覧。リモートコントロールのURL・プロジェクト・状態(busy / waiting / idle)・待っている理由・経過時間を返す。キャッシュを読むだけ(台帳はサブPCにしか無い)

aide_zaim_master

Zaimへ登録するときに渡すID(口座・カテゴリ・ジャンル)の候補。24時間キャッシュし、一覧に無いものを使いたいときだけ refresh: true で引き直す

aide_zaim_payment

Zaimへ支出を1件登録する。書き込みツール(作成のみ。この経路から取り消し・修正はできない)

aide_create_notification

aide-botへ利用者に知らせる情報を登録する。ChatGPTスケジュール向けの書き込みツール

aide_create_task_candidate

aide-botへ対応が必要なタスク候補を登録する。ChatGPTスケジュール向けの書き込みツール

aide_save_daily_brief

aide-botへ日次ブリーフを登録する。ChatGPTスケジュール向けの書き込みツール

aide_research_desk_import_weekly_report

Research Deskへ宅配事業・ロッカー事業の業界情報を登録する。ChatGPTスケジュール向けの書き込みツール(1回あたり全体10件・1事業5件まで。重複判定・同一イベントの統合更新・冪等性はResearch Desk側が持つ)

ChatGPTスケジュール向け3ツールは、サーバー側の AIDE_BOT_URLAIDE_BOT_TOKENAIDE_BOT_EMAIL を使って aide-bot の POST /api/notices へ登録する。メールアドレスや認証トークンは MCPの引数・応答・ログへ出さない。詳しい設定と手動確認は docs/chatgpt-mcp.md を参照。

aide_research_desk_import_weekly_report も同じ考え方で、AIDE_RESEARCH_DESK_URLAIDE_RESEARCH_DESK_TOKEN を使って Research Desk の AIDE専用内部APIPOST /api/internal/weekly-report)へ中継する。ChatGPTはAIDEの接続認証だけを使い、 Research Desk側の認証情報には触れない。重複判定・同一イベントの統合更新・実行履歴・冪等性は Research Desk側が持つため、AIDEは入力の形(事業ごとの件数上限・URL・列挙値・日時・主要数値の 大きさ)だけを検証して結果をそのまま返す。同一性判定に使う項目(発表主体・対象製品・発表日・ extractedMetrics)も、AIDEは形を確かめて渡すだけで判定そのものは行わない。応答の insertedCount(新規)・mergedCount(統合更新)・duplicateCount(変化なし)・ excludedCount(上限超過で除外)で1件ごとの結末が分かる。

機能一覧ページ

GET /features で、このサーバーで使える機能(MCPツール・HTTPエンドポイント・workerジョブ)を 一覧表示する。デプロイ済みのAIDEに何が載っているかをブラウザから確認するためのもの。

実体は src/web/features.ts。MCPツールは登録簿から自動生成するため、ツールを増やせば何もしなくても 載る。HTTPエンドポイントだけは静的な宣言なので、src/server.ts にルートを足したら ENDPOINTS も更新する。

このページは認証なしで公開する。 載せてよいのは「どんな機能が存在するか」という静的なカタログ だけで、キャッシュの中身・取得時刻などの実データ、環境変数の値、認証の有効・無効は載せない。 掲載範囲はOAuthのディスカバリメタデータ(/.well-known/...)で既に公開されている情報を超えない。

/ は404のままにしている。

アイコンとPWAマニフェスト

src/web/icons/ に置いたPNGを GET /icons/<名前> で返し、GET /manifest.webmanifest で ホーム画面へ追加したときの名前とアイコンを返す(どちらも認証は不要)。ブラウザが <link> の 有無によらず取りにくる /favicon.ico にも、同じ32px版のPNGを返している。

アイコンの正は src/web/icons/icon.svg の1枚。 配信しているPNGはそこから scripts/build-icons.sh で書き出した写しなので、絵を直すときはSVGだけを直してスクリプトを 流し、生成物ごとコミットする(PNGを直接編集しても、次にスクリプトを流した時点で戻る)。 書き出しには rsvg-convertlibrsvg2-bin)を使うが、生成物をコミットするためCI・本番では 実行しない。したがって依存ゼロの方針には触れない。

実行時に画像を加工しない。 依存ゼロを保つため画像処理ライブラリを入れておらず、必要な サイズ(512 / 192 / 180 / 32)をあらかじめ書き出してコミットしてある。サイズを増やすときは scripts/build-icons.shsrc/web/assets.tsICONS の両方へ足す(ICONS に載って いるものがマニフェストにもそのまま出る)。片方だけだとテストが落ちる。

サイズが宣言とずれていないかは src/web/assets.test.ts がPNGのIHDRを直接読んで確かめている。

絵は中心から半径204.8px以内に収める。 512pxの版を maskable としても名乗っており、 Androidのアダプティブアイコンはそれより外を切り落としてよいことになっている。同じ理由で 背景は角を丸めず512x512いっぱいに塗る(角を透明にすると切り抜き形によっては地が透ける)。

同じ画像をMCPサーバーとしても名乗る。 initialize の応答の serverInfo.icons(MCP仕様 2025-11-25 の Implementation.icons)に載せており、対応するクライアントではコネクタの一覧などに このアイコンが出る。名乗るURLは同一オリジンの絶対URLにする必要がある(クライアントは資格情報を 付けずに取りに行き、サーバーと別オリジンのアイコンは拒否してよいことになっている)ため、 resolveBaseUrl() が返す公開URLから組み立てている(src/web/assets.tsmcpIcons())。 差し替えるときに触るのは ICONS だけで、Web側とMCP側の両方がそこから生成される。

表示するかどうかはクライアント側の実装次第で、載せたからといって必ず出るとは限らない。 反映にはコネクタの再接続が要ることがある。また initialize は相手が要求した版へ下げてネゴシエートするため、 クライアントが 2025-11-25 より前の版で繋いでいれば、名乗っても読まれない。 アクセスの記録には プロトコル版を持たせていないので、切り分け用にネゴシエート結果だけをサーバーのログへ1行残している ([mcp] initialize: protocol=... client=...)。

動作状況ページ

GET /status で、AIDE自身がいま正しく動いているかを表示する。それまでは、Claudeに aide_ping を 聞くかVPSのログを見るしか確かめる手段が無かった。

載せるもの

全体の判定と対応すべきこと、サーバー(稼働時間・バージョン・認証の有無)、定期ジョブの最後の実行、キャッシュの鮮度と件数、接続先の設定状況、MCPの登録クライアント数・トークン数、MCPへのアクセスの記録

載せないもの

残高の金額、シークレットの値、Zaimのログイン状態

認証

許可したGoogleアカウント(AIDE_STATUS_ALLOWED_EMAILS)。Supabase未設定の環境では AIDE_AUTH_PASSWORDAIDE_AUTH_DISABLED=1 なら素通しし、画面上で警告を出す

機能一覧(/features)とは公開範囲が正反対。 あちらは認証なしで公開する代わりに静的なカタログ しか載せない。こちらは実データを載せるためログインの内側に置く。見た目は共通 (src/web/layout.ts)だが、この境界は混ぜない。

判定そのものは src/core/views/health.ts が持ち、表示(src/web/status.ts)は並べ方と色だけを決める。 しきい値を変えるときはビュー側だけを見ればよい。

MCPへのアクセスの記録

Claudeアプリからの呼び出しは、これまでどこにも残らなかった。ジョブの失敗は Signaly へ飛び (src/worker/notify.ts)、実行の記録はキャッシュに残る(src/worker/record.ts)のに、 MCPだけは「いつ・どのツールが呼ばれたか」を後から確かめる手段が無かった(#116)。 /mcp のやり取り1件につき1行を残し、このページの「MCPアクセス」へ新しい順で出す。

残すもの

時刻・メソッド(認証で弾いた場合は auth)・ツール名・名乗ったクライアント名とバージョン・成否・失敗理由1行・所要ミリ秒

残さないもの

ツールの引数と応答の中身、アクセス元IP、アクセストークン、セッションID

置き場

data/mcp-access.json(直近200件。書き込みは2秒ぶんまとめて行う)

残高や部屋の状態を記録に含めない。 含めると、MCPが返すデータの置き場をもう1つ増やすのと 変わらなくなる。画面に出せるのも「いつ・誰が・どのツールを・成功したか」までで、中身が要るときは Claudeに聞くことになる。

置き場をキャッシュ(src/core/cache/store.ts)にしていないのは、あちらが worker からHTTPで届く 取得結果の置き場で、受け口(POST /api/cache/:key)から上書きできるため。記録を書くのはサーバー 自身なので、経路を共有する理由が無い。メモリ上だけに持つ案は、デプロイのたびに空になり 「最後にClaudeが繋いだのはいつか」に答えられなくなるため採らなかった。

認証で弾いたアクセスも1行残す。 /mcp はトークンが無ければ requireBearer() が401を返して そこで終わり、src/mcp/transport.ts までは届かない(src/server.ts)。ここを記録しないと、 Claudeのトークンが切れて呼び出しが全部落ちている状態と、誰も繋いでいない状態が画面上で 区別できない。ただし /mcp は公開されている口なので、叩かれ続けたぶんは1分に1件へ落とす。

上限を超えた分は「接続確認・一覧の取得 → 認証で弾いたもの → 残り」の順に捨てる。 単純に古い順で 捨てると、Claudeが定期的に投げてくるぶんや外から叩かれた認証失敗が並んだだけで、いちばん見たい ツールの呼び出しが押し出される。枠を分けずに優先順位だけで守るのは、片方が空でももう片方に 使えるようにするため。画面でも接続確認・一覧の取得の行は既定で畳んであり、チェックを入れたときだけ 同じ表に混ざる。

記録に失敗してもMCPの応答は変えない(src/mcp/access-log.ts)。実行記録と同じ方針で、記録の 失敗だけで成功した呼び出しが失敗扱いになるのを避ける。

ページを開いても外部へ問い合わせない

材料はすべて手元にあるもの(キャッシュ・実行記録・環境変数の有無)で済ませている。開くたびに ops-dashboard や GitHub を叩くと、相手が落ちているだけで画面が開かなくなる。疎通の確認は 「疎通を確認する」を押したときだけ POST /status/checks として走る。

Zaim は疎通確認の対象外。 ログインは Playwright を使う重い処理で、巡回は worker の仕事にしてある。

worker 側の設定は「未設定」と断定しない

本番では worker がサブPC、サーバーがVPSで動き、.env が別deploy.yml がVPSへ書くのは AIDE_* の一部だけで、ZAIM_*AIDE_SIGNALY_WEBHOOK_URL は含まれない)。サーバー側の 環境変数を見て判定すると、正しく動いていても常に「未設定」と出る。

そのため接続先には sideserver / worker)を持たせ、worker 側は判定せず「worker側」と表示する。 実際に動いているかは、定期ジョブの実行記録で分かる。

ログインは許可したGoogleアカウントだけ

ログインには他アプリ(dayspan・shopping-list)と同じ共有SupabaseプロジェクトのGoogleログインを使い、 AIDE_STATUS_ALLOWED_EMAILS に挙げたメールアドレスの人だけを通す(src/auth/supabase.ts)。 実データを載せる画面をパスワード1本の内側に置くと、漏れても気づけず、誰が開いたかも残らない。

@supabase/supabase-js は入れず、Auth の REST(/auth/v1/authorize/auth/v1/token?grant_type=pkce)を fetch で直接叩いている。実行時依存ゼロの方針(後述)と、 必要なのが認可URLの組み立てとコードの交換だけであるため。Supabase側のセッションは保持しない。 身元が分かった時点で失効させ、以降は自前のCookieだけで通す。

環境変数

内容

AIDE_SUPABASE_URL

共有SupabaseプロジェクトのURL

AIDE_SUPABASE_PUBLISHABLE_KEY

同プロジェクトの公開鍵(旧 anon key)

AIDE_STATUS_ALLOWED_EMAILS

画面を開いてよいメールアドレス(カンマ区切り)

3つとも設定するか、3つとも空にするかのどちらかで、半端な状態は起動時に落とす。 許可メールだけが 空だと「Googleアカウントがあれば誰でも開ける」状態になるため。3つとも空なら従来どおり AIDE_AUTH_PASSWORD でのログインになり、その場合は POST /status/login も生きている。 Googleログインが有効な環境では、パスワードでのログインは受け口ごと無効になる(404)。 残すと、メールアドレスで絞った意味がパスワード1本で消える。

戻り先URLの登録ずれは、起動時とその画面から検知する

Supabaseダッシュボードの Authentication > URL Configuration > Redirect URLs に、 AIDEの戻り先を登録しておく必要がある。ここが一致しないとき、Supabase(GoTrue)はエラーを返さず プロジェクトの Site URL へ静かに倒す。 ログインは「成功したのに別のアプリの画面が開く」という形で 壊れ、devtoolsで302先を1文字ずつ見比べるまで原因が分からなかった(#93)。認証基盤は他アプリと 共用のため、AIDEが何も変えていなくても他アプリ側の変更で許可リストが書き換われば同じことが起きる。

照合の規則が直感に反する。フラグメントだけを落とし、クエリは付いたまま glob で照合される (GoTrue の utilities.IsRedirectURLValid)。AIDEの戻り先は CSRF 対策の state を載せた <AIDE_BASE_URL>/status/auth/callback?state=<乱数> なので、パスだけを完全一致で登録しても通らない。 末尾に ** を付けた形で登録する。

https://aide.gucchii.com/status/auth/callback**

(Site URL と scheme・ホスト・ポートが一致する戻り先は許可リストを見ずに通るため、Site URL が AIDEのものであるプロジェクトではこのずれは起きない。共有プロジェクトの Site URL は別アプリのもの。)

一致しているかは src/auth/redirect-check.ts が確かめる。GET /auth/v1/verify成立しない トークンを渡すと、GoTrue は /auth/v1/authorize とまったく同じ判定関数で戻り先を決めてから エラーのリダイレクトを返すため、Location に「実際に採用された戻り先」がそのまま出る。 許可リストの中身そのものを読むには Management API とアカウント全体に及ぶ Personal Access Token が 要るが、欲しいのは一覧ではなく「AIDEの戻り先が通るか」なので、既にある公開鍵だけで足りる この経路を採った。成立しないトークンなのでセッションもメール送信も起こらない。

確認は2か所で走る。

  • 起動時に1回AIDE_BASE_URL があるときだけ)。結果はログに出し、起動は止めない。 Googleログインが壊れていると /status に入れず画面側の確認にも辿り着けないため、 そのときに気づける場所はログしかない

  • /status の「疎通を確認する」ボタンsupabase-redirect)。他の接続先と並べて表示する

セッションはOAuthとは別系統

ログインした結果はCookie1つで持つ(src/web/session.ts)。MCPのOAuthに載せると画面を開くたびに 認可コードの往復が要る。Cookieには署名だけを入れ、サーバー側に状態を持たない。 Googleログインならメールアドレスを、パスワードでのログインなら身元なしを入れ、メールアドレスも 署名の対象に含める(含めないと署名の合う値の宛先だけ書き換えて別人を名乗れる)。 許可リストとの照合はCookieを出すときだけでなく開くたびに行うので、リストから外せば 発行済みのCookieもその場で通らなくなる。

署名鍵はパスワードから導かない。 導くと、Cookieを1つ手に入れた相手がオフラインで パスワードを総当たりでき、回数制限(オンライン試行にしか効かない)を迂回されてしまう。しかも そのパスワードはClaudeアプリの接続認可と同じ1本なので、被害がこの画面の閲覧に留まらない。 鍵は data/auth/status-session-key に独立した乱数を1つ持つ(600で作る)。

rm data/auth/status-session-key   # 画面のログインを全部失効させる

総当たり対策は認可画面と同じ仕組み(src/auth/ratelimit.ts)を共有する。守っている パスワードが同じである以上、片方だけ無制限に試せると回数制限が意味を失う。

共通知識ページ

GET /knowledge で、全アプリ共通の共有知識(guchi-apps/docs)に何が入っているかと、 各リポジトリの知見メモがどう採用・却下されたかを1画面にまとめる(#161)。それまでは、 共有知識の中身はリポジトリを開かないと分からず、判定の結果は出典Issueのコメントに散っていた。

載せるもの

knowledge/ の見出し(=1知見)と確認日・出典、agent-rules/standards/guides/templates/ のファイル一覧、知見メモごとの採用/却下/未判定と理由・反映先、リポジトリ別の内訳、判定の滞留日数

載せないもの

シークレットの値。Markdownの本文(見出しと日付・出典だけを出し、中身はGitHubのリンクへ送る)

認証

動作状況ページと同じ。 private リポジトリの中身を出すので、機能一覧のように無認証では出さない

判定を下すのはAIDEではない。guchi-apps/docspromote-knowledge.yml(毎日05:00 JST)が 知見メモを審査して共有知識へのPull Requestを作り、結果を出典Issueへコメントする。 AIDEはその材料と結果を読んで並べるだけで、README冒頭の「高コストなAI推論はやらない」に従う。

ログインの関門は動作状況ページと共有する

判定は currentSession()src/web/status.ts)1か所だけが持ち、この画面へ書き写さない。 片方だけ条件が古くなると(例: 許可メールを開くたびに再照合する、という扱いが欠ける)、 そこが素通しの入口になる。

開こうとした画面は戻り先として持ち回る。 ログインの受け口は /status/login/status/auth/callback の1組しかないため、指定が無いと /knowledge を直接開いた人が ログイン後に動作状況へ落ちて戻ってこない。パスワードならフォームの hidden、Googleなら 往復用の署名付きCookie(src/web/session.tsHandshake.next)で運ぶ。

戻り先は受け取り側で必ず検証するsafeLanding())。署名やフォームが保証するのは 「AIDEが出した画面から来たこと」までで、値そのものは利用者の手を通る。ナビに載っている 画面(src/web/layout.tsNAV)以外は既定(/status)へ落とす。外部URLをそのまま Location に載せると、ログイン直後に別サイトへ送り出す踏み台になる。

判定の記録が空でも「まだ運用が始まっていない」とは限らない

このページを入れた時点で、判定の記録はフリート全体で1件も存在しなかったpromote-knowledge.yml は追加以降の実行がすべて失敗していて、知見メモ188件が未判定のまま 溜まっていた。設計上「判定エージェントが止まっても誰も気付かない」ことは分かっていた (issue-deckの docs/shared-knowledge.md)が、気付く場所が無かった。

そのため、採用も却下も0件のときは「未判定が溜まっている」ではなく「判定の記録が1件もない」と 言い切る。未判定の滞留日数(最も古いメモからの経過日数)も先頭に出し、判定が毎日走っている 前提が崩れていることを画面から拾えるようにしている。

マーカーは「行全体が一致するか」で見る

知見メモも判定結果も、Issueコメントの不可視マーカー(<!-- knowledge-candidate --> / <!-- knowledge-promotion:judged -->)で識別する。素の文字列一致では誤検出する。 この運用を決めたIssue自身が、書式の説明としてマーカーをコードフェンスや地の文へ貼っているためで、 実データでは判定していない11件が「却下」として並んだ。

対策は2つで、どちらも src/core/views/knowledge.ts にある。

  • コードフェンスで囲まれた部分を落としてから探す

  • 行全体がマーカーであることまで見る(テンプレートは必ず独立した行に置く。地の文での言及は `<!-- ... -->` の形になる)

同じ材料を読む promote-knowledge.yml はこの区別をしていないが、あちらは集めたあとの解釈を Claude Codeに委ねているため実害が出ていない。

開くとGitHubへ問い合わせる(動作状況ページとは逆)

/status は「相手が落ちているだけで画面が開かなくなる」のを避けるため手元の材料しか読まない。 このページは取得結果そのものが中身なので避けようがなく、開くとGraphQLを2本 (共有知識のファイル・フリート横断のIssue検索)投げる。実測で8〜10秒・コスト2ポイント。

材料が変わるのは共有知識へのPull Requestがマージされたときと判定が走ったときだけなので、 取得結果はプロセス内に5分持ち回る。判定を手で流した直後に確かめたいときは ?refresh=1 を付ける。

Issue検索は新しい順に2ページ(最大200件)までめくる。上限に達した場合は画面に明示する (黙って切ると「全部見た」と読めてしまう)。

書式の揺れは、落とさず拾う

知見メモの書式は実際には揃っていない。見出しが ### のもの・**太字** のもの・地の文だけの ものがあり、マーカーの位置もコメントの先頭と末尾の両方がある。解析は best-effort に振り、 見出しが取れなかったものは本文の冒頭を見出しとして出す。 落とすと「メモを書いたのに一覧に 出ない」という最悪の形になるため、精度より取りこぼしの無さを優先している。

コネクタ: Zaim

Zaimは残高取得の公式APIが無いため、Playwrightで画面を巡回して取得する。AIDEが存在する理由そのものにあたるコネクタ(公式MCPも公式APIも無い領域)。

src/core/connectors/zaim/
  parse.ts       生テキスト → 数値化(純粋関数。テストはここに集中する)
  scrape.ts      子プロセスで巡回スクリプトを起動する
  refresh.ts     子プロセスで連携口座の一括更新スクリプトを起動する
  retry.ts       再試行と自動再ログインの「判断」(純粋関数。テストはここ)
  session.ts     子プロセスの起動と、失敗時の回復(再試行・自動再ログイン)
  web-payment.ts Web版の入力画面からの登録(#214)。子プロセスでスクリプトを起動する
  web-idempotency.ts / idempotency.ts  二重登録を防ぐ記録(経路ごとに別ファイル)
  scripts/       Playwright本体(子プロセスとして実行)
    login.mjs        初回の手動ログイン。storage state を保存する
    auto-login.mjs   ID・パスワードによる自動ログイン(任意機能)
    scrape.mjs       残高+証券詳細ページの巡回
    refresh.mjs      連携口座の「データを更新する」を押し、反映を待つ
    keep-alive.mjs   セッション延長のみ(軽量)
    web-payment.mjs  入力画面(/money/new)を埋めて品目明細を1件登録する
    receipt-form.mjs 入力画面の当て方のうち、ブラウザが要らない判断(純粋関数。テストはここ)

前提

PlaywrightはAIDEの依存に含めない。実行環境へグローバル導入する。

npm install -g playwright && playwright install chromium

package-lock を肥大化させず、ブラウザ実行環境をアプリ本体から分離するため。

初回セットアップ

GUIのある端末で一度だけ手動ログインし、storage state を保存する。

node src/core/connectors/zaim/scripts/login.mjs

保存先は既定で data/zaim/storage-state.jsonリポジトリ基準。カレントディレクトリ相対にするとワーカーからの実行時にずれる)。中身はCookieそのものなので data/ ごと gitignore している。

セッション

Zaimの認証Cookieは約2時間で失効するが、アクセスのたびにその時点から延長される。つまり維持できるかどうかは「2時間以内に1回でも成功したか」だけで決まる。取得を行わない期間は keep-alive.mjs で延長だけする。

この「1回でも成功したか」が曲者で、単発の失敗がそのままセッション喪失になっていた(#63)。以前は zaim-keep-alive が毎時1回きり・再試行なしで、最悪間隔が1時間5分あった。2026-08-16 に瞬間的なネットワーク断(net::ERR_ADDRESS_UNREACHABLE)で1回落ち、次の実行が2時間1分後になった時点で失効している。いまは次の3段で守っている。

何をするか

どこ

再試行

一時的な失敗(ネットワーク断・タイムアウト等)を最大3回・合計40秒の待ちでやり直す

retry.ts / session.ts

間隔の余裕

30分ごと(揺らぎ2分)に回し、最悪間隔を32分にする。3回続けて失敗しても2時間に間に合う

deploy/systemd/

自動再ログイン

失効を検知したら、資格情報がある場合だけ1度だけログインし直してやり直す

auto-login.mjs

セッション失効は再試行しない。 やり直しても同じ結果になるため、isRetriableZaimFailure() で切り分けて即座に次の手(自動再ログイン)へ移る。

Zaimの子プロセスを起こす経路は runZaimScript() に一本化する。 巡回・セッション延長・一括更新のどれもここを通す。以前は一括更新(refresh.ts)だけが execFile を直呼びしており、失効しても自動再ログインを試さないまま落ちていた(#190)。回復の仕組みを足しても、経路から外れているものには効かない——実際 zaim-refresh が失効で落ちた直後に zaim-keep-alive が同じ失効を自動再ログインで直しており、直せたはずのものが直っていなかった。

失効の判定は「ログイン画面へ飛ばされたか」で見る

Zaimは未ログインでもHTTPエラーを返さず、SSO(id.kufu.jp)のログイン画面へ飛ばす。判定は scripts/session-check.mjs の1か所に集約し、飛び先のURLとパスワード入力欄の有無だけで見る。

本文の文言では判定しない。 以前は「ログイン/メールアドレス/パスワード」を含み、かつ 「残高/総残高/評価額」を含まないことを失効の条件にしていたが、金額を載せないページでは ログイン済みでも失効と誤判定する。 連携口座一覧(/online_accounts)がまさにそれで、 zaim-refresh はボタンを押した30秒後の確認で必ず失敗し、「手動ログインが必要」という誤った 通知だけが届いていた(#89)。連携口座の最終更新が何日も進まない原因になる。

自動再ログイン(任意機能)

ZAIM_EMAILZAIM_PASSWORD両方が設定されている環境でだけ有効になる。片方だけの設定は設定漏れとみなし、未設定として扱う。

  • 未設定なら従来どおり ZAIM_SESSION_EXPIRED で失敗させ、手動ログインをやり直す。開発機・CIではこちらが既定

  • CAPTCHAや追加認証を突破しにいかない方針は変えていない。 auto-login.mjs は追加認証を検知したら素直に失敗し、呼び出し側は元の ZAIM_SESSION_EXPIRED を投げ直す。通知は従来どおり「手動ログインが必要」として届く

  • 自動再ログインは1回きり。ログインし直しても失効するなら(資格情報が古い等)諦める。ログインと失効を往復させないため

  • 資格情報の値は session.ts では読まない(設定の有無だけを見る)。子プロセスへ環境変数として渡し、ログ・通知・例外メッセージには出さない

値の正は1Passwordに置くが、実行時に1Password CLIは呼ばない(#1)。worker が動くサブPCの .env へ人が転記する。VPS側には要らない(VPSはキャッシュを読むだけ)。

ログイン画面は id.kufu.jp のSSOで構成が変わりうるため、セレクタは ZAIM_LOGIN_EMAIL_SELECTOR / ZAIM_LOGIN_PASSWORD_SELECTOR / ZAIM_LOGIN_SUBMIT_SELECTOR で上書きできる。未設定なら type / name 属性から総当たりで探す。

呼び出し方

scrapeZaimSnapshot() はヘッドレスChromiumを起動するため数十秒かかる。MCPやAPIの同期リクエストから直接呼んではいけない。worker から定期実行してキャッシュに書き、参照側はキャッシュを読む。

連携口座の更新(巡回の前に押す)

Zaimの連携口座は「データを更新する」を押すまで、各金融機関から再取得されない。 押さないまま巡回すると、その日の資産額として古い残高が記録される。zaim-refresh ジョブが https://zaim.net/online_accounts のボタンを押し、完了を待ってから zaim-sync が巡回する(#62)。

反映までの時間は口座によって大きく違う。 多くは5〜15分で戻るが、SBI証券・楽天証券・Ponta・MUFGカードは押してから約35分後にしか進まない。この差を見込まずに待ちを打ち切ると、遅い口座だけ前日の残高のまま巡回され、asset-manager 側の当日の合計がそのぶんずれる(#178)。

押し方・判定

ボタン

form[action="/online_accounts/renewal"] button[type=submit]クラス名はCSS Modulesのハッシュ付きでZaimのデプロイごとに変わるため使わない

確認ダイアログ

data-confirm によるネイティブダイアログが出る。Playwrightの既定は dismiss なので page.on("dialog", (d) => d.accept()) が必須(無いと押しても必ずキャンセルされる)

完了判定

Zaim側に完了のシグナルは無い。口座ごとの「最終更新」が進んだかで判定する。反映まで5〜15分、遅い口座は約35分

打ち切り

連携設定が壊れている口座は何度押しても進まないため、全口座の完了は待てない。しばらくどの口座も進まなくなったら打ち切る(最短40分・静穏3分・上限45分)

「最短40分」は遅い口座の実測に合わせた値(#178)。早い口座が5〜8分で出揃ったあと、遅い口座が進むまで30分近くどの口座も動かない。静穏だけで打ち切ると毎回そこで抜けてしまうため、静穏の判定は40分を過ぎてから効かせている。上限(ZAIM_REFRESH_MAX_WAIT_MS)を縮めるときは、refresh.tsREFRESH_TIMEOUT_MSaide-zaim-refresh.serviceTimeoutStartSecaide-zaim-sync.timer との65分の間隔もあわせて見直す。

一括更新だけは、やり直しに全体の上限(totalTimeout)を掛ける。 巡回とセッション延長は1回が数十秒なので3回やり直しても次の定期実行に食い込まないが、一括更新は1回で最大45分待つ。上限が無いと、やり直した回が TimeoutStartSec(55分)に掛かって systemd から殺され、押下の結果すら受け取れない。残り時間が2分を切ったらやり直さず、元のセッション失効エラーをそのまま投げる(タイムアウトのエラーで上書きすると通知の分類が壊れる)。逆に2分あればやり直す価値がある——反映を待ち切れなくても、「データを更新する」さえ押せていれば65分後の巡回は新しい残高を読める。

zaim-refreshzaim-keep-alive の重なりは直していない。 両者は同じ storage state を読み書きし、タイマーの都合で必ず重なる(keep-alive は30分ごとなので、45分走る一括更新の最中に2〜3回起動する)。失効で落ちた回だけを見ると並行アクセスが原因に見えるが、成功した回もまったく同じように重なっている(2026-08-28 22:30・08-29 10:30 の成功回でも、開始の1分後に keep-alive が起動している)。ロックや排他を足しても失効は防げないので、直すべきなのは落ちた側が自力で回復することのほう。

「最終更新」はブラウザのタイムゾーンで描かれる

Zaimは連携口座の最終更新をクライアント側で描画するため、表示される日時はヘッドレス Chromiumのタイムゾーンに従う。subpc のシステムTZはUTCなので、指定しないと9時間ずれる。 取り込み側(parse.ts)は表示文字列をJSTとして解釈し「当日(JST)に更新されたか」を見るので、 ずれると当日更新できた口座まで「更新できなかった口座」として通知される(#89)。

コンテキストの共通設定(scripts/context.mjs)で timezoneId: "Asia/Tokyo" に固定し、 newContext を呼ぶスクリプトが全部それを渡していることをテストで縛っている。表示の日付形式は Zaim側が決めているため locale は指定しない。

画面の確認は ZAIM_REFRESH_DRY_RUN=1 で行う。ボタンを押さずに、いま読めている口座と最終更新を出力するだけになる(押すとZaimが実際に各金融機関へ取得しにいくため、確認のたびに押さないで済むようにしてある)。

ZAIM_REFRESH_DRY_RUN=1 node --env-file-if-exists=.env src/core/connectors/zaim/scripts/refresh.mjs

更新できない口座の扱い

連携先のAPIキーの権限エラーや金融機関側のログイン期限切れで、何度押しても更新できない口座が残る(Zaim側の連携設定を直すまで解消しない)。AIDEはこれを次のように扱う。

  • 古い残高を捨てたり書き換えたりしない。 取得した事実だけを持つのがAIDEの責務で、当日値として記録するかの判断は asset-manager 側にある

  • 代わりに口座ごとの最終更新を持たせる。balances / holdingslastUpdatedAt と、連携口座の一覧 onlineAccounts(いずれもJSTオフセット付きのISO8601)

  • GET /api/money/summary は最終更新が当日でない口座を staleAccounts にまとめ、note にも断りを入れる。捨てるか使うかは呼び出し側が決める

  • lastUpdatedAtAIDEが巡回した時刻(fetchedAt)とは別物。巡回が新しくても中身が何ヶ月も前ということがある

  • 更新できない口座が出たら Signaly へ通知する(ジョブ失敗の通知)。ジョブ自体は成功扱いのまま

  • 判定するのは押下側(zaim-refresh)ではなく巡回側(zaim-sync)。 押した直後には反映の遅い口座がまだ進んでおらず、押下側で見ると「更新できない口座」と「反映が遅いだけの口座」を区別できない(#178)

asset-manager との境界

置き場所

理由

巡回・パース

AIDE

「取得」そのもの。他アプリからも再利用する

連携口座の更新(ボタン押下)と最終更新の取得

AIDE

Zaimへ取りに行く経路そのもの。取得結果に「いつのものか」を添えるところまで

最終更新が当日でない口座の残高を記録するか

asset-manager

「その日の資産額として何を採るか」は資産管理側の判断

Category.valuationAlias との照合、評価額への反映

asset-manager

資産管理固有のドメインロジック

同期を実行できるユーザーの制限

asset-manager

asset-manager の認証・ユーザーモデルに紐づく

asset-manager は巡回結果を読み取りAPIGET /api/money/summary)から受け取る。

取得は巡回、登録は2経路

同じZaimでも、読む経路と書く経路はまったく別にしている。混同すると、片方の資格情報で もう片方を動かそうとして詰まる。さらに書く側も公式APIとWeb版の画面の2本ある(#214)。

取得(残高・保有銘柄)

登録: 公式API

登録: Web版の入力画面

手段

Playwrightでの画面巡回

POST /v2/home/money/payment

Playwrightでの画面操作

資格情報

ログイン状態(storage state)

OAuth 1.0a(AIDE_ZAIM_*

ログイン状態(storage state)

動く場所

サブPCの worker

VPSのサーバー(同期リクエスト内)

サブPC(storage state がある側。VPSは中継する)

実装

scrape.ts / parse.ts / session.ts

oauth.ts / write.ts

web-payment.ts / scripts/web-payment.mjs

置き換えの候補になるか

ならない

なる

Zaim APIで扱えるのは利用者が手入力したレコードだけという制約があり、残高も取れない。 だから取得は巡回のまま残している。

銀行・カード・スマートレシート由来の自動連携レコードはAPIから見えず、編集もできない。 既存レコードの口座付け替え・集計対象外化はこの経路では実現できない(asset-manager#153 Phase 5)。

Web版の入力画面からの登録(置き換えに載せるため)

Zaimの「レシート置き換え」の候補になるのは、Web版の入力画面で作った明細だけ。 品目・出金元・日付・金額がまったく同じでも、公式APIで作った明細は候補にならない (guchi-apps/asset-manager#300 で実測)。分かれ目は内容ではなく作成経路にある。

作成経路

置き換え候補になるか

公式API(POST /v2/home/money/payment)・出金元が「反映待ち」

ならない

公式API・出金元が連携カード

ならない

Web版の入力画面(/money/new)・出金元が連携カード

なる

そのため、置き換えに載せたい明細だけは POST /api/zaim/payment/web を通す。置き換えの 操作そのものはスマートフォンアプリ限定なので、この経路が担うのは登録まで。置き換えは人が行う。

呼び出し方

登録に使う値は呼び出し元が決めて渡すwrite.ts と同じ方針で、アプリ固有のドメイン知識は AIDEへ持ち込まない)。公式API経由との違いは3つ。

  • カテゴリはIDではなく名前で渡すcategoryName / genreName)。画面がIDを受け取る欄を 持っていないため。名前は GET /api/zaim/master で引ける

  • name(品目名)・place(店舗名)・fromAccountId(出金元)は必須。 置き換えの成立条件が 「品目・出金元・日付・金額の一致」なので、欠けた明細を作っても目的を果たさない。 fromAccountId には自動連携しているクレジットカードを指定する(どの口座が自動連携かは AIDEでは判断しない)

  • moneyId は返せない。 履歴の行にレコードidが振られておらず、画面から読めない。 代わりに冪等キーをメモ欄へ #<requestId> として書き込むので、後からZaim側で引ける

curl -sS -X POST http://127.0.0.1:4747/api/zaim/payment/web \
  -H "authorization: Bearer $AIDE_ZAIM_WRITE_SECRET" \
  -H "content-type: application/json" \
  -d '{"requestId":"asset-manager:receipt-item:1","date":"2026-08-29","amount":1880,
       "name":"ピザ","place":"ドミノ・ピザ","categoryName":"食費","genreName":"外食",
       "fromAccountId":21678522}'

応答まで数十秒かかる(ヘッドレスChromiumの起動を含む)。呼び出し元はタイムアウトを長く 取ること——短く切ると「登録されたか分からない」状態を自分で作ることになる。 "dryRun": true を足すと送信だけ行わず、埋まった内容を返して終える。

動くのは storage state があるマシンだけ。VPSは中継する

この口は AIDE_ZAIM_*(OAuth)を見ない。使うのはログイン状態だけで、Playwrightと data/zaim/storage-state.json がある実行環境——いまはサブPC——でしか成立しない。 ところが呼び出し元(asset-manager)もAIDEのサーバーもVPSにいるため、#214 の実装だけでは 呼び出し元から一度も届かなかった。そこでサブPCにも受け口を常駐させ、VPSの同じパスが そこへ同期で中継する(#215)。

asset-manager(VPS)
  └ POST https://aide.gucchii.com/api/zaim/payment/web   ← 呼び出し元から見えるURLは変えない
      └ AIDE サーバー(VPS・PM2)
          └ AIDE_ZAIM_WEB_UPSTREAM_URL があれば中継(src/core/connectors/zaim/web-payment-forward.ts)
              └ 受け口(サブPC・systemd。Tailscaleのアドレスで待ち受け)
                  └ Playwright で Zaim の画面を操作

VPS(中継する側)

サブPC(画面を操作する側)

動かすもの

本体サーバー(PM2)

src/worker/zaim-web-server.tsaide-zaim-web.service

開く口

従来どおり全部

POST /api/zaim/payment/web/health だけ

要る設定

AIDE_ZAIM_WEB_UPSTREAM_URL

AIDE_ZAIM_WRITE_SECRETAIDE_ZAIM_WEB_HOST

冪等の記録

持たない

data/zaim-web-payments.json

呼び出し元から見えるURLは変えない。 asset-manager が知っているのは AIDE_BASE_URL だけで、 AIDEがどのマシンで何を動かしているかは AIDE 側の都合だから。サブPCへ直接向けさせると、 読み取りAPIまでサブPC経由になり、常駐が止まった瞬間に無関係な機能まで落ちる。

サブPCで動かすのは本体サーバーではない。 src/server.ts をそのまま常駐させれば済むように 見えるが、それだとOAuth認可サーバーとログイン画面がもう1組でき、data/auth/ が二重になる。 必要なのはこの1経路だけなので、それだけを開いた小さな受け口(src/worker/zaim-web-routes.ts)に してある。認証・入力の検査・失敗の分類は本体と同じ handleZaimWebPayment を通す。

受け口の .envAIDE_ZAIM_WEB_UPSTREAM_URL を書かない。 中継する側の設定で、 受け口はこれが設定されていると起動を拒否する。取り違えても回り続けないよう、中継には x-aide-zaim-web-forwarded: 1 を付けて1往復で止めている。

中継が失敗したときにどちらへ倒すか

「Zaimに何も登録されていないと言い切れるか」だけで分けるweb-payment.tsclassifyWebFailure() と同じ考え方)。ここを誤ると、二重登録か、登録済みの明細を 人が探す手間かのどちらかになる。

中継の結果

分類

呼び出し元

接続できない(ECONNREFUSEDENOTFOUND 等)

rejected

そのまま送り直してよい

受け口が画面を開く前に断った(401・429・503 等)

rejected

設定を直して送り直す

受け口が kind を返した

その値のまま

受け口の判断に従う

打ち切り・応答待ちでの切断

failed

Zaimを確認するまで再送しない

受け口が返した kind は潰さない。 特に conflict(前回の結果が確定していない)を 再送可能な分類へ倒すと、同じ支出が2件でき、この経路には削除が無いので人が手で消すことになる。

同時に流すのは1件だけ

ログイン状態はファイル1つで、2つのChromiumが同時に開くと更新が競合し、巡回まで巻き込んで セッションを失う。そのため createZaimWebPayment()実行中なら待たせずに rejected で断る。 待たせないのは、待つと呼び出し元のタイムアウトに掛かって「登録されたか分からない」になるため。 画面を開く前に断れば言い切れる。

画面の当て方

クラス名はCSS Modulesのハッシュ付きPaymentForm-module__total___3LWZX)でZaimのデプロイ ごとに変わるため使わない。当てているのは name 属性・placeholder・ラベルの文言。 実物で確かめた作り(2026-08-31)は次のとおり。

実際の作り

フォーム

form#money_new_formaction="/receipts")。品目の行は3行で固定。増やす操作は無い

品目名・メモ

input[name="item_name"] / input[name="comment"]。素直に入力できる

金額

input[name="amount"]readonly。クリックで開く電卓にキーボードで打ち、Enterで確定する。電卓のボタンは合成クリックに反応しない

カテゴリ

コンボボックスに名前で絞り込んで選ぶ。絞り込みは部分一致で、「その他」だけでは13件が並ぶため、直前のカテゴリ見出しと合わせて特定する

出金元

フォーム内で唯一の <select>option の value がZaimの口座IDそのもの

日付

ピッカーを開いて「前/次」で月を送り、日を押す。表示形式(2026年8月31日(月))を自前で組み立てない(曜日の計算がずれると違う日付で登録される)

使わない品目行は消してから送る。 空行が無視される保証が無く、0円の明細が増えると 人が手で消すことになる(この経路は削除を持たない)。

要素が見つからなければ必ず失敗させる。 入力欄が欠けたまま進むと、金額や出金元の無い明細が 家計簿に残る。埋め終えた後も、送信の直前に「入れたつもりの値が実際に入っているか」を読み直す。

画面の確認は送信せずに行える。

ZAIM_WEB_PAYMENT_DRY_RUN=1 \
ZAIM_WEB_PAYMENT_INPUT='{"requestId":"check:1","date":"2026-08-29","amount":1880,"name":"ピザ","place":"ドミノ・ピザ","categoryName":"食費","genreName":"外食","fromAccountId":21678522}' \
node --env-file-if-exists=.env src/core/connectors/zaim/scripts/web-payment.mjs

二重登録を止める

記録は data/zaim-web-payments.jsonweb-idempotency.ts)。公式API経由の data/zaim-payments.json とは別ファイルにしている——あちらは「money_id が入っていること」を 確定の印にしており、idを持てないこちらの記録を混ぜると、登録できた明細まで「結果不明」として 扱われる。

失敗したときにZaimへ何が残っているかで扱いが割れる。ここが二重登録の分かれ目になる。

失敗の種類

記録

呼び出し元

送信のに止まった(要素が見つからない・カテゴリが候補に無い・セッション失効)

消す

rejected。直せば送り直してよい

送信したで確認できない・打ち切り・Chromiumが落ちた

残す

failed。次の再送は conflict で止まる

一時的な失敗をやり直さないrunZaimScriptretryTransient: false)。巡回は何度実行しても 結果が変わらないが、登録は変わる。セッション失効時の自動再ログインだけは従来どおり通す—— 失効はページを開いた時点で分かるため、送信より前で必ず起きる。

コネクタ: ops-dashboard

VPS・サブPCの稼働状況。AIDEは指標を集めない。 ops-dashboard が既にホスト指標・外形監視・AI/GitHub/1Password の残枠を集約しているため、その読み取りAPIを叩いて 1本のMCPツール(aide_ops_status)に畳むだけにしている。

Zaimと違い「公式APIが無いから自分で取りに行く」ケースではなく、既にある集約を横断ビューへ畳む ケースにあたる。ここで指標収集を作り直すと ops-dashboard と二重になる。

src/core/connectors/ops-dashboard/
  types.ts   ops-dashboard のレスポンスのうち、AIDEが使うフィールドだけを再宣言
  index.ts   6本のGETを並行で叩く。1本落ちても他を巻き込まない
src/core/views/ops.ts        しきい値判定と圧縮(summarizeOps は純粋関数。テストはここ)

経路

両方とも同じVPS上で動くため localhost で届き、ops-dashboard を外部公開する必要がないfetch しか使わないので実行時依存も増えない。方式はaide#27と同じ。

環境変数

未設定のとき

設定したとき

AIDE_OPS_DASHBOARD_URL

http://127.0.0.1:3110

そのURLへ問い合わせる

AIDE_OPS_DASHBOARD_TOKEN

取得を試みず「未設定」を返す

Authorization: Bearer で認証する

トークンは認証情報として扱う。ログにもMCPのレスポンスにも出さない。取得失敗の理由は HTTPステータスと例外の種別まで丸める(例外の message にはURLが載るため)。

ops-dashboard 側の読み取りAPIは元々ログインセッション必須で、サーバー間用のトークン認証は ops-dashboard#85 で追加済み (requireSessionOrApiToken)。

全ソースが 401 になるとき

トークンは1Password上の1か所を両側から参照している(#217)。以前はAIDE側にも同じ値を複製して おり、ずれると6ソースすべてが 401 になっていた(#63)。

どちら側

環境変数

1Password

ops-dashboard(受け)

OPS_API_TOKEN

op://apps/ops-dashboard/ops-api-token

AIDE(送り)

AIDE_OPS_DASHBOARD_TOKEN

同上(提供側を参照する)

unavailable1本だけ 401 なら ops-dashboard 側のルート追加漏れ、6本すべて 401 なら 値の不一致か、ops-dashboard 側で OPS_API_TOKEN が未設定(未設定だとトークン経路は常に不可)。 1Passwordの正は1か所だが、GitHubのsecretは各リポジトリへ同期した写しなので、値を入れ替えた後に 片方のリポジトリだけ同期していないとずれる。

返す粒度

「いま異常があるか」に答えられるところまで。24時間分の履歴・上位プロセス・tmuxセッションの名前や 作業ディレクトリ・全ディスクマウントは返さない。 生の指標をそのまま渡してもコンテキストを食うだけで 答えは良くならない。詳細を見たいときは ops-dashboard の画面がある。

problems に異常が1行ずつ入り、これだけ読めば答えられるようにしている。しきい値は src/core/views/ops.tsTHRESHOLDS にまとめてある(残枠の 15% / 35% は ops-dashboard 側の remainingTone() と揃えてある)。

オフラインのホストでは指標を評価しない。 最後に受け取った値をそのまま判定すると、落ちる直前の CPU 100% を「いま高負荷」として報告してしまう。

ok(判定できた範囲で異常なし)と complete(全ソースを取得できた)は別に返す。1本だけ落ちるケース (1Password CLIが無い等)は普通に起きるため、全体を失敗にすると「他は正常だった」という情報まで失う。

コネクタ: subscription-lists

月額固定費(サブスクリプション)と次の支払予定。AIDEは契約情報を持たない。 subscription-lists が既に管理しているため、 サーバー間参照用の読み取りAPI(GET /api/internal/subscriptions)を叩いて aide_money_summaryfixedCosts に畳むだけにしている。ops-dashboard と同じ「既にある集約を横断ビューへ畳む」ケース。

src/core/connectors/subscriptions/
  types.ts   subscription-lists のレスポンスのうち、AIDEが使うフィールドだけを再宣言
  index.ts   1本のGET。設定・タイムアウト・失敗理由の丸め
src/core/views/money.ts      Zaimのキャッシュと合わせて畳む(summarizeFixedCosts は純粋関数。テストはここ)

経路

両方とも同じVPS上で動くため localhost で届き、subscription-lists を外部公開する必要がないfetch しか使わないので実行時依存も増えない。

環境変数

未設定のとき

設定したとき

AIDE_SUBSCRIPTIONS_URL

http://127.0.0.1:3107

そのURLへ問い合わせる

AIDE_SUBSCRIPTIONS_TOKEN

取得を試みず「未設定」を返す

Authorization: Bearer で認証する

トークンは相手側の INTERNAL_API_KEY同じ値で、認証情報として扱う。1Passwordでは値を 複製せず提供側の op:// をそのまま参照する(#217)。取得に失敗しても Zaim 由来の残高・保有銘柄は 従来どおり返す。失敗の理由はHTTPステータスと例外の種別まで丸める(例外の message にはURLが 載るため)。

計算はしない

月額換算・次回支払日・契約状況は相手が計算済みで返す。月末クランプ(billingDay=31 の2月)・ 料金改定履歴の期間切り替え・請求サイクルの判定は向こうの src/lib/billing.ts にあり、こちらで 再実装すれば必ずズレる。仕様は subscription-lists の docs/internal-api.md

基準日(referenceDate)は日本時間で渡す。 VPSのタイムゾーンはUTCで、渡さないと日本時間の 00:00〜09:00 が前日基準で計算される。

返す粒度と、totals へ足さない理由

通貨別の月額合計・支払方法別の月額合計・契約ごとの明細(契約状況と支払方法つき)・ 31日以内の支払予定まで。契約IDやラベルは返さない(詳細は subscription-lists の画面がある)。

支払方法別の合計は相手の totals に無いため、AIDE側で明細から積み上げているsummarizeByPaymentMethod)。 通貨別合計と同じく通貨をまたいで加算せず、支払方法と通貨の組で束ねる。積み上げの都合で 浮動小数の誤差が出るため小数2桁へ丸めており、相手が計算した monthlyByCurrency とは末尾が 一致しないことがある。

契約状況(contractStatus)は既定で解約済み(ENDED)が取得対象から外れるため、実質 AUTO_RENEWING / SCHEDULED_TO_END の2値になる。その旨は note に添えている。

MoneySummary.totals(残高・保有銘柄)へは足さない。あちらは「いま持っている額」(ストック)で 固定費は「毎月出ていく額」(フロー)にあたり、同じ合計に混ぜると意味が壊れる。

通貨は JPY / USD の混在を許すため、合計は通貨別で返す。円換算値(monthlyJpy)は相手が Frankfurter のレートで計算した参考値で、取得できていなければ null になる。

コネクタ: myroom

いまの部屋の状態(室温・湿度・気圧・CO2・照度とエアコンの運転状態)。AIDEはセンサーの値を 集めない。 myroom が Raspberry Pi からの受信・保存・ 鮮度判定まで持っているため、サーバー間参照用の読み取りAPI(GET /api/internal/room-state)を 叩いて aide_room_status に畳むだけにしている。ops-dashboard・subscription-lists と同じ 「既にある集約を横断ビューへ畳む」ケース。

src/core/connectors/myroom/
  types.ts   myroom のレスポンスのうち、AIDEが使うフィールドだけを再宣言
  index.ts   1本のGET。設定・タイムアウト・失敗理由の丸め
src/core/views/room.ts       しきい値判定と圧縮(summarizeRoom は純粋関数。テストはここ)

経路

両方とも同じVPS上で動くため localhost で届き、myroom を外部公開する必要がないfetch しか使わないので実行時依存も増えない。

環境変数

未設定のとき

設定したとき

AIDE_MYROOM_URL

http://127.0.0.1:8000

そのURLへ問い合わせる

AIDE_MYROOM_TOKEN

取得を試みず「未設定」を返す

Authorization: Bearer で認証する

トークンは相手側の内部APIキーと同じ値で、認証情報として扱う。1Passwordでは値を複製せず 提供側の op:// をそのまま参照する(#217)。失敗の理由はHTTPステータスと例外の種別まで丸める (例外の message にはURLが載るため)。

myroom の読み取りAPIは元々 Supabase のユーザーログイン必須で、サーバー間から読める口が無い。 内部APIは myroom#161 で追加する。未実装の バージョンに対しては 404 が返り、unavailable に「内部APIが未実装のバージョン」として出る。

鮮度と判定

センサーの鮮度判定・気圧オフセットの適用・デバイスの表示名は myroom 側が持っている。 こちらで再実装すれば必ずズレるため、判定済みの値を受け取る(しきい値は staleThresholdMinutes として一緒に返る)。

受信が止まっているセンサー(stale)の値は判定に使わない。 数日前に止まったセンサーの32℃を 「いまの室温」として報告してしまうため(ops ビューがオフラインのホストを評価しないのと同じ)。 値そのものは残すので、後から見れば何度で止まったかは分かる。

快適域のしきい値は src/core/views/room.tsTHRESHOLDS にまとめてある(CO2の1000ppmは 建築物衛生法の管理基準)。

返す粒度

「いま部屋がどうなっているか」に答えられるところまで。履歴・日別統計・記録の一覧は返さない。 生の時系列を渡してもコンテキストを食うだけで答えは良くならない(詳細は myroom の画面がある)。

キャッシュを挟まない。 部屋の状態は鮮度そのものが価値であり、ジョブ間隔ぶん古くなると 「いま暑いか」に答えられなくなる(README「どこまでを『重い取得』とみなすか」の右側)。

コネクタ: DaySpan(予定・タスク・日付リマインド・移動)

今日・今週の予定と空き時間。AIDEはGoogleカレンダーへ直接繋がない。 DaySpan が Google Calendar の予定・Notion のタスクと 日付リマインド・移動を1つのカレンダーへ統合済みなので、サーバー間参照用の読み取りAPI (GET /api/internal/schedule)を叩いて aide_scheduleaide_daily_briefing の予定に畳む。 ops-dashboard・subscription-lists・myroom と同じ「既にある集約を横断ビューへ畳む」ケース。

src/core/connectors/dayspan/
  types.ts   DaySpan のレスポンスのうち、AIDEが使うフィールドだけを再宣言
  index.ts   1本のGET。設定・タイムアウト・失敗理由の丸め
src/core/views/schedule.ts    返す粒度への圧縮と空き時間の算出(純粋関数。テストはここ)

Googleカレンダーの認可をAIDEへ持ってこない(aide#173)

取得処理をAIDEへ寄せる案(asset-manager の Zaim と同じ寄せ方)は採らなかった。DaySpanは Google Calendar API用のリフレッシュトークンをAES-256-GCMで暗号化して自前DBに保持している (向こうのREADME「認可の分離」)。AIDEへ同じ認可経路をもう1本作ると、OAuthクライアント・ 同意画面・トークンの失効と再認可を2か所で面倒を見ることになる。

「AIDEにGoogleのコネクタを足す」が起点のIssueだったが、予定については足す必要が無かった (aide#173 のコメント)。読み取りAPIで繋いでおけば、必要になった時点で寄せられる。

経路

両方とも同じVPS上で動くため localhost で届き、DaySpan を外部公開する必要がないfetch しか使わないので実行時依存も増えない。

環境変数

未設定のとき

設定したとき

AIDE_DAYSPAN_URL

http://127.0.0.1:3113

そのURLへ問い合わせる

AIDE_DAYSPAN_TOKEN

取得を試みず「未設定」を返す

Authorization: Bearer で認証する

トークンは相手側の INTERNAL_API_KEY同じ値で、認証情報として扱う。1Passwordでは値を 複製せず提供側の op:// をそのまま参照する(#217)。失敗の理由はHTTPステータスと例外の種別まで 丸める(例外の message にはURLが載るため)。

タイムアウトは8秒と、他のコネクタ(3秒)より長い。DaySpanの内部APIは受けたリクエストの中で Google Calendar と Notion を叩くため、localhost で完結する相手と違って外部サービスの応答時間が そのまま乗る。短く切りすぎると、相手が正常でも毎回タイムアウトになる。

日付はDaySpanに解釈させる

基準日を渡さなければ、DaySpanが利用者の設定タイムゾーン(既定 Asia/Tokyo)で「今日」を決める。 VPSのタイムゾーンはUTCだが、呼び出し側でJSTの日付を作る必要はない(subscription-lists の referenceDate とは扱いが逆なので注意)。

朝のブリーフィングからは日付を明示して渡している。 あちらは自分でJSTの暦日を決めており、 省略すると schedule だけ別の日を返しうるため。

時刻は DaySpan が設定タイムゾーンで描いた HH:MMstartTime / endTime)をそのまま使う。 AIDE側で時差を足し引きしない。 解釈し直すと、DaySpanの画面で見た時刻と食い違う。

空き時間はAIDE側で算出する

DaySpanは表示用のアプリで、空き時間という概念を持たない。そのため freeSlots だけはこちらの 計算にあたる(src/core/views/schedule.tscomputeFreeSlots。純粋関数)。

扱い

数える時間帯

既定 08:00〜22:00(freeFrom / freeTo で変えられる)。夜中を空きとして数えても答えが良くならない

塞ぐもの

時刻の決まった予定と移動

塞がないもの

終日の予定・時刻の無いタスク。時間帯を持たないものを塞ぐと1日が丸ごと埋まり、答えが常に「空きなし」になる

最小の長さ

30分。これより短い隙間は移動と準備で消える

日付をまたぐ予定

終了が開始より前のものは、その日の窓の終わりまでとして扱う(翌日へ持ち越すと、DaySpanの日ごとの振り分けと食い違う)

終日の予定を無視したことに気づけるよう、allDayCount を日ごとに併せて返している。

「予定が無い」と「取得できていない」を混ぜない

DaySpanは部分的な失敗をHTTP 200のまま errors に載せてくる(Notionが落ちていても、取れた Googleの予定は返す)。握りつぶすと「予定が無い日」として読まれるため、unavailable へそのまま 持ち上げて complete: false にする。

Google未接続・NotionのDB未設定は errors に出ない。 DaySpan側で「失敗」ではないためで、 該当する配列が空のまま返る。これだけでは「今日は何も無い」と区別が付かないので、連携そのものの 状態(sources.googleConnected 等)を併せて返し、note にも断りを入れる。

返す粒度

予定は件名・時間帯・場所・カレンダー名・繰り返しの有無まで。本文(description)は返さない。 予定の中身を読み上げるためのツールではなく、「その日が何で埋まっていて、どこが空いているか」に 答えるためのもので、本文を載せるとコンテキストを食うだけになる。

期限切れタスク(overdueTasks)は既定で取りにいかないincludeOverdueTasks: true のときだけ)。 遡るとNotionへの往復が1回増えるうえ、半年前に期限が過ぎたタスクを読み上げても行動は変わらない。

一度に返す日数は14日までに切る(DaySpan側の上限は31日)。

コネクタ: GitHub

各リポジトリの開発状況。ClaudeアプリにはGitHubのコネクタが無い(接続済みは Notion・Gmail・ Googleカレンダー・Googleドライブ・AIDE)ため、GitHubは「Core と MCP層の境界」でいう 公式MCPが無いものにあたる。Zaimと同じ位置づけ。

src/core/connectors/github/
  types.ts   GraphQLレスポンスのうちAIDEが使うフィールドだけ再宣言
  query.ts   クエリ。取得内容は RepoStatus フラグメント1つに集約している
  index.ts   POST /graphql と、失敗理由の丸め
src/core/views/dev.ts        対象の選別と圧縮(summarizeDev は純粋関数。テストはここ)

AIDEを唯一の取得口にはしない

GitHub取得は既に3実装ある。AIDEはこれらを置き換えない。

リポジトリ

用途

issue-deck

GitHub Appでの認証、Issue/PR操作、Actions、webhook。書き込みを伴う

ops-dashboard

Actions残枠

portfolio

公開用のリポジトリ情報取り込み

issue-deck はGitHub Appの認証・webhook受信・書き込みが本体で、AIDE経由にすると往復が増えるだけ。 AIDEが持つのは横断ビューと、下記のIssue起票だけに限る。

書き込みはIssueの起票1本だけ

外出先でClaudeアプリに思いついたことを話し、そのままIssueにしたいという要望 (guchi-apps/question#15)に対して aide_create_issue を持つ(aide#50)。

Claudeアプリからの経路だけが塞がっていたため入れたもの。Claude Code(端末・GitHub Actions)は gh issue create で起票できるが、issue-deck はMCPサーバーを持たず POST /api/issues は Cookie認証のため、Claudeアプリから叩けるものが1つも無かった。「責務」の3条件を満たす。

  • 作成だけ。 編集・close・コメント・PR操作は持たない。issue-deck の画面と Claude Code の仕事

  • トークンを分ける。 取得用の AIDE_GITHUB_TOKEN はRead-onlyのままで、起票は AIDE_GITHUB_ISSUE_TOKEN(Issues: Read and write)を使う。フォールバックはしない

  • AIDEが起票してもissue-deckと食い違わない。 issue-deck は webhook で GitHub 側の Issue を 取り込むため、どちらから作っても同じように画面へ出る

  • 暴発を機械的に止める。 Claudeは会話の流れでツールを自発的に呼ぶため、1回の呼び出しで1件・ 10分あたり3件・直前と同一タイトルは拒否、という上限をコード側に持つ(write.ts

  • ラベルを勝手に作らない。 GitHubのIssue作成APIは未知のラベル名を渡すとラベルごと新規作成 してしまう。起票前に対象リポジトリのラベル一覧を引き、実在するものだけを付ける (既定は 70.confirm。無いリポジトリでは黙って落ちる)。落としたラベルがあったときは、 実在するラベル名を availableLabels に添えて返す(下記「起票に使うラベルの候補」)

  • 出所を本文に残す。 口述の書き起こしは人が自分で書いたIssueと精度が違うため、本文末尾に AIDE経由で起票した旨と <!-- aide:created-via-mcp --> を必ず付ける

返す粒度

状態の俯瞰まで。ソースコードやREADMEの本文は返さない(aide#32 で確定)。ファイル取得の ツールは追加しない。返す量が大きくなるうえ「MCP層は狭く」の方針とぶつかる。コードの詳細は Claude Code(CLI)とissue-deckが担当する。

取得のツールは1本だけaide_dev_status)。「全体の俯瞰」と「1リポジトリの詳細」を別ツールに 割るとツール選択が曖昧になるため、引数 repo の有無で深さを切り替えている (起票の aide_create_issue は用途が別なので分けている。上記「書き込みはIssueの起票1本だけ」)。

attention に注意点が1行ずつ入り、これだけ読めば答えられるようにしている。しきい値は src/core/views/dev.tsDEFAULTS にまとめてある。

起票に使うラベルの候補

repo を指定して呼んだときだけ、そのリポジトリに定義されているラベル(名前・色・説明)を detail.labels に返す(aide#122)。Claudeアプリから aide_create_issue を呼ぶとき、 どのラベルが実在するかを知る手段が無かったため。実在しない名前は起票時に黙って落ちるので、 知らないまま渡すと「付けたつもりのラベルが付いていない」だけの結果になる。

ラベル取得専用のツールは足さない。 3本目のGitHubツールを増やすとツール選択が曖昧になり、 「MCP層は狭く」の方針とぶつかる。起票先のリポジトリは1件に決まっているので、 詳細モードの一部として返せば足りる。

  • クエリはフラグメントの外に置くquery.tsDEV_REPO_QUERY)。共有すると俯瞰でも 26リポジトリぶんのラベルが返り、要らない情報でレスポンスが数倍になる

  • 名前と説明はGitHubの表記のまま返す(加工すると labels にそのまま渡せなくなる)。 色だけは # を補う。GitHubは # 無しの6桁16進で返す

  • 上限は100件(LABEL_FETCH_LIMIT)。切れた場合は省いた件数を note に書く。 黙って切ると、実在するラベルを無いものとして扱ってしまう

RESTではなくGraphQLを使う理由

対象が26リポジトリあり、RESTだと同じ内容に約80リクエストかかる(リポジトリごとに compare・releases・commits・issues)。GraphQLなら1リクエスト・実測2ポイントで済む (上限は1時間5000ポイント)。fetchPOST /graphql するだけなので実行時依存も増えない。

落とし穴

  • compare の向きが直感に反する。 defaultBranchRef.compare(headRef:"main") は base=デフォルトブランチ / head=main なので、**behindBy が「未リリースのコミット数」**に あたる(aheadBy は main 側だけにあるコミット数)。RESTの compare/main...developahead_by と一致することを確認済み。テストで固定してある

  • main が無いリポジトリでは compare が NOT_FOUND を返す。 master 運用のリポジトリで 普通に起きる安定した状態なので、取得失敗として数えない(数えると complete が恒久的に false になり、本物の失敗が埋もれる)

  • 詳細モードで組織全体を引かない。 全リポジトリを深く掘るクエリはGitHub側の処理が重く、 実測で5秒のタイムアウトに掛かった。コストではなく応答時間の問題。1リポジトリだけを引く クエリに分けてある(約1秒)

  • 俯瞰は実測3〜4秒かかる(うち compare だけで約1.4秒)。タイムアウトは10秒に取っている

設定

環境変数

未設定のとき

AIDE_GITHUB_TOKEN

取得を試みず「未設定」を返す

AIDE_GITHUB_ISSUE_TOKEN

aide_create_issue が「未設定」を返す。GitHubへは何も送らない

AIDE_GITHUB_ORG

guchi-apps

AIDE_GITHUB_REPOS

archived を除き、直近 AIDE_GITHUB_ACTIVE_DAYS 日にpushがあったものを自動で拾う

AIDE_GITHUB_ACTIVE_DAYS

90

トークンは認証情報として扱う。ログにもMCPのレスポンスにも出さない。取得失敗の理由は HTTPステータスと例外の種別まで丸める。GraphQLの errorsmessage は載せず、 どのリポジトリのどのフィールドかと種別だけを返す(message に内部の構成が載ることがあるため)。

fine-grained PAT を使う。GitHub App は採らなかった(この用途に対して、秘密鍵の保管と JWT署名→インストールトークン交換の実装が重い)。取得用と起票用で別のトークンを持つ。

トークン

権限

使うところ

AIDE_GITHUB_TOKEN

Metadata / Contents / Issues / Pull requests / Actions の read のみ対象リポジトリに guchi-apps/docs を含める

aide_dev_status・共通知識ページ(/knowledge

AIDE_GITHUB_ISSUE_TOKEN

Metadata: read と Issues: read and write のみ

aide_create_issue

1本にまとめて取得側にも書き込み権限を持たせると、26リポジトリを横断する取得の経路が そのまま書き込みのできる経路になる。分ければ、起票を止めたいときにこのトークンだけ失効させればよい。 readGitHubWriteConfig()AIDE_GITHUB_TOKEN へフォールバックしない(Read-onlyのトークンで 書き込みを試みて403を返すだけの経路ができ、権限を持たせたかが設定から読み取れなくなるため)。

キャッシュは挟まず都度叩く。1リクエストで済み、レート制限にも余裕があり、 「いまどうなっているか」という問いに対してキャッシュの古さは害にしかならない。

guchi-apps/docs は private なので、fine-grained PAT の対象リポジトリに入っていないと /knowledge だけが失敗するaide_dev_status は他のリポジトリで動いてしまうため気づけない)。 権限不足と存在しないリポジトリはGitHubでは同じ NOT_FOUND になるので、画面では 「未設定」(トークンが無い)と「取得できなかった」(トークンはあるが読めない)を別の表示にしている。

共通知識ページだけは例外で、5分だけ持ち回る共通知識ページ)。 あちらは1回の表示にGraphQLを2本・8〜10秒使い、材料も日単位でしか変わらないため、 都度叩くと待ち時間だけが増える。同じトークンを使うが、読むのは guchi-apps/docs の ファイルとフリート横断のIssue検索で、aide_dev_status とは材料が別になる。

コネクタ: Open-Meteo(天気予報)

朝のブリーフィング(guchi-apps/question#7)の材料として、今日・明日の天気・最高/最低気温・ 降水確率を取得する。

src/core/connectors/weather/
  types.ts   応答のうちAIDEが使うフィールドと、正規化後の形(WeatherForecast)
  parse.ts   日ごとの配列を1日ずつへ畳む+WMO天気コードの日本語化(純粋関数。テストはここ)
  index.ts   地点の設定・問い合わせURL・失敗理由の丸め
src/worker/jobs/weather-sync.ts   毎時取得してキャッシュ(weather-forecast)へ書く

Open-Meteo を直接叩く

myroom(backend/weather.py)と portfolio(src/hooks/use-weather.ts)が既に Open-Meteo を 使っており、取得元が揃う。APIキーが要らないので新しい認証経路が増えず、fetch だけで 書けるので実行時依存も増えない。

myroom 経由にはしない。 ops-dashboard・subscription-lists をあの形にしたのは「認証情報を 1か所へ閉じる」「スケジューラを集約する」ためで、天気はAPIキーも巡回も持たないためどちらにも 当たらない。挟むと結合が増えるだけになる。

利用条件(無料枠)

条件

AIDEでの扱い

非商用に限る

個人利用なので満たす

1日10,000回未満(1時間5,000回・1分600回)

毎時1回の worker ジョブだけが叩く(1日24回)

CC BY 4.0 の帰属表示

WeatherForecast.attribution に同梱し、機能一覧ページ(/features)に出す

帰属表示を /features に置いたのは、天気そのものはキャッシュと横断ビュー(=認証の内側)に しか出ないため。誰でも見られるページに1か所だけ持たせ、取得元が増えたらそこへ足す。

キャッシュを挟む理由

取得自体は軽い(HTTP GETが1本)ので「どこまでを重い取得とみなすか」では都度叩く側にあたるが、 利用回数の条件がある外部APIなので、呼ばれた回数だけ外へ出ていく作りにはしない。 予報は毎時更新なので、毎時取れば鮮度も足りる。

設定

環境変数

未設定のとき

AIDE_WEATHER_LAT

34.82(myroom の OUTDOOR_LAT と同じ)

AIDE_WEATHER_LON

135.56(myroom の OUTDOOR_LON と同じ)

値が要るのは worker が動くサブPCだけ(VPSはキャッシュを読むだけ)。座標として読めない値は 既定へ落とさず例外にする。黙って既定へ戻すと、書き間違えたまま「別の地点の予報が正常に 取れている」状態になり、画面にも通知にも異常が出ない。

座標は自宅の位置にあたるため、ログにも失敗通知にも出さない。失敗理由はHTTPステータスと 種別まで丸める(Open-Meteo が HTTP 429 を返しました(利用回数の上限) など)。

コネクタ: Claude Code(サブPCのセッション)

サブPCで動いている Claude Code のセッションと、そのリモートコントロールURL(#123)。 Claudeとの会話から aide_claude_sessions を呼べば、URLをタップしてそのセッションの画面へ 移動できる。URLだけでは終了済みか判別できず、複数セッションのどれかも区別できないため、 状態・プロジェクト・経過時間を併せて返す。

src/core/connectors/claude-code/
  types.ts   ~/.claude/sessions/<pid>.json のうち、AIDEが使うフィールドだけを再宣言
  index.ts   台帳を読み、生きているセッションだけを畳む(純粋関数はここ)
src/worker/jobs/claude-sessions-sync.ts   収集して claude-sessions キーへ送る
src/core/views/claude-sessions.ts         キャッシュを読み、経過時間と鮮度を添える

台帳の場所と読み方

Claude Code はセッションごとに ~/.claude/sessions/<pid>.json を書いており、作業ディレクトリ・ tmuxセッション名・起動時刻・busy/idle と、リモートコントロールの接続先ID (bridgeSessionId)が入っている。URLは https://claude.ai/code/<bridgeSessionId> で組み立てる。

この台帳の形はAIDEが決められない。 Claude Code のバージョンが上がればフィールドは黙って 増減するため、必須として扱うのは pid だけにし、他は常に「無いかもしれない」前提で読む。

同じディレクトリの <pid>.<hash>.key は認証情報。 拡張子が .json のものだけを読み、 .key は開かない。

状態は3値。waiting が「放置」の手掛かり

statusbusy(応答中)・waiting(人の入力を待っている)・idle(待機中)の3つで、 2値ではないwaiting のときは waitingFor に理由(permission prompt = 承認待ち、 input needed = 入力待ち)が入る。

Issueが挙げた「放置セッションかの判断がつかない」に直接答えるのはこの値で、busy/idle へ丸めると 人の返事を待って止まっているセッションが idle に紛れるstatusForMinutes(その状態が 続いている分数)と併せて返し、長く待たせているものを見分けられるようにしている。

statusbridgeSessionIdキーごと存在しないことがある(SDK経由の起動など)。 必須として扱わず、欠けていれば null で返す。

一覧に出さないもの

kindinteractive でないセッション(SDK経由の裏方プロセス)は一覧から除く。 リモートコントロールもtmuxでのattachもできず、人が開いて操作する対象ではないため。 ただし黙って落とさない。 件数を nonInteractive として数え、note に出す (落としたことが見えないと「何件動いているか」がずれる)。

kind を書かない世代の台帳は除外しない(欠けているだけで裏方とは限らないため)。

終了済みセッションの除き方

台帳は終了時に消えるとは限らず、残骸のPIDが別のプロセスへ割り当て直されることもある。 PIDの存在確認だけでは足りない。 台帳が持つ procStart/proc/<pid>/stat の22番目の フィールド=起動時刻)まで一致して初めて同じプロセスとみなす。

/proc/<pid>/stat は前から数えて22番目を取ってはいけない。2番目のフィールドがプロセス名で、 名前自体に空白や括弧を含められるため、閉じ括弧の最後の出現より後ろだけを数える

収集が worker 側にある理由と、その代償

台帳はサブPCのファイルシステムにしか無く、MCPサーバーはVPSで動く。呼ばれたときに読みに 行けないため、他の重い取得と同じ経路(POST /api/cache/:key)でサーバーへ送る (worker とサーバーが別マシンである問題)。

代償として、返せるのはスナップショットになる。2分ごとに収集し、直前に始めた・終えた セッションは反映されないことがあるため、収集時刻(collectedAt)と経過分数 (snapshotAgeMinutes)を必ず併せて返す。10分以上更新されていなければ stale を立て、 「いま動いているもの」として扱わせない(セッションが無いという意味ではない)。

このジョブはサブPCの systemd timer が入っていて初めて動く。 入っていなければツールは stale を返し続ける。ユニットは deploy/systemd/ にあり、実行場所は systemdユニットのとおり手で反映する。

ops-dashboard 経由にしなかった理由

ops-dashboard コネクタは既にサブPCのtmuxセッションをVPSの localhost から キャッシュ無しで都度取得しており(30秒間隔で更新)、こちらより鮮度が良い。それでも相乗りせず 自前で収集しているのは次の2点による。

  • 向こうのモデルはtmuxセッション単位で、Claude Code のセッション単位ではない。 tmuxの外で動いているセッションは表現できず、1つのtmuxセッションと1つのClaude Codeセッションが 常に対応するとも限らない。bridgeSessionId を運ぶには結局この台帳を読む必要がある。

  • 別リポジトリ(ops-dashboard)のエージェント改修とデプロイが前提になる。 向こうの手当てが済むまでAIDE側は何も返せず、リリースの単位が2リポジトリにまたがる。

代償は鮮度で、2分ぶん古くなりうる(向こうなら30秒)。aide_claude_sessions が返す snapshotAgeMinutes はそのための値で、間隔を詰めても消えない性質のものとして扱っている。 実運用で2分では足りないと分かった場合は、host-stats エージェント側に bridgeSessionId / status / startedAt を足して相乗りへ移すのが素直な移行先になる。

ログに出さないもの

セッション名・作業ディレクトリ・リモートコントロールURLはジャーナルにも通知にも出さない。 URLは開けばそのセッションを操作できるもので、ログへ残す粒度ではない。ジョブが残すのは 件数だけにしてある。

横断ビュー: 朝のブリーフィング

Claudeアプリから「今日はどんな感じ?」と聞いたときに、今日の予定・交通・天気を1回の呼び出しで 返す(guchi-apps/question#7・aide#36)。3ソースをClaudeに個別に叩かせると往復もトークンも増えるため、 AIDE側で1本に畳む。

src/core/views/briefing.ts   セクションの器・天気の畳み込み・未接続の表現(純粋関数。テストはここ)
src/mcp/tools/briefing.ts    aide_daily_briefing

天気・交通の単機能ツール(aide_weatheraide_transit_delay)は作らない。 各コネクタは Core に置き、MCP層へ出す口はこのビューへ集約する(「Core は広く、MCP層は狭く」)。

予定だけは aide_schedule を別に持つ(aide#173)。こちらは日付が今日に固定で、天気・交通と 一緒に「今日1日の見通し」を返すもの。「今週の予定は」「何時なら空いているか」には期間の指定と 空き時間が要り、このビューでは答えられない。両方のツールの説明文で用途を書き分ける—— 横断ビュー同士でも、選択が曖昧になればMCP層を狭くしている意味が無くなる (aide_room_status との棲み分けと同じ)。

ソースごとに独立して失敗させる

「予定は取れたが交通が取れなかった」を表現できるように、セクションごとに state を持たせ、 1つの失敗で全体を落とさないstate は4つ。

意味

ok

中身が入っている

unavailable

接続は設定されているが取得できなかった(対象日ぶんが無い場合も含む)

not_configured

接続が設定されていない(トークン等が未設定)

not_connected

コネクタ自体がまだ無い。依存Issueの完了待ち

unavailablenot_connected を分けているのは、前者は直せば取れる/後者はまだ存在しないという 違いをClaudeに伝えるため。どちらも「情報が無い」だが、言うべきことが違う。

「今日」はJSTの暦日で切る

Asia/Tokyo の暦日(YYYY-MM-DD)を対象日とし、深夜も暦日どおりに扱う。ツールは引数を取らない。

天気は日付で突き合わせる。配列の先頭を「今日」とみなさない。 キャッシュは日付をまたいで残るため、 添字で取ると日付が変わった直後に昨日の予報を「今日」として返してしまう。対象日が含まれていない場合は unavailable にして、取得済みの時刻と理由を添える。

深夜0時台は「明日」が必ず欠ける

天気の取得は今日・明日の2日ぶん固定(FORECAST_DAYS)で、同期は毎時。日付が変わってから次の同期が 走るまでは、キャッシュの中身が「前日・当日」のままになり、tomorrow が見つからない。このとき キャッシュ自体は新しいので stale にはならない(180分に満たない)。

セクション全体は落とさず tomorrow だけを null にし、「明日の天気が無い」と読まれないよう note に 断り書きを添える。日をまたいだ直後だけ毎日起きる状態なので、鮮度の判定で表現しようとすると壊れる。

aide_room_status との棲み分け

aide_daily_briefing が返す天気は今日・明日の予報aide_room_status が返すのはいまの実測 (室温・湿度・CO2と、myroom 経由の屋外の気温・湿度・気圧)。「いま暑いか」は後者、「今日は暑くなるか」 「傘は要るか」は前者にあたる。両方のツールの説明文でこの違いを書き分ける——横断ビュー同士でも、 選択が曖昧になればMCP層を狭くしている意味が無くなる。

鮮度はビュー全体で揃えない

天気は毎時更新のキャッシュ、交通は分単位、予定は都度と性質が違うため、1つの stale に潰すと意味が 壊れる。fetchedAt / ageMinutes / staleセクションごとに持つ

揃った順に足す

天気(キャッシュ)と予定(DaySpan・aide#173)は 実データを返し、交通(aide#33)は not_connected のまま。セクションは名前付きフィールドと共通の器 (BriefingSection<T>)にしてあるので、コネクタが入ったら該当セクションを差し替えるだけで足せる。 ニュース(question#7 で保留中)を後から加える場所も同じ。

MCPの同期リクエスト内で重い取得は行わない。 天気はキャッシュを読むだけ。予定は localhost への HTTP GETを1本、短いタイムアウト付きで叩く(「どこまでを『重い取得』とみなすか」)。交通を足すときも同じ。

予定は取得できても ok にならないことがある。 DaySpanが部分的な失敗を返した場合は、取れたぶんを data に残したまま unavailable にする。捨てると天気だけの答えになり、ok にすると欠けたことが 伝わらないため。

キャッシュと worker

取得と提供を分離するための仕組み。

src/core/cache/store.ts    JSONファイルのキャッシュ
src/worker/run.ts          ジョブのエントリポイント(ワンショット実行)
src/worker/jobs/           個々のジョブ
src/core/views/            キャッシュを読んで横断ビューを組み立てる

なぜキャッシュを挟むか

Zaimの巡回は12秒前後かかる。MCPやAPIの同期リクエストの中で走らせると、応答が遅いうえにヘッドレスChromiumのぶんメモリが跳ねる。VPSは2GBしかないため成立しない。

worker が定期実行して data/cache/ に書き、MCPサーバーとAPIはそれを読むだけにする。

JSONファイルである理由

データモデルがまだ固まっていない。この段階でDBを入れると、形を変えるたびにマイグレーション運用のコストが先に来る。形が安定したらMariaDBへ移す。

書き込みは一時ファイル + rename で行う。直接上書きすると、書き込み中に読まれたときに壊れたJSONを掴む。

ジョブの実行

npm run worker zaim-refresh     # 連携口座を一括更新(押して最大45分待つ、1日2回想定)
npm run worker zaim-sync        # 巡回してキャッシュ更新(重い、1日2回想定)
npm run worker zaim-keep-alive  # セッション延長のみ(軽い、30分ごと想定)
npm run worker weather-sync     # 天気予報を取得(軽い、1時間ごと想定)
npm run worker claude-sessions-sync  # Claude Codeのセッションを収集(軽い、2分ごと想定)

常駐させずワンショットで実行し、スケジューリングは外(cron / systemd timer / PM2)に任せる。常駐プロセスを増やさずに済み、失敗しても次回実行で自然に復旧する。失敗時は終了コード1を返すので、スケジューラ側から検知できる。

実行記録

実行のたびに結果を job-<ジョブ名> というキャッシュキーへ1件だけ書く(src/worker/record.ts)。 動作状況ページ(/status)はこれを読んで「最後に成功したのはいつか」に答える。

通知(Signaly)では代われない。 あちらは流れて消えるうえ、成功時は何も送らないため、 「動いているが最後の成功が3日前」という状態を後から知る手段が無かった。

置き場をキャッシュにしているのは、worker がサブPC・サーバーがVPSで動くため。ファイルに書いても サーバーからは見えないが、取得結果と同じ経路(POST /api/cache/:key)に載せれば、開発機(両方ローカル)と 本番(別マシン)で同じコードのまま届く。ジョブごとにキーを分けているのは、1つにまとめると書く前に 現在値を読む必要があり、書き込み専用の受け口に読み取り口を足すことになるため。

記録するのは成否・時刻・所要時間・1行のメッセージ・実行ホストだけで、取得した値そのものは入れない。 記録に失敗してもジョブは失敗させない(通知と同じ方針)。

実行間隔の制約

Zaimの認証Cookieは約2時間で失効し、アクセスのたびにその時点から延長される。したがって 2時間以内に必ず1回はZaimへアクセスする必要がある

ジョブ

間隔

最悪間隔

理由

zaim-keep-alive

30分ごと(揺らぎ2分)

32分

有効期間2時間に対し、3回続けて失敗しても間に合う余裕を取る

zaim-refresh

1日2回 10:30 / 22:30 JST

押してから反映まで5〜15分、遅い口座は約35分かかる。24時までにその日の最終データを確定させるための逆算

zaim-sync

1日2回 11:35 / 23:35 JST

zaim-refresh の完了を見込んだ時刻に置き、夜の1回でその日のうちに当日の値を確定させる

「最悪間隔」は RandomizedDelaySec を含めた実際の空き時間。zaim-keep-alive はここを2時間より十分短く保つことが要件で、毎時(最悪1時間5分)では1回失敗しただけで超えていた(#63)。Zaimの2つは巡回そのものが目的なので、この制約は掛からない。

zaim-sync は以前05:00 JSTだった。 「issue-deckの並行ビルドと競合せず、朝の時点で当日のデータが揃う」ことが理由だったが、その時刻では更新ボタンを押した当日ぶんが翌日のキャッシュにしか載らない。23:35へ移しても朝には前夜23:35のデータ(経過8時間ほど)があり、当日ぶんが揃っているという条件は満たせるため移した(#62)。

さらに昼の1回を足して1日2回にした(#165)。 日次1回だと日中に残高を尋ねても前夜の値しか返らず、キャッシュの経過が最大24時間になる。夜の1回は動かしていないので、#62 の「その日のうちに当日ぶんを確定させる」条件はそのまま保たれる。頻度に合わせて次の3つも詰めてある。

何を

変更

どこ

「動いていない」とみなす遅れ

36時間 → 18時間(12時間+実行のずれのぶん)

src/worker/jobs/catalog.tsstaleAfterMinutes

残高データを「古い」とみなす経過

24時間 → 18時間

src/core/views/money.tsSTALE_AFTER_MINUTES

更新できなかった口座の判定

毎回 → その日の最後の巡回だけ

src/worker/jobs/zaim-sync.tsdecideStaleAccountCheck

最後の1つは必須。更新漏れは「最終更新が当日(JST)か」で見るため、昼の時点では前夜に更新できた口座まで当日でない側に入る。判定したままにすると、昼で「警告」・夜で「復旧」を毎日往復する通知になる。通知の抑制では塞げない(全口座が当日になった時点で記録ごと消えるため、次の警告は窓に関係なく無条件で届く)。

さらに押下を 10:30 / 22:30 へ45分前倒しし、反映を待つ時間も最大45分へ延ばした(#178)。 押下から巡回までが20分しかなく、反映に約35分かかる口座(SBI証券・楽天証券・Ponta・MUFGカード)が毎晩間に合っていなかった。巡回の 11:35 / 23:35 は動かしていないので、#62 の「23:59までにその日ぶんを確定させる」条件はそのまま保たれる。押下と巡回の65分の間隔が、この修正の根拠そのものなので、どちらかの時刻を動かすときは必ず両方を見る。

あわせて更新漏れの判定を押下側(zaim-refresh)から巡回側(zaim-sync)へ移した。押した直後には反映の遅い口座がまだ進んでおらず、押下側で判定すると「更新できない口座」と「反映が遅いだけの口座」を区別できないため(判定の境目である20時はそのまま流用している)。

移すにあたって「判定しない」を空の配列で表さないこと。 巡回は連携口座一覧の取得に失敗しても残高・保有銘柄は返すため、onlineAccounts は空配列になりうる(scripts/scrape.mjs が例外を握る)。これをそのまま notifyStaleAccounts へ渡すと「更新漏れ0件」=直ったと読まれ、未解決の記録を消して「復旧しました」を誤って送る。押下側では「1件も読めなかった」をジョブの失敗にしていたため、この経路は無かった。decideStaleAccountCheck は判定しない場合に null を返し、通知を呼ばせない。

subpcのシステムTZはUTCなので、タイマーには Asia/Tokyo の明示が必須。

このスケジューリングは常時起動のホストに置く必要がある。 開発機(メインPC)は常時起動しない前提のため、セッションを維持できない。本番では subpc が担う。

systemdユニット

ユニットは deploy/systemd/ にある。実行場所はサブPCの ~/.config/systemd/user/ で、リポジトリからは自動反映されない(VPSへの deploy.yml が触るのはサーバー側だけ)。間隔を変えたら手で反映する。

cp deploy/systemd/*.timer deploy/systemd/*.service ~/.config/systemd/user/
systemctl --user daemon-reload
systemctl --user enable --now aide-zaim-refresh.timer   # 初回のみ(未導入のユニット)
systemctl --user enable --now aide-claude-sessions-sync.timer  # 初回のみ(未導入のユニット)
systemctl --user enable --now aide-zaim-web.service     # 初回のみ(未導入のユニット)
systemctl --user restart aide-zaim-keep-alive.timer aide-zaim-refresh.timer aide-zaim-sync.timer
systemctl --user list-timers 'aide-*'

aide-zaim-web.service だけタイマーを持たない常駐(#215。VPSからの中継を受ける口)。 systemctl --user status aide-zaim-webcurl -sS http://$AIDE_ZAIM_WEB_HOST:4748/health で見る。 ログアウトで落ちないよう lingering が要るloginctl enable-linger $USER。他のタイマーも同じ前提)。

以前はユニットがリポジトリの外にしか無く、間隔がなぜその値なのかを追えなかったため、実体をこちらへ移している。

サブPC側のコードも自動では更新されない。 deploy.yml が配るのはVPS(サーバー)だけで、 worker が動く ~/apps/aide人が git pull するまで古いまま。worker まわりの修正は、 develop へマージしただけでは効かない(#89 の時点で17コミットぶん遅れていた)。

# サブPCで
cd ~/apps/aide && git pull --ff-only

ジョブ失敗の通知

終了コード1は systemd のジャーナルに残るだけで誰にも届かない。実際に aide-zaim-sync.service の失敗が丸一日気づかれずに放置されたため、失敗を Signaly(Webhook受信 + Web Push の通知ハブ)へ送っている(src/worker/notify.ts)。

環境変数

未設定のとき

設定したとき

AIDE_SIGNALY_WEBHOOK_URL

通知しない(開発機)

失敗・復旧をそのWebhookへ送る(本番 = サブPC)

URLに含まれる channel_id が宛先の識別子そのもの(Webhook自体は認証なし)なので、認証情報として扱う。ログにも通知本文にも出さない。

送るのは次の4つだけ。

  • 失敗: ジョブ名・失敗理由・発生時刻・実行ホストを載せる。ZAIM_SESSION_EXPIRED はタイトルと「対応」欄で他の失敗と区別する。失効はさらに3通りに書き分ける(下記)

  • 復旧: 失敗が記録されている状態で成功したときに1回だけ

  • セッション回復(ジョブ横断): Zaimのジョブが成功したとき、他のZaimジョブに残っている失効の失敗へ1回だけ。ジョブ単位の復旧通知では、12時間ごとの zaim-refresh の失効を30分ごとの zaim-keep-alive が直しても次の zaim-refresh まで伝わらない(#191)。消すのは失効の記録だけで、別の理由で失敗しているジョブの記録は残す

  • 一部失敗: zaim-sync の巡回時点で最終更新が当日でない口座があるとき。ジョブ自体は成功扱いのまま(押下も巡回も成功しており、AIDE側では直せない)。署名は「更新できなかった口座名の集合」なので、同じ口座が落ち続けている間は静かになり、別の口座が落ちたときは抑制せずに届く。記録は <ジョブ名>:stale-accounts としてジョブ自体の失敗とは別に持つ。判定するのはその日の最後の巡回(23:35)だけ(#165 / #178。理由は上の「実行間隔の制約」)

失効の通知は「自動で直る見込みがあるか」で書き分ける(#191)。かつては失効=手動ログインでしか直らなかったが、いまは自動再ログイン(#63)があり、多くは次の zaim-keep-alive が勝手に直す。それでも「手動でログインし直すまで失敗し続けます」と送っていたため、受け取った側が手動対応の要否を判断できなかった(2026-08-29 22:32 の zaim-refresh の失敗は、4秒後の zaim-keep-alive が自動再ログインして復旧している)。判定は失敗の中身と資格情報の有無だけで行い、資格情報の値は読まない

状態

見分け方

通知

自動再ログイン待ち

失効のマーカーのみ・ZAIM_EMAIL/ZAIM_PASSWORD あり

黄色。「30分ごとの zaim-keep-alive が自動で試みる」。手動を促さない

自動再ログインも失敗

ZAIM_AUTO_RELOGIN_FAILED が付いている

赤。手動ログインを促す

自動再ログインの設定なし

失効のマーカーのみ・資格情報なし(開発機・CI)

赤。手動ログインを促す

ZAIM_AUTO_RELOGIN_FAILED を付けるのは session.ts だけで、自動再ログインを実際に試したうえで直らなかった場合に限る。元のメッセージは丸ごと残し、マーカーは行を分けて足す(ZAIM_SESSION_EXPIRED を落とすと失効として分類されなくなり、失敗理由の1行目も失われる)。抑制の署名もこの3状態で分ける。 同じ署名にすると、「自動で直る見込み」を送った直後に自動再ログインが失敗しても、6時間の抑制で「手動が要る」が届かない。

定期実行の成功は送らない。zaim-keep-alive は30分ごとなので、成功も送ると1日48件になり肝心の失敗が埋もれる。

同じ理由で失敗し続けている間は6時間に1回までに抑えている(30分ごとの zaim-keep-alive がセッション失効すると、抑制しないと48件/日届く)。理由が変わった場合は抑制せずに送る。抑制で黙っている状態と直った状態を区別できるように、復旧通知だけは出している。

未解決の失敗は data/worker/notify-state.json に持つ(ジョブ名・失敗理由の署名・時刻・回数だけ。取得データも認証情報も入れない)。通知の送信失敗でジョブを二重に失敗させない。 送信・記録まわりの例外はすべて握りつぶし、ログに一行残すだけにする。送れなかった回は通知済みにせず、次の実行で送り直す。

プロセスが起動する前に落ちるケース(node が起動しない・OOMで強制終了)は拾えない。 そこまで拾うなら systemd の OnFailure= が要る(ユニットは deploy/systemd/ にある)。

金額の扱い

balances(残高一覧)には証券口座の合計が含まれ、holdings(保有銘柄)はその内訳にあたる。両者を足すと証券分を二重に数えるため、横断ビューでは合算値を出していない。

worker とサーバーが別マシンである問題

本番では worker はサブPC、MCPサーバーはVPSで動く。別マシンなのでキャッシュファイルを共有できない。放置すると worker が更新するキャッシュとサーバーが読むキャッシュが別物になり、Claudeへ古いデータを返し続ける。

worker から HTTP で送る形で解消している。

サブPC                          VPS
worker ──POST /api/cache/:key──▶ サーバー ──▶ data/cache/

環境変数

未設定のとき

設定したとき

AIDE_INGEST_URL

ローカルのキャッシュへ直接書く(開発機)

そのURLへHTTPで送る(本番)

AIDE_INGEST_SECRET

送信・受信の共通シークレット

同じコードが開発機(両方ローカル)と本番(別マシン)の両方で動く。

AIDE_INGEST_URL だけ設定して AIDE_INGEST_SECRET が無い場合はジョブを失敗させる。 黙ってローカルへ書くと「送ったつもりで届いていない」状態になり、気づくのが遅れるため。

両方とも未設定の場合は失敗しない。 開発機ではそれが正しい挙動だが、サブPCで設定を落とすとジョブは成功したままVPSのキャッシュだけが止まる。 実際、サブPCの .env にこの2つが無く、巡回結果がサブPC側の data/cache/ にだけ書かれ続け、本番のキャッシュが3日ぶん古いままになっていた(#89)。サーバー側からは worker の .env を見られない(worker 側の設定は「未設定」と断定しない)ため、サブPC側で確かめる。

# サブPCで。値そのものは出さず、設定されているかだけを見る。
grep -c '^AIDE_INGEST_\(URL\|SECRET\)=' ~/apps/aide/.env   # 2 なら設定済み
journalctl --user -u aide-zaim-sync.service -n 5 -o cat      # 「…へ送信した」なら届いている

ジョブのログが「ローカルのキャッシュへ書いた」で終わっている間は、何度巡回しても本番のキャッシュは更新されない。

受け口の認証

MCPのOAuthとは別系統で、共有シークレット1本。呼び出し元が自分のworkerに限られるためOAuthは過剰で、issue-deck の dispatch と同じ方式に揃えている。

受け入れるキーはサーバー側で明示的に限定している(任意のキーで書き込めると、参照側が読まないゴミが溜まる)。巡回結果(zaim-snapshot)だけでなく、ジョブの実行記録(job-<ジョブ名>)も同じ経路で届くので、両方を許可する。記録側は送信に失敗しても例外を投げない(ジョブを二重に失敗させないため)ので、ここで弾くとログ1行が残るだけで、/status のジョブ欄が永久に「記録なし」になる(#89)。

個人アプリ向けの読み取りAPI

Claudeアプリ等へMCPで出しているのと同じデータを、既存の個人アプリへはRESTで出す。実装は src/api/read.ts

エンドポイント

GET /api/money/summary

返す内容

aide_money_summary と同じ横断ビュー(buildMoneySummary()

認証

Authorization: Bearer $AIDE_READ_SECRET

curl -s -H "Authorization: Bearer $AIDE_READ_SECRET" http://127.0.0.1:3114/api/money/summary
{
  "empty": false,
  "fetchedAt": "2026-08-16T03:00:00.000Z",
  "ageMinutes": 120,
  "stale": false,
  "totals": { "balances": 1234567, "holdings": 234567 },
  "balances": [{ "name": "〇〇銀行", "amount": 1000000,
                 "lastUpdatedAt": "2026-08-16T23:20:11+09:00" }],
  "holdings": [{ "account": "〇〇証券", "name": "〇〇インデックス", "amount": 234567,
                 "occurrence": 1, "occurrenceCount": 1,
                 "lastUpdatedAt": "2026-08-16T23:21:00+09:00" }],
  "onlineAccounts": [{ "name": "〇〇銀行", "lastUpdatedAt": "2026-08-16T23:20:11+09:00" }],
  "staleAccounts": [{ "name": "△△銀行", "lastUpdatedAt": "2024-12-18T10:00:00+09:00" }],
  "fixedCosts": {
    "configured": true,
    "monthlyByCurrency": [{ "currency": "JPY", "amount": 2470 }],
    "monthlyByPaymentMethod": [{ "paymentMethod": "〇〇カード", "currency": "JPY", "amount": 2470 }],
    "monthlyJpy": 2470, "usdJpyRate": 152.3, "count": 2,
    "items": [{ "name": "〇〇", "monthlyAmount": 1490, "currency": "JPY",
                "contractStatus": "AUTO_RENEWING", "paymentMethod": "〇〇カード",
                "nextPaymentDate": "2026-09-05" }],
    "upcoming": [{ "name": "〇〇", "date": "2026-09-05", "amount": 1490, "currency": "JPY" }],
    "unavailable": null, "note": "..."
  },
  "note": "..."
}

fixedCosts も同じ器で出ている。 buildMoneySummary() の戻り値をそのまま返しているため、 月額固定費に項目が増えるとこのAPIの応答にも同時に出る(追加のみなので既存の読み手は壊れない)。

取得時刻と経過分数を必ず併せて返し、鮮度の判断は呼び出し側に委ねる。 MCP層と同じ方針で、AIDEは 「古いから返さない」という判断をしない。キャッシュが空でも200を返す(empty: true)。まだ一度も巡回して いないのは状態であってエラーではなく、呼び出し側が区別できる形で伝わればよい。

lastUpdatedAtZaim側が各金融機関から取得した時刻で、AIDEが巡回した時刻(fetchedAt)とは別物。 更新できない口座があると巡回が新しくても中身は古いままになるため、当日でないものを staleAccounts に まとめている(更新できない口座の扱い)。これも捨てるかどうかは決めない。 連携していない口座(現金・手入力)と、この項目を持たない時期のキャッシュでは null になる。

キャッシュを素で返さない理由

GET /api/cache/:key(書き込みと対称な形)ではなく横断ビューを出している。外へ見せる契約が1本で済み、 キャッシュの構造を後から変えられる余地が残る。素で返す口は、必要になった時点で足す。

読み取りと書き込みでシークレットを分ける

AIDE_READ_SECRETAIDE_INGEST_SECRET とは別の値にする。同じ値を使うと、読みたいだけのアプリへ キャッシュの書き込み権限まで渡すことになる。未設定なら読み取り口は503を返し、そもそも開かない。

公開範囲

呼び出し元(asset-manager 等)は同じVPS上で動くため、http://127.0.0.1:3114 で叩く。外向けのURLを 経由する必要はない。

ただし /api を丸ごと外部から遮断することはできない。worker はサブPCから POST /api/cache/:key を 外向けURLへ送るためで、Apacheで絞る対象は /api/money/api/zaim の2つに限る (公開URLからの遮断)。

個人アプリ向けのZaim登録API

car-care(給油記録)・asset-manager(レシート由来の支出)がZaimへ支出を登録するための口。実装は src/api/zaim.ts

この口はVPS内のアプリ専用のまま据え置く。 外(VPS外)のClaude Code・Claudeアプリからの登録は MCPツールで受ける(外部のClaude CodeからのZaim登録)。

エンドポイント

POST /api/zaim/payment / GET /api/zaim/master

認証

Authorization: Bearer $AIDE_ZAIM_WRITE_SECRET

必要な設定

上のシークレットと、Zaim OAuthの4つ(AIDE_ZAIM_CONSUMER_KEY ほか)。1つでも欠ければ503

curl -s -X POST -H "Authorization: Bearer $AIDE_ZAIM_WRITE_SECRET" \
  -H "Content-Type: application/json" http://127.0.0.1:3114/api/zaim/payment \
  -d '{"requestId":"car-care:fuel-log:1234","amount":6800,"date":"2026-08-19",
       "categoryId":101,"genreId":10101,"fromAccountId":12345,"place":"〇〇SS"}'
# => {"ok":true,"moneyId":987654321,"duplicated":false,"requestId":"car-care:fuel-log:1234"}

呼び出しの向きは「アプリ → AIDE」

読み取り(GET /api/money/summary)はアプリがAIDEから引くが、登録もアプリからのpushにしている。 登録済みかどうかの状態(Zaimの money_id)を持つのは各アプリのレコードで、登録の起点も画面操作だから。 AIDEがアプリを巡回して「未登録の支出」を集める形にすると、アプリごとに取得APIを生やすことになる。

カテゴリ・ジャンルはAIDEが決めない

categoryId / genreId / fromAccountId呼び出し元がIDで指定する。「ガソリン代 → 自動車費/ガソリン」 のような対応はアプリ側のドメイン知識で、AIDEに持ち込むとアプリが増えるたびにここが太る。 IDは GET /api/zaim/master(口座・カテゴリ・ジャンルの一覧)で引ける。連携先の設定時に一度引いて、 アプリ側の設定として持つ想定。 (MCP経由の呼び出し元はClaudeで状態を持たないため、そちらだけ24時間キャッシュを挟む。 外部のClaude CodeからのZaim登録

二重登録を止める

同じ支出をZaimへ2回登録しても、この経路からは取り消せない(作成だけを持ち、削除は持たない)。 呼び出し元が自分のレコードごとに一意な requestId を付け、AIDE側は data/zaim-payments.jsonrequestIdmoney_id を記録する(直近500件)。

  • Zaimへ送る前に記録する。 送った後に記録すると、応答が届かなかった場合に何も残らず、再送で二重登録になる

  • 登録済みの requestId はZaimへ送らず、前回の money_idduplicated: true で返す

  • 前回の結果が確定していない requestId は 409 で止める。 打ち切り・Zaim側の障害では登録された 可能性が残るため、機械的な再送を許さない(人がZaimを確認し、未登録なら別の requestId で送り直す)

  • Zaimが内容を拒んだ(4xx)ときだけ記録を消し、直してからの再送を許す

記録に書くのは requestIdmoney_id・時刻の3つだけで、金額・店名・コメントは持たない。 二重登録を防ぐのに要らないうえ、支出の中身そのものを持つのは取得・整形という責務から外れる。

応答の意味

状態

意味

200

登録できた(duplicated: true なら再送で、Zaimへは送っていない)

400

入力が不正。直して送り直す

409

前回の結果が不明。再送しない。Zaimを確認する

422

Zaimが内容を拒んだ。登録はされていない

502

Zaimへ届かない・打ち切り。登録されたかは不明

503

AIDE_ZAIM_* が揃っておらず、口が開いていない

公開範囲と、アプリ側の守り

呼び出し元は同じVPS上のアプリなので http://127.0.0.1:3114 で叩く。ただし上記のとおり /api を丸ごと外部から遮断できないため、遮断が入るまではこの2本も公開URL上に出ている。 晒されるのは「書き込む口」だけでなく、GET /api/zaim/master が返す口座名の一覧も含む。

  • 公開URLからの遮断はVPSのApache側で行う(公開URLからの遮断

  • 遮断が入った後も、シークレットと総当たり対策は外さない。 Apacheを通らない http://127.0.0.1:3114 を叩ける者(同じVPS上の他アプリ・別経路で入り込んだプロセス)には シークレットだけが盾になる。認可画面と同じ総当たり対策(送信元ごとに15分あたり5回まで・ 失敗時に固定の待ち・超過で429)を掛けている(src/api/zaim.ts)。回数の枠は画面のログインとは 別に数える

  • シークレットは openssl rand -base64 32 相当の長さにする(推測ではなく総当たりの対象になるため)

公開URLからの遮断

/api/money/api/zaim同じVPS上のアプリが 127.0.0.1:3114 へ直接叩く口で、公開URLを 経由して使う必要がない。aide.gucchii.com のVirtualHost(guchi-apps/vps リポジトリの apache/sites-available/aide.gucchii.com{,-le-ssl}.conf)で、80番・443番の両方に次を置いて落とす。

<LocationMatch "^/api/(zaim|money)(/|$)">
    Require all denied
</LocationMatch>
  • /api を丸ごとは落とさない。 worker がサブPCから POST /api/cache/:key を公開URLへ送るため、 ここが403になると巡回結果の投入が止まる

  • <Location> の前方一致ではなく <LocationMatch> を使う。/api/moneyfoo のような隣接パスを 巻き込まないため

  • 403で落とす(404で存在を隠す案より、設定から意図が読み取れるほうを採った)

設定の反映は vps 側のIssue(guchi-apps/vps#101)で行う。 このリポジトリのデプロイでは反映されない。

アクセストークンの取得

consumer key / secret は dev.zaim.net でのアプリ登録で発行する。 アクセストークンはブラウザでの認可が要るため、実行時ではなく次のスクリプトで1回だけ取る。

AIDE_ZAIM_CONSUMER_KEY=xxx AIDE_ZAIM_CONSUMER_SECRET=yyy \
  node src/core/connectors/zaim/scripts/oauth-token.mjs

認可の画面は手元のPCのブラウザで開き、戻り先URLに付く oauth_verifier を貼り付ける。 取れた値は本番の .env(GitHubのsecret経由)と1Passwordにだけ置く。

個人アプリ向けの画像メール送信API

Research Desk(guchi-apps/research-desk#64)が、ブラウザ内で圧縮・ZIP化した画像を社用メールへ 即時送信するための口。実装は src/api/image-mail.ts。iCloudショートカット「画像を社用メールに 送る」と同等の操作を、Research Deskの画面から行えるようにする。

呼び出し元はResearch Deskのサーバー**(src/app/api/image-mail/send/route.ts。Supabase認証は あちらで完結する)で、ブラウザではない。** サーバー間通信のためCORS対応は不要——/api/zaim/payment と同じ構図。

エンドポイント

POST /api/image-mail/sendmultipart/form-data

認証

Authorization: Bearer $AIDE_IMAGE_MAIL_TOKEN(Research Desk側の同名環境変数と同じ値)

必要な設定

上のトークン、宛先(AIDE_IMAGE_MAIL_TO)、Gmail OAuthの3つ(AIDE_GMAIL_CLIENT_ID ほか)。1つでも欠ければ503。送信元(AIDE_IMAGE_MAIL_FROM)は任意

リクエストの項目

title(200文字まで)・imageCount(整数)・width1200/900/600)・idempotencyKeyzip(2MiBまで)

件名・宛先・送信元はAIDE側で固定する

件名は常に [画像] {title} で組み立てる。[画像] は固定文字列で、リクエストのどの項目からも 変更できない。宛先(AIDE_IMAGE_MAIL_TO)・BCC(AIDE_IMAGE_MAIL_BCC)・送信元 (AIDE_IMAGE_MAIL_FROM)もAIDE側の環境変数で固定し、リクエストに同じ項目があっても無視する (Research Desk側もそもそも送らない)。呼び出し元から任意のアドレスを名乗れないようにする ため、送信元だけをリクエスト項目にする例外は作っていない。

送信元はAIDEが保持するGmail資格情報

送信元GmailのOAuthクライアント・リフレッシュトークンはAIDEだけが持ち、Research Deskへは渡さない。 googleapis 等のSDKは入れず、依存ゼロの方針に従い fetch とMIMEの手組みだけで実装している (src/core/connectors/image-mail/gmail.ts)。

AIDE_IMAGE_MAIL_FROM を設定すると、その値が From ヘッダに載る(aide#238)。 user@example.com表示名 <user@example.com> のどちらの形でも書け、日本語の表示名は RFC 2047でエンコードされる。未設定なら From を書かず、Gmailが認可済みアカウントのアドレスで 補完する(以前の挙動)。

Gmailが From に許すのは、認可したアカウント本人か、Gmailの設定「アカウントとインポート → 名前 → 他のメールアドレスを追加」で確認済みの別アドレス(send-as alias)だけ。 それ以外を 指定すると送信時に拒否され、この口は422を返す。エイリアスの確認を済ませてから設定すること。

形式が不正な値(@ が無い・改行を含むなど)を設定した場合は、Gmailへ送らず503を返す—— From へ素通しすると改行で任意のヘッダを差し込まれるため、src/core/connectors/image-mail/gmail.tsformatFromAddress() で弾いている。

gmail.send はGoogleの sensitive scope にあたり、OAuth同意画面の公開ステータスが「テスト」の ままだとリフレッシュトークンが7日で失効する(Gmailを載せていない理由 と同じ制約。あちらは読み取り、こちらは送信専用で権限が異なるため、Issue #230で改めて判断した)。 運用では同意画面を「本番」へ切り替えておく必要がある。

二重送信を止める

idempotencyKey が同じ再送はGmailへ送らず、前回の messageIdduplicated: true で返す。 考え方は二重登録を止めると同じで、送る前に「結果不明」として記録し、 送信されなかったことが確実な場合(Gmailの拒否・資格情報の失効)だけ記録を消す。

応答の意味

状態

意味

200

送信できた(duplicated: true なら再送で、Gmailへは送っていない)

400

入力が不正。直して送り直す

409

前回の結果が不明。再送しない。Gmailの送信済みメールを確認する

422

Gmailが内容を拒んだ。送信されていない

502

Gmailへ届かない・打ち切り。送信されたかは不明

503

AIDE_IMAGE_MAIL_* / AIDE_GMAIL_* が揃っていない、または AIDE_IMAGE_MAIL_FROM の形式が不正で、口が開いていない

記録するもの・しないもの

送信成功・失敗、件数、横幅、ZIPサイズ、Gmail messageIdを data/image-mail-log.json に記録する (src/core/connectors/image-mail/log.ts)。タイトルは記録しない——写真の内容を示唆する 文字列を平文ログへ溜めないため。画像データ(ZIP本体)は送信処理後に保持しない。 メモリ上で Gmail APIへ渡すだけで、ディスクへは一度も書かない。

公開URLからの遮断リストには入れない

Research Desk側のサーバーから直接届く必要があるため、/api/money/api/zaim と違い 公開URLからの遮断のApache設定には image-mail を入れない。

ChatGPTからAsset Managerへ請求情報を取り込む(MCP)

ChatGPTのスケジュールからGmailの請求情報を取り込む経路として、MCPツール asset_manager_import_payment を提供する(#199)。このツールはZaim APIを直接呼ばず、 Asset Managerの POST /api/receipts/import だけを呼び出す。gmailMessageIdconfidence は必須で、source: "gmail" はAIDE側が付与する。同じ gmailMessageId の再送は Asset Manager側の冪等性で duplicate になる。

date は購入日時で、YYYY-MM-DD に加えて YYYY-MM-DDTHH:mm(秒・末尾の Z / +09:00 も可)を 受け付ける(#236)。書式はAsset Manager側の parsePurchasedAtlib/receipt-service.ts)に 合わせてあり、タイムゾーンを省いた値はAsset ManagerがJSTとして解釈する。時刻を付けるのは、 メール本文に購入時刻・利用時刻が印字されていて読み取れるときだけで、読み取れない・自信が無い ときは日付だけを送る(推測した時刻を送らない)。メールの受信日時(internalDateDate ヘッダ)を購入時刻として使ってはいけない——請求メールは購入から数時間〜数日遅れて届くため、 家計簿に誤った購入時刻が残る。Zaimの支出に時刻の概念は無いので、時刻が残るのはAsset Manager側だけ。

電気・ガスなど使用量が書かれた請求メールでは、任意項目の usage(32文字以内)に本文の表記の まま渡せる(例: 258kWh12m3)。単位表記の正規化はAsset Manager側が行うため、AIDEは 本文の表記を変換せずそのまま送る。使用量を name に含めてはいけない(分類履歴のキーに影響 するため)。本文から読み取れない・自信が無い月は usage を省略する(#223)。

Asset ManagerのレスポンスJSON(statusreceiptIdzaimMoneyIdreason 等)は加工せず返す。 認証用の ZAIM_SYNC_SECRETAIDE_ASSET_MANAGER_ZAIM_SYNC_SECRET としてAIDE側だけが保持し、 MCPの入力・出力・ログへは出さない。本番URLは AIDE_ASSET_MANAGER_URL(既定は https://asset.gucchii.com)で指定する。デプロイ時のsecret/variable配線は .github/secrets-manifest.tsv.github/workflows/deploy.yml が正である。

外部のClaude CodeからのZaim登録(MCP)

上のHTTP APIはVPS内のアプリ専用のまま据え置く。外(VPS外)のClaude Code・Claudeアプリから 登録するための口は、OAuth Bearerで守られている /mcp の側にMCPツールとして持つ(aide#135)。 実装は src/mcp/tools/zaim.tssrc/core/views/zaim-master.ts で、Zaimを叩く本体 (src/core/connectors/zaim/write.ts)はHTTP APIと共用している。

ツール

役割

aide_zaim_master

口座・カテゴリ・ジャンルの候補(IDと名前)を返す。読み取りのみ

aide_zaim_payment

支出を1件登録し、moneyId と登録された内容を返す

1本に畳んでいない。 aide_dev_status のように読み取りを1本へ畳む前例はあるが、ここは 読みと書きなので、畳むとClaude Code側で「常に許可」にしたときに書き込みまで素通しになる。 承認の粒度が変わるので分ける。

マスタは24時間キャッシュする

GET /api/zaim/master は「連携先の設定時に一度引く」前提でキャッシュを持たない。 呼び出し元がアプリならアプリ側の設定として持てるが、Claudeは状態を持たないので登録のたびに引く。 そのまま通すとZaimのAPIが1回の登録で3本飛ぶため、キャッシュ(data/cache/zaim-master.json)を挟む。

  • 鮮度の基準は24時間src/core/views/money.ts と同じ値で、Zaim由来のデータに対する既存の基準に揃えた。 口座の新設・カテゴリのカスタマイズは月に1回あるかどうかの操作なので、これで十分短い

  • workerジョブにはしない。 Playwright巡回ではなくOAuthのHTTP GET 3本で、 「重い取得」の基準では「都度叩く」側に近い。 ジョブにすると口座を作った直後に次の同期まで候補へ出てこない

  • 古いキャッシュを掴んだときの逃げ道を持つ。 aide_zaim_masterrefresh: true のほか、 aide_zaim_payment は渡されたIDがマスタに無ければ自動で一度だけ引き直す

  • キャッシュのキーは POST /api/cache/:key の許可キー(src/api/ingest.ts)へ足さない。 書くのはこのサーバー自身だけで、受け口から「どのIDがどの口座か」を差し替えられる理由が無い

  • GET /api/zaim/master にはキャッシュを挟まない。 設定時にしか呼ばれないため毎回叩いてよく、 経路によって鮮度が違うfetchZaimMaster() は素のまま、鮮度の扱いは呼び出し側が持つ)

冪等キーは支出の内容から作る

HTTP APIの requestId は「呼び出し元のレコードを指す文字列」(car-care:fuel-log:1234)だが、 Claudeには対応するレコードが無い。 毎回新しい値を作らせると、同じ支出を2回言うだけで2件登録され、 この経路からは取り消せない。そこでAIDE側が内容から決める。

  • キーは mcp:<SHA-256の先頭16桁>。材料は日付・金額・カテゴリ・ジャンル・口座・店名・品名

  • comment は材料に混ぜない。 メモを書き換えただけで別の支出とみなされ、判定をすり抜けるため

  • キーを平文にはしない。 data/zaim-payments.json は「requestIdmoney_id・時刻の3つだけ」 という方針で、内容をそのまま並べた値を置くと支出の一覧そのものになる

  • ただし「復元できないから安全」ではない。 材料の取りうる範囲は狭く(金額は上限30万円、 日付は数十通り、カテゴリ・ジャンルはマスタから既知)、総当たりで元の値へ戻せる。 安全側の根拠は復元の難しさではなく、この値を data/ とサーバーログの外へ出さないことに置く。 MCPの応答に requestId は載せない(返すのは moneyId と登録内容だけ)。 固定のsaltを混ぜる案は採らない——saltが変われば同じ支出が別の鍵になり、判定そのものが効かなくなる

  • 既に同じキーの記録があれば登録せずに止め、既存の moneyId を返す。同じ日・同じ店・同じ金額の 正当な2件目は、利用者に確認したうえで allowDuplicate: true を付けると mcp:...#2 として通る

  • 前回の結果が確定していない記録(money_id が null)は allowDuplicate でも跨がせない。 createZaimPayment()conflict 判定は requestId の完全一致なので、連番で別の鍵にすると 素通りしてしまう。同じ系列に1件でもあれば conflict のまま止め、Zaimの画面で確認してもらう

  • 連番は件数ではなく空き番号で決める。古い記録は500件で落ちるため、件数から作ると 残っている番号と衝突し、別の支出が「登録済み」として素通りする

会話由来の入力に対する歯止め

write.tsMAX_AMOUNT(1億円)は「アプリが桁を1つ間違えた」を止めるためのもので、 会話からの入力には緩い。 MCP経由だけ次を足している。

  • 1件30万円までMCP_MAX_AMOUNT)。超えるものはZaimの画面から登録してもらう

  • 未来の日付は登録しない(日本時間の暦日で判定)

  • カテゴリ・ジャンル・口座のIDがマスタに実在するかを、Zaimへ送る前に確かめる。 ジャンルが指定カテゴリのものかまで見る(Zaimは mapping=1 で両方をIDで受け取るため、 噛み合わない組み合わせは意図しない分類になる)

  • 登録できたら moneyId と併せて、解決後の口座名・カテゴリ名・ジャンル名を含む登録内容を返す。 桁違い・日付の取り違えに、利用者がその応答で気づけるようにするため

カテゴリ・ジャンルの名寄せは持たない。 「ガソリン代 → 自動車費/ガソリン」の対応はここでも作らず、 Claudeが aide_zaim_master の一覧から選ぶ。AIDEにドメイン知識を持ち込まない方針はHTTP APIと同じ。

認証

ClaudeアプリからリモートMCPサーバーへ接続するための OAuth 2.1 を実装している。認可サーバーとリソースサーバーを同一プロセスに置いている(利用者が1人で、分ける利点がないため)。

ここで扱うのは「機械(Claudeアプリ)をこのサーバーへ接続させる」ための認可で、利用者の身元は問わない。 ブラウザで動作状況ページ(/status)を開くほうは別系統で、許可したGoogleアカウントだけが通る(動作状況ページ を参照)。

起動時に必ず決まる

AIDE_AUTH_PASSWORD が未設定だと起動しない。 認証なしのまま公開してしまう事故を、設定ミスではなく起動失敗として顕在化させるため。

無効にするには AIDE_AUTH_DISABLED=1 を明示する。その場合は起動時に警告を出す。

Claudeが叩くパス

実測(2026-08-14)で判明した順序。404を返すとClaudeは無認証のまま接続を継続してしまうため、必ず応答する。

/.well-known/oauth-protected-resource/mcp
/.well-known/oauth-protected-resource
/.well-known/oauth-authorization-server

未認証で /mcp を叩かれた場合は、401 と WWW-Authenticate: Bearer resource_metadata="..." を返す。このヘッダが無いとClaudeはディスカバリを始めない。

フロー

動的クライアント登録(RFC 7591)→ 認可コード + PKCE(S256必須)→ トークン。 クライアントは動的登録のため client_secret を持たない。その代わりに PKCE を必須にしている。

利用者の認証はパスワード1つ。凝った作りにすると壊れるうえ、利用者が1人なら得るものが無い。

トークン

不透明なランダム文字列で、サーバー側で照合する。JWTと違い即座に失効させられることを優先した。

rm data/auth/oauth-state.json   # 全トークンを即時失効

有効期限はアクセストークン30日・リフレッシュトークン180日と長い。個人利用で再認証の手間を避けるためで、上記の失効手段があることが前提。リフレッシュはローテーションする(使ったリフレッシュトークンは無効化する)。

総当たり対策

公開URL上に単一パスワードのフォームを晒すため、回数制限を入れている。

対象

制限

認可(パスワード)

失敗ごとに約0.7秒待つ。15分間に5回失敗で15分ロック(Retry-After を返す)

動的クライアント登録

送信元ごとに1時間20件まで

動作状況ページのGoogleログインには回数制限を置いていない。総当たりできるパスワードが無く、 許可リストに載っているかどうかはGoogleでのログインを終えて初めて判定されるため。

ロックは送信元ごとに独立している。全体で1つにすると、第三者が失敗を繰り返すだけで正規利用者を締め出せる。送信元の判定は X-Forwarded-For の先頭を優先する(プロキシ配下では socket のアドレスが全リクエストで同じになり、制限が機能しないため)。

状態はプロセス内メモリに置く。再起動で消えるが、試行のたびにディスクへ書くと、書き込み負荷でサービスを劣化させる材料を与えることになる。

登録エンドポイントは仕様上(RFC 7591)未認証で公開される。 無制限に受け付けると状態ファイルが際限なく膨らむため、ここにも上限を設けている。

設計上の注意

  • redirect_uri が登録内容と一致しない場合、そこへリダイレクトしない。 エラーもクライアントへ返さず認可画面で止める。緩めるとオープンリダイレクトになる

  • パスワード照合は長さが違っても同じ経路を通す。 早期returnすると処理時間からパスワード長を推測される

  • 公開URLはリバースプロキシのヘッダから解決する。 Apache や cloudflared の背後ではHostが公開名と異なる。メタデータのURLがずれるとクライアントが別ホストへ飛んで認証が壊れる

既知の未整理

Claudeは Anthropic/ToolboxAnthropic/ClaudeAI の2クライアントで接続するため、再接続のたびに登録が積み上がる。実害は無いが、肥大化したら同一 client_name + redirect_uri の再利用を入れる。

本番

ポート

3114(vps README の予約済みポートに登録済み)

想定ドメイン

aide.gucchii.com

環境変数の配線

本番の .envdeploy.yml が毎回まるごと上書きする。 VPS上で手で追記した値はデプロイのたびに 消える。消えても例外にはならず、そのコネクタが「未設定」を返すだけなので、次に呼ぶまで誰も気づけない (実際に AIDE_GITHUB_TOKENAIDE_OPS_DASHBOARD_TOKEN がその状態だった。#55)。

実行時に本番で要る値を足すときは、5か所すべてに通す。

#

場所

役割

1

.github/secrets-manifest.tsv

1Password(正)と GitHub secret/variable の対応表

2

deploy.yml のジョブの env:

GitHub側の値を取り出す。scripts/generate-workflow-env-block.sh で生成する

3

「Deploy and restart」ステップの env:

SSHアクションへ渡す

4

同ステップの envs:

appleboy/ssh-action はここに列挙した名前しかリモートへ渡さない

5

同ステップの .env heredoc

実際にVPSへ書き出す

1Password 側へ値を入れたら scripts/sync-github-secrets.sh --only <KEY> で GitHub Secret へ同期する (実行時に1Passwordは呼ばない。#1)。トークン類は未発行でもデプロイを止めないよう ${VAR:-} で書き、 空ならAIDE側が「未設定」として振る舞う。

この5か所の抜けは src/deploy-env-wiring.test.ts が検査する。src/ が読む AIDE_* は、すべて 配線されているか、テスト内の NOT_REQUIRED_IN_PRODUCTION に理由付きで登録されているかのどちらかになる。

この検査は process.env["AIDE_XXX"] という直接参照だけを走査する(aide#230)。 readResearchDeskConfig(env: NodeJS.ProcessEnv = process.env) のように、テストでenvを 差し替えられるようにする目的で関数の引数に持たせ、内部では env["AIDE_XXX"] と間接的に 読む書き方をすると、この走査から漏れる——本番で値が空でも検査は気づかない。実行時に 本番で要る AIDE_* は、src/api/zaim.tszaimWriteSecret() のように引数を取らず process.env["AIDE_XXX"] を直接参照する形にする。 テスト側は process.env["AIDE_XXX"] = "..." を実際にセット・削除して差し替える。

コメント中に環境変数名をワイルドカード付きで例示するときも要注意で、 `process.env["AIDE_GMAIL_*"]` のように process.env["..."] の形で書くと、正規表現が * の手前までを1つの名前として拾い、存在しない AIDE_GMAIL_ の配線漏れとして誤検出する。 コメントでは AIDE_GMAIL_* を直接参照する、のように地の文で書く。

Maintenance

ActivityMaintained
ResponsivenessUnresponsive

Resources

Unclaimed servers have limited discoverability.

Looking for Admin?

If you are the server author, to access and configure the admin panel.

Related MCP Connectors

Related MCP Servers

  • A
    license
    Not graded
    quality
    D
    maintenance
    A unified MCP server that provides OAuth-enabled integrations for multiple services through a single deployment. Currently supports Slack with 47 tools, with plans to expand to Gmail, Google Calendar, Notion, and 100+ other integrations.
    6
    MIT
  • A
    license
    Not graded
    quality
    D
    maintenance
    A self-hosted MCP server for managing email, calendar, and cloud storage across Microsoft 365, Google Workspace, and IMAP accounts, enabling natural language interaction through any MCP client.
    1
    MIT
  • A
    license
    Not graded
    quality
    B
    maintenance
    MCP server providing persistent memory, semantic search, versioned storage, webhook fanout, and unified LLM routing for AI agents via FastAPI runtime with multiple backend options.
    31
    Apache 2.0

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/guchi-apps/aide'

If you have feedback or need assistance with the MCP directory API, please join our Discord server