feat: initial public release (MAESTRO)

This commit is contained in:
oss-sync
2026-06-03 05:08:00 +00:00
commit f5c7666f6b
823 changed files with 184150 additions and 0 deletions
+239
View File
@@ -0,0 +1,239 @@
# AAO Gateway モード — 機能概要 (2026-05-20 時点)
このドキュメントは「LiteLLM Proxy 代替として AAO に追加された Gateway 機能」が今回の一連の作業でどう変わったかを日本語で解説します。技術詳細の設計 doc は末尾の関連ドキュメントセクションを参照。
---
## TL;DR (3 行)
- **AAO 自身が OpenAI 互換 LLM Gateway として動けるようになりました。** 他の AAO や任意の OpenAI クライアントから `/v1/chat/completions` を叩けます。
- **設定 UI のトグル 1 つで on/off** (同一プロセス内で起動・停止)。別プロセスで動かす運用も従来通り可能 (advanced)。
- 仮想 API キーごとに **月次 token 予算 + RPM レート制限**、Prometheus でリアルタイム監視。Telemetry 外部送信ゼロ。
---
## なぜこれを作ったか
LiteLLM Proxy には次の懸念がありました:
1. **Telemetry**: デフォルトで匿名利用データが外部送信される (opt-out 可だが要設定)。
2. **License 変更リスク**: MIT → BSL/SSPL への変更前例があり、組織で長期運用するには不確実性が高い。
3. **追加依存**: Python サービス + Redis (任意) + DB を、AAO とは別に運用する必要がある。
これらを **「AAO 単一バイナリ + 既存 SQLite + telemetry 完全ゼロ」** で代替するのが本機能の目的です。LiteLLM 固有の高度機能 (sticky routing / canary / cost USD / OpenTelemetry / Slack アラート / multi-region federation 等) は **意図的にスコープ外**にして、シンプルな in-house ゲートウェイに振り切りました。
---
## どの機能が、どのタイミングで入ったか
時系列に沿って、追加された機能とユーザーへの影響を解説します。
### Phase 1: Gateway モードの土台 (PR #326)
| 項目 | 内容 |
|---|---|
| 起動方法 | `AAO_MODE=gateway` を環境変数指定すると、worker / scheduler / UI を起動せず、軽量に gateway サーバだけ立つ |
| エンドポイント | `/v1/chat/completions` (SSE ストリーミング) + `/v1/models` + `/health` (LiteLLM 互換 JSON) |
| 認証 | Bearer Token (config.yaml に static な virtual keys) |
| ルーティング | 複数 backend (llama-server / vLLM など) を **least-busy** (進行中リクエスト数が一番少ない backend) で振り分け |
| 安全停止 | SIGTERM 時に進行中 SSE を `shutdown_graceful_sec` (デフォルト 30s) 待って drain、`gateway_shutdown` SSE event でクライアントに retry を促す |
### Phase 2a: 仮想キーを DB で管理 + Admin REST API (PR #330)
- API キー形式: `sk-aao-<base62-32>` (32 文字、23 bytes エントロピー、SHA-256 hash で DB 保存)
- **raw key は発行直後の 1 回だけ表示**、以降は prefix しか見えない (LiteLLM 同様の安全設計)
- Admin REST API で発行 / 一覧 / rotate / soft delete (revoked_at)
- config.yaml の static key は起動時に自動で DB に移行
- `team` フィールドでテナント分離
### Phase 2b: 予算とレート制限 + UI 一式 (PR #332)
| 機能 | 実装 |
|---|---|
| 月次 token 予算 | キーごとに `tokens_budget`。UTC 月初に counter リセット。超過した次のリクエストで 429 を返す (post-hoc enforcement) |
| RPM レート制限 | キーごとに `rate_limit_rpm`。sliding 60s window、in-memory カウンタ + 30s 周期で DB flush |
| 使用量集計 | `gateway_key_usage` テーブルに (key_id, period_start) 単位で tokens_in / tokens_out / requests を記録 |
| UI | Settings → Tools → **"Gateway Keys"** タブ。発行・無効化・rotate・月次グラフ |
### Phase 3a: Polish bundle (PR #333)
Phase 1-2b で残った INVESTIGATE 8 件をまとめて解消:
1. Orphan key 検出時の警告ログ
2. `Authorization: Bearer ...` の regex を RFC 6750 厳密に
3. 5 秒 LRU の key auth cache (DB 負荷削減)
4. config 由来 key の drift resync (config.yaml を書き換えた時の DB 整合)
5. PATCH で revoked key を編集しようとしたら 409 conflict
6. Dead field cleanup
7. MAX_TRACKED_KEYS LRU eviction (rate limiter)
8. **SSE error の sentinel 化**: `gateway_shutdown` / `gateway_timeout` / `budget_exhausted` / `rate_limited` の 4 種をクライアントが識別可能に
### Phase 3b: Prometheus exporter (PR #335)
- **Gateway 11 metrics**: requests_total / tokens_total / backend_busy_slots / key_cache_size / latency histogram など
- **Worker 6 metrics**: jobs_total / piece_runs / queue depth など
- 全 metric に **per-team label** を載せて、テナント単位の利用量を分離可視化
- `/metrics`**デフォルトで localhost 限定** (`127.0.0.1` / `::1` allowlist)、外部 Prometheus 用に bearer token opt-in
- cardinality 暴走を防ぐため、label を 1 桁オーダーに抑える discipline
### cleanup (PR #337)
- AAO の LLM クライアント (`openai-compat.ts`) で gateway sentinel SSE error を parse
### ops (PR #340)
- `scripts/gateway.sh start | stop | status | logs` (`.gateway.pid` 管理、`logs/gateway.log`)
- `AAO_CONFIG` env で config.yaml path を override 可能 (両モード共通)
- `GATEWAY_PORT` env で listen port を override 可能
- DB 共有設計 (worker と gateway は同一 SQLite を WAL で共有可能) を doc に明記
### Phase 3c: UI 制御 + 同プロセス default ← **本セッションで完了 (PR #341)**
ここが今回の最大の変化です。これまで「gateway 専用プロセスを別 port で起動する」モデルだったのを抜本的に変更:
| Before (Phase 1-3b) | After (Phase 3c) |
|---|---|
| `AAO_MODE=gateway` で gateway 専用プロセスを別 port (例: 9877) に起動 | 通常の worker AAO の **同一プロセス・同一 port** で動作 |
| Worker UI と gateway を物理的に分離 | UI のトグル 1 つで gateway を mount / unmount |
| 各 AAO に gateway 用の追加デプロイが必要 | 既存 AAO を起動して UI から有効化するだけ |
#### 新しい設定 UI (Settings → Tools → "Gateway Server")
- **Enable Gateway** トグル: チェックで gateway 起動、外して停止
- **Backends list** (フォーム編集):
- Endpoint URL (http/https)
- Backend ID (任意の文字列、ルーティング識別子)
- Max slots (並列処理上限)
- API key (backend 側に必要な場合)
- 編集 + Save で **hot reload**: 進行中 SSE は `gateway_shutdown` event で drain、新接続から新 config で動作
- **リアルタイム状態 badge**: `running` / `disabled` / `misconfigured` を 3 秒周期で表示
#### 動作モード
- **Gateway 有効時**: `/v1/*` paths が gateway sub-app にルーティングされ、`/health` は LiteLLM 互換 JSON を返す
- **Gateway 無効時**: `/v1/*` は 404、`/health` は bridge の `{status:'ok'}` (Docker healthcheck 等の既存利用に影響なし)
#### Phase 3c で重要だった 8 件のバグ修正
レビューで critical な問題が見つかり、すべて修正済み:
1. **prom-client メトリクス重複登録クラッシュ**: gateway 設定変更 (= bounce) 1 回ごとに `Counter` を新規登録 → 2 回目で throw して bridge プロセスごと死ぬ問題。`WeakMap<Registry, Map<prefix, GatewayMetrics>>` で memoize して解決
2. **BackendStatusRegistry が worker 用の list を見ていた**: 同プロセス mode で gateway も worker と同じ registry を共有していたため、gateway 専用の backend が status 不明 → routing blind / `/health` 空 / metrics 0。Gateway は自前 registry を `gateway.backends[]` 上に build する設計に
3. **`/health` LiteLLM 互換が壊れていた**: bridge の既存 `/health` (`{status:'ok'}`) が mountGateway より先に登録されていて、LiteLLM 互換 JSON が返らない問題。`classifyGatewayPath` を 3 値 (`gateway-only` / `gateway-when-enabled` / `false`) に拡張し、Express middleware の登録順序を修正
4. **transition 中の config が dropped される**: `state === 'starting' | 'stopping'` 中に新規 config 適用が落ちる問題。`pendingConfig` queue + mutex chain replay で対応 (実用上は mutex serialize で到達不能だが、防御として実装)
5. **stop() が state を misconfigured のまま残す**: 公開 stop API が state を `disabled` にリセットしない問題
6. **configsEquivalent の比較が不安定**: `JSON.stringify` の key 順依存で偽 bounce が起きる問題。stable stringify に
7. **listenPort が env 依存**: 実 listen port と表示 port が乖離する可能性。`CoreServerOptions.listenPort` で配線
8. **`api_key` 入力時に `${VAR}` env 参照が黙って literal 保存される**: amber warning text を form に表示
これらは round-1 / round-2 の adversarial review で発見・修正。テストでは隠蔽されやすいバグ (例: 1 番は `beforeEach` で fresh Registry を使うと検出できない) を含むため、shared-registry pattern の test を追加で書いて検証しています。
---
## どう使うか (運用ガイド)
### パターン A: 単一 AAO で gateway を有効化 (Phase 3c 以降の推奨)
```
1. scripts/server.sh start で AAO を通常起動
2. UI に admin としてログイン
3. Settings → Tools → "Gateway Server" を開く
4. "Enable Gateway" にチェック → Backends list を入力 → Save
5. Settings → Tools → "Gateway Keys" で API キーを発行 (raw key は発行直後のみ表示)
6. 他の AAO や OpenAI クライアントから:
base_url: http://this-aao:9876/v1
api_key: sk-aao-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
```
### パターン B: Gateway 専用サーバとしてデプロイ (advanced)
GPU サーバが多い、または worker UI を運用したくない場合:
```bash
AAO_MODE=gateway scripts/gateway.sh start
# AAO_CONFIG=/etc/aao/gateway.yaml で別 config を指定可能
# worker mode と同じ DB を共有可能 (キー管理が両モードで一貫)
```
### パターン C: 既存 LiteLLM Proxy からの乗り換え
互換性のポイント:
- Endpoint path: `/v1/chat/completions``/v1/models``/health` 同じ
- Response header `x-litellm-model-id` をそのまま発行 (vendor-neutral な `x-aao-backend-id` も同値で同時発行)
- Key format: `sk-aao-*` (LiteLLM の `sk-*` から prefix を変更、長さ・エントロピー同等)
- `/health` の JSON shape: `{healthy_endpoints, unhealthy_endpoints, healthy_count, unhealthy_count}` で LiteLLM 互換
クライアント側の `parseLiteLLMHealth` 等の既存コードは無修正で動作します。
---
## 監視
`/metrics` で Prometheus 形式メトリクスを取得:
```
# Gateway 系
aao_gateway_requests_total{backend="llm-a",team="ops",status="ok"} 142
aao_gateway_tokens_total{backend="llm-a",team="ops",direction="out"} 95821
aao_gateway_backend_busy_slots{backend="llm-a"} 2
# Worker 系 (same-process mode 時に同じ endpoint から)
aao_worker_jobs_total{piece="chat",status="succeeded"} 38
```
外部 Prometheus からスクレイプする場合は config で `provider.metrics.bearer_token` を設定し、IP allowlist を緩和。
---
## 意図的にやっていない機能 (実需確認まで保留)
| 機能 | 保留理由 |
|---|---|
| Sticky routing (cache hit 最適化) | 1 backend 構成 + cache hit 率 実測無しでは ROI 不明 |
| Canary routing (model rollout) | 現状 model A→B 切り替え予定なし |
| Token → USD コスト換算 | tokens 数で十分か admin の要望次第 |
| Audit log テーブル | compliance 要件無し時点では不要 |
| Pre-reserve budget (並列 burst) | 実際に N×max_tokens overshoot が観測されたら検討 |
| OpenTelemetry trace | 単一 org deploy では ROI 低い |
| Slack / Email アラート | Prometheus Alertmanager で代用想定 |
| Multi-AAO federation | Prometheus federation で代用想定 |
---
## 次のステップ
- Backend ルーティングの動作 (least-busy の精度)
- SSE drain の挙動 (大量同時接続 + graceful shutdown)
- Budget / RPM の境界値 (オフバイワン無いか)
- Prometheus metrics の cardinality / scrape duration
- UI hot reload の race condition
- LiteLLM 互換性 (既存 client コードが無修正で動くか)
を検証。dogfooding で観測した問題から Phase 4 のスコープを決めます。
---
## 関連ドキュメント
- In-product help: `ui/src/content/help/11-llm-gateway.md`
- INVESTIGATE backlog (open follow-up issues): Gitea issue #338
---
## マージ済み PR 一覧
| Phase | PR | merge commit | 日付 |
|---|---|---|---|
| 1 | #326 | `178baa9` | 2026-05-18 |
| 2a | #330 | `78a796d` | 2026-05-18 |
| 2b | #332 | `b0569c7` | 2026-05-19 |
| 3a | #333 | `30e9d78` | 2026-05-19 |
| 3b | #335 | `9efc60f` | 2026-05-19 |
| cleanup | #337 | `bcfdd41` | 2026-05-20 |
| ops | #340 | `13bd3cd` | 2026-05-20 |
| **3c** | **#341** | **`561ff24`** | **2026-05-20 (本セッション)** |
累計コード追加: 約 12,500 行 (テスト含む)、テスト件数 2720 (ベースライン 2707 から +13)。
+85
View File
@@ -0,0 +1,85 @@
# Architecture Overview
MAESTRO は、ユーザーが投げたタスクを LLM 駆動のワークフロー(Piece)で実行する
エージェントオーケストレーターである。コントリビュータ向けのコードマップは
[../AGENTS.md](../AGENTS.md) も参照。
## 実行フロー
```
UI (POST /api/local/tasks)
→ bridge/server.ts (Express API)
→ Repository (SQLite: jobs テーブルに enqueue)
→ Worker.poll() が queued ジョブを取得
→ piece-classifier.ts: LLM がタスクを分類し Piece を選択
→ piece-runner.ts: pieces/*.yaml を読み、movements を順に実行
→ agent-loop.ts: 1 movement の ReAct ループ (LLM ↔ tool calls)
├─ 中間遷移: transition ツール
└─ 終了: complete ツール (success / aborted / needs_user_input)
→ ジョブ完了: DB 更新 + 進捗コメント。成果物は workspace/output/
```
1. **API 受付**`bridge/server.ts` がタスクを受け、`Repository` 経由で `jobs` テーブルに `queued` で登録する。
2. **ワーカー**`worker.ts` が DB をポーリングし、自分の `profiles`/`task_classes` に合致するジョブを取得(複数ワーカーが並走)。
3. **分類**`piece-classifier.ts` がタスク本文と全 Piece の description を LLM に渡し、最適な Piece を選ぶ。
4. **Piece 実行**`piece-runner.ts` が Piece の movements を順に回す。verify movement のフィードバックは次の execute に引き継がれ、`transition.lessons` で movement 間の教訓が蓄積される。
5. **ReAct ループ**`agent-loop.ts` が 1 movement 内で LLM とツールを往復させる。`ContextManager` が LLM の `usage` からトークン使用量を追跡し、閾値(70/85/95%)で warn / prompt / force_transition を発火する。
## Piece と Movement
- **Piece** = `pieces/*.yaml``movements` 配列で構成。
-**Movement**`allowed_tools`LLM に提示するツール)、`edit`Write/Edit 可否)、`rules`(遷移条件)を持つ。`allowed_tools` 外のツールは LLM から見えない。
- **遷移**: 中間ホップは `transition``rules[].next` に列挙した宛先のみ選択可)、終了は `complete``complete.result` がユーザーに見える唯一の最終出力。
- **`default_next`** はエンジン内部の sentinel(コンテキスト溢れ時の強制遷移、ASK 上限時のフォールバック)。
- **Progressive pressure**: 同一 movement への連続訪問が増えると警告を注入し、閾値超過で ABORT。
## ツールランタイム
ツールは `src/engine/tools/*.ts` のモジュール群。`tools/index.ts` が動的にロードし dispatch する。各ツールは 1 行の description(毎 LLM 呼び出しに乗るため簡潔に)を持ち、詳細手順は `docs/tools/<name>.md``ReadToolDoc` で取得)に置く。主なモジュールは [../AGENTS.md](../AGENTS.md#tool-modules) の一覧を参照。
Read 系ツールは並列実行される。Write/Edit は movement の `edit: true` のときのみ提示され、書き込みは主に `workspace/output/` に限られる。
## Bash サンドボックス
エージェントの Bash 実行は、利用可能なら **bwrap サンドボックス**で隔離する:
- **ファイルシステム**: タスクの workspace のみ rw bind、`/usr` 等は ro、他タスクの workspace やホスト `/home` は不可視。
- **環境変数**: `--clearenv` + 最小 allowlist のみ注入(シークレット env はサンドボックス内から見えない)。
- **ネットワーク**: `--unshare-net` で遮断(外向き通信は SSRF ガード付きの WebFetch/MCP に集約)。
- **各 Bash コールは独立**したサンドボックス(揮発 `/tmp`・毎回新名前空間)。永続するのは workspace のみ。
`safety.bash_sandbox` でモードを選ぶ(`auto`/`always`/`off`)。bwrap 不在時は **hardened フォールバック**(コマンド許可リスト + パススコープ検査 + env スクラブ付き exec)になる。実行時 `pip`/`npm install` は全モードで拒否され、Python パッケージは `runtime/python-requirements.txt` からプリベイクされる。詳細は [operations/bash-sandbox-provisioning.md](operations/bash-sandbox-provisioning.md)。
## ワークスペース構造(ジョブ実行時)
```
{worktree_dir}/local/{taskId}/
input/ アップロード・DownloadFile の保存先
output/ 成果物(Write/Edit が許可される主な場所)
logs/ activity.log / 各種履歴
subtasks/ SpawnSubTask の結果
skills/ ReadSkill で materialize されたスキルファイル
```
## データベース
SQLitebetter-sqlite3)。`db/schema.sql` が初期スキーマ。追加カラムは `db/migrate.ts`
`PRAGMA table_info` → 存在チェック → `ALTER TABLE ADD COLUMN` のパターンで冪等に適用する
(バージョン管理テーブルは使わない)。主なテーブル: `jobs` / `local_tasks` /
`local_task_comments` / `audit_log` ほか。
## ジョブのライフサイクル
`queued``dispatching``running``succeeded` / `failed` / `waiting_human`ASK 回答待ち)/ `waiting_subtasks`(並列サブタスク待ち)。失敗時は `retry` で再 `queued`(最大 `retry.max_attempts` 回)。
## オプションのサブシステム
- **LLM Gateway**`src/gateway/` — MAESTRO 自身を OpenAI 互換 LLM プロキシとして公開(仮想キー・予算・Prometheus メトリクス)。複数 GPU/チーム共有向け。env/接続種別が `AAO_*`/`aao_gateway` の歴史的接頭辞を使う。
- **MCP** — Model Context Protocol サーバー連携(`MCP_ENCRYPTION_KEY` 必須)。
- **Reflection** — ジョブ完了ごとにユーザーメモリを LLM が自動更新(既定 OFF、revert 可)。
- **認証** — Passport による Google/Gitea OAuth(任意)。`private`/`org`/`public` の可視性モデル。
- **スケジューラ** — cron 式の定期タスク。
## フロントエンド
React + Vite + TailwindCSS + @tanstack/react-query。`ui/src/App.tsx` がルート。2 カラム(list + detail)レイアウトで、タスク一覧・スケジュール・設定・スキル/Piece 管理を扱う。
+212
View File
@@ -0,0 +1,212 @@
# ベンチマーク (`npm run bench`)
エージェントの **ツールコール能力 / 命令追従性 / 頭の良さ / チェックリスト使用 / 効率** を、1 つの統合タスクから多軸で計測するためのフレームワーク。
モデル変更・piece 改修・ツール追加などの前後で同じタスクを走らせ、品質回帰を検出する用途を想定している。
---
## 1. 前提
| 項目 | 必須 |
|------|------|
| `scripts/server.sh start`(またはそれ相当)でオーケストレータが起動していること | ✅ |
| `config.yaml``provider` が動作する LLM を指している(タスク実行 + judge の両方で使う) | ✅ |
| 初回のみ `npm run bench:fixtures``bench/fixtures/sales.xlsx` を生成 | ✅ |
ベンチランナーは外部ネットに依存しない。`fixtures/web/*` はランナー内蔵の **localhost HTTP サーバ** が配信する(起動時にランダムポートを取り、`{WEB_PORT}` トークンで prompt に注入)。
---
## 2. 使い方
```bash
# 全タスク
npm run bench
# 単一タスク
npm run bench -- --task=composite-mini-report
# 別ホスト/ポートのオーケストレータ向け
npm run bench -- --server=http://127.0.0.1:9876
# LLM judge を skip (axis D を 1.0 固定にして programmatic だけで採点)
BENCH_JUDGE=off npm run bench
# judge を別エンドポイント・別モデルにする
BENCH_JUDGE_ENDPOINT=https://api.example.com/v1 \
BENCH_JUDGE_MODEL=gpt-oss:20b \
npm run bench
```
実行が終わると `bench/results/<run_id>/` に書き出される(`run_id` は ISO タイムスタンプ)。
---
## 3. 出力の見方
```
bench/results/2026-05-01T03-22-11Z/
summary.md # ← ここを最初に見る
composite-mini-report/
result.json # 完全な採点 + raw データ
workspace/
logs/activity.log # エージェントが書いたログ
output/report.md # エージェントの成果物
```
`summary.md` の冒頭:
```
# Bench run @ 2026-05-01T03:22:11.000Z
**Overall: 73 / 100**
| Task | Status | Total | A | B | C | D |
| ---------------------- | ---------- | ----: | --: | --: | --: | --: |
| composite-mini-report | succeeded | 73 | 90% | 100%| 70% | 60% |
```
各タスクの詳細セクションでは axis ごとに ✓/✗ 内訳とツールコールの全シーケンスが折りたたみで見られる。
`result.json` は CI から機械可読に扱える形式。
---
## 4. 採点軸(重み 100 点満点)
| 軸 | 重み | 何を見るか | 判定 |
|----|----:|-----------|------|
| **A. Tools** | 30 | `must_use_tools` を呼んだか / `forbidden_tools` を避けたか / `forbidden_tool_for_ext` (例: Read 禁止 .xlsx) | プログラム |
| **B. Checklist** | 15 | `CreateChecklist` / `CheckItem×N` / `GetChecklist` の使用 | プログラム |
| **C. Instructions** | 30 | 出力ファイル名・1 行目固定・セクション順・行数・文字数・禁止パターンなど | プログラム |
| **D. Reasoning** | 25 | 内容の妥当性・統合の質・「次アクション」の具体性など | LLM judge ルーブリック |
| _補助: Efficiency_ | | duration / prompt tokenssummary に数値表示のみ) | – |
`Total = A×30 + B×15 + C×30 + D×25` を 0..100 に正規化。
`completion_status: [succeeded, waiting_human, failed, aborted, cancelled]` で受理する終了状態を指定。デフォルトは `[succeeded]` のみ。**failure でも grader は走り部分スコアが出る**。
---
## 5. 既存タスク
### `composite-mini-report`
3 ソース(Excel / Web / Markdown)を統合してミニレポートを書かせるタスク。1 本で全軸が動く。
- 必須ツール: `ReadExcel` / `WebFetch` / `Read` / `Write` / `CreateChecklist` / `CheckItem` (≥3 回) / `GetChecklist`
- 禁止: `.xlsx``Read` で開く(バイナリ混入防止 — issue #189 と同じ罠)
- 出力 `output/report.md` に厳格な形式制約(1 行目固定、セクション順、各セクション 5 行以内、「次アクション」3 件 40 字以内、画像・HTML 禁止)
- judge ルーブリック: `factual_grounding` / `actions_quality` / `synthesis`
`bench/tasks/composite-mini-report.yaml` を参考実装としてそのまま使える。
---
## 6. タスクを追加する
`bench/tasks/*.yaml` を作るだけで自動的に拾われる。スキーマは `src/bench/types.ts``BenchTask` を参照。最小例:
```yaml
id: my-task
title: 短いタスク説明
piece_hint: chat # piece 名 (省略時は chat)
timeout_minutes: 5
fixtures: # 任意
- source: fixtures/data.txt # bench/ ルート相対
dest: input/data.txt # input/ に置けば attachments としてアップロード
- source: fixtures/web/page.html
dest: web/page.html # web/ に置けば fixture HTTP server が配信
prompt_tokens: # 任意。prompt 内の {KEY} を実行時に置換
CUSTOM_KEY: foo
prompt: |
http://127.0.0.1:{WEB_PORT}/page.html を読み、… {CUSTOM_KEY} …
expected:
must_use_tools: [WebFetch, Write]
forbidden_tools: [Bash]
forbidden_tool_for_ext:
Read: ['.xlsx']
must_produce_files: [output/answer.md]
completion_status: [succeeded]
checklist: # 任意。指定すると軸 B が有効化される
required_tools: [CreateChecklist, CheckItem, GetChecklist]
min_check_item_calls: 3
grading:
programmatic:
constraints:
- { type: file_first_line_equals, file: output/answer.md, line: '# Title' }
- { type: file_must_contain_in_order, file: output/answer.md, sections: ['## A', '## B'] }
- { type: file_section_max_lines, file: output/answer.md, section: A, max: 5 }
- { type: file_line_starts_with, file: output/answer.md, prefix: '-', min_lines: 3, section: B }
- { type: file_line_max_chars, file: output/answer.md, max: 40, section: B }
- { type: file_no_pattern, file: output/answer.md, pattern: '!\[' }
llm_judge: # 任意。指定しないと軸 D は 1.0 固定
rubrics:
- name: relevance
prompt: 出力が prompt の意図と整合しているか
max_score: 10
```
### プログラム制約の種類
| `type` | 意味 |
|--------|------|
| `file_first_line_equals` | ファイル 1 行目が完全一致するか |
| `file_must_contain_in_order` | 指定文字列が指定の順序で出現するか |
| `file_line_starts_with` | (任意セクション内で) 指定 prefix で始まる行が `min_lines` 以上あるか |
| `file_line_max_chars` | (任意セクション内で) 各行の文字数が `max` 以下か |
| `file_section_max_lines` | 指定セクションの非空行が `max` 以下か |
| `file_no_pattern` | 正規表現 (multiline) にマッチしないか |
`section``## ヘッダ``ヘッダ` 部分(`##` は付けない)。指定無しならファイル全体が対象。
---
## 7. 内部構造
```
bench/
fixtures/
tasks/
results/ # gitignored
src/bench/
types.ts # BenchTask / BenchResult / 制約スキーマ
fixture-server.ts # localhost HTTP fixture server
runner.ts # /api/local/tasks に投入 + ポーリング + ログ収集
grader.ts # 軸 A/B/C のプログラム採点
judge.ts # 軸 D の LLM judge 呼び出し + JSON parse
summary.ts # bench/results/<run_id>/summary.md 書き出し
grader.test.ts
scripts/
bench-run.ts # CLI エントリ (`npm run bench`)
build-bench-fixtures.ts # sales.xlsx 生成 (`npm run bench:fixtures`)
```
ベンチランナーは既存の `/api/local/tasks` API を使うだけで、orchestrator 内部とは疎結合になっている。新しい piece やツールを追加してもベンチ側は基本変更不要。
---
## 8. トラブルシューティング
| 症状 | 原因 / 対処 |
|------|-------------|
| `runner failed for ...: fetch failed` | `scripts/server.sh start` が立っていない、または `--server=` で指定したポートが違う |
| 全タスクで axis D が 0.0 | judge LLM のレスポンスが JSON parse 失敗。`bench/results/<run>/summary.md` の reasoning details を確認。OS の事情で短い応答しか返ってこないモデルなら `BENCH_JUDGE_MODEL` を別モデルに切り替える |
| タイムアウトで status が固まる | タスク YAML の `timeout_minutes` を伸ばす。プロバイダ側 `provider.timeoutMinutes` も併せて確認 |
| 同じタスクで毎回スコアが揺れる | LLM judge が確率的なため。programmatic 軸だけ見る・複数回平均を取る運用が無難 |
| `bench/results/` がコミットに乗ってしまった | `.gitignore` 済みだが、過去に追跡されていた場合は `git rm -r --cached bench/results` |
---
## 9. 参考: 関連 issue / 機能
- #156 — このベンチマーク自体
- #189`Read` で xlsx を開かない仕様(composite-mini-report の `forbidden_tool_for_ext` 罠と直結)
- #190 — preflight ログ表示の整理(activity.log を grader が読みやすいことの恩恵)
+229
View File
@@ -0,0 +1,229 @@
# Configuration Reference
MAESTRO は単一の `config.yaml``config.yaml.example` をコピーして作成)で設定する。
- **YAML キーは snake_case**`max_concurrency`)、コード内は camelCase。`src/config.ts``transformKeys` が変換する。
- 一部は**環境変数で上書き**できる([末尾参照](#environment-variable-overrides))。
- `config_version: 2` が現行スキーマ。
> 値の一次ソースは `config.yaml.example`(コメント付き)と `src/config.ts`。本リファレンスは各項目の意味をまとめたもの。
---
## `llm` — ジョブ実行時の LLM 接続
| キー | 既定 | 意味 |
|------|------|------|
| `timeout_minutes` | 10 | 1 リクエスト全体の上限(分)。 |
| `retry.max_attempts` | 3 | 429/5xx/一時接続失敗時の再試行回数。 |
| `retry.backoff_ms` | [2000,5000,15000] | 各再試行の待機(ms)。 |
| `retry.retryable_status` | [429,500,502,503,504] | 再試行対象の HTTP ステータス。 |
### `llm.workers[]` — ジョブ実行に使う接続先(必須)
| キー | 意味 |
|------|------|
| `id` | ワーカー識別子。 |
| `connection_type` | `direct`Ollama/vLLM 等の OpenAI 互換 backend に直結)/ `aao_gateway`(別 Gateway 経由、Gateway Key 必須)。 |
| `endpoint` | OpenAI 互換 API のベース URL(例 `http://localhost:11434/v1`)。 |
| `model` | 使用モデル名(ワーカーごとに明示。`default_model` は廃止)。 |
| `api_key` | `aao_gateway` 時の virtual key 等(任意)。 |
| `roles` | 用途フィルタ: `auto`/`fast`/`quality`/`title`/`reflection` 等。`[title]` のみ=タイトル生成専用。 |
| `max_concurrency` | このワーカーの並列度。 |
| `vlm` | `true` で画像入力対応(ReadImage は VLM ワーカーを優先)。 |
| `enabled` | 有効/無効。 |
### `llm.metrics` — Prometheus exporterworker 側)
| キー | 既定 | 意味 |
|------|------|------|
| `enabled` | true | `/metrics`bridge HTTP, 既定 `PORT=9876`)に mount。 |
| `prefix` | `aao_worker` | メトリクス名 prefix。 |
| `bearer_token` | — | 設定すると Bearer 認証必須(`env:NAME` 形式可)。 |
| `allowed_hosts` | localhost のみ | 許可元 IP。本番は bearer か allowlist を必ず設定。 |
---
## `gateway` — LLM Gateway サーバー(任意)
MAESTRO 自身を OpenAI 互換の LLM Gateway として公開する(仮想キー・予算・メトリクス付き、複数 GPU/チーム共有向け)。**env 変数や接続種別が `AAO_*` / `aao_gateway` という歴史的な接頭辞を使う点に注意(AAO = この Gateway 機能の旧称)。** 詳細は [aao-gateway-overview.md](aao-gateway-overview.md)。
| キー | 既定 | 意味 |
|------|------|------|
| `enabled` | false | true で同 process gateway が起動(hot reload 対応)。 |
| `listen_port` | 4000 | separate-deploy 時のみ。 |
| `request_timeout_sec` | 600 | 1 リクエスト全体(streaming 込み)。 |
| `upstream_timeout_sec` | 30 | 各 upstream の TTFB 上限。 |
| `shutdown_graceful_sec` | 30 | SIGTERM 後の SSE drain 上限。 |
| `backends[]` | — | `id`/`endpoint`/`model`/`max_slots`/`api_key`。model 厳密一致で routing。 |
| `virtual_keys[]` | — | bootstrap/backup 用(`key`/`team`/`allowed_models`/`tokens_budget`/`rate_limit_rpm`)。新規発行は admin API 推奨。 |
| `metrics` | enabled | `prefix: aao_gateway`、team/key_prefix/backend ラベル。auth 必須運用。 |
Virtual Key の発行・rotation は admin REST API`POST /api/admin/gateway/keys`)または UISettings → LLM → Gateway Server)で行う。
---
## `storage` — パス・容量
| キー | 既定 | 意味 |
|------|------|------|
| `worktree_dir` | ./data/workspaces | ジョブ作業ディレクトリのベース。 |
| `custom_pieces_dir` | — | リポジトリ内 `pieces/` に加えて読む Piece dir(任意)。 |
| `user_folder_root` | ./data/users | `{root}/{userId}/` に AGENTS.md/scripts/notes 等を保存。 |
| `task_upload_max_size_mb` | 50 | タスク/コメント body 上限(base64 込み。範囲 11000)。 |
| `trash_retention_days` | 30 | `trash/` の自動 sweep。0 で都度全削除。 |
---
## Execution — 並列度・上限・再試行
| キー | 既定 | 意味 |
|------|------|------|
| `concurrency` | 4 | 全ワーカー合算の最大並列ジョブ数(env `CONCURRENCY`)。 |
| `max_movements` | 200 | 1 ジョブ内の最大 movement 数(loop 防止)。 |
| `retry.max_attempts` | 3 | ジョブ失敗時の最大再試行。 |
| `retry.backoff_seconds` | [60,300,900] | 各 attempt 間の待機秒。 |
| `ask.max_per_job` | 2 | 1 ジョブの ASK(ユーザー質問)上限。 |
| `subtasks.max_depth` | 2 | SpawnSubTask のネスト最大深度。 |
| `subtasks.max_per_parent` | 10 | 1 ジョブが生成できるサブタスク最大数。 |
---
## `context` — LLM コンテキスト管理
| キー | 既定 | 意味 |
|------|------|------|
| `limit_tokens` | 自動取得→128000 | コンテキスト上限。省略時はプロバイダ API から自動取得。 |
| `thresholds[]` | 0.7=warn / 0.85=prompt / 0.95=force_transition | 使用率閾値ごとの動作。 |
---
## `safety` — 暴走防止・Bash サンドボックス
| キー | 既定 | 意味 |
|------|------|------|
| `max_iterations` | 200 | 1 movement 内の最大イテレーション。 |
| `max_revisits` | 3 | 同一 movement の最大再訪問。超過で ABORT。 |
| `prompt_guard_ratio` | 0.8 | プロンプトがコンテキスト上限の何%まで許容するか(0.5–0.95)。 |
| `history_summarization.enabled` | true | 古い turn を構造化要約に置換して粘る。 |
| `history_summarization.tail_turns` | 2 | 末尾何 turn を保護するか。 |
| `history_summarization.preserve_recent_budget` | 8000 | 末尾保護の最大トークン。 |
| `bash_unrestricted` | false | true で Bash のコマンド許可リストを撤廃(**サンドボックス機構は別途 `bash_sandbox` が制御**)。 |
| `bash_sandbox` | auto | Bash 隔離機構: `auto`(bwrap あれば使用、無ければ hardened-whitelist/ `always`(bwrap 強制・不在なら起動失敗、本番推奨)/ `off`(bwrap 不使用、env スクラブは維持)。詳細 [operations/bash-sandbox-provisioning.md](operations/bash-sandbox-provisioning.md)。 |
---
## `search_filter` — WebSearch の機密情報漏洩防止
| キー | 既定 | 意味 |
|------|------|------|
| `blocked_patterns[]` | — | 完全一致で検索クエリから除去するパターン。 |
| `auto_block.private_ip` | true | 10/172.16-31/192.168/127.* を自動ブロック。 |
| `auto_block.internal_domain` | true | `.local`/`.internal`/`.lan`/`.intranet`/`.corp`/`.home`。 |
| `auto_block.email` / `phone` | true | メール/電話番号。 |
---
## `browser` — BrowseWeb ランタイム
| キー | 既定 | 意味 |
|------|------|------|
| `page_timeout` | 60000 | ページ遷移 timeoutms)。 |
| `action_timeout` | 30000 | アクション timeoutms)。 |
| `captcha_solve` | skip | `skip` / `novnc`(人手 CAPTCHA 解決)。 |
| `max_captcha_pages` | 5 | CAPTCHA ページ上限。 |
| `channel` | chromium | `chromium`/`chrome`/`msedge`。 |
| `executable_path` | — | ブラウザ実行ファイル(channel と排他)。 |
---
## `tools` — ツール設定
UI 上は Web & Search / Browser / Media & Documents / External Services / Legacy Knowledge に分かれるが YAML は `tools` 1 ブロック。主な項目:
**Web & Search**: `searxng_url`(WebSearch フォールバック先), `webfetch_timeout`(sec), `websearch_timeout`, `webfetch_allowed_hosts[]`SSRF 例外: private IP/.local を許可する場合)。
**Media & Documents**: `vision_model`/`vision_base_url`/`vision_timeout`/`vision_max_tokens`ReadImage 用 VLM, `ocr_model`, `office_{excel,docx,pdf}_max_size_mb`(既定 10, `office_pptx_max_size_mb`50, `office_pptx_max_uncompressed_mb`200, zip-bomb 検知), `speech_server_url`/`speech_timeout`/`speech_language`
**External Services**: `x_*`Twitter/X CLI 連携: `x_cli_command`/`x_auth_token`/`x_ct0`/`x_proxy`/`x_download_*` 等), `google_maps_api_key`/`maps_timeout`(未設定で Nominatim/OSRM, `amazon_affiliate_tag`/`keepa_api_key`
**User scripts**: `user_scripts_enabled`RunUserScript。plain runtime は Node `--permission` で sandbox 化), `user_scripts_allow_userids[]`
**Legacy Knowledge**: `knowledge_service_url`(未設定で knowledge ツール無効), `knowledge_namespaces`namespace ごとの api_key)。新規 namespace は MCP 経由を推奨。
---
## `notes` — Shared Knowledge Notes 注入
`data/users/{userId}/notes/` のノートをシステムプロンプトに注入。`inject.per_note_max_kb`(日本語は 4 推奨), `inject.total_max_kb`, `inject.over_budget_strategy``truncate_last`/`skip_remaining`/`degrade_to_search`)。
---
## `auth` — 認証(任意)
未設定なら認証なしで動作。
| キー | 意味 |
|------|------|
| `session_secret` | セッション署名鍵(ランダム文字列)。 |
| `session_max_age` | セッション有効期間(ms, 既定 86400000=24h)。 |
| `secure_cookie` | HTTPS 環境では true。 |
| `admin_emails[]` | admin ロールにするメール。 |
| `primary_provider` | `google` / `gitea`(両方有効時に明示)。 |
| `providers.google` | `client_id`/`client_secret`/`callback_url`。 |
| `providers.gitea` | `client_id`/`client_secret`/`base_url`/`callback_url`。ログイン時に Gitea org を取得し可視性に利用。 |
---
## `branding`(任意)
`app_name`/`primary_color`/`login_page_title`/`logo_url`/`favicon_url`/`footer_text`。Settings → System → Brandingadmin)で GUI 編集可。`config.yaml``data/branding/` は gitignore 済み。
---
## `secrets`
`master_key_path`(既定 `./data/secrets/master.key`, 32 byte, 初回起動で自動生成・mode 0600)。SSH 鍵・MCP トークン等の暗号化に使う。
---
## `reflection` — 学習(既定 OFF
ON で毎ジョブ完了後にユーザーメモリを LLM が自動更新(snapshot は revert 可)。`enabled`, `max_memory_changes_per_job`(3), `piece_edit_cooldown_hours`(24), `snapshot_retention_days`(90), `per_user_daily_budget_tokens`(200000)。
---
## `mcp` — Model Context Protocol
サーバーは admin UIglobal/各ユーザー(self-hosted)で管理。**`MCP_ENCRYPTION_KEY` env64 hex)が必須。** `call_timeout_seconds`(60), `max_binary_size_mb`(20), `max_output_files_per_job`(10), `max_output_size_mb_per_job`(200), `tool_cache_ttl_seconds`(600), `oauth_pending_ttl_minutes`(10), `allow_private_addresses`private 網の MCP server 用、既定 false)。
---
## `ssh` — SSH ツール(既定 OFF
`enabled`, `allow_private_addresses`(global 既定、admin は per-connection grant 可), `call_timeout_seconds`(30), `max_output_bytes`(32768), `max_{upload,download}_size_mb`(100), `audit_retention_days`(90), `admin_bypasses_grants`(true), abuse 検知(`abuse_window_minutes`/`abuse_failure_threshold`/`abuse_lock_minutes`)。
**Interactive Console**`ssh.console`: `enabled`, `idle_timeout_seconds`(1800), `max_session_duration_seconds`(14400), `scrollback_bytes`(524288), `max_sessions_per_connection`(3) ほか。手順は [ssh.md](ssh.md)。
---
## `notifications.push` — Web PushV2, 任意)
HTTPS ホスティング必須(iOS は PWA インストール)。`enabled`, `vapid_subject`(RFC 8292), `vapid_current_path`(自動生成・mode 0600, `vapid_history_dir`, `payload_max_bytes`(3072, 上限 4096), `queue_concurrency`(8), `per_send_timeout_ms`(10000)。鍵ローテーション: `npm run vapid-rotate`
---
## Environment variable overrides
一部設定は環境変数で上書きできる:
| 環境変数 | 上書き対象 |
|----------|------------|
| `OLLAMA_BASE_URL` | LLM エンドポイント |
| `OLLAMA_MODEL` | モデル名 |
| `WORKTREE_DIR` | `storage.worktree_dir` |
| `CONCURRENCY` | `concurrency` |
| `DB_PATH` | SQLite DB パス |
| `PORT` | bridge HTTP ポート(既定 9876 |
| `LOG_LEVEL` | `debug`/`info`/`warn`/`error`(既定 info |
| `MCP_ENCRYPTION_KEY` | MCP/SSH 秘密の暗号化鍵(MCP 利用時必須) |
+235
View File
@@ -0,0 +1,235 @@
# コンテキスト構築と溢れ時動作
この資料は、新規メッセージ送信時に何が LLM へ渡るか、movement 間で何が引き継がれるか、コンテキストが逼迫した時にどう動くかをまとめたものです。
## 全体像
```mermaid
flowchart TD
U[UI / API からタスク・コメント送信] --> DB[(SQLite jobs / comments)]
DB --> W[Worker が job を取得]
W --> C[会話コンテキストを組み立て]
C --> P[piece-runner が Piece を実行]
P --> M[Movement 開始]
M --> A[agent-loop: LLM に送信]
A --> T{LLM 応答}
T -->|tool_call| X[許可されたツールを実行]
X --> H[ツール結果を movement 内履歴へ追加]
H --> A
T -->|transition| N{next_step}
N -->|次 movement| P
N -->|COMPLETE| S[Job succeeded]
N -->|ASK| Q[waiting_human]
N -->|ABORT| F[failed / aborted]
N -->|WAIT_SUBTASKS| ST[waiting_subtasks]
```
## 新規メッセージで送られる内容
ローカルタスクでは、Worker が `job.instruction` をそのまま送るのではなく、現在時刻、直近コメント、workspace 状況、タスク本文をまとめた `enrichedInstruction` を作ります。
```mermaid
flowchart TD
J[job.instruction] --> E[enrichedInstruction]
Time[現在日時ブロック] --> E
Comments[直近コメント最大5件] --> Trunc[各コメント最大500文字に切り詰め]
Trunc --> E
Files[input/ と output/ のファイル一覧] --> E
E --> Piece[runPiece]
```
含まれるもの:
| 種別 | 内容 |
|------|------|
| 現在時刻 | `buildTimeContextBlock()` の結果 |
| これまでのやり取り | Local task comments の末尾 5 件 |
| コメント本文 | 1 コメントあたり最大 500 文字 |
| workspace 状況 | `input/``output/` のファイル一覧 |
| タスク本文 | 現在の `job.instruction` |
含まれないもの:
- すべての過去コメント全文
- 過去 movement の LLM 会話履歴全文
- `logs/` 配下のログ全文
- `input/` / `output/` のファイル本文そのもの
ファイル本文が必要な場合は、movement 内で `Read` などのツールを使って取得します。
## Movement 内で送られる内容
各 movement は、system prompt と user prompt から開始します。movement の中では、LLM がツールを呼ぶたびに assistant の tool call と tool result が同じ movement の `messages` に追加され、次の LLM 呼び出しへ再送されます。
```mermaid
sequenceDiagram
participant R as piece-runner
participant L as agent-loop
participant M as LLM
participant T as Tool
R->>L: movement + enrichedInstruction
L->>M: system prompt + user prompt
M-->>L: tool_call
L->>T: executeTool
T-->>L: tool result
L->>M: これまでの messages + tool result
M-->>L: transition(next_step, summary, lessons)
L-->>R: MovementResult
```
movement 内で保持されるもの:
- system prompt
- user prompt
- LLM の tool call
- ツール結果
- `ReadImage` などが返した画像コンテキスト
- transition までのリマインド文
movement が終わると、この `messages` 全体は次 movement にそのまま渡されません。
## Movement 間で引き継がれる内容
movement 間では、会話履歴全文ではなく、限定された要約情報だけが引き継がれます。
```mermaid
flowchart LR
M1[Movement A] --> TR[transition]
TR --> Lessons[lessons / summary]
Lessons --> Log[logs/lessons.jsonl]
Lessons --> Inject[次 movement の prompt に注入]
Inject --> M2[Movement B]
```
主な引き継ぎ:
| 引き継ぎ元 | 次 movement への渡り方 |
|------------|------------------------|
| transition の `lessons` | `## 前のステップで得た教訓` として注入 |
| `lessons` 未指定で COMPLETE | `summary` / output を lesson として扱う |
| verify 系の指摘 | 対象 movement へフィードバックとして追記 |
| チェックリスト | `logs/checklists/*.json` から現在状態を注入 |
| workspace の変更状況 | 一部の verify 後に git status / diff 抜粋を付加 |
Lessons は最大 2000 文字程度に抑えられ、古いものから削られます。
## コンテキスト逼迫時の動作
`ContextManager` は LLM の usage が取れる場合は `prompt_tokens`、取れない場合は文字数推定で使用率を見ます。
デフォルト閾値:
| 使用率 | action | 動作 |
|--------|--------|------|
| 70% | `warn` | progress に警告を記録 |
| 85% | `prompt` | 「作業をまとめて transition してください」という user message を追加 |
| 95% | `force_transition` | 現 movement を強制的に `defaultNext` へ進める。`defaultNext` がなければ `ABORT` |
| 99% | exhausted 判定 | 内部判定用。直接の圧縮処理ではない |
```mermaid
flowchart TD
LLM[LLM 応答完了] --> Usage{usage あり?}
Usage -->|あり| Tokens[prompt_tokens を記録]
Usage -->|なし・3 iteration 以降| Chars[messages 文字数から推定]
Tokens --> Ratio[context 使用率を計算]
Chars --> Ratio
Ratio --> Warn{70%以上?}
Warn -->|warn 未発火| W[warn action]
Ratio --> Prompt{85%以上?}
Prompt -->|prompt 未発火| P[transition 促しを messages に追加]
Ratio --> Force{95%以上?}
Force -->|force_transition 未発火| F[defaultNext へ強制遷移]
```
現状の重要点:
- コンテキスト逼迫時に、会話全体を自動要約して同じ movement を継続する処理はありません。
- `force_transition` は「完了した」と判断するのではなく、`movement.defaultNext` へ進める機械的な退避です。
- `defaultNext``COMPLETE` の movement では、結果として完了扱いになる場合があります。
- `defaultNext` がない場合は `ABORT` になります。
## ツール出力の切り詰め
大きなファイルやコマンド出力をそのまま入れると movement 内の `messages` が膨らむため、一部ツールは残コンテキストに応じて出力を自動切り詰めします。
```mermaid
flowchart TD
Tool[Read / Bash / Office 系ツール] --> Budget[残コンテキストから tool result 予算を計算]
Budget --> Fit{予算内?}
Fit -->|Yes| Full[全文または要求範囲を返す]
Fit -->|No| Cut[先頭側を返し、自動切り詰め注記を付ける]
Cut --> Hint[続きを読む offset / byte_offset / grep 等を案内]
```
予算計算:
- `ContextManager` がある場合: `getAvailableTokens()` の 50% を 1 回の tool result 予算にする
- 予算の上限: 60,000 tokens
- 予算の下限: 最低返却 tokens を確保
- `Read` は行指向なら行境界、改行が少ないファイルは byte/char 境界で切ります
- `Bash``head` / `tail` / `grep` / `awk` / `sed` などで絞る案内を付けます
## よくあるパターン
### 1. 通常の新規タスク
```mermaid
flowchart TD
Create[タスク作成] --> Recent[直近コメント最大5件 + workspace 状況]
Recent --> Initial[initial_movement]
Initial --> Tools[必要なファイルだけツールで読む]
Tools --> Transition[transition]
Transition --> Next[次 movement]
```
この場合、過去コメント全文やファイル本文は最初から全部送られません。
### 2. ユーザーへの ASK 後に再開
```mermaid
flowchart TD
Ask[transition: ASK] --> Wait[Job waiting_human]
Wait --> Reply[ユーザーが返信]
Reply --> Queue[Job queued]
Queue --> Worker[Worker が再取得]
Worker --> Context[直近コメント最大5件を再構築]
Context --> Resume[resumeMovement から再開]
```
ASK 再開時も、基本は直近コメントから再構築します。前回 movement 内の LLM メッセージ全文を保存して再投入する方式ではありません。
### 3. 長いファイルを読む
```mermaid
flowchart TD
Read[Read large file] --> Budget{tool result 予算}
Budget --> Truncated[自動切り詰め]
Truncated --> Notice[続きの読み方を表示]
Notice --> Targeted[必要範囲だけ再 Read / Grep]
```
長文を読む場合は、全文を一度に入れるより、検索や範囲指定で必要箇所を絞る前提です。
### 4. コンテキストが 95% を超える
```mermaid
flowchart TD
High[Context 95%以上] --> Force[force_transition]
Force --> HasDefault{defaultNext あり?}
HasDefault -->|Yes| Next[defaultNext へ移動]
HasDefault -->|No| Abort[ABORT]
Next --> Runner[piece-runner が遷移判定を続行]
```
これは現状の退避動作です。圧縮サマリを作って同じ movement を継続する動作は未実装です。
## 参照実装
| 内容 | ファイル |
|------|----------|
| 新規タスクの `enrichedInstruction` 構築 | `src/worker.ts` |
| Piece / movement 遷移、Lessons 注入 | `src/engine/piece-runner.ts` |
| movement 内の LLM messages と tool result 追加 | `src/engine/agent-loop.ts` |
| コンテキスト閾値管理 | `src/engine/context-manager.ts` |
| Read / Bash 等の tool result 切り詰め | `src/engine/tools/core.ts` |
+151
View File
@@ -0,0 +1,151 @@
# Agent Orchestrator — Design System
A local, single-tenant agent orchestration platform. Users submit natural-language tasks via a small Japanese-language admin UI; an LLM classifier routes each task into a named **Piece** (workflow), which runs a multi-step **Movement** chain against local Ollama workers and emits progress/results back to the UI as chat-style comments.
## Product at a glance
- **One product, one surface:** a React + Vite + TailwindCSS admin dashboard at `/ui/` on the orchestrator server (`http://this-machine:9876/ui/`). Mobile (single-column), tablet (list + chat), desktop (list + chat + detail) layouts.
- **Language:** Japanese throughout (UI labels, empty states, toasts). Latin/mono treatment reserved for identifiers, status keywords, version tags, wordmark.
- **Primary objects:** Task → Job → Movement → Tool call. Status kanban: `queued / running / waiting_human / waiting_subtasks / retry / succeeded / failed / cancelled`.
- **Interaction model:** a threaded chat with progress cards interleaved between user requests and agent results (green = result, amber = ASK, blue bubble = user, grey pill card = progress).
## Sources consulted
- **Codebase (read-only local mount):** `gitea-agent-orchestrator/`
- `ui/tailwind.config.js`, `ui/src/index.css` — tokens, fonts
- `ui/src/lib/utils.ts``statusTone()`, `relativeTime()`, activity parsing
- `ui/src/components/**` — TopBar, FilterBar, TaskListItem, ChatPane, ChatMessage, DetailHeader, OverviewTab, ProgressTab, CreateTaskDialog, EmptyState, StatChip, StatusBadge, LoadingSpinner
- `ui/public/favicon.svg` — the "hub + 3 agents" mark
- `README.md` (root) — product narrative, piece list, tool registry
- **No Figma, no slide templates, no marketing site** were provided; this design system documents the existing admin UI only.
## Index
| File | Purpose |
|---|---|
| `README.md` | This document |
| `SKILL.md` | Agent Skill wrapper (portable to Claude Code) |
| `colors_and_type.css` | CSS variables for color/type/spacing/radii/shadow |
| `assets/logo.svg` | 32×32 brand mark (blue rounded square, orchestrator hub + 3 agent nodes) |
| `assets/wordmark.svg` | Logo + "AGENT ORCHESTRATOR" mono wordmark lockup |
| `preview/*.html` | Atomic design-system cards (registered in review) |
| `ui_kits/admin/` | High-fidelity React recreation of the admin dashboard |
---
## Content fundamentals
**Voice.** Terse, functional, Japanese. Almost no marketing flourish. Labels are nouns or imperative verbs — "新しい依頼" (new request), "Task 作成" (create Task), "詳細" (details), "送信" (send), "共有停止" (stop sharing).
**Person.** Neither "あなた" nor "私" — the UI talks about objects, not people. Empty state uses a numbered list of actions ("左パネルからタスクを選択する" — "select a task from the left panel") rather than second-person.
**Case & casing.**
- Japanese sentences where there's human prose.
- English identifiers kept English, capitalized as in code: **Tasks / Schedules / Settings / Users** (TopBar navs), **Inbox / Running / Waiting / Subtasks / Retry / Done / Failed / Cancelled** (status columns). These are not translated, even in a Japanese UI.
- Meta labels are SMALL-CAPS UPPERCASE with wide tracking in the mono face: `STEP`, `TOOL`, `PREVIEW`, `FINAL`, `ASK`, `LOG`.
- "Agent Orchestrator" wordmark: uppercase, mono, letter-spacing: 0.16em, blue-600.
- Product name "agent-orchestrator" in kebab-case in docs; UI header is Title Case.
**Example strings** (verbatim from code):
- `新しい依頼` (new request) — primary CTA
- `スレッドを選択してください` (please select a thread) — empty state title
- `左の一覧から選ぶと、会話、進捗、成果物を追えます。` — empty state description
- `メッセージを入力... (Ctrl+Enter で送信)` — composer placeholder
- `まだ進行情報がありません。` (no progress info yet)
- `(activity.log がまだありません)` — empty log fallback
- `良かった` / `改善が必要` — feedback thumbs labels
**Numbers & units.** Counts render as `<bold number> 件` (items), `<bold number> 実行中` (running), `<bold number> 待機` (waiting). Relative time in Japanese: `たった今 / N分前 / N時間前 / N日前`. Durations mix units: `ms`, `s`, `m Ns`.
**Tone.** Operator-facing, not consumer-facing. No exclamation marks, no emoji in UI chrome. Emoji appear only as **domain signals inside agent content**: 👍 / 👎 on feedback buttons, 📋 on checklist progress cards. Unicode symbols (☑ ✗ ⊘ ☐ ▶) are used as list markers inside agent-emitted checklists. Do not introduce new emoji outside these established spots.
---
## Visual foundations
**Palette.** A near-monochromatic slate neutral scale (Tailwind `slate-50…900`) carrying the entire surface, with **#2563eb (blue-600)** as the only brand color for primary actions, active states, focus rings, and the logo. Semantic status pills add pastel bg / deep fg pairs: green (running/success), amber (waiting/retry), indigo (subtasks), red (failed), blue (succeeded/queued edge cases), slate (queued/cancelled). All defined verbatim in `statusTone()` in `ui/src/lib/utils.ts`.
**Type.** `IBM Plex Sans JP` for everything, `IBM Plex Mono` for identifiers, log output, version tags, wordmark, cron expressions, and the micro-label uppercase treatment. Body is **13px** — small and dense. Titles jump to 18px (detail) or 20px (dialog); chat bubbles are 14px leading-relaxed. Mobile forces input `font-size: 16px !important` to prevent iOS auto-zoom.
- *Font substitution note:* IBM Plex is already loaded from Google Fonts in the codebase; no substitution required.
**Weight.** Heavy. 700 ("bold") is the workhorse — buttons, pill labels, status chips, even 10px/11px micro labels. 800 ("extra-bold") is reserved for titles and primary CTAs. 400/500 appear in body copy only.
**Spacing.** Tailwind 4px scale. Gutters between panels are `8px` (`p-2 gap-2`). Cards pad `16px` (`p-4`). Buttons pad `6px 10px` (small chips), `8px 16px` (primary). The desktop layout is a 3-column grid: `clamp(240px, 22vw, 280px)` list / flexible chat / `clamp(280px, 26vw, 440px)` detail (or `clamp(360px, 30vw, 560px)` wide).
**Backgrounds.** Flat solid colors — **never gradients**. App root is `#f3f6fb` (between slate-50 and slate-100); content cards are white. Activity log switches to an inverted surface: `bg-slate-900 text-slate-100` as a "terminal" zone. No illustrations, no patterns, no photos, no blur/glassmorphism.
**Animation.** Minimal and purposeful. Only three motion idioms:
1. `transition-colors` on hover/active states (Tailwind default ~150ms).
2. `animate-pulse` on a 2px blue dot while a job is running.
3. `animate-spin` on the loading spinner (2px slate-200 border, blue-600 top-border).
No fades, no slides, no springs, no bounces. Expand/collapse caret rotates 90° (`rotate-90`) on click.
**Hover states.** Buttons and list items darken one step: transparent → `bg-slate-100`, `bg-blue-600``bg-blue-700`, border-slate-200 → border-slate-300. Text links: `hover:underline` only on small text actions. No scale, no shadow-lift.
**Press / active states.** Active navigation uses **filled accent**: `bg-blue-600 text-white`. Active filter pills use **tinted** style: `border-blue-600 bg-blue-50 text-blue-700`. Active list item: `border-blue-500 bg-blue-50`. No shrink, no darken-further.
**Focus.** `focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500`. Inputs on focus: `focus:border-blue-400 focus:ring-2 focus:ring-blue-100`.
**Borders.** 1px, `#e2e8f0` (slate-200) by default. Active states promote to `slate-300` (hover) or `blue-500/600` (selected). Chat bubbles have no border on the user (blue-filled) bubble but do on ask/result bubbles to soften the pastel.
**Shadows.** Two tiers only:
- `shadow-sm` — resting card (every panel, chip, list item). Nearly invisible, but cards in Vite have it.
- `shadow-2xl` — Dialog overlay only.
No colored shadows, no inner shadows, no glows.
**Radii (heavy rounding).** The product reads "rounded" first, flat second.
- `rounded-lg` (8px): small selects, small buttons, rotating caret container, log surface.
- `rounded-xl` (12px): **default** — cards, buttons, inputs, list items, panels.
- `rounded-2xl` (16px): dialogs, chat bubbles (tail reduced to `rounded-br-md` / `rounded-bl-md`).
- `rounded-full` (9999px): status pills, filter tabs, avatar, pulse dot.
**Transparency & blur.** Modal overlay uses `bg-slate-900/50` or `bg-black/40` — straight alpha, no backdrop-blur. No frosted chrome anywhere.
**Cards.** White fill, 1px slate-200 border, `rounded-xl`, `shadow-sm`. Padded `p-3` or `p-4`. The admin list panel is itself a card; list items inside are nested mini-cards with the same recipe.
**Chat bubbles.**
- User: `rounded-2xl rounded-br-md`, `bg-blue-600 text-white`, `shadow`.
- Agent ASK: `rounded-2xl rounded-bl-md`, `bg-amber-50 border border-amber-200`, `shadow-sm`.
- Agent RESULT: `rounded-2xl rounded-bl-md`, `bg-green-50 border border-green-200`, `shadow-sm`, renders Markdown.
- Progress card: centered, `bg-slate-50 border border-slate-200 rounded-xl`, 12px slate-500 text, click-to-expand.
**Protection gradients vs capsules.** Never gradients. Always capsules/pills for chips and status, always rectangular cards for containers.
**Imagery vibe.** The brand has no photography. The single branded image is the `favicon.svg`: a rounded blue square with a white central "hub" and three satellite nodes connected by thin 55%-opacity white lines — a literal orchestrator-connecting-workers glyph. Keep this as the only decorative asset unless explicitly asked.
**Layout rules.** Fixed TopBar at top (white, slate-200 bottom border). Main content fills remaining `h-dvh` and is `overflow-hidden` at the root — panels handle their own scroll. Toasts slide in at the top-center (`mx-4 mt-2`). Dialogs center-screen, `max-width: min(860px, 92vw)`, `max-height: 88dvh`, scroll internally.
---
## Iconography
**No icon font, no icon library dependency.** Icons are inline SVG written directly in components, drawn at `w-3.5 h-3.5`, `w-4 h-4`, or `w-5 h-5`, stroke-based, `stroke-width="2"`, `strokeLinecap="round"`, `strokeLinejoin="round"`, `fill="none"`. Style is close to **Lucide / Feather** — 2px stroke, round caps, 24×24 viewBox, minimal. Example glyphs in source: magnifying-glass (search), paperclip (attach), cross (close), VNC monitor square, chevron-right caret.
**Substitution policy.** When extending the system, prefer **Lucide** (`https://unpkg.com/lucide-static`) or hand-write a 2px-stroke, round-cap, 24×24 SVG inline. Do **not** introduce Heroicons solid, Material, or Phosphor — those break the line-weight consistency.
**Unicode glyphs** are used functionally inside agent-generated content:
- `☑ ✗ ⊘ ☐` — checklist item states (done / failed / skipped / pending)
- `▶` — expand caret (rotates to down)
- `×` — close buttons within attachment chips
- `✕` — mobile dialog close
- `+` / `` — add/create indicators
- `·` — meta separator (`worker: … · mode: …`)
**Emoji.** Deliberately limited:
- 👍 / 👎 — feedback rating only
- 📋 — checklist progress card header only
Do not introduce new emoji. When agent markdown renders emoji, the `prose` plugin styles them at 14px inline — do not restyle.
**Logo usage.** The 32×32 favicon is the only mark. Minimum size 16×16. Clear space: `x/4` on all sides where `x` is the square's edge. Do not recolor the blue fill; if placing on blue, invert to a white square with blue contents.
---
## Component notes (see `ui_kits/admin/`)
The admin UI kit recreates: TopBar, FilterBar, TaskListItem, TaskListPanel, ChatPane, ChatMessage (user/ask/result/progress variants), DetailPanel with tab pills, StatusBadge, StatChip, EmptyState, LoadingSpinner, CreateTaskDialog, and the composite desktop 3-column layout.
## Caveats
- No external brand guide, marketing site, or Figma was provided — this system is derived strictly from the live admin UI source.
- No printed/decorative imagery exists in the codebase; the only "brand asset" is the favicon logo.
- Fonts load from Google Fonts CDN (same as production); no local TTFs needed.
+128
View File
@@ -0,0 +1,128 @@
/* Agent Orchestrator — Colors & Type
Extracted from gitea-agent-orchestrator/ui (Tailwind config + index.css + usage).
Palette: slate neutrals + #2563eb blue accent, with pastel semantic chips.
*/
@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;700&family=IBM+Plex+Sans+JP:wght@400;500;600;700;800&display=swap');
:root {
/* —— Brand / Accent ———————————————————————— */
--accent: #2563eb; /* blue-600 — primary action, active tab, focus ring */
--accent-deep: #1d4ed8; /* blue-700 — hover on primary button */
--accent-50: #eff6ff;
--accent-100: #dbeafe;
--accent-200: #bfdbfe;
--accent-500: #3b82f6;
--accent-700: #1d4ed8;
/* —— Ink / Neutrals (slate scale) ————————————— */
--ink: #0f172a; /* slate-900 — primary text */
--muted: #64748b; /* slate-500 — secondary text */
--slate-50: #f8fafc; /* body surface */
--slate-100: #f1f5f9; /* scrollbar track, chip bg */
--slate-200: #e2e8f0; /* default border */
--slate-300: #cbd5e1;
--slate-400: #94a3b8;
--slate-500: #64748b;
--slate-600: #475569;
--slate-700: #334155;
--slate-800: #1e293b;
--slate-900: #0f172a;
--app-bg: #f3f6fb; /* root bg (index.html) */
/* —— Semantic status tones (from statusTone()) ————
bg / fg pairs used in status pills and cards. */
--status-running-bg: #dcfce7; --status-running-fg: #166534; /* green */
--status-waiting-bg: #fef9c3; --status-waiting-fg: #854d0e; /* amber */
--status-subtasks-bg: #e0e7ff; --status-subtasks-fg: #3730a3; /* indigo */
--status-failed-bg: #fee2e2; --status-failed-fg: #b91c1c; /* red */
--status-succeeded-bg: #dbeafe; --status-succeeded-fg: #1e40af; /* blue */
--status-retry-bg: #fef3c7; --status-retry-fg: #92400e; /* amber-deep */
--status-queued-bg: #e2e8f0; --status-queued-fg: #475569; /* slate */
/* Message bubbles (ChatMessage.tsx) */
--bubble-user-bg: #2563eb; --bubble-user-fg: #ffffff;
--bubble-ask-bg: #fffbeb; --bubble-ask-border: #fde68a; /* amber-50/200 */
--bubble-result-bg: #f0fdf4; --bubble-result-border: #bbf7d0; /* green-50/200 */
/* Log / terminal surface (ProgressTab) */
--log-bg: #0f172a;
--log-fg: #f1f5f9;
/* —— Typography ——————————————————————————— */
--font-sans: 'IBM Plex Sans JP', 'Hiragino Sans', -apple-system, BlinkMacSystemFont, sans-serif;
--font-mono: 'IBM Plex Mono', ui-monospace, Menlo, monospace;
/* UI base is small & dense — 13px body, mobile auto-zooms to 16. */
--fs-10: 10px; /* micro labels, version tag */
--fs-11: 11px; /* meta, pill labels, timestamps */
--fs-12: 12px; /* secondary body, nav labels */
--fs-13: 13px; /* DEFAULT body */
--fs-14: 14px; /* chat bubble body */
--fs-15: 15px; /* chat header */
--fs-18: 18px; /* detail title */
--fs-20: 20px; /* dialog title (xl) */
--fw-regular: 400;
--fw-medium: 500;
--fw-bold: 700; /* used aggressively — even small chips are bold */
--fw-extra: 800; /* titles and headers */
/* —— Radii (very rounded) ————————————————————— */
--radius-sm: 8px; /* rounded-lg — small buttons, selects, log surface */
--radius-md: 12px; /* rounded-xl — DEFAULT card/button/input — everywhere */
--radius-lg: 16px; /* rounded-2xl — dialogs, chat bubbles */
--radius-pill: 9999px; /* status pills, filter tabs, avatar */
/* —— Shadow system ————————————————————————— */
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05); /* card resting */
--shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
--shadow-2xl: 0 25px 50px -12px rgb(0 0 0 / 0.25); /* modal */
/* —— Spacing scale (Tailwind units × 4) ————————— */
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 20px;
--space-6: 24px;
--space-8: 32px;
/* —— Borders ——————————————————————————————— */
--border-default: 1px solid var(--slate-200);
--border-active: 1px solid var(--accent);
/* —— Semantic aliases ———————————————————————— */
--fg-1: var(--slate-900);
--fg-2: var(--slate-600);
--fg-3: var(--slate-500);
--fg-muted: var(--slate-400);
--bg-1: #ffffff;
--bg-2: var(--slate-50);
--bg-app: var(--app-bg);
--border: var(--slate-200);
}
/* —— Base type roles ————————————————————————— */
html, body { font-family: var(--font-sans); color: var(--fg-1); background: var(--bg-app); }
body { font-size: var(--fs-13); }
.h1 { font-size: var(--fs-20); font-weight: var(--fw-extra); color: var(--fg-1); letter-spacing: -0.01em; }
.h2 { font-size: var(--fs-18); font-weight: var(--fw-extra); color: var(--fg-1); }
.h3 { font-size: var(--fs-15); font-weight: var(--fw-bold); color: var(--fg-1); }
.h4 { font-size: var(--fs-13); font-weight: var(--fw-bold); color: var(--fg-1); }
.p { font-size: var(--fs-13); color: var(--fg-2); line-height: 1.55; }
.meta { font-size: var(--fs-11); color: var(--fg-3); }
.micro { font-size: var(--fs-10); color: var(--fg-muted); text-transform: uppercase; letter-spacing: 0.08em; font-weight: var(--fw-bold); }
.mono { font-family: var(--font-mono); }
.code { font-family: var(--font-mono); font-size: var(--fs-12); background: var(--slate-100); padding: 2px 6px; border-radius: 6px; }
/* The signature "Agent Orchestrator" wordmark style used in TopBar */
.wordmark {
font-family: var(--font-mono);
font-size: var(--fs-11);
font-weight: var(--fw-bold);
color: var(--accent);
text-transform: uppercase;
letter-spacing: 0.16em;
}
@@ -0,0 +1,128 @@
// ChatPane — mirrors ui/src/components/chat/* with user/ask/result/progress bubbles
function Bubble({ role, children, footer }) {
const isUser = role === 'user';
const style = {
maxWidth: '85%',
padding: '10px 14px',
borderRadius: 16,
fontSize: 13,
lineHeight: 1.55,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
};
if (isUser) {
Object.assign(style, { background: '#2563eb', color: '#fff', borderBottomRightRadius: 4, alignSelf: 'flex-end' });
} else if (role === 'ask') {
Object.assign(style, { background: '#fef9c3', color: '#854d0e', border: '1px solid #fde68a', borderBottomLeftRadius: 4 });
} else if (role === 'result') {
Object.assign(style, { background: '#ecfdf5', color: '#065f46', border: '1px solid #a7f3d0', borderBottomLeftRadius: 4 });
} else {
Object.assign(style, { background: '#fff', color: '#0f172a', border: '1px solid #e2e8f0', borderBottomLeftRadius: 4 });
}
return (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: isUser ? 'flex-end' : 'flex-start', gap: 4 }}>
<div style={style}>{children}</div>
{footer && <div style={{ fontSize: 10, color: '#94a3b8' }}>{footer}</div>}
</div>
);
}
function ProgressBubble({ text }) {
return (
<div style={{
alignSelf: 'flex-start', background: '#f1f5f9', color: '#475569',
padding: '8px 12px', borderRadius: 12, fontSize: 12,
display: 'inline-flex', alignItems: 'center', gap: 8,
}}>
<Spinner />
<span>{text}</span>
</div>
);
}
function ChatHeader({ task, onOpenDetail, detailOpen }) {
return (
<div style={{
flexShrink: 0, padding: '12px 16px', borderBottom: '1px solid #e2e8f0', background: '#fff',
display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12,
}}>
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 10, fontFamily: 'IBM Plex Mono, monospace', color: '#94a3b8', letterSpacing: '.08em' }}>
TASK #{task.id}
</div>
<div style={{ fontSize: 15, fontWeight: 700, color: '#0f172a', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{task.title}
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<StatusBadge status={task.status} />
<button onClick={onOpenDetail} style={{
padding: '6px 10px', borderRadius: 8, border: '1px solid #e2e8f0',
background: detailOpen ? '#eff6ff' : '#fff',
color: detailOpen ? '#1d4ed8' : '#475569',
fontSize: 12, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit',
}}>詳細</button>
</div>
</div>
);
}
function Composer({ onSend }) {
const [text, setText] = React.useState('');
const send = () => { if (!text.trim()) return; onSend(text.trim()); setText(''); };
return (
<div style={{ flexShrink: 0, borderTop: '1px solid #e2e8f0', background: '#fff', padding: 12 }}>
<div style={{
display: 'flex', alignItems: 'flex-end', gap: 8, background: '#f8fafc',
border: '1px solid #e2e8f0', borderRadius: 12, padding: 8,
}}>
<button style={{ padding: 6, background: 'transparent', border: 'none', color: '#94a3b8', cursor: 'pointer' }}>
<IconAttach width={16} height={16} />
</button>
<textarea
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { e.preventDefault(); send(); } }}
rows={2}
placeholder="メッセージを入力 (⌘+Enter で送信)"
style={{
flex: 1, resize: 'none', border: 'none', outline: 'none', background: 'transparent',
fontFamily: 'inherit', fontSize: 13, color: '#0f172a', lineHeight: 1.5, minHeight: 32,
}}
/>
<button onClick={send} style={{
padding: '6px 14px', background: '#2563eb', color: '#fff', borderRadius: 8,
fontSize: 12, fontWeight: 700, border: 'none', cursor: text.trim() ? 'pointer' : 'not-allowed',
opacity: text.trim() ? 1 : 0.5, fontFamily: 'inherit',
}}>送信</button>
</div>
<div style={{ marginTop: 6, fontSize: 10, color: '#94a3b8', paddingLeft: 4 }}>エージェントは常に /brainstorm /plan /implement のパイプラインで動作します</div>
</div>
);
}
function ChatPane({ task, messages, onSend, onOpenDetail, detailOpen }) {
const scrollRef = React.useRef(null);
React.useEffect(() => {
if (scrollRef.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}, [messages.length, task.id]);
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', background: '#f8fafc' }}>
<ChatHeader task={task} onOpenDetail={onOpenDetail} detailOpen={detailOpen} />
<div ref={scrollRef} style={{
flex: 1, overflowY: 'auto', padding: '16px 20px',
display: 'flex', flexDirection: 'column', gap: 12, minHeight: 0,
}}>
{messages.map((m, i) => (
m.role === 'progress'
? <ProgressBubble key={i} text={m.content} />
: <Bubble key={i} role={m.role} footer={m.footer}>{m.content}</Bubble>
))}
</div>
<Composer onSend={onSend} />
</div>
);
}
window.ChatPane = ChatPane;
@@ -0,0 +1,134 @@
// DetailPanel — tabs: overview, progress (activity + log surface)
function Tabs({ tab, onTab }) {
const items = [
{ id: 'overview', label: '概要' },
{ id: 'progress', label: '進捗' },
{ id: 'subtasks', label: 'サブタスク' },
];
return (
<div style={{ display: 'flex', gap: 4, padding: '8px 12px', borderBottom: '1px solid #e2e8f0', background: '#fff' }}>
{items.map(it => (
<button key={it.id} onClick={() => onTab(it.id)} style={{
padding: '6px 12px', borderRadius: 8, fontSize: 12, fontWeight: 600,
border: 'none', cursor: 'pointer', fontFamily: 'inherit',
background: tab === it.id ? '#eff6ff' : 'transparent',
color: tab === it.id ? '#1d4ed8' : '#64748b',
}}>{it.label}</button>
))}
</div>
);
}
function OverviewTab({ task }) {
const Row = ({ label, value }) => (
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, padding: '8px 0', borderBottom: '1px solid #f1f5f9', fontSize: 12 }}>
<span style={{ color: '#64748b' }}>{label}</span>
<span style={{ color: '#0f172a', fontWeight: 600, textAlign: 'right' }}>{value}</span>
</div>
);
return (
<div style={{ padding: 16, overflowY: 'auto', fontSize: 13, color: '#0f172a' }}>
<div style={{ fontSize: 10, fontFamily: 'IBM Plex Mono, monospace', color: '#94a3b8', letterSpacing: '.08em' }}>DESCRIPTION</div>
<div style={{ marginTop: 6, color: '#334155', lineHeight: 1.6, fontSize: 13 }}>{task.body}</div>
<div style={{ marginTop: 16, display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<StatChip label="試行" value={`${task.attempts}/3`} />
<StatChip label="ピース" value={task.piece} color="#2563eb" />
<StatChip label="ワーカー" value={task.worker || '—'} />
</div>
<div style={{ marginTop: 16 }}>
<Row label="リポジトリ" value={<span style={{ fontFamily: 'IBM Plex Mono, monospace', fontSize: 11 }}>{task.repo}</span>} />
<Row label="ブランチ" value={<span style={{ fontFamily: 'IBM Plex Mono, monospace', fontSize: 11 }}>{task.branch}</span>} />
<Row label="作成日時" value={new Date(task.createdAt).toLocaleString('ja-JP')} />
<Row label="更新日時" value={relativeTime(task.updatedAt)} />
<Row label="担当" value={task.assignee} />
</div>
<div style={{ marginTop: 16, display: 'flex', gap: 8 }}>
<button style={{
padding: '8px 14px', borderRadius: 10, border: '1px solid #e2e8f0',
background: '#fff', color: '#475569', fontSize: 12, fontWeight: 600,
cursor: 'pointer', fontFamily: 'inherit', whiteSpace: 'nowrap',
}}>再試行</button>
<button style={{
padding: '8px 14px', borderRadius: 10, border: '1px solid #fecaca',
background: '#fff', color: '#b91c1c', fontSize: 12, fontWeight: 600,
cursor: 'pointer', fontFamily: 'inherit', whiteSpace: 'nowrap',
}}>キャンセル</button>
</div>
</div>
);
}
function ProgressTab({ task }) {
const events = task.events || [];
return (
<div style={{ padding: 16, overflowY: 'auto' }}>
<div style={{ fontSize: 10, fontFamily: 'IBM Plex Mono, monospace', color: '#94a3b8', letterSpacing: '.08em', marginBottom: 8 }}>ACTIVITY</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, marginBottom: 20 }}>
{events.map((e, i) => (
<div key={i} style={{ display: 'flex', gap: 10, fontSize: 12 }}>
<div style={{ flexShrink: 0, marginTop: 3 }}>
<div style={{ width: 8, height: 8, borderRadius: 9999, background: e.kind === 'error' ? '#dc2626' : e.kind === 'ok' ? '#16a34a' : '#3b82f6' }} />
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ color: '#0f172a', fontWeight: 600 }}>{e.label}</div>
<div style={{ color: '#64748b', fontSize: 11 }}>{e.meta}</div>
</div>
<div style={{ fontSize: 10, color: '#94a3b8', fontFamily: 'IBM Plex Mono, monospace' }}>{e.time}</div>
</div>
))}
</div>
<div style={{ fontSize: 10, fontFamily: 'IBM Plex Mono, monospace', color: '#94a3b8', letterSpacing: '.08em', marginBottom: 6 }}>ACTIVITY.LOG</div>
<div style={{
background: '#0f172a', color: '#e2e8f0',
fontFamily: 'IBM Plex Mono, monospace', fontSize: 11, lineHeight: 1.6,
padding: 12, borderRadius: 8, whiteSpace: 'pre', overflowX: 'auto',
}}>
{`[10:42:18] ` + String.fromCharCode(9432) + ` starting worker for task #` + task.id + `
[10:42:19] ` + String.fromCharCode(9432) + ` branch: ` + task.branch + `
[10:42:21] ` + String.fromCharCode(9432) + ` piece: ` + task.piece + `
[10:42:22] ` + String.fromCharCode(9655) + ` /brainstorm
[10:43:04] ` + String.fromCharCode(10003) + ` /plan (12 steps)
[10:43:05] ` + String.fromCharCode(9655) + ` /implement
[10:44:58] ` + String.fromCharCode(10003) + ` tests passed`}
</div>
</div>
);
}
function DetailPanel({ task, onClose }) {
const [tab, setTab] = React.useState('overview');
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', background: '#f8fafc', borderLeft: '1px solid #e2e8f0' }}>
<div style={{
flexShrink: 0, padding: '12px 16px', borderBottom: '1px solid #e2e8f0', background: '#fff',
display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8,
}}>
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 10, fontFamily: 'IBM Plex Mono, monospace', color: '#94a3b8', letterSpacing: '.08em' }}>DETAIL</div>
<div style={{ fontSize: 13, fontWeight: 700, color: '#0f172a', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>#{task.id} {task.title}</div>
</div>
<button onClick={onClose} style={{
width: 28, height: 28, borderRadius: 8, border: '1px solid #e2e8f0',
background: '#fff', color: '#64748b', cursor: 'pointer', display: 'inline-flex',
alignItems: 'center', justifyContent: 'center',
}}><IconClose width={12} height={12} /></button>
</div>
<Tabs tab={tab} onTab={setTab} />
<div style={{ flex: 1, minHeight: 0, overflow: 'hidden' }}>
{tab === 'overview' && <OverviewTab task={task} />}
{tab === 'progress' && <ProgressTab task={task} />}
{tab === 'subtasks' && (
<div style={{ padding: 16, fontSize: 13, color: '#64748b' }}>
サブタスクはこのタスクにありません
</div>
)}
</div>
</div>
);
}
window.DetailPanel = DetailPanel;
@@ -0,0 +1,86 @@
// Shared small primitives for the admin UI kit.
// Status tone + tiny SVG icons + labels matching the codebase.
const STATUS_LABELS = {
queued: 'Inbox', running: 'Running', waiting_human: 'Waiting',
waiting_subtasks: 'Subtasks', retry: 'Retry', succeeded: 'Done',
failed: 'Failed', cancelled: 'Cancelled',
};
const STATUS_TONE = {
running: { bg: '#dcfce7', fg: '#166534' },
waiting_human: { bg: '#fef9c3', fg: '#854d0e' },
waiting_subtasks: { bg: '#e0e7ff', fg: '#3730a3' },
failed: { bg: '#fee2e2', fg: '#b91c1c' },
succeeded: { bg: '#dbeafe', fg: '#1e40af' },
retry: { bg: '#fef3c7', fg: '#92400e' },
queued: { bg: '#e2e8f0', fg: '#475569' },
cancelled: { bg: '#e2e8f0', fg: '#475569' },
};
function StatusBadge({ status, small }) {
const tone = STATUS_TONE[status] || STATUS_TONE.queued;
const label = STATUS_LABELS[status] || status;
const style = {
background: tone.bg, color: tone.fg,
fontSize: small ? 10 : 11, fontWeight: 700,
padding: small ? '1px 8px' : '2px 10px', borderRadius: 9999,
display: 'inline-flex', alignItems: 'center', whiteSpace: 'nowrap',
};
return <span style={style}>{label}</span>;
}
function StatChip({ label, value, color }) {
return (
<div style={{
background: '#fff', border: '1px solid #e2e8f0', borderRadius: 12,
padding: '8px 12px', boxShadow: '0 1px 2px 0 rgb(0 0 0 / 0.05)',
minWidth: 0, flex: '1 1 0', minWidth: 80,
}}>
<div style={{ fontSize: 10, fontWeight: 700, color: '#64748b', letterSpacing: '.06em', textTransform: 'uppercase', whiteSpace: 'nowrap' }}>{label}</div>
<div style={{
fontSize: 15, fontWeight: 800, color: color || '#0f172a', marginTop: 2,
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}}>{value}</div>
</div>
);
}
function Spinner() {
return <div style={{
width: 16, height: 16, border: '2px solid #e2e8f0', borderTopColor: '#2563eb',
borderRadius: '9999px', animation: 'ao-spin 1s linear infinite', display: 'inline-block',
}} />;
}
function PulseDot() {
return <span style={{
display: 'inline-block', width: 8, height: 8, background: '#3b82f6',
borderRadius: 9999, animation: 'ao-pulse 1.2s ease-in-out infinite',
}} />;
}
function IconSearch(props) {
return <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" {...props}><path d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg>;
}
function IconAttach(props) {
return <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" {...props}><path d="M21.44 11.05l-9.19 9.19a6 6 0 01-8.49-8.49l9.19-9.19a4 4 0 015.66 5.66l-9.2 9.19a2 2 0 01-2.83-2.83l8.49-8.48"/></svg>;
}
function IconClose(props) {
return <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" {...props}><path d="M4 4l8 8M12 4l-8 8"/></svg>;
}
function relativeTime(ms) {
const mins = Math.floor((Date.now() - ms) / 60000);
if (mins < 1) return 'たった今';
if (mins < 60) return `${mins}分前`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `${hrs}時間前`;
return `${Math.floor(hrs / 24)}日前`;
}
Object.assign(window, {
STATUS_LABELS, STATUS_TONE,
StatusBadge, StatChip, Spinner, PulseDot,
IconSearch, IconAttach, IconClose, relativeTime,
});
@@ -0,0 +1,12 @@
# Admin Dashboard UI Kit
High-fidelity recreation of the Agent Orchestrator admin UI (`ui/` in the codebase).
- `index.html` — interactive 3-column dashboard: task list, chat, detail panel. Click a task to open chat; open detail to see Overview / Progress tabs.
- `TopBar.jsx` — top navigation with wordmark, section nav, status counts, primary CTA
- `TaskList.jsx` — FilterBar + LocalTaskListItem
- `ChatPane.jsx` — header + messages + composer with user / ask / result / progress bubbles
- `DetailPanel.jsx` — tabbed detail with Overview + Progress (activity timeline + log surface)
- `Primitives.jsx` — StatusBadge, StatChip, tiny SVG icons, spinner, pulse dot
Cosmetic recreation — no real API. Data is inline sample Japanese task data.
@@ -0,0 +1,92 @@
// TaskList — FilterBar + LocalTaskListItem recreation
function FilterBar({ status, onStatus, search, onSearch, sort, onSort, counts, total }) {
const columns = ['queued', 'running', 'waiting_human', 'waiting_subtasks', 'retry', 'succeeded', 'failed', 'cancelled'];
const chipStyle = (active) => ({
flexShrink: 0, padding: '6px 10px', borderRadius: 9999,
fontSize: 11, fontWeight: 700, whiteSpace: 'nowrap', cursor: 'pointer',
border: '1px solid ' + (active ? '#2563eb' : '#e2e8f0'),
background: active ? '#eff6ff' : '#fff',
color: active ? '#1d4ed8' : '#64748b',
fontFamily: 'inherit',
});
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, paddingBottom: 12, borderBottom: '1px solid #e2e8f0' }}>
<div style={{
display: 'flex', alignItems: 'center', gap: 8, background: '#fff', border: '1px solid #e2e8f0',
borderRadius: 12, padding: '6px 12px', boxShadow: '0 1px 2px 0 rgb(0 0 0 / 0.05)',
}}>
<IconSearch width={14} height={14} style={{ color: '#94a3b8', flexShrink: 0 }} />
<input value={search} onChange={(e) => onSearch(e.target.value)} placeholder="検索..."
style={{ flex: 1, border: 'none', outline: 'none', background: 'transparent', fontSize: 13, fontFamily: 'inherit', color: '#0f172a', minWidth: 0 }} />
</div>
<div style={{ display: 'flex', gap: 6, overflowX: 'auto', paddingBottom: 4 }}>
<button style={chipStyle(status === 'all')} onClick={() => onStatus('all')}>
All <span style={{ color: '#94a3b8', marginLeft: 2 }}>{total}</span>
</button>
{columns.map(s => (
<button key={s} style={chipStyle(status === s)} onClick={() => onStatus(s)}>
{STATUS_LABELS[s]} <span style={{ color: '#94a3b8', marginLeft: 2 }}>{counts[s] || 0}</span>
</button>
))}
</div>
<select value={sort} onChange={(e) => onSort(e.target.value)} style={{
padding: '6px 10px', fontSize: 12, background: '#fff', border: '1px solid #e2e8f0',
borderRadius: 8, color: '#334155', outline: 'none', fontFamily: 'inherit',
}}>
<option value="updated">新しい順</option>
<option value="status">ステータス順</option>
<option value="title">タイトル順</option>
</select>
</div>
);
}
function TaskItem({ task, active, onClick }) {
return (
<button onClick={onClick} style={{
width: '100%', textAlign: 'left', padding: '10px 12px', borderRadius: 12,
border: '1px solid ' + (active ? '#3b82f6' : '#e2e8f0'),
background: active ? '#eff6ff' : '#fff',
cursor: 'pointer', transition: 'background .15s', fontFamily: 'inherit',
}}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, minWidth: 0 }}>
<div style={{ fontSize: 13, fontWeight: 700, color: '#0f172a', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
#{task.id} {task.title}
</div>
<StatusBadge status={task.status} small />
</div>
<div style={{ marginTop: 2, fontSize: 11, color: '#64748b', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{task.body.length > 60 ? task.body.slice(0, 60) + '…' : task.body}
</div>
<div style={{ marginTop: 2, fontSize: 10, color: '#94a3b8' }}>{relativeTime(task.updatedAt)}</div>
</button>
);
}
function TaskList({ tasks, activeId, onSelect, filters, setFilters }) {
const counts = {};
for (const s of ['queued', 'running', 'waiting_human', 'waiting_subtasks', 'retry', 'succeeded', 'failed', 'cancelled']) {
counts[s] = tasks.filter(t => t.status === s).length;
}
const filtered = tasks
.filter(t => filters.status === 'all' || t.status === filters.status)
.filter(t => !filters.search || (t.title + t.body).toLowerCase().includes(filters.search.toLowerCase()))
.sort((a, b) => filters.sort === 'title' ? a.title.localeCompare(b.title) : b.updatedAt - a.updatedAt);
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
<FilterBar
status={filters.status} onStatus={(s) => setFilters(f => ({ ...f, status: s }))}
search={filters.search} onSearch={(q) => setFilters(f => ({ ...f, search: q }))}
sort={filters.sort} onSort={(s) => setFilters(f => ({ ...f, sort: s }))}
counts={counts} total={tasks.length}
/>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginTop: 8, overflowY: 'auto', flex: 1, minHeight: 0 }}>
{filtered.map(t => <TaskItem key={t.id} task={t} active={activeId === t.id} onClick={() => onSelect(t.id)} />)}
{filtered.length === 0 && <div style={{ fontSize: 13, color: '#64748b', padding: '12px 8px' }}>スレッドがありません</div>}
</div>
</div>
);
}
window.TaskList = TaskList;
@@ -0,0 +1,62 @@
// TopBar — mirrors ui/src/components/layout/TopBar.tsx
function TopBar({ page, onNavigate, counts, onOpenCreate, user }) {
const navItem = (id, label) => (
<button
key={id}
onClick={() => onNavigate(id)}
style={{
padding: '6px 12px', borderRadius: 8, fontSize: 12, fontWeight: 500,
border: 'none', cursor: 'pointer', fontFamily: 'inherit',
background: page === id ? '#2563eb' : 'transparent',
color: page === id ? '#fff' : '#64748b',
transition: 'background .15s',
}}
>{label}</button>
);
return (
<div style={{
flexShrink: 0, background: '#fff', borderBottom: '1px solid #e2e8f0',
padding: '12px 16px', display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
<img src="../../assets/logo.svg" width="22" height="22" alt="" />
<span style={{
fontFamily: 'IBM Plex Mono, monospace', fontSize: 11, fontWeight: 700,
color: '#2563eb', textTransform: 'uppercase', letterSpacing: '.16em',
}}>Agent Orchestrator</span>
<span style={{ fontFamily: 'IBM Plex Mono, monospace', fontSize: 10, color: '#94a3b8' }}>v1.14.0</span>
<div style={{ display: 'flex', gap: 4 }}>
{navItem('tasks', 'Tasks')}
{navItem('schedules', 'Schedules')}
{navItem('settings', 'Settings')}
{navItem('users', 'Users')}
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<div style={{ fontSize: 11, color: '#64748b', display: 'flex', gap: 6 }}>
<span><b style={{ color: '#334155' }}>{counts.total}</b> </span>
<span><b style={{ color: '#16a34a' }}>{counts.running}</b> 実行中</span>
<span><b style={{ color: '#d97706' }}>{counts.waiting}</b> 待機</span>
{counts.failed > 0 && <span><b style={{ color: '#dc2626' }}>{counts.failed}</b> 失敗</span>}
</div>
{user && (
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<div style={{
width: 24, height: 24, borderRadius: 9999, background: '#dbeafe', color: '#1d4ed8',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center', fontSize: 11, fontWeight: 700,
}}>{user.name.charAt(0)}</div>
<span style={{ fontSize: 12, color: '#475569' }}>{user.name}</span>
</div>
)}
<button onClick={onOpenCreate} style={{
padding: '8px 16px', background: '#2563eb', color: '#fff', borderRadius: 12,
fontSize: 13, fontWeight: 700, border: 'none', cursor: 'pointer', fontFamily: 'inherit',
}}>新しい依頼</button>
</div>
</div>
);
}
window.TopBar = TopBar;
@@ -0,0 +1,231 @@
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8">
<title>Agent Orchestrator — Admin</title>
<link rel="stylesheet" href="../../colors_and_type.css">
<style>
* { box-sizing: border-box; }
html, body { height: 100%; margin: 0; }
body {
font-family: var(--font-sans);
background: var(--bg-app, #f8fafc);
color: var(--fg1, #0f172a);
font-size: 13px;
-webkit-font-smoothing: antialiased;
}
@keyframes ao-spin { from { transform: rotate(0) } to { transform: rotate(360deg) } }
@keyframes ao-pulse { 0%, 100% { opacity: 1 } 50% { opacity: 0.35 } }
::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-thumb { background: #cbd5e1; border: 2px solid #f8fafc; border-radius: 10px; }
::-webkit-scrollbar-thumb:hover { background: #94a3b8; }
</style>
</head>
<body>
<div id="root"></div>
<script src="https://unpkg.com/[email protected]/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
<script src="https://unpkg.com/[email protected]/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
<script src="https://unpkg.com/@babel/[email protected]/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
<script type="text/babel" src="./Primitives.jsx"></script>
<script type="text/babel" src="./TopBar.jsx"></script>
<script type="text/babel" src="./TaskList.jsx"></script>
<script type="text/babel" src="./ChatPane.jsx"></script>
<script type="text/babel" src="./DetailPanel.jsx"></script>
<script type="text/babel">
const MIN = 60 * 1000;
const H = 60 * MIN;
const now = Date.now();
const SAMPLE_TASKS = [
{
id: 412, title: 'Xの朝のAIダイジェスト生成',
body: '毎朝 7:00 JST にフォロー中のAI関連アカウントの過去24hをサマリし、DMで送信する。Twitter CLIを使用し、1スレッドにまとめること。',
status: 'running', piece: 'x-ai-digest', worker: 'worker-03', attempts: 1,
assignee: '@daichi', repo: 'gitea:daichi/agent-orchestrator', branch: 'task/412-morning-digest',
createdAt: now - 2*H, updatedAt: now - 4*MIN,
events: [
{ kind: 'info', label: '/brainstorm 完了', meta: '12個のアイデアを生成', time: '10:42' },
{ kind: 'info', label: '/plan 完了', meta: '12ステップ · 推定 4分', time: '10:43' },
{ kind: 'info', label: '/implement 実行中', meta: 'ステップ 8 / 12', time: '10:45' },
],
},
{
id: 411, title: 'Brave Search の CAPTCHA 回避',
body: 'noVNC経由でBraveに繰り返しCAPTCHAが発生。ユーザーの介入が必要。',
status: 'waiting_human', piece: 'general', worker: 'worker-01', attempts: 2,
assignee: '@daichi', repo: 'gitea:daichi/agent-orchestrator', branch: 'task/411-brave-captcha',
createdAt: now - 3*H, updatedAt: now - 18*MIN,
events: [
{ kind: 'info', label: '/brainstorm 完了', meta: '', time: '08:10' },
{ kind: 'error', label: 'ASK が発行されました', meta: 'CAPTCHAの解決を依頼', time: '08:22' },
],
},
{
id: 410, title: 'GitHub Issue #284 の対応',
body: 'scheduler.ts のタイムアウト処理リファクタ。worker-manager.test.ts を更新。',
status: 'succeeded', piece: 'general', worker: 'worker-02', attempts: 1,
assignee: '@daichi', repo: 'gitea:daichi/agent-orchestrator', branch: 'task/410-sched-timeout',
createdAt: now - 8*H, updatedAt: now - 2*H,
events: [
{ kind: 'info', label: '/plan 完了', meta: '7ステップ', time: '02:11' },
{ kind: 'ok', label: 'PR 作成', meta: '#284 テスト通過', time: '04:08' },
],
},
{
id: 409, title: 'ブレスト: 社内AIエージェント活用事例',
body: '営業部向け、週次の活用アイデアを10件ブレストし、優先順位をつけて提出。',
status: 'queued', piece: 'brainstorming', worker: null, attempts: 0,
assignee: '@tomoko', repo: 'gitea:corp/ops', branch: '—',
createdAt: now - 30*MIN, updatedAt: now - 25*MIN,
events: [],
},
{
id: 408, title: '四半期データの集計とグラフ化',
body: 'Q3のSNSエンゲージメントを集計し、CSVとPNGで出力。',
status: 'waiting_subtasks', piece: 'data-process', worker: 'worker-05', attempts: 1,
assignee: '@kenta', repo: 'gitea:corp/analytics', branch: 'task/408-q3-roundup',
createdAt: now - 5*H, updatedAt: now - 45*MIN,
events: [
{ kind: 'info', label: 'サブタスク3件を発行', meta: '#408-1, #408-2, #408-3', time: '09:30' },
],
},
{
id: 407, title: '競合サービスのリサーチ',
body: 'エージェント型ワーカー系SaaSを3社分析し、比較表を作成する。',
status: 'failed', piece: 'research', worker: 'worker-04', attempts: 3,
assignee: '@tomoko', repo: 'gitea:corp/research', branch: 'task/407-competitors',
createdAt: now - 26*H, updatedAt: now - 10*H,
events: [
{ kind: 'error', label: 'タイムアウト', meta: '3回連続で失敗', time: 'yesterday' },
],
},
{
id: 406, title: 'ゲーム実況の告知ツイート生成',
body: '今夜のストリーム用の告知ツイートを3案作成。ハッシュタグ付き。',
status: 'retry', piece: 'game-tweet-generator', worker: 'worker-06', attempts: 2,
assignee: '@daichi', repo: 'gitea:daichi/stream', branch: 'task/406-tweet-gen',
createdAt: now - 40*MIN, updatedAt: now - 8*MIN,
events: [
{ kind: 'error', label: 'レート制限', meta: '60秒後に再試行', time: '10:35' },
],
},
{
id: 405, title: '経費申請書のOCRと仕分け',
body: '添付PDFをOCRし、勘定科目ごとに仕分け。',
status: 'cancelled', piece: 'office-process', worker: null, attempts: 1,
assignee: '@kenta', repo: 'gitea:corp/ops', branch: '—',
createdAt: now - 48*H, updatedAt: now - 20*H,
events: [],
},
];
const INITIAL_MESSAGES = {
412: [
{ role: 'user', content: '毎朝7:00にAI関連のXアカウントの過去24hをまとめて、DMで送ってほしい。1スレッドで。', footer: '10:41 · @daichi' },
{ role: 'assistant', content: '了解。x-ai-digest ピースを使用します。対象アカウント、まとめる観点、文字数制限を確認させてください。' },
{ role: 'ask', content: '❓ 以下を確認させてください:\n\n1. 対象アカウントリストはこのリポジトリの accounts.txt で良いですか?\n2. 1ツイートあたりの上限文字数は280でOK?\n3. 日本語メインの要約で良いですか?' },
{ role: 'user', content: '1. OK\n2. OK\n3. 日本語で。でも原文が英語なら簡潔な英語の引用も残して。' },
{ role: 'progress', content: '/implement — ステップ 8 / 12 (Twitter CLIでタイムライン取得中)' },
],
411: [
{ role: 'user', content: 'Brave Searchで検索結果が取れない。何度もCAPTCHAが出てるっぽい。' },
{ role: 'progress', content: 'noVNCセッションを開いて確認中...' },
{ role: 'ask', content: '❓ CAPTCHAの解決が必要です。noVNCで手動で解決していただけますか?\n\nsession: https://novnc.internal/412\n\n解決後 `/resume 411` と返信してください。' },
],
410: [
{ role: 'user', content: 'Issue #284 の対応お願い。scheduler.tsのタイムアウト処理が不安定。' },
{ role: 'assistant', content: '了解。/brainstorm から始めます。' },
{ role: 'result', content: '✅ 完了しました。\n\n- PR: gitea:daichi/agent-orchestrator#291\n- 変更: scheduler.ts, worker-manager.test.ts, worker.test.ts\n- テスト: 42 passed\n\nレビューお願いします。' },
],
409: [
{ role: 'user', content: '営業部向けに今週のエージェント活用ネタを10個ブレストしてほしい。' },
{ role: 'assistant', content: '了解。キューに入りました。ワーカーの空きが出次第処理します。' },
],
408: [
{ role: 'user', content: 'Q3のSNSエンゲージメントまとめて、折れ線グラフと棒グラフのPNGで。' },
{ role: 'progress', content: 'サブタスク3件の完了を待っています (#408-1, #408-2, #408-3)' },
],
407: [
{ role: 'user', content: '競合3社のリサーチと比較表を作成。' },
{ role: 'assistant', content: '3回試行しましたが、外部サイトの読み込みタイムアウトが続いています。' },
],
406: [
{ role: 'user', content: '今夜のストリーム告知を3案、ハッシュタグ付きで。' },
{ role: 'progress', content: 'レート制限中 — 60秒後に再試行します' },
],
405: [
{ role: 'user', content: '経費PDFのOCRと仕分け。' },
{ role: 'assistant', content: 'キャンセルされました。' },
],
};
function App() {
const [tasks, setTasks] = React.useState(SAMPLE_TASKS);
const [activeId, setActiveId] = React.useState(412);
const [detailOpen, setDetailOpen] = React.useState(true);
const [messages, setMessages] = React.useState(INITIAL_MESSAGES);
const [filters, setFilters] = React.useState({ status: 'all', search: '', sort: 'updated' });
const [page, setPage] = React.useState('tasks');
const active = tasks.find(t => t.id === activeId) || tasks[0];
const counts = {
total: tasks.length,
running: tasks.filter(t => t.status === 'running').length,
waiting: tasks.filter(t => t.status === 'waiting_human' || t.status === 'waiting_subtasks').length,
failed: tasks.filter(t => t.status === 'failed').length,
};
const onSend = (text) => {
setMessages(m => ({
...m,
[activeId]: [...(m[activeId] || []), { role: 'user', content: text, footer: 'たった今 · @daichi' }],
}));
// fake echo after small delay
setTimeout(() => {
setMessages(m => ({
...m,
[activeId]: [...(m[activeId] || []), { role: 'progress', content: 'エージェントが応答を生成中...' }],
}));
}, 400);
};
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100vh' }}>
<TopBar
page={page} onNavigate={setPage}
counts={counts}
onOpenCreate={() => alert('新しい依頼 (モック)')}
user={{ name: 'Daichi' }}
/>
<div style={{
flex: 1, minHeight: 0, display: 'grid',
gridTemplateColumns: detailOpen ? '320px 1fr 380px' : '320px 1fr',
background: '#f1f5f9', gap: 1,
}}>
<div style={{ background: '#fff', padding: 12, minWidth: 0, display: 'flex', flexDirection: 'column' }}>
<TaskList
tasks={tasks} activeId={activeId} onSelect={setActiveId}
filters={filters} setFilters={setFilters}
/>
</div>
<ChatPane
task={active}
messages={messages[active.id] || []}
onSend={onSend}
onOpenDetail={() => setDetailOpen(v => !v)}
detailOpen={detailOpen}
/>
{detailOpen && <DetailPanel task={active} onClose={() => setDetailOpen(false)} />}
</div>
</div>
);
}
ReactDOM.createRoot(document.getElementById('root')).render(<App />);
</script>
</body>
</html>
@@ -0,0 +1,197 @@
// ChatPane — mirrors ui/src/components/chat/* with user/ask/result/progress bubbles
function Bubble({ role, children, footer }) {
const isUser = role === 'user';
const style = {
maxWidth: '85%',
padding: '10px 14px',
borderRadius: 16,
fontSize: 13,
lineHeight: 1.55,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
};
if (isUser) {
Object.assign(style, { background: '#f1f5f9', color: '#0f172a', borderBottomRightRadius: 4, alignSelf: 'flex-end' });
} else if (role === 'ask') {
Object.assign(style, { background: '#fef9c3', color: '#854d0e', border: '1px solid #fde68a', borderBottomLeftRadius: 4 });
} else if (role === 'result') {
Object.assign(style, { background: '#ecfdf5', color: '#065f46', border: '1px solid #a7f3d0', borderBottomLeftRadius: 4 });
} else {
Object.assign(style, { background: '#fff', color: '#0f172a', border: '1px solid #e2e8f0', borderBottomLeftRadius: 4 });
}
return (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: isUser ? 'flex-end' : 'flex-start', gap: 4 }}>
<div style={style}>{children}</div>
{footer && <div style={{ fontSize: 10, color: '#94a3b8' }}>{footer}</div>}
</div>
);
}
function ProgressBubble({ text }) {
return (
<div style={{
alignSelf: 'flex-start', background: '#f1f5f9', color: '#475569',
padding: '8px 12px', borderRadius: 12, fontSize: 12,
display: 'inline-flex', alignItems: 'center', gap: 8,
}}>
<Spinner />
<span>{text}</span>
</div>
);
}
function ChatHeader({ task, onOpenDetail, detailOpen }) {
return (
<div style={{
flexShrink: 0, padding: '12px 16px', borderBottom: '1px solid #e2e8f0', background: '#fff',
display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12,
}}>
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 10, fontFamily: 'IBM Plex Mono, monospace', color: '#94a3b8', letterSpacing: '.08em' }}>
TASK #{task.id}
</div>
<div style={{ fontSize: 15, fontWeight: 700, color: '#0f172a', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{task.title}
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<StatusBadge status={task.status} />
<button onClick={onOpenDetail} style={{
padding: '6px 10px', borderRadius: 8, border: '1px solid #e2e8f0',
background: detailOpen ? '#eff6ff' : '#fff',
color: detailOpen ? '#1d4ed8' : '#475569',
fontSize: 12, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit',
}}>詳細</button>
</div>
</div>
);
}
function Composer({ onSend, disabled }) {
const [text, setText] = React.useState('');
const [sending, setSending] = React.useState(false);
const [error, setError] = React.useState(null);
const send = async () => {
if (!text.trim() || disabled || sending) return;
setError(null); setSending(true);
try {
await Promise.resolve(onSend(text.trim()));
setText('');
} catch (e) {
setError(e?.message || '送信に失敗しました');
} finally {
setSending(false);
}
};
return (
<div style={{ flexShrink: 0, borderTop: '1px solid #e2e8f0', background: '#fff', padding: 12 }}>
{error && (
<div style={{
marginBottom: 8, padding: '8px 10px', background: '#fef2f2', border: '1px solid #fecaca',
color: '#b91c1c', borderRadius: 8, fontSize: 12, display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8,
}}>
<span> {error}</span>
<button onClick={send} style={{
padding: '2px 8px', borderRadius: 6, border: '1px solid #fecaca',
background: '#fff', color: '#b91c1c', fontSize: 11, fontWeight: 700,
cursor: 'pointer', fontFamily: 'inherit',
}}>再送信</button>
</div>
)}
<div style={{
display: 'flex', alignItems: 'flex-end', gap: 8, background: '#f8fafc',
border: '1px solid #e2e8f0', borderRadius: 12, padding: 8,
opacity: disabled ? 0.6 : 1,
}}>
<button style={{ padding: 6, background: 'transparent', border: 'none', color: '#94a3b8', cursor: 'pointer' }}>
<IconAttach width={16} height={16} />
</button>
<textarea
value={text}
disabled={disabled || sending}
onChange={(e) => setText(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { e.preventDefault(); send(); } }}
rows={2}
placeholder={disabled ? '送信できません' : 'メッセージを入力 (⌘+Enter で送信)'}
style={{
flex: 1, resize: 'none', border: 'none', outline: 'none', background: 'transparent',
fontFamily: 'inherit', fontSize: 13, color: '#0f172a', lineHeight: 1.5, minHeight: 32,
}}
/>
<button onClick={send} disabled={disabled || sending || !text.trim()} style={{
padding: '6px 14px', background: '#2563eb', color: '#fff', borderRadius: 8,
fontSize: 12, fontWeight: 700, border: 'none',
cursor: (disabled || sending || !text.trim()) ? 'not-allowed' : 'pointer',
opacity: (disabled || sending || !text.trim()) ? 0.5 : 1, fontFamily: 'inherit',
display: 'inline-flex', alignItems: 'center', gap: 6,
}}>
{sending && <Spinner />}
{sending ? '送信中' : '送信'}
</button>
</div>
<div style={{ marginTop: 6, fontSize: 10, color: '#94a3b8', paddingLeft: 4 }}>エージェントは常に /brainstorm /plan /implement のパイプラインで動作します</div>
</div>
);
}
function ChatPane({ task, messages, onSend, onOpenDetail, detailOpen, loading, onOpenCreate }) {
const scrollRef = React.useRef(null);
React.useEffect(() => {
if (scrollRef.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}, [messages.length, task && task.id]);
if (!task) {
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', background: '#fff', justifyContent: 'center' }}>
<EmptyState
icon={<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"/></svg>}
title="タスクを選択してください"
hint="左のリストから会話を開くか、新しい依頼を作成できます。"
action={onOpenCreate && (
<button onClick={onOpenCreate} style={{
padding: '8px 14px', borderRadius: 10, fontSize: 12, fontWeight: 700,
background: '#2563eb', color: '#fff', border: 'none', cursor: 'pointer',
fontFamily: 'inherit', display: 'inline-flex', alignItems: 'center', gap: 6,
}}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round"><path d="M12 5v14M5 12h14"/></svg>
新しい依頼
</button>
)}
/>
</div>
);
}
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', background: '#fff' }}>
<ChatHeader task={task} onOpenDetail={onOpenDetail} detailOpen={detailOpen} />
<div ref={scrollRef} style={{
flex: 1, overflowY: 'auto', padding: '16px 20px',
display: 'flex', flexDirection: 'column', gap: 12, minHeight: 0,
}}>
{loading && (
<>
<div style={{ alignSelf: 'flex-end', width: '60%' }}><SkeletonLine height={40} style={{ borderRadius: 16 }} /></div>
<div style={{ alignSelf: 'flex-start', width: '70%' }}><SkeletonLine height={56} style={{ borderRadius: 16 }} /></div>
<div style={{ alignSelf: 'flex-start', width: '40%' }}><SkeletonLine height={32} style={{ borderRadius: 12 }} /></div>
</>
)}
{!loading && messages.length === 0 && (
<EmptyState
compact
title="まだメッセージがありません"
hint="下の入力欄から依頼の詳細を送信してください。エージェントが /brainstorm から開始します。"
/>
)}
{!loading && messages.map((m, i) => (
m.role === 'progress'
? <ProgressBubble key={i} text={m.content} />
: <Bubble key={i} role={m.role} footer={m.footer}>{m.content}</Bubble>
))}
</div>
<Composer onSend={onSend} disabled={task.status === 'cancelled' || task.status === 'succeeded'} />
</div>
);
}
window.ChatPane = ChatPane;
@@ -0,0 +1,134 @@
// DetailPanel — tabs: overview, progress (activity + log surface)
function Tabs({ tab, onTab }) {
const items = [
{ id: 'overview', label: '概要' },
{ id: 'progress', label: '進捗' },
{ id: 'subtasks', label: 'サブタスク' },
];
return (
<div style={{ display: 'flex', gap: 4, padding: '8px 12px', borderBottom: '1px solid #e2e8f0', background: '#fff' }}>
{items.map(it => (
<button key={it.id} onClick={() => onTab(it.id)} style={{
padding: '6px 12px', borderRadius: 8, fontSize: 12, fontWeight: 600,
border: 'none', cursor: 'pointer', fontFamily: 'inherit',
background: tab === it.id ? '#eff6ff' : 'transparent',
color: tab === it.id ? '#1d4ed8' : '#64748b',
}}>{it.label}</button>
))}
</div>
);
}
function OverviewTab({ task }) {
const Row = ({ label, value }) => (
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, padding: '8px 0', borderBottom: '1px solid #f1f5f9', fontSize: 12 }}>
<span style={{ color: '#64748b' }}>{label}</span>
<span style={{ color: '#0f172a', fontWeight: 600, textAlign: 'right' }}>{value}</span>
</div>
);
return (
<div style={{ padding: 16, overflowY: 'auto', fontSize: 13, color: '#0f172a' }}>
<div style={{ fontSize: 10, fontFamily: 'IBM Plex Mono, monospace', color: '#94a3b8', letterSpacing: '.08em' }}>DESCRIPTION</div>
<div style={{ marginTop: 6, color: '#334155', lineHeight: 1.6, fontSize: 13 }}>{task.body}</div>
<div style={{ marginTop: 16, display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<StatChip label="試行" value={`${task.attempts}/3`} />
<StatChip label="ピース" value={task.piece} color="#2563eb" />
<StatChip label="ワーカー" value={task.worker || '—'} />
</div>
<div style={{ marginTop: 16 }}>
<Row label="リポジトリ" value={<span style={{ fontFamily: 'IBM Plex Mono, monospace', fontSize: 11 }}>{task.repo}</span>} />
<Row label="ブランチ" value={<span style={{ fontFamily: 'IBM Plex Mono, monospace', fontSize: 11 }}>{task.branch}</span>} />
<Row label="作成日時" value={new Date(task.createdAt).toLocaleString('ja-JP')} />
<Row label="更新日時" value={relativeTime(task.updatedAt)} />
<Row label="担当" value={task.assignee} />
</div>
<div style={{ marginTop: 16, display: 'flex', gap: 8 }}>
<button style={{
padding: '8px 14px', borderRadius: 10, border: '1px solid #e2e8f0',
background: '#fff', color: '#475569', fontSize: 12, fontWeight: 600,
cursor: 'pointer', fontFamily: 'inherit', whiteSpace: 'nowrap',
}}>再試行</button>
<button style={{
padding: '8px 14px', borderRadius: 10, border: '1px solid #fecaca',
background: '#fff', color: '#b91c1c', fontSize: 12, fontWeight: 600,
cursor: 'pointer', fontFamily: 'inherit', whiteSpace: 'nowrap',
}}>キャンセル</button>
</div>
</div>
);
}
function ProgressTab({ task }) {
const events = task.events || [];
return (
<div style={{ padding: 16, overflowY: 'auto' }}>
<div style={{ fontSize: 10, fontFamily: 'IBM Plex Mono, monospace', color: '#94a3b8', letterSpacing: '.08em', marginBottom: 8 }}>ACTIVITY</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, marginBottom: 20 }}>
{events.map((e, i) => (
<div key={i} style={{ display: 'flex', gap: 10, fontSize: 12 }}>
<div style={{ flexShrink: 0, marginTop: 3 }}>
<div style={{ width: 8, height: 8, borderRadius: 9999, background: e.kind === 'error' ? '#dc2626' : e.kind === 'ok' ? '#16a34a' : '#3b82f6' }} />
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ color: '#0f172a', fontWeight: 600 }}>{e.label}</div>
<div style={{ color: '#64748b', fontSize: 11 }}>{e.meta}</div>
</div>
<div style={{ fontSize: 10, color: '#94a3b8', fontFamily: 'IBM Plex Mono, monospace' }}>{e.time}</div>
</div>
))}
</div>
<div style={{ fontSize: 10, fontFamily: 'IBM Plex Mono, monospace', color: '#94a3b8', letterSpacing: '.08em', marginBottom: 6 }}>ACTIVITY.LOG</div>
<div style={{
background: '#0f172a', color: '#e2e8f0',
fontFamily: 'IBM Plex Mono, monospace', fontSize: 11, lineHeight: 1.6,
padding: 12, borderRadius: 8, whiteSpace: 'pre', overflowX: 'auto',
}}>
{`[10:42:18] ` + String.fromCharCode(9432) + ` starting worker for task #` + task.id + `
[10:42:19] ` + String.fromCharCode(9432) + ` branch: ` + task.branch + `
[10:42:21] ` + String.fromCharCode(9432) + ` piece: ` + task.piece + `
[10:42:22] ` + String.fromCharCode(9655) + ` /brainstorm
[10:43:04] ` + String.fromCharCode(10003) + ` /plan (12 steps)
[10:43:05] ` + String.fromCharCode(9655) + ` /implement
[10:44:58] ` + String.fromCharCode(10003) + ` tests passed`}
</div>
</div>
);
}
function DetailPanel({ task, onClose }) {
const [tab, setTab] = React.useState('overview');
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', background: '#f8fafc', borderLeft: '1px solid #e2e8f0' }}>
<div style={{
flexShrink: 0, padding: '12px 16px', borderBottom: '1px solid #e2e8f0', background: '#fff',
display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8,
}}>
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 10, fontFamily: 'IBM Plex Mono, monospace', color: '#94a3b8', letterSpacing: '.08em' }}>DETAIL</div>
<div style={{ fontSize: 13, fontWeight: 700, color: '#0f172a', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>#{task.id} {task.title}</div>
</div>
<button onClick={onClose} style={{
width: 28, height: 28, borderRadius: 8, border: '1px solid #e2e8f0',
background: '#fff', color: '#64748b', cursor: 'pointer', display: 'inline-flex',
alignItems: 'center', justifyContent: 'center',
}}><IconClose width={12} height={12} /></button>
</div>
<Tabs tab={tab} onTab={setTab} />
<div style={{ flex: 1, minHeight: 0, overflow: 'hidden' }}>
{tab === 'overview' && <OverviewTab task={task} />}
{tab === 'progress' && <ProgressTab task={task} />}
{tab === 'subtasks' && (
<div style={{ padding: 16, fontSize: 13, color: '#64748b' }}>
サブタスクはこのタスクにありません
</div>
)}
</div>
</div>
);
}
window.DetailPanel = DetailPanel;
@@ -0,0 +1,169 @@
// Shared small primitives for the admin UI kit.
// Status tone + tiny SVG icons + labels matching the codebase.
const STATUS_LABELS = {
queued: 'Inbox', running: 'Running', waiting_human: 'Waiting',
waiting_subtasks: 'Subtasks', retry: 'Retry', succeeded: 'Done',
failed: 'Failed', cancelled: 'Cancelled',
};
const STATUS_TONE = {
running: { bg: '#dcfce7', fg: '#166534' },
waiting_human: { bg: '#fef9c3', fg: '#854d0e' },
waiting_subtasks: { bg: '#e0e7ff', fg: '#3730a3' },
failed: { bg: '#fee2e2', fg: '#b91c1c' },
succeeded: { bg: '#dbeafe', fg: '#1e40af' },
retry: { bg: '#fef3c7', fg: '#92400e' },
queued: { bg: '#e2e8f0', fg: '#475569' },
cancelled: { bg: '#e2e8f0', fg: '#475569' },
};
function StatusBadge({ status, small }) {
const tone = STATUS_TONE[status] || STATUS_TONE.queued;
const label = STATUS_LABELS[status] || status;
const style = {
background: tone.bg, color: tone.fg,
fontSize: small ? 10 : 11, fontWeight: 700,
padding: small ? '1px 8px' : '2px 10px', borderRadius: 9999,
display: 'inline-flex', alignItems: 'center', whiteSpace: 'nowrap',
};
return <span style={style}>{label}</span>;
}
function StatChip({ label, value, color }) {
return (
<div style={{
background: '#fff', border: '1px solid #e2e8f0', borderRadius: 12,
padding: '8px 12px', boxShadow: '0 1px 2px 0 rgb(0 0 0 / 0.05)',
minWidth: 0, flex: '1 1 0', minWidth: 80,
}}>
<div style={{ fontSize: 10, fontWeight: 700, color: '#64748b', letterSpacing: '.06em', textTransform: 'uppercase', whiteSpace: 'nowrap' }}>{label}</div>
<div style={{
fontSize: 15, fontWeight: 800, color: color || '#0f172a', marginTop: 2,
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}}>{value}</div>
</div>
);
}
function Spinner() {
return <div style={{
width: 16, height: 16, border: '2px solid #e2e8f0', borderTopColor: '#2563eb',
borderRadius: '9999px', animation: 'ao-spin 1s linear infinite', display: 'inline-block',
}} />;
}
function PulseDot() {
return <span style={{
display: 'inline-block', width: 8, height: 8, background: '#3b82f6',
borderRadius: 9999, animation: 'ao-pulse 1.2s ease-in-out infinite',
}} />;
}
function IconSearch(props) {
return <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" {...props}><path d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg>;
}
function IconAttach(props) {
return <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" {...props}><path d="M21.44 11.05l-9.19 9.19a6 6 0 01-8.49-8.49l9.19-9.19a4 4 0 015.66 5.66l-9.2 9.19a2 2 0 01-2.83-2.83l8.49-8.48"/></svg>;
}
function IconClose(props) {
return <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" {...props}><path d="M4 4l8 8M12 4l-8 8"/></svg>;
}
// ---- State primitives: loading / empty / error ----
function SkeletonLine({ width = '100%', height = 10, style }) {
return <div style={{
width, height, borderRadius: 6,
background: 'linear-gradient(90deg, #f1f5f9 0%, #e2e8f0 50%, #f1f5f9 100%)',
backgroundSize: '200% 100%',
animation: 'ao-shimmer 1.4s ease-in-out infinite',
...style,
}} />;
}
function SkeletonCard({ lines = 2 }) {
return (
<div style={{
padding: '10px 12px', borderRadius: 12, border: '1px solid #e2e8f0',
background: '#fff', display: 'flex', flexDirection: 'column', gap: 6,
}}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
<SkeletonLine width="60%" height={12} />
<SkeletonLine width={44} height={14} style={{ borderRadius: 9999 }} />
</div>
{Array.from({ length: lines }).map((_, i) => (
<SkeletonLine key={i} width={i === lines - 1 ? '40%' : '90%'} height={9} />
))}
</div>
);
}
function SkeletonList({ count = 5, lines = 2 }) {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{Array.from({ length: count }).map((_, i) => <SkeletonCard key={i} lines={lines} />)}
</div>
);
}
function EmptyState({ icon, title, hint, action, compact }) {
return (
<div style={{
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
textAlign: 'center', padding: compact ? '24px 16px' : '48px 24px', gap: 8,
color: '#64748b',
}}>
{icon && <div style={{
width: 40, height: 40, borderRadius: 9999, background: '#f1f5f9',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
color: '#94a3b8', marginBottom: 4,
}}>{icon}</div>}
{title && <div style={{ fontSize: 13, fontWeight: 700, color: '#334155' }}>{title}</div>}
{hint && <div style={{ fontSize: 12, color: '#64748b', maxWidth: 280, lineHeight: 1.5 }}>{hint}</div>}
{action && <div style={{ marginTop: 8 }}>{action}</div>}
</div>
);
}
function ErrorState({ title = '読み込みに失敗しました', hint, onRetry, compact }) {
return (
<div style={{
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
textAlign: 'center', padding: compact ? '24px 16px' : '40px 24px', gap: 8,
}}>
<div style={{
width: 40, height: 40, borderRadius: 9999, background: '#fee2e2',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
color: '#b91c1c', marginBottom: 4,
}}>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M12 9v4m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"/></svg>
</div>
<div style={{ fontSize: 13, fontWeight: 700, color: '#b91c1c' }}>{title}</div>
{hint && <div style={{ fontSize: 12, color: '#64748b', maxWidth: 320, lineHeight: 1.5 }}>{hint}</div>}
{onRetry && (
<button onClick={onRetry} style={{
marginTop: 4, padding: '6px 14px', borderRadius: 8, fontSize: 12, fontWeight: 700,
background: '#fff', border: '1px solid #e2e8f0', color: '#334155',
cursor: 'pointer', fontFamily: 'inherit',
}}>再試行</button>
)}
</div>
);
}
function relativeTime(ms) {
const mins = Math.floor((Date.now() - ms) / 60000);
if (mins < 1) return 'たった今';
if (mins < 60) return `${mins}分前`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `${hrs}時間前`;
return `${Math.floor(hrs / 24)}日前`;
}
Object.assign(window, {
STATUS_LABELS, STATUS_TONE,
StatusBadge, StatChip, Spinner, PulseDot,
SkeletonLine, SkeletonCard, SkeletonList, EmptyState, ErrorState,
IconSearch, IconAttach, IconClose, relativeTime,
});
@@ -0,0 +1,34 @@
# Admin UI Kit
Agent Orchestrator 管理画面のハイファイ UI キット。`ui/src/` の実装に対応します。
## エントリ
- `index.html` — 4画面シェル(Tasks / Schedules / Users / Settings)。TopBar のナビで切替。
## コンポーネント
| ファイル | 役割 |
|---|---|
| `TopBar.jsx` | ロゴ + ワードマーク + セクションナビ(下線インジケータ)+ ユーザーアバター |
| `TaskList.jsx` | 左パネル最上部の「新しい依頼」ボタン、カウント行、検索バー内にソートアイコン統合、ステータスフィルタ chip、タスクリスト |
| `ChatPane.jsx` | タスク詳細のチャット UIuser / assistant / ask / result / progress バブル) |
| `DetailPanel.jsx` | 右側の詳細パネル(Overview / Progress タブ) |
| `SchedulesPage.jsx` | スケジュール一覧 + 詳細(Cron / Event トリガー、実行履歴) |
| `UsersPage.jsx` | ユーザー一覧 + ロール設定 / プロフィール |
| `SettingsPage.jsx` | 左サイドバー + 中央フォーム(Provider, Pieces, Workers ほか) |
| `Primitives.jsx` | StatusBadge, StatChip, SVG アイコン、スピナー、pulse dot |
## 設計上の決定(v2
- **プライマリアクションは左パネル最上部** — 「新しい依頼」は TopBar 右上ではなく、タスクリスト文脈内の青いフル幅ボタン。
- **カウントはプライマリアクション直下に統合** — 「合計 / 実行中 / 待機 / 失敗」は TaskList の冒頭に置き、TopBar 右側はアバターのみでスッキリ。
- **ソートは検索バー内にアイコン化** — `select` を外し、検索バー右端の小さなアイコンボタンをクリックすると 3 つの並び順からドロップダウンで選べる。リストの縦領域が約 40px 広がる。
- **4画面で同じシェルを共有** — Tasks / Schedules / Users は「左リスト + 中央詳細」、Settings のみ「左サイドバー + 中央フォーム」。ヘッダー → サマリ chip → カードフォーム のリズムで操作導線を揃える。
- **ナビは下線インジケータ** — タブ的な視覚。アクティブ以外はコントラストを落とす。
## 実装との対応
`ui/src/components/` 以下の同名コンポーネントにそのまま対応させる想定です。UI キットはあくまで意思決定のモック — 実 API は未接続、サンプルデータは `index.html` 内にインライン。
旧バージョン(移植前)は `../admin-legacy/` にあります。
@@ -0,0 +1,427 @@
// SchedulesPage — mirrors the Tasks 3-pane shell: list | detail | history
// Data model derives from ui/src/pages/SchedulesPage.tsx.
const DAYS = ['日', '月', '火', '水', '木', '金', '土'];
function parseCronToDisplay(cron) {
if (cron === 'once') return '一回のみ';
const parts = (cron || '').split(' ');
if (parts.length !== 5) return cron;
const [min, hour, dom, , dow] = parts;
const hhmm = `${hour}:${String(min).padStart(2, '0')}`;
if (dom !== '*' && dow === '*') return `毎月${dom}${hhmm}`;
if (dow !== '*' && dom === '*') return `毎週${DAYS[Number(dow)] ?? dow}${hhmm}`;
if (dom === '*' && dow === '*') return `毎日 ${hhmm}`;
return cron;
}
function formatDateShort(iso) {
if (!iso) return '—';
const d = new Date(iso);
return d.toLocaleString('ja-JP', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' });
}
function relativeFromNow(iso) {
if (!iso) return '—';
const diff = new Date(iso).getTime() - Date.now();
const abs = Math.abs(diff);
const mins = Math.round(abs / 60000);
const hrs = Math.round(mins / 60);
const days = Math.round(hrs / 24);
const unit = mins < 60 ? `${mins}` : hrs < 24 ? `${hrs}時間` : `${days}`;
return diff >= 0 ? `${unit}` : `${unit}`;
}
// ── Left: list of schedules ──────────────────────────────────────────────
function ScheduleListItem({ sch, active, onClick }) {
return (
<button onClick={onClick} style={{
width: '100%', textAlign: 'left', padding: '10px 12px', borderRadius: 12,
border: '1px solid ' + (active ? '#3b82f6' : '#e2e8f0'),
background: active ? '#eff6ff' : '#fff',
cursor: 'pointer', transition: 'background .15s', fontFamily: 'inherit',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0 }}>
<span style={{
width: 8, height: 8, borderRadius: 9999, flexShrink: 0,
background: sch.isActive ? '#22c55e' : '#cbd5e1',
}} />
<div style={{
flex: 1, minWidth: 0,
fontSize: 13, fontWeight: 700, color: sch.isActive ? '#0f172a' : '#64748b',
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}}>{sch.title || 'タイトルなし'}</div>
{sch.triggerKind === 'event' && (
<span style={{
fontSize: 10, fontWeight: 700, color: '#5b21b6', background: '#ede9fe',
padding: '2px 6px', borderRadius: 4, flexShrink: 0,
}}>event</span>
)}
</div>
<div style={{ marginTop: 4, fontSize: 11, color: '#64748b', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{sch.triggerKind === 'event' ? sch.eventSource : parseCronToDisplay(sch.cronExpression)}
</div>
<div style={{ marginTop: 2, fontSize: 10, color: '#94a3b8' }}>
{sch.isActive
? (sch.nextRunAt ? `次回 ${formatDateShort(sch.nextRunAt)} (${relativeFromNow(sch.nextRunAt)})` : '次回未定')
: '停止中'}
</div>
</button>
);
}
function ScheduleListPane({ items, activeId, onSelect, filter, setFilter, search, setSearch, onOpenCreate }) {
const filtered = items.filter(s => {
if (filter === 'active' && !s.isActive) return false;
if (filter === 'paused' && s.isActive) return false;
if (filter === 'event' && s.triggerKind !== 'event') return false;
if (search && !(s.title + s.body).toLowerCase().includes(search.toLowerCase())) return false;
return true;
});
const counts = {
all: items.length,
active: items.filter(s => s.isActive).length,
paused: items.filter(s => !s.isActive).length,
event: items.filter(s => s.triggerKind === 'event').length,
};
const chipStyle = (on) => ({
flexShrink: 0, padding: '6px 10px', borderRadius: 9999,
fontSize: 11, fontWeight: 700, whiteSpace: 'nowrap', cursor: 'pointer',
border: '1px solid ' + (on ? '#2563eb' : '#e2e8f0'),
background: on ? '#eff6ff' : '#fff',
color: on ? '#1d4ed8' : '#64748b', fontFamily: 'inherit',
});
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
<button onClick={onOpenCreate} style={{
width: '100%', padding: '10px 14px', marginBottom: 10, background: '#2563eb',
color: '#fff', borderRadius: 12, fontSize: 13, fontWeight: 700, border: 'none',
cursor: 'pointer', fontFamily: 'inherit', display: 'inline-flex', alignItems: 'center',
justifyContent: 'center', gap: 6, boxShadow: '0 1px 2px 0 rgb(0 0 0 / 0.05)',
}}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round"><path d="M12 5v14M5 12h14"/></svg>
新しいスケジュール
</button>
<div style={{
display: 'flex', alignItems: 'center', gap: 10, fontSize: 11,
color: '#64748b', padding: '0 2px 10px',
}}>
<span><b style={{ color: '#334155', fontWeight: 700 }}>{counts.all}</b> </span>
<span style={{ color: '#cbd5e1' }}>·</span>
<span><b style={{ color: '#16a34a', fontWeight: 700 }}>{counts.active}</b> 有効</span>
{counts.paused > 0 && <span><b style={{ color: '#64748b', fontWeight: 700 }}>{counts.paused}</b> 停止</span>}
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, paddingBottom: 12, borderBottom: '1px solid #e2e8f0' }}>
<div style={{
display: 'flex', alignItems: 'center', gap: 8, background: '#fff', border: '1px solid #e2e8f0',
borderRadius: 12, padding: '6px 12px', boxShadow: '0 1px 2px 0 rgb(0 0 0 / 0.05)',
}}>
<IconSearch width={14} height={14} style={{ color: '#94a3b8', flexShrink: 0 }} />
<input value={search} onChange={(e) => setSearch(e.target.value)} placeholder="検索..."
style={{ flex: 1, border: 'none', outline: 'none', background: 'transparent', fontSize: 13, fontFamily: 'inherit', color: '#0f172a', minWidth: 0 }} />
</div>
<div style={{ display: 'flex', gap: 6, overflowX: 'auto', paddingBottom: 4 }}>
<button style={chipStyle(filter === 'all')} onClick={() => setFilter('all')}>All <span style={{ color: '#94a3b8', marginLeft: 2 }}>{counts.all}</span></button>
<button style={chipStyle(filter === 'active')} onClick={() => setFilter('active')}>有効 <span style={{ color: '#94a3b8', marginLeft: 2 }}>{counts.active}</span></button>
<button style={chipStyle(filter === 'paused')} onClick={() => setFilter('paused')}>停止 <span style={{ color: '#94a3b8', marginLeft: 2 }}>{counts.paused}</span></button>
<button style={chipStyle(filter === 'event')} onClick={() => setFilter('event')}>Event <span style={{ color: '#94a3b8', marginLeft: 2 }}>{counts.event}</span></button>
</div>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginTop: 8, overflowY: 'auto', flex: 1, minHeight: 0 }}>
{filtered.map(s => <ScheduleListItem key={s.id} sch={s} active={activeId === s.id} onClick={() => onSelect(s.id)} />)}
{filtered.length === 0 && (
(search || filter !== 'all') ? (
<EmptyState
compact
icon={<IconSearch width={18} height={18} />}
title="該当するスケジュールはありません"
hint="検索やフィルタを変えてみてください。"
action={
<button onClick={() => { setSearch(''); setFilter('all'); }} style={{
padding: '6px 12px', borderRadius: 8, fontSize: 12, fontWeight: 700,
background: '#fff', border: '1px solid #e2e8f0', color: '#334155',
cursor: 'pointer', fontFamily: 'inherit',
}}>フィルタをクリア</button>
}
/>
) : (
<EmptyState
compact
icon={<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>}
title="スケジュールがありません"
hint="定期実行やイベントトリガーを登録するとここに表示されます。"
/>
)
)}
</div>
</div>
);
}
// ── Center: schedule detail editor ───────────────────────────────────────
function FormRow({ label, help, children }) {
return (
<label style={{ display: 'block', marginBottom: 14 }}>
<div style={{ fontSize: 11, fontWeight: 600, color: '#475569', marginBottom: 4 }}>{label}</div>
{children}
{help && <div style={{ fontSize: 10, color: '#94a3b8', marginTop: 4 }}>{help}</div>}
</label>
);
}
function TextInput(props) {
return <input {...props} style={{
width: '100%', padding: '8px 12px', fontSize: 13, fontFamily: 'inherit',
background: '#fff', border: '1px solid #e2e8f0', borderRadius: 10,
outline: 'none', color: '#0f172a',
...(props.style || {}),
}} />;
}
function SelectInput({ children, ...props }) {
return <select {...props} style={{
width: '100%', padding: '8px 12px', fontSize: 13, fontFamily: 'inherit',
background: '#fff', border: '1px solid #e2e8f0', borderRadius: 10,
outline: 'none', color: '#0f172a',
}}>{children}</select>;
}
function ScheduleDetail({ sch, onPatch, onTrigger, onDelete }) {
if (!sch) {
return (
<div style={{ padding: 40, display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%' }}>
<EmptyState
icon={<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>}
title="スケジュールを選択してください"
hint="左のリストから編集したいスケジュールを開きます。"
/>
</div>
);
}
const isEvent = sch.triggerKind === 'event';
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
{/* Header */}
<div style={{
flexShrink: 0, padding: '14px 20px', borderBottom: '1px solid #e2e8f0', background: '#fff',
display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12,
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, minWidth: 0 }}>
<span style={{
width: 10, height: 10, borderRadius: 9999,
background: sch.isActive ? '#22c55e' : '#cbd5e1',
}} />
<div style={{ fontSize: 10, fontWeight: 700, color: '#64748b', letterSpacing: '.08em', textTransform: 'uppercase' }}>SCHEDULE #{sch.id}</div>
<div style={{ fontSize: 15, fontWeight: 700, color: '#0f172a', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{sch.title || 'タイトルなし'}
</div>
</div>
<div style={{ display: 'flex', gap: 8, flexShrink: 0 }}>
<button onClick={() => onTrigger(sch.id)} style={{
padding: '6px 12px', background: '#fff', border: '1px solid #bfdbfe', color: '#1d4ed8',
borderRadius: 8, fontSize: 12, fontWeight: 700, cursor: 'pointer', fontFamily: 'inherit',
display: 'inline-flex', alignItems: 'center', gap: 4,
}}>
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
今すぐ実行
</button>
<button onClick={() => onPatch(sch.id, { isActive: !sch.isActive })} style={{
padding: '6px 12px', background: '#fff', border: '1px solid #e2e8f0', color: '#475569',
borderRadius: 8, fontSize: 12, fontWeight: 700, cursor: 'pointer', fontFamily: 'inherit',
}}>{sch.isActive ? '停止' : '再開'}</button>
<button onClick={() => onDelete(sch.id)} style={{
padding: '6px 12px', background: '#fff', border: '1px solid #fecaca', color: '#dc2626',
borderRadius: 8, fontSize: 12, fontWeight: 700, cursor: 'pointer', fontFamily: 'inherit',
}}>削除</button>
</div>
</div>
{/* Body */}
<div style={{ flex: 1, overflowY: 'auto', padding: '20px 24px', background: '#f8fafc' }}>
<div style={{ maxWidth: 640, margin: '0 auto' }}>
{/* Summary strip */}
<div style={{
display: 'flex', gap: 10, marginBottom: 20, flexWrap: 'wrap',
}}>
<StatChip label="トリガー" value={isEvent ? 'Event' : 'Cron'} />
<StatChip label={isEvent ? 'ソース' : 'スケジュール'} value={isEvent ? sch.eventSource : parseCronToDisplay(sch.cronExpression)} />
<StatChip label="ピース" value={sch.pieceName} color="#2563eb" />
{sch.isActive
? <StatChip label="次回実行" value={sch.nextRunAt ? relativeFromNow(sch.nextRunAt) : '—'} />
: <StatChip label="ステータス" value="停止中" color="#64748b" />}
</div>
{/* Form card */}
<div style={{
background: '#fff', border: '1px solid #e2e8f0', borderRadius: 16, padding: 20,
boxShadow: '0 1px 2px 0 rgb(0 0 0 / 0.05)',
}}>
<div style={{ fontSize: 11, fontWeight: 700, color: '#64748b', letterSpacing: '.08em', textTransform: 'uppercase', marginBottom: 14 }}>
基本情報
</div>
<FormRow label="タイトル">
<TextInput value={sch.title || ''} onChange={e => onPatch(sch.id, { title: e.target.value })} placeholder="週次ニュースまとめ" />
</FormRow>
<FormRow label="プロンプト" help="エージェントに送るメッセージ">
<textarea value={sch.body} onChange={e => onPatch(sch.id, { body: e.target.value })} rows={4} style={{
width: '100%', padding: '8px 12px', fontSize: 13, fontFamily: 'inherit',
background: '#fff', border: '1px solid #e2e8f0', borderRadius: 10,
outline: 'none', color: '#0f172a', resize: 'vertical', lineHeight: 1.55,
}} />
</FormRow>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<FormRow label="ピース">
<SelectInput value={sch.pieceName} onChange={e => onPatch(sch.id, { pieceName: e.target.value })}>
<option value="auto">auto</option>
<option value="chat">chat</option>
<option value="research">research</option>
<option value="general">general</option>
<option value="x-ai-digest">x-ai-digest</option>
</SelectInput>
</FormRow>
<FormRow label="出力フォーマット">
<SelectInput value={sch.outputFormat || 'markdown'} onChange={e => onPatch(sch.id, { outputFormat: e.target.value })}>
<option value="markdown">markdown</option>
<option value="plain">plain</option>
<option value="json">json</option>
</SelectInput>
</FormRow>
</div>
</div>
{/* Trigger card */}
<div style={{
background: '#fff', border: '1px solid #e2e8f0', borderRadius: 16, padding: 20,
marginTop: 16, boxShadow: '0 1px 2px 0 rgb(0 0 0 / 0.05)',
}}>
<div style={{ fontSize: 11, fontWeight: 700, color: '#64748b', letterSpacing: '.08em', textTransform: 'uppercase', marginBottom: 14 }}>
トリガー
</div>
<div style={{ display: 'flex', gap: 8, marginBottom: 14 }}>
{['cron', 'event'].map(k => (
<button key={k} onClick={() => onPatch(sch.id, { triggerKind: k })} style={{
flex: 1, padding: '10px 12px', borderRadius: 10,
border: '1px solid ' + (sch.triggerKind === k ? '#2563eb' : '#e2e8f0'),
background: sch.triggerKind === k ? '#eff6ff' : '#fff',
color: sch.triggerKind === k ? '#1d4ed8' : '#64748b',
fontWeight: 700, fontSize: 12, cursor: 'pointer', fontFamily: 'inherit',
textAlign: 'left', display: 'flex', flexDirection: 'column', gap: 2,
}}>
<span>{k === 'cron' ? '定期実行 (Cron)' : 'イベントトリガー'}</span>
<span style={{ fontSize: 10, color: sch.triggerKind === k ? '#3b82f6' : '#94a3b8', fontWeight: 500 }}>
{k === 'cron' ? '毎日 / 毎週 / カスタム' : 'GitHub / Mail / Webhook'}
</span>
</button>
))}
</div>
{!isEvent && (
<>
<FormRow label="Cron 式" help="分 時 日 月 曜日 · 例: 0 7 * * * = 毎日 07:00 (UTC)">
<TextInput value={sch.cronExpression} onChange={e => onPatch(sch.id, { cronExpression: e.target.value })}
style={{ fontFamily: 'IBM Plex Mono, monospace' }} placeholder="0 7 * * *" />
</FormRow>
<div style={{ padding: '10px 12px', background: '#f8fafc', border: '1px solid #e2e8f0', borderRadius: 10, fontSize: 12, color: '#475569' }}>
<b style={{ color: '#0f172a' }}>{parseCronToDisplay(sch.cronExpression)}</b>
{sch.nextRunAt && <span> · 次回 {formatDateShort(sch.nextRunAt)} ({relativeFromNow(sch.nextRunAt)})</span>}
</div>
</>
)}
{isEvent && (
<>
<FormRow label="イベントソース">
<SelectInput value={sch.eventSource || ''} onChange={e => onPatch(sch.id, { eventSource: e.target.value })}>
<option value="github.issue.opened">github.issue.opened</option>
<option value="github.pr.opened">github.pr.opened</option>
<option value="gitea.push">gitea.push</option>
<option value="mail.received">mail.received</option>
<option value="webhook.custom">webhook.custom</option>
</SelectInput>
</FormRow>
<FormRow label="フィルタ条件" help="該当イベントが発火した時のみ実行">
<TextInput value={sch.eventFilter || ''} onChange={e => onPatch(sch.id, { eventFilter: e.target.value })}
style={{ fontFamily: 'IBM Plex Mono, monospace' }} placeholder='repo == "agent-orchestrator" && label == "bug"' />
</FormRow>
</>
)}
</div>
{/* History */}
<div style={{
background: '#fff', border: '1px solid #e2e8f0', borderRadius: 16, padding: 20,
marginTop: 16, boxShadow: '0 1px 2px 0 rgb(0 0 0 / 0.05)',
}}>
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14,
}}>
<div style={{ fontSize: 11, fontWeight: 700, color: '#64748b', letterSpacing: '.08em', textTransform: 'uppercase' }}>
実行履歴
</div>
<span style={{ fontSize: 11, color: '#94a3b8' }}>直近 {sch.history?.length || 0} </span>
</div>
{(sch.history || []).length === 0 && (
<EmptyState
compact
icon={<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>}
title="まだ実行されていません"
hint={sch.isActive ? '次回の実行後にここに履歴が追加されます。' : 'スケジュールは停止中です。「再開」で有効化できます。'}
/>
)}
<div style={{ display: 'flex', flexDirection: 'column' }}>
{(sch.history || []).map((h, i) => (
<div key={i} style={{
display: 'flex', alignItems: 'center', gap: 12, padding: '10px 0',
borderTop: i === 0 ? 'none' : '1px solid #f1f5f9',
}}>
<StatusBadge status={h.status} small />
<div style={{ flex: 1, fontSize: 12, color: '#334155' }}>
<a href="#" style={{ color: '#2563eb', fontWeight: 700, textDecoration: 'none' }}>#{h.taskId}</a>
{' · '}{h.summary || '—'}
</div>
<div style={{ fontSize: 11, color: '#94a3b8', whiteSpace: 'nowrap' }}>{formatDateShort(h.at)}</div>
</div>
))}
</div>
</div>
<div style={{ height: 40 }} />
</div>
</div>
</div>
);
}
function SchedulesPage({ schedules, activeId, setActiveId, onPatch, onTrigger, onDelete, onOpenCreate }) {
const [filter, setFilter] = React.useState('all');
const [search, setSearch] = React.useState('');
const active = schedules.find(s => s.id === activeId) || schedules[0];
return (
<div style={{
flex: 1, minHeight: 0, display: 'grid',
gridTemplateColumns: '320px 1fr',
background: '#f1f5f9', gap: 1,
}}>
<div style={{ background: '#fff', padding: 12, minWidth: 0, display: 'flex', flexDirection: 'column' }}>
<ScheduleListPane
items={schedules} activeId={active?.id} onSelect={setActiveId}
filter={filter} setFilter={setFilter} search={search} setSearch={setSearch}
onOpenCreate={onOpenCreate}
/>
</div>
<div style={{ background: '#fff', minWidth: 0 }}>
<ScheduleDetail sch={active} onPatch={onPatch} onTrigger={onTrigger} onDelete={onDelete} />
</div>
</div>
);
}
window.SchedulesPage = SchedulesPage;
@@ -0,0 +1,318 @@
// SettingsPage — sidebar groups + scrollable form (matches existing SettingsSidebar structure)
const SETTINGS_GROUPS = [
{
label: '基本設定',
sections: [
{ id: 'general', label: 'General', desc: 'タイムゾーン・言語' },
{ id: 'provider', label: 'Provider', desc: 'LLM API キー・デフォルトモデル' },
{ id: 'workers', label: 'Workers', desc: '並列数・タイムアウト・リトライ' },
{ id: 'workspace', label: 'Workspace', desc: '作業ディレクトリ・クリーンアップ' },
{ id: 'progress', label: 'Progress', desc: '進捗報告の頻度' },
],
},
{
label: 'セキュリティ・アクセス制御',
sections: [
{ id: 'repos', label: 'Repos', desc: 'Gitea 接続・許可リポジトリ' },
{ id: 'access-control', label: 'Access Control', desc: 'ロール・権限マトリクス' },
{ id: 'search-filter', label: 'Search Filter', desc: 'NGワード・ドメイン制限' },
],
},
{
label: 'ツール設定',
sections: [
{ id: 'tools', label: 'Tools', desc: '利用可能なツールの有効化' },
{ id: 'browser-settings', label: 'Browser', desc: 'noVNC・セッション' },
],
},
{
label: 'エージェント制御',
sections: [
{ id: 'ask-subtasks', label: 'Ask / Subtasks', desc: 'ASK・サブタスクの挙動' },
{ id: 'context', label: 'Context', desc: 'コンテキスト長・注入ルール' },
{ id: 'memory-safety', label: 'Memory / Safety', desc: 'メモリ制限・安全装置' },
],
},
];
const PIECES = ['auto', 'chat', 'research', 'general', 'x-ai-digest', 'brainstorming', 'data-process'];
function SettingsSidebar({ section, onSelect, piece, onSelectPiece }) {
const itemStyle = (active) => ({
display: 'block', width: '100%', textAlign: 'left',
padding: '6px 10px', borderRadius: 8, border: 'none', cursor: 'pointer',
fontSize: 12, fontFamily: 'inherit', marginBottom: 1,
background: active ? '#eff6ff' : 'transparent',
color: active ? '#1d4ed8' : '#475569',
fontWeight: active ? 700 : 500,
});
return (
<div style={{ height: '100%', overflowY: 'auto', padding: '12px 10px', background: '#fff', borderRight: '1px solid #e2e8f0' }}>
{SETTINGS_GROUPS.map(g => (
<div key={g.label} style={{ marginBottom: 12 }}>
<div style={{
fontSize: 10, fontWeight: 700, color: '#94a3b8', letterSpacing: '.08em',
textTransform: 'uppercase', padding: '4px 10px 4px', marginBottom: 2,
}}>{g.label}</div>
{g.sections.map(s => (
<button key={s.id} style={itemStyle(section === s.id && !piece)} onClick={() => onSelect(s.id)}>
{s.label}
</button>
))}
</div>
))}
<div style={{
fontSize: 10, fontWeight: 700, color: '#94a3b8', letterSpacing: '.08em',
textTransform: 'uppercase', padding: '4px 10px 4px', marginBottom: 2,
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
}}>
<span>Pieces</span>
<button title="Piece を追加" style={{
border: 'none', background: 'transparent', color: '#2563eb',
cursor: 'pointer', fontSize: 14, fontWeight: 700, padding: 0, lineHeight: 1,
}}>+</button>
</div>
{PIECES.map(p => (
<button key={p} style={itemStyle(piece === p)} onClick={() => onSelectPiece(p)}>
{p}
</button>
))}
</div>
);
}
function SaveBar({ onDiscard }) {
// 3 states: idle / saving / saved / error — demonstrated via toggle
const [state, setState] = React.useState('idle');
const [dirty, setDirty] = React.useState(false);
// mark dirty when any descendant input changes
const onInput = React.useCallback(() => setDirty(true), []);
React.useEffect(() => {
const h = () => setDirty(true);
document.addEventListener('input', h);
return () => document.removeEventListener('input', h);
}, []);
const save = async () => {
setState('saving');
// mock latency; in 1/5 odds surface an error to showcase failure UI
await new Promise(r => setTimeout(r, 700));
if (Math.random() < 0.2) {
setState('error');
return;
}
setState('saved'); setDirty(false);
setTimeout(() => setState('idle'), 1500);
};
const discard = () => { setDirty(false); setState('idle'); onDiscard?.(); };
return (
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
{state === 'saved' && (
<span style={{
fontSize: 11, color: '#166534', background: '#dcfce7', padding: '3px 8px',
borderRadius: 9999, fontWeight: 700, display: 'inline-flex', alignItems: 'center', gap: 4,
}}>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round"><path d="M5 13l4 4L19 7"/></svg>
保存しました
</span>
)}
{state === 'error' && (
<span style={{
fontSize: 11, color: '#b91c1c', background: '#fee2e2', padding: '3px 8px',
borderRadius: 9999, fontWeight: 700, display: 'inline-flex', alignItems: 'center', gap: 4,
}}> 保存に失敗</span>
)}
<button onClick={discard} disabled={!dirty || state === 'saving'} style={{
padding: '6px 12px', background: '#fff', border: '1px solid #e2e8f0', color: '#475569',
borderRadius: 8, fontSize: 12, fontWeight: 700,
cursor: (!dirty || state === 'saving') ? 'not-allowed' : 'pointer',
opacity: (!dirty || state === 'saving') ? 0.5 : 1,
fontFamily: 'inherit',
}}>Discard</button>
<button onClick={save} disabled={state === 'saving'} style={{
padding: '6px 14px', background: '#2563eb', border: 'none', color: '#fff',
borderRadius: 8, fontSize: 12, fontWeight: 700,
cursor: state === 'saving' ? 'wait' : 'pointer',
opacity: state === 'saving' ? 0.7 : 1,
fontFamily: 'inherit', display: 'inline-flex', alignItems: 'center', gap: 6,
}}>
{state === 'saving' && <Spinner />}
{state === 'saving' ? '保存中…' : 'Save & Apply'}
</button>
</div>
);
}
// Simple mock form surface — shows that the detail pane follows the exact same "card with form" rhythm as Schedules/Users.
function SettingsForm({ section, piece }) {
const meta = (() => {
for (const g of SETTINGS_GROUPS) for (const s of g.sections) if (s.id === section) return s;
return null;
})();
const title = piece ? `Piece: ${piece}` : (meta?.label || section);
const desc = piece ? `${piece} ピースの定義・ムーブメント・ツール設定` : (meta?.desc || '');
// Sample fields per-section (placeholder — the real forms are in gitea-agent-orchestrator/ui)
const fields = piece ? [
{ label: 'Description', kind: 'text', value: piece === 'auto' ? '入力内容から最適なピースを自動選択' : `${piece} 用の定義` },
{ label: 'Max movements', kind: 'number', value: 25 },
{ label: 'Initial movement', kind: 'select', value: 'execute', options: ['execute', 'plan', 'research'] },
] : ({
provider: [
{ label: 'Default provider', kind: 'select', value: 'anthropic', options: ['anthropic', 'openai', 'google', 'bedrock'] },
{ label: 'Model', kind: 'select', value: 'claude-sonnet-4.5', options: ['claude-sonnet-4.5', 'claude-opus-4', 'gpt-4.1'] },
{ label: 'API key', kind: 'password', value: 'sk-ant-•••••••••••••••••••••••', env: true },
{ label: 'Max tokens', kind: 'number', value: 8192 },
{ label: 'Temperature', kind: 'number', value: 0.7, step: 0.1 },
],
workers: [
{ label: 'Parallel workers', kind: 'number', value: 6 },
{ label: 'Per-task timeout (sec)', kind: 'number', value: 900 },
{ label: 'Max retries', kind: 'number', value: 3 },
{ label: 'Retry backoff (sec)', kind: 'number', value: 60 },
],
general: [
{ label: 'System timezone', kind: 'select', value: 'Asia/Tokyo', options: ['Asia/Tokyo', 'UTC', 'America/Los_Angeles'] },
{ label: 'Language', kind: 'select', value: 'ja', options: ['ja', 'en'] },
{ label: 'Allow anonymous task creation', kind: 'toggle', value: false },
],
repos: [
{ label: 'Gitea URL', kind: 'text', value: 'https://gitea.internal' },
{ label: 'Access token', kind: 'password', value: 'gitea_•••••••••••', env: true },
{ label: 'Allowed repos', kind: 'text', value: 'daichi/*, corp/*', help: 'カンマ区切り・グロブ可' },
],
'access-control': [
{ label: 'Admin ロール allow', kind: 'text', value: '*' },
{ label: 'Operator ロール allow', kind: 'text', value: 'tasks.*, schedules.*' },
{ label: 'Viewer ロール allow', kind: 'text', value: 'tasks.read, schedules.read' },
],
tools: [
{ label: 'Read / Write / Edit', kind: 'toggle', value: true },
{ label: 'Bash', kind: 'toggle', value: true },
{ label: 'Browser (noVNC)', kind: 'toggle', value: true },
{ label: 'WebSearch', kind: 'toggle', value: false },
],
'browser-settings': [
{ label: 'noVNC endpoint', kind: 'text', value: 'https://novnc.internal' },
{ label: 'Session timeout (min)', kind: 'number', value: 30 },
{ label: 'Allow CAPTCHA fallback to human', kind: 'toggle', value: true },
],
'ask-subtasks': [
{ label: 'Max ASK depth', kind: 'number', value: 3 },
{ label: 'Auto-resume after ASK timeout (min)', kind: 'number', value: 60 },
{ label: 'Allow parallel subtasks', kind: 'toggle', value: true },
],
context: [
{ label: 'Context window (tokens)', kind: 'number', value: 200000 },
{ label: 'Auto-compact threshold', kind: 'number', value: 80, help: '% で指定' },
],
'memory-safety': [
{ label: 'Memory limit per worker (MB)', kind: 'number', value: 2048 },
{ label: 'Kill on OOM', kind: 'toggle', value: true },
{ label: 'Safe-mode Bash commands only', kind: 'toggle', value: false },
],
progress: [
{ label: 'Progress update interval (sec)', kind: 'number', value: 15 },
{ label: 'Show subtask progress', kind: 'toggle', value: true },
],
workspace: [
{ label: 'Workspace root', kind: 'text', value: '/var/lib/agent/workspace' },
{ label: 'Clean after task completion', kind: 'toggle', value: false },
],
'search-filter': [
{ label: 'Blocked domains', kind: 'text', value: 'example-bad.com, *.malicious.example' },
{ label: 'NG words', kind: 'text', value: '', help: 'カンマ区切り' },
],
}[section] || []);
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
{/* Header */}
<div style={{
flexShrink: 0, padding: '14px 20px', borderBottom: '1px solid #e2e8f0', background: '#fff',
display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12,
}}>
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 10, fontWeight: 700, color: '#64748b', letterSpacing: '.08em', textTransform: 'uppercase' }}>
{piece ? 'PIECE' : 'SETTINGS'}
</div>
<div style={{ fontSize: 15, fontWeight: 700, color: '#0f172a' }}>{title}</div>
{desc && <div style={{ fontSize: 12, color: '#64748b', marginTop: 2 }}>{desc}</div>}
</div>
<div style={{ display: 'flex', gap: 8, flexShrink: 0 }}>
<SaveBar />
</div>
</div>
{/* Body */}
<div style={{ flex: 1, overflowY: 'auto', padding: '20px 24px', background: '#f8fafc' }}>
<div style={{ maxWidth: 640, margin: '0 auto' }}>
<div style={{
background: '#fff', border: '1px solid #e2e8f0', borderRadius: 16, padding: 20,
boxShadow: '0 1px 2px 0 rgb(0 0 0 / 0.05)',
}}>
{fields.map((f, i) => (
<FormRow key={i} label={
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
{f.label}
{f.env && <span style={{ fontSize: 9, fontWeight: 700, color: '#92400e', background: '#fef3c7', padding: '1px 5px', borderRadius: 3 }}>ENV</span>}
</span>
} help={f.help}>
{f.kind === 'toggle' ? (
<div style={{
display: 'inline-flex', alignItems: 'center', width: 44, height: 24, borderRadius: 9999,
background: f.value ? '#2563eb' : '#cbd5e1', padding: 2,
justifyContent: f.value ? 'flex-end' : 'flex-start', cursor: 'pointer',
}}>
<div style={{ width: 20, height: 20, borderRadius: 9999, background: '#fff', boxShadow: '0 1px 2px rgb(0 0 0 / .2)' }} />
</div>
) : f.kind === 'select' ? (
<SelectInput value={f.value} onChange={() => {}}>
{f.options.map(o => <option key={o} value={o}>{o}</option>)}
</SelectInput>
) : f.kind === 'password' ? (
<TextInput type="password" value={f.value} readOnly={!!f.env}
style={{ fontFamily: 'IBM Plex Mono, monospace', background: f.env ? '#f8fafc' : '#fff' }} />
) : f.kind === 'number' ? (
<TextInput type="number" value={f.value} step={f.step || 1} onChange={() => {}} />
) : (
<TextInput value={f.value} onChange={() => {}} />
)}
</FormRow>
))}
{fields.length === 0 && (
<EmptyState
compact
icon={<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="9"/><path d="M12 8v4M12 16h.01"/></svg>}
title="このセクションは未実装です"
hint="`gitea-agent-orchestrator/ui` 側で設定項目を追加するとここに表示されます。"
/>
)}
</div>
<div style={{ height: 40 }} />
</div>
</div>
</div>
);
}
function SettingsPage({ section, setSection, piece, setPiece }) {
return (
<div style={{
flex: 1, minHeight: 0, display: 'grid',
gridTemplateColumns: '240px 1fr',
background: '#f1f5f9', gap: 1,
}}>
<SettingsSidebar
section={section} onSelect={(s) => { setSection(s); setPiece(null); }}
piece={piece} onSelectPiece={(p) => setPiece(p)}
/>
<div style={{ background: '#fff', minWidth: 0 }}>
<SettingsForm section={section} piece={piece} />
</div>
</div>
);
}
window.SettingsPage = SettingsPage;
@@ -0,0 +1,199 @@
// TaskList — FilterBar + LocalTaskListItem recreation
const SORT_OPTIONS = [
{ value: 'updated', label: '新しい順' },
{ value: 'status', label: 'ステータス順' },
{ value: 'title', label: 'タイトル順' },
];
function SortMenu({ sort, onSort }) {
const [open, setOpen] = React.useState(false);
const ref = React.useRef(null);
React.useEffect(() => {
const h = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); };
document.addEventListener('mousedown', h); return () => document.removeEventListener('mousedown', h);
}, []);
const current = SORT_OPTIONS.find(o => o.value === sort) || SORT_OPTIONS[0];
return (
<div ref={ref} style={{ position: 'relative', flexShrink: 0 }}>
<button
onClick={() => setOpen(v => !v)}
title={`並び順: ${current.label}`}
style={{
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
width: 28, height: 28, border: 'none', background: open ? '#eff6ff' : 'transparent',
color: open ? '#1d4ed8' : '#64748b', borderRadius: 8, cursor: 'pointer',
}}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M3 6h13M3 12h9M3 18h5M17 8V4m0 0l-3 3m3-3l3 3"/></svg>
</button>
{open && (
<div style={{
position: 'absolute', right: 0, top: 'calc(100% + 6px)', zIndex: 10,
background: '#fff', border: '1px solid #e2e8f0', borderRadius: 12,
boxShadow: '0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)',
minWidth: 160, padding: 4,
}}>
{SORT_OPTIONS.map(o => (
<button key={o.value} onClick={() => { onSort(o.value); setOpen(false); }} style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
width: '100%', padding: '6px 10px', borderRadius: 8, border: 'none',
background: sort === o.value ? '#eff6ff' : 'transparent',
color: sort === o.value ? '#1d4ed8' : '#334155',
fontSize: 12, fontWeight: sort === o.value ? 700 : 500, cursor: 'pointer',
fontFamily: 'inherit', textAlign: 'left',
}}>
{o.label}
{sort === o.value && <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><path d="M5 13l4 4L19 7"/></svg>}
</button>
))}
</div>
)}
</div>
);
}
function FilterBar({ status, onStatus, search, onSearch, sort, onSort, counts, total }) {
const columns = ['queued', 'running', 'waiting_human', 'waiting_subtasks', 'retry', 'succeeded', 'failed', 'cancelled'];
const chipStyle = (active) => ({
flexShrink: 0, padding: '6px 10px', borderRadius: 9999,
fontSize: 11, fontWeight: 700, whiteSpace: 'nowrap', cursor: 'pointer',
border: '1px solid ' + (active ? '#2563eb' : '#e2e8f0'),
background: active ? '#eff6ff' : '#fff',
color: active ? '#1d4ed8' : '#64748b',
fontFamily: 'inherit',
});
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, paddingBottom: 12, borderBottom: '1px solid #e2e8f0' }}>
<div style={{
display: 'flex', alignItems: 'center', gap: 6, background: '#fff', border: '1px solid #e2e8f0',
borderRadius: 12, padding: '4px 6px 4px 12px', boxShadow: '0 1px 2px 0 rgb(0 0 0 / 0.05)',
}}>
<IconSearch width={14} height={14} style={{ color: '#94a3b8', flexShrink: 0 }} />
<input value={search} onChange={(e) => onSearch(e.target.value)} placeholder="検索..."
style={{ flex: 1, border: 'none', outline: 'none', background: 'transparent', fontSize: 13, fontFamily: 'inherit', color: '#0f172a', minWidth: 0, padding: '4px 0' }} />
<div style={{ width: 1, height: 18, background: '#e2e8f0', flexShrink: 0 }} />
<SortMenu sort={sort} onSort={onSort} />
</div>
<div style={{ display: 'flex', gap: 6, overflowX: 'auto', paddingBottom: 4 }}>
<button style={chipStyle(status === 'all')} onClick={() => onStatus('all')}>
All <span style={{ color: '#94a3b8', marginLeft: 2 }}>{total}</span>
</button>
{columns.map(s => (
<button key={s} style={chipStyle(status === s)} onClick={() => onStatus(s)}>
{STATUS_LABELS[s]} <span style={{ color: '#94a3b8', marginLeft: 2 }}>{counts[s] || 0}</span>
</button>
))}
</div>
</div>
);
}
function TaskItem({ task, active, onClick }) {
return (
<button onClick={onClick} style={{
width: '100%', textAlign: 'left', padding: '10px 12px', borderRadius: 12,
border: '1px solid ' + (active ? '#3b82f6' : '#e2e8f0'),
background: active ? '#eff6ff' : '#fff',
cursor: 'pointer', transition: 'background .15s', fontFamily: 'inherit',
}}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, minWidth: 0 }}>
<div style={{ fontSize: 13, fontWeight: 700, color: '#0f172a', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
#{task.id} {task.title}
</div>
<StatusBadge status={task.status} small />
</div>
<div style={{ marginTop: 2, fontSize: 11, color: '#64748b', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{task.body.length > 60 ? task.body.slice(0, 60) + '…' : task.body}
</div>
<div style={{ marginTop: 2, fontSize: 10, color: '#94a3b8' }}>{relativeTime(task.updatedAt)}</div>
</button>
);
}
function TaskList({ tasks, activeId, onSelect, filters, setFilters, onOpenCreate, loading, error, onRetry }) {
const counts = {};
for (const s of ['queued', 'running', 'waiting_human', 'waiting_subtasks', 'retry', 'succeeded', 'failed', 'cancelled']) {
counts[s] = tasks.filter(t => t.status === s).length;
}
const running = counts.running || 0;
const waiting = (counts.waiting_human || 0) + (counts.waiting_subtasks || 0);
const failed = counts.failed || 0;
const filtered = tasks
.filter(t => filters.status === 'all' || t.status === filters.status)
.filter(t => !filters.search || (t.title + t.body).toLowerCase().includes(filters.search.toLowerCase()))
.sort((a, b) => filters.sort === 'title' ? a.title.localeCompare(b.title) : b.updatedAt - a.updatedAt);
const hasSearch = !!filters.search || filters.status !== 'all';
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
<button onClick={onOpenCreate} style={{
width: '100%', padding: '10px 14px', marginBottom: 10,
background: '#2563eb', color: '#fff', borderRadius: 12,
fontSize: 13, fontWeight: 700, border: 'none', cursor: 'pointer',
fontFamily: 'inherit', display: 'inline-flex', alignItems: 'center',
justifyContent: 'center', gap: 6, boxShadow: '0 1px 2px 0 rgb(0 0 0 / 0.05)',
transition: 'background .15s',
}}
onMouseEnter={(e) => e.currentTarget.style.background = '#1d4ed8'}
onMouseLeave={(e) => e.currentTarget.style.background = '#2563eb'}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round"><path d="M12 5v14M5 12h14"/></svg>
新しい依頼
</button>
<div style={{
display: 'flex', alignItems: 'center', gap: 10,
fontSize: 11, color: '#64748b', padding: '0 2px 10px',
}}>
<span><b style={{ color: '#334155', fontWeight: 700 }}>{tasks.length}</b> </span>
<span style={{ color: '#cbd5e1' }}>·</span>
<span><b style={{ color: '#16a34a', fontWeight: 700 }}>{running}</b> 実行中</span>
<span><b style={{ color: '#d97706', fontWeight: 700 }}>{waiting}</b> 待機</span>
{failed > 0 && <span><b style={{ color: '#dc2626', fontWeight: 700 }}>{failed}</b> 失敗</span>}
</div>
<FilterBar
status={filters.status} onStatus={(s) => setFilters(f => ({ ...f, status: s }))}
search={filters.search} onSearch={(q) => setFilters(f => ({ ...f, search: q }))}
sort={filters.sort} onSort={(s) => setFilters(f => ({ ...f, sort: s }))}
counts={counts} total={tasks.length}
/>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginTop: 8, overflowY: 'auto', flex: 1, minHeight: 0 }}>
{loading && <SkeletonList count={6} />}
{!loading && error && (
<ErrorState
title="タスクの読み込みに失敗"
hint={error}
onRetry={onRetry}
compact
/>
)}
{!loading && !error && filtered.map(t => <TaskItem key={t.id} task={t} active={activeId === t.id} onClick={() => onSelect(t.id)} />)}
{!loading && !error && filtered.length === 0 && (
hasSearch ? (
<EmptyState
compact
icon={<IconSearch width={18} height={18} />}
title="該当するタスクはありません"
hint="検索ワードやステータスフィルタを変えてみてください。"
action={
<button onClick={() => setFilters(f => ({ ...f, search: '', status: 'all' }))} style={{
padding: '6px 12px', borderRadius: 8, fontSize: 12, fontWeight: 700,
background: '#fff', border: '1px solid #e2e8f0', color: '#334155',
cursor: 'pointer', fontFamily: 'inherit',
}}>フィルタをクリア</button>
}
/>
) : (
<EmptyState
compact
icon={<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M9 12h6M9 16h6M9 8h6M5 21h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v14a2 2 0 002 2z"/></svg>}
title="まだ依頼がありません"
hint="左上の「新しい依頼」から最初のタスクを作成できます。"
/>
)
)}
</div>
</div>
);
}
window.TaskList = TaskList;
@@ -0,0 +1,57 @@
// TopBar — mirrors ui/src/components/layout/TopBar.tsx
function TopBar({ page, onNavigate, counts, onOpenCreate, user }) {
const navItem = (id, label) => {
const active = page === id;
return (
<button
key={id}
onClick={() => onNavigate(id)}
style={{
position: 'relative',
padding: '10px 4px', marginBottom: -13,
fontSize: 12, fontWeight: active ? 700 : 500,
border: 'none', background: 'transparent', cursor: 'pointer', fontFamily: 'inherit',
color: active ? '#0f172a' : '#64748b',
borderBottom: '2px solid ' + (active ? '#2563eb' : 'transparent'),
transition: 'color .15s, border-color .15s',
}}
>{label}</button>
);
};
return (
<div style={{
flexShrink: 0, background: '#fff', borderBottom: '1px solid #e2e8f0',
padding: '12px 16px', display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
<img src="../../assets/logo.svg" width="22" height="22" alt="" />
<span style={{
fontFamily: 'IBM Plex Mono, monospace', fontSize: 11, fontWeight: 700,
color: '#2563eb', textTransform: 'uppercase', letterSpacing: '.16em',
}}>Agent Orchestrator</span>
<span style={{ fontFamily: 'IBM Plex Mono, monospace', fontSize: 10, color: '#94a3b8' }}>v1.14.0</span>
<div style={{ display: 'flex', gap: 14, alignItems: 'stretch' }}>
{navItem('tasks', 'Tasks')}
{navItem('schedules', 'Schedules')}
{navItem('settings', 'Settings')}
{navItem('users', 'Users')}
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
{user && (
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<div style={{
width: 24, height: 24, borderRadius: 9999, background: '#dbeafe', color: '#1d4ed8',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center', fontSize: 11, fontWeight: 700,
}}>{user.name.charAt(0)}</div>
<span style={{ fontSize: 12, color: '#475569' }}>{user.name}</span>
</div>
)}
</div>
</div>
);
}
window.TopBar = TopBar;
@@ -0,0 +1,313 @@
// UsersPage — left list of users + center profile/role editor
// Data model derives from ui/src/pages/UsersPage.tsx.
const ROLE_TONE = {
admin: { bg: '#ede9fe', fg: '#5b21b6' },
operator: { bg: '#dbeafe', fg: '#1d4ed8' },
viewer: { bg: '#e2e8f0', fg: '#475569' },
};
const USER_STATUS_TONE = {
pending: { bg: '#fef9c3', fg: '#854d0e', label: '承認待ち' },
active: { bg: '#dcfce7', fg: '#166534', label: 'アクティブ' },
disabled: { bg: '#e2e8f0', fg: '#475569', label: '無効' },
};
function UserAvatar({ name, size = 32 }) {
const initial = (name || '?').charAt(0).toUpperCase();
// simple deterministic hue from name
let hue = 0; for (const c of (name || '')) hue = (hue * 31 + c.charCodeAt(0)) % 360;
return (
<div style={{
width: size, height: size, borderRadius: 9999,
background: `hsl(${hue} 60% 92%)`, color: `hsl(${hue} 50% 35%)`,
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
fontSize: size * 0.45, fontWeight: 800, flexShrink: 0,
}}>{initial}</div>
);
}
function UserListItem({ user, active, onClick }) {
return (
<button onClick={onClick} style={{
width: '100%', textAlign: 'left', padding: '10px 12px', borderRadius: 12,
border: '1px solid ' + (active ? '#3b82f6' : '#e2e8f0'),
background: active ? '#eff6ff' : '#fff',
cursor: 'pointer', transition: 'background .15s', fontFamily: 'inherit',
display: 'flex', alignItems: 'center', gap: 10,
}}>
<UserAvatar name={user.name || user.email} size={36} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, minWidth: 0 }}>
<div style={{
flex: 1, minWidth: 0,
fontSize: 13, fontWeight: 700, color: '#0f172a',
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}}>{user.name || '(未設定)'}</div>
{user.status === 'pending' && (
<span style={{
fontSize: 10, fontWeight: 700, color: '#854d0e', background: '#fef9c3',
padding: '1px 6px', borderRadius: 4, flexShrink: 0,
}}>承認</span>
)}
</div>
<div style={{ fontSize: 11, color: '#64748b', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{user.email}
</div>
<div style={{ marginTop: 2, display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{
fontSize: 10, fontWeight: 700, padding: '1px 6px', borderRadius: 4,
background: ROLE_TONE[user.role]?.bg, color: ROLE_TONE[user.role]?.fg,
}}>{user.role}</span>
<span style={{ fontSize: 10, color: '#94a3b8' }}>· {user.taskCount} タスク</span>
</div>
</div>
</button>
);
}
function UserListPane({ users, activeId, onSelect, filter, setFilter, search, setSearch, onOpenInvite }) {
const filtered = users.filter(u => {
if (filter !== 'all' && u.role !== filter && u.status !== filter) return false;
if (search && !((u.name || '') + u.email).toLowerCase().includes(search.toLowerCase())) return false;
return true;
});
const counts = {
all: users.length,
admin: users.filter(u => u.role === 'admin').length,
operator: users.filter(u => u.role === 'operator').length,
viewer: users.filter(u => u.role === 'viewer').length,
pending: users.filter(u => u.status === 'pending').length,
};
const chipStyle = (on) => ({
flexShrink: 0, padding: '6px 10px', borderRadius: 9999,
fontSize: 11, fontWeight: 700, whiteSpace: 'nowrap', cursor: 'pointer',
border: '1px solid ' + (on ? '#2563eb' : '#e2e8f0'),
background: on ? '#eff6ff' : '#fff',
color: on ? '#1d4ed8' : '#64748b', fontFamily: 'inherit',
});
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
<button onClick={onOpenInvite} style={{
width: '100%', padding: '10px 14px', marginBottom: 10, background: '#2563eb',
color: '#fff', borderRadius: 12, fontSize: 13, fontWeight: 700, border: 'none',
cursor: 'pointer', fontFamily: 'inherit', display: 'inline-flex', alignItems: 'center',
justifyContent: 'center', gap: 6, boxShadow: '0 1px 2px 0 rgb(0 0 0 / 0.05)',
}}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round"><path d="M12 5v14M5 12h14"/></svg>
ユーザーを招待
</button>
<div style={{
display: 'flex', alignItems: 'center', gap: 10, fontSize: 11,
color: '#64748b', padding: '0 2px 10px',
}}>
<span><b style={{ color: '#334155', fontWeight: 700 }}>{counts.all}</b> </span>
{counts.pending > 0 && <><span style={{ color: '#cbd5e1' }}>·</span>
<span><b style={{ color: '#d97706', fontWeight: 700 }}>{counts.pending}</b> 承認待ち</span></>}
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, paddingBottom: 12, borderBottom: '1px solid #e2e8f0' }}>
<div style={{
display: 'flex', alignItems: 'center', gap: 8, background: '#fff', border: '1px solid #e2e8f0',
borderRadius: 12, padding: '6px 12px', boxShadow: '0 1px 2px 0 rgb(0 0 0 / 0.05)',
}}>
<IconSearch width={14} height={14} style={{ color: '#94a3b8', flexShrink: 0 }} />
<input value={search} onChange={(e) => setSearch(e.target.value)} placeholder="名前・メールで検索..."
style={{ flex: 1, border: 'none', outline: 'none', background: 'transparent', fontSize: 13, fontFamily: 'inherit', color: '#0f172a', minWidth: 0 }} />
</div>
<div style={{ display: 'flex', gap: 6, overflowX: 'auto', paddingBottom: 4 }}>
<button style={chipStyle(filter === 'all')} onClick={() => setFilter('all')}>All <span style={{ color: '#94a3b8', marginLeft: 2 }}>{counts.all}</span></button>
<button style={chipStyle(filter === 'admin')} onClick={() => setFilter('admin')}>Admin <span style={{ color: '#94a3b8', marginLeft: 2 }}>{counts.admin}</span></button>
<button style={chipStyle(filter === 'operator')} onClick={() => setFilter('operator')}>Operator <span style={{ color: '#94a3b8', marginLeft: 2 }}>{counts.operator}</span></button>
<button style={chipStyle(filter === 'viewer')} onClick={() => setFilter('viewer')}>Viewer <span style={{ color: '#94a3b8', marginLeft: 2 }}>{counts.viewer}</span></button>
{counts.pending > 0 && <button style={chipStyle(filter === 'pending')} onClick={() => setFilter('pending')}>承認待ち <span style={{ color: '#94a3b8', marginLeft: 2 }}>{counts.pending}</span></button>}
</div>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginTop: 8, overflowY: 'auto', flex: 1, minHeight: 0 }}>
{filtered.map(u => <UserListItem key={u.id} user={u} active={activeId === u.id} onClick={() => onSelect(u.id)} />)}
{filtered.length === 0 && (
(search || filter !== 'all') ? (
<EmptyState
compact
icon={<IconSearch width={18} height={18} />}
title="該当するユーザーがいません"
hint="検索やフィルタを変えてみてください。"
action={
<button onClick={() => { setSearch(''); setFilter('all'); }} style={{
padding: '6px 12px', borderRadius: 8, fontSize: 12, fontWeight: 700,
background: '#fff', border: '1px solid #e2e8f0', color: '#334155',
cursor: 'pointer', fontFamily: 'inherit',
}}>フィルタをクリア</button>
}
/>
) : (
<EmptyState
compact
icon={<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 00-3-3.87M16 3.13a4 4 0 010 7.75"/></svg>}
title="ユーザーがいません"
hint="右上の「ユーザーを招待」から追加できます。"
/>
)
)}
</div>
</div>
);
}
function UserDetail({ user, onPatch, onDelete, onApprove }) {
if (!user) {
return (
<div style={{ padding: 40, display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%' }}>
<EmptyState
icon={<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="8" r="4"/><path d="M4 21v-1a8 8 0 0116 0v1"/></svg>}
title="ユーザーを選択してください"
hint="左のリストから表示・編集したいユーザーを開きます。"
/>
</div>
);
}
const statusTone = USER_STATUS_TONE[user.status] || USER_STATUS_TONE.active;
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
{/* Header */}
<div style={{
flexShrink: 0, padding: '14px 20px', borderBottom: '1px solid #e2e8f0', background: '#fff',
display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12,
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, minWidth: 0 }}>
<UserAvatar name={user.name || user.email} size={40} />
<div style={{ minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<div style={{ fontSize: 15, fontWeight: 700, color: '#0f172a' }}>{user.name || '(未設定)'}</div>
<span style={{
fontSize: 10, fontWeight: 700, padding: '2px 8px', borderRadius: 9999,
background: statusTone.bg, color: statusTone.fg,
}}>{statusTone.label}</span>
</div>
<div style={{ fontSize: 12, color: '#64748b' }}>{user.email}</div>
</div>
</div>
<div style={{ display: 'flex', gap: 8, flexShrink: 0 }}>
{user.status === 'pending' && (
<button onClick={() => onApprove(user.id)} style={{
padding: '6px 12px', background: '#16a34a', border: 'none', color: '#fff',
borderRadius: 8, fontSize: 12, fontWeight: 700, cursor: 'pointer', fontFamily: 'inherit',
}}>承認</button>
)}
<button onClick={() => onDelete(user.id)} style={{
padding: '6px 12px', background: '#fff', border: '1px solid #fecaca', color: '#dc2626',
borderRadius: 8, fontSize: 12, fontWeight: 700, cursor: 'pointer', fontFamily: 'inherit',
}}>削除</button>
</div>
</div>
{/* Body */}
<div style={{ flex: 1, overflowY: 'auto', padding: '20px 24px', background: '#f8fafc' }}>
<div style={{ maxWidth: 640, margin: '0 auto' }}>
{/* Summary strip */}
<div style={{ display: 'flex', gap: 10, marginBottom: 20, flexWrap: 'wrap' }}>
<StatChip label="タスク数" value={user.taskCount} />
<StatChip label="最終ログイン" value={user.lastLogin || '—'} />
<StatChip label="登録日" value={user.createdAt || '—'} />
</div>
{/* Role & permissions */}
<div style={{
background: '#fff', border: '1px solid #e2e8f0', borderRadius: 16, padding: 20,
boxShadow: '0 1px 2px 0 rgb(0 0 0 / 0.05)',
}}>
<div style={{ fontSize: 11, fontWeight: 700, color: '#64748b', letterSpacing: '.08em', textTransform: 'uppercase', marginBottom: 14 }}>
ロールと権限
</div>
{[
{ id: 'admin', label: 'Admin', desc: '全ての設定変更・ユーザー管理・システム操作' },
{ id: 'operator', label: 'Operator', desc: 'タスク作成・実行・スケジュール管理' },
{ id: 'viewer', label: 'Viewer', desc: '閲覧のみ。タスクの作成・変更不可' },
].map(r => (
<button key={r.id} onClick={() => onPatch(user.id, { role: r.id })} style={{
width: '100%', textAlign: 'left', padding: '12px 14px', borderRadius: 10,
border: '1px solid ' + (user.role === r.id ? '#2563eb' : '#e2e8f0'),
background: user.role === r.id ? '#eff6ff' : '#fff',
cursor: 'pointer', fontFamily: 'inherit', marginBottom: 8,
display: 'flex', alignItems: 'center', gap: 12,
}}>
<span style={{
width: 18, height: 18, borderRadius: 9999, flexShrink: 0,
border: '2px solid ' + (user.role === r.id ? '#2563eb' : '#cbd5e1'),
background: user.role === r.id ? '#2563eb' : '#fff',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
}}>
{user.role === r.id && <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="#fff" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round"><path d="M5 13l4 4L19 7"/></svg>}
</span>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 13, fontWeight: 700, color: '#0f172a' }}>{r.label}</div>
<div style={{ fontSize: 11, color: '#64748b', marginTop: 2 }}>{r.desc}</div>
</div>
</button>
))}
</div>
{/* Profile */}
<div style={{
background: '#fff', border: '1px solid #e2e8f0', borderRadius: 16, padding: 20,
marginTop: 16, boxShadow: '0 1px 2px 0 rgb(0 0 0 / 0.05)',
}}>
<div style={{ fontSize: 11, fontWeight: 700, color: '#64748b', letterSpacing: '.08em', textTransform: 'uppercase', marginBottom: 14 }}>
プロフィール
</div>
<FormRow label="表示名">
<TextInput value={user.name || ''} onChange={e => onPatch(user.id, { name: e.target.value })} />
</FormRow>
<FormRow label="メールアドレス">
<TextInput value={user.email} readOnly style={{ background: '#f8fafc', color: '#64748b' }} />
</FormRow>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<FormRow label="タイムゾーン">
<SelectInput value={user.timezone || 'Asia/Tokyo'} onChange={e => onPatch(user.id, { timezone: e.target.value })}>
<option value="Asia/Tokyo">Asia/Tokyo</option>
<option value="UTC">UTC</option>
<option value="America/Los_Angeles">America/Los_Angeles</option>
<option value="Europe/London">Europe/London</option>
</SelectInput>
</FormRow>
<FormRow label="デフォルトピース">
<SelectInput value={user.defaultPiece || 'auto'} onChange={e => onPatch(user.id, { defaultPiece: e.target.value })}>
<option value="auto">auto</option>
<option value="chat">chat</option>
<option value="research">research</option>
</SelectInput>
</FormRow>
</div>
</div>
<div style={{ height: 40 }} />
</div>
</div>
</div>
);
}
function UsersPage({ users, activeId, setActiveId, onPatch, onDelete, onApprove, onOpenInvite }) {
const [filter, setFilter] = React.useState('all');
const [search, setSearch] = React.useState('');
const active = users.find(u => u.id === activeId) || users[0];
return (
<div style={{
flex: 1, minHeight: 0, display: 'grid',
gridTemplateColumns: '320px 1fr',
background: '#f1f5f9', gap: 1,
}}>
<div style={{ background: '#fff', padding: 12, minWidth: 0, display: 'flex', flexDirection: 'column' }}>
<UserListPane
users={users} activeId={active?.id} onSelect={setActiveId}
filter={filter} setFilter={setFilter} search={search} setSearch={setSearch}
onOpenInvite={onOpenInvite}
/>
</div>
<div style={{ background: '#fff', minWidth: 0 }}>
<UserDetail user={active} onPatch={onPatch} onDelete={onDelete} onApprove={onApprove} />
</div>
</div>
);
}
window.UsersPage = UsersPage;
@@ -0,0 +1,399 @@
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8">
<title>Agent Orchestrator — Admin</title>
<link rel="stylesheet" href="../../colors_and_type.css">
<style>
* { box-sizing: border-box; }
html, body { height: 100%; margin: 0; }
body {
font-family: var(--font-sans);
background: var(--bg-app, #f8fafc);
color: var(--fg1, #0f172a);
font-size: 13px;
-webkit-font-smoothing: antialiased;
}
@keyframes ao-spin { from { transform: rotate(0) } to { transform: rotate(360deg) } }
@keyframes ao-pulse { 0%, 100% { opacity: 1 } 50% { opacity: 0.35 } }
@keyframes ao-shimmer { 0% { background-position: 200% 0 } 100% { background-position: -200% 0 } }
::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-thumb { background: #cbd5e1; border: 2px solid #f8fafc; border-radius: 10px; }
::-webkit-scrollbar-thumb:hover { background: #94a3b8; }
</style>
</head>
<body>
<div id="root"></div>
<script src="https://unpkg.com/[email protected]/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
<script src="https://unpkg.com/[email protected]/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
<script src="https://unpkg.com/@babel/[email protected]/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
<script type="text/babel" src="./Primitives.jsx"></script>
<script type="text/babel" src="./TopBar.jsx"></script>
<script type="text/babel" src="./TaskList.jsx"></script>
<script type="text/babel" src="./ChatPane.jsx"></script>
<script type="text/babel" src="./DetailPanel.jsx"></script>
<script type="text/babel" src="./SchedulesPage.jsx"></script>
<script type="text/babel" src="./UsersPage.jsx"></script>
<script type="text/babel" src="./SettingsPage.jsx"></script>
<script type="text/babel">
const MIN = 60 * 1000;
const H = 60 * MIN;
const now = Date.now();
const SAMPLE_TASKS = [
{
id: 412, title: 'Xの朝のAIダイジェスト生成',
body: '毎朝 7:00 JST にフォロー中のAI関連アカウントの過去24hをサマリし、DMで送信する。Twitter CLIを使用し、1スレッドにまとめること。',
status: 'running', piece: 'x-ai-digest', worker: 'worker-03', attempts: 1,
assignee: '@daichi', repo: 'gitea:daichi/agent-orchestrator', branch: 'task/412-morning-digest',
createdAt: now - 2*H, updatedAt: now - 4*MIN,
events: [
{ kind: 'info', label: '/brainstorm 完了', meta: '12個のアイデアを生成', time: '10:42' },
{ kind: 'info', label: '/plan 完了', meta: '12ステップ · 推定 4分', time: '10:43' },
{ kind: 'info', label: '/implement 実行中', meta: 'ステップ 8 / 12', time: '10:45' },
],
},
{
id: 411, title: 'Brave Search の CAPTCHA 回避',
body: 'noVNC経由でBraveに繰り返しCAPTCHAが発生。ユーザーの介入が必要。',
status: 'waiting_human', piece: 'general', worker: 'worker-01', attempts: 2,
assignee: '@daichi', repo: 'gitea:daichi/agent-orchestrator', branch: 'task/411-brave-captcha',
createdAt: now - 3*H, updatedAt: now - 18*MIN,
events: [
{ kind: 'info', label: '/brainstorm 完了', meta: '', time: '08:10' },
{ kind: 'error', label: 'ASK が発行されました', meta: 'CAPTCHAの解決を依頼', time: '08:22' },
],
},
{
id: 410, title: 'GitHub Issue #284 の対応',
body: 'scheduler.ts のタイムアウト処理リファクタ。worker-manager.test.ts を更新。',
status: 'succeeded', piece: 'general', worker: 'worker-02', attempts: 1,
assignee: '@daichi', repo: 'gitea:daichi/agent-orchestrator', branch: 'task/410-sched-timeout',
createdAt: now - 8*H, updatedAt: now - 2*H,
events: [
{ kind: 'info', label: '/plan 完了', meta: '7ステップ', time: '02:11' },
{ kind: 'ok', label: 'PR 作成', meta: '#284 テスト通過', time: '04:08' },
],
},
{
id: 409, title: 'ブレスト: 社内AIエージェント活用事例',
body: '営業部向け、週次の活用アイデアを10件ブレストし、優先順位をつけて提出。',
status: 'queued', piece: 'brainstorming', worker: null, attempts: 0,
assignee: '@tomoko', repo: 'gitea:corp/ops', branch: '—',
createdAt: now - 30*MIN, updatedAt: now - 25*MIN,
events: [],
},
{
id: 408, title: '四半期データの集計とグラフ化',
body: 'Q3のSNSエンゲージメントを集計し、CSVとPNGで出力。',
status: 'waiting_subtasks', piece: 'data-process', worker: 'worker-05', attempts: 1,
assignee: '@kenta', repo: 'gitea:corp/analytics', branch: 'task/408-q3-roundup',
createdAt: now - 5*H, updatedAt: now - 45*MIN,
events: [
{ kind: 'info', label: 'サブタスク3件を発行', meta: '#408-1, #408-2, #408-3', time: '09:30' },
],
},
{
id: 407, title: '競合サービスのリサーチ',
body: 'エージェント型ワーカー系SaaSを3社分析し、比較表を作成する。',
status: 'failed', piece: 'research', worker: 'worker-04', attempts: 3,
assignee: '@tomoko', repo: 'gitea:corp/research', branch: 'task/407-competitors',
createdAt: now - 26*H, updatedAt: now - 10*H,
events: [
{ kind: 'error', label: 'タイムアウト', meta: '3回連続で失敗', time: 'yesterday' },
],
},
{
id: 406, title: 'ゲーム実況の告知ツイート生成',
body: '今夜のストリーム用の告知ツイートを3案作成。ハッシュタグ付き。',
status: 'retry', piece: 'game-tweet-generator', worker: 'worker-06', attempts: 2,
assignee: '@daichi', repo: 'gitea:daichi/stream', branch: 'task/406-tweet-gen',
createdAt: now - 40*MIN, updatedAt: now - 8*MIN,
events: [
{ kind: 'error', label: 'レート制限', meta: '60秒後に再試行', time: '10:35' },
],
},
{
id: 405, title: '経費申請書のOCRと仕分け',
body: '添付PDFをOCRし、勘定科目ごとに仕分け。',
status: 'cancelled', piece: 'office-process', worker: null, attempts: 1,
assignee: '@kenta', repo: 'gitea:corp/ops', branch: '—',
createdAt: now - 48*H, updatedAt: now - 20*H,
events: [],
},
];
const INITIAL_MESSAGES = {
412: [
{ role: 'user', content: '毎朝7:00にAI関連のXアカウントの過去24hをまとめて、DMで送ってほしい。1スレッドで。', footer: '10:41 · @daichi' },
{ role: 'assistant', content: '了解。x-ai-digest ピースを使用します。対象アカウント、まとめる観点、文字数制限を確認させてください。' },
{ role: 'ask', content: '❓ 以下を確認させてください:\n\n1. 対象アカウントリストはこのリポジトリの accounts.txt で良いですか?\n2. 1ツイートあたりの上限文字数は280でOK?\n3. 日本語メインの要約で良いですか?' },
{ role: 'user', content: '1. OK\n2. OK\n3. 日本語で。でも原文が英語なら簡潔な英語の引用も残して。' },
{ role: 'progress', content: '/implement — ステップ 8 / 12 (Twitter CLIでタイムライン取得中)' },
],
411: [
{ role: 'user', content: 'Brave Searchで検索結果が取れない。何度もCAPTCHAが出てるっぽい。' },
{ role: 'progress', content: 'noVNCセッションを開いて確認中...' },
{ role: 'ask', content: '❓ CAPTCHAの解決が必要です。noVNCで手動で解決していただけますか?\n\nsession: https://novnc.internal/412\n\n解決後 `/resume 411` と返信してください。' },
],
410: [
{ role: 'user', content: 'Issue #284 の対応お願い。scheduler.tsのタイムアウト処理が不安定。' },
{ role: 'assistant', content: '了解。/brainstorm から始めます。' },
{ role: 'result', content: '✅ 完了しました。\n\n- PR: gitea:daichi/agent-orchestrator#291\n- 変更: scheduler.ts, worker-manager.test.ts, worker.test.ts\n- テスト: 42 passed\n\nレビューお願いします。' },
],
409: [
{ role: 'user', content: '営業部向けに今週のエージェント活用ネタを10個ブレストしてほしい。' },
{ role: 'assistant', content: '了解。キューに入りました。ワーカーの空きが出次第処理します。' },
],
408: [
{ role: 'user', content: 'Q3のSNSエンゲージメントまとめて、折れ線グラフと棒グラフのPNGで。' },
{ role: 'progress', content: 'サブタスク3件の完了を待っています (#408-1, #408-2, #408-3)' },
],
407: [
{ role: 'user', content: '競合3社のリサーチと比較表を作成。' },
{ role: 'assistant', content: '3回試行しましたが、外部サイトの読み込みタイムアウトが続いています。' },
],
406: [
{ role: 'user', content: '今夜のストリーム告知を3案、ハッシュタグ付きで。' },
{ role: 'progress', content: 'レート制限中 — 60秒後に再試行します' },
],
405: [
{ role: 'user', content: '経費PDFのOCRと仕分け。' },
{ role: 'assistant', content: 'キャンセルされました。' },
],
};
const SAMPLE_SCHEDULES = [
{
id: 1, title: '毎朝のAIダイジェスト', body: 'フォロー中のAI関連アカウントの過去24hをサマリし、DMで送る。',
pieceName: 'x-ai-digest', outputFormat: 'markdown',
triggerKind: 'cron', cronExpression: '0 7 * * *',
nextRunAt: new Date(now + 8*H).toISOString(),
lastRunAt: new Date(now - 16*H).toISOString(),
isActive: true,
history: [
{ taskId: 412, status: 'running', summary: '実行中', at: new Date(now - 4*MIN).toISOString() },
{ taskId: 398, status: 'succeeded', summary: '12アカウント ・ 8件のハイライト', at: new Date(now - 16*H).toISOString() },
{ taskId: 385, status: 'succeeded', summary: '9アカウント ・ 5件のハイライト', at: new Date(now - 40*H).toISOString() },
{ taskId: 373, status: 'failed', summary: 'Twitter API レート制限', at: new Date(now - 64*H).toISOString() },
],
},
{
id: 2, title: '週次ニュースまとめ (月曜09:00)', body: '先週の業界ニュースを5本まとめ、社内Slackに投稿。',
pieceName: 'research', outputFormat: 'markdown',
triggerKind: 'cron', cronExpression: '0 9 * * 1',
nextRunAt: new Date(now + 4*24*H).toISOString(),
lastRunAt: new Date(now - 3*24*H).toISOString(),
isActive: true,
history: [
{ taskId: 340, status: 'succeeded', summary: '5本投稿', at: new Date(now - 3*24*H).toISOString() },
],
},
{
id: 3, title: 'GitHub Issue 自動トリアージ', body: '新規 Issue にラベル付けし、優先度を判定してコメント。',
pieceName: 'general', outputFormat: 'json',
triggerKind: 'event', eventSource: 'github.issue.opened', eventFilter: 'repo == "agent-orchestrator"',
lastRunAt: new Date(now - 2*H).toISOString(),
isActive: true,
history: [
{ taskId: 408, status: 'succeeded', summary: '#294 に bugラベルを付与', at: new Date(now - 2*H).toISOString() },
{ taskId: 402, status: 'succeeded', summary: '#293 に enhancementラベル', at: new Date(now - 6*H).toISOString() },
],
},
{
id: 4, title: '月次レポート生成', body: '月末にKPIサマリを作成し、経営会議用PDFを出力。',
pieceName: 'data-process', outputFormat: 'markdown',
triggerKind: 'cron', cronExpression: '0 18 28 * *',
nextRunAt: new Date(now + 10*24*H).toISOString(),
lastRunAt: null,
isActive: false,
history: [],
},
];
const SAMPLE_USERS = [
{ id: 'u1', name: 'Daichi', email: '[email protected]', role: 'admin', status: 'active', taskCount: 142, lastLogin: 'たった今', createdAt: '2025-11-02', timezone: 'Asia/Tokyo', defaultPiece: 'auto' },
{ id: 'u2', name: 'Tomoko', email: '[email protected]', role: 'operator', status: 'active', taskCount: 38, lastLogin: '2時間前', createdAt: '2025-12-14', timezone: 'Asia/Tokyo', defaultPiece: 'chat' },
{ id: 'u3', name: 'Kenta', email: '[email protected]', role: 'operator', status: 'active', taskCount: 21, lastLogin: '昨日', createdAt: '2026-01-20', timezone: 'Asia/Tokyo', defaultPiece: 'research' },
{ id: 'u4', name: 'Aya', email: '[email protected]', role: 'viewer', status: 'active', taskCount: 4, lastLogin: '3日前', createdAt: '2026-02-09', timezone: 'Asia/Tokyo', defaultPiece: 'auto' },
{ id: 'u5', name: null, email: '[email protected]', role: 'viewer', status: 'pending', taskCount: 0, lastLogin: '—', createdAt: '2026-04-17', timezone: 'Asia/Tokyo', defaultPiece: 'auto' },
{ id: 'u6', name: 'Hiro', email: '[email protected]', role: 'viewer', status: 'disabled', taskCount: 2, lastLogin: '1ヶ月前', createdAt: '2026-01-03', timezone: 'Asia/Tokyo', defaultPiece: 'auto' },
];
function DemoStateFloater({ state, setState }) {
const [open, setOpen] = React.useState(false);
const STATES = [
{ id: 'normal', label: '通常', hint: 'サンプルデータを表示' },
{ id: 'loading', label: 'Loading', hint: 'スケルトンを表示' },
{ id: 'error', label: 'Error', hint: 'エラー状態と再試行ボタン' },
{ id: 'empty', label: 'Empty', hint: 'データなし・初回利用' },
];
const current = STATES.find(s => s.id === state) || STATES[0];
return (
<div style={{
position: 'fixed', right: 16, bottom: 16, zIndex: 50,
display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 8,
}}>
{open && (
<div style={{
background: '#fff', border: '1px solid #e2e8f0', borderRadius: 12,
boxShadow: '0 10px 25px -5px rgb(0 0 0 / 0.15), 0 8px 10px -6px rgb(0 0 0 / 0.1)',
padding: 8, minWidth: 220,
}}>
<div style={{
fontSize: 10, fontWeight: 700, color: '#94a3b8', letterSpacing: '.08em',
textTransform: 'uppercase', padding: '4px 10px 6px',
}}>デモ状態</div>
{STATES.map(s => (
<button key={s.id} onClick={() => setState(s.id)} style={{
display: 'block', width: '100%', textAlign: 'left', padding: '8px 10px',
border: 'none', borderRadius: 8, fontFamily: 'inherit', cursor: 'pointer',
background: state === s.id ? '#eff6ff' : 'transparent',
}}>
<div style={{ fontSize: 12, fontWeight: 700, color: state === s.id ? '#1d4ed8' : '#334155' }}>{s.label}</div>
<div style={{ fontSize: 11, color: '#64748b', marginTop: 1 }}>{s.hint}</div>
</button>
))}
</div>
)}
<button onClick={() => setOpen(v => !v)} title="デモ状態を切り替え" style={{
display: 'inline-flex', alignItems: 'center', gap: 8,
padding: '8px 14px', borderRadius: 9999,
background: '#0f172a', color: '#fff', border: 'none', cursor: 'pointer',
fontSize: 11, fontWeight: 700, fontFamily: 'inherit',
boxShadow: '0 4px 12px rgb(0 0 0 / 0.25)',
}}>
<span style={{ width: 8, height: 8, borderRadius: 9999,
background: state === 'normal' ? '#22c55e' : state === 'loading' ? '#3b82f6' : state === 'error' ? '#ef4444' : '#94a3b8' }} />
状態: {current.label}
</button>
</div>
);
}
function App() {
const [tasks, setTasks] = React.useState(SAMPLE_TASKS);
const [activeId, setActiveId] = React.useState(412);
const [detailOpen, setDetailOpen] = React.useState(true);
const [messages, setMessages] = React.useState(INITIAL_MESSAGES);
const [filters, setFilters] = React.useState({ status: 'all', search: '', sort: 'updated' });
const [page, setPage] = React.useState('tasks');
const [schedules, setSchedules] = React.useState(SAMPLE_SCHEDULES);
const [activeScheduleId, setActiveScheduleId] = React.useState(1);
const patchSchedule = (id, patch) => setSchedules(xs => xs.map(s => s.id === id ? { ...s, ...patch } : s));
const triggerSchedule = (id) => alert('#' + id + ' を今すぐ実行 (モック)');
const deleteSchedule = (id) => setSchedules(xs => xs.filter(s => s.id !== id));
const [users, setUsers] = React.useState(SAMPLE_USERS);
const [activeUserId, setActiveUserId] = React.useState('u1');
const patchUser = (id, patch) => setUsers(xs => xs.map(u => u.id === id ? { ...u, ...patch } : u));
const deleteUser = (id) => setUsers(xs => xs.filter(u => u.id !== id));
const approveUser = (id) => patchUser(id, { status: 'active' });
const [settingsSection, setSettingsSection] = React.useState('provider');
const [settingsPiece, setSettingsPiece] = React.useState(null);
// ── Demo state switch (loading / error / empty) — just a small floater, not part of the product ──
const [demoState, setDemoState] = React.useState(() => localStorage.getItem('admin-demo-state') || 'normal');
React.useEffect(() => { localStorage.setItem('admin-demo-state', demoState); }, [demoState]);
const isLoading = demoState === 'loading';
const hasError = demoState === 'error';
const isEmpty = demoState === 'empty';
const viewTasks = isLoading || hasError ? [] : (isEmpty ? [] : tasks);
const viewActiveId = isEmpty ? null : activeId;
const viewSchedules = isLoading || isEmpty ? [] : schedules;
const viewUsers = isLoading || isEmpty ? [] : users;
const active = tasks.find(t => t.id === activeId) || tasks[0];
const counts = {
total: tasks.length,
running: tasks.filter(t => t.status === 'running').length,
waiting: tasks.filter(t => t.status === 'waiting_human' || t.status === 'waiting_subtasks').length,
failed: tasks.filter(t => t.status === 'failed').length,
};
const onSend = (text) => {
setMessages(m => ({
...m,
[activeId]: [...(m[activeId] || []), { role: 'user', content: text, footer: 'たった今 · @daichi' }],
}));
// fake echo after small delay
setTimeout(() => {
setMessages(m => ({
...m,
[activeId]: [...(m[activeId] || []), { role: 'progress', content: 'エージェントが応答を生成中...' }],
}));
}, 400);
};
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100vh' }}>
<TopBar
page={page} onNavigate={setPage}
counts={counts}
onOpenCreate={() => alert('新しい依頼 (モック)')}
user={{ name: 'Daichi' }}
/>
{page === 'tasks' && (
<div style={{
flex: 1, minHeight: 0, display: 'grid',
gridTemplateColumns: detailOpen ? '320px 1fr 380px' : '320px 1fr',
background: '#f1f5f9', gap: 1,
}}>
<div style={{ background: '#fff', padding: 12, minWidth: 0, display: 'flex', flexDirection: 'column' }}>
<TaskList
tasks={viewTasks} activeId={viewActiveId} onSelect={setActiveId}
filters={filters} setFilters={setFilters}
onOpenCreate={() => alert('新しい依頼 (モック)')}
loading={isLoading}
error={hasError ? 'ネットワークエラー: 接続を確認してください' : null}
onRetry={() => setDemoState('normal')}
/>
</div>
<ChatPane
task={isLoading || hasError || isEmpty ? null : active}
messages={!active ? [] : (messages[active.id] || [])}
onSend={onSend}
onOpenDetail={() => setDetailOpen(v => !v)}
detailOpen={detailOpen}
loading={isLoading}
onOpenCreate={() => alert('新しい依頼 (モック)')}
/>
{detailOpen && !isLoading && !hasError && !isEmpty && <DetailPanel task={active} onClose={() => setDetailOpen(false)} />}
</div>
)}
{page === 'schedules' && (
<SchedulesPage
schedules={viewSchedules} activeId={isEmpty || isLoading ? null : activeScheduleId} setActiveId={setActiveScheduleId}
onPatch={patchSchedule} onTrigger={triggerSchedule} onDelete={deleteSchedule}
onOpenCreate={() => alert('新しいスケジュール (モック)')}
/>
)}
{page === 'users' && (
<UsersPage
users={viewUsers} activeId={isEmpty || isLoading ? null : activeUserId} setActiveId={setActiveUserId}
onPatch={patchUser} onDelete={deleteUser} onApprove={approveUser}
onOpenInvite={() => alert('ユーザーを招待 (モック)')}
/>
)}
{page === 'settings' && (
<SettingsPage
section={settingsSection} setSection={setSettingsSection}
piece={settingsPiece} setPiece={setSettingsPiece}
/>
)}
<DemoStateFloater state={demoState} setState={setDemoState} />
</div>
);
}
ReactDOM.createRoot(document.getElementById('root')).render(<App />);
</script>
</body>
</html>
@@ -0,0 +1,31 @@
<!doctype html>
<html lang="ja"><head><meta charset="utf-8">
<title>比較: 新しい依頼ボタン配置</title>
<link rel="stylesheet" href="../colors_and_type.css">
<style>
* { box-sizing: border-box; }
html, body { margin: 0; height: 100%; background: #f1f5f9; font-family: var(--font-sans); }
.wrap { display: grid; grid-template-columns: 1fr 1fr; gap: 1px; height: 100vh; background: #cbd5e1; }
.col { display: flex; flex-direction: column; background: #fff; overflow: hidden; }
.label {
flex-shrink: 0; padding: 10px 16px; background: #0f172a; color: #f1f5f9;
font-family: 'IBM Plex Mono', monospace; font-size: 11px; font-weight: 700;
letter-spacing: .1em; text-transform: uppercase;
display: flex; justify-content: space-between; align-items: center;
}
.label .tag { color: #94a3b8; font-weight: 400; }
.label.a { background: #1d4ed8; }
iframe { flex: 1; width: 100%; border: none; min-height: 0; }
</style></head>
<body>
<div class="wrap">
<div class="col">
<div class="label"><span>現状</span><span class="tag">TopBar 右上</span></div>
<iframe src="admin/index.html"></iframe>
</div>
<div class="col">
<div class="label a"><span>案 A</span><span class="tag">左パネル最上部</span></div>
<iframe src="admin-v2/index.html"></iframe>
</div>
</div>
</body></html>
+86
View File
@@ -0,0 +1,86 @@
# Getting Started
MAESTRO を起動して最初のタスクを動かすまでのガイド。設定項目の詳細は
[configuration.md](configuration.md)、全体構造は [architecture.md](architecture.md) を参照。
## 1. 前提
- **Node.js 22 以上**
- **OpenAI 互換の LLM エンドポイント** — 例: [Ollama](https://ollama.com/)`http://localhost:11434/v1`)、vLLM など。MAESTRO 自体のビルド/テストには不要だが、タスク実行には必要。
- **任意(Bash サンドボックス用)**: `bwrap`bubblewrap, 非特権 user namespace が有効なこと)と `python3`/`pip`。マルチユーザー運用では有効化を推奨([operations/bash-sandbox-provisioning.md](operations/bash-sandbox-provisioning.md))。
## 2. インストール(ソースから)
```bash
git clone https://gitea.example.com/your-org/maestro.git
cd maestro
npm ci # バックエンド依存
npm --prefix ui ci # UI 依存
cp config.yaml.example config.yaml
```
## 3. 最小設定
`config.yaml` で LLM 接続先とワーカーを設定する。最低限、使用するモデルとエンドポイントを指定すればよい(既定は Ollama `http://localhost:11434/v1`)。各項目の意味は [configuration.md](configuration.md) を参照。
主要な環境変数で上書きも可能:
```bash
export OLLAMA_BASE_URL=http://localhost:11434/v1
export OLLAMA_MODEL=qwen2.5:14b
```
## 4. ビルドと起動
```bash
scripts/build-all.sh # バックエンド(dist/) と UI(ui/dist/) をビルド
scripts/server.sh start # ビルド + 起動(PID 管理付き)
```
ブラウザで **http://localhost:9876** を開く。
サーバー管理:
```bash
scripts/server.sh status # 状態確認
scripts/server.sh logs # ログを tail -f
scripts/server.sh restart
scripts/server.sh stop
```
> `scripts/build-all.sh` は最後に Bash サンドボックス用 Python パッケージ
> `runtime/python-requirements.txt`)を自動でプリベイクする。スキップするには
> `--skip-python`。システム Python への書き込みに権限が要る環境では
> `sudo bash scripts/prebake-python.sh` を別途実行する。
## 5. Docker で起動
```bash
cp .env.example .env # OLLAMA_BASE_URL 等を設定
docker compose up -d
# http://localhost:9876
```
DB とワークスペースは named volume`maestro-data` / `maestro-workspaces`)に永続化される。`config.yaml` をホストからマウントする場合は `docker-compose.yml` のコメントを参照。
## 6. 最初のタスク
1. UI を開き、新規タスクを作成(タイトル + 依頼内容を入力)。
2. LLM がタスクを分類し、適切な Piece(ワークフロー)へ自動ルーティングする。
3. 進捗タブで Movement の進行とツール呼び出しを確認、成果物は Output/Files タブでプレビューできる。
## 7. 認証を有効にする(任意)
既定では認証なしで動作する。Google / Gitea の OAuth を使う場合は `config.yaml`
`auth` セクションを設定する(クライアント ID/シークレット/コールバック URL)。詳細は
[configuration.md の auth セクション](configuration.md#auth) を参照。
## 8. Bash サンドボックスを有効にする(任意・マルチユーザー推奨)
エージェントの Bash 実行をタスク単位で隔離する。本番では:
1. ホストに Python パッケージをプリベイク: `sudo bash scripts/prebake-python.sh`
2. `config.yaml``safety.bash_sandbox: always`
3. サーバー再起動
手順とトラブルシュートは [operations/bash-sandbox-provisioning.md](operations/bash-sandbox-provisioning.md) を参照。
+554
View File
@@ -0,0 +1,554 @@
# メンテナンスチェックリスト
コード変更時に連動して修正が必要な箇所のリスト。
過去に実際に発生した不整合をもとに作成。
---
## 1. ツールモジュールを新規追加した場合
**対象ファイル:**
- `src/engine/tools/<module>.ts` — 新モジュール本体
- `src/engine/tools/index.ts``tryLoadModule` で動的ロード追加
- `src/bridge/tools-api.ts``/api/tools` のモジュール一覧に追加
- `CLAUDE.md` — 「ツールモジュール構成」テーブルに行を追加
**なぜ必要か:**
`index.ts` はエージェント実行時のツール dispatch に使われ、`tools-api.ts` は Settings UI のピース編集画面でツール候補を返す API。
`tools-api.ts` への追加を忘れると、エンジンでは使えるのに UI のドロップダウンに表示されないという不整合が起きる(実際に amazon, speech, checklist, knowledge の4モジュールで発生した)。
**確認方法:**
```bash
# index.ts でロードしているモジュール数と tools-api.ts のモジュール数を比較
grep -c 'tryLoadModule' src/engine/tools/index.ts
grep -c "engine/tools/" src/bridge/tools-api.ts
# 数が一致していなければ漏れがある
```
---
## 2. 既存モジュールにツールを追加した場合
**対象ファイル:**
- `pieces/*.yaml` — 必要な piece の `allowed_tools` にツール名を追加
- `CLAUDE.md` — 「ツールモジュール構成」テーブルの該当モジュール行に新ツール名を追加
- `docs/tools/{name}.md` — 新ツールの詳細ドキュメント(推奨)
- ツール `description` — 1 文 + 「詳細は ReadToolDoc({ name: "XXX" })」を末尾に記述
**なぜ必要か:**
`allowed_tools` に載っていないツールは LLM に提示されない。ツールを実装しても piece に追加しなければエージェントが使えない。
CLAUDE.md のテーブルが古いと、Claude Code 自身が既存ツールを認識せずに新規実装してしまうリスクがある。
ツール description は毎 LLM 呼び出しに乗るため 1 文に絞り、詳細は ReadToolDoc 経由で必要時のみ読み込む(agent-loop が movement 開始時に description サマリを自動カタログ化する)。
**確認方法:**
新ツールが使われるべき piece を特定し、`allowed_tools` に含まれているか確認する。
CLAUDE.md のモジュールテーブルに新ツール名が含まれているか確認する。
---
## 3. ツールをリネーム・削除した場合
**対象ファイル:**
- `pieces/*.yaml` — 全 piece の `allowed_tools` から旧名を削除/リネーム
- `src/engine/tools/raw-save.ts``RAW_SAVE_TOOLS` に旧名が残っていないか
- `ui/src/components/settings/ToolsForm.tsx` — ヘルプテキスト等にツール名の言及がないか
- `CLAUDE.md` — ツールモジュール構成テーブル
**なぜ必要か:**
BrowserAction を BrowseWeb に統合した際、5つの piece と UI ヘルプテキストに旧名の参照が残っていた。
**確認方法:**
```bash
grep -r '旧ツール名' pieces/ src/ ui/src/ CLAUDE.md
```
---
## 4. config.yaml に新しい設定キーを追加した場合
**対象ファイル:**
- `src/config.ts``ToolsConfig` / `AppConfig` / `LlmConfig` / `StorageConfig` 等のインターフェースにフィールド追加
- `src/config-normalize.ts` — v1 → v2 normalizer に新フィールドの mirror が要るか確認 (storage / llm 配下に置く場合)
- `config.yaml.example` — YAML キー(スネークケース)でサンプル・コメント追加。`config_version: 2` を必ず先頭に保つ
- `ui/src/components/settings/` — 該当セクションの Form コンポーネントに UI フィールド追加
- `CLAUDE.md` — 設定セクションに説明追加(必要に応じて)
**なぜ必要か:**
config.ts と config.yaml.example の不一致は「設定が効かない」「ドキュメントと実動作が違う」を招く。
Settings UI への追加を忘れると、YAML を直接編集しないと設定できなくなる。
v2 layout (`llm.*` / `storage.*`) では normalizer 側の backfill / migrate path も触らないと、v1 ファイルでの読み取り互換が壊れる。
**確認方法:**
```bash
# config.ts のフィールド数と config.yaml.example のキー数を目視比較
# Settings UI の各 Form ファイルで未対応フィールドがないか確認
# normalize の v1 fixture が落ちていないか
npx vitest run src/config-normalize.test.ts
```
---
## 4-B. 新しい Settings Form コンポーネントを追加した場合
**対象ファイル (両方必須):**
- `ui/src/components/settings/<Name>Form.tsx` — Form 本体
- `ui/src/components/settings/ConfigForm.tsx``renderActiveForm()``switch(section)` に新 `case 'new-id': return <NewForm {...formProps} />;` を追加
- `ui/src/components/settings/SettingsSidebar.tsx``CONFIG_GROUPS[].sections[]``{ id: 'new-id', label: '...' }` を追加。admin 限定なら親グループに `adminOnly: true`
- `ui/src/lib/urlState.ts` — section id の許可リストにも追加 (URL state からの復元)
- 旧 id をリネームした場合は `LEGACY_SECTION_REDIRECT` (SettingsSidebar.tsx) にエントリ追加してブックマーク互換を維持
**なぜ必要か:**
ConfigForm の switch と SettingsSidebar の section リストは独立に管理されており、片方だけ追加すると「サイドバーには項目があるが何も描画されない」「描画はされるがサイドバーから飛べない」状態になる。Step 3 (sidebar 再編) / Step 7-9 (form 分割) で両方を毎回触る前提で設計されている。
**禁止事項:**
- 削除した旧 Form (`ProviderForm.tsx`, `WorkersForm.tsx`, `WorkspaceForm.tsx` のような単一巨大 form) を復活させない。LLM Workers / Paths & Storage / Execution / Tools-* の分割は v2 design の前提
- カンマ区切り入力で配列フィールドを作らない。chip エディタ / textarea / multiselect のいずれかを使うこと (値に `,` を含めるケースを壊さないため)
- Secret 入力で `'********'` の magic string 判定を再導入しない。`SecretInput` (4-state: `unchanged` / `literal` / `env_ref` / `cleared`) を再利用すること
**確認方法:**
```bash
# section id が両方のファイルで揃っているか
grep -E "'[a-z-]+'" ui/src/components/settings/SettingsSidebar.tsx | grep "id:"
grep "case '" ui/src/components/settings/ConfigForm.tsx
# legacy redirect が漏れていないか
grep -A 20 'LEGACY_SECTION_REDIRECT' ui/src/components/settings/SettingsSidebar.tsx
```
---
## 5. SSRF 保護に関わる変更をした場合
**対象ファイル:**
- `src/engine/tools/shared/ssrf.ts` — SSRF チェックロジック本体
- `src/engine/tools/browser.ts` — BrowseWeb`setupRouteInterception` + `ssrfCheck`
- `src/engine/tools/web.ts` — WebFetch / DownloadFile
- `ui/src/components/settings/ToolsForm.tsx` — SSRF Allowed Hosts のヘルプテキスト
**なぜ必要か:**
`webfetchAllowedHosts` は WebFetch と BrowseWeb の両方で使われている。片方だけ修正すると保護に穴が開くか、意図しないブロックが発生する。
---
## 6. ログ出力に関わる変更をした場合
**対象ファイル:**
- `src/engine/tools/raw-save.ts``RAW_SAVE_TOOLS` / `RAW_LOG_ONLY_TOOLS` の更新
- 各ツールモジュール内の `appendXxxHistory()` 関数
**なぜ必要か:**
新ツールを追加したとき、`RAW_SAVE_TOOLS` への追加を忘れると `logs/raw/` に出力が保存されない。
knowledge ツールのように独自で raw 保存する場合は `RAW_SAVE_TOOLS` に含めない(二重保存回避)。
**保存先の整理:**
| ログ種別 | パス | 内容 |
|---------|------|------|
| 生データ | `logs/raw/{tool}-{timestamp}.txt/.json` | ツール実行結果の生出力 |
| WebFetch 履歴 | `logs/webfetch-history.jsonl` | URL, ステータス, サイズ |
| ダウンロード履歴 | `logs/downloads.jsonl` | 保存パス, サイズ |
| ナレッジ履歴 | `logs/knowledge-history.jsonl` | クエリ, ヒット数, 所要時間 |
| チェックリスト | `logs/checklists/{name}.json` | チェックリスト状態 |
| rawdata インデックス | `logs/rawdata-history.jsonl` | raw 保存のメタデータ |
---
## 7. ツールの詳細ドキュメントを書く場合
**対象ファイル:**
- `docs/tools/{toolname-lowercase}.md` — ツールの詳細な使い方ガイド
- 該当ツールの `description` — 概要 + `ReadToolDoc({ name: "XXX" })` で参照可能と明記
- `src/engine/tools/docs.ts``TOOL_DOC_ALIASES` — 関連ツール名を同じ doc にマップ(例: `checkitem: 'checklist'`
- 既存 piece に同じガイダンスが書かれていれば削除する(重複解消)
**なぜ必要か:**
Tool description は毎回 LLM のコンテキストに乗るため肥大化させたくない。詳細手順・ワークフロー例は `docs/tools/{name}.md` に置き、`ReadToolDoc` ツールで必要時に取得する設計。
ReadToolDoc は META_TOOLS として常時利用可能なので、piece の `allowed_tools` に追加する必要はない。
関連ツール(CheckItem / CreateChecklist / GetChecklist 等)は1つの doc にまとめてエイリアス経由で引けるようにする。
**確認方法:**
- ファイル名は小文字(例: `BrowseWeb``browseweb.md`
- description の末尾に「詳細は ReadToolDoc(...) で取得可能」を明記
- 全 piece から対応する重複ガイダンスを削除
---
## 8. piece の movement 構造を変更した場合
**対象ファイル:**
- 対象 `pieces/*.yaml``rules` に遷移先を明示追加
**なぜ必要か:**
`default_next` は機械的フォールバック専用。LLM が選べる遷移先は `rules[].next` に明示されたものだけ。
`rules` に追加せず `default_next` だけに頼ると、LLM が遷移先を選択できない。
---
## 9. Reflection に新しい memory `type` を追加した場合
**対象ファイル:**
- `src/engine/reflection/types.ts``ReflectionMemoryType` の union を拡張
- `src/engine/reflection/reflection-schema.ts` — LLM tool schema の `type` enum
- `src/engine/reflection/reflection-prompt.ts` — システムプロンプトの type 説明
- `src/engine/reflection/semantic-validator.ts``ALLOWED_TYPES` セット
- `src/bridge/memory-api.ts` — PUT のバリデーション
- `ui/src/components/settings/MemoryLearningForm.tsx` — type ドロップダウン
**なぜ必要か:**
4 種類 (user / feedback / project / reference) はメモリ意味論の単位。LLM は schema で型強制されるので、schema・prompt・validator のいずれかを忘れると LLM が「未知の type」を吐いて全 reflection が `rejected_unknown_type` で落ちる。UI ドロップダウンの更新を忘れるとユーザーが手動編集できなくなる。
---
## 10. Reflection に新しい rejection code を追加した場合
**対象ファイル:**
- `src/engine/reflection/types.ts``ReflectionRejectionCode` の union を拡張
- `src/engine/reflection/semantic-validator.ts` — 新しいケースで返す
- `src/engine/reflection/applier.ts` — 必要に応じて applier 側の rejection 経路も追加
- `src/bridge/memory-api.ts` — 同じコードを返す PUT バリデーションを更新
- `ui/src/components/settings/MemoryLearningForm.tsx` — UI 側でコード → 日本語メッセージ変換
- `src/engine/reflection/applier.fuzz.test.ts` — fuzz の "known code list" に追加
**なぜ必要か:**
rejection code は metrics・UI 表示・fuzz invariant の3箇所で参照される。fuzz テストは「decision の code は known list のいずれか」と assertion しているので、追加し忘れると fuzz が落ちる。UI 側で未対応コードはユーザーに「不明なエラー」と表示されてしまう。
---
## 11. MCP サーバー追加 / authKind 仕様変更時
**対象ファイル:**
- `src/mcp/registry.ts``auth_kind` の受け入れ・バリデーション
- `src/mcp/token-manager.ts``hasToken` / `getValidToken` の authKind 分岐
- `src/bridge/mcp-api.ts` — admin (global server) ルート、`/api/mcp/connections`
- `src/bridge/user-servers-api.ts` — user-owned server CRUD
- `ui/src/components/userfolder/McpServersPanel.tsx` — authKind ラジオ + 条件付きフォーム
- `ui/src/components/userfolder/McpConnectionsPanel.tsx` — authKind=api_key は Authorize ボタン非表示
- `docs/mcp.md` — 運用者向け手順 (OAuth と api_key 両方)
**なぜ必要か:**
authKind は server 行に保存され、token-manager・aggregator・UI のそれぞれで分岐する。Phase 8 で OAuth 専用設計から拡張したため、新しい authKind を入れる場合は schema migration + UI フォーム + docs の 3 点を必ず触る。OAuth start ルートは authKind=oauth でしか動かない (api_key は 400 を返す) ので、route 側のガードも忘れない。
**確認方法:**
- `grep -rn "authKind" src/mcp/ src/bridge/mcp-api.ts src/bridge/user-servers-api.ts ui/src/components/userfolder/Mcp*.tsx` で参照漏れを確認
- 統合テスト: `npx vitest run src/mcp/integration.test.ts` (OAuth + api_key 両 path をカバー)
---
## UI フォントサイズスケール (Tailwind class 規約)
UI に新しいパネルやコンポーネントを追加するときは、以下の **6 段階スケール** から選ぶ。それ以外の `text-[Npx]` を導入すると全体の字組が崩れる。
| サイズ | クラス | 用途 |
|---|---|---|
| 10px | `text-[10px]` | セクションラベル(uppercase)、極小バッジ |
| 11px | `text-2xs` ⭐ (Tailwind 拡張、`tailwind.config.js` で定義済み) | ヘルプテキスト、キャプション、メタ情報 |
| 12px | `text-xs` | 本文小、ボタンラベル、フォーム入力、テーブルセル |
| 13px | `text-[13px]` (Tailwind 標準に該当なし) | パネル本文、メイン段落 |
| 14px | `text-sm` | フォームラベル、ナビゲーション項目、見出し小 |
| 16px+ | `text-base` / `text-lg` / `text-xl` / `text-2xl` | h2 セクション見出し、ページタイトル、ヒーロー |
### 禁止リスト
以下は **絶対に書かないこと**。書いた場合は `2026-05 commit 993ef2f` の正規化と同様の一括置換でやり直しになる。
| 書いてしまった | 置き換え先 |
|---|---|
| `text-[11px]` | `text-2xs` |
| `text-[12px]` | `text-xs` |
| `text-[14px]` | `text-sm` |
| `text-[15px]` | `text-sm` |
| `text-[16px]` | `text-base` |
| `text-[18px]` | `text-lg` |
| `text-[9px]` | `text-[10px]` (極小に丸め) |
| `text-[12.5px]` | `text-[13px]` |
| 任意の非標準 px | 上記スケールに最も近いものに丸める |
### なぜ必要か
- 過去に新規パネル (Help, MCP, AGENTS.md 等) を追加するたびに implementer が独自に `text-[Npx]` を書き、累積で **13 種類のサイズ** が混在した
- 統一したフォントサイズは UI の一貫性 + 認知負荷の低下に直結する
- Tailwind utility (`text-xs/sm/base/lg/xl/2xl`) は font-size と line-height がセット定義されているので、行間も自然に揃う
- 不要な arbitrary value (`text-[Npx]`) は CSS の出力サイズも増やす
### 確認方法
```bash
cd ui
# 禁止サイズが残っていないか確認 — 何も出なければ OK
grep -rohE 'text-\[(9|11|12|12\.5|14|15|16|17|18)px\]' src --include='*.tsx'
# 全体のサイズ分布を確認
grep -rohE 'text-\[[0-9]+(\.[0-9]+)?px\]|text-(xs|sm|base|lg|xl|2xl|3xl|4xl|2xs)' src --include='*.tsx' | sort | uniq -c | sort -rn
```
期待される分布 (Tailwind utility が主、arbitrary は 10px / 13px のみ):
```
text-xs (~250 ↑)
text-2xs (~180 ↑)
text-sm (~100 ↑)
text-[13px] (~100)
text-[10px] (~100)
text-base, text-lg, text-xl, text-2xl, text-4xl ... (少量)
```
### 触る場所
- 新規 UI コンポーネント全般 (`ui/src/**/*.tsx`)
- 既存パネルへの追加要素
- 設定フォーム (`ui/src/components/settings/`)
- ダイアログ・モーダル
---
## 12. SSH 関連変更時 (Phase 0-8)
### 12-A. SSH ツール (`SshExec` / `SshUpload` / `SshDownload`) を変更したとき
**対象ファイル:**
- `src/engine/tools/ssh.ts` — TOOL_DEFS + executeTool + 12-step orchestration
- `src/engine/tools/ssh.test.ts` — unit tests (23 件)
- `src/engine/tools/ssh.e2e.test.ts` — in-process ssh2 server e2e (4 件、SKIP_SSH_E2E=1 で skip)
- `src/engine/tools/index.ts``tryLoadModule('./ssh.js')` 経由のロード + dispatch
- `src/engine/tools/core.ts``ToolContext.pieceName` (piece grants check に必要)
- `src/engine/piece-runner.ts``ToolContext` 構築時に `pieceName: piece.name` を thread
- `src/engine/tools/docs.ts``TOOL_DOC_ALIASES` で 3 ツール名を `'ssh-tools'` にマップ
- `docs/tools/ssh-tools.md` — LLM 向け詳細ドキュメント
- `src/bridge/server.ts``setSshSubsystem({...})` で DI
**新ツールを SSH 系で追加する場合、または既存ツールに引数を増やす場合:**
- ssh.ts の `TOOL_DEFS` description は **1 文 + 「詳細は ReadToolDoc」** に絞る (毎呼び出しのトークン節約)
- preflight (steps 1-7) を流用するため `runExec`/`runUpload`/`runDownload` の skeleton を踏襲
- `finally``clearBuffer(pem, passphrase)` を必ず実行 — ssh2 の internal retention は防げないが我々の copy は zeroize する
- 監査 action 名は `ssh.<verb>` 形式 (例: `ssh.exec`, `ssh.upload`)。command 全文は **記録せず SHA-256 16-char hex**`detail.command_hash`
- piece grants check は `subsystem.accessResolver.resolve()` を必ず通す (piece membership → access decision → state checks → policy の順)
**新しい SshSessionError code を追加する場合:**
- `src/ssh/session.ts``SshSessionErrorCode` union を拡張
- `src/engine/tools/ssh.ts``formatSessionError` switch に LLM-actionable message を追加
- `docs/tools/ssh-tools.md` の「共通エラーコード一覧」表に追記
- `docs/ssh.md` の「Troubleshooting → Symptom → cause table」に追記
**確認方法:**
```bash
# tool が grants check を通っているか
grep -n "accessResolver\.resolve\|preflight" src/engine/tools/ssh.ts
# 監査 action 名が既存と整合
grep -n "action:" src/engine/tools/ssh.ts src/bridge/ssh-api.ts | sort -u
# tests
npx vitest run src/engine/tools/ssh.test.ts
SKIP_SSH_E2E= npx vitest run src/engine/tools/ssh.e2e.test.ts # 明示的に run
```
### 12-B. SSH HTTP API (`src/bridge/ssh-api.ts`) を変更したとき
**対象ファイル:**
- `src/bridge/ssh-api.ts` — User router + Admin router (25 endpoints)
- `src/bridge/ssh-api.test.ts` — auth / maintenance / reason / CRUD / TOFU / grants / rotation
- `src/ssh/maintenance.ts` — in-memory maintenance flag
- `src/ssh/admin-rate-limit.ts` — token bucket for force-unlock (10/hr)
- `src/bridge/server.ts``ssh.enabled` ゲートで両 router をマウント
- `src/ssh/session.ts``sshTest()` (HTTP test endpoint で使用)
- `docs/ssh.md` §"HTTP API Reference" — endpoint table
**新しい admin write エンドポイントを追加するとき:**
- `body.reason` 必須 (≥ 8 chars)。`validateReason()` を必ず呼ぶ
- 操作直前に `maintenance503()` ガードを呼ぶ (read 系は不要)
- 成功・失敗どちらも `auditRepo.beginAndComplete()``ssh_audit_log` に記録
- 監査 action は `src/ssh/audit-repo.ts``SshAuditOutcome` と Phase 5 設計書の enum を踏襲
- admin-only フラグ (`allow_remote_unrestricted`, `allow_private_addresses` per-connection) は user 経路で常に reject
**新しい user write エンドポイントを追加するとき:**
- `getUserId()` で取得した userId をオーナーチェックに使う
- 他人の所有物に対しては `404` を返す (存在を漏らさない)
**確認方法:**
```bash
# admin endpoint で reason check / maintenance ガードが漏れていないか
grep -n "deps.requireAdmin\|maintenance503\|validateReason" src/bridge/ssh-api.ts
# audit log の action 名が既存と整合しているか
grep -n "action:" src/bridge/ssh-api.ts
```
### 12-C. SSH repo / schema を変更したとき
**対象ファイル:**
- `src/db/schema.sql``ssh_connections`, `ssh_connection_grants`, `ssh_audit_log`, `ssh_abuse_counters`, `system_deks`
- `src/db/migrate.ts``PRAGMA table_info` → 列存在チェック → `ALTER TABLE ADD COLUMN` パターン (バージョン管理テーブル不使用)
- `src/ssh/connection-repo.ts` / `grants-repo.ts` / `audit-repo.ts` / `abuse-repo.ts` / `access.ts`
- 各 repo に対応する `*.test.ts`
- `src/ssh/recovery.ts` — boot 時 pending → aborted sweep
- `src/ssh/crypto.ts` — DEK ラップ / マスター鍵検証
- `docs/ssh.md` §"Connection Model" / §"Access Grants" / §"Audit Log"
**スキーマ列を追加するとき:**
- `schema.sql``CREATE TABLE` に列追加 (初期スキーマ)
- `migrate.ts` に冪等 ALTER TABLE 追加 (既存 DB 用)
- 既存テストが影響を受けないか `npm test` で検証
- 列をエンドポイントから書く場合、`POST/PATCH` ハンドラのバリデーションに追加し、admin-only フラグなら user 経路で reject
**新しい監査 action を追加するとき:**
- `src/ssh/audit-repo.ts``SshAuditOutcome` は触らない (固定 5 値: `pending|success|failed|denied|aborted`)
- action 名は `ssh.<entity>.<verb>` 形式 (例: `ssh.connection.host_key.replace`)
- `docs/ssh.md` §"Audit Log → Actions" 表に追加
- `docs/tools/ssh-tools.md` の「監査ログ」セクションは tool 経由 action のみなので、tool 起因なら追記
**確認方法:**
```bash
# 監査 action 一覧 (重複/typo 検出)
grep -rn "action: 'ssh\." src/ | awk -F"'" '{print $2}' | sort -u
# repo CAS / transaction が崩れていないか
grep -n "BEGIN\|COMMIT\|prepare(" src/ssh/*.ts | head -30
```
### 12-D. SSH UI を変更したとき
**対象ファイル:**
- ユーザー向け:
- `ui/src/components/userfolder/SshConnectionsPanel.tsx` — 一覧 + actions
- `ui/src/components/userfolder/SshConnectionForm.tsx` — 作成・編集フォーム
- `ui/src/components/userfolder/SshHostKeyDialog.tsx` — TOFU verify ダイアログ
- 管理者向け:
- `ui/src/components/settings/SshForm.tsx` — Settings → SSH のルート
- `ui/src/components/settings/SshGlobalConnectionsForm.tsx` — グローバル接続 CRUD
- `ui/src/components/settings/SshGrantsForm.tsx` — grants CRUD
- `ui/src/components/settings/SshAuditLog.tsx` — 全テナント監査ログ
- `ui/src/components/settings/SshMasterKeyRotationForm.tsx` — 鍵 rotation
- 型定義: `ui/src/lib/ssh-types.ts`
- `ui/src/components/settings/ConfigForm.tsx` / `SettingsSidebar.tsx``SshForm` の登録 (新規 Form 追加時)
**確認方法:**
- 新 form を追加した場合 `ConfigForm.tsx` の section リストに登録、`SettingsSidebar.tsx` のナビ項目に追加
- ssh-types.ts と API レスポンス shape (`SshConnection`, `SshGrant`, `SshAuditRow`) が一致しているか
- 禁止フォントサイズ (`text-[11px]` 等) を導入していないか — 既存セクションの「UI フォントサイズスケール」参照
### 12-E. piece schema (`allowed_ssh_connections`) を変更したとき
**対象ファイル:**
- `src/engine/piece-runner.ts``allowed_ssh_connections` の lint (validateMovement)
- `src/engine/types.ts` (or piece schema 定義箇所) — `allowed_ssh_connections?: string[]`
- `pieces/*.yaml` — SSH ツールを `allowed_tools` に含む movement は **必ず** `allowed_ssh_connections` 宣言が必要 (空配列 `[]` でも可)
- `docs/ssh.md` §"Per-piece `allowed_ssh_connections`"
**lint 規約:**
- `allowed_tools` に SSH ツール名が含まれる場合、`allowed_ssh_connections` の宣言が必須 (`undefined` は reject)
- 値は配列、各要素は `*` または lowercase hex + ハイフン UUID (≥ 8 chars)
- 空配列 `[]` は "deny all" として明示扱い
**確認方法:**
```bash
# 既存 piece に SSH 使用宣言があるか
grep -l 'Ssh\(Exec\|Upload\|Download\)' pieces/*.yaml | xargs -I {} grep -l 'allowed_ssh_connections' {}
```
### 12-F. config.yaml の SSH セクション (`SshRuntimeConfig`) を変更したとき
**対象ファイル:**
- `src/ssh/config.ts``SshRuntimeConfig` interface + `SSH_DEFAULTS` + `mergeSshConfig`
- `src/config.ts``transformKeys` でスネーク→キャメル変換、`AppConfig` に統合
- `config.yaml.example` — コメント付きデフォルト値
- `ui/src/components/settings/SshForm.tsx` — UI で編集可能にする場合
- `docs/ssh.md` §"`config.yaml` Reference"
**確認方法:**
```bash
# config.ts と config.yaml.example の SSH キー数が一致するか
grep -E '^\s+[a-z_]+:' config.yaml.example | grep -A 30 '^# ssh:' | head -40
```
### 12-G. SSH Console (interactive PTY) を変更したとき
SSH Console は SshExec/Upload/Download とは別系統の対話的 PTY 経路。Phase 8 で新規追加。
- 新ツール SshConsoleEnsure / SshConsoleSend / SshConsoleSnapshot を追加した時:
- [ ] `src/engine/tools/ssh-console.ts``TOOL_DEFS` に追加
- [ ] `src/engine/tools/index.ts` の dispatch + aggregation に追加
- [ ] `src/engine/tools/docs.ts``TOOL_DOC_ALIASES` にエイリアス
- [ ] `docs/tools/ssh-console-tools.md` を更新
- 新 WS endpoint を追加した時:
- [ ] `src/bridge/server.ts` に upgrade handler 配線
- [ ] auth / visibility / access の 4 段階チェックを記述
-`SessionCloseReason` を追加した時:
- [ ] `src/ssh/console-protocol.ts` (server) と `ui/src/lib/ssh-console-types.ts` (UI) の両方に追加
- [ ] `ConsoleHeader.tsx` の disconnected reason 表示に文言を追加
## 13. Scheduler から呼ばない手動オペレーション endpoint
以下の endpoint は **UI からの手動操作専用** で、scheduler / Routine / 自動化経路から起動できない設計になっている。Routine 側の payload schema にこれらの override を追加してはいけない (scheduled task の `allowed_tools` 境界が想定外に拡大するリスクのため)。
- `POST /api/local/tasks/:id/continue` — 別 piece で task を続ける (handoff)
- 実装: `src/bridge/local-tasks-api.ts``/continue` ハンドラ
- terminal 状態 (`succeeded` / `failed` / `waiting_human` / `cancelled`) のときのみ有効
- 新カテゴリを追加する場合: `scheduled-tasks-api.ts` / `scheduler.ts` 側に「実行する piece を override する」機能を持たせない。実行する piece は task 作成時に固定し、人間が UI から明示的に切り替える経路だけ残す
**確認方法:**
```bash
# scheduler 経路から /continue を叩いている箇所が無いか
grep -rn "/continue\b\|continueTaskWithPiece" src/scheduler.ts src/bridge/scheduled-tasks-api.ts
# 無いことが期待値
```
---
## 14. Knowledge Notes 追加・変更時
Knowledge Notes は `data/users/{userId}/notes/` 以下のマークダウンファイルで管理される共有ナレッジ機能。
FM (frontmatter) の `visibility` / `scope_org_id` で公開範囲を制御し、他ユーザーが検索・購読・inject できる。
**対象ファイル:**
- `src/db/schema.sql``src/db/migrate.ts` — dual path で両方更新 (知識テーブル追加時)
- `src/engine/tools/index.ts` — 新ツールの dynamic import
- `pieces/*.yaml``allowed_tools` に必要なツール名を追加
- `src/engine/tools/docs.ts``TOOL_DOC_ALIASES` — 関連ツールを同じ doc にまップ
- `ui/src/components/userfolder/FileTree.tsx``FILE_SUBDIRS` — サイドバーに新ディレクトリを追加
- `config.yaml.example``notes.inject` セクションのサンプル設定を更新
**セキュリティ・可視性チェックリスト:**
- [ ] `src/db/schema.sql``src/db/migrate.ts` の両方を更新 (dual path)
- [ ] 新ツールを `src/engine/tools/index.ts` の dynamic import に追加
- [ ] `pieces/*.yaml``allowed_tools` に必要なツール名を追加
- [ ] `src/engine/tools/docs.ts``TOOL_DOC_ALIASES` にエイリアスを追加
- [ ] `ui/src/components/userfolder/FileTree.tsx``FILE_SUBDIRS` を更新
- [ ] `config.yaml.example``notes.inject` セクションを更新
- [ ] FM の `visibility=org` チェックは publisher の所属 org で行う (`getUserOrgIds`)
- [ ] inject 注入経路 (`agent-loop.ts` `buildSystemPrompt`) は visibility WHERE を必ず通る
- [ ] cross-user read は `audit_log` に記録される
**なぜ必要か:**
`visibility=org` の org チェックを publisher 側の org で行わないと、異なる org のユーザーが他 org のノートを閲覧できる。inject 経路で visibility チェックを省略すると、private なノートがシステムプロンプト経由で漏洩する。cross-user read の監査記録が欠けると、後からアクセス追跡ができなくなる。
**確認方法:**
```bash
# visibility WHERE が inject 経路にあるか
grep -n "visibility\|getUserOrgIds" src/engine/agent-loop.ts
# audit_log への記録が行われているか
grep -n "audit_log\|read_note" src/engine/tools/knowledge.ts
# SearchNotes / ListNotes ツールが tools-api.ts に登録されているか
grep -n "knowledge\|notes" src/bridge/tools-api.ts
```
---
## Bash Unrestricted モード (`safety.bash_unrestricted`) の変更時
**対象ファイル:**
- `src/engine/tools/sandbox.ts` — bwrap サンドボックスのマウント構成・実行ロジック
- `src/engine/tools/core.ts``executeBash` の分岐 (`ctx.bashUnrestricted`)、`ToolContext` インターフェース
- `src/engine/piece-runner.ts``safetyConfig.bashUnrestricted``ToolContext` への伝播
- `src/worker-bootstrap.ts` — 起動時 bwrap 可用性チェック
- `src/config.ts``SafetyConfig.bashUnrestricted` 定義・バリデーション
**なぜ必要か:**
bwrap のマウント構成を変えた場合、セキュリティ境界が変わる。テスト (`sandbox.test.ts`, `core.test.ts``bashUnrestricted mode` セクション) を必ず更新すること。
---
## 自動検知の可能性
- **ツールモジュール登録漏れ**: `index.ts``tools-api.ts` のモジュール一覧を比較するスクリプトで CI チェック可能
- **piece の allowed_tools 不整合**: 全 piece の `allowed_tools` に含まれるツール名が実際の `TOOL_DEFS` に存在するか検証するスクリプトで CI チェック可能
- **code-review-graph**: `importers_of` で各ツールモジュールの参照元を列挙できるが、「tools-api.ts にも登録すべき」というルールの自動適用は困難。変更時の `detect_changes` + `get_impact_radius` で影響範囲の見落としを防ぐ用途が現実的
+250
View File
@@ -0,0 +1,250 @@
# MCP (Model Context Protocol) Server Integration
The orchestrator can call tools hosted on external **MCP servers** (OAuth-secured
SaaS like Canva, or self-hosted servers with static API keys). Connected MCP
tools are exposed to pieces via `mcp__<server>__<tool>` names, and can be
allowlisted with `mcp__<server>__*` wildcards in `piece.allowed_tools`.
This document is the **operator runbook** for setting up, troubleshooting, and
maintaining MCP integrations. For internal design notes, see
## Prerequisites
### 1. Generate `MCP_ENCRYPTION_KEY`
All OAuth client secrets, static API tokens, and user access tokens are
encrypted at rest with AES-256-GCM. The key is a 32-byte hex string.
```bash
openssl rand -hex 32
```
Set it in your environment before starting the orchestrator:
```bash
export MCP_ENCRYPTION_KEY=<the 64-hex output>
scripts/server.sh start
```
If `MCP_ENCRYPTION_KEY` is **not** set, the MCP subsystem boots fail-soft: a
warning is logged and all MCP endpoints return 503 / are hidden from the UI.
Other features continue normally.
> ⚠ **Key rotation invalidates all encrypted tokens.** Plan rotation as a
> migration event: ask every user to re-connect. There is no automatic
> re-encryption today.
### 2. Optional: `mcp.allow_private_addresses`
By default, MCP requests are routed through the SSRF strict-check, which
rejects loopback and private-IP addresses. For **self-hosted MCP servers** on
`localhost` or LAN, set in `config.yaml`:
```yaml
mcp:
allow_private_addresses: true
```
This skips the SSRF check entirely (same semantics as `insecureLocalTestMode`).
**Only enable in trusted networks.** Better cidr-aware controls are tracked in
the Phase 8 follow-ups.
## Authentication modes
There are two `auth_kind` values for an MCP server registration:
| `auth_kind` | Use case | Setup |
|---|---|---|
| `oauth` | SaaS providers (Canva, GitHub Apps, etc.) | Register OAuth app in provider's dev portal, capture `client_id` + `client_secret`, plug into Settings UI. User clicks **Connect** to authorize. |
| `api_key` | Self-hosted MCP, providers with personal access tokens | Generate a bearer token on the provider side, paste it into the server registration. No per-user dance. |
## Global vs user-owned servers
| Owner | Visibility | Who can register |
|---|---|---|
| **Global** (`owner_id IS NULL`) | All users see it on the Connections panel | Admins via `/api/mcp/servers` |
| **User-owned** (`owner_id = userId`) | Only the owner sees / uses it | Any user via `/api/mcp/user-servers` |
Admins can also register **user-owned** servers (they're "users too" from the
API's perspective). The Settings → User Folder → MCP Servers panel has both
sections — global at top (admin only), user's own below.
## Setup walkthroughs
### A. OAuth server (global, admin-managed)
1. **Provider portal**: register a new OAuth client. Configure the callback URL:
```
https://<your-orchestrator-host>/auth/mcp/<server_id>/callback
```
where `<server_id>` is the slug you'll use in step 2 (e.g. `canva`).
2. **Admin UI** → Settings → User Folder → **Global Servers** → **+ Add server**:
- **ID**: `canva` (matches callback URL)
- **Name**: `Canva` (display only)
- **URL**: `https://api.canva.com/mcp` (the MCP endpoint, not the OAuth host)
- **Auth**: OAuth
- **Client ID / Secret / Scopes**: from the provider portal
3. The orchestrator fetches `<URL_origin>/.well-known/oauth-authorization-server`
and stores `issuer`, `authorization_endpoint`, `token_endpoint`,
`discovery_fingerprint`. If discovery fails, see Troubleshooting below.
4. **Each user** clicks **Connect** on the Connections panel → OAuth flow runs
→ access + refresh tokens persisted (encrypted) under `user_mcp_tokens`.
5. From here on, tools cached for that server are usable by the user as
`mcp__canva__<tool>`.
### B. api_key server (self-hosted, user-managed)
1. **Provider**: generate a bearer token (e.g. `sk-...`). For most self-hosted
MCP, this is a static value in the server's config.
2. **User UI** → Settings → User Folder → **Your Servers** → **+ Add server**:
- **ID**: `my-tools`
- **Name**: `My self-hosted tools`
- **URL**: `http://10.0.0.10:8080/mcp` (or wherever)
- **Auth**: API Key
- **Static Token**: paste the bearer
3. No OAuth dance — `tools/list` and `tools/call` flow uses the static token
directly. Token is encrypted at rest in `mcp_servers.static_token_enc`.
4. If using a private IP, ensure `mcp.allow_private_addresses: true` is set
(see Prerequisites).
## How tools flow into pieces
The orchestrator caches `tools/list` results in `mcp_server_tools`, refreshed
on registration and on explicit admin refresh (no automatic TTL today). Piece
authors expose them via `allowed_tools`:
```yaml
movements:
- name: design
allowed_tools:
- Read
- Write
- mcp__canva__* # all tools from server `canva`
- mcp__my-tools__lint # a specific tool from `my-tools`
```
The wildcard `mcp__<server>__*` expands to all currently-cached tools for that
server.
## Job parking and resume
When a piece requires an MCP server (via `required_mcp` frontmatter or
discovered from `allowed_tools`) and the user has no connection, the worker
parks the job:
- `jobs.status = 'waiting_human'`
- `jobs.wait_reason = 'mcp_auth_required'`
- A comment is posted on the local task with a Connect link
When the user completes the OAuth flow, `resumeWaitingJobs(userId, serverId)`
re-queues every parked job for that pair. api_key servers don't park
(server-level credentials, not per-user).
## Troubleshooting
### Discovery fails (`/api/mcp/servers` returns 400 on POST)
Symptoms: registration fails with `Discovery fetch failed: <code>` or
`authorization_endpoint origin must match MCP url origin`.
Causes:
- Provider doesn't expose `/.well-known/oauth-authorization-server` at the
origin of the MCP URL. Check with `curl <origin>/.well-known/oauth-authorization-server`.
- Cross-origin `authorization_endpoint` or `token_endpoint` — orchestrator
enforces same-origin to prevent malicious redirects.
- SSRF block on a private-IP URL — set `mcp.allow_private_addresses: true`.
### OAuth callback fails with 400
Symptoms: After clicking **Connect**, the browser lands on `/auth/mcp/<id>/callback`
and gets `400 Bad Request`.
Causes:
- **State mismatch**: `code` or `state` query param missing, or the `state`
was already consumed (single-use, by design). Re-trigger the flow from scratch.
- **Token endpoint rejected the code**: check provider portal for misconfigured
redirect URI. The orchestrator uses exactly `<your-host>/auth/mcp/<server_id>/callback`.
### Tool calls return 401 silently
The token may have expired and refresh failed. Check the audit log:
```sql
SELECT detail FROM audit_log WHERE action LIKE 'mcp.%' AND created_at > datetime('now', '-1 hour');
```
`mcp.token.refresh` rows with status non-200, or `mcp.token.invalid_grant` rows
indicate the user's refresh token is gone. They need to re-connect.
### "MCP_ENCRYPTION_KEY not configured"
The env var was not set, OR it's the wrong length (must be exactly 64 hex chars
= 32 bytes). Verify with:
```bash
echo -n "$MCP_ENCRYPTION_KEY" | wc -c # should be 64
```
### Private-IP MCP rejected
You're hitting `http://localhost:...` or a RFC1918 address and SSRF is blocking
it. Set in `config.yaml`:
```yaml
mcp:
allow_private_addresses: true
```
then restart (`scripts/server.sh restart`). Note: `loadConfig().mcp` is read at
boot; runtime hot-reload doesn't propagate.
## Log prefixes
Grep the orchestrator log for:
| Prefix | Subsystem |
|---|---|
| `[mcp:registry]` | Server CRUD, discovery snapshots |
| `[mcp:token]` | hasToken, getValidToken, refresh, invalidation |
| `[mcp:oauth]` | OAuth start / callback handlers |
| `[mcp:client]` | SDK transport connect / close |
| `[mcp:executor]` | callTool execution + content[] handling |
| `[mcp:aggregator]` | tool list resolution, dispatch |
## Audit log entries
| `action` | Trigger | Detail (redacted) |
|---|---|---|
| `mcp.server.upsert` | Admin or user adds/updates a server | `serverId`, `authKind` |
| `mcp.oauth.start` | User clicks Connect on an oauth server | `serverId` |
| `mcp.oauth.callback` | User completes OAuth dance | `serverId`, `success` |
| `mcp.token.refresh` | getValidToken triggers refresh | `serverId`, `outcome` |
| `mcp.token.invalid_grant` | Refresh failed with `invalid_grant` | `serverId` |
| `mcp.call_tool` | A tool was invoked | `serverId`, `toolName`, `argsHash` |
Token strings, OAuth codes, and Authorization headers are scrubbed by
`src/mcp/redact.ts` before being written to detail JSON.
## SSRF and private IPs
The strict SSRF check (`src/mcp/ssrf-strict.ts`) is enforced for **all** MCP
fetches (discovery, token, /mcp). It:
1. Resolves the URL hostname to an IP.
2. Rejects loopback (`127.0.0.0/8`, `::1`), RFC1918, link-local, multicast,
CGNAT (`100.64.0.0/10`), and broadcast.
3. Pins the resolved IP to prevent TOCTOU attacks (`pinnedFetch`).
`mcp.allow_private_addresses: true` bypasses **all** of the above. Use only in
trusted dev/CI environments. Granular allow/deny (e.g. allow loopback but deny
multicast) is a Phase 8 follow-up.
## Future work
- Refresh-on-401 retry inside `tool-executor` (currently a 401 fails the call;
the user must re-trigger)
- Stdio transport for local MCP servers (no HTTP)
- Org-scoped shared tokens (schema already has `scope_type` / `scope_id`)
- TTL-based opportunistic refresh of `mcp_server_tools` cache
- `MCP_ENCRYPTION_KEY` rotation without invalidating tokens
- Cidr-aware private-IP allowlist (replace blanket `allow_private_addresses`)
@@ -0,0 +1,101 @@
# Bash サンドボックス 本番プロビジョニング手順 (aao)
> **順序厳守**: 先に「2. パッケージのプリベイク」を完了してから「4. 設定切替」を行う。逆順だと、サンドボックス内で `pip install` は拒否され、かつパッケージも無い状態になり、python3 を使う既存タスク(office 処理・data 処理等)が全滅する。
## 0. 前提
- 本番 worker は aao ホスト上の Node プロセスとして動く(alpine コンテナではない)。bwrap は `--ro-bind /usr` でホストの `/usr` をサンドボックスに持ち込むため、**プリベイク先はホストのシステム python の site-packages**。`/home` 配下の venv はサンドボックスに mount されず見えないので不可。
- アクセス: `ssh aao``user@your-host`)。落とし穴: root でビルドしない / DB は `maestro.db` 参照)。
## 1. bwrap / python の確認・導入
```bash
ssh aao
# bwrap (bubblewrap) と user namespace
which bwrap || sudo apt-get install -y bubblewrap
bwrap --ro-bind / / true && echo "bwrap OK" || echo "bwrap NG — user namespace を有効化する必要あり"
# user namespace が無効なら(Debian/Ubuntu:
# sudo sysctl -w kernel.unprivileged_userns_clone=1
# 永続化: /etc/sysctl.d/ に kernel.unprivileged_userns_clone=1 を追記
# python3 + pip
which python3 pip3 || sudo apt-get install -y python3 python3-pip
```
## 2. パッケージのプリベイク(システム python へ)
リポジトリの `runtime/python-requirements.txt` をホストのシステム python に入れる。読み取り専用 bind されるので全サンドボックスから import 可能になる。
```bash
# リポジトリの最新を pull 済みの前提
cd <maestro repo on aao>
# 推奨: 付属スクリプト(冪等・import 検証付き)。権限が要る場合は sudo を付ける
sudo bash scripts/prebake-python.sh
# ↑ これは内部で `pip3 install --break-system-packages -r runtime/python-requirements.txt`
# を実行し、最後に全パッケージの import を検証する。
# 注: `scripts/build-all.sh` は最後にこの prebake を自動実行する(--skip-python で無効化)。
# ただし build-all を sudo 無しで動かす運用では system へ書けず失敗(非致命的に警告)するため、
# その場合は上記のように prebake を別途 sudo で実行すること。
#
# distro パッケージを優先したい場合(C 拡張のビルド回避):
# sudo apt-get install -y python3-numpy python3-pandas python3-pil python3-lxml python3-bs4 python3-matplotlib
# 残り (pypdf, pymupdf, pdfplumber, openpyxl, xlsxwriter, xlrd, odfpy, striprtf,
# python-docx, python-pptx, markdownify, markdown, tabulate,
# python-dateutil, charset-normalizer) のみ pip
```
## 3. プリベイク検証
サンドボックス外(ホスト)で全 import が通ることを確認:
```bash
python3 -c "import pypdf, fitz, pdfplumber, docx, pptx, openpyxl, xlsxwriter, xlrd, odf, striprtf, bs4, lxml, markdownify, markdown, numpy, pandas, tabulate, dateutil, matplotlib, PIL, charset_normalizer, yaml; print('all imports OK')"
```
`fitz` は pymupdf。失敗するものがあれば `runtime/python-requirements.txt` を見直すか apt で補う。
## 4. 設定切替
`config.yaml``safety` セクション:
```yaml
safety:
bash_sandbox: always # bwrap 必須・不在なら起動失敗(本番推奨)
```
worker を再起動: `scripts/server.sh restart`。起動ログに `[startup] bash sandbox enabled — bwrap verified` が出れば OK。bwrap 不在なら `always` は起動失敗する(fail-closed)。
> `auto` のままでも bwrap があればサンドボックスは効く。`always` は「bwrap が無ければ動かさない」明示の本番ガード。
## 5. 切替後の統合検証(設計 Task 9)
テストタスクを 1 件流し、Bash ツールで以下を実行して期待結果を確認:
```bash
# 1) シークレットが見えない
python3 -c "import os; print(os.environ.get('MCP_ENCRYPTION_KEY'))" # → None
# 2) 他タスク dir / config に到達不可
cat ../../config.yaml 2>&1 || echo "blocked-as-expected"
# 3) ネット到達不可(内部サービス)
python3 -c "import socket; socket.create_connection(('10.0.0.10',8080),2)" 2>&1 || echo "net-blocked-as-expected"
# 4) プリベイク済みパッケージが使える
python3 -c "import pypdf, fitz, pandas; print('ok')" # → ok
# 5) install は拒否される(消えるインストールにならない)
pip install requests 2>&1 # → "Package installation is not available ... これは preinstalled ..."
```
加えて、既存の office 処理 / data 処理系 piece のタスクを数件流し、`--unshare-net` + プリベイクで完結することを確認(dogfooding)。不足パッケージが出たら `runtime/python-requirements.txt` に追加 → 手順 2 を再実行(実行時 `pip install` は恒久的に不可)。
## 6. ロールバック
```yaml
safety:
bash_sandbox: auto # bwrap があれば使い、無ければ hardened-whitelist にフォールバック
# もしくは off(bwrap を使わない。env スクラブは維持。デバッグ用)
```
`scripts/server.sh restart`。コード変更不要、設定のみで戻せる。
## 補足: Docker イメージ運用の場合
`Dockerfile` (`node:22-alpine`) には `bash bubblewrap python3 py3-pip` + `pip install -r runtime/python-requirements.txt``--break-system-packages`)を追加済み。ただし alpine/musl では `pandas/numpy/lxml/Pillow/pymupdf` の wheel 解決にビルド依存が要る場合があるため、イメージビルドを実機で検証すること(`pymupdf>=1.24` に pin 済)。コンテナ運用では `features: nesting=1`user namespace)が前提。
+87
View File
@@ -0,0 +1,87 @@
# Skills
スキルはエージェントが参照する**手順書・知識ベース**です。Claude Code / Codex / gstack と互換のフォーマット(Markdown + YAML frontmatter)で記述し、エージェントが必要に応じて `ReadSkill` で読み込みます。
## Piece との違い
| | Piece | Skill |
|---|-------|-------|
| 役割 | **実行テンプレート** — ツール権限・遷移フローを制御 | **参照知識** — 手順・規約・ガイドを提供 |
| フォーマット | YAMLMAESTRO 固有) | MarkdownClaude Code / Codex 互換) |
| 選択 | piece-classifier が自動選択 | エージェントが ReadSkill で任意に参照 |
| 実行制御 | `allowed_tools` でツールを制限 | 制御なし(エージェントの判断に委ねる) |
**判断基準:**
- ツールの許可・禁止や movement フローを定義したい → **Piece**
- 手順書・コーディング規約・デバッグガイドをエージェントに参照させたい → **Skill**
## スキルの構造
### 単一ファイル形式
```
data/skills/my-workflow.md
```
### ディレクトリ形式(スクリプト同梱)
```
data/skills/tdd/
SKILL.md ← 本体(frontmatter + 手順)
scripts/ ← 実行可能スクリプト(任意)
references/ ← 参照ドキュメント(任意)
```
### Frontmatter
```yaml
---
name: tdd
description: テスト駆動開発の手順
triggers:
- テスト
- 新機能
---
```
- `name`(必須): `[a-z0-9_-]` のみ
- `description`(必須): 1 行の説明
- `triggers`(任意): 関連キーワード
## スコープ
| スコープ | 場所 | 書き込み | 読み取り |
|---------|------|---------|---------|
| system | `data/skills/` | admin のみ | 全ユーザー |
| user | `data/users/{userId}/skills/` | 本人のみ | 本人のみ |
ユーザースキルがシステムスキルと同名の場合、ユーザースキルが優先されます。
## インストール方法
### UI から
Settings → Skills タブで:
- 「Install from URL」に Git リポジトリの HTTPS URL を入力
- 「+ New Skill」で手動作成
### エージェント経由
```
InstallSkill({ name: "my-skill", content: "...", scope: "user" })
InstallSkillFromDir({ sourcePath: "/workspace/output/skill-dir", name: "my-skill", scope: "user" })
```
### 手動
`data/skills/` または `data/users/{userId}/skills/``.md` ファイルまたはディレクトリを配置すると自動検出されます(60 秒のキャッシュ TTL)。
## セキュリティ
- インストール時にセキュリティスキャンが実行されます
- `high` 重大度(prompt injection、他ユーザーリソース参照等)はインストールをブロック
- `medium` 重大度(外部 URL、ネットワークコマンド等)は警告表示
- スクリプト実行は `bash_unrestricted: true` 時のみ可能(bwrap sandbox 内で隔離実行)
## エージェントからの利用
movement 開始時にスキルインデックス(名前 + 説明の一覧)がシステムプロンプトに自動注入されます。エージェントは `ReadSkill({ name: "tdd" })` で本文を取得し、指示に従って作業します。
ディレクトリ形式のスキルでは、レスポンスに sandbox 内パス(`/skills/tdd`)が含まれ、`bash /skills/tdd/scripts/run.sh` のようにスクリプトを実行できます。
+996
View File
@@ -0,0 +1,996 @@
# SSH Subsystem (Operator Runbook)
The orchestrator can run shell commands on remote servers (`SshExec`) and
move files between the workspace and remote hosts (`SshUpload`/`SshDownload`)
through a dedicated, audited SSH subsystem. Like the MCP integration, **the
feature is off by default** and requires a key + config flip to enable.
This document is the **operator runbook** for setting up, granting access,
verifying host keys, rotating the master key, and troubleshooting. For the
LLM-facing tool semantics see [docs/tools/ssh-tools.md](./tools/ssh-tools.md).
For the internal design (threat model, risk register, schema, 12-step
orchestration flow) see
## At a glance
| Aspect | Behavior |
|---|---|
| Default | `ssh.enabled: false` — tools hidden, panels hidden, API returns 503 |
| Tools exposed when enabled | `SshExec`, `SshUpload`, `SshDownload` |
| Authentication | Public-key only; passwords are **not** supported |
| Host key trust | TOFU (Trust-On-First-Use) with explicit verify; mismatch fails closed |
| Connection ownership | User-owned (private) **or** Global (admin-managed, shared via grants) |
| Encryption at rest | AES-256-GCM (per-row DEK, master key = `MCP_ENCRYPTION_KEY`) |
| Audit | Dedicated `ssh_audit_log` table, `pending → success/failed/denied/aborted` lifecycle |
| Abuse defense | 3-scope counters (`user` / `host:user` / `host`) with auto-lock |
| Network policy | SSRF strict by default; per-connection opt-in for private IPs |
| Algorithm policy | Strict allowlist (no SHA1-RSA, no weak DH/HMAC) |
## Prerequisites
### 1. `MCP_ENCRYPTION_KEY`
The SSH subsystem **shares the same master key as MCP** — there is only one
key per orchestrator. All private keys, passphrases, and global-connection
DEKs are encrypted with AES-256-GCM under a per-row DEK, and each DEK is
wrapped by this master key.
Generate it once (32 bytes = 64 hex chars):
```bash
openssl rand -hex 32
```
Export it before starting the server:
```bash
export MCP_ENCRYPTION_KEY=<the 64-hex output>
scripts/server.sh start
```
If `MCP_ENCRYPTION_KEY` is **not** set when `ssh.enabled: true`, the SSH
subsystem boots **fail-soft**: a warning is logged, all SSH endpoints
return 503, the tools are hidden from LLM, and the UI panels show a
configuration error banner. Other features (MCP excepted) continue
normally.
> ⚠ **Key rotation invalidates existing encrypted material.** There is a
> built-in [master key rotation flow](#master-key-rotation) that rewraps
> every row in maintenance mode. Do **not** swap the env var manually
> without using that flow — half-rotated state breaks every connection.
### 2. `ssh.enabled: true`
Flip the flag in `config.yaml`:
```yaml
ssh:
enabled: true
```
This is the master switch. With it `false`:
- HTTP endpoints (`/api/ssh/*`, `/api/ssh/admin/*`) return 503
- Tool defs are not exposed to the LLM (the dispatcher returns null)
- UI panels render an "SSH is disabled" empty state
- Database tables remain present (no destructive change)
Restart is **not** required — `ConfigManager` reload picks up the change
and rebuilds the SSH router.
### 3. `system_deks` bootstrap
The first time the orchestrator boots with `ssh.enabled: true` AND a
valid `MCP_ENCRYPTION_KEY`, it provisions a single row in `system_deks`
(via `INSERT OR IGNORE` inside a transaction, `CHECK(id=1)`). This DEK
encrypts **global connections** (those without an owner).
On every subsequent boot, `verifySystemDek` decrypts the stored DEK to
prove the master key still works. If it fails (key rotated outside the
rotation flow, or env var differs from when the DEK was wrapped), SSH
**fails closed for the session** and a `system_dek_verify_failed` error
is logged. User-owned connections may still partially work (their DEKs
are wrapped per user), but global connections will all error.
### 4. Optional: `allow_private_addresses`
By default, SSH connections are routed through the SSRF strict-check,
which blocks loopback (127.0.0.0/8, ::1) and private (10/8, 172.16/12,
192.168/16, fc00::/7, 169.254/16) addresses. For **LAN targets** you
must opt in.
There are two scopes:
```yaml
ssh:
enabled: true
allow_private_addresses: true # global default
```
```sql
-- per-connection opt-in (admin-only flag, audited)
UPDATE ssh_connections SET allow_private_addresses=1 WHERE id=?;
```
The per-connection flag is preferred — narrow the blast radius. The
global flag exists for trusted dev networks (homelab, isolated VPC).
The per-connection flag can only be set on **global** (admin-managed)
connections; for user-owned connections, the global flag applies.
## Quickstart
```bash
# 1. Set the key
openssl rand -hex 32 > ~/.mcp_encryption_key
export MCP_ENCRYPTION_KEY=$(cat ~/.mcp_encryption_key)
# 2. Enable SSH + allow LAN
cat >> config.yaml <<'YAML'
ssh:
enabled: true
allow_private_addresses: true # only if you're targeting LAN
YAML
# 3. Restart
scripts/server.sh restart
```
Then in the UI:
1. **Settings → User Folder → SSH Connections → Add**
2. Fill `label`, `host`, `port` (default 22), `username`, paste private key (OpenSSH PEM)
3. Optionally set `remote_path_prefix` (default `/`) — restricts upload/download paths
4. Click **Test** → first call returns `host_key_first_observe` with a fingerprint
5. **Verify** in the dialog (compare fingerprint with what you expect from `ssh-keyscan <host>`)
6. Add the connection's UUID to a piece's `allowed_ssh_connections`:
```yaml
# pieces/example.yaml
name: ssh-example
movements:
- name: deploy
allowed_tools: [SshExec, SshUpload]
allowed_ssh_connections: ["abcd1234-..."]
rules:
- condition: done
next: COMPLETE
instruction: |
Use SshExec to ...
```
7. Test the piece via the normal task UI.
## `config.yaml` Reference
Full SSH section with defaults:
```yaml
ssh:
# master switch
enabled: false
# SSRF policy — when true, allow private/loopback addresses (global)
allow_private_addresses: false
# wall-clock timeout for connect + handshake + exec/transfer (seconds)
call_timeout_seconds: 30
# stdout/stderr byte cap for SshExec (bytes)
max_output_bytes: 32768 # 32 KiB
# SFTP transfer size caps (MB)
max_upload_size_mb: 100
max_download_size_mb: 100
# ssh_audit_log retention (days). Admin can prune via UI.
audit_retention_days: 90
# When true (default), admins can use any connection without an explicit
# grant. Audited regardless. Set false for stricter least-privilege.
admin_bypasses_grants: true
# Abuse counters
abuse_window_minutes: 10 # rolling window for failure counting
abuse_failure_threshold: 5 # failures within window → lock
abuse_lock_minutes: 30 # lock duration on threshold breach
```
All keys translate to camelCase in `SshRuntimeConfig` (`src/ssh/config.ts`).
The `transformKeys` helper in `src/config.ts` handles the conversion.
## Connection Model
### Owner
Each row in `ssh_connections` has an `owner_id`:
| Owner | Visibility | Who creates |
|---|---|---|
| **User-owned** (`owner_id = userId`) | Only the owner; admin can also list but not edit | Any authenticated user (`POST /api/ssh/connections`) |
| **Global** (`owner_id IS NULL`) | All users see it in the picker (subject to grants) | Admin only (`POST /api/ssh/admin/globals`) |
Global connections solve the "team-shared infra account" use case — a
single set of credentials that multiple users invoke under their own
identity, audited per user, gated by grants.
### Encryption
For each connection:
1. Generate a fresh 32-byte DEK
2. Encrypt `private_key_pem` (and optionally `passphrase`) with the DEK
3. Wrap the DEK with the master key (`MCP_ENCRYPTION_KEY`)
4. Store: `private_key_enc`, `private_key_dek_enc`, `key_version`
`key_version` allows progressive rewrap during master key rotation (each
row tracks which generation of master key its DEK is wrapped under).
Global connections use the single `system_deks` row (id=1) rather than a
per-row DEK.
> ⚠ ssh2's internal key handling is opaque — once the PEM is loaded into
> the library, it lives in JS heap memory for the lifetime of the
> connection. We `Buffer.fill(0, 0)` our copies in the `finally` block
> but cannot reach into ssh2 internals. This is an acknowledged
> limitation; see the plan doc's "Acknowledged limitations" section.
## UI Walkthrough
### User: Settings → User Folder → SSH Connections
The **SshConnectionsPanel** lists the user's connections and any global
connections they have a grant for. Each row shows:
- Label + host:port + username
- Host key fingerprint + verify state (verified / pending / first_observe / mismatch)
- Lock state (if abuse counter triggered)
- Actions: **Test**, **Verify host key**, **Replace host key** (with reason), **Edit**, **Delete**
The "Add Connection" form (`SshConnectionForm`) collects:
- Label
- Host, port, username
- Private key (textarea — PEM format; passphrase optional)
- Remote path prefix (default `/`)
- Custom deny/allow regex patterns (newline-separated, validated at save-time)
`SshHostKeyDialog` opens on first_observe / mismatch and shows the
observed fingerprint side-by-side with the previously-stored one (if
any). "Trust this key" requires typing the fingerprint to confirm.
### Admin: Settings → SSH
Four sub-panels under `SshForm`:
| Panel | Component | Purpose |
|---|---|---|
| **Global Connections** | `SshGlobalConnectionsForm` | CRUD on global connections (`owner_id IS NULL`). Includes the `allow_remote_unrestricted` and per-connection `allow_private_addresses` flags |
| **Grants** | `SshGrantsForm` | List/create/delete grants. Per-piece or `applies_to_all_pieces`. Subject: user or org. Reason required |
| **Audit Log** | `SshAuditLog` | All-tenant audit view. Filter by action / outcome / connection / time range. Pagination |
| **Master Key Rotation** | `SshMasterKeyRotationForm` | Start a rotation job (provides new key, enters maintenance, rewraps rows). Polls status |
Admin can also force-unlock abuse counters from the per-connection page
(requires reason; rate-limited to 10/hour total).
## Host Key TOFU Flow
SSH security depends on knowing the **right** host key. We use Trust-On-
First-Use: the first time a connection is exercised, we record the
observed key and require explicit user verification before treating
it as trusted.
### States
`ssh_connections` carries three host-key columns:
| Column | Meaning |
|---|---|
| `host_key_b64` | The observed public key in OpenSSH base64 form. NULL = never observed. |
| `host_key_fingerprint` | SHA-256 fingerprint for UI display (`SHA256:...`). |
| `host_key_verified_at` | ISO8601 timestamp of the user's explicit "trust this key" action. NULL = pending. |
| `host_key_pending_token` | UUID issued at first_observe / mismatch; consumed atomically by `/verify-host-key`. |
A connection is **trusted** iff `host_key_verified_at IS NOT NULL` AND
the observed key during connect matches `host_key_b64`.
### Lifecycle
```
new connection
├─ user clicks Test (or LLM calls SshExec)
│ │
│ ▼
│ sshTest() observes the host key
│ │
│ ▼
│ onFirstObserve hook fires
│ - writes host_key_b64, host_key_fingerprint, host_key_pending_token
│ - audit row: ssh.connection.host_key.first_observe
│ - returns SshSessionError('host_key_first_observe')
│ (UI shows the fingerprint + pending token)
├─ user clicks Verify (typing fingerprint to confirm)
│ │
│ ▼
│ POST /api/ssh/connections/:id/verify-host-key
│ {token, fingerprint}
│ - atomic compare-and-set: token + fingerprint match → set host_key_verified_at
│ - audit row: ssh.connection.host_key.verify
verified — Exec/Upload/Download now work
```
On `host_key_mismatch` (server rebuilt, key rotated, or MITM):
```
├─ Exec/Upload/Download calls sshExec/sshUpload/sshDownload
│ │
│ ▼
│ ssh2 observes key ≠ host_key_b64
│ │
│ ▼
│ onMismatch hook fires
│ - writes new host_key_pending_token (DOES NOT overwrite host_key_b64 yet)
│ - audit row: ssh.connection.host_key.mismatch
│ - returns SshSessionError('host_key_mismatch')
│ (UI shows OLD vs NEW fingerprint side-by-side)
├─ user investigates externally (ssh-keyscan, IT team, etc.)
├─ user clicks "Replace key" with reason
│ │
│ ▼
│ POST /api/ssh/connections/:id/replace-host-key
│ {token, fingerprint, reason}
│ - atomic compare-and-set
│ - writes new host_key_b64, host_key_fingerprint, host_key_verified_at
│ - audit row: ssh.connection.host_key.replace
```
The pending token mechanism prevents a "verify swap" race: if a second
TOFU observation happens between the user's verify request and its
arrival, the old token is overwritten and the verify endpoint returns
`409 stale_token`.
### Banned algorithms
Even before TOFU completes, the host-key algorithm is checked against
an allowlist. SHA1-RSA and other weak algorithms are rejected before
the key is recorded (`host_key_alg_not_allowed`). This is hard-coded
in `src/ssh/session.ts` to avoid misconfiguration.
## Per-piece `allowed_ssh_connections`
A piece's movement must explicitly opt in to SSH usage. The
piece-runner enforces three invariants:
1. If a movement's `allowed_tools` contains any SSH tool name
(`SshExec`/`SshUpload`/`SshDownload`), `allowed_ssh_connections`
**must be declared** on that movement (even if empty)
2. The field must be an array of strings
3. Each entry must be `*` or a lowercase hex+hyphen UUID (≥ 8 chars)
Lint failures abort piece load.
### Forms
```yaml
# Explicit allowlist (most common)
allowed_ssh_connections: ["abcd1234-...", "ef567890-..."]
# Wildcard (admin-style — use sparingly)
allowed_ssh_connections: ["*"]
# Deny-all (still allows SSH tool in allowed_tools but refuses every UUID)
allowed_ssh_connections: []
```
The `*` form skips the per-piece check but **does not** skip the
[access grant check](#access-grants). A user without a grant for a
given connection still cannot use it even when the piece says `*`.
### Example
```yaml
name: backup-rotation
description: Daily backup rotation on prod servers
movements:
- name: list
allowed_tools: [SshExec]
allowed_ssh_connections: ["abcd1234-...", "ef567890-..."]
instruction: |
List the existing backup files on each server.
rules:
- condition: ready to rotate
next: rotate
- name: rotate
allowed_tools: [SshExec, SshUpload]
allowed_ssh_connections: ["abcd1234-...", "ef567890-..."]
instruction: |
Rotate the oldest backup ...
rules:
- condition: done
next: COMPLETE
```
## Access Grants
Grants connect a **subject** (user or org) to a **connection**, scoped
to a **piece** (or all pieces, admin-only).
### Schema
```sql
CREATE TABLE ssh_connection_grants (
id TEXT PRIMARY KEY,
connection_id TEXT NOT NULL,
subject_type TEXT NOT NULL, -- 'user' | 'org'
subject_id TEXT NOT NULL,
piece_name TEXT, -- NULL iff applies_to_all_pieces=1
applies_to_all_pieces INTEGER NOT NULL DEFAULT 0,
granted_by_user_id TEXT NOT NULL,
reason TEXT NOT NULL, -- required, ≥ 8 chars
expires_at TEXT, -- ISO8601 or NULL
created_at TEXT NOT NULL
);
```
### Decision tree
For a given `(userId, orgIds, connectionId, pieceName)`:
1. **Owner check**: if `connection.owner_id == userId` → access granted (owner of own connection)
2. **Admin bypass**: if user is admin AND `ssh.admin_bypasses_grants: true` → granted (audited)
3. **Grant lookup**:
- find rows where `connection_id = ?`
- subject matches (`subject_type='user' AND subject_id=userId`) OR (`subject_type='org' AND subject_id IN orgIds`)
- piece matches (`applies_to_all_pieces=1` OR `piece_name = ?`)
- not expired (`expires_at IS NULL OR expires_at > now()`)
- **any** matching row → granted
4. Otherwise → denied (`access denied (no_grant)`)
### Creating grants
Admin-only via `POST /api/ssh/admin/grants`:
```json
{
"connection_id": "abcd1234-...",
"subject_type": "user",
"subject_id": "alice",
"piece_name": "backup-rotation",
"applies_to_all_pieces": false,
"reason": "Alice owns backups for prod-east cluster",
"expires_at": null
}
```
For `applies_to_all_pieces: true`:
- `piece_name` **must be null**
- the admin endpoint requires explicit `reason` containing scope justification
- audit row records `action: ssh.grant.create` with `detail.applies_to_all=true`
- this is the highest-privilege grant — review carefully
### Org grants
Same schema with `subject_type: "org"`, `subject_id: <gitea org name>`.
Membership comes from `user_gitea_orgs` (populated at login via Gitea
OAuth). A user with multiple org memberships matches grants for any
of those orgs.
### Expiration
`expires_at` is checked at decision time (no background sweep). Expired
rows remain in the table for audit purposes. Admin can delete them via
`DELETE /api/ssh/admin/grants/:id`.
## Path Policy
### Local path (workspace)
For `SshUpload.local_path` and `SshDownload.local_path`:
- Resolved against `ctx.workspacePath` (the job's workspace root)
- `..` traversal → reject
- Symlinks: open with `O_NOFOLLOW`, lstat every parent → reject if any parent is a symlink leaving the workspace
- For download: parent directory must exist; target file must NOT exist (`O_CREAT | O_EXCL`)
### Remote path
For `remote_path` on upload/download:
- Must be **absolute** (starts with `/`)
- After POSIX normalization (`path.posix.normalize`), must start with the connection's `remote_path_prefix`
- `..` segments are collapsed by normalize; the post-normalize check catches escape attempts
- No glob expansion — exact path only
Example: connection has `remote_path_prefix = '/srv/agent'`
| Input | Normalized | Result |
|---|---|---|
| `/srv/agent/file.txt` | `/srv/agent/file.txt` | ✅ |
| `/srv/agent/sub/file.txt` | `/srv/agent/sub/file.txt` | ✅ |
| `/srv/agent/../etc/passwd` | `/etc/passwd` | ❌ outside prefix |
| `/srv/agentish/file` | `/srv/agentish/file` | ❌ prefix mismatch (not `/srv/agent/...`) |
| `file.txt` (relative) | n/a | ❌ not absolute |
## Command Filtering
`SshExec.command` runs through a two-stage filter.
### Stage 1: built-in deny-list
Hard-coded patterns in `src/ssh/deny-list.ts`. Examples (not exhaustive):
- `rm -rf /` and variants
- fork bombs (`:(){:|:&};:`)
- `mkfs.*`, `dd if=/dev/zero ...`
- shutdown / reboot / poweroff
- `:>/dev/sda` style block-device writes
If matched, the call is rejected with `command rejected by built-in
deny-list (matched pattern: ...).` and audited as `outcome=denied`.
The built-in list is **not** a comprehensive sandbox — it's a tripwire
against the most catastrophic typos and worst-case prompt injection
payloads. Production deployments should also configure connection-level
patterns.
### Stage 2: per-connection regex (optional)
Each connection can carry:
- `deny_patterns`: newline-separated regex list. Match → reject.
- `allow_patterns`: newline-separated regex list. If set, every command
must match at least one allow pattern (after passing both deny stages).
Both are validated at save-time by `validateCustomPatterns`:
- Each pattern must compile
- Each must pass the `safe-regex` ReDoS check
- Aggregate length capped (no megabyte-blobs of regex)
Example:
```
# deny_patterns
sudo
^\s*rm\s+
nc\s+-l
# allow_patterns
^(ls|cat|grep|tail|head|systemctl|journalctl)\s
^/srv/agent/scripts/
```
ReDoS-safe regex is enforced because user-supplied patterns run
synchronously on the command string before each call.
## SSRF + Algorithms
### SSRF (host resolution)
Every connection target goes through `ssrfStrict(host, allowPrivate)`:
1. DNS resolve host → list of A/AAAA records
2. For each address, check against the IP-policy:
- Reject 0.0.0.0, ::/0
- Reject 127.0.0.0/8, ::1 (loopback)
- Reject 10/8, 172.16/12, 192.168/16, fc00::/7 (private)
- Reject 169.254/16 (link-local — including AWS metadata)
3. **DNS pinning**: the resolved address is captured before connect;
ssh2 connects to the pinned IP, not to the hostname. This prevents
DNS rebinding (round 1: public IP passes check; round 2: returns
loopback during connect).
`allowPrivate` short-circuits step 2. Two opt-in flags compose:
- Global: `ssh.allow_private_addresses: true` in config.yaml
- Per-connection: `allow_private_addresses=1` on the row (admin sets via
`/api/ssh/admin/globals` or `/api/ssh/admin/connections/:id`)
Either being true allows private/loopback. Both default false.
### Algorithm allowlist
Hard-coded in `src/ssh/session.ts`:
| Category | Allowed |
|---|---|
| Key exchange | `curve25519-sha256`, `[email protected]`, `ecdh-sha2-nistp256/384/521`, `diffie-hellman-group14/16/18-sha256/512` |
| Server host key | `ssh-ed25519`, `rsa-sha2-256`, `rsa-sha2-512`, `ecdsa-sha2-nistp256/384/521` |
| Cipher | `[email protected]`, `[email protected]`, `aes256-ctr`, `aes192-ctr`, `aes128-ctr` |
| HMAC | `[email protected]`, `[email protected]`, `hmac-sha2-512`, `hmac-sha2-256` |
Notably banned: `ssh-rsa` (SHA1), `ssh-dss`, all `arcfour*`, `hmac-md5*`,
`hmac-sha1*`. Mismatch returns `host_key_alg_not_allowed` or
`auth_failed` depending on which stage caught it.
## Audit Log
Single table: `ssh_audit_log`. Every SSH operation writes here.
### Lifecycle
```
begin (outcome=pending) [commits before remote call]
remote call (ssh2 connect / exec / sftp)
complete (outcome=success|failed|denied|aborted) [updates same row]
```
If the orchestrator crashes between `begin` and `complete`, the row
stays `pending`. On next boot, the recovery sweep (`src/ssh/recovery.ts`)
updates pending rows older than 10 minutes to `aborted` with
`detail.recovered_at` set.
### Actions
| Action | Triggered by |
|---|---|
| `ssh.exec` | `SshExec` |
| `ssh.upload` | `SshUpload` |
| `ssh.download` | `SshDownload` |
| `ssh.connection.upsert` | User/admin connection create/edit |
| `ssh.connection.delete` | User/admin connection delete |
| `ssh.connection.host_key.first_observe` | TOFU first observation |
| `ssh.connection.host_key.mismatch` | TOFU mismatch |
| `ssh.connection.host_key.tofu_record` | Internal helper write |
| `ssh.connection.host_key.verify` | User `/verify-host-key` |
| `ssh.connection.host_key.replace` | User `/replace-host-key` |
| `ssh.connection.disable` | Admin disable |
| `ssh.connection.enable` | Admin enable |
| `ssh.abuse.unlock_manual` | Admin force-unlock |
| `ssh.grant.create` | Admin grant create |
| `ssh.grant.delete` | Admin grant delete |
| `ssh.master_key.rotate.start` | Admin rotation start |
### Detail column
JSON blob with action-specific fields:
- `ssh.exec`: `{command_hash: "abc...", exit_code: 0, stdout_bytes: 123, stderr_bytes: 0, truncated: false}`
- `ssh.upload`: `{local_path: "...", remote_path: "...", bytes: 4096}`
- `ssh.download`: same shape
- `ssh.connection.host_key.first_observe`: `{fingerprint: "SHA256:...", pending_token: "uuid"}`
- All denied: `{error: "no_grant" | "abuse_locked" | "disabled" | ...}`
The `ssh.exec` action does **not** record the command string — only its
SHA-256 hash (16-char hex prefix) to avoid leaking secrets / PII. If you
need to investigate a specific exec, correlate the hash with stdin
logs from the LLM activity log (workspace `logs/activity.log`).
### Retention
`ssh.audit_retention_days` (default 90) controls a lazy sweep. Admin can
trigger pruning manually from the UI. There is no hard cap on table
size — disk-fill is mitigated by the hashing + truncation strategy
above, plus admin-driven cleanup.
## Abuse Counters & Lock
Defends against credential spraying, mistyped scripts in loops, and
brute-force scans.
### Three scopes
| Scope | Key | When |
|---|---|---|
| `user` | `(user_id,)` | Any SSH failure by this user |
| `host:user` | `(host, username)` | Failure on this (host, username) tuple |
| `host` | `(host,)` | Failure on this host (global connections only) |
The `host` scope intentionally **only counts failures on global
connections** to prevent cross-user DoS: a user repeatedly failing on
their own connection cannot lock out other users from a shared host.
For user-owned connections, the `host` counter is updated for
admin-notification only — no lock applies.
### Algorithm
```
on failure:
for each scope:
increment counter
if count(within abuseWindowMinutes) >= abuseFailureThreshold:
lock until now + abuseLockMinutes
on success (user scope only):
reset user counter
other scopes age out naturally with the window
```
Counters are stored in `ssh_abuse_counters`, with separate columns per
scope. All updates are transactional (no UPSERT race).
### Force-unlock
Admin can force-unlock from `SshGlobalConnectionsForm` or per-connection
admin page:
```
POST /api/ssh/admin/connections/:id/force-unlock
{reason: "Confirmed credentials rotated; user retried with old key"}
```
Rate-limited to 10/hour total across all admins (`admin-rate-limit.ts`,
token bucket). Audited as `ssh.abuse.unlock_manual`.
## Master Key Rotation
Replaces `MCP_ENCRYPTION_KEY` and rewraps every row's DEK under the new
key. This is **the** way to rotate the master key — do not edit the env
var manually.
### Flow
1. **Admin starts** via `POST /api/ssh/admin/rotate-master-key`:
```json
{"new_key_hex": "<64-hex>", "reason": "Annual rotation"}
```
2. **Maintenance mode engages** — `sshMaintenance.enter()` returns 503
for all SSH write endpoints (read endpoints stay alive). The LLM
sees `SSH subsystem is in maintenance` errors for tool calls.
3. **Per-row rewrap**:
- For each `ssh_connections` row: decrypt DEK under old key, re-encrypt under new key, bump `key_version`, commit (one tx per row)
- For each `system_deks` row: same
4. **New key validated** by decrypting a test value
5. **Maintenance exits** automatically
6. **Caller polls** `GET /api/ssh/admin/rotate-master-key/:jobId` for status (`running` / `succeeded` / `failed`)
### Failure modes
- **Crash mid-rotation**: rows have mixed `key_version`. Next boot detects this and stays in maintenance until a follow-up rotation completes. The admin must re-issue the rotation with the new key.
- **Wrong old key**: the first row decryption fails → job aborts before any change, maintenance exits, audit records `ssh.master_key.rotate.start` with `outcome=failed`.
- **Disk write fails mid-row**: that single row is rolled back; rotation continues. Operator must re-run.
The rotation job runs in-process (not as a separate worker). For large
fleets (>1000 rows) expect 1-2s per row of decrypt+encrypt+write.
### `MCP_ENCRYPTION_KEY` env var
After successful rotation, **the env var must be updated to the new
key** before the next restart. The orchestrator writes the new key to
the audit log (encrypted under the OLD key) and returns it once in the
HTTP response — there's no second chance. Update your secrets store
immediately.
## Operator Runbook
### A. Add a global (admin-managed) connection
```bash
# Via UI: Settings → SSH → Global Connections → Add
# Or via API (requires admin session cookie):
curl -X POST http://localhost:3000/api/ssh/admin/globals \
-H 'Content-Type: application/json' \
-d @- <<'JSON'
{
"label": "prod-east-bastion",
"host": "bastion.prod-east.example.com",
"port": 22,
"username": "deploy",
"private_key_pem": "-----BEGIN OPENSSH PRIVATE KEY-----\n...\n-----END...",
"passphrase": null,
"remote_path_prefix": "/srv/deploy",
"allow_private_addresses": false,
"deny_patterns": "sudo\n^\\s*rm\\s+",
"allow_patterns": "",
"reason": "Production deploy bastion — owned by SRE"
}
JSON
```
Then verify the host key (next section) and grant access.
### B. Grant org access to a global connection
```bash
curl -X POST http://localhost:3000/api/ssh/admin/grants \
-H 'Content-Type: application/json' \
-d '{
"connection_id": "<uuid>",
"subject_type": "org",
"subject_id": "engineering",
"piece_name": "prod-deploy",
"applies_to_all_pieces": false,
"reason": "Engineering org runs prod-deploy piece"
}'
```
### C. Verify a TOFU first-observe
1. From the user's side or admin side, click **Test** in the SshConnections panel
2. The response is `host_key_first_observe` with a SHA-256 fingerprint and pending token
3. **Verify externally** that the fingerprint matches the real server:
```bash
ssh-keyscan -t ed25519 bastion.prod-east.example.com 2>/dev/null \
| ssh-keygen -lf -
```
Compare the resulting `SHA256:...` with what the UI shows
4. In the dialog, type the fingerprint to confirm and click **Verify**
5. Audit row `ssh.connection.host_key.verify` recorded; subsequent calls succeed
### D. Force-unlock a stuck connection
Symptom: user reports "SshExec returns `access denied (abuse_locked)`"
1. Settings → SSH → Global Connections → click the row → "Locks" section
2. Inspect the counter state (which scope is locked, until when)
3. If genuinely needs early unlock (e.g. user fixed the bad credentials), click **Force unlock**, enter reason
4. If suspicious (unexplained 5+ failures), investigate audit log first
### E. Rotate the master key
```bash
NEW_KEY=$(openssl rand -hex 32)
# Start rotation
JOB=$(curl -s -X POST http://localhost:3000/api/ssh/admin/rotate-master-key \
-H 'Content-Type: application/json' \
-d "{\"new_key_hex\":\"$NEW_KEY\",\"reason\":\"Q2 annual rotation\"}" \
| jq -r .job_id)
# Poll until done
while true; do
STATUS=$(curl -s http://localhost:3000/api/ssh/admin/rotate-master-key/$JOB \
| jq -r .status)
echo "$STATUS"
[ "$STATUS" = "succeeded" ] && break
[ "$STATUS" = "failed" ] && { echo FAILED; exit 1; }
sleep 2
done
# Update env var BEFORE next restart
echo "MCP_ENCRYPTION_KEY=$NEW_KEY" >> /etc/orchestrator/secrets.env
```
### F. Prune old audit logs
Settings → SSH → Audit Log → "Prune older than N days" (defaults to the
config retention value). Or via API:
```bash
curl -X DELETE 'http://localhost:3000/api/ssh/admin/audit?older_than_days=90'
```
## Troubleshooting
### Symptom → cause table
| Error | Common cause | Fix |
|---|---|---|
| `SSH is disabled` (503) | `ssh.enabled: false` | Set true, restart not required |
| `SSH subsystem is in maintenance` | Master key rotation in progress | Wait for job to complete, or check rotation log |
| `access denied (no_grant)` | User lacks grant for connection | Admin creates a grant, or user uses an owned connection |
| `access denied (disabled)` | Admin disabled the connection | Admin re-enables, or use different connection |
| `access denied (abuse_locked)` | Counter triggered | Wait for lock window, or admin force-unlocks |
| `piece "X" does not list connection Y` | `allowed_ssh_connections` missing UUID | Add UUID to the movement's `allowed_ssh_connections` |
| `host_key_first_observe` | First time exercising connection | Verify fingerprint in UI |
| `host_key_not_verified` | Key recorded but never verified | Click Verify in UI |
| `host_key_mismatch` | Server key changed | Investigate (legitimate rotation? MITM?), then Replace via UI |
| `host_key_alg_not_allowed` | Server using SHA1-RSA etc. | Upgrade server to ed25519 / rsa-sha2-256 |
| `auth_failed` | Wrong key, wrong username | Re-check connection settings |
| `connect_timeout` | Network unreachable, firewall | Check from host, check SSRF policy |
| `exec_timeout` | Long-running command | Increase `timeout_ms`, or run in background and `Download` results |
| `output_too_large` | stdout > 32 KiB | Filter the command, or write to file and `Download` |
| `forbidden_address` | Target is private IP, no opt-in | Set `allow_private_addresses` per-connection or globally |
| `system_dek_verify_failed` (log) | `MCP_ENCRYPTION_KEY` changed without rotation flow | Stop server, restore old key OR re-rotate via flow |
### Where to look
| Question | Source |
|---|---|
| What did the LLM try to do? | `logs/activity.log` in the job's workspace |
| What did SSH do? | `ssh_audit_log` (Admin UI or SQL) |
| Was it actually denied at the SSH layer? | Audit row `outcome` = `denied` |
| What was the exit code? | Audit `detail.exit_code` (for `ssh.exec`) |
| Did it crash mid-call? | Audit `outcome` = `aborted` (recovery sweep) |
| Why was the host key flagged? | Audit `ssh.connection.host_key.*` rows |
| Who has access to a connection? | `ssh_connection_grants` filtered by `connection_id` |
## Security Model Summary
Detailed threat model + risk register: see plan doc §"Security Design
Deep-Dive (rev 3)" and §"Risk Register (rev 3)".
Key points operators must understand:
1. **The orchestrator is a credential proxy.** Anyone with admin rights
can read connection plaintext (via the rotation flow, which decrypts
server-side). Treat admin access as production-credential-equivalent.
2. **TOFU is the floor, not the ceiling.** First-observe is unauthenticated.
For high-stakes targets, pre-populate `host_key_b64` from a trusted
bootstrap (e.g. baked into the connection at create time via the
`host_key_b64` field) rather than relying on the orchestrator's first
observation.
3. **The deny-list is not a sandbox.** Built-in patterns catch obvious
misuse. Real isolation requires connection-level configuration
(restricted shell account, `remote_path_prefix`, narrow `allow_patterns`)
and target-side controls.
4. **Audit log is local-only.** No HMAC chain (acknowledged limitation
R-audit-tamper). For tamper-evidence, ship `ssh_audit_log` rows to an
external SIEM via SQLite hooks or periodic export.
5. **ssh2 internal key retention** (R-ssh2-leak): the PEM lives in JS
heap for the connection lifetime. Process compromise reveals plaintext
credentials. Mitigations: short-lived processes, separate worker
per high-stakes connection.
6. **Master key compromise = total compromise.** Key rotation invalidates
already-leaked encrypted material — if an attacker has both the DB
and the old master key, all stored creds are theirs. Rotate keys
immediately on suspected compromise AND rotate every stored credential
on the target side.
## HTTP API Reference
User router: mounted at `/api/ssh` — requires `requireAuth`.
| Method | Path | Purpose |
|---|---|---|
| GET | `/connections` | List own connections + grant-visible globals |
| POST | `/connections` | Create user-owned connection |
| GET | `/connections/:id` | Read |
| PATCH | `/connections/:id` | Edit (owner only) |
| DELETE | `/connections/:id` | Delete (owner only) |
| POST | `/connections/:id/test` | Trigger TOFU observation / verify path |
| POST | `/connections/:id/verify-host-key` | Atomic verify (token + fingerprint) |
| POST | `/connections/:id/replace-host-key` | Atomic replace (token + fingerprint + reason) |
| GET | `/connections/:id/audit` | Owner's view of audit rows for this connection |
| GET | `/grants/visible-to-me` | List grants visible (subject=user or matching org) |
Admin router: mounted at `/api/ssh/admin` — requires `requireAdmin`.
| Method | Path | Purpose |
|---|---|---|
| GET | `/connections` | All connections (cross-tenant) |
| GET | `/connections/:id` | Admin read |
| PATCH | `/connections/:id/disable` | Soft-disable (audited; reason required) |
| PATCH | `/connections/:id/enable` | Re-enable |
| DELETE | `/connections/:id` | Hard-delete |
| POST | `/connections/:id/force-unlock` | Clear abuse counter (rate-limited; reason required) |
| POST | `/globals` | Create global connection |
| PATCH | `/globals/:id` | Edit global |
| DELETE | `/globals/:id` | Delete global |
| GET | `/grants` | List all grants |
| POST | `/grants` | Create grant |
| DELETE | `/grants/:id` | Delete grant |
| POST | `/rotate-master-key` | Start master key rotation |
| GET | `/rotate-master-key/:jobId` | Poll rotation status |
| GET | `/audit` | All-tenant audit view (paginated) |
All admin write endpoints require:
- `requireAdmin` middleware
- `maintenance503()` guard (rejects writes during rotation)
- `validateReason()` on `body.reason` (≥ 8 chars)
- `auditRepo.beginAndComplete()` for success/failure both
## SSH Console (Interactive)
`ssh.console.enabled: true` で有効化。
- 1 タスク = 1 PTY セッション。job をまたいで shell state を維持
- Tab `SSH` がタスク詳細に出る (piece が SshConsole* を allow している場合)
- WebSocket: `/api/local/tasks/:taskId/console/ws`
- REST status: `GET /api/local/tasks/:taskId/console/status`
- 監査: `ssh.console.{open,send,snapshot,resize,input_rejected,close}`
- 自動 close: idle 30min / duration 4h / host disconnect / maintenance / admin kill
- 同 connection あたり最大 3 sessions (古い順に evict)
Admin: `GET /api/admin/ssh/console-sessions` で一覧、 `POST /api/admin/ssh/console-sessions/:taskId/kill` で kill (admin role only)。
## See Also
- [docs/tools/ssh-tools.md](./tools/ssh-tools.md) — LLM-facing tool semantics
- [docs/tools/ssh-console-tools.md](./tools/ssh-console-tools.md) — SSH Console tool semantics (Ensure/Send/Snapshot)
- [docs/mcp.md](./mcp.md) — MCP integration (shares `MCP_ENCRYPTION_KEY`)
- [docs/maintenance-checklist.md](./maintenance-checklist.md) §12 — checklist for SSH-related code changes
+54
View File
@@ -0,0 +1,54 @@
# AnnotateImage
画像上に矩形枠・矢印・テキストラベルを SVG で重畳描画する。元画像は変更せず `output/` に新しいファイルとして保存される。
## 基本
```js
AnnotateImage({
input_path: "input/screenshot.png",
output_path: "output/annotated.png",
annotations: [
{ type: "rectangle", x: 100, y: 50, width: 200, height: 80, color: "#FF0000", label: "問題箇所" },
{ type: "arrow", from_x: 50, from_y: 200, to_x: 150, to_y: 250, color: "#00FF00", label: "ここに注目" },
{ type: "text", x: 300, y: 100, text: "重要", color: "#0000FF", font_size: 24 }
]
})
```
## annotation の種類
### rectangle(矩形)
- `x`, `y`: 左上の座標(px
- `width`, `height`: 幅・高さ(px
- `color`: 線色(CSS カラー、デフォルト `#FF0000`
- `label`: 矩形の上に表示するラベル(任意)
### arrow(矢印)
- `from_x`, `from_y`: 始点
- `to_x`, `to_y`: 終点(矢印の先)
- `color`: 線・矢じり色
- `label`: 始点付近に表示するラベル(任意)
### text(テキスト)
- `x`, `y`: テキストの基準点
- `text`: 表示文字列
- `color`: 文字色
- `font_size`: フォントサイズ(任意、画像サイズに応じて自動調整)
## 自動スケーリング
線幅・フォントサイズは画像サイズに応じて自動調整される(短辺ベース)。固定値で見栄えが崩れることは少ない。
## 用途
- スクリーンショットへの注釈追加
- 手順書の作成(操作手順を矢印で示す)
- バグ報告での問題箇所のハイライト
- レポート用の図解作成
## 注意
- `output_path``output/` 配下である必要がある
- 元画像は変更されない(非破壊的)
- 日本語ラベル使用時は環境に日本語フォントが必要(prepare.sh で自動チェック)
+87
View File
@@ -0,0 +1,87 @@
# Bash ツール
シェルコマンド実行ツール。**用途は限定されている**。
## 許可される用途
- ファイル操作: `cp`, `mv`, `rm`, `mkdir`, `ls`, `find`
- テキスト処理: `cat`, `grep`, `sed`, `awk`, `head`, `tail`, `sort`, `uniq`, `wc`
- Python スクリプト実行: `python3 script.py``python3 -c "..."` (データ処理・グラフ生成等)
- Git の参照系: `git log`, `git diff`, `git status` (履歴・差分の確認)
- アーカイブ: `tar`, `zip`, `unzip`
## 禁止される用途
**パッケージ・ソフトウェアのインストール一切**
- `apt install`, `apt-get install`
- `pip install`, `pip3 install`
- `npm install`, `yarn add`
- `curl ... | sh`, `wget ... | bash`
- `cargo install`, `go install`
**永続的システム変更**
- `systemctl`, `service` の操作
- `crontab`, `at` の登録
- `chmod` で権限を緩和する操作
**ネットワーク経由のダウンロード(コードや実行可能物)**
- `curl https://.../install.sh`
- 必要なら DownloadFile ツールを使う
## なぜインストールが禁止か
- ジョブ実行環境はサンドボックス化されており、永続化されない
- 必要な機能は専用ツール(`allowed_tools` に列挙されたもの)で提供される
- インストールが必要 = ツールの設計が足りないので、ユーザーに報告して機能追加を依頼する
## 代替
「○○ をインストールしたい」と思ったときの代替策:
| やりたいこと | 専用ツールで代替 |
|-------------|------------------|
| HTTP リクエスト | WebFetch / DownloadFile |
| HTML パース | BrowseWeb |
| 画像加工 | AnnotateImage / ReadImage |
| OCR | OCRTool / BatchOCRTool |
| Office ファイル読み込み | ReadPdf / ReadExcel / ReadDocx / ReadPPTX |
| 音声書き起こし | TranscribeAudio |
| データベース | SQLite |
| 検索 | WebSearch / SearchKnowledge |
## サンドボックス機構 (`safety.bash_sandbox`)
Bash 実行の隔離方式を選ぶ。**2 つの設定は直交する**:
- `safety.bash_sandbox`: 隔離機構を選ぶ — `auto`(既定)/ `always` / `off`
- `safety.bash_unrestricted`: コマンドホワイトリストを適用するか否か(`true` で撤廃)。**bwrap が走るかどうかは制御しない**(それは `bash_sandbox` の役割)
| `bash_sandbox` | 挙動 |
|----------------|------|
| `auto`(既定) | bwrap があれば bwrap サンドボックス、無ければ hardened(whitelist + パススコープ + env スクラブ)にフォールバック |
| `always` | bwrap を強制。bwrap 不在なら**起動失敗**(本番推奨) |
| `off` | bwrap を使わず exec**env スクラブと、unrestricted でなければ whitelist + パススコープは維持**)。デバッグ用・非推奨 |
### bwrap サンドボックスの構成
| マウント | 種別 |
|---------|------|
| タスクの workspace (`{worktreeDir}/local/{taskId}/`) | read-write bind |
| `/usr`, `/bin`, `/sbin`, `/lib`, `/etc` | read-only bind |
| `/lib64` (存在する場合) | read-only bind |
| `/tmp` | private tmpfs |
| `/proc`, `/dev` | proc / dev |
**マウントされないもの**: `/home`, 他タスクの workspace、ホストの `/tmp` など。他ユーザーの workspace にはファイルシステムレベルでアクセス不可。
**環境変数**: `--clearenv` で全消去後、`PATH`/`HOME`/`LANG`/`LC_ALL`/`TZ`/`TERM`/`TMPDIR` の最小 allowlist のみ注入する。`MCP_ENCRYPTION_KEY` 等のシークレットはサンドボックス内から見えない。hardened フォールバック経路も同じ allowlist で exec する。
**ネットワーク**: `--unshare-net` で隔離(ループバックのみ)。bash からの外向き通信は不可 — Web 取得は SSRF ガード付きの WebFetch / DownloadFile / MCP 経由に一本化されている。
**パッケージ**: 各 Bash コールは独立した bwrap サンドボックスで実行され(揮発 `/tmp`・毎回新しい名前空間)、`/usr` は read-only。よって `pip install` / `npm install` は永続せず、全モードで明示的に拒否される。必要な Python パッケージは `runtime/python-requirements.txt` にプリベイクされ、システム pythonread-only bind)から import できる。
### 前提条件 (`always` / bwrap 経路)
- コンテナ/ホストで **user namespace** が有効であること (PVE: `features: nesting=1`)
- `bwrap` バイナリがインストール済みであること
- `bash_sandbox: always`(または `bash_unrestricted: true`)では起動時に bwrap の動作確認を行い、失敗時はエラー終了する。`auto` では失敗時に警告ログを出し hardened へフォールバック
+162
View File
@@ -0,0 +1,162 @@
# Brainstorm 詳細ガイド
着手前または行き詰まり時に、複数アプローチを構造化された形で比較してから 1 つを採用する **思考の checkpoint** ツール。
LLM が「最初に思いついた方法でそのまま突き進む」「同じ tool が失敗してるのに同じ args で呼び直す」といったループに陥ることを防ぐ。
## 何を解決するか
問題のあるパターン:
```
失敗 → リトライ → 失敗 → リトライ → 失敗 → リトライ → ...
```
Brainstorm を挟むと:
```
失敗 → Brainstorm({ context: 失敗内容, approaches: [A, B, C], chosen: B }) → B を試す
```
## 必須フィールド
| フィールド | 説明 |
|---|---|
| `task` | 今解こうとしているサブ問題を **1 文** で。例: `"input/data.xlsx の中身を要約したい"` |
| `approaches` | 検討する解法の配列。**2 個以上**必要 (1 個だと比較にならない) |
| `chosen` | 採用する approach の `name``approaches[].name` のどれかと完全一致させる |
| `rationale` | 採用理由を 1-2 文で |
## 任意フィールド
| フィールド | 説明 |
|---|---|
| `context` | これまで試した手段・失敗内容など。行き詰まり時のリセット用途で記入する |
## approaches[] の各要素
| フィールド | 必須 | 説明 |
|---|---|---|
| `name` | ✓ | 短い名前 (例: `"ReadExcel 直接"`, `"CSV エクスポート経由"`) |
| `description` | ✓ | 1-2 文で具体的な手順 |
| `reliability` | - | `high` / `medium` / `low`。副作用無し・後戻り可能なら high |
| `speed` | - | `fast` / `medium` / `slow` |
| `prerequisites` | - | 前提条件・必要なもの |
| `risks` | - | 想定される失敗パターン |
## 使うべき場面
1. **複雑な依頼の着手前** — レポート生成・複数ファイル処理・多段階の調査など
2. **同じ tool が 2 回以上失敗した時** — エラー内容を `context` に書いて、別アプローチを 2-3 個並べる
3. **存在しないファイルを掴んだ時**`output/foo.xlsx` が無いと分かった時点で「Glob で実在確認 / ユーザーに ASK / 別パスを試す」を比較
4. **方針が複数あって迷った時** — どっちでも動きそうな選択肢がある時に、確実性で選び直す
## 使わなくて良い場面
- 短い質問への即答
- 自明な単一 tool で済む依頼 (例: 「current time を教えて」)
- 1-2 ステップで完結する操作
## 使い方の例
### 例1: ファイル読み込みのアプローチ比較
```js
Brainstorm({
task: "input/data.xlsx の中身を要約したい",
approaches: [
{
name: "ReadExcel 直接",
description: "ReadExcel({ path: 'input/data.xlsx' }) で全シート読む",
reliability: "high",
speed: "fast",
},
{
name: "シート分割→個別 Read",
description: "SplitExcelSheets で .md に分割してから Read で 1 シートずつ",
reliability: "high",
speed: "medium",
prerequisites: "出力ディレクトリの書き込み許可",
},
{
name: "ヘッダーだけ先に確認",
description: "ReadExcel に range: 'A1:Z3' を渡して構造把握 → 範囲拡大",
reliability: "high",
speed: "fast",
risks: "範囲を間違えるとデータを取り逃がす",
},
],
chosen: "ReadExcel 直接",
rationale: "ファイルサイズが小さければ全件読みが最速で確実"
})
```
### 例2: エラー連発時のリセット
```js
Brainstorm({
task: "output/レポート.xlsx を読みたい",
context: "ReadExcel が JSZip エラー、ReadPdf も extension mismatch、Bash cat も file not found。\n直前のターンで Write が成功したという認識だが実際は失敗していた可能性",
approaches: [
{
name: "Glob で実在確認",
description: "Glob({ pattern: 'output/*' }) で実際に存在するファイル一覧を取る",
reliability: "high",
speed: "fast",
},
{
name: "Write をやり直す",
description: "前回の Write 失敗が原因なら、対象ファイルを改めて生成する",
reliability: "medium",
speed: "medium",
risks: "既存ファイルを上書きしてしまう可能性",
},
{
name: "ユーザーに ASK",
description: "complete({ status: 'needs_user_input', missing_info: 'ファイルパスを確認させて' })",
reliability: "high",
speed: "slow",
},
],
chosen: "Glob で実在確認",
rationale: "副作用なしで現状把握できる。実在しないなら次の手も決まる"
})
```
## 出力の形
Brainstorm は以下のような Markdown を返す:
```
# Brainstorm: <task>
## 背景 / これまでの試行
<context があれば>
## 検討した N 個のアプローチ
**A 案**
<description>
[確実性: high / 速度: fast]
✓ **B 案** (採用)
<description>
[確実性: medium / 速度: medium]
**C 案**
...
## 採用: B 案
理由: <rationale>
続けて、採用したアプローチで実装に進んでください。
```
このアウトプットが activity log / tool 履歴に残るので、後から「どのアプローチを比較したか」「なぜそれを選んだか」を追跡できる。
## 注意
- **Brainstorm は思考の checkpoint であって、独立した解答ではない**。Brainstorm 後は採用したアプローチで実際の tool を呼ぶ
- **2 個以上の approaches が必須**。1 個だと「比較」にならない
- `chosen``approaches[].name` と完全一致必要 (大小・空白も含めて)
- 短い質問・自明なタスクで Brainstorm を呼ぶ必要は無い (オーバーヘッドになる)
+48
View File
@@ -0,0 +1,48 @@
# Browser Sessions
Save a logged-in browser session per site so scheduled tasks can scrape authenticated
pages without you being present.
## How to add a session
1. Open Settings → ツール設定 → Browser Sessions.
2. Click **Add site session**.
3. Fill in:
- **Label**: human-readable name (e.g., "My Twitter").
- **Start URL**: the page that proves you're logged in (e.g., `https://twitter.com/home`).
- **Logged-in selector** (optional): a CSS selector that only exists when logged in.
- **Login URL pattern** (optional): a glob that matches the site's login page (e.g., `https://twitter.com/i/flow/login**`).
4. Click **Open login window** — a browser appears inside the dialog.
5. Log in normally. Solve any CAPTCHA / 2FA.
6. Click **Save**. The session is captured, encrypted, and stored.
## How to use a session in a task
When creating a local or scheduled task, pick the saved session from the
**Browser session** dropdown. The agent's `BrowseWeb` calls inside that task
will run with your saved cookies / localStorage.
## Expiry
If the session expires (cookie rotation, password change, etc.) the next task
will fail with `AUTH_SESSION_EXPIRED`, the session will be marked **Expired**
in the settings list, and a comment will be posted on the task notifying you.
Click **Re-login** in the Browser Sessions list to capture a fresh state.
## Security
- Sessions are encrypted with a personal key derived per user. Other users
cannot read them. Admins can revoke and delete sessions, but cannot decrypt them.
- Sessions are not shared with org / public visibility — they are always bound to
the task owner.
- Audit logs record every save / use / decrypt with timestamp, actor, and result.
## Limitations (v1)
- Sessions are read-only snapshots — cookie mutations during a task run are NOT
written back. Sites that rotate refresh tokens on every request may need
re-login periodically.
- IndexedDB and sessionStorage are not captured by `Playwright.context.storageState`,
so sites that depend heavily on them may not work.
- One profile, one site. Cross-domain SSO sessions need every involved origin
visited during the initial login.
+287
View File
@@ -0,0 +1,287 @@
# BrowseWeb 詳細ガイド
ヘッドレスブラウザで Web ページを操作するツール。同一ジョブ内ではブラウザコンテキスト(Cookie・ログイン状態)が永続化される。
## 2 つのモード
### 1. 基本モード — URL を開いてテキスト取得
```js
BrowseWeb({ url: "https://example.com" })
```
ローカルで生成した HTML をブラウザで確認したい場合は、workspace ルートからの **相対パス** をそのまま渡す(推奨)。
```js
BrowseWeb({ url: "output/viewer.html" })
```
例:
- `output/viewer.html` を開く → `BrowseWeb({ url: "output/viewer.html" })`
- `input/sample.html` を開く → `BrowseWeb({ url: "input/sample.html" })`
内部的には実行中ジョブの workspace 絶対パスと結合され `file://` URL に変換される。`../` で workspace 外に出るパスは拒否される。`file:///` で始まる絶対 URL を直接渡すことも可能だが、workspace 外を指すものは拒否される。
オプション:
- `waitFor`: 待機する CSS セレクタ(省略時は load イベント完了まで待機)
- `extractSelector`: 特定要素のテキストだけ抽出する CSS セレクタ
- `screenshot`: スクリーンショットを保存するファイル名(例: `"page.png"``output/page.png`
- `timeout`: タイムアウト(ms、デフォルト 60000)
### 2. アクションモード — 連続操作
```js
BrowseWeb({
actions: [
{ type: "goto", url: "https://example.com/login" },
{ type: "fill", ref: "e3", value: "[email protected]" },
{ type: "click", ref: "e5" },
{ type: "getText" }
]
})
```
利用可能な `type`:
- `goto``url` で指定したページに遷移
- `click``selector` または `ref` で要素をクリック
- `fill``selector` または `ref` の input/textarea に `value` を入力
- `screenshot``value` で指定したファイル名で保存(省略時 `screenshot.png`
- `getText` — 全ページのスナップショット(ref 注釈付き)または `selector` 内のテキストを取得
- `wait``ms` ミリ秒待機(最大 30000
- `dumpHtml``ref` または `selector`(省略時 body)の outerHTML を取得(脱出口、後述)
## 長文ページの取得(preview + ファイル保存)
`getText` (selector 有無問わず) およびスナップショットの戻り値が **5000 文字を超える** 場合、フルテキストはワークスペースの `logs/browse/{ISO-timestamp}-{hash}.txt` に保存され、戻り値は **先頭 5000 文字 + 続きの取得方法案内** になる:
```
(先頭 5000 文字)
... (truncated; full 38214 chars saved to logs/browse/2026-05-07T09-30-12-a1b2c3d4.txt — Read({file_path:"logs/browse/2026-05-07T09-30-12-a1b2c3d4.txt", offset, limit}) で続きを取得可能)
```
続きを読みたい場合は `Read` ツールで `offset` / `limit` を指定:
```js
Read({ file_path: "logs/browse/2026-05-07T09-30-12-a1b2c3d4.txt", offset: 200, limit: 200 })
```
5000 文字以下のページなら従来通り全文が直接返り、ファイルは作成されない。
## ref 注釈の仕組み(重要)
`BrowseWeb({ url })``getText` の出力には、操作可能な要素が以下のような注釈付きで埋め込まれる:
```
ようこそ
{e1 link "ホーム" href="/"} {e2 link "製品" href="/products"}
ログインしてください
{e3 textbox name="email" placeholder="メールアドレス"}
{e4 textbox name="password"}
{e5 button "ログイン"}
```
- `e1`, `e2`, ... の ID(ref)は出現順に自動採番される
- 各 ref は内部的に Playwright で解釈可能なセレクタ(`data-testid` / `id` / `[name]` / `aria-label` / nth-of-type CSS chain の優先順)にマッピングされている
- click/fill アクションで `ref: "e5"` のように指定するだけで操作できる
- **CSS セレクタを自分で組み立てる必要がない**
### 検出される要素の範囲
ref が振られるのは以下の要素:
- 標準 HTML タグ: `<a>` / `<button>` / `<input>` / `<select>` / `<textarea>` / `<label>` / `<summary>` / `<details>` / `<option>`
- ARIA role: `button` / `link` / `menuitem` / `menuitemcheckbox` / `menuitemradio` / `tab` / `option` / `checkbox` / `radio` / `switch` / `combobox` / `listbox` / `slider` / `spinbutton` / `textbox` / `searchbox` / `treeitem`
- `[onclick]` / `[tabindex>=0]` / `[contenteditable=true]` 属性
- JavaScript で `addEventListener('click'|'mousedown'|'pointerdown', ...)` 経由で listener が後付けされた要素(jQuery / vanilla JS / Vue / Svelte の compile 後コードで多用される)
- open shadow DOM 内部の上記要素
- iframe 内の上記要素(同一オリジン / cross-origin 共に対応。Stripe Elements / OAuth / reCAPTCHA など)
検出されないもの: closed shadow DOM、`<canvas>` / WebGL の描画内容、React の `onClick={...}`(ただし React コンポーネントは大抵 `<button>``role="button"` を使うので別経路で拾える)。
### iframe 内の要素
iframe を含むページの `getText` の出力は、メインフレームのテキストの後ろに **フレームごとのセクション** が並ぶ形式になる。メインフレーム本文中には iframe の位置に `[[IFRAME ...]]` プレースホルダーが残るので、フレームの出現順や種別が把握できる:
```
これは決済画面です
[[IFRAME name=card title=Card details src=https://js.stripe.com/v3/elements]]
[ボタン] {e3 button "支払う"}
--- iframe f1 url="https://js.stripe.com/v3/elements/..." name="card" ---
{f1.e1 textbox "Card number"}
{f1.e2 textbox "MM / YY"}
{f1.e3 textbox "CVC"}
--- end iframe f1 ---
```
iframe 内の要素を click / fill / dumpHtml したいときは、frame ID prefix 付きの ref を指定するだけ:
```js
BrowseWeb({
actions: [
{ type: "fill", ref: "f1.e1", value: "4242 4242 4242 4242" },
{ type: "fill", ref: "f1.e2", value: "12 / 30" },
{ type: "fill", ref: "f1.e3", value: "123" },
{ type: "click", ref: "e3" } // メインフレームの「支払う」ボタン
]
})
```
frame ID (`f1`, `f2`, …) は `getText` 取得時の出現順に採番される。同じページに同じ iframe が複数ある場合は src/name で見分けてセクションヘッダーで識別する。
cross-origin iframe (Stripe / OAuth / reCAPTCHA など) でも Playwright が内部で透過的に DOM を取得するので、同じ感覚で操作できる。ただし iframe の中身が完全に読み込まれる前に snapshot を取ると `[empty]``[cannot inspect: ...]` が出ることがあるので、その場合は `wait` を挟んで再取得する。
### 状態属性
ref 注釈の末尾には ARIA 状態が列挙される。エージェントは「いまトグルが開いてるか」「チェック済みか」「無効化されてるか」を判断できる:
```
{e3 tab "設定" selected}
{e7 button "保存" disabled}
{e2 combobox "国" expanded haspopup}
{e9 checkbox "規約に同意" checked}
{e5 button "メニュー" pressed}
```
利用される状態: `expanded` / `collapsed` / `pressed` / `selected` / `checked` / `mixed` / `disabled` / `required` / `haspopup`
### ref はいつリセットされる?
- ページ遷移(`goto` または click でナビゲーションが発生)したとき
- 同一ジョブ内でも、ナビゲーション後は **getText を呼んで新しいスナップショットを取得する**
- 同一ジョブが終わるとブラウザコンテキストごと破棄される
## ワークフロー例
### 例1: ログインしてダッシュボードのデータを取得
```js
// Step 1: ログインページを開いて要素を確認
BrowseWeb({ url: "https://app.example.com/login" })
// → 出力に {e3: input[email]}, {e4: input[password]}, {e5: button "ログイン"} が含まれる
// Step 2: フォーム入力 → 送信 → 遷移後の状態を取得
BrowseWeb({
actions: [
{ type: "fill", ref: "e3", value: "[email protected]" },
{ type: "fill", ref: "e4", value: "p@ssword" },
{ type: "click", ref: "e5" },
{ type: "getText" } // ← ダッシュボードの新 ref を取得
]
})
// Step 3: ダッシュボードでさらにナビゲート(Cookie が維持されているため再ログイン不要)
BrowseWeb({ url: "https://app.example.com/dashboard/orders" })
```
### 例2: 複数ページを順に巡回
```js
// 検索結果ページを開く
BrowseWeb({ url: "https://example.com/search?q=foo" })
// → {e1: link "結果1" href="/item/1"}, {e2: link "結果2" href="/item/2"} ...
// 各リンクの href を確認したら、url 直接指定で各ページへ
BrowseWeb({ url: "https://example.com/item/1" })
BrowseWeb({ url: "https://example.com/item/2" })
```
### 例3: 動的ページの読み込み待ち
```js
BrowseWeb({
url: "https://app.example.com/spa",
waitFor: ".content-loaded" // この CSS セレクタが現れるまで待つ
})
```
## ユーザーに手動操作を委譲する(noVNC 経由のハンドオフ)
BrowseWeb で詰まったとき、エージェントは `InteractiveBrowse` を呼んでブラウザの操作権をユーザーに渡せる。
### 使うべき場面
1. **ログイン / 2FA / SSO 同意画面** — パスワードや TOTP / プッシュ通知を agent に持たせず、ユーザーに直接入力してもらう
2. **CAPTCHA / bot 検証** — reCAPTCHA、画像選択、Cloudflare チャレンジ等
3. **BrowseWeb の click が空振りし続ける**`dumpHtml` でも構造が複雑すぎて selector が組めない、closed shadow DOM、ドラッグ&ドロップが必須等
4. **canvas / WebGL ベースの UI** — 地図ペインや図形エディタなど DOM では addressable でない領域
5. **画面状態を目視確認したい** — agent が想定通りの画面にいるか不安なとき
### フロー
```js
// Step 1: ユーザーに引き継ぐ宣言
InteractiveBrowse({
url: "https://example.com/login",
reason: "ログインが必要です。ID / パスワードを入力して、画面右下の release ボタンを押してください。"
})
// → ジョブが waiting_human に遷移し、UI に noVNC リンクが表示される
// → ユーザーがブラウザ画面で操作 → release を押すとジョブが再開
// → 戻り値に sessionId が含まれる
```
ジョブ再開後、agent は **同じ sessionId で `BrowseWithSession`** を呼んで続きを引き継ぐ:
```js
// Step 2: ユーザーが完了させた状態 (ログイン済み等) で続行
BrowseWithSession({
sessionId: "abc-123", // InteractiveBrowse の戻り値の sessionId
url: "https://example.com/dashboard",
action: "getText" // または click / fill / screenshot
})
```
### `reason` の書き方
ユーザーに何をしてほしいかは `reason` フィールドで明確に伝えること。UI に表示される。良い例:
- 「ログインしてください。完了したら release を押してください」
- 「reCAPTCHA を解いてください。完了したら release を押してください」
- 「カートに入れたい商品を選んでください。完了したら release を押してください」
### 制約
- `InteractiveBrowse`**ローカルタスク経由のジョブ** でのみ使える(`taskId` が必要)。Gitea Issue 直接実行や taskId が立たない subtask root では使えない
- noVNC が orchestrator にインストール / 設定されていない環境ではエラー(Xvfb / x11vnc / websockify が必要、`config.yaml``browser.captcha_solve: novnc` 設定)
- ユーザーが release を押さない限りジョブは進まない。長時間放置すると `browser.auth_timeout`(デフォルト 10 分)で timeout
### 既存の Browser Sessions 機能との違い
| 機能 | 用途 |
|---|---|
| **Browser Sessions** (Settings UI から保存) | スケジュール実行や定期タスクなど **agent しか動いていない時間帯** に、過去にログイン済みの cookie / storageState を再利用 |
| **InteractiveBrowse** | **ジョブ実行中、その場で** ユーザーがブラウザを操作してログインや人間判断を行う |
定期タスクで毎回 InteractiveBrowse を呼ぶのは非効率なので、定常運用のサイトは Browser Sessions として登録するのが正解。「初回ログイン or セッション切れ時だけ InteractiveBrowse」のような使い分けが望ましい。
## トラブルシューティング
- **「ref "e5" not found in current snapshot」と出る**: ページ遷移後で ref がリセットされている。`getText` で新しいスナップショットを取得する
- **テキストが取れない / 空に近い**: ページが SPA で JavaScript で描画されている。`waitFor` で描画完了を待つ
- **ボタンを押せない / click しても何も起きない**:
1. 要素が visible でない可能性。先に getText で本当に存在するか確認
2. ref 注釈に `disabled` が出ていないか確認
3. `dumpHtml({ ref: "..." })` で要素の生 HTML を見て、独自 selector を組む
4. それでもダメなら `InteractiveBrowse` でユーザーに引き継ぐ
- **`<div>` に click 反応する独自 UI が ref に出ない**: addEventListener フックで多くは検出されるが、React の `onClick={...}` (root delegation) や `el.onclick = fn` 直接代入は捕捉できない。`dumpHtml` で構造を見て selector を直接組むか、`InteractiveBrowse` で渡す
- **ログインが維持されない**: 別ジョブから呼んでいる可能性。同一ジョブ内なら維持される。定常運用は Browser Sessions に保存する
## ファイルダウンロード
リンククリック等でブラウザがファイルダウンロードを開始すると、自動的に workspace の `output/` 配下に保存される。戻り値の末尾に以下の形式で通知される:
```
[download] saved output/report.csv (12345 bytes)
```
- ファイル名は server-suggested 名から path traversal 対策と禁則文字置換を経て決定される
- 衝突時は `foo-1.csv`, `foo-2.csv` 形式で番号付与される
- 失敗時は `[download] FAILED <name>: <reason>` と出る
- ダウンロードされたファイルは続く `Read`, `ReadPdf`, `ReadExcel`, `Bash` 等の tool で参照できる
- 履歴は `logs/downloads.jsonl` に追記される (DownloadFile と同じファイル、`source: 'BrowseWeb'` フィールドで区別)
ダウンロードを認証付きで行いたい場合は、Browser Sessions 機能で対象サイトのログインセッションを保存し、タスクで bind した状態で BrowseWeb を呼ぶこと。
## SSRF 保護
ローカル/プライベート IP127.x.x.x, 10.x.x.x, 172.16-31.x.x, 192.168.x.x, ::1, fc00::/7 等)へのアクセスはデフォルトでブロックされる。社内ホストへアクセスする必要がある場合は、Settings UI の「SSRF Allowed Hosts」に追加する。
+42
View File
@@ -0,0 +1,42 @@
# チェックリスト系ツール(CreateChecklist / CheckItem / GetChecklist
複数アイテム(ファイル、ページ、URL等)を順次処理するときの進捗管理に使う。
**「1件処理→即CheckItem」のループを厳守すること。**
## 基本ワークフロー
```
1. CreateChecklist({ name: "ocr-pages", items: ["page-001.png", "page-002.png", ...] })
2. for each item:
a. アイテムを1件処理する(OCR、ダウンロード、加工など)
b. CheckItem({ name: "ocr-pages", item: "page-001.png", status: "done" }) を即呼ぶ
c. 次のアイテムに進む
3. GetChecklist({ name: "ocr-pages" }) で漏れがないか確認
```
## ステータス
- `done` — 正常完了
- `failed` — 処理失敗(後で再試行・スキップ判断)
- `skipped` — 意図的にスキップ(理由を summary に書く)
## 厳禁パターン
**複数アイテムをまとめて処理してから後でまとめて CheckItem を呼ぶ**
- 途中でクラッシュ・中断したときに進捗が失われる
- アイテム順がブレる
- レビュー時に作業順序が追えない
**1件処理 → 即 CheckItem → 次のアイテム** を1件ずつ繰り返す
## ファイルの保存場所
`logs/checklists/{name}.json` に保存される。再開時に GetChecklist で前回の状態を取り出せる。
## いつ使うべきか
- 同種のアイテムを 3 件以上順次処理するとき
- 処理が長時間にわたり、中断・再開がありえるとき
- 後で何が処理済み/失敗かを振り返る必要があるとき
逆に、1〜2件しかない・1ステップで終わる処理には不要。
+50
View File
@@ -0,0 +1,50 @@
# DownloadFile
URL からファイルをダウンロードしてワークスペースに保存する。
## 基本
```js
DownloadFile({
url: "https://example.com/chart.png",
filename: "images/sales-chart.png",
section: "output"
})
```
## パラメータ
- `url`: ダウンロード元 URL
- `filename`: 保存先パス(section 配下からの相対パス)
- `section`: `"input"``"output"` (成果物に使う場合は `"output"`
## ファイル命名規約
### 画像(成果物に埋め込む場合)
- パス: `images/{わかりやすい名前}.png` (または .jpg / .webp / .gif
- section: `"output"`
- 命名は内容を表すスラッグ(kebab-case 推奨): `sales-q3-chart.png`, `product-screenshot-home.png`
### ダウンロード履歴
`logs/downloads.jsonl` に各ダウンロードのメタ情報(URL, 保存先, サイズ)が記録される。
## 成果物への画像埋め込み
ダウンロードした画像は Markdown レポートから相対パスで埋め込める:
```markdown
![Q3 売上推移](./images/sales-q3-chart.png)
```
レポート(output/report.md)と画像(output/images/*.png)が同じ section 配下にあれば `./images/` で参照可能。
## SSRF 保護
WebFetch と同じく、ローカル/プライベート IP はデフォルトブロック。Settings UI の「SSRF Allowed Hosts」で例外設定可能。
## 注意
- 大きすぎるファイル(数百MB以上)はタイムアウトしやすい
- バイナリファイル(PDF, 動画等)も保存可能だが、画像以外の用途では IngestDocument / TranscribeAudio 等の専用ツールも検討
+51
View File
@@ -0,0 +1,51 @@
# YouTube ツール(GetYouTubeTranscript / SearchYouTube
YouTube の動画情報・字幕を取得する。
## SearchYouTube — 動画検索
```js
SearchYouTube({
query: "ローカル LLM ベンチマーク",
limit: 10
})
// → 動画タイトル・URL・チャンネル名・再生回数・投稿日 のリスト
```
検索結果は概要のみ。動画の中身を知りたい場合は GetYouTubeTranscript で字幕を取る。
## GetYouTubeTranscript — 字幕取得
```js
GetYouTubeTranscript({
url: "https://www.youtube.com/watch?v=xxx"
// または video_id: "xxx"
})
// → タイムスタンプ付きの字幕テキスト
```
## 重要: 動画の内容を扱う場合は必ず字幕を取得
調査タスクで「YouTube 動画について書く」場合:
- ❌ 動画タイトルやサムネイルから推測して書く
- ❌ 内部知識や他サイトの情報で代用する
-**必ず GetYouTubeTranscript で実際の字幕を取得して引用する**
字幕がない動画(自動字幕も無い)は「字幕なし」と明記し、内容を書かないか別の情報源を探す。
## 出力フォーマット
タイムスタンプ付き:
```
[00:00] こんにちは、今日は...
[00:15] 最初に説明するのは...
[01:30] 結論として...
```
引用時はタイムスタンプも示すと信頼性が上がる。
## トラブルシューティング
- **字幕が取れない**: 字幕無し動画。動画 ID を確認、または別動画を探す
- **権限エラー**: 一部地域制限がある動画。代替を探す
- **URL 形式**: 短縮 URLyoutu.be/xxx)も対応
+77
View File
@@ -0,0 +1,77 @@
# Piece 編集ツール(ListPieces / GetPiece / CreatePiece / UpdatePiece
Piece(ワークフロー定義 YAML)を CRUD するツール群。`piece-builder` piece で使用。
## ListPieces — 一覧
```js
ListPieces()
// → 全 Piece の名前・説明・トリガーキーワード一覧
```
新規 Piece を作る前に **必ず実行して既存 Piece を確認**する。重複・類似機能の Piece を作らないように。
## GetPiece — 取得
```js
GetPiece({ name: "research" })
// → 指定 Piece の完全な YAML 定義
```
- 既存 Piece の構造を参考にする
- UpdatePiece の前に現状を確認
## CreatePiece — 作成
```js
CreatePiece({
name: "my-new-piece", // 英小文字・数字・ハイフンのみ
yaml_content: `
name: my-new-piece
description: ...
initial_movement: gather
movements:
- name: gather
persona: ...
instruction: ...
allowed_tools: [Read, Write, ...]
rules:
- condition: ...
next: ...
`
})
```
必須要素:
- `name`
- `description`
- `initial_movement`
- `movements`(少なくとも 1 つ)
- 各 movement の `rules`(遷移条件、`next` を明示)
## UpdatePiece — 更新
```js
UpdatePiece({
name: "research",
yaml_content: "..." // 全体を置き換える
})
```
**差分更新ではなく全体置換**。GetPiece で取得 → 編集 → UpdatePiece の流れ。
## 制限
- `general``chat` は削除不可(更新は可能)
- YAML パースエラーは即座にエラー
- movement 構造の検証あり(`rules[].next` が存在する movement か等)
## 設計指針
新しい Piece を作る前に:
1. ListPieces で既存を確認
2. 既存 Piece に少しの調整で対応できないか検討
3. 必要なら GetPiece で類似 Piece を参考にする
4. その上で CreatePiece
「Piece が増えすぎる」のはメンテナンス負債。**Piece は追加よりも既存 Piece の改良が原則**。
+53
View File
@@ -0,0 +1,53 @@
# ListUserAssets
Lists user-authored scripts, templates, and recordings stored in the caller's user folder (`data/users/{userId}/`).
## Input
```ts
{
kind?: 'scripts' | 'templates' | 'recordings' | 'all' // default: 'all'
}
```
## Output
Human-readable text listing each asset category.
**Scripts** (`.js` files in `scripts/`): each entry shows the filename, description, and declared params.
**Templates** and **Recordings**: filename, byte size, and last-modified timestamp.
Example output:
```
User folder for user-abc:
Scripts (2):
- foo.js: "Log into example.com" — params: [date:string]
- bar.js: "Check dashboard" — params: []
Templates (0):
(none)
Recordings (1):
- rec-2026-05-09T12-34-56.json (1234 bytes, 2026-05-09T12:34:56.000Z)
```
## Owner gate
The tool always reads the folder of the **authenticated caller** (`ctx.userId`). There is no way to list another user's assets.
If the caller is unauthenticated (`ctx.userId` missing), the tool returns `isError: true` with a message about authentication.
## Workflow example
```
ListUserAssets({ kind: 'scripts' })
→ see which scripts are available and what params they need
RunUserScript({ name: 'foo', params: { date: '2026-05-01' } })
→ execute the script
```
## Notes
- Scripts without a frontmatter block are listed with an empty description and no params.
- A parse error in one script is reported inline for that entry; other scripts are still listed.
- The tool is a META_TOOL — no need to add it to `allowed_tools` in piece YAML.
+97
View File
@@ -0,0 +1,97 @@
# SearchNotes / ReadNote / WriteNote
ユーザーの共有 knowledge notes`data/users/{userId}/notes/{folder}/{file}.md`)を扱う 3 ツール。
ファイルは YAML frontmatter + Markdown 本文で構成され、DB の `note_index` (FTS5 対応) に mirror される。
## SearchNotes
購読中(`note_subscriptions``mode=search` または `mode=inject``enabled=1` の行がある)の note を FTS5 全文検索する。
### 引数
| フィールド | 型 | 必須 | 説明 |
|---|---|---|---|
| `query` | string | 必須 | 検索クエリ。ツール内部でフレーズ検索として扱われる |
| `folder` | string | 省略可 | 特定フォルダーのみに絞り込む |
| `limit` | integer | 省略可 | 最大取得件数(デフォルト 10、上限 100) |
### 戻り値
マッチした note のリスト(`owner_id/folder/file_name: title`)。
続いて `ReadNote` で全文を取得できる。
### FTS5 クエリの注意
クエリはフレーズ検索として自動エスケープされる。`kubernetes pod``"kubernetes pod"` に変換。
AND / OR 演算子を使いたい場合は複数回呼び出して結果を手動合算すること。
---
## ReadNote
特定の note の全文(frontmatter + 本文)を取得。可視性チェックあり。
### 引数
| フィールド | 型 | 必須 | 説明 |
|---|---|---|---|
| `owner_id` | string | 必須 | note の所有者 user ID |
| `folder` | string | 必須 | フォルダー名 |
| `file_name` | string | 必須 | ファイル名(例: `foo.md` |
### 可視性ルール
- 自分の noteowner_id が自分): 常に読める
- `visibility: public` の note: 全ユーザーが読める
- `visibility: org` の note: `scope_org_id` が自分の所属 org に含まれる場合のみ読める
- `visibility: private` の他人の note: 読めない(isError: true
---
## WriteNote
自分の `notes/{folder}/{file}.md` を作成または更新する。
### 引数
| フィールド | 型 | 必須 | 説明 |
|---|---|---|---|
| `folder` | string | 必須 | フォルダー名(`[a-zA-Z0-9._-]` のみ) |
| `file_name` | string | 必須 | ファイル名(`.md` で終わる) |
| `content` | string | 必須 | YAML frontmatter を含む完全な Markdown 内容 |
### Frontmatter フィールド
```yaml
---
title: "Note のタイトル(省略可)"
visibility: public # private | org | public
scope_org_id: "org-id" # visibility=org の場合に必須(自分の所属 org の ID)
mode_hint: search # search | inject(省略可)
tags:
- kubernetes
- security
---
```
- `visibility` は必須。省略すると `private` として扱われる
- `visibility: org` の場合、`scope_org_id` は自分の所属 org の ID でなければならない
- `mode_hint: inject` にすると、購読者の system prompt に自動注入される
- フォルダーとファイル名は固定の 2 階層(`notes/<folder>/<file>.md`
### 書き込み後の動作
- DB の `note_index` + FTS5 テーブルを即座に更新
- 同フォルダーへの self subscription がなければ自動作成(`mode=search``enabled=1`
- エラー時は `isError: true` を返す(バリデーションエラーや権限エラー)
---
## 使い分け
| 場面 | ツール |
|---|---|
| 「CVE 対象?」「〜の設定は?」など知識検索 | `SearchNotes("CVE")` |
| 検索結果の 1 件の詳細を読む | `ReadNote` |
| スケジュールタスクで取得した情報をチームと共有 | `WriteNote``visibility: org` |
| 自分用のメモ・ログを残す | `WriteNote``visibility: private` |
+150
View File
@@ -0,0 +1,150 @@
# Office ファイル系ツール(ReadPdf / ReadExcel / ReadDocx / ReadPPTX / PdfToImages / SplitExcelSheets / SplitDocxSections
ローカル workspace の Office 文書・PDF を読み込むツール群。
## 読み取り系
### ReadPdf
```js
// 全文抽出 (page 区切りで markdown)
ReadPdf({ path: "input/manual.pdf" })
// ページ範囲を絞る
ReadPdf({ path: "input/spec.pdf", page_range: "10-25" })
// grep -n 風検索 (query mode)
ReadPdf({ path: "input/manual.pdf", query: "保証期間" })
ReadPdf({ path: "input/manual.pdf", query: "保証期間", context_lines: 5 })
ReadPdf({ path: "input/spec.pdf", query: "第\\d+条", query_mode: "regex" })
```
スキャン PDF(テキストレイヤなし)の場合は **自動で PdfToImages + Vision OCR にフォールバック**。手動で `PdfToImages → ReadImage` を呼ぶ必要は通常ない。query を併用すると OCR 結果にも同じフィルタが適用される。
#### 引数
| 引数 | 型 | デフォルト | 説明 |
|---|---|---|---|
| `path` | string | (required) | workspace 相対の PDF パス |
| `page_range` | `"3"` / `"1-5"` | (全ページ) | 抽出ページ範囲 |
| `max_pages` | number | (無制限) | 抽出ページ数の上限 |
| `max_chars` | number | 50,000 | 返却文字数の上限 |
| `query` | string | (なし) | 一致行のみ grep -n 風で返す。trim 後 empty なら全文 mode |
| `query_mode` | `substring` / `regex` / `iregex` | `substring` | 検索モード。substring は大小無視 + metachar auto-escape |
| `context_lines` | number (0..20) | 2 | query マッチ前後の context 行数 |
#### query mode の出力例
```
# foo.pdf, query: "保証期間"
### Summary
- Total pages: 50
- Pages with match: 3
- Total matches: 5
### Matches
## Page 7 — 2 matches
6: 商品は購入日より
> 7: 保証期間内に故障した場合、無償修理対象…
8: ただし、消耗品は対象外です。
> 22: 延長保証期間は最大 3 年まで…
```
`>` が一致行、空白マーカーが context 行。隣接マッチは context window が overlap したら 1 cluster にまとめられ context 重複を回避。
#### gotcha
- **MAX_MATCHES_PER_PAGE = 50**: 1 ページで 50 件超のマッチは打ち切り、page header に `(capped)` を付与。`"the"` のような broad pattern を絞るシグナル
- **`query_mode: "regex"` の invalid pattern** → `isError: true` で friendly メッセージ。substring mode は metachar をエスケープするので絶対に regex error にならない
- **OCR fallback path** でも query 適用。スキャン PDF + キーワード検索 OK
- 上記 PdfToImages の手動呼び出しは **DPI / 出力ファイル個別管理** がしたい時のみ。普通の "PDF を vision で読みたい" は ReadPdf 単発で済む
### ReadExcel
```js
ReadExcel({ file_path: "input/data.xlsx" })
// → 全シートのセル内容をテキスト形式で返す
```
巨大な Excel は token を食うので、シートを絞る場合は SplitExcelSheets を使う。
### ReadDocx
```js
ReadDocx({ file_path: "input/spec.docx" })
// → 本文 + 表を抽出
```
### ReadPPTX
```js
ReadPPTX({ file_path: "input/slides.pptx" })
// → 各スライドのテキスト・表・スピーカーノートを返す
```
## 変換・分割系
### PdfToImages
```js
PdfToImages({ file_path: "input/scan.pdf", dpi: 200 })
// → output/ReadPdf/page-001.png, page-002.png, ... に保存
```
スキャン PDF を ReadImage で扱うときの前段。
### SplitExcelSheets
```js
SplitExcelSheets({ file_path: "input/big.xlsx" })
// → output/excel/{sheetname}.md と manifest.json を生成
```
シート単位で別ファイルにすることで、Read で必要なものだけ取り出せる。
### SplitDocxSections
```js
SplitDocxSections({ file_path: "input/long-spec.docx" })
// → 見出しベースで分割した .md と manifest.json を生成
```
長い Word 文書を構造化して Read で取り回しやすくする。
## ファイル選択の指針
| ファイル | 第一選択 | フォールバック |
|---|---|---|
| PDF(テキストあり) | ReadPdf | - |
| PDF(スキャン画像) | ReadPdf(自動 OCR フォールバック) | 手動 PdfToImages → ReadImage |
| PDF 内をキーワード検索 | ReadPdf + `query` | Read → GrepReadPdf で出力保存後) |
| Excel(小〜中) | ReadExcel | - |
| Excel(巨大) | SplitExcelSheets → Read | - |
| Word(短〜中) | ReadDocx | - |
| Word(長文・章構成) | SplitDocxSections → Read | - |
| PowerPoint | ReadPPTX | - |
## 注意
- すべて workspace 内のローカルファイル(`input/` または `output/`)が対象
- URL 指定不可 → DownloadFile で先にローカル保存
- 全ツール read-only(書き込まない)
## ファイルサイズ上限
Read 系ツールは悪意あるファイル / リソース枯渇対策として入力サイズを検証する。デフォルトは以下の通りで、`config.yaml``tools` セクション、または Settings UI の「Tools → Office ファイルサイズ上限」から変更可能。
| ツール | デフォルト | config キー |
|---|---|---|
| ReadExcel | 10 MB | `tools.office_excel_max_size_mb` |
| ReadDocx | 10 MB | `tools.office_docx_max_size_mb` |
| ReadPdf | 10 MB | `tools.office_pdf_max_size_mb` |
| ReadPPTX | 50 MB | `tools.office_pptx_max_size_mb` |
| ReadPPTX 展開後 | 200 MB | `tools.office_pptx_max_uncompressed_mb` |
PPTX の「展開後」は ZIP bomb 検知用で、ZIP 内の全エントリの非圧縮合計サイズに対する閾値。超過時は `ZIP bomb detected: ...` エラーとなる。
マクロ付きファイル(`.xlsm` / `.docm` / `.pptm` / `.xlsb`)は警告付きで読み込まれる(実行はされない)。
+38
View File
@@ -0,0 +1,38 @@
# ReadImage
画像ファイルを LLM に直接渡して内容を認識・説明させる。VLMVision Language Model)対応 worker でのみ使用可能。
## 基本
```js
ReadImage({ file_path: "input/screenshot.png" })
// → 画像内の文字・図表・物体について自然言語の説明が返る
```
## 動作要件
- 呼び出し時の worker が `vlm: true` で設定されている必要がある
- 設定がない場合、このツールは `allowed_tools` に書いてあっても利用不可(function definition から自動除外される)
## 用途
- スクリーンショットの内容説明
- 図・グラフ・チャートの読み取り
- ページレイアウトの確認
- 写真の被写体・状況把握
## 文字読み取りについて
- ある程度の OCR は可能だが、**精度が要求される文字情報**には別途 OCR ツールの使用を検討
- 数字・記号・固有名詞を厳密に扱う場合は VLM のハルシネーションに注意
- パラメータシート、表、コード等は誤読リスクが高い
## 入力ファイル
- `input/` または `output/` 配下のローカル画像ファイル
- URL 指定は不可(DownloadFile で先にローカル保存する)
- 対応形式: png, jpg, jpeg, gif, webp, bmp
## SearchKnowledge との連携
SearchKnowledge が返したページ画像(`input/knowledge/{ns}/page_xxx.png`)も ReadImage で内容確認できる。
+51
View File
@@ -0,0 +1,51 @@
# ReadUserMemory
Loads a specific memory entry from the caller's personal user folder.
## Overview
Memory entries are stored in `data/users/{userId}/memory/{name}.md`. Each file has YAML frontmatter (`name`, `description`, `type`) and a plain Markdown body.
The MEMORY.md index (injected into system prompt automatically) gives a one-line summary per entry. Use `ReadUserMemory` when you need the full body of a specific fact.
---
## Usage
```json
{
"name": "preferred-language"
}
```
**Response example:**
```
# Memory: preferred-language
**Type**: user
**Description**: User prefers Japanese output
Always respond in Japanese unless the user explicitly asks for another language.
```
---
## Parameters
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `name` | string | Yes | Entry identifier to load (no `.md` extension) |
---
## Error cases
- Returns an error if `name` does not exist in the memory folder.
- Returns an error if no user is authenticated.
---
## Related tools
- `UpdateUserMemory` — create, update, or delete memory entries.
- `ReadToolDoc({ name: "UpdateUserMemory" })` — full authoring guide.
+85
View File
@@ -0,0 +1,85 @@
# ReadUserTemplate
Loads a template file from the caller's `templates/` subdir (`data/users/{userId}/templates/`).
## Overview
Templates are plain Markdown files the user stores in their `templates/` folder via the UI.
Unlike `memory/` entries, frontmatter is **optional** — a template can be pure Markdown prose
with no YAML header at all.
Use this tool when the user says "use the weekly-report template" or "follow the api-error-email
boilerplate" — call `ReadUserTemplate`, read the shape, then adapt it to the task at hand.
---
## Usage
```json
{ "name": "weekly-report" }
```
Or with the `.md` extension (both forms work):
```json
{ "name": "weekly-report.md" }
```
**Response example (no frontmatter):**
```
# Template: weekly-report
## Body
# Weekly Report
Fill in this week's highlights here.
```
**Response example (with frontmatter):**
```
# Template: api-error-email
## Frontmatter
title: "API Error Email"
audience: "external"
## Body
Dear customer,
We apologize for the inconvenience.
```
---
## Parameters
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `name` | string | Yes | Template filename, with or without `.md` extension (max 128 chars) |
---
## Error cases
- Returns an error if the template does not exist in `templates/`.
- Returns an error if no user is authenticated.
- Returns an error if `name` contains path traversal characters or slashes.
---
## Use cases
- Weekly / monthly report boilerplate: `ReadUserTemplate({ name: "weekly-report" })` → fill in stats.
- Email canned responses: `ReadUserTemplate({ name: "api-error-email" })` → personalise and send.
- Code boilerplate: `ReadUserTemplate({ name: "react-component" })` → generate a new component.
---
## Related tools
- `ListUserAssets({ kind: "templates" })` — see what templates exist before reading one.
- `ReadUserMemory` — for structured facts/preferences (requires frontmatter).
- `RunUserScript` — for executable Node/Playwright scripts stored in `scripts/` / `browser-macros/`.
- This tool is a META_TOOL — no need to add it to `allowed_tools` in piece YAML.
+108
View File
@@ -0,0 +1,108 @@
# RenderUserTemplate
Renders a template from `templates/` by substituting `{{var}}` placeholders with caller-supplied params.
## Overview
Companion to `ReadUserTemplate`. Instead of returning the raw body, this tool:
1. Parses the template's frontmatter `params` spec (same shape as scripts / browser-macros).
2. Validates caller-supplied `params` against the spec (type-check + defaults applied).
3. Replaces every `{{name}}` placeholder in the body with the resolved value.
4. Returns the rendered body (no `# Template:` header, no frontmatter — just the substituted text).
Placeholders **not declared** in `frontmatter.params` are left literal — so prose like
`use {{column}} as the key` survives unchanged when no `column` param exists.
---
## Usage
Template file `templates/weekly-report.md`:
```markdown
---
description: Weekly status report
params:
- name: date
type: string
- name: summary
type: string
default: "(no summary)"
---
# Status — {{date}}
{{summary}}
```
Call:
```json
{ "name": "weekly-report", "params": { "date": "2026-05-11", "summary": "shipped 3 PRs" } }
```
Response:
```
# Status — 2026-05-11
shipped 3 PRs
```
---
## Parameters
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `name` | string | Yes | Template filename, with or without `.md` extension (max 128 chars) |
| `params` | object | No | Key-value params matching the template's `frontmatter.params` spec |
---
## Frontmatter `params` schema
Identical to scripts / browser-macros:
```yaml
params:
- name: identifier # must match /^[a-zA-Z_$][a-zA-Z0-9_$]*$/
type: string | number | boolean
description: optional
default: optional # if omitted, the param is required
```
Param values are coerced to string via `String(value)` when substituted.
---
## Error cases
- `name` missing or invalid characters → error.
- Template file does not exist in `templates/` → error.
- Frontmatter is malformed (bad YAML, bad `params` shape) → error.
- A required param (no default) is missing from the call → error: `param X: required but not provided`.
- A param has the wrong type → error: `param X: expected number, got string`.
Templates without any frontmatter render as pure pass-through — any `{{var}}` stays literal.
---
## Use cases
- Weekly / monthly reports: `RenderUserTemplate({ name: "weekly-report", params: { date, summary } })`.
- Email canned responses with variable substitution.
- Code boilerplate with a few configurable parts (component name, props).
For more dynamic logic (conditionals, loops), open `ReadUserTemplate` and do the substitution
inline in your output — there is no Handlebars / Liquid / etc. engine, by design.
---
## Related tools
- `ReadUserTemplate` — returns the raw body + frontmatter. Use this when you want to inspect
the template structure or do substitution yourself.
- `ListUserAssets({ kind: "templates" })` — list available templates.
- `RunUserScript` — for executable scripts (not just text substitution).
- This tool is a META_TOOL — no need to add it to `allowed_tools` in piece YAML.
+130
View File
@@ -0,0 +1,130 @@
# RunUserScript
Executes a user-authored script from the caller's user folder.
Two kinds of scripts are supported:
| kind | directory | runtime | signature | use case |
|------|-----------|---------|-----------|----------|
| `'script'` (default) | `scripts/` | plain Node.js — no Chromium | `main({ params })` | Data processing, API calls, computation, file conversion |
| `'browser-macro'` | `browser-macros/` | Playwright — Chromium | `main({ context, params })` | Web automation with a live browser session |
## Input
```ts
{
name: string, // filename — '.js' is appended if absent
params?: Record<string, unknown>, // runtime values matching the script's param spec
kind?: 'script' | 'browser-macro' // default: 'script'
}
```
## Param validation
Params are validated against the `params:` block in the script's YAML frontmatter:
- Extra params not listed in the spec → error containing "param"
- Wrong type for a declared param → error containing "param"
- Missing required param (no default) → error containing "param"
- Params with defaults are filled in automatically when not supplied
On any param error the tool returns `isError: true` immediately — no subprocess is spawned.
## Session integration (browser-macro only)
If a `browser-macro` script's frontmatter declares `session_profile_id: <N>`, the tool:
1. Loads the profile from the DB (owner-gated — must belong to `ctx.userId`).
2. Decrypts the user's envelope-encrypted DEK using the master key.
3. Decrypts the AES-GCM storageState blob using the DEK.
4. Passes the decrypted Playwright `storageState` object to the child process.
If any step fails the tool returns `isError: true` with a descriptive message.
For `kind: 'script'` (plain runtime), `session_profile_id` in the frontmatter is ignored — no session is loaded.
## Self-healing recorder (browser-macro only)
When a `browser-macro` fails at runtime, the tool automatically enables the BrowseWeb recorder for the current task (if not already enabled). On task completion, `recording-flush` stages a candidate patch as `browser-macros/{name}.next.js` for diff review.
Plain scripts (`kind: 'script'`) do **not** auto-enable the recorder.
## Output format
On success:
```
<result stringified>
[script logs]
<console.log lines from the child process>
```
The result is JSON-stringified if it is an object or array; `String(result)` otherwise. The `[script logs]` section is only appended when the script produced logs.
On failure (plain):
```
RunUserScript "{name}" failed: <error message>
```
On failure (browser-macro):
```
RunUserScript "{name}" failed: <error message>
The recorder is now enabled for this task; subsequent BrowseWeb actions will be captured.
On task complete, a candidate patch will be saved as browser-macros/{name}.next.js for review.
```
## Error cases
| Situation | `isError` | message contains |
|-----------|-----------|-----------------|
| No authenticated user | true | "authenticated" |
| Script file not found | true | "not found" |
| Frontmatter parse error | true | "frontmatter" |
| Param type / missing error | true | "param" |
| Session profile not found / not owned | true | "not found or does not belong" |
| Profile not active | true | "not active" |
| DEK / blob decryption failure | true | "decrypt" |
| Script timeout (60 s) | true | "timeout" |
| Script exits non-zero | true | "exited code" |
| Plain script denied child_process (e.g. spawning python) | true | "exited code" + "use the Bash tool" |
## Notes
- The tool is a META_TOOL — it is available in every movement without listing it in `allowed_tools`.
- Use `kind: 'browser-macro'` for any script that needs a browser (`context`).
- Use `ListUserAssets` first to discover available scripts and their param specs.
- On browser-macro failure, use `BrowseWeb` as a manual fallback.
## Running Python (don't — use Bash)
`RunUserScript` runs **Node only**. There is no Python interpreter path. A
common footgun is to write a Node script that does
`child_process.spawn('python3', ...)` and run it here — that **cannot work**:
plain scripts run under Node's `--permission` model, which denies
`child_process` entirely (you get `ERR_ACCESS_DENIED`). Even if it were
allowed, the child's env is scrubbed, so it would not see the orchestrator's
provisioned Python environment.
To run Python, use the **`Bash` tool** instead: `python3 your_script.py`. The
Bash sandbox has the pip packages pre-baked (pypdf, pdfplumber, python-docx,
python-pptx, openpyxl, pandas, numpy, …). That is the supported, working path.
## Security and trust model
`RunUserScript` is **disabled by default**. To enable it, add to `config.yaml`:
```yaml
tools:
user_scripts_enabled: true
```
**Only enable for trusted users.** User scripts run in a restricted child process:
- Env is scrubbed — only `PATH`, `HOME`, `TMPDIR/TMP`, `LANG`, `NODE_ENV`, and `PLAYWRIGHT_BROWSERS_PATH` are forwarded. API keys, database passwords, and other secrets in the orchestrator's environment are not visible to the script.
- CWD is set to the system tmpdir, not the orchestrator workspace.
- Stdout is capped at 1 MB and stderr at 200 KB; exceeding either limit kills the child.
- On timeout, the entire process group (including Playwright's Chromium for browser-macros) is killed.
**The two runtimes have different capability levels:**
- **Plain scripts (`kind: 'script'`)** run under Node's Permissions Model (`--permission`): `--allow-fs-read` is limited to the child-runner dir and tmpdir, `--allow-fs-write` to tmpdir, and `child_process`, worker threads, and native addons are **denied**. A plain script that tries to spawn a subprocess (e.g. python) fails with `ERR_ACCESS_DENIED`. See "Running Python" above.
- **Browser-macros (`kind: 'browser-macro'`)** cannot use `--permission` — Chromium launch, native bindings, and outbound HTTPS all need unrestricted `child_process`/addons/network. They run with full Node.js capability (env-scrubbed only) and rely on container-level isolation. Treat them as trusted code.
+41
View File
@@ -0,0 +1,41 @@
# SearchAmazon
Amazon.co.jp で商品を検索する。商品画像・価格・Keepa の価格推移グラフ・アフィリエイトリンクを含む整形済み Markdown を返す。
## 基本
```js
SearchAmazon({
query: "ノートPC 16GB",
limit: 5
})
```
## 出力フォーマット
```markdown
## 商品名
![商品画像](https://...)
- 価格: ¥xxx,xxx
- 評価: ★4.5 (1234件)
- [Amazon で見る](アフィリエイトリンク)
![価格推移](Keepa グラフ)
```
## 重要: 出力をそのまま埋め込む
返ってきた Markdown は **必ずそのまま最終回答に含める**
- ❌ 画像要素 `![...](...)` を省略する
- ❌ 画像をテキストリンクに置き換える
- ✅ 商品画像・Keepa グラフを含めて、全部そのまま出力に貼る
これは Amazon ガイドラインへの準拠とユーザー UX の両方の理由から。
## 設定
Settings UI の "Tools" セクション:
- **Amazon Affiliate Tag**: 必須(例: `your-tag-22`)。未設定だとアフィリエイトリンクが正しく生成されない
- **Keepa API Key**: 任意。設定すると価格推移データが詳細化(無くてもグラフ画像リンクは出る)
+103
View File
@@ -0,0 +1,103 @@
# SearchKnowledge / ListNamespaces / ListDocuments / IngestDocument / IngestStatus
DKSDocument Knowledge Service)に取り込んだ社内文書をベクトル検索で参照するツール群。
## 利用可能性チェック
```js
ListNamespaces() // 利用可能な namespace 一覧を返す
```
DKS が設定されていなければ "Knowledge service not configured" を返す。
namespace が空なら何も検索できない。
## 文書一覧の確認
```js
ListDocuments({ namespace: "product-a-support" })
```
その namespace に取り込み済みの文書を表示する。
## 検索
```js
SearchKnowledge({ namespace: "product-a-support", query: "返品ポリシーは何日以内?" })
```
レスポンスには:
1. **sections** — マッチしたツリーノード(タイトル + summary + ページ範囲)
2. **page_image_urls** — 関連ページの画像(PNG
### 自動ダウンロード
検索結果に含まれるページ画像は **自動的にワークスペース** `input/knowledge/{namespace}/page_001.png` などに保存される。
LLM はそのローカルパスを `ReadImage` でそのまま閲覧できる。
```js
// SearchKnowledge の出力例:
// ## 返品ポリシー (manual.pdf, pages: 3, 4)
// 購入後30日以内であれば...
//
// ### ページ画像(ReadImage で閲覧可能)
// - input/knowledge/product-a-support/page_003.png
// - input/knowledge/product-a-support/page_004.png
ReadImage({ file_path: "input/knowledge/product-a-support/page_003.png" })
```
### 生 JSON の保存
DKS の生レスポンス JSON は `logs/raw/searchknowledge-{timestamp}.json` に保存される。doc_id 等の詳細フィールドが必要なときはそちらを Read する。
## 文書の取り込み
```js
// 1. 取り込み開始(非同期)
IngestDocument({ namespace: "product-a-support", file_path: "input/manual.pdf" })
// → "取込を開始しました (job: xxx, 45ページ検出)。完了確認は IngestStatus で可能です。"
// 2. 進捗確認
IngestStatus({ namespace: "product-a-support", job_id: "xxx" })
// → "ジョブ xxx: 処理中: VLM 12/45ページ, ツリー構築: 未完了"
// または "完了 (manual.pdf)" / "失敗: ..."
```
DKS は内部で:
1. PDF → ページ画像化
2. VLM でページごとに記述生成
3. ツリー構造(章・節)構築
4. ベクトル化してインデックス登録
を行う。45 ページで数分かかる規模感。
## ワークフロー例
### 質問応答
```
SearchKnowledge → 関連 sections + ページ画像取得
↓ 必要なら ReadImage で図表確認
回答文に sections の要点を引用、根拠ページを示す
```
### 新文書を取り込んで検索
```
IngestDocument → job_id 取得
↓ 待機(数分後 or 別作業)
IngestStatus → completed まで polling
SearchKnowledge で取り込み済みコンテンツを検索
```
## ログ
`logs/knowledge-history.jsonl` に各ツール呼び出し(クエリ・件数・所要時間・エラー)が記録される。
## 注意
- **検索ヒット件数は DKS 側で制御** されるので、件数上限を心配する必要はない
- DKS サーバーがローカル/プライベート IP でも、API キー認証経由なので SSRF 例外不要
- VLM 処理はバックグラウンドで動くので、IngestDocument 後すぐに SearchKnowledge を呼んでもまだヒットしない可能性あり(IngestStatus で完了確認)
+112
View File
@@ -0,0 +1,112 @@
# Microsoft Learn 検索 / キャッシュツール
`learn.microsoft.com` を検索するための 4 つのツール群。オンライン検索とローカルキャッシュ (魚拓) を統合する。
## ツール一覧
| ツール | 用途 |
|--------|------|
| `SearchMicrosoftLearn` | オンライン検索 + ローカルキャッシュヒットを統合して返す |
| `FetchMicrosoftLearn` | ページを取得し Markdown 化してキャッシュに保存 |
| `SearchMicrosoftLearnCache` | キャッシュ済みページのみ FTS5 全文検索 (オフライン) |
| `RefreshMicrosoftLearnCache` | キャッシュ済みページを強制再取得 |
## 標準フロー
1. `SearchMicrosoftLearn({ query: "azure managed identity" })` で候補 URL を一覧取得
2. 興味のある URL を `FetchMicrosoftLearn({ url })` で取得 (初回はオンライン、2 回目以降はキャッシュ)
3. キャッシュに溜まってきたら `SearchMicrosoftLearnCache({ query })` でオフライン検索可能
## キャッシュ仕様
- 場所: `data/ms-learn-cache/pages.sqlite`
- DB: SQLite + FTS5 (external content)、ロケール (`en-us` / `ja-jp` 等) 横断検索
- TTL: なし (永続)。古さが気になったら `RefreshMicrosoftLearnCache` で個別に再取得
- 1 ページ = HTML から `<main>` / `<article>` を抽出して minimal markdown 化したもの
## SearchMicrosoftLearn
### 引数
| 名前 | 型 | 必須 | 説明 |
|------|----|------|------|
| `query` | string | yes | 自然言語キーワード |
| `locale` | string | no | `en-us` (デフォルト)、`ja-jp` 等 |
| `products` | string[] | no | 製品スコープ (例: `["azure"]``["dotnet"]`)。省略時は Learn 全範囲 |
| `top` | integer | no | 取得件数 (デフォルト 10、最大 25) |
### 出力例
```
## Online results (5)
- [Managed identities for Azure resources](https://learn.microsoft.com/en-us/entra/identity/managed-identities-azure-resources/overview) [cached]
Managed identities provide an automatically managed identity in Microsoft Entra ID...
- [Use a managed identity to connect to Azure SQL](https://learn.microsoft.com/en-us/azure/azure-sql/database/authentication-aad-overview)
...
## Cache hits (2)
- [Managed identity types](https://learn.microsoft.com/en-us/entra/identity/managed-identities-azure-resources/overview)
... two types of <mark>managed</mark> identities ...
```
`[cached]` マーカーが付いている結果は `FetchMicrosoftLearn` を呼ばなくても直近のキャッシュから即取り出せます。
### 注意
- locale はデフォルト `en-us`。日本語版は遅延・取りこぼしが多いので、特別な理由がない限り `en-us` を推奨
- `products` 絞り込みは Learn 検索 API の仕様に依存。指定しなくても困らない場面が多い
- オンライン検索が失敗した場合 (rate limit / network) はキャッシュ検索のみで結果を返す
## FetchMicrosoftLearn
### 引数
| 名前 | 型 | 必須 | 説明 |
|------|----|------|------|
| `url` | string | yes | `https://learn.microsoft.com/...` で始まる URL |
### 挙動
- URL 正規化: クエリ文字列とハッシュは削除して比較
- キャッシュヒット時は HTTP リクエストを発生させず、保存済みの Markdown を返す
- ヒットしない場合は HTTP 取得 → HTML から `<main>` 抽出 → minimal markdown 変換 → SQLite 保存
### 出力
冒頭にメタデータ行 (`Cached (age=...)` または `Fetched and cached (...)`) + 本文 markdown。
## SearchMicrosoftLearnCache
オフライン専用。FTS5 のクエリ構文をそのまま使えるが、デフォルトはスペース区切りの AND 検索 (各単語をフレーズ扱い)。
### 引数
| 名前 | 型 | 必須 | 説明 |
|------|----|------|------|
| `query` | string | yes | 検索クエリ |
| `top` | integer | no | 取得件数 (デフォルト 10、最大 25) |
### 出力
ヒットしたページ毎に `[title](url)` + ハイライト付きスニペット (`<mark>` タグ)。
## RefreshMicrosoftLearnCache
キャッシュ済みページの内容が古いと判断したときに使う。HTTP 取得を強制し既存レコードを上書き。
### 引数
| 名前 | 型 | 必須 | 説明 |
|------|----|------|------|
| `url` | string | yes | 再取得する URL |
## 設定
`config.yaml` の追加設定は不要。`data/ms-learn-cache/` ディレクトリは初回呼び出し時に自動作成される。
## 制限事項
- HTML→Markdown 変換は Learn の構造に最適化した最小実装。汎用 HTML には使えない
- Learn 以外のドメインは拒否 (`learn.microsoft.com` のみ)
- ページ内の画像は取得しない (テキスト検索のみ用途)
- API レート制限に当たった場合は `SearchMicrosoftLearn` がエラーを返すが、キャッシュ検索は引き続き使える
+49
View File
@@ -0,0 +1,49 @@
# 地図ツール(SearchPlaces / GetDirections / ReverseGeocode
地名・住所・経路情報を扱う。Google Maps API キーがあればそちら、無ければ Nominatim/OSRM(無料)を使用。
## SearchPlaces — 場所検索
```js
SearchPlaces({
query: "東京駅 ラーメン",
location: "35.6812,139.7671", // 任意: 中心座標
limit: 5
})
// → 名称・住所・座標・評価(API キーがあれば)等
```
## GetDirections — 経路検索
```js
GetDirections({
origin: "東京駅",
destination: "羽田空港",
mode: "driving" // driving / walking / transit / bicycling
})
// → 距離・所要時間・経路ステップ
```
## ReverseGeocode — 座標から住所
```js
ReverseGeocode({
lat: 35.6812,
lng: 139.7671
})
// → 住所文字列
```
## API 設定
Settings UI の "Tools" セクション:
- **Google Maps API Key**: 設定すると Google Places/Directions API を使用(高精度・有料)
- 未設定: Nominatim(住所検索)、OSRM(経路)の無料 API を使用
Google Maps API は精度・情報量が多いが、ビジネス要件・無料枠の制約に注意。
## 用途
- 出張・旅程の経路情報
- 店舗・施設の所在確認
- ジオデータの正規化
+115
View File
@@ -0,0 +1,115 @@
# Slide Tools (pptxgenjs)
PowerPoint で再編集可能な .pptx を生成するツール群。
4 ツール:
- `SetTheme` : テーマ (色・フォント・サイズ) を選ぶ。冒頭で 1 回
- `AddSlide` : スライドを 1 枚追加する
- `BuildPptx` : 蓄積した状態から .pptx を書き出す。最後に 1 回
- `ResetSlides` : 全スライドを破棄する (テーマは維持)
中間状態は `output/.slides.json` に保存される。直接編集しないこと。
## SetTheme
```ts
SetTheme({
preset: "corporate-blue" | "minimal-mono" | "vibrant" | "academic" | "dark" | "warm-paper",
overrides?: {
primary?: string, // "#1A5490" 等
accent?: string,
background?: string,
text?: string,
muted?: string,
heading_font?: string,
body_font?: string,
title_size?: number, // pt
heading_size?: number,
body_size?: number,
}
})
```
preset 一覧:
| preset | 雰囲気 |
|---|---|
| corporate-blue | 営業・社内提案 (青基調) |
| minimal-mono | 既定。シンプルな黒白 |
| vibrant | ポップ、LT 向け (赤×ティール) |
| academic | 学術発表 (落ち着いた青、セリフ) |
| dark | 暗背景・明るいテキスト |
| warm-paper | クリーム背景、温かみ |
## AddSlide
```ts
AddSlide({
layout: "title" | "section" | "bullets" | "two-column" |
"image-right" | "image-left" | "image-full" |
"table" | "chart" | "quote" | "closing" | "custom",
content: { /* layout 依存 */ },
notes?: string
})
```
### layout ごとの content
**title**: `{ title, subtitle?, author?, date? }`
**section**: `{ number?: "01", title }`
**bullets**: `{ title, bullets: string[], footnote? }`
**two-column**: `{ title, left: {heading?, bullets?, text?}, right: {...} }`
**image-right** / **image-left**: `{ title, body: string | string[], image: { path, alt? } }`
**image-full**: `{ image: { path }, caption? }`
**table**: `{ title, headers: string[], rows: string[][], col_widths?: number[] }`
- col_widths は比率 (合計 1.0 で全幅、例 `[0.3, 0.5, 0.2]`)。省略時は均等割
**chart**: `{ title, chart_type: "bar"|"line"|"pie"|"doughnut"|"area"|"scatter",
data: { categories: string[], series: [{name, values: number[]}] } }`
- series[].values.length は categories.length と一致必須
**quote**: `{ quote, attribution? }`
**closing**: `{ message?: "Thank you", contact? }`
**custom**: `{ elements: Array<...> }` (escape hatch、詳細下記)
### custom.elements
座標単位は inch。安全領域は x=0.5, y=0.5, w=12.33, h=6.5。
```ts
{ type: "text", text, x, y, w, h, options?: {font_size, bold, color, align} }
{ type: "image", path, x, y, w, h }
{ type: "shape", shape: "rect"|"roundRect"|"arrow"|"oval"|"line",
x, y, w, h, options?: {fill, line, text} }
{ type: "table", headers, rows, x, y, w, h }
{ type: "chart", chart_type, data, x, y, w, h }
```
### よくある失敗
- 画像パスは workspace 相対 (`input/foo.png` 等)。URL は不可 → 事前に DownloadFile
- chart の series.values.length と categories.length の不一致は AddSlide 時点で reject
- table.col_widths を指定するなら headers の長さと同じ要素数
## BuildPptx
```ts
BuildPptx({ output?: string }) // 既定 "output/slides.pptx"
```
- `output` は workspace 相対、`output/` 配下のみ可
- 戻り値に「スライド数 / ファイルサイズ / テーマ / 警告」が含まれる
- スライドが 0 枚なら error
- `.slides.json` が壊れていれば error + `ResetSlides()` を提案
## ResetSlides
```ts
ResetSlides()
```
- slides[] を空にする
- theme は維持
- 全枚やり直すときのみ使う
## PDF が欲しい場合
このツールは PDF 出力に非対応。生成された .pptx を PowerPoint / Keynote / LibreOffice で開いて Export してもらう。
+59
View File
@@ -0,0 +1,59 @@
# SpawnSubTask
タスクを並列サブタスクに分解して実行する。各サブタスクは独立した worker(ジョブ)で動き、完了後に親タスクが結果を集約する。
## 基本
```js
SpawnSubTask({
title: "ローカル LLM 比較調査",
instruction: "Ollama, vLLM, llama.cpp の最新性能ベンチマークを比較する。各ツールについて: 1) 直近6ヶ月の主要ベンチマーク, 2) ハードウェア要件, 3) 対応モデル一覧 を output/report.md にまとめる。",
piece: "research" // 任意。指定しないと自動分類
})
```
呼び出すと `subtasks/{index}/` にサブタスクのワークスペースが作られ、結果はそこに集約される。
## いつ使うか
### 並列分解が効果的なケース
- 2 つ以上の **独立したテーマ**(互いに参照しない)
- 各テーマが軽くなく、調査・処理に時間がかかる
- 分解後の各タスクが単独でも意味を持つ成果物になる
例:
- 「3 つの製品比較レポート」→ 製品ごとに 3 サブタスク
- 「複数 PDF の OCR 処理」→ ファイルごとに分解
- 「複数 SNS の情報収集」→ プラットフォーム別に分解
### 分解しないほうがよいケース
- 単一テーマで論理的に連続する処理(A→B→C のように依存)
- サブタスクが極端に小さい(オーバーヘッドの方が大きい)
- 全体像を見ながら判断する必要がある作業(対話的タスク等)
## instruction の書き方
- **完結した依頼文**で書く(親タスクの文脈を持たないので、サブタスクは instruction だけで判断する)
- 期待する成果物(出力ファイル名・場所)を明示
- 必要な前提情報があれば文中に展開
❌ 「これと同じ調査を別キーワードでやって」
✅ 「キーワード『A』『B』『C』について、各々のメリット・デメリットを比較する独立した調査を行い、output/A-vs-B.md にまとめる」
## piece の指定
- 省略時: 親と同じ classifier ロジックで自動選択
- 明示する場合: `research`, `general`, `office-process` 等の piece 名を指定
## 結果の参照
サブタスク完了後、親タスクは:
- `subtasks/{index}/output/` 以下にサブタスクの成果物がある
- Read で参照して集約レポートを作成する
## 制限
- ネスト深さは `subtasks.maxDepth`(デフォルト 2)まで
- サブタスクが waiting_human 等で停止すると親もブロックされる
+42
View File
@@ -0,0 +1,42 @@
# SQLite
ワークスペース内の SQLite データベースに対してクエリを実行する。
## 基本
```js
SQLite({
db_path: "input/data.db",
query: "SELECT name, price FROM products WHERE category = 'A' LIMIT 10"
})
```
## edit 制御
- **edit: false の movement**: SELECT のみ許可(読み取り専用)
- **edit: true の movement**: INSERT / UPDATE / DELETE / CREATE / ALTER 等の DDL/DML も許可
## 用途
- 既存の SQLite データベースの内容調査
- データ集計(GROUP BY, JOIN
- スキーマ確認(`SELECT name FROM sqlite_master WHERE type='table'`
- 加工後データの新規 DB への書き込み(edit movement のみ)
## クエリの実行結果
- 行は JSON 配列で返る
- 大量行はトークン消費が大きいので **必ず LIMIT を付ける** か WHERE で絞る
- 1 万行を超えるような結果は LIMIT 100 程度から始めて段階的に確認
## 入力ファイルの場所
- workspace 内のパス(`input/`, `output/`, `data/` 等)
- 絶対パスは禁止
- DB ファイルが存在しないときは(edit movement なら)新規作成される
## トラブルシューティング
- **database is locked**: 他プロセスが DB を開いている。暫く待ってリトライ
- **no such table**: スキーマ確認 → テーブル名スペルチェック
- **disk I/O error**: ディスク容量・パーミッション確認
+148
View File
@@ -0,0 +1,148 @@
# SSH Console Tools (SshConsoleEnsure / SshConsoleSend / SshConsoleSnapshot)
AI と人間が共有する SSH PTY セッションを操作する 3 ツール。1 タスクに 1 PTY セッションが対応し、`cd` / 環境変数 / foreground プロセスは job をまたいで維持される。長時間の対話作業 / TUI (vim, top, less, tmux) / 複数ラウンドの調査向け。
単発コマンドだけなら **`SshExec`** (ssh-ops piece) のほうが軽い。本ツール群は対話的シェル + AI が画面を見続ける用途に最適化されている。
## 典型的な flow (まずこれを真似る)
```js
// 1. どの接続が使えるか発見 (タスク本文に UUID が無いとき)
SshListConnections({})
// → {"connections":[{"id":"abcd1234-...","label":"prod-aao","host":"...","host_key_verified":true}]}
// 2. セッション確保 (冪等。何度呼んでも同じセッションを返す)
SshConsoleEnsure({ connection_id: "abcd1234-..." })
// → {"ok":true,"reused":false,"connection_id":"abcd1234-...","cols":120,"rows":32}
// 3. コマンドを送信。改行で実行される
SshConsoleSend({
connection_id: "abcd1234-...",
input: "uptime\n",
wait_ms: 800, // 出力が落ち着くまで待つ ms (default 500, max 5000)
})
// → {"ok":true,"bytes_sent":7,"screen_after":"... load average: 0.05 ...","new_output_bytes":120}
// 4. screen_after で見切れた場合は scrollback を取得
SshConsoleSnapshot({
connection_id: "abcd1234-...",
kind: "scrollback",
max_bytes: 32768,
})
// → {"kind":"scrollback","byte_count":12345,"truncated":false,"text":"..."}
```
## SshConsoleEnsure
セッションを確保する (無ければ open、有れば再利用)。**冪等**。`SshConsoleSend` を呼ぶ前に必須ではない (auto-ensure される) が、最初に明示的に呼んでおくと「セッション開設に成功した」ことを確認できる。
| Param | Required | Description |
|---|---|---|
| `connection_id` | yes | UUID。piece の `allowed_ssh_connections` に含まれている必要がある。**label / hostname / 思い出した文字列で代用してはいけない** — 必ず `SshListConnections``id` を渡すこと |
| `cols` | no | 初回 open 時のターミナル幅。default `ssh.console.default_cols` (120) |
| `rows` | no | 初回 open 時のターミナル高さ。default `ssh.console.default_rows` (32) |
| `force_replace` | no | bool。default `false`。既存 session が**別の** `connection_id` にある場合の挙動を制御 (下記参照) |
Return:
```json
{"ok": true, "reused": <bool>, "connection_id": "...", "cols": 120, "rows": 32, "host_fingerprint": "SHA256:..."}
```
`reused: true` なら過去ターンから引き継いだ既存セッション (cd 等の state あり)。`false` なら今回新規 open。
### connection_id mismatch の挙動 (重要)
同じ task で**別の** `connection_id` を渡した場合:
- `force_replace: false` (default) → エラー返却。レスポンスに **既存セッションの connection_id が含まれる** ので、それをそのまま使うか、本当に切り替えたければ次の呼び出しで `force_replace: true` を渡す
- `force_replace: true` → 旧セッションは `connection_change` 理由で閉じられ、新セッションが開く (旧 shell の state は失われる)
**典型的なバグパターン**: ジョブをまたいで動作するエージェントが `connection_id` を覚えていなくて、
LLM の hallucination で適当な UUID を生成 → mismatch reject される、というケース。エラーメッセージの中に
正しい `connection_id` が出ているのでそれを使うか、Send/Snapshot で `connection_id` を省略する。
## SshConsoleSend
入力を送る。**printable な shell コマンド (改行なし、制御文字なし、2 文字以上) には server が自動で末尾に `\n` を付加して実行する**。例: `input: "ls -la"` でも `input: "ls -la\n"` でも同じ結果。
auto-append が発火した時は response に `auto_newline_appended: true` が載るので、必要なら呼び出し側で検知できる。
raw のまま送りたい (改行を付けない) ケース:
- sudo の password prompt に応答中 (echo OFF — タイプ + 別 Send で `\n`)
- vim の insert mode で文字を順に打鍵
- less / top / htop 等 TUI で 1 キー操作 (`q`, `j`, `k`, space, etc.)
- これらは制御文字を含むか 1 文字なので auto-append は発火しない。
| Param | Required | Description |
|---|---|---|
| `connection_id` | no | UUID。**省略時はこの task の active session を自動採用 (推奨)**。明示する場合は active session の id と一致する必要があり、不一致なら reject (active id が surface される) |
| `input` | yes | raw 文字列。LF / CRLF / control 文字 (`\x03` Ctrl-C, `\x04` Ctrl-D, `\x1b` Esc, `\t` Tab) を透過 |
| `wait_ms` | no | 送信後の screen_after 取得までの待ち時間 (default 500ms, max 5000ms) |
Return:
```json
{
"ok": true,
"bytes_sent": 7,
"screen_after": "user@your-host:~$ uptime\n 12:34 ...",
"new_output_bytes": 120
}
```
### 入力フィルタ
各 line は connection 側の `deny_patterns` / `allow_patterns` (および組み込み deny-list) と照合される。1 行でも NG にひっかかると入力**全体**が reject される (部分実行はしない)。エラー例: `SshConsoleSend: line 2 rejected by builtin_deny (rm\s+-rf).`
### TUI 操作のコツ
- vim 起動: `SshConsoleSend({input: "vim test.txt\n", wait_ms: 1000})` → 待ってから `SshConsoleSnapshot` で画面確認
- vim 抜ける: `SshConsoleSend({input: "\x1b:q!\n"})` (`\x1b` は Esc)
- top/htop 抜ける: `SshConsoleSend({input: "q"})`
- 走行中プロセス中断: `SshConsoleSend({input: "\x03"})` (Ctrl-C)
- パス完成 (Tab): `SshConsoleSend({input: "ls /var/lo\t"})` (Tab だけ送って screen で候補確認)
### よくある間違い
- `wait_ms` が短すぎて screen_after に出力が間に合わない → 再度 `SshConsoleSnapshot` で取り直す
- printable input は server が自動で `\n` を付加するので改行忘れは基本問題ない。raw 入力したい場合 (TUI 操作等) は制御文字を含めること
- 大量出力で screen_after が切れる → `SshConsoleSnapshot({kind: "scrollback"})` で取得
## SshConsoleSnapshot
| Param | Required | Description |
|---|---|---|
| `connection_id` | no | UUID。**省略時はこの task の active session を自動採用 (推奨)**。明示する場合は active session の id と一致する必要があり、不一致なら reject |
| `kind` | no | `screen` (デフォルト) — 現在の表示画面 / `scrollback` — それ以前を含む過去の出力 |
| `max_bytes` | no | scrollback の上限 (default 8192, max 65536)。tail から `max_bytes` バイト返す |
Return (kind=screen):
```json
{"kind":"screen","cols":120,"rows":32,"text":"...","cursor":{"x":0,"y":15}}
```
Return (kind=scrollback):
```json
{"kind":"scrollback","byte_count":123456,"truncated":true,"text":"..."}
```
text は ANSI escape strip 済み (色 / cursor 移動シーケンスを除去)。raw が必要な場合は audit log を参照。
## エラー時のリカバリ
| エラー | 対応 |
|---|---|
| `host_key_*` | UI (Settings → User Folder → SSH Connections) で TOFU 検証してから再試行 |
| `command_rejected (builtin_deny / custom_deny)` | deny-list で reject。admin に許可パターン追加を相談 (ローカルで回避してはいけない) |
| `idle_timeout` / `duration_cap` | 古いセッションが閉じた。`SshConsoleEnsure` を再度呼んで開け直す |
| `connection_change` | 同 task で `force_replace: true` 付き Ensure が呼ばれた → 古いセッションが閉じた |
| `this task already has an active session on connection X (...)` | エラー文の中の **X が正しい id**。X を `connection_id` に使うか、Send/Snapshot で省略する。本当に切り替えたければ `force_replace: true` |
| `this task has an active session on connection X, not Y` | Send/Snapshot 側で id mismatch。X を使う or 省略する |
| `maintenance` | admin の対応を待つ。`complete({status: 'needs_user_input', missing_info: 'SSH maintenance window'})` で停止 |
| `not initialised` | `ssh.enabled` または `ssh.console.enabled` が false / `MCP_ENCRYPTION_KEY` 未設定。admin に依頼 |
| `does not declare allowed_ssh_connections` | piece YAML の movement に `allowed_ssh_connections: ['*']` 等を追加する必要あり |
## deny-list の限界
deny-list は **first line of defense** であって信頼境界ではない。`bash -c "..."``$VAR` 経由の動的展開は通る。多層防御 (audit + abuse lock + admin kill) で運用する。
機密値 (token / password / SSH key) は input 文字列に直接書かない。サーバー側の env / config / secrets manager から読ませる。
+271
View File
@@ -0,0 +1,271 @@
# SSH ツール詳細ガイド (SshExec / SshUpload / SshDownload / SshListConnections)
リモートサーバーで shell コマンドを実行したり、ワークスペースとリモートファイルシステムの間でファイルを転送するためのツール群。同じ前提・同じエラーモデル・同じ監査経路を共有するので、本ドキュメントに統合してある。運用者向けの設計・設定詳細は **[docs/ssh.md](../ssh.md)** を参照。
## 4 ツールの位置づけ
| ツール | 用途 | 入力 |
|--------|------|------|
| `SshListConnections` | この movement で使える接続の UUID + label + host 一覧を取得 | (引数なし) |
| `SshExec` | リモートで shell 単一行を実行 | `connection_id`, `command`, (任意) `timeout_ms` |
| `SshUpload` | workspace → リモートへファイル転送 (SFTP) | `connection_id`, `local_path`, `remote_path`, (任意) `timeout_ms` |
| `SshDownload` | リモート → workspace へファイル取得 (SFTP) | `connection_id`, `remote_path`, `local_path`, (任意) `timeout_ms` |
転送系の 3 ツールは、接続側の `remote_path_prefix` 配下の絶対パスのみを受け付け、`workspace` 外への local パスは reject される。`connection_id` は piece 側の `allowed_ssh_connections` に明示されている UUID のみ使用可能。
タスク本文に `connection_id` が記されていないときは、まず `SshListConnections` を呼んで該当の host / label の UUID を取得すること。
## 共通: 4 つの前提条件
ツール呼び出し前に以下が全て揃っている必要がある。どれか一つでも欠けると即エラー応答 (audit には `denied` で記録される)。
1. **`ssh.enabled: true`** が `config.yaml` で設定されている
2. **`MCP_ENCRYPTION_KEY`** 環境変数が 64 hex 文字 (= 32 バイト) で設定されている
3. **対象 connection の host key が verify 済**。新規作成直後は `host_key_verified_at IS NULL` 状態で SshExec/Upload/Download は `host_key_not_verified` で失敗する。SSH Connections パネル (Settings → User Folder → SSH Connections) で `/test` を実行 → 鍵 fingerprint を確認 → "Verify" ボタンで verify する
4. **piece の現在 movement で `allowed_ssh_connections` に当該 UUID が明示**されている (またはワイルドカード `*`)。空配列 `[]` は「SSH 使用するが許可なし」の deny 宣言とみなされ全 UUID が reject される
不足時のエラーメッセージ例: `SshExec error: piece "ops" movement "exec" does not list connection abcd1234... in allowed_ssh_connections.`
## SshListConnections
```js
SshListConnections({})
```
引数なし。現在の movement の `allowed_ssh_connections` + ジョブ owner の access grant を満たす接続だけを返す (admin 無効化 / piece 除外 / grant 無しは filter out)。
戻り値 (JSON 文字列):
```json
{
"connections": [
{
"id": "abcd1234-5678-90ab-cdef-1234567890ab",
"label": "prod-aao",
"host": "10.0.0.10",
"port": 22,
"username": "deploy",
"host_key_verified": true,
"host_key_pending": false
}
]
}
```
- `host_key_verified: false` の接続は SshExec/Upload/Download/Console* で使う前に UI から TOFU 検証する必要がある (`host_key_pending: true` ならまだ未検証で取り消し可能な状態)
- `connections` が空配列の場合は admin に接続登録 / grant 発行を依頼する
- 通常は **最初に呼ぶ** ことで AI が "どの host か" を発見できる。1 ターンで複数回呼ぶ必要はない (結果は安定)
- 監査 action: `ssh.list_connections` (detail に `count``wildcard` フラグ)
## SshExec
```js
SshExec({
connection_id: "abcd1234-...",
command: "ls -la /srv/agent",
timeout_ms: 30000 // 任意
})
```
戻り値 (JSON 文字列):
```json
{
"exit_code": 0,
"stdout": "total 12\ndrwxr-xr-x 3 agent agent ...",
"stderr": "",
"truncated_stdout": false,
"truncated_stderr": false
}
```
- `exit_code` は remote プロセスの終了コード。0 でない場合も isError=false で返り、LLM が判断する
- 標準出力は `config.yaml``ssh.max_output_bytes` (デフォルト 32 KiB) で truncate される。`truncated_stdout: true` の場合はコマンドを `head` / `tail` / `grep` で絞り込んで再試行する
- 同等以上のサイズが見込まれる出力は SshDownload でファイル取得した上で `Read` で扱うこと
### command フィルタリング (2 段)
- **組み込み deny-list**: `rm -rf /`, `mkfs`, `dd`, `:(){:|:&};:` 系のシステム破壊 / fork bomb 系を unconditional で reject
- **接続側カスタム正規表現**: 接続作成時に `deny_patterns` / `allow_patterns` (改行区切りの正規表現リスト) を設定可能。デフォルトは空 (= 制限なし)。`allow_patterns` を設定した場合、deny を通過した後さらに全 allow パターンに合致しないと reject
エラー: `SshExec error: command rejected by built-in deny-list (matched pattern: rm\s+-rf).` / `command rejected by connection deny-list.`
### timeout
`timeout_ms` 未指定時は `config.yaml``ssh.call_timeout_seconds` (デフォルト 30 秒)。これは TCP 接続 + handshake + 認証 + コマンド実行を全て含む wall-clock。タイムアウトすると `exec_timeout` エラーで終了し、audit row は `failed` outcome + `detail.error = 'exec_timeout'` で記録される (途中で生成された stdout は破棄される)。
## SshUpload
```js
SshUpload({
connection_id: "abcd1234-...",
local_path: "output/report.csv", // workspace 相対
remote_path: "/srv/agent/2026-05/report.csv" // 絶対パス、prefix 配下
})
```
戻り値:
```json
{
"ok": true,
"bytes": 4096,
"remote": "/srv/agent/2026-05/report.csv"
}
```
- `local_path`: workspace ルートからの相対パス。シンボリックリンク経由で workspace 外を指すパスは O_NOFOLLOW + parent lstat で reject される
- `remote_path`: 接続の `remote_path_prefix` (例: `/srv/agent`) 配下の絶対パスのみ。`/srv/agent/../etc/passwd` のような traversal は POSIX 正規化後に prefix 外と判定されて reject
- アップロード先のディレクトリは事前に存在している必要がある (`mkdir -p` 相当を行いたければ先に `SshExec({command: "mkdir -p /srv/agent/2026-05"})` を呼ぶ)
- 既存ファイルへの上書きは現状 reject せず upload する。冪等性が必要な場合は呼び出し側で確認すること
### サイズ上限
`config.yaml``ssh.max_upload_size_mb` (デフォルト 100 MB) を超える local ファイルは `remote_too_large` 相当で reject。
## SshDownload
```js
SshDownload({
connection_id: "abcd1234-...",
remote_path: "/srv/agent/2026-05/log.txt",
local_path: "input/log.txt" // workspace 相対
})
```
戻り値:
```json
{
"ok": true,
"bytes": 8192,
"local": "input/log.txt"
}
```
- `local_path`**既に存在するファイルへの上書きは reject** される (`local_target_exists` エラー)。新規パスを指定するか、既存ファイルを別ツールで削除してから再試行
- 親ディレクトリは呼び出し側で作成済にしておくこと。`Write` 相当の mkdir-p は行わない (e.g. `output/foo/bar.txt` を指定するなら、事前に `Bash({command: "mkdir -p output/foo"})` 等で作成)
- `remote_path` の prefix 配下チェック、サイズ上限 (`ssh.max_download_size_mb`)、SSRF チェックは Upload と同じ
## Host key TOFU フロー (LLM 側で完結しない)
接続を新規作成した直後は host key が観測されていない (`host_key_b64 IS NULL`)。最初の `/test` 呼び出し (または最初の Exec/Upload/Download) で鍵を観測すると、`host_key_first_observe` エラーが返り、`host_key_b64` / `host_key_fingerprint` / `host_key_pending_token` が DB に書き込まれる。
```
Host key first-observe on connection <id> (fingerprint SHA256:...).
Verify via UI (SshConnections panel) before retrying. Pending token: <uuid>
```
LLM ではここで止め、ユーザーに **UI で fingerprint を確認 → Verify** を依頼する。Verify を完了するまで全 SSH ツールは `host_key_not_verified` で失敗する。
サーバー再構築や鍵 rotation で fingerprint が変わると `host_key_mismatch` が返る。これは **既存鍵の上書きにあたるので reason 付きで UI から明示的に replace** する必要がある (`/replace-host-key` エンドポイント)。LLM は自分で replace してはいけない。
```
WARN: Host key MISMATCH on connection <id> (now SHA256:...).
Likely possibilities: server rebuild, key rotation, or MITM.
Verify carefully via UI and supply a reason. Pending token: <uuid>
```
## 共通エラーコード一覧
`isError: true` で返るエラーメッセージは以下のいずれか。LLM は基本的に **retry せず**、メッセージに従って人に判断を仰ぐか、別の手段に切り替えること。
| code | 意味 | 対応 |
|------|------|------|
| `host_key_first_observe` | 初回鍵観測 | UI で verify するようユーザーに依頼 |
| `host_key_mismatch` | 鍵 fingerprint が変化 | UI で replace するようユーザーに依頼 (MITM 可能性) |
| `host_key_not_verified` | 鍵記録済だが未 verify | 同上、UI で verify |
| `host_key_alg_not_allowed` | サーバーが禁止アルゴリズムを提示 | 接続不能、運用者に報告 |
| `auth_failed` | 秘密鍵が認証拒否された | 接続設定 (key/username) を確認 |
| `connect_timeout` | ハンドシェイク前に timeout | network 経路 / SSRF policy 確認 |
| `exec_timeout` | コマンド実行が timeout | `timeout_ms` を増やす、コマンドを軽量化 |
| `transfer_timeout` | SFTP 転送が timeout | ファイルサイズ確認、回線確認 |
| `output_too_large` | stdout が `max_output_bytes` 超過 | フィルタリング、SshDownload に切替 |
| `remote_too_large` | ファイルが `max_(up\|down)load_size_mb` 超過 | サイズ確認、設定変更 |
| `local_target_exists` | download 先が既存 | 別パス選択 |
| `forbidden_address` | SSRF policy で reject | private 接続なら `allow_private_addresses` 設定 |
| `invalid_host` / `dns_failed` / `connect_failed` | 接続 / DNS 失敗 | host 設定、ネットワーク確認 |
`abuse_locked` / `disabled_by_admin` 等の運用上の reject は `SshExec: access denied (...) for connection X.` 形式のエラー (isError=true) で返る。
## abuse counter による自動 lock
連続失敗を 3 つのスコープで集計する:
- **user**: 同一ユーザー × 任意接続
- **host:user**: 同一 (host, username) ペア
- **host (global)**: 同一 host (global connection のみ対象)
`config.yaml``ssh.abuse_window_minutes` (10) 以内に `ssh.abuse_failure_threshold` (5) 回失敗すると、当該スコープが `ssh.abuse_lock_minutes` (30) ロック。ロック解除は時間経過待ち、または **admin が UI から force-unlock** (理由 + 8 字以上必須、レート制限 10 回/時)。
成功すると user scope のカウンターだけクリアされる (他のスコープは時間経過で window から外れる)。
## 監査ログ
3 ツールはすべて以下のライフサイクルを踏む:
```
audit.begin (outcome=pending) → commit (DB)
remote 呼び出し
audit.complete (outcome=success | failed | denied | aborted)
```
途中でプロセスがクラッシュした場合、`pending` 行は次回起動時の recovery sweep で `aborted` に倒される (forensics 用「実行されたが結果不明」)。
action 名:
- `ssh.exec` (SshExec)
- `ssh.upload` (SshUpload)
- `ssh.download` (SshDownload)
- `ssh.connection.host_key.first_observe` / `mismatch` (TOFU 発火時)
`ssh.exec``detail` には command そのものではなく **SHA-256 truncated hex (16 char)**`command_hash` として記録される。command 全文は記録されない (PII / secrets 漏洩防止)。retry 検知やパターン分析は hash 比較で行う。
監査ログの参照経路:
- ユーザー本人の接続: SshConnections パネルの "Audit" タブ
- admin (全接続): Settings → SSH → Audit Log (フィルタ: action / outcome / connection / time range)
## Workflow Recipes
### A. リモートで生成したレポートを workspace に取り込む
```js
// 1. リモートでレポート生成
SshExec({ connection_id: CONN, command: "/srv/agent/build-report.sh > /tmp/report-$(date +%Y%m%d).csv" })
// 2. 生成パスを確認
const ls = SshExec({ connection_id: CONN, command: "ls -1 /tmp/report-*.csv | tail -1" })
const remote = JSON.parse(ls.output).stdout.trim()
// 3. workspace に取り込み
SshDownload({ connection_id: CONN, remote_path: remote, local_path: `input/${remote.split('/').pop()}` })
```
### B. workspace で加工した設定ファイルを反映
```js
// 1. ワークスペースで設定を生成
Write({ file_path: "output/nginx.conf", content: "..." })
// 2. リモートにアップロード
SshUpload({ connection_id: CONN, local_path: "output/nginx.conf", remote_path: "/srv/agent/nginx.conf" })
// 3. validate + reload
SshExec({ connection_id: CONN, command: "nginx -t -c /srv/agent/nginx.conf && systemctl reload nginx" })
```
### C. 大量出力を直接受け取らずファイル経由で扱う
```js
// 直接 SshExec すると max_output_bytes で truncate される
// → 一度ファイルに書いてから Download する
SshExec({ connection_id: CONN, command: "journalctl -u app --since '1 hour ago' > /tmp/app.log" })
SshDownload({ connection_id: CONN, remote_path: "/tmp/app.log", local_path: "input/app.log" })
Read({ file_path: "input/app.log", offset: 0, limit: 200 }) // 必要に応じて
```
## 関連ツール
- `Read` / `Write` / `Edit`: workspace 内のファイルを扱う前後で組み合わせる
- `Bash`: workspace 内でのローカル処理 (mkdir, jq 加工等)
## 参考
- [docs/ssh.md](../ssh.md) — 設定・UI フロー・運用ガイド・セキュリティモデル
+57
View File
@@ -0,0 +1,57 @@
# TranscribeAudio
音声ファイルを文字起こしする。話者分離(ダイアライゼーション)対応。外部の音声認識サーバーに送信して結果を受け取る。
## 基本
```js
TranscribeAudio({
file_path: "input/meeting.mp3",
language: "ja", // 省略時 config の speech_language または "ja"
diarize: true, // 話者分離(デフォルト true)
prompt: "固有名詞: 山田太郎、Project Apollo" // 文字起こしヒント
})
```
## サーバー設定(必須)
Settings UI の "Tools" セクションで:
- **Speech Server URL**: 例 `http://localhost:8000/v1`
- **Speech Timeout**: 秒(デフォルト 300
- **Speech Language**: デフォルト言語コード(`ja`, `en` 等)
サーバー URL が未設定なら "Speech server not configured" で失敗する。
## 入力ファイル
- 対応形式: `mp3`, `wav`
- workspace 内のローカルファイルパス(input/ 配下推奨)
- 大きいファイルはタイムアウトに注意(Speech Timeout を増やす)
## 出力フォーマット
### diarize: false (またはセグメント情報なし)
プレーンテキスト全文:
```
こんにちは。今日の会議を始めます。最初の議題は...
```
### diarize: true
話者ごとに区切られたテキスト:
```
[Speaker_A] こんにちは。今日の会議を始めます。
[Speaker_B] よろしくお願いします。最初の議題なんですが...
[Speaker_A] そうですね、まずは...
```
話者ラベルは `Speaker_A`, `Speaker_B`, ... のような自動採番(実名は出ない)。
## prompt の使い方
固有名詞・専門用語・略語を伝えると認識精度が上がる:
```
prompt: "Project Apollo, MLflow, Kubernetes, 田中部長"
```
短く、対象と関連の深い語だけを列挙。長すぎるとノイズになる。
+43
View File
@@ -0,0 +1,43 @@
# UpdateDashboardWidget
ユーザーの個人ダッシュボード (Side Info Panel) の Markdown widget を upsert するツール。
## いつ使う
- ユーザーから「ダッシュボードにメモして」「news タブを更新して」などと頼まれたとき
- 長期的に残したい情報 (ニュース要約、TODO、参照リンク) を残すよう指示されたとき
- 1 タスク内の一時メモには使わない (それは task のコメントに書く)
## 引数
| name | required | 説明 |
|---|---|---|
| `slug` | yes | Widget の安定 ID。kebab-case (`memo`, `news`, `todo`)、32 文字以内 |
| `content` | yes | Markdown 本文。64KB まで |
| `title` | 新規 slug では必須 | 表示タイトル。既存 slug を更新するときは省略可(既存タイトル維持) |
| `mode` | optional | `replace` (default) または `append` |
## 挙動
- 同じユーザーの `slug` が既に存在 → 更新
- 存在しない → 新規作成 (title 必須)
- `mode='append'` → 既存 content の末尾に `\n\n` 区切りで追記
## ワークフロー例
「最新のテック関連ニュースを news タブにまとめておいて」:
1. `WebFetch` などでニュースを収集
2. Markdown でまとめて
3. `UpdateDashboardWidget({ slug: "news", title: "ニュース", content: "<markdown>" })` を呼ぶ
4. ユーザーには「ダッシュボードの news タブに反映しました」と返す
## gotcha
- `slug` は user スコープでユニーク。他ユーザーの slug と衝突は起きない
- 書き込み先は実行中タスクの owner の dashboard。共有タスクでも他人の dashboard には書かない
- 1 度書いた slug の title は更新できない (新しいタイトルにしたい場合は UI から行うか、新 slug を切る)
- 64KB を超える content は失敗する → 古いログを切り詰めるか、append ではなく replace でローテーション
## 関連
+81
View File
@@ -0,0 +1,81 @@
# UpdateUserMemory
Writes or deletes a persistent memory entry in the caller's personal user folder.
## Overview
Memory entries are stored in `data/users/{userId}/memory/` as individual Markdown files with YAML frontmatter. An index (`MEMORY.md`) is automatically maintained and injected into the LLM system prompt at the start of every movement, giving the agent immediate awareness of what has been stored without reading every fact file.
Use `ReadUserMemory` to load the full body of a specific entry.
---
## Actions
### `upsert`
Creates a new entry or replaces an existing one with the same `name`.
**Required fields:** `action`, `name`, `type`, `description`, `body`
```json
{
"action": "upsert",
"name": "preferred-language",
"type": "user",
"description": "User prefers Japanese output",
"body": "Always respond in Japanese unless the user explicitly asks for another language."
}
```
The index line in MEMORY.md will be:
```
- [preferred-language](preferred-language.md) — User prefers Japanese output
```
### `delete`
Moves the fact file to `trash/` (no hard delete) and removes its index line from MEMORY.md.
**Required fields:** `action`, `name`
```json
{
"action": "delete",
"name": "preferred-language"
}
```
Returns an error if the entry does not exist.
---
## Parameters
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `action` | `"upsert" \| "delete"` | Yes | Operation to perform |
| `name` | string | Yes | Entry identifier: alphanumeric, dash, underscore only; no `.md` extension |
| `type` | `"user" \| "feedback" \| "project" \| "reference"` | For upsert | Category of the entry |
| `description` | string | For upsert | One-line summary shown in MEMORY.md index |
| `body` | string | For upsert | Full content of the fact file |
---
## Memory types
| Type | Intended use |
|------|-------------|
| `user` | Long-term user preferences, standing instructions |
| `feedback` | Corrections the user has given (e.g. "don't do X") |
| `project` | Project-specific facts (stack, conventions, key files) |
| `reference` | Reference data (URLs, credentials patterns, external IDs) |
---
## Notes
- **Idempotent upsert:** calling upsert twice with the same `name` replaces the entry; no duplicate index lines are created.
- **Soft delete:** deleted entries land in `trash/` and are never immediately erased.
- **Owner-only:** requires an authenticated user (`ctx.userId`). Cross-user writes are not possible.
- **Name format:** only `[a-zA-Z0-9_-]` are allowed. The `.md` extension is appended automatically.
+69
View File
@@ -0,0 +1,69 @@
# WebFetch
URL を HTTP GET してレスポンス本文を取得するツール。静的ページ向け。
## 基本
```js
WebFetch({ url: "https://example.com/article", timeout: 30 })
```
- HTML はテキスト化されて返る(タグ等は除去)
- JSON / XML / プレーンテキストもそのまま取得可能
- リダイレクトは自動で追従
## いつ使うか
| 状況 | 使うツール |
|------|-----------|
| 静的 HTML ページ | **WebFetch** |
| JS で動的レンダリングされる SPA | BrowseWeb |
| ボタン・フォーム操作が必要 | BrowseWeb |
| ファイルダウンロード | DownloadFile |
| 検索結果を一覧で取得 | WebSearch |
WebFetch は軽量で速い。BrowseWeb はブラウザ起動コストがかかるので、できる限り WebFetch を優先する。
## レスポンス履歴
各 WebFetch 呼び出しは `logs/webfetch-history.jsonl` に記録される。後から「どの URL を取得したか」を振り返れる。
## スクリーンショット添付(vlmEnabled 時のみ)
ワーカーが `vlm=true`(主 LLM が画像入力対応)の場合、WebFetch は成功時に Playwright でファーストビュー(1280×1600 viewport)のスクショを撮り、LLM の文脈に `image_url` として自動添付する。天気・ダッシュボード・地図など、HTML テキスト抽出では情報が欠落しやすいサイトの理解を補う目的。
- 保存先: `logs/webfetch-screenshots/{timestamp}-{url-hash}.png`
- `logs/webfetch-history.jsonl` の各レコードに `screenshotPath` が記録される
- Playwright 未インストール・CAPTCHA・タイムアウト等で失敗しても WebFetch 本体は成功扱い(テキストだけ返る)
- 無効化: `config.yaml``tools.webfetch_screenshot: false`
- タイムアウト: `tools.webfetch_screenshot_timeout_ms`(デフォルト 15,000
## SSRF 保護
ローカル/プライベート IP127.x.x.x, 10.x.x.x, 172.16-31.x.x, 192.168.x.x, ::1, fc00::/7 等)はデフォルトでブロックされる。社内ホストへアクセスする必要がある場合は Settings UI の「SSRF Allowed Hosts」に追加する。
## トラブルシューティング
- **本文がほぼ空**: SPA で JS レンダリングが必要 → BrowseWeb に切り替え
- **タイムアウト**: `timeout` パラメータを増やす(デフォルト 30 秒)
- **403/404**: User-Agent 制限・bot 検出の可能性 → BrowseWeb なら回避できる場合あり
- **SSRF blocked**: ローカル/プライベート IP に向いている → 設定追加またはターゲット見直し
## エラー時のフォールバック方針
WebFetch がエラーを返した場合、以下の原則で `BrowseWeb` にリトライする:
| エラー | BrowseWeb で再試行すべきか |
|---|---|
| HTTP 403 / 429 | **する** — bot 検出・レート制限。ブラウザ User-Agent で回避できる可能性 |
| HTTP 502 / 503 / 504 | **する** — CDN/upstream の一時的エラー。別の HTTP スタックで成功することがある |
| ネットワークエラー / タイムアウト | **する** — 動的ページが静的 fetch に応答しないケースが多い |
| HTTP 404 / 401 / 410 | しない — 永続的なエラー。URL を見直すべき |
| `invalid_url` | しない — URL の記述ミス |
| `ssrf_blocked` | しない — セキュリティ設定。Settings で allowed hosts を追加 |
| `pdf_blocked` | しない — `DownloadFile` + `ReadPdf` の組み合わせを使う |
| `binary_blocked` | しない — `DownloadFile` でバイナリ保存する |
| 本文が極端に短い(< 200 chars | **する** — SPA の空シェルを取得した可能性が高い |
| `Just a moment...` 等 Cloudflare challenge | **する** — ブラウザで JS challenge を通過できる |
リトライ時は同じ URL を `BrowseWeb({ url: "..." })` に渡すだけでよい。`BrowseWeb` はジョブ内で Cookie・セッションを保持するので、複数回呼んでもログイン状態は引き継がれる。
+51
View File
@@ -0,0 +1,51 @@
# WebSearch
Web 検索ツール。SearXNG または Playwright + Google 検索の組み合わせで動作する。
## 基本
```js
WebSearch({ query: "ローカル LLM 比較 2026", limit: 10 })
```
返ってくるのは検索結果のリスト(タイトル・URL・スニペット)。本文は含まれない。
本文が必要なら検索結果の URL に対して WebFetch / BrowseWeb を呼ぶ。
## 使うべき場面
- **最新情報の確認**(モデルの内部知識は学習時点まで)
- **実在性の確認**(人名・製品名・URL の存在チェック)
- **複数情報源の比較**
## 基本原則
### 1. 内部知識に頼らない(厳守)
調査タスクで「思い出して書く」ことは禁止。**必ず WebSearch → WebFetch で一次情報を取得する**。
古い情報、捏造、ハルシネーションのリスクが高い。
### 2. 追加質問への再検証
ユーザーからフォローアップ質問がきたら、関連キーワードで再検索すること。前回の検索結果に依存しない。
### 3. 一次情報の優先
- ブログ記事や要約サイトより、公式ドキュメント・公式発表・論文を優先
- 二次情報を引用する場合は「これは二次情報」と明記
- 動画の内容を扱う場合は GetYouTubeTranscript で字幕を取得してから扱う
### 4. 取得失敗時の振る舞い
一次情報にアクセスできなかった場合:
- 「情報を入手できなかった」と明記する
- Web 検索の断片的なスニペットから推測・捏造してはならない
- 推測を含む場合は「推測」と明示する
## 検索クエリのフィルタリング
機密情報漏洩防止のため、以下が含まれるクエリは自動でブロック・サニタイズされる:
- プライベート IP10.x.x.x, 172.16-31.x.x, 192.168.x.x, 127.x.x.x
- 内部ドメイン(`.local`, `.internal`, `.lan`, `.intranet`, `.corp`, `.home`
- メールアドレス、電話番号
これらを含むクエリは設定で許可されない限り検索エンジンに送られない。
+139
View File
@@ -0,0 +1,139 @@
# WriteUserScript
Creates or overwrites a script in the caller's user folder.
Two destinations are supported:
| kind | directory | runtime | signature |
|------|-----------|---------|-----------|
| `'script'` (default) | `scripts/` | plain Node.js | `main({ params })` |
| `'browser-macro'` | `browser-macros/` | Playwright — Chromium | `main({ context, params })` |
## Input
```ts
{
name: string, // slug — '.js' appended if absent
content: string, // full file text (frontmatter + main())
kind?: 'script' | 'browser-macro', // default: 'script'
overwrite?: boolean // default: false — error if file exists
}
```
## Required file structure
The content must define a `main` function. The following forms are all accepted:
```js
// ES function declaration
async function main({ params }) { }
// Arrow / assigned function
const main = async ({ params }) => { };
// CommonJS export
module.exports = async function main({ params }) { };
exports.main = async function({ params }) { };
```
If none of these patterns is found the tool returns `isError: true` with a
hint to add a `main` definition.
## YAML frontmatter (recommended)
```yaml
---
description: One-line human-readable description shown in ListUserAssets
params:
- name: url
type: string
- name: limit
type: number
default: 10
---
```
Frontmatter is parsed by `RunUserScript` for param validation. Scripts without
frontmatter still run, but param validation is skipped.
Browser macros may additionally declare `session_profile_id: <N>` to auto-load
a saved login session (see `RunUserScript` docs).
## Size limit
256 KB (UTF-8 encoded). Exceeding the limit returns `isError: true`.
## Overwrite semantics
By default (`overwrite: false`) writing to an existing file is an error.
Pass `overwrite: true` to replace the existing file atomically.
## When to use
- You discovered a useful reusable pattern during a task — save it for next time.
- The user asks you to create or update a script they can run later via `RunUserScript`.
- You want to prototype a browser automation without going through the UI.
## Examples
### Plain Node script
```js
WriteUserScript({
name: "fetch-and-clean",
kind: "script",
content: `---
description: Fetch a URL and return cleaned JSON
params:
- name: url
type: string
---
const https = require('https');
async function main({ params }) {
const res = await fetch(params.url);
const json = await res.json();
return { items: json.items ?? [] };
}
`
})
```
### Browser macro
```js
WriteUserScript({
name: "screenshot-dashboard",
kind: "browser-macro",
content: `---
description: Take a screenshot of the dashboard
params:
- name: url
type: string
---
async function main({ context, params }) {
const page = await context.newPage();
await page.goto(params.url);
const buf = await page.screenshot({ fullPage: true });
return { screenshotBase64: buf.toString('base64') };
}
`
})
```
## Error cases
| Situation | `isError` | message contains |
|-----------|-----------|-----------------|
| No authenticated user | true | "authenticated" |
| `name` missing / empty | true | `"name"` |
| `name` contains `/`, space, etc. | true | "alphanumeric" |
| `content` missing `main` | true | "main" |
| Content exceeds 256 KB | true | "bytes" |
| File exists, `overwrite` not set | true | "overwrite" |
## Notes
- `WriteUserScript` is a META_TOOL — available in every movement without listing it in `allowed_tools`.
- After writing, use `RunUserScript` to immediately execute and verify the script.
- Use `ListUserAssets` to see all scripts currently in the folder.
+133
View File
@@ -0,0 +1,133 @@
# WriteUserTemplate
Creates or overwrites a Markdown template in the caller's `templates/` folder.
Templates written here are immediately usable by `ReadUserTemplate` (to inspect them) and `RenderUserTemplate` (to substitute `{{var}}` placeholders and apply defaults).
## Input
```ts
{
name: string, // slug — '.md' appended if absent
content: string, // full file text (optional frontmatter + Markdown body)
overwrite?: boolean // default: false — error if file exists
}
```
## File structure
The content is plain Markdown with an optional YAML frontmatter block:
```markdown
---
description: One-line description shown in ListUserAssets
params:
- name: date
type: string
- name: author
type: string
default: "Team"
---
# Report — {{date}}
Prepared by: {{author}}
## Highlights
...
```
Frontmatter is not required. Templates without `params:` render as-is (no substitution).
## Frontmatter params spec
Each param entry in `params:` supports:
| field | required | description |
|-------|----------|-------------|
| `name` | yes | placeholder name used as `{{name}}` in the body |
| `type` | yes | `string` \| `number` \| `boolean` |
| `default` | no | value applied when the param is omitted |
Required params (no default) must be supplied by the caller of `RenderUserTemplate`. Missing required params produce an error at render time.
## Size limit
128 KB (UTF-8 encoded). Exceeding the limit returns `isError: true`.
## Overwrite semantics
By default (`overwrite: false`) writing to an existing file is an error.
Pass `overwrite: true` to replace the file atomically.
## When to use
- You noticed a recurring structure (email skeleton, weekly report, issue template) — persist it for reuse.
- The user asks you to create or update a template so they can render it later.
- You want to encode a multi-step prompt skeleton with named slots.
## Examples
### Email template
```js
WriteUserTemplate({
name: "api-error-email",
content: `---
description: Customer-facing API error notification email
params:
- name: incident_id
type: string
- name: service
type: string
- name: eta
type: string
default: "unknown"
---
Dear Customer,
We have detected an issue with **{{service}}** (incident {{incident_id}}).
Our team is actively working on a resolution. Estimated resolution time: **{{eta}}**.
We apologize for the inconvenience.
— The Platform Team
`
})
```
### Report skeleton (no params)
```js
WriteUserTemplate({
name: "weekly-retro",
content: `# Weekly Retro
## What went well
-
## What to improve
-
## Action items
- [ ]
`
})
```
## Error cases
| Situation | `isError` | message contains |
|-----------|-----------|-----------------|
| No authenticated user | true | "authenticated" |
| `name` missing / empty | true | `"name"` |
| `name` contains `/`, space, etc. | true | "alphanumeric" |
| Content exceeds 128 KB | true | "bytes" |
| File exists, `overwrite` not set | true | "overwrite" |
## Notes
- `WriteUserTemplate` is a META_TOOL — available in every movement without listing it in `allowed_tools`.
- After writing, use `ReadUserTemplate` to verify the content and `RenderUserTemplate` to test substitution.
- Use `ListUserAssets` to see all templates currently in the folder.
+134
View File
@@ -0,0 +1,134 @@
# X / Twitter ツール(XSearch / XUserPosts / XPostDetail / XFetchCardMedia
twitter-cli を内部で呼び出して X (旧 Twitter) のデータを取得する read-only ツール群。
## 認証設定(必須)
twitter-cli を動かすには Cookie 認証が必要。Settings UI の "Tools" セクションで設定:
- **X Auth Token**: ブラウザの `auth_token` cookie の値
- **X ct0**: ブラウザの `ct0` cookie の値
任意:
- **X Proxy**: `http://proxy:port` 形式
- **X Chrome Profile**: cookie 抽出元のプロファイルパス
設定が無いと「認証エラー」で失敗する。
## XSearch — 投稿検索
```js
XSearch({
query: "ローカル LLM",
limit: 20,
tab: "Latest", // Top / Latest / Photos / Videos
full_text: true, // 長文の省略を避ける
compact: false, // true で token 節約
output_path: "x/local-llm.txt" // 任意: output/x/ 配下に保存
})
```
## XUserPosts — ユーザー投稿一覧
```js
XUserPosts({
username: "elonmusk", // @ なし
limit: 50,
full_text: true
})
```
## XPostDetail — 投稿の詳細+リプライ
```js
XPostDetail({
url: "https://twitter.com/.../status/1234567890",
// または status_id: "1234567890"
})
```
返り値にはリプライツリーが含まれる。議論の流れを追いたいときに使う。
## 出力フォーマット
- デフォルト: 構造化テキスト(投稿者・本文・いいね数等)
- `compact: true`: token 節約版(簡潔表記)
- `output_path` 指定時: ファイルにも保存(パスは output/x/ 相対)
## メディアの自動ダウンロード
X / Twitter ツールは取得した投稿に紐付く画像 / 動画 (poster) を**自動的に
ワークスペースにダウンロード**して `localPath` を返す。LLM はそのパスを
ReadImage / AnnotateImage / Bash 等に直接渡せる。
```yaml
# 出力例 (XPostDetail / XUserPosts / XSearch 共通)
data:
- id: '1234567890'
media:
- type: photo
url: https://pbs.twimg.com/media/AAA.jpg?name=large
localPath: logs/x-media/1234567890/0.jpg # ← 自動付与
bytes: 384172
```
保存先は `{workspace}/logs/x-media/{tweet_id}/{N}.{ext}`。同じ tweet を再取得しても
既存ファイルは上書きしない (idempotent)。
### 動画の扱い
設定で挙動を切り替える (`tools.x_download_video`):
| モード | 挙動 |
| --- | --- |
| `thumbnail` (default) | poster (静止画 jpg) のみ DL。内容把握に十分で軽量 |
| `full` | variants から最高 bitrate の mp4 を DL。サイズ大なので明示的に有効化 |
| `never` | 動画系は完全にスキップ |
### サイズ上限
`tools.x_media_max_mb` (default 25) を超えるメディアはスキップしてログに記録。
content-length ヘッダで判定し、ボディが膨張した場合も DL 後に再度チェックする。
### 完全に無効化したいとき
```yaml
tools:
x_download_media: never # 全 X ツールでメディア DL を無効化
```
## XFetchCardMedia — quiz / poll / link card の画像取得
XSearch / XPostDetail で `media: []` が返るが、tweet が quiz / poll / link card
形式で card 画像があるはずだと LLM が判断した時のみ呼ぶ専用 tool。
```js
XFetchCardMedia({
tweet: "https://x.com/someuser/status/1234567890"
// または tweet: "1234567890" (この場合 screen_name 任意)
})
```
挙動:
- Playwright で X.com を開き、GraphQL response + 対象 article の DOM から
`pbs.twimg.com/(media|card_img)/...` URL を抽出
- 抽出した URL を `logs/x-media/{tweetId}/` に DL
- 成功すれば保存パスを返す
- 0 件なら "no card media found" を返す (LLM は plain text と判断)
**重要**: 1 回の呼び出しに Playwright 起動 + ページ遷移で約 14 秒かかる。
**XSearch / XUserPosts / XPostDetail からは自動発動しない**。LLM が以下の
状況でのみ明示的に呼ぶこと:
- XPostDetail が `media: []` を返した
- かつ tweet 本文が画像クイズ / 投票 / link preview を示唆する
- かつその画像の中身が次の判断に必要
text-only tweet には呼ばないこと (14 秒を無駄にする)。
## トラブルシューティング
- **認証エラー**: cookie の有効期限切れ。ブラウザで取得し直して Settings に再投入
- **rate limit**: しばらく待ってから再実行。検索回数を絞る
- **twitter-cli not found**: `scripts/install-twitter-cli.sh` で導入が必要
- **`media: []` のままで画像が取れない**: card / quiz 形式の投稿は X API 自体に
メディアが乗らない。XFetchCardMedia を呼んで Playwright 経由で取得する
+199
View File
@@ -0,0 +1,199 @@
# User Folder Layout
## Overview
Every authenticated user gets a personal folder at `data/users/{userId}/`.
This folder is the user's private, cross-task workspace. Unlike a job's
ephemeral workspace (which lives under `{worktree_dir}/local/{taskId}/` and is
tied to a single run), the user folder **persists indefinitely** across tasks,
sessions, and server restarts.
The primary use-cases are:
- Storing reusable scripts (`scripts/`) and browser macros (`browser-macros/`) that any of your tasks can invoke via `RunUserScript`.
- Keeping template files and reference documents you want agents to access without uploading them every time.
- Holding auto-generated recordings of browser sessions so you can review or convert them later.
- Managing saved browser login sessions (`browser-sessions/`) that macros can use.
Access is **owner-only**: the REST API enforces that only the owner (or an
admin) can read, write, or delete files inside the folder. The directory is
created on first login and is never shared between accounts.
---
## Subdirectories
### `scripts/`
**AI-generated plain Node.js programs.** No Chromium. Signature: `main({ params })`.
Best for: data processing, API calls, computation, file conversion, scheduled task helpers — anything that does not need a browser.
Files are edited directly in the **User Folder → scripts/** panel. The agent writes and runs these via `RunUserScript({ name, kind: 'script' })` (the default `kind`).
See [docs/tools/runuserscript.md](tools/runuserscript.md) for the exact file format and invocation details.
### `browser-macros/`
**Playwright-based browser automation scripts.** Launches Chromium. Signature: `main({ context, params })`.
Generated automatically by the **Save as Script** button in the recordings panel (previously these went to `scripts/`). Can also be written manually in the UI. The agent runs them via `RunUserScript({ name, kind: 'browser-macro' })`.
If a `session_profile_id` is declared in the frontmatter, the corresponding saved browser session (from `browser-sessions/`) is loaded automatically.
**Self-healing patches**: when a macro fails, the agent auto-enables the BrowseWeb recorder; on task completion a candidate patch is staged as `browser-macros/{name}.next.js`. The Diff review pane lets you accept or reject it. See [Self-Healing Patches](#self-healing-script-patches) below.
### `templates/`
Static files — Markdown snippets, HTML skeletons, CSV headers, prompt
fragments — that you want to reuse across tasks. Agents can read these with
the standard `Read` tool by referencing the path the API returns.
### `recordings/`
Browser-session recordings produced by `BrowseWeb` when the `record_to`
parameter is set. Each recording lands here as `{name}.json` once the session
ends. The file contains an ordered list of timestamped actions.
You never write here directly — the server writes recordings automatically.
The **User Folder → recordings/** panel lets you view recordings and convert them
to browser macros via **Save as Script** (saves to `browser-macros/`).
### `browser-sessions/`
**Virtual subdir (no actual filesystem directory).** Manages saved browser login
profiles — cookies and storage state captured via noVNC (CAPTCHA/2FA bypass).
Sessions are encrypted with a per-user key.
Managed from the **User Folder → browser-sessions/** panel in the UI:
1. Click "Add site session", enter the URL and a label.
2. Log in inside the noVNC window that opens.
3. Click Save — the encrypted storage state is written to the DB.
Browser macros reference a session by `session_profile_id: <N>` in their frontmatter.
### `trash/`
Soft-deleted scripts, macros, templates, and recordings are moved here rather than
immediately erased. Files in `trash/` accumulate with a timestamp prefix:
`{YYYYMMDD-HHMMSS}-{rand4hex}-{name}`. Restore by copying the content back to the
original subdir via the editor.
### `memory/`
Persistent memory entries managed by the `UpdateUserMemory` and `ReadUserMemory` tools.
**`MEMORY.md`** (index file) — automatically injected into the agent's system prompt at the start of every movement. It contains one line per entry:
```
- [preferred-language](preferred-language.md) — User prefers Japanese output
- [project-stack](project-stack.md) — Tech stack for the main project
```
**Individual fact files** (`{name}.md`) — each has YAML frontmatter followed by a plain Markdown body:
```
---
name: preferred-language
description: User prefers Japanese output
type: user
---
Always respond in Japanese unless the user explicitly asks for another language.
```
**Note:** `MEMORY.md` is agent-managed via `UpdateUserMemory`. Manual edits to the index file are tolerated but may cause duplicate lines if you change the link format `- [name](file.md)`.
**Memory types:**
| Type | Intended use |
|------|-------------|
| `user` | Long-term user preferences, standing instructions |
| `feedback` | Corrections the user has given (e.g. "don't do X") |
| `project` | Project-specific facts (stack, conventions, key files) |
| `reference` | Reference data (URLs, credentials patterns, external IDs) |
---
## AGENTS.md
`data/users/{userId}/AGENTS.md` (at the top level of your user folder, not
inside a subdirectory) is your **personal agent instruction file**. Whenever
the orchestrator starts a task on your behalf it reads this file and injects
its contents into the agent's system prompt, before any piece-specific
instructions.
Edit it from the **Settings → Agent instructions** panel or with any text
editor — the agent picks up the latest version at the start of each task.
---
## Script vs Browser-Macro Format
### Plain scripts (`scripts/`)
```js
---
description: "Fetch and summarise data"
params:
- name: url
type: string
---
async function main({ params }) {
const data = await fetch(params.url).then(r => r.json());
return data.summary;
}
module.exports = main;
```
Invocation: `RunUserScript({ name: 'my-script', kind: 'script', params: { url: '...' } })`
### Browser macros (`browser-macros/`)
```js
---
description: "Log in and navigate to the dashboard"
params:
- name: username
type: string
- name: password
type: string
session_profile_id: 1
---
async function main({ context, params }) {
const page = await context.newPage();
try {
await page.goto('https://example.com/login');
await page.locator('#username').fill(params.username);
await page.locator('#password').fill(params.password);
await page.locator('button[type=submit]').click();
} finally {
await page.close();
}
}
module.exports = main;
```
Invocation: `RunUserScript({ name: 'login', kind: 'browser-macro', params: { username: '...', password: '...' } })`
Full field reference, parameter passing, and session-profile selection are documented in [docs/tools/runuserscript.md](tools/runuserscript.md).
---
## Self-Healing Script Patches
When a `RunUserScript` call with `kind: 'browser-macro'` fails because a selector no longer matches the page, the agent automatically enables the BrowseWeb recorder. The recorded interactions are compiled into a candidate patch at `browser-macros/{name}.next.js`.
The Diff review pane in **User Folder → browser-macros/** compares the current `.js` with the `.next.js` candidate and asks you to approve or reject the patch. On approval the server atomically replaces `{name}.js`. On rejection the staging file is discarded.
This means the live `browser-macros/` folder always contains only reviewed, approved code.
---
## Permissions and Privacy
The `data/users/{userId}/` directory is created with mode `0700` (owner read/write/execute only) at the filesystem level. At the API level every endpoint under `/api/users/me/` requires a valid session cookie and verifies that the authenticated user's ID matches the folder being accessed. Admin users can access any user's folder.
No other user — including users in the same organisation — can list or read your scripts, macros, templates, recordings, or AGENTS.md.
### Auth-disabled mode (local dev only)
When the `auth:` section is absent from `config.yaml` (`authActive=false`), the User Folder API injects a synthetic `{ id: 'local', role: 'user' }` user, so all operations go to `data/users/local/`. Production deployments should always run with auth enabled.