feat: initial public release (MAESTRO v0.1.0)

Open-source release of MAESTRO, an agent orchestration platform that runs
LLM-driven tasks through sandboxed tools, with a web UI. Apache-2.0.
See README.md and docs/ (getting-started, configuration, architecture).
This commit is contained in:
clade 2026-06-02 07:19:34 +00:00 committed by clade
commit 7049a874f3
825 changed files with 184390 additions and 0 deletions

3
.env.example Normal file
View File

@ -0,0 +1,3 @@
GITEA_API_TOKEN=your-gitea-token
GITEA_WEBHOOK_SECRET=your-webhook-secret
OLLAMA_BASE_URL=http://localhost:11434/v1

41
.gitignore vendored Normal file
View File

@ -0,0 +1,41 @@
node_modules/
dist/
data/
.env
config.yaml
.superpowers/
*.db
*.db-wal
*.db-shm
input/
output/
logs/
.worktrees/
.claude/
.playwright-mcp/
.superpowers/
orch.pid
.server.pid
src/generated/
.worktrees/
vendor/
# Added by code-review-graph
.code-review-graph/
# Local debugging / scratch scripts (kept out of tree)
scripts/dump_payload.mjs
# Benchmark output (run results / copied workspaces)
bench/results/
.context/
data/secrets/master.key
data/browser-sessions/*
!data/browser-sessions/.gitkeep
.gstack/
# Core dumps from native crashes (sqlite/playwright/sharp). These contain raw
# process memory — including the decrypted master key, SSH private keys and the
# session secret — so they must never be committed.
core
core.*

134
AGENTS.md Normal file
View File

@ -0,0 +1,134 @@
# AGENTS.md — Codebase orientation for contributors
This is a map of the MAESTRO codebase for contributors (and AI coding agents).
For build/test/PR mechanics see [CONTRIBUTING.md](CONTRIBUTING.md); for the
request lifecycle in depth see [docs/architecture.md](docs/architecture.md).
## Working norms
- State conclusions and corrections directly; don't pad with reflexive agreement.
- Before editing code, understand the blast radius of a change (callers, tests).
If a change is large or risky, describe the approach before implementing.
- Investigate reported bugs by reproducing the actual behavior before concluding.
## Execution flow
```
UI POST
→ bridge/server.ts (Express API)
→ Repository (SQLite: jobs table)
→ Worker.poll() picks up a queued job
→ piece-runner.ts: loads pieces/*.yaml
→ agent-loop.ts: ReAct loop (LLM ↔ tool calls, up to safety.maxIterations)
→ each movement completes → `transition` (intermediate) / `complete` (terminal)
→ job finishes → DB update / progress comment
```
## Main layers (`src/`)
- **`engine/piece-runner.ts`** — loads `pieces/*.yaml`, runs movements in order;
carries verify-movement feedback into the next execute; loop-detection aborts on
excessive repeat visits; `transition.lessons` accumulates cross-movement lessons.
- **`engine/agent-loop.ts`** — the ReAct loop for one movement. Intermediate hops
use the `transition` tool; termination (success/aborted/needs_user_input) uses the
`complete` tool. `complete.result` is the only user-visible final output. A
`ContextManager` tracks token usage from LLM `usage` responses and fires
warn/prompt/force_transition at thresholds.
- **`engine/context-manager.ts`** — threshold-based context-usage monitoring; can
auto-detect a model's context limit from the provider API.
- **`engine/piece-classifier.ts`** — LLM-based piece selection from the task text and
all piece descriptions.
- **`engine/tools/index.ts`** — dynamically loads and dispatches all tool modules.
- **`llm/openai-compat.ts`** — OpenAI-compatible (Ollama/vLLM/…) SSE streaming client;
accumulates `tool_calls` deltas into `LLMEvent`s. Retry via `provider.retry`.
- **`worker.ts` / `worker-manager.ts`** — Workers poll the DB for jobs matching their
`profiles`/`task_classes` and run them; multiple workers run concurrently and are
rebuilt on config change.
- **`config.ts` / `config-manager.ts`** — single `config.yaml`; snake_case YAML ↔
camelCase code via `transformKeys`; runtime read/write with optimistic locking and
change events.
- **`db/repository.ts`** — SQLite (better-sqlite3). Manages `jobs`, `local_tasks`,
`local_task_comments`, `audit_log`, etc. Schema: `db/schema.sql`.
- **`bridge/server.ts`** — Express API server. Submodules: `pieces-api`, `config-api`,
`tools-api`, `scheduled-tasks-api`, `admin-api`, `share-api`, `browser-api`,
`subtask-activity-api`, and more.
- **`scheduler.ts`** — cron-style scheduled tasks (daily/weekly/monthly/cron/once).
- **`bridge/auth.ts`** — Passport OAuth2 (Google / Gitea). `requireAuth` allows active
users; `requireAdmin` allows admins. Auth is optional (unset = no auth).
- **`gateway/`** — optional LLM gateway (a proxy with virtual keys, budgets, and
Prometheus metrics). Note: its env vars use the `AAO_*` prefix and the
`aao_gateway` connection type for historical reasons (AAO = the gateway).
## `pieces/*.yaml` — task definitions
Each piece is an array of `movements`. Per movement: `allowed_tools`, `edit`
(Write/Edit permission), and `rules` (transition conditions). Tools not in
`allowed_tools` are not offered to the LLM.
**Movement transition principles:** `transition` is for intermediate hops only
(`rules[].next` lists allowed targets); termination uses `complete`. `default_next`
is an engine-internal sentinel (context-overflow forced transition, ASK fallback).
Progressive pressure warns on repeat visits and aborts past a threshold.
## Tool modules
| Module | Tools |
|--------|-------|
| `core.ts` | Read / Write / Edit / Bash / Glob / Grep |
| `web.ts` | WebSearch / WebFetch / DownloadFile |
| `image.ts` | ReadImage / AnnotateImage |
| `office.ts` | ReadPdf / ReadExcel / ReadDocx / ReadPPTX / PdfToImages / Split… |
| `data.ts` | SQLite |
| `review.ts` | BatchReviewTextWithLLM / MergeReviewedResults |
| `browser.ts` | BrowseWeb (Playwright) |
| `knowledge.ts` | SearchKnowledge / ListDocuments / IngestDocument / … |
| `orchestration.ts` | SpawnSubTask |
| `x.ts`, `maps.ts`, `youtube.ts`, `amazon.ts`, `speech.ts`, `ms-learn.ts` | optional integrations |
| `checklist.ts` | CreateChecklist / CheckItem / GetChecklist |
| `pieces.ts` | ListPieces / GetPiece / CreatePiece / UpdatePiece |
| `skills.ts` | ReadSkill / ListSkills / InstallSkill (META_TOOL) |
| `docs.ts` | ReadToolDoc (META_TOOL) |
| `ssh.ts` | SSH execution / transfer |
`raw-save.ts` and `structured-blocks.ts` are helper modules (not registered tools).
Tool descriptions are kept to one sentence (they ride every LLM call); detailed
guidance lives in `docs/tools/<name>.md` and is fetched via `ReadToolDoc`.
## Bash sandbox
The Bash tool runs inside a bwrap sandbox (filesystem confined to the task
workspace, env scrubbed, network unshared) when available, with a hardened
whitelist fallback otherwise. `safety.bash_sandbox` selects the mode
(`auto`/`always`/`off`). Runtime `pip`/`npm install` is rejected; Python packages
are pre-baked from `runtime/python-requirements.txt`. See
[docs/operations/bash-sandbox-provisioning.md](docs/operations/bash-sandbox-provisioning.md).
## Workspace layout (per job)
```
{worktree_dir}/local/{taskId}/
input/ uploads & DownloadFile output
output/ artifacts (the main Write/Edit-allowed area)
logs/ activity.log, history files
subtasks/ SpawnSubTask results
skills/ skill files materialized by ReadSkill
```
## DB migrations
`db/schema.sql` is the initial schema. New columns are applied idempotently in
`db/migrate.ts` (`PRAGMA table_info` → existence check → `ALTER TABLE ADD COLUMN`).
Update both `schema.sql` and `migrate.ts`.
## Tests
Backend tests live next to their source as `*.test.ts` (vitest auto-discovers).
## Adding a tool
1. Export `TOOL_DEFS` and an `executeTool` from `src/engine/tools/<module>.ts`.
2. Register the dynamic import in `tools/index.ts`.
3. Add the tool name to the using piece's `allowed_tools`.
4. Add `docs/tools/<name>.md`. See `docs/maintenance-checklist.md` for the full
list of places that must stay in sync.

19
CHANGELOG.md Normal file
View File

@ -0,0 +1,19 @@
# Changelog
All notable changes to MAESTRO are documented here. The format is loosely based
on [Keep a Changelog](https://keepachangelog.com/), and the project aims to
follow semantic versioning.
## v0.1.0 — Initial public release (2026-06-02)
First open-source release of MAESTRO, an agent orchestration platform:
- Runs tasks against any OpenAI-compatible LLM endpoint (Ollama, vLLM, …).
- LLM-classified task routing into **Pieces** (YAML workflows) of **movements**.
- Sandboxed tool runtime (Read/Write/Edit/Bash/Glob/Grep, Office, Web, Browser,
Image, Data/SQLite, Knowledge/RAG, SSH, MCP, sub-tasks, and more).
- Bash tool sandbox (bwrap-based filesystem/network/env isolation with a
hardened fallback) and a declarative pre-baked Python toolchain.
- Optional LLM Gateway (virtual keys, budgets, metrics), reflection-based
learning, scheduled tasks, and a React web UI.
- Apache-2.0 licensed.

78
CONTRIBUTING.md Normal file
View File

@ -0,0 +1,78 @@
# Contributing to MAESTRO
Thanks for your interest in contributing! This guide covers how to build, run,
test, and submit changes.
## Prerequisites
- **Node.js 22+**
- An **OpenAI-compatible LLM endpoint** for running the app (e.g. [Ollama](https://ollama.com/) at `http://localhost:11434/v1`, or vLLM). Not required just to build/test.
- Optional, for the Bash tool sandbox: `bwrap` (bubblewrap) with unprivileged
user namespaces, plus `python3`/`pip` for the pre-baked tool packages.
## Setup
```bash
git clone <your fork or the repo URL> maestro
cd maestro
npm ci # backend deps
npm --prefix ui ci # UI deps
cp config.yaml.example config.yaml # then edit provider/workers
```
## Build & run
```bash
scripts/build-all.sh # builds backend (dist/) and UI (ui/dist/)
scripts/server.sh start # build + start with PID management
scripts/server.sh logs # tail logs
scripts/server.sh stop
# open http://localhost:9876
```
`scripts/build-all.sh` also pre-bakes the Python packages the Bash sandbox uses
(`runtime/python-requirements.txt`). Pass `--skip-python` to skip that step, or
run `scripts/prebake-python.sh` separately (may need `sudo` to write to the
system Python). See `docs/operations/bash-sandbox-provisioning.md`.
During UI development, `cd ui && npm run dev` runs Vite with HMR.
## Tests
```bash
npm test # all backend tests (vitest)
npx vitest run src/engine/tools/core.test.ts # a single file
```
- Backend tests live next to their source as `*.test.ts` (vitest auto-discovers).
- DOM-dependent UI tests need a browser-like environment and may not run in a
headless sandbox.
## Conventions
- **Config keys are snake_case in YAML** (`max_concurrency`) and **camelCase in
code** (`maxConcurrency`); `src/config.ts`'s `transformKeys` converts between them.
- New config options must be reflected in `config.yaml.example` **and**
`docs/configuration.md`.
- New tools: add a module under `src/engine/tools/`, register it in
`tools/index.ts`, list it in the relevant Piece's `allowed_tools`, and add a
one-line description plus `docs/tools/<name>.md`. See `docs/maintenance-checklist.md`.
- DB schema changes: update `src/db/schema.sql` and add an idempotent migration in
`src/db/migrate.ts`.
## Architecture
See `AGENTS.md` for a contributor-oriented architecture overview and
`docs/architecture.md` for the execution flow in depth.
## Submitting changes
1. Branch from `main` (e.g. `feat/...`, `fix/...`).
2. Keep changes focused; add/adjust tests for behavior you change.
3. Ensure `npx tsc --noEmit` is clean and the relevant tests pass.
4. Open a pull request describing the change and how you verified it.
## License
By contributing, you agree that your contributions are licensed under the
project's [Apache-2.0](LICENSE) license.

72
Dockerfile Normal file
View File

@ -0,0 +1,72 @@
FROM node:22-alpine AS builder
WORKDIR /app
# 依存関係のインストール
COPY package.json package-lock.json* ./
COPY ui/package.json ui/package-lock.json* ./ui/
RUN npm ci --ignore-scripts
RUN npm --prefix ui ci --ignore-scripts
# noVNC スタンドアロン (vnc.html を含む Web 配布物) を取得。
# npm の @novnc/novnc は lib のみで vnc.html を含まないため、
# Browser タブの iframe 用に GitHub から tarball を取得する。
ARG NOVNC_VERSION=1.6.0
RUN apk add --no-cache --virtual .novnc-fetch curl tar \
&& mkdir -p /app/vendor/noVNC \
&& curl -fSL "https://github.com/novnc/noVNC/archive/refs/tags/v${NOVNC_VERSION}.tar.gz" \
| tar -xz -C /app/vendor/noVNC --strip-components=1 \
&& test -f /app/vendor/noVNC/vnc.html \
&& apk del .novnc-fetch
# TypeScript ビルド
COPY tsconfig.json ./
COPY src ./src
COPY ui ./ui
RUN npm run build:server
RUN npm run build:ui
# --- ランタイムステージ ---
FROM node:22-alpine AS runtime
RUN apk add --no-cache \
git \
ca-certificates \
tzdata \
bash \
bubblewrap \
python3 \
py3-pip
# Pre-bake python packages into the system site-packages (read-only bind-mounted
# into every bash sandbox). Runtime `pip install` is intentionally unsupported.
COPY runtime/python-requirements.txt /tmp/python-requirements.txt
RUN pip3 install --no-cache-dir --break-system-packages -r /tmp/python-requirements.txt \
&& rm /tmp/python-requirements.txt
WORKDIR /app
# 本番依存のみインストール
COPY package.json package-lock.json* ./
RUN npm ci --omit=dev --ignore-scripts
# ビルド済み成果物をコピー
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/ui/dist ./ui/dist
COPY --from=builder /app/vendor ./vendor
# schema.sql は dist に含まれないため個別コピー
COPY src/db/schema.sql ./dist/db/schema.sql
# デフォルト設定
COPY config.yaml ./
# データ永続化ディレクトリ
RUN mkdir -p /data /workspaces
ENV NODE_ENV=production \
PORT=9876 \
DB_PATH=/data/maestro.db
EXPOSE 9876
CMD ["node", "dist/index.js"]

38
GEMINI.md Normal file
View File

@ -0,0 +1,38 @@
<!-- code-review-graph MCP tools -->
## MCP Tools: code-review-graph
**IMPORTANT: This project has a knowledge graph. ALWAYS use the
code-review-graph MCP tools BEFORE using Grep/Glob/Read to explore
the codebase.** The graph is faster, cheaper (fewer tokens), and gives
you structural context (callers, dependents, test coverage) that file
scanning cannot.
### When to use graph tools FIRST
- **Exploring code**: `semantic_search_nodes` or `query_graph` instead of Grep
- **Understanding impact**: `get_impact_radius` instead of manually tracing imports
- **Code review**: `detect_changes` + `get_review_context` instead of reading entire files
- **Finding relationships**: `query_graph` with callers_of/callees_of/imports_of/tests_for
- **Architecture questions**: `get_architecture_overview` + `list_communities`
Fall back to Grep/Glob/Read **only** when the graph doesn't cover what you need.
### Key Tools
| Tool | Use when |
|------|----------|
| `detect_changes` | Reviewing code changes — gives risk-scored analysis |
| `get_review_context` | Need source snippets for review — token-efficient |
| `get_impact_radius` | Understanding blast radius of a change |
| `get_affected_flows` | Finding which execution paths are impacted |
| `query_graph` | Tracing callers, callees, imports, tests, dependencies |
| `semantic_search_nodes` | Finding functions/classes by name or keyword |
| `get_architecture_overview` | Understanding high-level codebase structure |
| `refactor_tool` | Planning renames, finding dead code |
### Workflow
1. The graph auto-updates on file changes (via hooks).
2. Use `detect_changes` for code review.
3. Use `get_affected_flows` to understand impact.
4. Use `query_graph` pattern="tests_for" to check coverage.

201
LICENSE Normal file
View File

@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or Derivative
Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2026 MAESTRO contributors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

7
NOTICE Normal file
View File

@ -0,0 +1,7 @@
MAESTRO
Copyright 2026 MAESTRO contributors
This product includes software developed as part of the MAESTRO project.
Licensed under the Apache License, Version 2.0. See the LICENSE file for the
full license text.

67
README.md Normal file
View File

@ -0,0 +1,67 @@
# MAESTRO
![License](https://img.shields.io/badge/license-Apache--2.0-blue)
**MAESTRO** — タスクを LLM 駆動で実行するエージェントオーケストレーションプラットフォーム。タスクの種類を LLM が自動判定し、適切なワークフロー(**Piece**)で処理する。ツールはサンドボックス化されたランタイムで実行され、ワークスペース・ファイル・進捗を Web UI で管理できる。
OpenAI 互換の LLM エンドポイント([Ollama](https://ollama.com/) / vLLM など)があれば単体で動作する。
## 主な機能
- **タスク自動ルーティング** — タスク本文を LLM が分類し、最適な PieceYAML ワークフロー)へ振り分け。
- **Piece × Movement** — ReAct ループで LLM とツールが対話しながら、段階的にタスクを進める。
- **豊富なツール群** — ファイル操作Read/Write/Edit/Bash/Glob/Grep、OfficePDF/Excel/Docx/PPTX、Web 取得、ブラウザ操作Playwright、画像、SQLite、ナレッジ検索RAG、SSH、サブタスク並列実行、MCP 連携、ほか。
- **Bash サンドボックス** — bwrap によるファイルシステム/ネットワーク/環境変数の隔離(不在時は強化版 whitelist にフォールバック。Python パッケージはプリベイク。
- **LLM Gateway任意** — 仮想キー・予算・メトリクス付きの LLM プロキシ。複数 GPU/チームでの共有運用に対応。
- **学習Reflection・定期タスク・タスク共有・OAuth 認証Google/Gitea** — いずれも任意で有効化。
- **Web UI** — タスク作成・進捗・成果物プレビュー・設定編集・スキル/Piece 管理。
## クイックスタート
### Docker最短
```bash
cp .env.example .env # OLLAMA_BASE_URL などを設定
docker compose up -d
# http://localhost:9876 を開く
```
LLM エンドポイントは `.env` / `config.yaml` で指定する(既定は `http://localhost:11434/v1`)。
### ソースから
```bash
git clone https://gitea.example.com/your-org/maestro.git
cd maestro
npm ci && npm --prefix ui ci
cp config.yaml.example config.yaml # provider / workers を編集
scripts/build-all.sh
scripts/server.sh start # http://localhost:9876
```
詳しい手順は **[docs/getting-started.md](docs/getting-started.md)** を参照。
## 必要要件
- **Node.js 22+**
- **OpenAI 互換の LLM エンドポイント**Ollama / vLLM など)
- 任意Bash サンドボックス用): `bwrap`bubblewrap, 非特権 user namespace+ `python3`/`pip`
## ドキュメント
- **[docs/getting-started.md](docs/getting-started.md)** — インストール・初回起動・最初のタスク・認証/サンドボックスの有効化
- **[docs/configuration.md](docs/configuration.md)** — `config.yaml` の全設定項目リファレンス
- **[docs/architecture.md](docs/architecture.md)** — 実行フロー・Piece/Movement・ツール・DB・サンドボックス
- **[docs/tools/](docs/tools/)** — 各ツールの詳細
- **[docs/operations/bash-sandbox-provisioning.md](docs/operations/bash-sandbox-provisioning.md)** — 本番でのサンドボックス有効化手順
- **[AGENTS.md](AGENTS.md)** / **[CONTRIBUTING.md](CONTRIBUTING.md)** — コントリビュータ向け
## サーバー管理
```bash
scripts/server.sh start | stop | restart | status | logs
```
## ライセンス
[Apache-2.0](LICENSE)。

7
bench/fixtures/notes.md Normal file
View File

@ -0,0 +1,7 @@
# チーム注意事項 (2026 Q1)
- 売上の集計単位は必ず Q1 (1-3 月) ベース。複数 Q を混在させない
- 公開資料には数値の出典 (シート名・行範囲) を必ず併記する
- レポートは 100 行以内に収める。冗長な説明より要点重視
- 想定読者は経営層なので業界用語の濫用は避ける
- 「次アクション」は具体的な担当者・期限を含める形で書くこと

BIN
bench/fixtures/sales.xlsx Normal file

Binary file not shown.

View File

@ -0,0 +1,22 @@
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="utf-8">
<title>2026年4月 社内発表</title>
</head>
<body>
<h1>2026年4月 社内発表</h1>
<section>
<h2>新製品 ProductG 発表</h2>
<p>当社は新製品 <strong>ProductG</strong> の正式販売を開始します。市場投入は <strong>2026年Q2</strong> を予定しています。</p>
</section>
<section>
<h2>研究開発投資の増額</h2>
<p>2026 会計年度の R&amp;D 予算を前年比 <strong>15%</strong> 増額することが取締役会で承認されました。</p>
</section>
<section>
<h2>新オフィス開設</h2>
<p>東京・大阪・福岡の 3 拠点に加え、新たに名古屋オフィスを 2026年6月 に開設します。</p>
</section>
</body>
</html>

View File

@ -0,0 +1,105 @@
id: composite-mini-report
title: 3 ソース統合 + チェックリスト + 形式厳守
piece_hint: chat
timeout_minutes: 12
fixtures:
- source: fixtures/sales.xlsx
dest: input/sales.xlsx
- source: fixtures/notes.md
dest: input/notes.md
- source: fixtures/web/announcement.html
dest: web/announcement.html
prompt: |
以下の手順で `output/report.md` にミニレポートを作ってください。
## 必須手順 (順守すること)
1. 最初に CreateChecklist で進めるべき TODO を全部登録する (最低 4 項目)
2. 各 TODO を進めるたびに CheckItem で完了マークを付ける
3. 完了前に GetChecklist で進捗を確認する
## 情報源
- `input/sales.xlsx` の Sheet1 から「2026年Q1 売上トップ3 商品」を抽出
- `http://127.0.0.1:{WEB_PORT}/announcement.html` から発表内容を抽出
- `input/notes.md` からチーム注意事項を抽出
## 出力 `output/report.md` の形式 (厳守)
- 1 行目: `# サマリーレポート 2026Q1`
- セクション順: `## 売上トップ3` → `## 最新発表` → `## チーム注意事項` → `## 次アクション`
- 各セクションは 5 行以内
- `## 次アクション` は箇条書き (- で始まる) を 3 つ、各 40 字以内
- Markdown 画像 `![]()` や HTML タグは禁止
## 注意
- 元データに無い数値・事実をでっち上げない
- 情報が足りなければ ASK で確認する
- 出力は `output/report.md` のみ、他のファイルを作らない
expected:
must_use_tools: [ReadExcel, WebFetch, Read, Write, CreateChecklist, CheckItem, GetChecklist]
forbidden_tool_for_ext:
Read: ['.xlsx', '.docx', '.pptx', '.xls', '.doc', '.ppt']
must_produce_files: [output/report.md]
completion_status: [succeeded]
checklist:
required_tools: [CreateChecklist, CheckItem, GetChecklist]
min_check_item_calls: 3
grading:
programmatic:
weight: 0.7
constraints:
- type: file_first_line_equals
file: output/report.md
line: '# サマリーレポート 2026Q1'
- type: file_must_contain_in_order
file: output/report.md
sections: ['## 売上トップ3', '## 最新発表', '## チーム注意事項', '## 次アクション']
- type: file_section_max_lines
file: output/report.md
section: 売上トップ3
max: 5
- type: file_section_max_lines
file: output/report.md
section: 最新発表
max: 5
- type: file_section_max_lines
file: output/report.md
section: チーム注意事項
max: 5
- type: file_line_starts_with
file: output/report.md
prefix: '-'
min_lines: 3
section: 次アクション
- type: file_line_max_chars
file: output/report.md
max: 40
section: 次アクション
- type: file_no_pattern
file: output/report.md
pattern: '!\['
- type: file_no_pattern
file: output/report.md
pattern: '<[a-zA-Z][^>]*>'
llm_judge:
weight: 0.3
rubrics:
- name: factual_grounding
prompt: |
レポート内の売上トップ3 / 発表内容 / 注意事項 が、与えられた 3 ソース (sales.xlsx,
announcement.html, notes.md) に忠実か。捏造や混同があれば減点。
max_score: 10
- name: actions_quality
prompt: |
「次アクション」3 項目が、3 ソースの内容を踏まえた具体的・行動可能なものか。
抽象的すぎる、ソースと無関係な内容は減点。
max_score: 10
- name: synthesis
prompt: |
3 ソースの統合がレポート全体として論理的に整合しているか。
max_score: 10

View File

@ -0,0 +1,115 @@
# reflection-smoke.yaml
#
# Smoke test for the reflection / Hermes-mode system.
#
# DESIGN NOTE — why this is a single-step task
# ─────────────────────────────────────────────
# The ideal reflection bench is a two-run sequence:
# Run 1: submit a task + negative feedback → reflection fires → memory
# entry "feedback_user_prefers_terse_output" is written.
# Run 2: submit a second task → the reflection-produced memory entry
# appears in the system prompt → response is demonstrably terse.
#
# The current bench harness (src/bench/runner.ts) does not support multi-run
# sequences or DB assertions (reflection_metrics, memory tables). The grader
# (src/bench/grader.ts) only evaluates:
# A — tool calls from activity.log
# B — checklist tool usage
# C — file output constraints (file_first_line_equals, file_no_pattern, etc.)
# D — LLM judge rubrics against output files
#
# Therefore this YAML exercises a single task whose prompt explicitly carries
# the lesson ("one-line terse reply") that reflection would have injected into
# the system prompt on a second run. The programmatic constraints enforce the
# structural signature of a terse reply, and the LLM judge validates content
# quality. This gives a useful regression gate even without multi-step support.
#
# FULL TWO-RUN FLOW (for manual / integration testing)
# ─────────────────────────────────────────────────────
# 1. Start orchestrator with reflection.enabled: true and a reflection worker.
# 2. Submit a chat task with body:
# "Summarise the Pythagorean theorem."
# The agent will produce a verbose multi-paragraph response.
# 3. Rate that task feedback_rating='bad' via the UI.
# 4. Wait ~60 s. A task_kind='reflection' job should appear in the jobs table
# with outcome='applied' in reflection_metrics.
# Verify: SELECT outcome FROM reflection_metrics ORDER BY created_at DESC LIMIT 1;
# 5. In data/users/<userId>/memory/, confirm a file like
# feedback_user_prefers_terse_output.md exists.
# 6. Submit a second task: "Summarise the Pythagorean theorem."
# The reflection memory should now be in the system prompt.
# The response should be ≤ 3 sentences with no "Certainly!" preamble.
#
# HOW THIS FILE IS DISCOVERED
# ───────────────────────────
# The bench runner (scripts/bench-run.ts) does:
# glob("bench/tasks/*.yaml")
# No registration step is needed. Drop this file and it is automatically
# included in `npm run bench` and `npm run bench -- --task=reflection-smoke`.
id: reflection-smoke
title: Reflection smoke — terse reply under explicit lesson
piece_hint: chat
timeout_minutes: 5
prompt: |
IMPORTANT USER PREFERENCE (simulating a reflection-injected memory entry):
The user prefers terse, one-line replies with no preamble phrases such as
"Certainly!", "Of course!", "Sure!", "Great question!", or "Happy to help!".
Task: What is the Pythagorean theorem?
Instructions:
1. Write your answer to `output/answer.md`.
2. The answer MUST be a single Markdown line (no headings, no bullet lists).
3. The line MUST NOT start with a preamble phrase.
4. The line MUST be 120 characters or fewer.
expected:
must_use_tools: [Write]
forbidden_tools: [Bash]
must_produce_files: [output/answer.md]
completion_status: [succeeded]
grading:
programmatic:
weight: 0.6
constraints:
# The output must be a single non-empty line — no second non-empty line.
- type: file_no_pattern
file: output/answer.md
pattern: '(?m)^.+\n\n?.+'
# Must not contain heading markers.
- type: file_no_pattern
file: output/answer.md
pattern: '^#'
# Must not start with common preamble phrases.
- type: file_no_pattern
file: output/answer.md
pattern: '(?i)^(certainly|of course|sure[,!]|great question|happy to help|absolutely)[!,.]'
# Must not use bullet / numbered lists.
- type: file_no_pattern
file: output/answer.md
pattern: '(?m)^[-*\d]'
# Each line ≤ 120 chars (the single content line).
- type: file_line_max_chars
file: output/answer.md
max: 120
llm_judge:
weight: 0.4
rubrics:
- name: terseness
prompt: |
The output should be a single terse line (no preamble, no bullet list,
no heading) that correctly states the Pythagorean theorem.
Score 10 if the answer is ≤ 2 short sentences, factually correct, and
starts directly with the mathematical content (e.g. "In a right triangle…"
or "a² + b² = c²…").
Deduct points proportionally for verbosity, preamble phrases, or inaccuracy.
max_score: 10
- name: factual_accuracy
prompt: |
Does the answer correctly state the Pythagorean theorem
(a² + b² = c² for a right triangle)? Score 10 for correct, 0 for wrong.
max_score: 10

395
config.yaml.example Normal file
View File

@ -0,0 +1,395 @@
# MAESTRO 設定ファイル (v2 layout)
#
# このファイルを config.yaml にコピーして編集してください:
# cp config.yaml.example config.yaml
#
# ─── v2 への移行 (v1 → v2) ─────────────────────────────────────
# 旧構造 (provider.* / 平置きの worktree_dir 等) は 1 リリース分だけ
# 読み取り互換が残っています。手元の config.yaml が旧形式の場合は:
#
# scripts/migrate-config.sh --dry-run # 変換後を確認
# scripts/migrate-config.sh # in-place で書き換え (.bak を自動保存)
#
# v3.0 で v1 形式は起動 fatal になる予定です。
# v2 schema バージョン (必須)。
# - 2 : このリリースの正規形 (= 本ファイル)
# - 1 / 未指定 : v1 互換読み取り + 起動 warning
# - その他 : 起動 fatal (typo / 未来形式の混入防止)
config_version: 2
# ─── LLM ─────────────────────────────────────────────────────
# ジョブ実行時に LLM 呼び出し先として使う接続群と、retry / timeout / metrics。
llm:
timeout_minutes: 10 # 1 リクエスト全体の上限 (分)。default 10
retry:
max_attempts: 3
backoff_ms: [2000, 5000, 15000] # 429 / 5xx / 一時的接続失敗時の待機 ms
retryable_status: [429, 500, 502, 503, 504]
# workers[] — この AAO がジョブ実行時に呼ぶ接続先。
#
# connection_type:
# direct — Ollama / llama.cpp / vLLM 等の OpenAI 互換 backend に直接接続
# aao_gateway — 別 AAO Gateway 経由で接続 (Gateway Key 必須)
#
# トップレベル `gateway.*` (この AAO 自身が gateway として動く設定) と
# 単語衝突を避けるため、worker 側は `aao_gateway` と prefix 付き。
#
# model はワーカーごとに明示。`default_model` は廃止された。
# roles: 用途別 (auto / fast / quality / title / reflection 等) のフィルタ。
# max_concurrency: ワーカー単位の並列度。
# vlm: true で画像入力に対応 (ReadImage は VLM ワーカーを優先)。
workers:
- id: local-ollama
connection_type: direct
endpoint: http://localhost:11434/v1
model: qwen3:32b
roles: [auto, fast, quality]
max_concurrency: 1
enabled: true
vlm: false
# 例: 別 AAO Gateway 越しに共有 GPU プールを使う
# - id: team-gateway
# connection_type: aao_gateway
# endpoint: http://gateway.example.com:9876/v1
# api_key: ${TEAM_AAO_GATEWAY_KEY} # gateway 発行の sk-aao-*** virtual key
# model: qwen3:32b
# roles: [quality]
# max_concurrency: 2
# enabled: true
# 例: タイトル生成専用ワーカー (chat ジョブは受け付けない)
# - id: title-worker
# connection_type: direct
# endpoint: http://localhost:11434/v1
# model: qwen3:8b
# roles: [title]
# max_concurrency: 4
# enabled: true
# 例: Reflection 専用ワーカー (cheap モデルで memory 更新を回す)
# - id: reflector
# connection_type: direct
# endpoint: http://localhost:11434/v1
# model: qwen3:8b
# roles: [reflection]
# max_concurrency: 1
# enabled: true
# Prometheus exporter (worker side). default で enabled。
# /metrics が bridge HTTP server (PORT, default 9876) に mount される。
# access control: default では localhost (127.0.0.1 / ::1) のみ通る。
# 本番では (a) bearer_token を設定するか、(b) allowed_hosts で前段 IP を許可。
# env: AAO_WORKER_METRICS_BEARER_TOKEN / AAO_WORKER_METRICS_ALLOWED_HOSTS (CSV) でも上書き可。
metrics:
enabled: true
prefix: aao_worker # /^[a-z][a-z0-9_]*$/
# bearer_token: env:AAO_WORKER_METRICS_BEARER_TOKEN
# allowed_hosts:
# - 127.0.0.1
# - ::1
# - localhost
# # - 0.0.0.0 # 全許可。前段で firewall を必ず使う運用前提
# ─── AAO Gateway Server ──────────────────────────────────────
# この AAO 自身を OpenAI 互換 LLM Gateway として公開する設定。
# 有効化すると `/v1/chat/completions` などが同 process で立ち上がる。
#
# Virtual Keys (sk-aao-*** 形式) は **admin REST API での発行を推奨**:
# POST /api/admin/gateway/keys
# config.yaml の virtual_keys[] は bootstrap / backup 用途のみで、
# DB に自動 import される (source='config-import')。rotation は admin API 経由でのみ。
#
# 同 process / separate process の deploy 方法は docs/aao-gateway-overview.md を参照。
# UI からは Settings → LLM → Gateway Server で全て編集可能。
# gateway:
# enabled: false # true で同 process gateway が即時起動
# # (ConfigManager hot reload 対応、再起動不要)
# listen_port: 4000 # separate-deploy 時のみ有効 (default LiteLLM 互換)
# request_timeout_sec: 600 # 1 リクエスト全体 (streaming 込み)
# upstream_timeout_sec: 30 # 各 upstream fetch の TTFB 上限
# shutdown_graceful_sec: 30 # SIGTERM 後、in-flight SSE の drain 上限秒
#
# backends:
# - id: gpu-a # `x-aao-backend-id` / `/v1/models` に出る ID
# endpoint: http://gpu-a:11434/v1
# model: qwen3:32b # 厳密一致 routing
# max_slots: 2 # llama-server -np と合わせる
# api_key: ${GPU_A_API_KEY} # backend が bearer 必須な場合のみ
# - id: gpu-b
# endpoint: http://gpu-b:11434/v1
# model: qwen3:32b
# max_slots: 2
#
# # Bootstrap / Backup 専用 virtual_keys (新規発行は admin API 経由を推奨)。
# # virtual_keys:
# # - key: ${TEAM_ALPHA_KEY} # 起動時に DB へ idempotent import
# # team: alpha
# # allowed_models: [qwen3:32b]
# # # tokens_budget: 1000000 # 月次 token 上限 (UTC 月初に reset)
# # # rate_limit_rpm: 60 # 1 分あたり最大リクエスト数
#
# # Prometheus exporter (gateway side)。default enabled。
# # team / key_prefix / backend ラベルが出るので auth 必須運用 (default localhost のみ)。
# # env: AAO_GATEWAY_METRICS_BEARER_TOKEN / AAO_GATEWAY_METRICS_ALLOWED_HOSTS (CSV) で上書き可。
# # metrics:
# # enabled: true
# # prefix: aao_gateway
# # bearer_token: env:AAO_GATEWAY_METRICS_BEARER_TOKEN
# # allowed_hosts:
# # - 127.0.0.1
# # - ::1
# # # - 0.0.0.0 # 前段 firewall 必須
# ─── Storage / Paths ─────────────────────────────────────────
# 旧 worktree_dir / custom_pieces_dir / user_folder_root /
# tools.task_upload_max_size_mb / tools.trash_retention_days
# は normalizer により storage.* に集約された。
storage:
worktree_dir: ./data/workspaces # ジョブ実行時の作業ディレクトリのベース
# custom_pieces_dir: ./custom-pieces # リポジトリ内の pieces/ に加えて読みに行く Piece dir (任意)
user_folder_root: ./data/users # {root}/{userId}/ 配下に AGENTS.md/scripts/notes 等を保存
task_upload_max_size_mb: 50 # /api/local/tasks と /comments の body 上限 (MB)
# base64 で乗るので実ファイル目安は値 × 0.75。範囲 [1, 1000]
trash_retention_days: 30 # data/users/{userId}/trash/ の自動 sweep (起動時 + 24h 周期)
# 0 で sweep 毎に全削除
# ─── Execution ───────────────────────────────────────────────
# ジョブ全体の並列度・movement 上限・ジョブ retry。
concurrency: 4 # 全 worker 合算の最大並列ジョブ数 (env: CONCURRENCY)
max_movements: 200 # 1 ジョブ内の最大 movement 数 (loop 防止)
retry:
max_attempts: 3 # ジョブ失敗時の最大再試行回数
backoff_seconds: [60, 300, 900] # 各 attempt 間の待機秒
# ASK (ユーザーへの質問) 制御
ask:
max_per_job: 2 # 1 Job あたりの ASK 上限
# Subtask 制御
subtasks:
max_depth: 2 # SpawnSubTask のネスト最大深度
max_per_parent: 10 # 1 ジョブが生成できるサブタスクの最大数
# ─── Context (LLM コンテキスト管理) ───────────────────────────
# context:
# limit_tokens: 128000 # 省略時は Ollama API で自動取得、それも失敗なら 128000
# thresholds:
# - ratio: 0.7
# action: warn
# - ratio: 0.85
# action: prompt
# - ratio: 0.95
# action: force_transition
# ─── Safety (エージェント自爆防止) ────────────────────────────
# safety:
# max_iterations: 200 # 1 movement 内の最大イテレーション
# max_revisits: 3 # 同一 movement の最大再訪問
# prompt_guard_ratio: 0.8 # コンテキスト上限の何 % まで prompt を許容するか (0.50.95)
# history_summarization: # 古い turn を構造化要約に置換して粘る (Opencode 方式)
# enabled: true # default true
# tail_turns: 2 # 末尾何 turn を必ず保護するか
# preserve_recent_budget: 8000 # 末尾保護の最大トークン数
# bash_unrestricted: false # true: コマンドホワイトリストを撤廃し任意コマンド実行可。
# # 代わりに bwrap サンドボックスで workspace 単位の
# # ファイルシステム隔離を強制 (タスク間の横断アクセス防止)。
# # 前提: コンテナで user namespace が有効 (nesting=1)。
# # 起動時に bwrap の動作確認を行い、失敗時はエラー終了。
# # Bash サンドボックス機構:
# # auto (既定) bwrap があれば sandbox、無ければ hardened-whitelist にフォールバック
# # always sandbox を強制。bwrap 不在なら起動失敗(本番推奨)
# # off bwrap を使わないenv スクラブは維持)。デバッグ用、非推奨
# bash_sandbox: auto
# ─── Search Filter (WebSearch の機密情報漏洩防止) ─────────────
# search_filter:
# blocked_patterns: # カスタムブロックパターン (完全一致で除去)
# - secret-project
# - internal-codename
# auto_block: # 自動検出 (default: 全 true)
# private_ip: true # 10.* / 172.16-31.* / 192.168.* / 127.*
# internal_domain: true # .local / .internal / .lan / .intranet / .corp / .home
# email: true
# phone: true # 日本の電話番号
# ─── Browser Runtime (BrowseWeb / BrowserAction) ──────────────
# browser:
# page_timeout: 60000 # ms
# action_timeout: 30000 # ms
# captcha_solve: novnc # 'skip' (default) / 'novnc'
# max_captcha_pages: 5
# channel: chrome # 'chromium' (default) / 'chrome' / 'msedge'
# executable_path: /usr/bin/google-chrome # channel と排他
# ─── Tools (Web & Search / Media / External / Legacy) ────────
# UI 上は 5 カテゴリに分かれて編集可能 (Web & Search / Browser Runtime /
# Media & Documents / External Services / Legacy Knowledge)。YAML は
# 互換のため `tools` 1 ブロックで管理。
tools:
# Web & Search
searxng_url: http://localhost:8080 # WebSearch フォールバック先 (通常は Playwright + Google)
webfetch_timeout: 30 # WebFetch / DownloadFile timeout (sec)
# websearch_timeout: 15
# webfetch_allowed_hosts: # SSRF 例外 (private IP / .local 等を許可する場合)
# - my-internal-host.local
# Media & Documents
# vision_model: qwen2-vl:8b-instruct # ReadImage 用 VLM (provider と別エンドポイントなら vision_base_url)
# vision_base_url: http://localhost:11434/v1
# vision_timeout: 60
# vision_max_tokens: 1024
# ocr_model: glm-ocr # OCR 用モデル (vision_base_url の server に問い合わせる)
# office_excel_max_size_mb: 10 # ReadExcel 上限 (default 10)
# office_docx_max_size_mb: 10 # ReadDocx 上限
# office_pdf_max_size_mb: 10 # ReadPdf 上限
# office_pptx_max_size_mb: 50 # ReadPPTX 上限
# office_pptx_max_uncompressed_mb: 200 # PPTX ZIP 展開後上限 (zip-bomb 検知)
# speech_server_url: http://localhost:8000/v1
# speech_timeout: 300
# speech_language: ja
# External Services
# x_cli_command: ["twitter"] # twitter-cli 実行コマンド
# x_timeout: 90
# x_auth_token: "..." # 任意: auth_token cookie
# x_ct0: "..." # 任意: ct0 cookie
# x_proxy: http://127.0.0.1:7890 # 任意: twitter-cli 用 proxy
# x_chrome_profile: "Profile 2" # Chrome cookie 抽出 profile
# x_download_media: auto # 'auto' (default) / 'never'
# x_download_video: thumbnail # 'thumbnail' (default) / 'full' / 'never'
# x_media_max_mb: 25
# x_media_fetch_timeout_seconds: 15
# google_maps_api_key: "..." # 未設定なら Nominatim / OSRM
# maps_timeout: 30
# amazon_affiliate_tag: "your-tag-22"
# keepa_api_key: "..."
# User scripts (RunUserScript)
# user_scripts_enabled: false # true で許可。plain runtime は Node --permission で sandbox 化
# user_scripts_allow_userids: # 未指定 = 全ユーザー許可 (user_scripts_enabled に従う)
# - alice-id
# - bob-id
# Legacy Knowledge (DKS) — 新規 namespace 追加は MCP 経由を推奨
# knowledge_service_url: http://dks-server:8100 # 未設定で knowledge ツール無効
# knowledge_namespaces:
# product-a-support:
# api_key: "sk-product-a-xxx"
# contract-review:
# api_key: "sk-contract-yyy"
# ── Shared Knowledge Notes ───────────────────────────────────
# data/users/{userId}/notes/ のノートをシステムプロンプトに自動注入する設定。
# notes:
# inject:
# per_note_max_kb: 8 # 日本語コンテンツは 4 推奨
# total_max_kb: 32
# over_budget_strategy: skip_remaining # truncate_last / skip_remaining (default) / degrade_to_search
# ─── 認証 (オプション) ────────────────────────────────────────
# 未設定なら認証なしで動作 (従来互換)。
# auth:
# session_secret: "ランダムな文字列を設定してください"
# session_max_age: 86400000 # 24h (ms)
# secure_cookie: false # HTTPS 環境では true
# admin_emails:
# - "admin@example.com"
# # primary_provider: gitea # 'google' | 'gitea'。両方有効時に明示
# providers:
# google:
# client_id: ""
# client_secret: ""
# callback_url: "http://localhost:3000/auth/google/callback"
# gitea:
# client_id: ""
# client_secret: ""
# base_url: "https://gitea.example.com"
# callback_url: "http://localhost:3000/auth/gitea/callback"
# ─── Branding (オプション) ────────────────────────────────────
# config.yaml / data/branding/ は .gitignore 済みで git pull 影響なし。
# Settings → System → Branding で GUI 編集可 (admin)。
# branding:
# app_name: "My Team AI"
# primary_color: "#2563eb"
# login_page_title: "My Team AI"
# logo_url: "/branding/logo-abc123.svg"
# favicon_url: "/branding/favicon-def456.png"
# footer_text: "© 2026 Your Team"
# ─── Secrets ─────────────────────────────────────────────────
# secrets:
# master_key_path: ./data/secrets/master.key # 32-byte key, auto-generated on first start (mode 0600)
# ─── Reflection ("Hermes" mode) ──────────────────────────────
# default OFF。ON にすると毎ジョブ完了後に user memory を LLM が自動更新する。
# snapshot は data/users/{userId}/.reflection-history/ に残り UI から revert 可。
# reflection:
# enabled: false
# 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) ────────────────────────────
# Individual servers は admin UI (global) または各ユーザー (self-hosted) で管理。
# MCP_ENCRYPTION_KEY env (64 hex chars) が必須。
# mcp:
# 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: false # 自前 MCP server を private 網に置く場合 true
# ─── SSH (off by default) ────────────────────────────────────
# 有効化手順は docs/ssh.md / config.yaml.example のコメント (旧版) を参照。
# Operator runbook: docs/ssh.md
# Sample piece: pieces/ssh-ops.yaml
#
# ssh:
# enabled: false
#
# # allow_private_addresses: false # global default。admin は per-connection で grant 可
# # call_timeout_seconds: 30
# # max_output_bytes: 32768
# # max_upload_size_mb: 100
# # max_download_size_mb: 100
# # audit_retention_days: 90
# # admin_bypasses_grants: true
# # abuse_window_minutes: 10
# # abuse_failure_threshold: 5
# # abuse_lock_minutes: 30
#
# # ── Interactive SSH Console (live PTY-backed shell) ──
# console:
# enabled: false # true で SshConsole* tools + UI Terminal タブを公開
# idle_timeout_seconds: 1800 # 30min I/O-less = auto close
# max_session_duration_seconds: 14400 # 4h hard cap
# scrollback_bytes: 524288 # 512KB scrollback / session
# max_sessions_per_connection: 3
# max_input_bytes_per_send: 16384
# auto_inject_screen_lines: 24
# default_cols: 120
# default_rows: 32
# ── Browser Notifications V2 (Web Push) ───────────────────────────────
# Requires HTTPS hosting + (for iOS) PWA installation on the client side.
# notifications:
# push:
# enabled: false # true で V2 を有効化
# vapid_subject: "https://maestro.example.com/" # RFC 8292 — operations URL preferred
# vapid_current_path: "./data/secrets/vapid.json" # 自動生成 (mode 0600)
# vapid_history_dir: "./data/secrets/vapid-history"
# payload_max_bytes: 3072 # JSON byte length cap (上限 4096)
# queue_concurrency: 8
# per_send_timeout_ms: 10000
#
# 起動時に vapid_current_path に鍵が無ければ自動生成、mode 0600 で保存。
# 鍵をローテーションする場合: npm run vapid-rotate

25
deploy/maestro.service Normal file
View File

@ -0,0 +1,25 @@
[Unit]
Description=MAESTRO
After=network.target
[Service]
Type=simple
User=agent-bot
WorkingDirectory=/opt/maestro
ExecStart=/usr/bin/node dist/index.js
Restart=on-failure
RestartSec=10
EnvironmentFile=/opt/maestro/.env
# Logging
StandardOutput=journal
StandardError=journal
SyslogIdentifier=maestro
# Security hardening
NoNewPrivileges=true
ProtectSystem=strict
ReadWritePaths=/opt/maestro/data /tmp/agent-workspaces
[Install]
WantedBy=multi-user.target

36
docker-compose.yml Normal file
View File

@ -0,0 +1,36 @@
services:
maestro:
build:
context: .
dockerfile: Dockerfile
image: maestro:latest
container_name: maestro
restart: unless-stopped
ports:
- "9876:9876"
env_file:
- .env
environment:
- NODE_ENV=production
- PORT=9876
- DB_PATH=/data/maestro.db
- WORKTREE_DIR=/workspaces
volumes:
# SQLite DB 永続化
- maestro-data:/data
# エージェントワークスペース永続化
- maestro-workspaces:/workspaces
# 設定ファイル (任意でホストからマウント)
# - ./config.yaml:/app/config.yaml:ro
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://localhost:9876/health').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
volumes:
maestro-data:
driver: local
maestro-workspaces:
driver: local

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
docs/architecture.md Normal file
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
docs/bench.md Normal file
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
docs/configuration.md Normal file
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.50.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
docs/context-flow.md Normal file
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
docs/design/README.md Normal file
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.

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;
}

View File

@ -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;

View File

@ -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;

View File

@ -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,
});

View File

@ -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.

View File

@ -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;

View File

@ -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;

View File

@ -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/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
<script src="https://unpkg.com/@babel/standalone@7.29.0/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>

View File

@ -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;

View File

@ -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;

View File

@ -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,
});

View File

@ -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/` にあります。

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
<script src="https://unpkg.com/@babel/standalone@7.29.0/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: 'daichi@example.com', role: 'admin', status: 'active', taskCount: 142, lastLogin: 'たった今', createdAt: '2025-11-02', timezone: 'Asia/Tokyo', defaultPiece: 'auto' },
{ id: 'u2', name: 'Tomoko', email: 'tomoko@example.com', role: 'operator', status: 'active', taskCount: 38, lastLogin: '2時間前', createdAt: '2025-12-14', timezone: 'Asia/Tokyo', defaultPiece: 'chat' },
{ id: 'u3', name: 'Kenta', email: 'kenta@example.com', role: 'operator', status: 'active', taskCount: 21, lastLogin: '昨日', createdAt: '2026-01-20', timezone: 'Asia/Tokyo', defaultPiece: 'research' },
{ id: 'u4', name: 'Aya', email: 'aya@example.com', role: 'viewer', status: 'active', taskCount: 4, lastLogin: '3日前', createdAt: '2026-02-09', timezone: 'Asia/Tokyo', defaultPiece: 'auto' },
{ id: 'u5', name: null, email: 'newbie@example.com', role: 'viewer', status: 'pending', taskCount: 0, lastLogin: '—', createdAt: '2026-04-17', timezone: 'Asia/Tokyo', defaultPiece: 'auto' },
{ id: 'u6', name: 'Hiro', email: 'hiro@example.com', 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>

View File

@ -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
docs/getting-started.md Normal file
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) を参照。

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
docs/mcp.md Normal file
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`)

View File

@ -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`[[reference_aao_production]] 参照)。
## 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 にフォールバック
# もしくは offbwrap を使わない。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
docs/skills.md Normal file
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
docs/ssh.md Normal file
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`, `curve25519-sha256@libssh.org`, `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 | `aes256-gcm@openssh.com`, `aes128-gcm@openssh.com`, `aes256-ctr`, `aes192-ctr`, `aes128-ctr` |
| HMAC | `hmac-sha2-512-etm@openssh.com`, `hmac-sha2-256-etm@openssh.com`, `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

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
docs/tools/bash.md Normal file
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 サンドボックス、無ければ hardenedwhitelist + パススコープ + 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
docs/tools/brainstorm.md Normal file
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 を呼ぶ必要は無い (オーバーヘッドになる)

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
docs/tools/browseweb.md Normal file
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: "user@example.com" },
{ 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`, ... の IDrefは出現順に自動採番される
- 各 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: "user@example.com" },
{ 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
docs/tools/checklist.md Normal file
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ステップで終わる処理には不要。

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 等の専用ツールも検討

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
docs/tools/listpieces.md Normal file
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 の改良が原則**。

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
docs/tools/notes.md Normal file
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
docs/tools/office.md Normal file
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
docs/tools/readimage.md Normal file
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 で内容確認できる。

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.

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.

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
docs/tools/runuserscript.md Normal file
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.

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**: 任意。設定すると価格推移データが詳細化(無くてもグラフ画像リンクは出る)

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 で完了確認)

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` がエラーを返すが、キャッシュ検索は引き続き使える

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
docs/tools/slide.md Normal file
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 してもらう。

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
docs/tools/sqlite.md Normal file
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**: ディスク容量・パーミッション確認

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-hostaao:~$ 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
docs/tools/ssh-tools.md Normal file
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 フロー・運用ガイド・セキュリティモデル

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, 田中部長"
```
短く、対象と関連の深い語だけを列挙。長すぎるとノイズになる。

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 でローテーション
## 関連

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
docs/tools/webfetch.md Normal file
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
docs/tools/websearch.md Normal file
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`
- メールアドレス、電話番号
これらを含むクエリは設定で許可されない限り検索エンジンに送られない。

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.

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
docs/tools/xsearch.md Normal file
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
docs/user-folder-layout.md Normal file
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.

7370
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

77
package.json Normal file
View File

@ -0,0 +1,77 @@
{
"name": "maestro",
"version": "0.1.0",
"type": "module",
"description": "MAESTRO — Multi-Agent Execution System for Task Routing & Orchestration. A local-first control plane for coordinating AI agents, workers, tools, and gateways.",
"main": "dist/main.js",
"scripts": {
"build": "npm run build:server",
"build:server": "bash scripts/generate-version.sh && tsc && npm run copy:assets",
"build:ui": "npm --prefix ui run build",
"build:all": "./scripts/build-all.sh --skip-install",
"copy:assets": "mkdir -p dist/db dist/bridge && cp src/db/schema.sql dist/db/schema.sql && cp src/bridge/auth-login.html dist/bridge/auth-login.html && cp src/bridge/auth-pending.html dist/bridge/auth-pending.html",
"install:all": "npm ci && npm --prefix ui ci",
"start": "node dist/main.js",
"dev": "node --loader ts-node/esm src/main.ts",
"test": "vitest run",
"test:watch": "vitest",
"lint:pieces": "node scripts/lint-pieces.mjs",
"bench": "tsx scripts/bench-run.ts",
"bench:fixtures": "tsx scripts/build-bench-fixtures.ts",
"vapid-rotate": "tsx scripts/vapid-rotate.ts"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.29.0",
"@novnc/novnc": "^1.6.0",
"@types/ssh2": "^1.15.5",
"@types/web-push": "^3.6.4",
"@xterm/headless": "^5.5.0",
"adm-zip": "^0.5.16",
"better-sqlite3": "^12.6.2",
"cron-parser": "^5.5.0",
"exceljs": "^4.4.0",
"express": "^4.18.3",
"express-session": "^1.19.0",
"fast-xml-parser": "^5.4.2",
"gray-matter": "^4.0.3",
"http-proxy": "^1.18.1",
"mammoth": "^1.11.0",
"p-queue": "^9.3.0",
"passport": "^0.7.0",
"passport-google-oauth20": "^2.0.0",
"passport-oauth2": "^1.8.0",
"pdf-parse": "^2.4.5",
"playwright": "^1.59.1",
"pptxgenjs": "^4.0.1",
"prom-client": "^15.1.3",
"proper-lockfile": "^4.1.2",
"sharp": "^0.34.5",
"ssh2": "^1.17.0",
"undici": "^7.25.0",
"uuid": "^13.0.0",
"web-push": "^3.6.7",
"ws": "^8.20.1",
"yaml": "^2.4.1"
},
"devDependencies": {
"@types/adm-zip": "^0.5.7",
"@types/better-sqlite3": "^7.6.8",
"@types/express": "^4.17.21",
"@types/express-session": "^1.18.2",
"@types/http-proxy": "^1.17.17",
"@types/node": "^20.11.30",
"@types/passport": "^1.0.17",
"@types/passport-google-oauth20": "^2.0.17",
"@types/passport-oauth2": "^1.8.0",
"@types/proper-lockfile": "^4.1.4",
"@types/supertest": "^7.2.0",
"@types/uuid": "^10.0.0",
"@types/ws": "^8.18.1",
"fast-check": "^3.23.2",
"jszip": "^3.10.1",
"supertest": "^7.2.2",
"tsx": "^4.21.0",
"typescript": "^5.4.3",
"vitest": "^1.4.0"
}
}

98
pieces/SCHEMA.md Normal file
View File

@ -0,0 +1,98 @@
# Piece YAML Schema
This is the reference for the piece YAML format consumed by
`src/engine/piece-runner.ts` (`loadPiece` / `validatePieceDef`) and the
`/api/pieces` HTTP layer (`src/bridge/pieces-api.ts` `validatePiece`).
Field names are snake_case in the YAML; the engine maps them to
camelCase internally (see `Movement` in `src/engine/agent-loop.ts`).
## Top-level
| Field | Type | Required | Notes |
|-------|------|----------|-------|
| `name` | string | yes | lowercase `[a-z0-9-]+` |
| `description` | string | yes | shown in the piece classifier |
| `max_movements` | positive integer | yes | hard cap on movement count per run |
| `initial_movement` | string | yes | must reference a `movements[].name` |
| `triggers.keywords` | string[] | no | classifier hint only |
| `required_mcp` | string[] | no | `[a-z0-9_-]{1,64}` server slugs |
| `model` | string | no | preferred LLM model |
| `movements` | Movement[] | yes | non-empty array |
## Movement
| Field | Type | Required | Notes |
|-------|------|----------|-------|
| `name` | string | yes | unique within the piece |
| `edit` | boolean | yes | when true, Write/Edit are exposed |
| `persona` | string | yes | system-prompt persona |
| `instruction` | string | yes | the movement's task description |
| `allowed_tools` | string[] | yes | tool names; `'mcp__*'` wildcard allowed |
| `allowed_commands` | string[] | no | Bash command allowlist (overrides default) |
| `allowed_ssh_connections` | string[] | conditional | see below |
| `rules` | Rule[] | yes | transition rules; may be empty |
| `default_next` | string | no | engine-internal fallback (sentinel-friendly) |
| `max_consecutive_revisits` | number | no | loop-detection threshold override |
## `allowed_ssh_connections`
Per-movement SSH connection allowlist (Phase 4 of the SSH tool integration
| Value | Meaning |
|-------|---------|
| `undefined` (field omitted) | SSH tools reject with `no_allowed_connections_declared`. |
| `[]` (empty array) | SSH tools reject with `no_allowed_connections_declared`. The empty form is preferred over omission when the movement intentionally denies all connections (intent is explicit). |
| `['<connection-id>', ...]` | Only listed connection IDs may be passed to SSH tools. |
| `['*']` | Any registered connection may be passed. Still subject to ownership and grant checks (defense in depth). Use sparingly — typically only `ssh-ops`-style pieces. |
**Required**: If a movement's `allowed_tools` contains any of `SshExec`,
`SshUpload`, or `SshDownload`, then `allowed_ssh_connections` MUST be
present. `validatePieceDef` and `validatePiece` both reject pieces that
omit it for SSH-using movements.
**Format**: each entry must be `'*'` or a lowercase hex/hyphen id with
8+ characters (loose match against `randomUUID()` output).
Example:
```yaml
movements:
- name: ops
edit: false
persona: ops-operator
instruction: Run health checks on production hosts.
allowed_tools: [SshExec, Read]
allowed_ssh_connections:
- 6f9619ff-8b86-d011-b42d-00c04fc964ff
- 7a8b9cde-1234-4567-89ab-cdef12345678
rules:
- condition: all checks pass
next: COMPLETE
```
## Rule
```yaml
- condition: <human-readable description shown to the LLM>
next: <movement name | WAIT_SUBTASKS>
```
`rules[].next` may NOT use the reserved terminal sentinels
`COMPLETE` / `ABORT` / `ASK` — those are reachable only through the
`complete` tool (status: `success` / `aborted` / `needs_user_input`).
`default_next` does accept the terminal sentinels because it is an
engine-internal fallback (context overflow, ASK limit, SpawnSubTask
unavailable).
## Validation paths
Two validators implement the same rules:
- `validatePieceDef` in `src/engine/piece-runner.ts` — runs on every
`loadPiece` (file-backed) and `CreatePiece` (runtime).
- `validatePiece` in `src/bridge/pieces-api.ts` — runs on `PUT
/api/pieces/:name` (UI editor).
Both must stay in sync. When changing the schema, update both and add
test coverage in `src/engine/piece-runner.test.ts` and
`src/bridge/pieces-api.test.ts`.

121
pieces/brainstorming.yaml Normal file
View File

@ -0,0 +1,121 @@
name: brainstorming
description: |
複数の視点から並列にアイデアや選択肢を検討し、推奨方針を導き出す。
選ぶべき場合: 「どうすべきか」「どの方針が良いか」を多角的に検討する必要がある
選ぶべきでない場合: 答えが調査で明確になるタスク、具体的な成果物の作成が主目的
max_movements: 999
initial_movement: decompose
triggers:
keywords:
- brainstorming
- ブレスト
- ブレインストーミング
- 方針検討
- アイデア出し
movements:
- name: decompose
edit: false
persona: facilitator
instruction: |
課題を分析し、複数の視点から検討すべきポイントを特定してください。
1. タスクの指示を注意深く読み、何が求められているかを理解する
2. 検討すべき視点や切り口を 2〜5 個に分解する
- : 技術的実現性、コスト/リソース、ユーザーへの影響、リスク、長期的な拡張性
3. 各視点ごとに SpawnSubTask で独立した調査・検討タスクを作成する
- piece は "research-sub" を指定する
- instruction には「この視点で分析し、output/analysis.md に結論と根拠を書いてください」と具体的に記述
- 各サブタスクは異なる分析レンズを持つよう明確に指定する
4. 全サブタスクの登録が完了したら WAIT_SUBTASKS に遷移する
allowed_tools:
- Read
- Grep
- Glob
- SpawnSubTask
rules:
- condition: "全てのサブタスクを SpawnSubTask で登録し終えた"
next: WAIT_SUBTASKS
- condition: 全てのサブタスクを登録し終えたSpawnSubTask不可の場合は自分で分析を完了した
next: aggregate
default_next: aggregate
- name: aggregate
edit: true
persona: analyst
instruction: |
各サブタスクの検討結果を統合し、推奨方針をまとめてください。
1. subtasks/ ディレクトリを確認するGlob: subtasks/*/output/**
2. 各サブタスクの分析結果を読み込む
3. 共通点・相違点・トレードオフを整理する
4. 総合的な推奨方針を output/recommendation.md に作成する
- 各視点からの主要な発見
- トレードオフの整理
- 推奨アプローチとその根拠
- リスクと緩和策
5. 完了したら verify に遷移する
allowed_tools:
- Read
- Glob
- Grep
- Write
- Edit
- SearchKnowledge
- ListNamespaces
- ListDocuments
- SearchNotes
- ReadNote
- 'mcp__*'
rules:
- condition: "output/recommendation.md に推奨方針をまとめた"
next: verify
default_next: verify
- name: verify
edit: false
persona: reviewer
instruction: |
output/ の成果物を確認する。
確認手順:
1. まず Glob で output/ 内のファイル一覧を確認する
2. output/recommendation.md がなければ「修正が必要」と判断し aggregate に差し戻す
3. ファイルがあれば Read で内容を確認し、各視点の分析が含まれているか・推奨方針が論理的かをチェックする
4. 不足や誤りがあれば、`transition({next_step: "aggregate", summary: ...})` で差し戻す。summary は次の形式で書く:
[判定] needs_fix
## 問題点
- [ファイル名:行番号または項目名] 何が問題か
## 期待する修正
- 何をどう直すべきか
## 合格基準
- 再レビューで何を確認するか
## 次にやること
- aggregate で最初に着手すべき具体的な修正
5. summary は抽象論で終えず、具体的な不足点・期待する修正内容を必ず含める
## チェックシート確認
GetChecklist でチェックシートが存在する場合、全アイテムが完了done/failed/skippedしていることを確認する。
remaining が 0 でないまま完了してはならない。
## 合格時のユーザーへの返答complete ツール)
output/ の内容で合格と判断したら、`complete({status: "success", result: ...})` を呼ぶ。
result はそのままユーザーに表示される最終回答。output/ のファイルを Read で読み、その内容をベースに整形する。
- 「output/xxx.md を確認してください」のようなファイル参照ではなく、内容そのものを回答として返すこと
- 【厳守】「✅ 完了」「推奨方針をまとめました」「確認しました」等のステータス表示・メタ説明・内部作業の報告は一切書かない。1行目からいきなり本題の内容を書き始めること
- 推奨方針・トレードオフ・根拠を会話調で分かりやすく伝える
- 表・リスト・見出しなど Markdown 書式を活用して読みやすくする
## 終了方法のまとめ
- 合格: `complete({status: "success", result: "ユーザー向け最終回答"})`
- 修正必要: `transition({next_step: "aggregate", summary: "差し戻し指摘"})` (上記形式で)
- 技術的失敗: `complete({status: "aborted", abort_reason: "..."})`
allowed_tools: [Read, Glob, Grep]
# default_next is the engine-internal fallback (context overflow / ASK
# limit / SpawnSubTask unavailable). Not exposed to the LLM.
default_next: COMPLETE
rules:
- condition: output/ にファイルがない、または内容に不足がある
next: aggregate

73
pieces/chat.yaml Normal file
View File

@ -0,0 +1,73 @@
name: chat
description: |
汎用デフォルト piece。質問・調査・コード生成・文書作成・データ処理など、
特化型 piece に明確にマッチしない依頼を全てここで処理する。
単一 movement で必要なツールを自由に呼び出し、依頼の性質に応じて
会話で返すかファイル出力するかを判断する。
max_movements: 999
initial_movement: respond
movements:
- name: respond
edit: true
persona: assistant
instruction: |
ユーザーの依頼に対して必要な調査・作業を行い、最終回答を返す。
## 進め方
1. 入力把握: input/ の添付ファイルを確認し、必要なら内容を読む
2. 情報収集: 事実・知識に関する依頼は必ず Web 検索で裏取りする(モデルの内部知識だけで答えない)
3. 多角的に検索: 1つの結果で判断せず、複数の視点から情報を集める
4. 時刻依存の依頼(「今日のニュース」「最新動向」等)は必ず最新情報を取りに行く
5. 必要なら output/ にファイルを書き出す(後述)
6. 回答が固まったら **`complete({status: "success", result: "..."})`** を呼ぶ。result がそのままユーザーに表示される最終出力
## 回答のスタイル(依頼の性質に合わせる)
- **短い質問・対話的な依頼**: 会話として自然な文体で要点を簡潔に
- **レポート・文書生成依頼**: 構造化された Markdown で章立てして出力
- **コード生成・データ処理依頼**: 実行可能なコード / 整形済みデータを返し、要点を本文で説明
- 共通: 情報源の URL を必ず明記する(末尾「情報源:」または本文中リンク埋め込み)
- 「output/report.md に書きました」だけで終わらせない。本文で要点を必ず伝える
- 技術的な内部ログmovement 遷移など)を含めない
## 画像・ビジュアル素材の活用
回答に関連する画像(グラフ、スクリーンショット、製品画像、図解等)が
Web 上で見つかった場合は output/images/ に保存し、result 内に Markdown 画像として埋め込む:
`![説明](./images/ファイル名.png)`
テキストだけより画像を添えた方がわかりやすい場合は積極的に活用する。
## 一次情報へのアクセスと捏造禁止(厳守)
- YouTube 動画の内容を聞かれた場合は、必ず字幕を取得してから回答する
- 一次情報(動画字幕、論文本文、ページ本文等)に直接アクセスできなかった場合:
- Web 検索の断片的な情報から内容を推測・捏造してはならない
- 「字幕の取得に失敗したため正確な内容をお伝えできません」と正直に報告する
- 取得できた範囲(タイトル、概要等)のみを提示し、推測部分は明示する
- 二次情報(ブログ記事、要約サイト等)から得た情報の場合は、一次情報ではない旨を明記する
## ファイル出力が必要な場合
以下のときだけ output/ にファイルを書き出す:
- ユーザーが明示的にファイル作成を依頼した場合
- コード生成、文書作成など、テキスト回答では不十分な場合
- データが大きく、チャットに収まらない場合
ファイルを出力した場合は、回答の中で output/ファイル名 に言及する。
## Piece の作成・編集
ユーザーが「○○用の Piece を作って」「エージェントをカスタマイズしたい」と依頼した場合:
1. ListPieces で既存の Piece 一覧を確認する
2. 類似の Piece があれば GetPiece で内容を取得し、参考にする
3. ユーザーと対話しながら目的・ステップ・使用ツールをヒアリングする
4. YAML 定義を生成し、CreatePiece または UpdatePiece で保存する
5. 作成した Piece の内容をユーザーに説明する
## 完了方法(重要)
この piece は単一 movement のため、終了は必ず `complete` ツールで行う。`transition` は使わない。
- **回答できた場合**: `complete({status: "success", result: "ユーザー向け回答の全文"})`
- `result` がそのままユーザーに表示される最終出力。途中のメモや作業ログは入れない
- **ユーザー確認が必要**: `complete({status: "needs_user_input", missing_info: "確認したい内容", why_no_default: "デフォルトで進められない理由"})`
- **技術的失敗で打ち切り**: `complete({status: "aborted", abort_reason: "失敗の理由"})`
allowed_tools: [Read, Write, Edit, Glob, Grep, WebSearch, WebFetch, DownloadFile, ReadImage, AnnotateImage, ReadPdf, PdfToImages, ReadExcel, ReadDocx, ReadPPTX, SQLite, Bash, XSearch, XUserPosts, XPostDetail, XFetchCardMedia, BrowseWeb, SearchPlaces, GetDirections, ReverseGeocode, GetYouTubeTranscript, SearchYouTube, SearchAmazon, TranscribeAudio, ListPieces, GetPiece, CreatePiece, UpdatePiece, SearchKnowledge, ListNamespaces, ListDocuments, SearchNotes, ReadNote, WriteNote, SearchMicrosoftLearn, FetchMicrosoftLearn, SearchMicrosoftLearnCache, RefreshMicrosoftLearnCache, ReadToolDoc, UpdateDashboardWidget, 'mcp__*']
# default_next is the engine-internal fallback for context overflow / ASK
# limit reached / SpawnSubTask unavailable. It is NOT exposed to the LLM.
default_next: COMPLETE
rules: []

128
pieces/data-process.yaml Normal file
View File

@ -0,0 +1,128 @@
name: data-process
description: |
CSV, JSON, TSV, SQL などの構造化データファイルの加工・集計・変換・フィルタリング。
選ぶべき場合: 入力が構造化データファイルで、プログラム的な処理が必要
選ぶべきでない場合: Excel/Word/PDFなどのOffice系ファイル操作、Web調査が主目的
triggers:
keywords: ["CSV", "TSV", "JSON", "JSONL", "SQL", "フィルタ", "クレンジング", "ETL"]
max_movements: 999
initial_movement: process
movements:
- name: process
edit: true
persona: data-engineer
instruction: |
## 最初のステップ: 入力データの把握
加工に着手する前に、まずデータの構造を把握する:
1. Glob でワークスペース全体のファイル一覧を確認するinput/ だけでなくルート直下も含む)
2. 入力データファイルを Read や Bash で確認し、構造・件数・データ型を把握する
3. 指示に基づいて処理方針を立てる
## 処理手段の選択
**SQLite を使う場面**:
- 複数テーブルの JOIN や GROUP BY が必要
- フィルタリング条件が複雑WHERE 句で表現できる)
- 集計結果を別ファイルに export したいSELECT INTO / `.output`
- CSV を直接インポートして SQL で操作したい
**Bash + python3 を使う場面**:
- 数値計算・統計処理(平均・標準偏差・パーセンタイル等)
- JSON/JSONL のネスト構造を展開・変換する
- 行列変換・pivot・reshape など SQL では扱いにくい変換
- 複数ファイルをまとめて処理するスクリプトを書く
**jq / awk を使う場面**:
- JSON のフィールド抽出・変換jq
- テキスト系の列操作・集計awk
- 軽量な前処理パイプライン
ツールの詳細な使い方は ReadToolDoc({ name: "ツール名" }) で確認できる。
## 出力形式の選択基準
- **CSV**: 数値・表形式データ。後続の集計・可視化ツールへの受け渡し
- **JSON / JSONL**: ネスト構造あり、または行単位のストリーム処理向け
- **Markdown 表**: レポートやサマリーに埋め込む場合。行数が多い場合は上位N件に絞る
## スキャン PDF / 画像データの場合
レシートや帳票など画像由来のデータを扱う場合:
- テキスト PDF → ReadPdf で直接読む
- スキャン PDF / 画像 → PdfToImages でページ画像化し、ReadImage で内容を読み取る
## 実行
方針に従いデータを加工・集計する。結果を output/ にファイルとして書き出す。
前のステップから指摘事項がある場合は、それに優先して対応すること。
## 終了 / 遷移方法
- **次の report へ**: `transition({next_step: "report"})`
- **処理対象が特定できずユーザー確認が必要**: `complete({status: "needs_user_input", missing_info: "...", why_no_default: "..."})`
- **データが壊れている / 読み取れない / エラー発生で打ち切り**: `complete({status: "aborted", abort_reason: "..."})`
allowed_tools: [Read, Write, Bash, Glob, Grep, SQLite, WebSearch, WebFetch, DownloadFile, ReadExcel, ReadDocx, ReadPdf, ReadPPTX, SplitExcelSheets, PdfToImages, ReadImage, AnnotateImage, TranscribeAudio, SearchKnowledge, ListNamespaces, ListDocuments, ReadToolDoc, 'mcp__*']
default_next: report
rules:
- condition: output/ に結果を書き出した
next: report
- name: report
edit: true
persona: reporter
instruction: |
処理結果を元にレポートを作成し output/ にファイルとして書き出す。
表を含め、分かりやすくまとめる。
必ず Write ツールで output/ にレポートファイルを作成すること。
allowed_tools: [Read, Write, Bash, Glob, Grep, ReadImage, AnnotateImage, ReadPdf, ReadExcel, 'mcp__*']
default_next: verify
rules:
- condition: output/ にレポートを書き出した
next: verify
- name: verify
edit: false
persona: reviewer
instruction: |
output/ の成果物を確認する。
確認手順:
1. まず Glob で output/ 内のファイル一覧を確認する
2. output/ にファイルが1つもなければ「修正が必要」と判断し process に差し戻す
3. ファイルがあれば Read で内容を確認し、指示通りか・品質は十分かをチェックする
4. 不足や誤りがあれば、`transition({next_step: "process", summary: ...})` で差し戻す。summary は次の形式で書く:
[判定] needs_fix
## 問題点
- [ファイル名:行番号または項目名] 何が問題か
## 期待する修正
- 何をどう直すべきか
## 合格基準
- 再レビューで何を確認するか
## 次にやること
- process で最初に着手すべき具体的な修正
5. summary は抽象論で終えず、変更ファイル・不足点・期待する修正内容を必ず含める
## チェックシート確認
GetChecklist でチェックシートが存在する場合、全アイテムが完了done/failed/skippedしていることを確認する。
remaining が 0 でないまま完了してはならない。
## 合格時のユーザーへの返答complete ツール)
output/ の内容で合格と判断したら、`complete({status: "success", result: ...})` を呼ぶ。
result はそのままユーザーに表示される最終回答。output/ のファイルを Read で読み、その内容をベースに整形する。
- 「output/xxx.md を確認してください」のようなファイル参照ではなく、内容そのものを回答として返すこと
- 【厳守】「✅ 完了」「処理結果を作成しました」「確認しました」等のステータス表示・メタ説明・内部作業の報告は一切書かない。1行目からいきなり本題の内容を書き始めること
- 処理結果の要点を会話調で分かりやすく伝える
- 表・リスト・見出しなど Markdown 書式を活用して読みやすくする
- 長大な結果の場合は要点を構造化して提示し、詳細は省略してよい
## 終了方法のまとめ
- 合格: `complete({status: "success", result: "ユーザー向け最終回答"})`
- 修正必要: `transition({next_step: "process", summary: "差し戻し指摘"})` (上記形式で)
- 技術的失敗: `complete({status: "aborted", abort_reason: "..."})`
allowed_tools: [Read, Glob, Grep, ReadPdf, ReadImage, AnnotateImage, ReadExcel]
default_next: COMPLETE
rules:
- condition: output/ にファイルがない、または内容に不足・誤りがある
next: process

View File

@ -0,0 +1,86 @@
name: game-tweet-generator
description: |
指定したゲームアカウントの X (Twitter) 投稿を調査し、その情報を元に特定の SNS アカウント(例: NFTGamerJP風のツイート文章を生成する。
選ぶべき場合: 「ゲームの最新情報を調べて、こんな風にツイートして」と指示されたとき
選ぶべきでない場合: 一般的な SNS 調査のみ、またはツイート生成以外の目的
triggers:
keywords:
- ゲームツイート作成
- ゲーム調査ツイート
- アカウント風ツイート
- ゲームアップデート調査
- ツイート文案作成
max_movements: 1
initial_movement: generate
movements:
- name: generate
edit: true
persona: researcher_and_writer
instruction: |
AVAX に関連するゲームアカウントの X (Twitter) 投稿を調査し、その情報を元にターゲットアカウント(例: NFTGamerJP風のツイート文章を生成する。
## 関係するアカウント
- Off The Grid
- DeFi Kingdoms
- The Grotto
- Fort Block Games
- Beam
## ワークフロー
1. **ゲームアカウントの特定**
- Task instruction から対象となるゲームアカウントを抽出する
- 複数アカウント指定がある場合は主要なアカウントを優先
2. **最新投稿の調査**
- XUserPosts で対象アカウントの最新投稿を取得し、直近 1 週間以内を重点的に確認
- アップデート情報・イベント告知・コミュニティ反応などを選定
3. **詳細調査**
- 重要な投稿は XPostDetail でスレッド文脈・リプライを確認
- ゲームの最新動向が不足する場合は WebSearch で補完
4. **ターゲットアカウントのスタイル分析**
- 指定されたターゲットアカウントの過去投稿を XUserPosts で取得し、以下を分析:
- 使用する絵文字の種類と配置
- 文中の装飾(改行・区切り線等)とハッシュタグのパターン
- 情報提供とエンターテインメントのバランス
- 投稿の長さと構成
5. **ツイート文章の生成**
- 調査したゲーム情報を元に、ターゲットアカウントのスタイルに合わせた文章を作成
- 重要なアップデート・イベント情報の要約、適切な絵文字、関連ハッシュタグを含める
- 短め(簡潔版)と詳細版など複数バリエーションを提示する
## 原則
- 【必須】モデルの内部知識だけで情報を書かないこと。必ず実際のツイートデータを収集する
- 調査が一部失敗しても、取得できた情報で最善の提案を行う
- ターゲットアカウントのスタイルを参考にしつつ、情報に基づいた独自の文章を作ること(単なるコピーは不可)
## 完了方法
この piece は単一 movement のため、終了は必ず `complete` ツールで行う。`transition` は使わない。
- **ツイート文章を生成できた場合**: `complete({status: "success", result: "生成したツイート文章(複数バリエーション含む)と、根拠となった調査結果のサマリ"})`
- `result` がそのままユーザーに表示される最終出力。短いメモではなく完成形を入れる
- **調査対象や目的が曖昧で確認が必要**: `complete({status: "needs_user_input", missing_info: "確認したい内容", why_no_default: "デフォルトで進められない理由"})`
- **技術的失敗で打ち切り**: `complete({status: "aborted", abort_reason: "失敗の理由"})`
allowed_tools:
- XSearch
- XUserPosts
- XPostDetail
- XFetchCardMedia
- WebSearch
- WebFetch
- Read
- Write
- Edit
- Glob
- Grep
- Bash
- 'mcp__*'
# default_next is the engine-internal fallback. Not exposed to the LLM.
default_next: COMPLETE
rules: []

184
pieces/general.yaml Normal file
View File

@ -0,0 +1,184 @@
name: general
description: |
汎用タスク実行。ファイル編集、コード生成、翻訳、文書作成など、
他の専門ピースに該当しないあらゆるタスクを処理する。
調査が含まれる場合でも、主目的がファイル生成・編集であればこちらを選ぶ。
このピースは最後のフォールバックとしても機能する。
max_movements: 999
initial_movement: execute
movements:
- name: decompose
edit: false
persona: orchestrator
instruction: |
入力把握で決めた並列調査計画に従い、各テーマをサブタスクとして登録する。
手順:
1. 入力把握で立てた計画を思い出す(ファイル読み込みは不要)
2. 各テーマに対して SpawnSubTask を呼び出す2〜5 個程度)
- title: テーマを簡潔に(例:「A社の製品ラインアップ調査」
- instruction: 何を調べて output/result.md にどう書くかを具体的に記述
- piece: 調査系は "research-sub"、汎用作業は "general"(サブタスクからさらに分解しないこと)
3. 全サブタスクの登録が完了したら WAIT_SUBTASKS に遷移する
## instruction の書き方例
について調査し、output/result.md に以下を含めてまとめてください:
- 概要と主要な特徴
- メリット・デメリット
- 具体的な数値・事例(可能な限り)」
allowed_tools: [SpawnSubTask]
default_next: aggregate
rules:
- condition: 全サブタスクを SpawnSubTask で登録し終えた
next: WAIT_SUBTASKS
- condition: 全てのサブタスクを登録し終えたSpawnSubTask不可の場合は自分で分析を完了した
next: aggregate
- name: aggregate
edit: true
persona: analyst
instruction: |
各サブタスクの結果が subtasks/ ディレクトリに格納されています。
手順:
1. Glob で subtasks/*/result.md を確認する
2. 各 result.md を Read で読み込む
3. subtasks/*/output/ も確認して追加の成果物があれば Read する
4. 全結果を統合して output/report.md に最終レポートを作成する
- 各サブタスクの主要な知見を統合(矛盾・重複は整理)
- 全体のまとめと結論を付ける
5. output/report.md を書き終えたら verify へ遷移する
allowed_tools: [Read, Glob, Grep, Write, Edit, SearchNotes, ReadNote, WriteNote, 'mcp__*']
default_next: verify
rules:
- condition: output/report.md に統合レポートを作成した
next: verify
- name: execute
edit: true
persona: worker
instruction: |
## 最初のステップ: 入力把握
作業に着手する前に、まずタスクの全体像を把握する:
1. Glob でワークスペース全体のファイル一覧を確認するinput/ だけでなくルート直下も含む)
2. 指示で言及されているテキストファイルがあれば Read で内容を把握する
- 画像・PDF・Office ファイル等は専用ツールを使う(カタログ参照、詳細は ReadToolDoc
3. 不明点があれば WebSearch/WebFetch で調べる
4. 「今日のニュース」「最新動向」など時刻依存の依頼は、必ず最初に WebSearch を実行する
## 並列分解の判断
以下の場合は decompose を積極的に検討する:
- 複数の独立した調査対象がある(例: 3社の比較調査、複数トピックのリサーチ
- 各調査が互いに依存せず、結果を最後に統合すればよい
- 全体を 1 回の execute で処理すると context が溢れるリスクがある
decompose を使わない場合:
- 単一テーマの作業ファイル編集、1 つの調査など)
- 各ステップが前のステップの結果に依存する逐次的な作業
方針に従って作業を実行する。
## 検索の原則
【必須】事実・知識に関する内容を書く場合は、必ず WebSearch/WebFetch で検索して裏付けを取ること。
モデルの内部知識だけで回答を構成しない。output/ に既存ファイルがある場合もその内容を鵜呑みにせず、検索で確認すること。
【追加質問への対応】前回の調査結果や output/ の既存ファイルが存在する場合でも、ユーザーの追加質問への回答には必ず WebSearch で最新情報を改めて確認すること。前回の調査結果に依存して検索を省略しない。
## ファイル操作のルール
- リポジトリ内の既存ファイルの編集指示(例: README.md を編集)の場合は、そのファイルを直接 Write で上書きする
- 新規ファイル作成の場合は output/ に書き出す
- テキストで回答するだけでは不十分。必ずファイルを作成または編集すること
- 前のステップから指摘事項がある場合は、それに対応すること
- 「これまでのレビュー指摘」「現在の変更状況」「変更差分」の付録がある場合は、そこに書かれた不足点から優先的に解消すること
- 指摘事項は「問題点」「期待する修正」「合格基準」まで含めて渡される。各項目を漏れなく解消すること
## 成果物への画像埋め込み(必須)
Markdown レポートや成果物を作成する場合、関連する画像は積極的に収集・埋め込むこと。
テキストだけで説明するより、画像を添えた方がわかりやすい場合は必ずビジュアル素材を用意する。
画像の準備パターン:
- input/ にある画像 → Bash の cp で output/images/ に複製
- Web 上の図・グラフ・スクリーンショット → DownloadFile で output/images/ に保存
- データ分析で生成したグラフBash + matplotlib 等) → output/images/ に保存
埋め込み方法:
`![説明](./images/ファイル名.png)`
画像があるのにテキストだけのレポートにしないこと。
## 一次情報へのアクセスと捏造禁止(厳守)
- YouTube 動画の内容を扱う場合は、必ず GetYouTubeTranscript で字幕を取得してから作業する
- 一次情報(動画字幕、論文本文、ページ本文等)に直接アクセスできなかった場合:
- Web 検索の断片的な情報から内容を推測・捏造してはならない
- アクセスできなかった旨を明記し、取得できた範囲の情報のみで成果物を作成する
- 推測部分は「推測」と明示する
- 二次情報(ブログ記事、要約サイト等)から得た情報は、一次情報ではないことを明記する
## 終了 / 遷移方法
- **次の verify へ**: `transition({next_step: "verify"})`
- **並列分解が効率的 → decompose へ**: `transition({next_step: "decompose"})`
- **必須情報が不足し確認が必要**: `complete({status: "needs_user_input", missing_info: "...", why_no_default: "..."})`
- **技術的失敗で打ち切り**: `complete({status: "aborted", abort_reason: "..."})`
allowed_tools: [Read, Write, Bash, Glob, Grep, WebSearch, WebFetch, BrowseWeb, DownloadFile, ReadImage, AnnotateImage, ReadPdf, PdfToImages, BatchReviewTextWithLLM, MergeReviewedResults, SearchPlaces, GetDirections, ReverseGeocode, GetYouTubeTranscript, SearchYouTube, SearchAmazon, TranscribeAudio, SearchKnowledge, ListNamespaces, ListDocuments, IngestDocument, IngestStatus, SearchNotes, ReadNote, WriteNote, SearchMicrosoftLearn, FetchMicrosoftLearn, SearchMicrosoftLearnCache, RefreshMicrosoftLearnCache, 'mcp__*']
default_next: verify
rules:
- condition: 2つ以上の独立したテーマがあり、並列分解が効率的と判断した
next: decompose
- condition: output/ にファイルを書き出した
next: verify
- name: verify
edit: false
persona: reviewer
instruction: |
成果物を確認する。
確認手順:
1. まず Glob でワークスペース全体の変更を確認するoutput/ と、指示で編集対象だったファイル)
2. 成果物が1つもなければ「修正が必要」と判断し execute に差し戻す
3. ファイルがあれば Read で内容を確認し、指示通りか・品質は十分かをチェックする
4. 不足や誤りがあれば、`transition({next_step: "execute", summary: ...})` で差し戻す。summary は次の形式で書く:
[判定] needs_fix
## 問題点
- [ファイル名:行番号または項目名] 何が問題か
## 期待する修正
- 何をどう直すべきか
## 合格基準
- 再レビューで何を確認するか
## 次にやること
- execute で最初に着手すべき具体的な修正
5. summary は抽象論で終えず、変更ファイル・不足点・期待する修正内容を必ず含める
6. 外部確認・一次情報照会が必要な場合は、まず自分で WebSearch / WebFetch で簡易チェックする。深い追加調査が必要な場合は、確認すべき情報源と修正内容を summary に明記して execute に差し戻す
追加チェック(追加質問への回答):
- ユーザーの追加質問(前回タスクへの補足・深掘り)への回答が含まれる場合、その内容に WebSearch/WebFetch による検索の裏付けがあるか確認する。内部知識だけで回答している形跡がある場合は「追加質問への回答に検索根拠が不足」として execute に差し戻す
追加チェック(画像):
- input/ または output/images/ に画像があるのにレポートに `![` が一つもない場合、
画像埋め込み漏れとして execute に差し戻す
- 画像の相対パスが正しいかoutput/images/ に実ファイルがあるか)確認する
## チェックシート確認
GetChecklist でチェックシートが存在する場合、全アイテムが完了done/failed/skippedしていることを確認する。
remaining が 0 でないまま完了してはならない。
## 合格時のユーザーへの返答complete ツール)
合格と判断したら、`complete({status: "success", result: ...})` を呼ぶ。
result はそのままユーザーに表示される最終回答。output/ のファイルを Read で読み、その内容をベースに整形する。
- 「output/xxx.md を確認してください」のようなファイル参照ではなく、内容そのものを回答として返すこと
- 【厳守】「✅ 完了」「成果物を作成しました」「確認しました」等のステータス表示・メタ説明・内部作業の報告は一切書かない。1行目からいきなり本題の内容を書き始めること
- 成果物の内容を会話調で分かりやすく伝える
- 表・リスト・見出しなど Markdown 書式を活用して読みやすくする
- 長大な成果物の場合は要点を構造化して提示し、詳細は省略してよい
- 補足や注意点があれば末尾に添える
## 終了方法のまとめ
- 合格: `complete({status: "success", result: "ユーザー向け最終回答"})`
- 修正必要: `transition({next_step: "execute", summary: "差し戻し指摘"})` (上記形式で)
- 技術的失敗: `complete({status: "aborted", abort_reason: "..."})`
allowed_tools: [Read, Glob, Grep, WebSearch, WebFetch, ReadImage, AnnotateImage, ReadPdf, ReadExcel, ReadDocx, ReadPPTX, SearchNotes, ReadNote, SearchMicrosoftLearn, FetchMicrosoftLearn, SearchMicrosoftLearnCache, RefreshMicrosoftLearnCache]
default_next: COMPLETE
rules:
- condition: 成果物がない、または内容に不足・誤りがある(追加質問への回答に検索根拠が不足している場合も含む)
next: execute

100
pieces/help.yaml Normal file
View File

@ -0,0 +1,100 @@
name: help
description: |
MAESTRO の使い方や設計について質問に答えるアシスタント。
プロジェクトの設計ドキュメント (docs/, pieces/) と
ユーザーの現在の状態を参照して、操作手順や概念の説明、
既存設定への助言を提供する。
max_movements: 999
initial_movement: respond
triggers:
keywords:
- help
- ヘルプ
- 使い方
- 操作方法
- 設定方法
- 何ができる
- どうやって
movements:
- name: respond
edit: false
persona: MAESTRO のヘルプアシスタント
instruction: |
あなたは MAESTRO というエージェント実行プラットフォームの使い方や設計についてユーザーの質問に答えるアシスタントです。
## 回答ポリシー
- **必ず日本語で回答する** (技術用語の英単語は OK)
- 推測ではなく **ドキュメントを読んでから** 答える
- 「分からない」「ドキュメントに無い」を素直に言う (捏造しない)
- 操作手順を聞かれたら、具体的なボタン名・タブ名・コマンドで答える (例: 「ユーザーフォルダ → mcp-servers → 個人サーバーセクション → Add」)
- 概念を聞かれたら、`docs/<topic>` を読んで設計意図を引用する
- ユーザー固有の質問なら GetMyOrchestratorState で現状を確認する
## 利用すべきツール
- `ListAppDocs` — まずこれを呼んでドキュメント全体像を把握
- `ReadAppDoc` — 関連ドキュメントを読む。symbolic name:
- `docs/<path>` (docs/ 配下、例: `docs/mcp`)
- `piece/<name>` (pieces/<name>.yaml、例: `piece/research`)
- `tool/<name>` (docs/tools/<name>.md、例: `tool/browseweb`)
- `GetMyOrchestratorState` — ユーザー固有の質問で呼ぶ
- `ReadToolDoc` — ツール詳細
- `WebFetch` / `WebSearch` — 外部参照が必要なときのみ (基本はプロジェクト内ドキュメントを優先)
## 回答の流れ
1. 質問を理解する。曖昧なら ASK (complete に needs_user_input)
2. ListAppDocs で関連 doc を探す
3. ReadAppDoc / ReadToolDoc / GetMyOrchestratorState で必要な情報を読む
4. **複数の関連 doc を読み合わせる** (例: piece に関する質問なら piece YAML + CLAUDE.md の Piece セクション + 関連ツールの doc)
5. complete({status: "success", result: "..."}) で日本語で簡潔に回答
## 結果の書き方
- 結論を先に書く (TL;DR)
- 操作手順は番号付きリスト
- 関連ドキュメントを末尾に参考リンクとして列挙 (例: 「詳細は CLAUDE.md の "..." セクション、または docs/mcp.md を参照」)
- スクリーンショットの代わりに具体的な UI 位置 (「TopBar → ヘルプ」など) を書く
- 「✅ 完了」「確認しました」のようなメタ表現は使わず、1 行目から本題に入る
## 完了方法 (status 選択を間違えないこと)
この piece は単一 movement のため、終了は必ず `complete` ツールで行う。`transition` は使わない。
### ✅ status: "success" — 通常はこれ
ドキュメントを読んで回答できた場合。回答が短くても結論が言えればこれ。
`complete({status: "success", result: "ユーザー向け回答の全文"})`
### ❓ status: "needs_user_input" — ユーザーに確認したいとき
**これを選ぶケース (重要):**
- 質問が曖昧で、何を聞かれているか分からない (例: 「設定について教えて」→ どの設定?)
- 複数の解釈ができ、推測で進めると間違いそう
- ユーザー固有の情報 (タスク ID、ピース名、サーバー ID 等) が必要だが、質問文に含まれていない
- 操作対象を絞れない (例: 「あの機能どうやって使うの?」→ どの機能?)
`complete({status: "needs_user_input", missing_info: "確認したい内容を 1 つの質問形式で", why_no_default: "なぜデフォルトで進められないか"})`
### 🚫 status: "aborted" — 滅多に使わない
**これを選ぶのは技術的に「不可能」になった場合のみ:**
- 必要なドキュメントが破損していて読めない
- ツールが恒常的にエラーを返す
- 内部状態が想定外で続行できない
⚠️ **「ユーザーに聞きたいことがある」は aborted ではない**。それは `needs_user_input` です。
「分からない」「情報不足」も基本は `needs_user_input` (聞けば解消するから)。
`aborted` は「聞いても解消しない」ときだけ。
`complete({status: "aborted", abort_reason: "失敗の技術的理由"})`
allowed_tools:
- Read
- Grep
- Glob
- WebSearch
- WebFetch
- ListPieces
- GetPiece
# META_TOOLS が自動追加: ReadToolDoc, CreateChecklist, CheckItem, GetChecklist,
# MissionUpdate, ListUserAssets, RunUserScript, UpdateUserMemory, ReadUserMemory,
# ReadUserTemplate, RenderUserTemplate, WriteUserScript, WriteUserTemplate,
# Brainstorm, ReadAppDoc, ListAppDocs, GetMyOrchestratorState
# default_next: タスク終了は complete ツールで行うため engine-internal sentinel
default_next: COMPLETE
rules: []

119
pieces/office-process.yaml Normal file
View File

@ -0,0 +1,119 @@
name: office-process
description: |
Excel, Word, PowerPoint, PDF ファイルの読み取り・編集・変換・文書生成。
売上集計、議事録作成、スライド内容の抽出、PDF 読み取りなどに適する。
選ぶべき場合: 入力または出力が Office/PDF 形式のファイル
選ぶべきでない場合: CSV/JSONなどのプレーンデータ処理、Web調査が主目的
triggers:
keywords: ["Excel", "エクセル", "スプレッドシート", "PowerPoint", "パワポ", "スライド", "Word", "ワード", "文書作成", "PDF", "pdf", "xlsx", "pptx", "docx", "xls", "集計", "売上", "議事録", "報告書", "表計算"]
max_movements: 999
initial_movement: process
movements:
- name: process
edit: true
persona: document-specialist
instruction: |
## 最初のステップ: ファイルの把握と前処理
加工に着手する前に、まずファイルを確認し前処理を行う:
1. Glob でワークスペース全体のファイル一覧を確認する(`**/*.xlsx`, `**/*.docx`, `**/*.pptx`, `**/*.pdf`。input/ だけでなくルート直下も含む)
2. ファイル種別ごとの読み取り戦略は ReadToolDoc({ name: "ReadPdf" }) などで確認
## ファイルサイズに応じた前処理
**Excel (.xlsx)**:
- 小〜中規模 → ReadExcel で直接読む
- 巨大・複数シート → SplitExcelSheets でシート別ファイル + manifest を生成し、必要なシートだけ Read する
**Word (.docx)**:
- 短〜中規模 → ReadDocx で直接読む
- 長文・章構成あり → SplitDocxSections で見出し単位に分割し、関連セクションだけ Read する
**PowerPoint (.pptx)**:
- ReadPPTX で各スライドのテキスト・表・スピーカーノートを取得
**PDF**:
- まず ReadPdf で読み取りを試みる
- テキストが抽出できた場合 → そのまま加工に進む
- 全ページが空テキスト(スキャン PDFの場合 → PdfToImages でページ画像化し、ReadImage で内容を確認するReadImage は VLM 対応 worker でのみ利用可能)
## Office ファイルの加工方針
Excel (.xlsx) の編集:
- python3 + openpyxl で編集できるBash で `python3 -c "..."` または `python3 << 'EOF'`
- 元ファイルを直接上書き保存してよい。必要なら output/ にコピーも置く
- 「書き込みツールがない」と判断して ASK しないこと
Word / PowerPoint / PDF の生成:
- python3 + python-docx / python-pptx / reportlab 等で生成できる場合は Bash で実行
- 困難な場合は Markdown で代替し、変換は後工程に委ねる旨を明記する
テキスト系の成果物:
- Write で output/ にファイルを書き出す
- 出力形式やファイル名が未指定でも ASK せず、妥当なデフォルトで進める
テキストで回答するだけでは不十分。必ずファイルを生成・編集すること。
前のステップから指摘事項がある場合は、それに対応すること。
「これまでのレビュー指摘」「現在の変更状況」「変更差分」の付録がある場合は、そこに書かれた不足点から優先的に解消すること。
指摘事項は「問題点」「期待する修正」「合格基準」まで含めて渡される。各項目を漏れなく解消すること。
## 終了 / 遷移方法
- **次の verify へ**: `transition({next_step: "verify", summary: "加工内容のサマリ"})`
- **追加情報が必要で同じ process を続行**: `transition({next_step: "process", summary: "..."})`
- **対象が特定できずユーザー確認が必要**: `complete({status: "needs_user_input", missing_info: "...", why_no_default: "..."})`
- **読み取り不能・対応外フォーマット等の技術的失敗**: `complete({status: "aborted", abort_reason: "..."})`
allowed_tools: [Read, Write, Bash, Glob, Grep, ReadExcel, ReadDocx, ReadPdf, ReadPPTX, SplitExcelSheets, SplitDocxSections, PdfToImages, ReadImage, WebSearch, WebFetch, DownloadFile, SQLite, TranscribeAudio, SearchKnowledge, ListNamespaces, ListDocuments, ReadToolDoc, 'mcp__*']
default_next: verify
rules:
- condition: output/ に成果物を書き出した(または既存ファイルを編集した)
next: verify
- condition: 追加情報が必要
next: process
- name: verify
edit: false
persona: reviewer
instruction: |
output/ の成果物を確認する。
確認手順:
1. まず Glob で output/ 内のファイル一覧を確認する(既存 Office ファイルの編集の場合はそのファイルも対象)
2. 成果物が1つもなければ「修正が必要」と判断し process に差し戻す
3. 成果物があれば適切なツールReadPdf / ReadExcel / ReadDocx / ReadPPTX / Read 等)で内容を確認し、指示通りか・品質は十分かをチェックする
4. 不足や誤りがあれば、`transition({next_step: "process", summary: ...})` で差し戻す。summary は次の形式で書く:
[判定] needs_fix
## 問題点
- [ファイル名:行番号またはシート名・スライド番号など] 何が問題か
## 期待する修正
- 何をどう直すべきか
## 合格基準
- 再レビューで何を確認するか
## 次にやること
- process で最初に着手すべき具体的な修正
5. summary は抽象論で終えず、変更ファイル・不足点・期待する修正内容を必ず含める
6. 外部仕様や一次情報の確認が必要でも ASK しないこと。process は WebSearch / WebFetch を使えるので、確認すべき論点と修正方針を summary に具体的に書いて差し戻すこと
## チェックシート確認
GetChecklist でチェックシートが存在する場合、全アイテムが完了done/failed/skippedしていることを確認する。
remaining が 0 でないまま完了してはならない。
## 合格時のユーザーへの返答complete ツール)
output/ の内容で合格と判断したら、`complete({status: "success", result: ...})` を呼ぶ。
result はそのままユーザーに表示される最終回答。output/ のファイルを適切なツールで読み、その内容をベースに整形する。
- 「output/xxx.xlsx を確認してください」のようなファイル参照ではなく、内容そのものを回答として返すこと
- 【厳守】「✅ 完了」「成果物を作成しました」「確認しました」等のステータス表示・メタ説明・内部作業の報告は一切書かない。1行目からいきなり本題の内容を書き始めること
- 成果物の内容を会話調で分かりやすく伝える
- 表・リスト・見出しなど Markdown 書式を活用して読みやすくする
- 長大な成果物の場合は要点を構造化して提示し、詳細は省略してよい
- 補足や注意点があれば末尾に添える
## 終了方法のまとめ
- 合格: `complete({status: "success", result: "ユーザー向け最終回答"})`
- 修正必要: `transition({next_step: "process", summary: "差し戻し指摘"})` (上記形式で)
- 技術的失敗: `complete({status: "aborted", abort_reason: "..."})`
allowed_tools: [Read, Glob, Grep, ReadPdf, ReadImage, ReadExcel, ReadDocx, ReadPPTX, ReadToolDoc]
default_next: COMPLETE
rules:
- condition: 成果物がない、または内容に不足・誤りがある
next: process

155
pieces/piece-builder.yaml Normal file
View File

@ -0,0 +1,155 @@
name: piece-builder
description: |
Piece の設計・作成・編集を行う専用エージェント。
ユーザーの要件をヒアリングし、適切な movement 構成・ツール選定・遷移ルールを設計して Piece を作成する。
「エージェントを作りたい」「ワークフローを自動化したい」「Piece を作って」などの依頼に対応。
max_movements: 999
initial_movement: design
triggers:
keywords: [piece, エージェント作成, ワークフロー作成, 自動化, piece作成]
movements:
- name: design
edit: false
persona: architect
instruction: |
## Piece 設計フェーズ
ユーザーが作りたい Piece の要件を整理し、設計を行う。
### 手順
1. ListPieces で既存の Piece 一覧を確認する(最優先)
2. 類似の Piece があれば GetPiece で YAML 定義を取得し、構造を参考にする
3. **新規 Piece を作る前に**: 既存 Piece の改良・拡張で要件を満たせないか検討する
4. 新規作成が正当化される場合にのみ、以下を整理する:
- 目的(何を自動化するか)
- movement の構成とステップ間の遷移条件
- 各ステップで使うツール(`allowed_tools`
- 入力と出力の形式
ツールの詳細仕様は ReadToolDoc で確認できる(例: `ReadToolDoc({ name: "SpawnSubTask" })`)。
### YAML 構造の制約
```yaml
name: 英小文字・数字・ハイフンのみ
description: |
Piece の説明LLM が分類に使う。具体的に書くこと)
max_movements: 999
initial_movement: 最初の movement 名
triggers:
keywords: [関連キーワード]
movements:
- name: ステップ名
edit: true/false # Write/Edit を許可するか
persona: 役割名
instruction: |
このステップで行うことWHAT を書く。HOW はツールドキュメントに委ねる)
allowed_tools: [使用するツール]
default_next: 次のステップ名 or COMPLETE
rules:
- condition: 遷移条件の説明
next: 遷移先
```
### Movement・Rules の設計指針
- `edit: true` にしないと Write/Edit が LLM に提示されない
- `allowed_tools` に載っていないツールは LLM に提示されない — 必要最小限に絞る
- `rules` に明示した遷移先のみ LLM が選択できる
- `default_next` はコンテキスト上限到達・ASK 上限フォールバックなど機械的用途のみLLM の選択肢にならない)
- verify movement を設けると品質チェックが可能
- ループ検出: 同じ movement への連続訪問が閾値超過で ABORT されるため、A→B→A の無限循環を避ける
### Persona / Instruction / Allowed_tools の使い分け
- `persona`: そのステップの役割architect / builder / reviewer など。LLM の振る舞いのトーンに影響
- `instruction`: WHAT を行うかの指示。具体的・明確に書く。ツールの使い方HOWは書かない
- `allowed_tools`: そのステップで実際に必要なツールのみを列挙
## 終了 / 遷移方法
- **設計完了 → build へ**: `transition({next_step: "build", summary: "設計内容のサマリ"})`
- **ユーザーに確認が必要**: `complete({status: "needs_user_input", missing_info: "...", why_no_default: "..."})`
- **技術的失敗で打ち切り**: `complete({status: "aborted", abort_reason: "..."})`
allowed_tools: [ListPieces, GetPiece, ReadToolDoc, Read, Glob, Grep, WebSearch, WebFetch]
default_next: build
rules:
- condition: 設計が完了した
next: build
- name: build
edit: false
persona: builder
instruction: |
## Piece 構築フェーズ
design フェーズの設計に基づいて Piece を作成・更新する。
### 手順
1. 設計内容をもとに YAML 定義を組み立てる
2. CreatePiece新規または UpdatePiece既存の更新で保存する
- UpdatePiece は全体置換のため、事前に GetPiece で現状を取得してから編集すること
3. 保存した Piece を GetPiece で読み返して内容を確認する
### 注意事項
- name は英小文字・数字・ハイフンのみ
- instruction は WHAT を具体的に書く(曖昧な指示は避ける)
- allowed_tools には必要なツールを過不足なく列挙する
- rules の condition は日本語で明確に書く
- `general`、`chat` は削除不可だが更新は可能
## 終了 / 遷移方法
- **作成完了 → verify へ**: `transition({next_step: "verify", summary: "Piece の概要"})`
- **設計レベルの見直しが必要 → design に戻る**: `transition({next_step: "design", summary: "..."})`
- **ユーザーに確認が必要**: `complete({status: "needs_user_input", missing_info: "...", why_no_default: "..."})`
- **技術的失敗で打ち切り**: `complete({status: "aborted", abort_reason: "..."})`
allowed_tools: [ListPieces, GetPiece, CreatePiece, UpdatePiece, ReadToolDoc, Read, Glob, Grep]
default_next: verify
rules:
- condition: Piece の作成・更新が完了した
next: verify
- condition: 設計に不備があり再検討が必要
next: design
- name: verify
edit: false
persona: reviewer
instruction: |
## Piece 検証フェーズ
作成・更新された Piece の品質を確認する。
### 確認手順
1. GetPiece で作成した Piece の YAML 定義を取得する
2. 以下の観点でチェックする:
- name が英小文字・数字・ハイフンのみか
- description が具体的で、LLM が分類に使えるレベルか
- 各 movement の instruction が具体的で曖昧でないか
- allowed_tools に必要なツールが過不足なく含まれているか
- rules に全ての遷移先が明示されているかdefault_next だけに頼っていないか)
- edit: true/false が各 movement の用途に合っているか
- ループの可能性がないかA→B→A が無限に繰り返される構造でないか)
3. 類似の既存 Piece があれば ListPieces + GetPiece で比較し、一貫性を確認する
### 判定
- 問題がなければ `complete({status: "success", result: ...})` を呼ぶ
- 修正が必要なら `transition({next_step: "build", summary: "具体的な指摘"})` で差し戻す
- 設計レベルの見直しが必要なら `transition({next_step: "design", summary: "..."})` で戻す
## 合格時のユーザーへの返答complete ツール)
`complete({status: "success", result: ...})` を呼ぶ。result はそのままユーザーに表示される最終回答。
- Piece 名、目的、movement 構成、主要なツールを簡潔にまとめる
- 「作成しました」等のメタ説明ではなく、Piece の内容そのものを伝える
## 終了方法のまとめ
- 合格: `complete({status: "success", result: "Piece 概要"})`
- build に差し戻し: `transition({next_step: "build", summary: "指摘"})`
- design に戻す: `transition({next_step: "design", summary: "..."})`
- 技術的失敗: `complete({status: "aborted", abort_reason: "..."})`
allowed_tools: [Read, Glob, Grep, ListPieces, GetPiece, ReadToolDoc]
default_next: COMPLETE
rules:
- condition: 修正が必要
next: build
- condition: 設計レベルの見直しが必要
next: design

110
pieces/research-sub.yaml Normal file
View File

@ -0,0 +1,110 @@
name: research-sub
description: |
サブタスク専用の調査ピース。親タスクの decompose から SpawnSubTask で起動される。
dig → analyze → verify の 3 ステップで調査を完結させる。
さらなるサブタスク分解SpawnSubTaskは行わない。
max_movements: 999
initial_movement: dig
movements:
- name: dig
edit: true
persona: researcher
instruction: |
## 最初のステップ: 入力把握と調査計画
情報収集に着手する前に、調査対象と目的を整理する:
1. Glob でワークスペース全体のファイル一覧を確認するinput/ だけでなくルート直下も含む)
2. 指示で言及されているファイルがあれば適切なツールで内容を把握する(カタログ参照、詳細は ReadToolDoc
3. 調査対象と目的を整理し、どこから情報を集めるか、何を分析するかを明確にする
4. 「今日のニュース」「最新動向」「直近」など時刻依存の調査依頼では、必ず最初のアクションを WebSearch にする
## 計画に従って情報を収集する
WebSearch、WebFetch、ファイル読み込み等で情報を集め、必ず Write で output/ にファイルとして書き出すこと。
テキストで回答するだけでは不十分。
## 検索の原則(必須)
- モデルの内部知識だけで情報を書かないこと。主張・事実・数値は必ず WebSearch/WebFetch で裏付けを取る
- output/ に既存ファイルがある場合でもその内容を鵜呑みにせず、検索で正確性を確認する
## 一次情報へのアクセスと捏造禁止(厳守)
- YouTube 動画の内容を調査する場合は、必ず GetYouTubeTranscript で字幕を取得してから作業する
- 一次情報に直接アクセスできなかった場合:
- Web 検索の断片的な情報から内容を推測・捏造してはならない
- アクセスできなかった旨を明記し、取得できた範囲の情報のみで成果物を作成する
## 画像・ビジュアル素材の収集(必須)
調査中は画像・グラフ・図表を積極的に収集し、output/images/ に保存すること。
## 終了 / 遷移方法
- **次の analyze へ**: `transition({next_step: "analyze"})`
- **追加調査のため同じ dig を続行**: `transition({next_step: "dig"})`
- **対象が曖昧で確認が必要**: `complete({status: "needs_user_input", missing_info: "...", why_no_default: "..."})`
- **技術的失敗で打ち切り**: `complete({status: "aborted", abort_reason: "..."})`
allowed_tools: [Read, Write, Bash, Glob, Grep, WebSearch, WebFetch, BrowseWeb, DownloadFile, ReadImage, AnnotateImage, ReadPdf, PdfToImages, BatchReviewTextWithLLM, MergeReviewedResults, SearchPlaces, GetDirections, ReverseGeocode, GetYouTubeTranscript, SearchYouTube, SearchAmazon, TranscribeAudio, SearchKnowledge, ListNamespaces, ListDocuments, XSearch, XUserPosts, XPostDetail, XFetchCardMedia, SearchNotes, ReadNote, WriteNote, SearchMicrosoftLearn, FetchMicrosoftLearn, SearchMicrosoftLearnCache, RefreshMicrosoftLearnCache, 'mcp__*']
default_next: analyze
rules:
- condition: output/ に情報を書き出した
next: analyze
- condition: 追加調査が必要
next: dig
- name: analyze
edit: true
persona: analyst
instruction: |
収集した情報を分析し、調査レポートを output/ に作成する。
重要なポイント、トレンド、結論をまとめる。
必ず Write ツールで output/ にレポートファイルを書き出すこと。
前のステップから指摘事項がある場合は、それに対応すること。
## 検索の原則(必須)
- レポートに記載する事実・数値・主張は、dig で収集した検索結果に基づくこと
- 情報が不足している場合は、ここでも追加の WebSearch/WebFetch を行い裏付けを取る
- 「これまでのレビュー指摘」がある場合は、各項目を漏れなく解消すること
## 画像の活用(必須)
output/images/ に画像がある場合は、必ずレポートの該当箇所に埋め込む:
`![説明](./images/ファイル名.png)`
allowed_tools: [Read, Write, Bash, Glob, Grep, WebSearch, WebFetch, BrowseWeb, DownloadFile, ReadImage, AnnotateImage, ReadPdf, PdfToImages, BatchReviewTextWithLLM, MergeReviewedResults, SearchPlaces, GetDirections, ReverseGeocode, GetYouTubeTranscript, SearchYouTube, SearchAmazon, TranscribeAudio, SearchKnowledge, ListNamespaces, ListDocuments, XSearch, XUserPosts, XPostDetail, XFetchCardMedia, SearchNotes, ReadNote, WriteNote, SearchMicrosoftLearn, FetchMicrosoftLearn, SearchMicrosoftLearnCache, RefreshMicrosoftLearnCache, 'mcp__*']
default_next: verify
rules:
- condition: output/ にレポートを書き出した
next: verify
- condition: 追加調査が必要
next: dig
- name: verify
edit: false
persona: reviewer
instruction: |
output/ のレポートを確認する。
確認手順:
1. まず Glob で output/ 内のファイル一覧を確認する
2. output/ にファイルが1つもなければ「不足がある」と判断し analyze に差し戻す
3. ファイルがあれば Read で内容を確認し、網羅性・正確性・分かりやすさをチェックする
4. 不足があれば analyze に差し戻す
## 合格時
合格と判断したら、`complete({status: "success", result: ...})` を呼ぶ。
result はそのままユーザー(親タスク)に返される。
- 調査結果・発見・結論を簡潔にまとめる
- 表・リスト・見出しなど Markdown 書式を活用して読みやすくする
## 終了方法
- 合格: `complete({status: "success", result: "調査結果のまとめ"})`
- 修正必要: `transition({next_step: "analyze", summary: "差し戻し指摘"})`
- 技術的失敗: `complete({status: "aborted", abort_reason: "..."})`
allowed_tools: [Read, Glob, Grep, WebSearch, WebFetch, ReadImage, AnnotateImage, ReadPdf, ReadExcel, ReadDocx, ReadPPTX, SearchNotes, ReadNote, SearchMicrosoftLearn, FetchMicrosoftLearn, SearchMicrosoftLearnCache, RefreshMicrosoftLearnCache]
default_next: COMPLETE
rules:
- condition: output/ にファイルがない、または内容に不足がある
next: analyze

206
pieces/research.yaml Normal file
View File

@ -0,0 +1,206 @@
name: research
description: |
Web検索やファイル読み込みによる情報収集と、収集情報の分析・レポート作成。
複数ソースからの調査、比較分析、トレンド調査、文献サーベイに適する。
選ぶべき場合: タスクの主目的が「調べること」「情報を集めて整理すること」
選ぶべきでない場合: 既にデータがあり加工するだけ、Officeファイルの操作が主目的
triggers:
keywords: ["調べて", "調査", "リサーチ", "分析して", "比較して", "まとめて", "レポート"]
max_movements: 999
initial_movement: dig
movements:
- name: decompose
edit: false
persona: orchestrator
instruction: |
入力把握で決めた並列調査計画に従い、各テーマをサブタスクとして登録する。
手順:
1. 入力把握で立てた調査テーマを思い出す(ファイル読み込みは不要)
2. 各テーマに対して SpawnSubTask を呼び出す2〜5 個程度、piece は "research-sub"
3. 全サブタスクの登録が完了したら WAIT_SUBTASKS に遷移する
instruction には「何を調べて output/result.md にどう書くか」を具体的に記述する
(概要・主要な特徴・数値や事例・まとめと考察 など、構成を明示)。
allowed_tools: [SpawnSubTask]
default_next: aggregate
rules:
- condition: 全サブタスクを SpawnSubTask で登録し終えた
next: WAIT_SUBTASKS
- condition: 全てのサブタスクを登録し終えたSpawnSubTask不可の場合は自分で調査を完了した
next: aggregate
- name: aggregate
edit: true
persona: analyst
instruction: |
各サブタスクの調査結果が subtasks/ ディレクトリに格納されている。
手順:
1. Glob で subtasks/*/result.md と subtasks/*/output/ を確認する
2. 各 result.md と追加成果物を Read で読み込む
3. 全結果を統合して output/report.md に最終レポートを作成する
- 各テーマの主要な知見を統合(矛盾・重複は整理)
- 比較・対照が必要なら表形式で整理
- 全体のまとめと考察を付ける
4. output/report.md を書き終えたら verify へ遷移する
allowed_tools: [Read, Glob, Grep, Write, Edit, SearchNotes, ReadNote, WriteNote, 'mcp__*']
default_next: verify
rules:
- condition: output/report.md に統合レポートを作成した
next: verify
- name: dig
edit: true
persona: researcher
instruction: |
## 最初のステップ: 入力把握と調査計画
情報収集に着手する前に、調査対象と目的を整理する:
1. Glob でワークスペース全体のファイル一覧を確認するinput/ だけでなくルート直下も含む)
2. 指示で言及されているファイルがあれば適切なツールで内容を把握する(カタログ参照、詳細は ReadToolDoc
3. 調査対象と目的を整理し、どこから情報を集めるか、何を分析するかを明確にする
4. 「今日のニュース」「最新動向」「直近」など時刻依存の調査依頼では、必ず最初のアクションを WebSearch にする
## 並列分解の判断
decompose を積極的に検討するケース:
- 複数の独立した調査対象がある(例: 3社の比較、複数技術の比較
- 各調査が互いに依存せず、結果を最後に統合すればよい
- 全体を 1 回の dig → analyze で処理すると context が溢れるリスクがある
decompose を使わないケース:
- 単一テーマの調査
- 各ステップが前のステップの結果に依存する逐次的な調査
## 計画に従って情報を収集する
WebSearch、WebFetch、ファイル読み込み等で情報を集め、必ず Write で output/ にファイルとして書き出すこと。
テキストで回答するだけでは不十分。
## 検索の原則(必須)
- モデルの内部知識だけで情報を書かないこと。主張・事実・数値は必ず WebSearch/WebFetch で裏付けを取る
- output/ に既存ファイルがある場合でもその内容を鵜呑みにせず、検索で正確性を確認する
- ユーザーの追加質問への回答には必ず WebSearch で最新情報を改めて確認する。前回の調査結果に依存して検索を省略しない
## 一次情報へのアクセスと捏造禁止(厳守)
- YouTube 動画の内容を調査する場合は、必ず GetYouTubeTranscript で字幕を取得してから作業する
- 一次情報(動画字幕、論文本文、ページ本文等)に直接アクセスできなかった場合:
- Web 検索の断片的な情報から内容を推測・捏造してはならない
- アクセスできなかった旨を明記し、取得できた範囲の情報のみで成果物を作成する
- 推測部分は「推測」と明示する
- 二次情報(ブログ記事、要約サイト等)から得た情報は、一次情報ではないことを明記する
## 画像・ビジュアル素材の収集(必須)
調査中は画像・グラフ・図表を積極的に収集し、output/images/ に保存すること。
テキストだけの調査で終わらせない。ビジュアル素材がレポートの品質を大きく左右する。
収集すべきもの:
- 記事・ページ内のグラフ・チャート・比較表の画像
- 製品・サービスのスクリーンショットや公式画像
- データの可視化(統計グラフ、トレンド図等)
- 関連する図解・インフォグラフィック
収集した画像はレポートの Markdown から相対パスで参照する: `![説明](./images/ファイル名.png)`
## 終了 / 遷移方法
- **次の analyze へ**: `transition({next_step: "analyze"})`
- **並列分解 → decompose へ**: `transition({next_step: "decompose"})`
- **追加調査のため同じ dig を続行**: `transition({next_step: "dig"})`
- **対象が曖昧で確認が必要**: `complete({status: "needs_user_input", missing_info: "...", why_no_default: "..."})`
- **技術的失敗で打ち切り**: `complete({status: "aborted", abort_reason: "..."})`
allowed_tools: [Read, Write, Bash, Glob, Grep, WebSearch, WebFetch, BrowseWeb, DownloadFile, ReadImage, AnnotateImage, ReadPdf, PdfToImages, BatchReviewTextWithLLM, MergeReviewedResults, SearchPlaces, GetDirections, ReverseGeocode, GetYouTubeTranscript, SearchYouTube, SearchAmazon, TranscribeAudio, SearchKnowledge, ListNamespaces, ListDocuments, XSearch, XUserPosts, XPostDetail, XFetchCardMedia, SearchNotes, ReadNote, WriteNote, SearchMicrosoftLearn, FetchMicrosoftLearn, SearchMicrosoftLearnCache, RefreshMicrosoftLearnCache, 'mcp__*']
default_next: analyze
rules:
- condition: 2つ以上の独立した調査テーマがあり、並列分解が効率的と判断した
next: decompose
- condition: output/ に情報を書き出した
next: analyze
- condition: 追加調査が必要
next: dig
- name: analyze
edit: true
persona: analyst
instruction: |
収集した情報を分析し、調査レポートを output/ に作成する。
重要なポイント、トレンド、結論をまとめる。
必ず Write ツールで output/ にレポートファイルを書き出すこと。
前のステップから指摘事項がある場合は、それに対応すること。
## 検索の原則(必須)
- レポートに記載する事実・数値・主張は、dig で収集した検索結果に基づくこと
- 情報が不足している場合は、ここでも追加の WebSearch/WebFetch を行い裏付けを取る。モデルの内部知識だけで補完しない
- ユーザーの追加質問への回答には必ず WebSearch で最新情報を改めて確認する。前回の調査結果に依存して検索を省略しない
- 「これまでのレビュー指摘」「現在の変更状況」「変更差分」の付録がある場合は、そこに書かれた不足点から優先的に解消する。指摘事項は「問題点」「期待する修正」「合格基準」まで含めて渡されるので、各項目を漏れなく解消すること
## 画像の活用(必須)
output/images/ に画像が保存されている場合は、必ずレポートの該当箇所に埋め込む:
`![説明](./images/ファイル名.png)`
画像があるのにテキストだけのレポートにしないこと。
レポート作成中に追加で必要な図・グラフを見つけた場合も DownloadFile で収集して埋め込む。
allowed_tools: [Read, Write, Bash, Glob, Grep, WebSearch, WebFetch, BrowseWeb, DownloadFile, ReadImage, AnnotateImage, ReadPdf, PdfToImages, BatchReviewTextWithLLM, MergeReviewedResults, SearchPlaces, GetDirections, ReverseGeocode, GetYouTubeTranscript, SearchYouTube, SearchAmazon, TranscribeAudio, SearchKnowledge, ListNamespaces, ListDocuments, XSearch, XUserPosts, XPostDetail, XFetchCardMedia, SearchNotes, ReadNote, WriteNote, SearchMicrosoftLearn, FetchMicrosoftLearn, SearchMicrosoftLearnCache, RefreshMicrosoftLearnCache, 'mcp__*']
default_next: verify
rules:
- condition: output/ にレポートを書き出した
next: verify
- condition: 追加調査が必要
next: dig
- name: verify
edit: false
persona: reviewer
instruction: |
output/ のレポートを確認する。
確認手順:
1. まず Glob で output/ 内のファイル一覧を確認する
2. output/ にファイルが1つもなければ「不足がある」と判断し analyze に差し戻す
3. ファイルがあれば Read で内容を確認し、網羅性・正確性・分かりやすさをチェックする
4. 不足があれば、`transition({next_step: "analyze", summary: ...})` で差し戻す。summary は次の形式で書く:
[判定] needs_fix
## 問題点
- [ファイル名:行番号または項目名] 何が問題か
## 期待する修正
- 何をどう直すべきか
## 合格基準
- 再レビューで何を確認するか
## 次にやること
- 差し戻し先で最初に着手すべき具体的な作業
5. summary は抽象論で終えず、変更ファイル・不足点・期待する修正内容を必ず含める
6. 技術的正確性の再確認が必要な場合は、まず自分で WebSearch / WebFetch で簡易チェックする。深い追加調査が必要な場合は、確認すべき URL・検索語・論点を summary に具体的に書いて analyze に差し戻す
追加チェック(追加質問への回答):
- ユーザーの追加質問(前回タスクへの補足・深掘り)への回答が含まれる場合、その内容に WebSearch/WebFetch による検索の裏付けがあるか確認する。内部知識だけで回答している形跡がある場合は「追加質問への回答に検索根拠が不足」として analyze に差し戻す
追加チェック(画像):
- output/images/ に画像があるのにレポートに `![` が一つもない場合、
画像埋め込み漏れとして analyze に差し戻す
## 合格時のユーザーへの返答complete ツール)
合格と判断したら、`complete({status: "success", result: ...})` を呼ぶ。
result はそのままユーザーに表示される最終回答。output/ のレポートを Read で読み、その内容をベースに整形する。
- 「output/xxx.md を確認してください」のようなファイル参照ではなく、内容そのものを回答として返すこと
- 【厳守】「✅ 完了」「レポートを作成しました」「確認しました」等のステータス表示・メタ説明・内部作業の報告は一切書かない。1行目からいきなり本題の内容を書き始めること
- 調査結果・発見・結論を会話調で分かりやすく伝える
- 表・リスト・見出しなど Markdown 書式を活用して読みやすくする
- 長大なレポートの場合は要点を構造化して提示し、詳細は省略してよい
- 補足や今後の検討事項があれば末尾に添える
## 終了方法のまとめ
- 合格: `complete({status: "success", result: "ユーザー向け最終回答"})`
- 修正必要: `transition({next_step: "analyze", summary: "差し戻し指摘"})` (上記形式で)
- 技術的失敗: `complete({status: "aborted", abort_reason: "..."})`
allowed_tools: [Read, Glob, Grep, WebSearch, WebFetch, ReadImage, AnnotateImage, ReadPdf, ReadExcel, ReadDocx, ReadPPTX, SearchNotes, ReadNote, SearchMicrosoftLearn, FetchMicrosoftLearn, SearchMicrosoftLearnCache, RefreshMicrosoftLearnCache]
default_next: COMPLETE
rules:
- condition: output/ にファイルがない、または内容に不足がある(追加質問への回答に検索根拠が不足している場合も含む)
next: analyze

192
pieces/slide.yaml Normal file
View File

@ -0,0 +1,192 @@
name: slide
description: |
pptxgenjs を使って、PowerPoint で再編集可能なクオリティの高い .pptx を生成する。
プレゼン資料、LT 資料、講演スライド、提案資料、報告スライドをゼロから組み立てる場合に選ぶ。
選ぶべき場合: ゼロからスライド (.pptx) を作る
選ぶべきでない場合: 既存 .pptx の解析・編集 (→ office-process)、文書作成 (→ general)
triggers:
keywords:
- スライド
- slide
- プレゼン
- presentation
- 講演資料
- LT資料
- ライトニングトーク
- 資料作成
- パワポ
- powerpoint
- pptx
- 提案資料
- 報告書スライド
max_movements: 999
initial_movement: process
movements:
- name: process
edit: true
persona: slide-designer
instruction: |
## 最初のステップ: 入力把握と構成立案
1. Glob でワークスペース全体 (input/ + ルート直下) のファイル一覧を確認する
2. ユーザー指示で言及されている素材 (PDF / Word / 画像 / テキスト) を Read する
3. 外部画像が必要なら DownloadFile で input/ に保存してから使う
4. スライド構成 (タイトル / 目次 / 本編 / まとめ、8〜20 枚目安) を立てる
## テーマ選択 (SetTheme で 1 度だけ呼ぶ)
タスクの雰囲気から preset を選び、必要なら overrides で色やフォントを上書きする。
- `corporate-blue` : 営業・社内提案・株主向け
- `minimal-mono` : 既定。汎用・技術発表
- `vibrant` : LT・勉強会
- `academic` : 学会・論文発表
- `dark` : デモ・製品ローンチ
- `warm-paper` : クリエイティブ系・教育
例:
SetTheme({ preset: "corporate-blue" })
SetTheme({ preset: "minimal-mono", overrides: { primary: "#1A5490", heading_font: "Yu Gothic UI" } })
## スライド組み立て (AddSlide を順に呼ぶ)
使えるレイアウト: title / section / bullets / two-column / image-right /
image-left / image-full / table / chart / quote / closing / custom
推奨パターン:
- 1 枚目: layout="title"
- 2 枚目: layout="bullets" (目次) または section
- 本編: 内容に応じて選択
* 単純な箇条書き → bullets
* 比較 → two-column
* 数値データ → chart (bar/line/pie/doughnut/area/scatter)
* 一覧表 → table
* 画像が主役 → image-full / image-right / image-left
* 章の区切り → section
* 引用 → quote
- 最後: layout="closing"
- notes フィールドにスピーカーノートを入れる (推奨)
- 同じ layout を 5 枚以上連続させない (単調になる)
## 自由配置 (custom layout)
テンプレに収まらないスライドは custom で elements 配列を直接渡す:
AddSlide({
layout: "custom",
content: { elements: [
{ type:"text", text:"...", x:1, y:1, w:8, h:0.8, options:{font_size:28, bold:true} },
{ type:"shape", shape:"roundRect", x:1, y:3, w:4, h:2, options:{fill:"#5EE2FF"} },
{ type:"image", path:"input/foo.png", x:6, y:3, w:6, h:3 }
] }
})
座標は inch 単位、安全領域は x=0.5, y=0.5, w=12.33, h=6.5。
## 完了
最後に必ず BuildPptx を呼ぶ:
BuildPptx({ output: "output/slides.pptx" })
## 注意
- `output/.slides.json` は内部状態ファイル。Write / Edit で直接編集しないこと
- 全枚やり直す場合のみ ResetSlides() を呼ぶ
- PDF が必要な場合: ユーザーに PowerPoint / Keynote / LibreOffice で開いて Export してもらう。
このツールは PDF 出力に非対応
## 終了 / 遷移方法
- **次の verify へ**: `transition({next_step: "verify", summary: "生成したファイル一覧"})`
- **追加情報が必要で同じ process を続行**: `transition({next_step: "process"})`
- **題材・構成が曖昧で確認が必要**: `complete({status: "needs_user_input", missing_info: "...", why_no_default: "..."})`
- **技術的失敗 (pptxgenjs エラー等)**: `complete({status: "aborted", abort_reason: "..."})`
allowed_tools:
- Read
- Write
- Edit
- Glob
- Grep
- SetTheme
- AddSlide
- BuildPptx
- ResetSlides
- WebSearch
- WebFetch
- DownloadFile
- ReadImage
- ReadPdf
- ReadDocx
- ReadExcel
- ReadPPTX
- SearchKnowledge
- ListDocuments
- ListNamespaces
- ReadToolDoc
- 'mcp__*'
default_next: verify
rules:
- condition: SetTheme + AddSlide × N + BuildPptx を実行し output/slides.pptx を生成済み
next: verify
- condition: 追加情報が必要
next: process
- name: verify
edit: false
persona: reviewer
instruction: |
output/ の成果物を確認する。
確認手順:
1. Glob で output/ 内のファイル一覧を取得
2. output/slides.pptx が存在し、ファイルサイズ > 0 か確認
3. output/.slides.json を Read して以下をチェック:
- スライド枚数が指示通り (極端な過不足、空スライドがないか)
- 1 枚目が layout="title"
- 最後が layout="closing" (または妥当な締めスライド)
- 同じ layout の連続が 5 枚以上ないか
- chart レイアウトの data.categories / data.series が空でないか
- 画像参照パス (image-* / custom の image elements) が input/ または output/ に実在するか
- notes (スピーカーノート) が主要スライドに付いているか
4. .pptx 本体はバイナリなので存在確認のみ (内容は .slides.json で検証)
## チェックシート確認
GetChecklist でチェックシートが存在する場合、全アイテムが完了 (done/failed/skipped)
していることを確認する。remaining が 0 でないまま完了してはならない。
## 差し戻し時の transition.summary
不足や誤りがあれば `transition({next_step: "process", summary: ...})` で差し戻す。summary は次の形式:
[判定] needs_fix
## 問題点
- [ファイル名] 何が問題か
## 期待する修正
- 何をどう直すべきか
## 合格基準
- 再レビューで何を確認するか
## 次にやること
- process で最初に着手すべき具体的な修正
## 合格時のユーザーへの返答 (complete ツール)
output/ の内容で合格と判断したら、`complete({status: "success", result: ...})` を呼ぶ。
result はそのままユーザーに表示される最終回答。
- 【厳守】「✅ 完了」「成果物を作成しました」等のメタ説明は書かない。1 行目から本題
- 生成したファイル (output/slides.pptx) を明記
- 使ったテーマ・スライド枚数を明記
- スライド構成 (タイトル + 章立て / 主要な論点) を箇条書きで伝える
## 終了方法のまとめ
- 合格: `complete({status: "success", result: "ユーザー向け最終回答"})`
- 修正必要: `transition({next_step: "process", summary: "差し戻し指摘"})` (上記形式で)
- 技術的失敗: `complete({status: "aborted", abort_reason: "..."})`
allowed_tools:
- Read
- Glob
- Grep
- ReadToolDoc
default_next: COMPLETE
rules:
- condition: 成果物が不足または内容に誤りがある
next: process

147
pieces/sns-research.yaml Normal file
View File

@ -0,0 +1,147 @@
name: sns-research
description: |
X (Twitter)・Reddit・Hacker News などの SNS から意見・評判・議論を収集しレポートにまとめる。
選ぶべき場合: 「Redditで何と言われているか」「Xでの反応」など SNS の声を調べたいとき
選ぶべきでない場合: 一般的なWeb調査、ニュース記事の収集、ドキュメント処理
triggers:
keywords: ["Reddit", "reddit", "Twitter", "Hacker News", "HackerNews", "サブレディット", "subreddit"]
max_movements: 999
initial_movement: gather
movements:
- name: gather
edit: true
persona: researcher
instruction: |
## 調査計画
着手前に調査計画を立てる:
1. 調査対象と対象 SNS を決定する
2. 検索クエリ案を複数考える(日本語・英語の両方を検討)
3. verify からの差し戻しがある場合は、不足点を優先的に解消する
計画に従って SNS から情報を収集し、Write で output/raw/ にテキストファイルとして書き出す。
## SNS 別の収集方針
### X (Twitter)
- キーワードで広く拾う → XSearch
- 特定アカウントの発言を追う → XUserPosts
- 議論の流れ・リプライツリーまで欲しい → XPostDetail
### Reddit
BrowseWeb で必ず **old.reddit.com** を使う(軽量でテキスト抽出しやすい)。
- 検索: `old.reddit.com/search?q=キーワード`
- スレッド: `old.reddit.com/r/{サブレディット}/comments/...`
### Hacker News
WebFetch で Algolia API を使う。
- 検索: `https://hn.algolia.com/api/v1/search?query=キーワード`
- 記事詳細: `https://hn.algolia.com/api/v1/items/{id}`
## ファイル命名規則
`output/raw/{platform}-{query-slug}.txt`
: reddit-ollama-vs-vllm.txt, x-ollama-review.txt, hn-local-llm.txt
## SNS 調査の原則
モデルの内部知識だけで情報を書かないこと。必ず実際の SNS データを収集する。
検索ヒットがゼロだった場合も、その事実を raw ファイルに記録する(捏造しない)。
## 画像・スクリーンショットの収集
SNS 投稿には画像・グラフが含まれることが多い。重要なビジュアルは DownloadFile で
`output/images/{platform}-{slug}.png` に保存する。
## 終了 / 遷移方法
- **次の analyze へ**: `transition({next_step: "analyze"})`
- **追加収集のため同じ gather を続行**: `transition({next_step: "gather"})`
- **対象が曖昧で確認が必要**: `complete({status: "needs_user_input", missing_info: "...", why_no_default: "..."})`
- **技術的失敗で打ち切り**: `complete({status: "aborted", abort_reason: "..."})`
allowed_tools: [XSearch, XUserPosts, XPostDetail, XFetchCardMedia, BrowseWeb, WebFetch, WebSearch, Read, Write, Edit, Glob, Grep, DownloadFile, SearchKnowledge, ListNamespaces, ListDocuments, SearchNotes, ReadNote, 'mcp__*']
default_next: analyze
rules:
- condition: 追加収集が必要別のSNS、追加クエリ等
next: gather
- condition: 十分な情報を収集した
next: analyze
- name: analyze
edit: true
persona: analyst
instruction: |
output/raw/ の収集データを読み込み、分析してレポートを作成する。
手順:
1. Glob で output/raw/ 内のファイル一覧を確認
2. 各ファイルを Read で読み込む
3. 重要な意見・トレンド・共通見解を抽出
4. ポジティブ/ネガティブな意見を分類
5. output/report.md にレポートを書き出す
## レポートの構成
- トピック概要
- SNS 別の主な意見X / Reddit / HN それぞれ)
- 共通する見解・分岐する意見
- まとめ
## 画像の活用
output/images/ に画像がある場合は必ずレポートに埋め込む:
`![説明](./images/ファイル名.png)`
情報が不足している場合は gather に戻る(追加の検索クエリを明示すること)。
verify からの差し戻しがある場合は、指摘された不足点・期待する修正を優先的に解消すること。
allowed_tools: [Read, Write, Edit, Glob, Grep, WebSearch, WebFetch, DownloadFile, BatchReviewTextWithLLM, MergeReviewedResults, SearchKnowledge, ListNamespaces, ListDocuments, SearchNotes, ReadNote, 'mcp__*']
default_next: verify
rules:
- condition: output/report.md にレポートを書き出した
next: verify
- condition: 情報が不十分で追加収集が必要
next: gather
- name: verify
edit: false
persona: supervisor
instruction: |
output/ のレポートを確認する。
確認手順:
1. Glob で output/ 内のファイル一覧を確認する
2. output/report.md がなければ「不足がある」と判断し analyze に差し戻す
3. ファイルがあれば Read で内容を確認し、網羅性・正確性・分かりやすさをチェックする
4. 不足があれば、`transition({next_step: "analyze", summary: ...})` で差し戻す。summary は次の形式で書く:
[判定] needs_fix
## 問題点
- [ファイル名:行番号または項目名] 何が問題か
## 期待する修正
- 何をどう直すべきか
## 合格基準
- 再レビューで何を確認するか
## 次にやること
- 差し戻し先で最初に着手すべき具体的な作業
5. summary は抽象論で終えず、具体的な不足点・期待する修正内容を必ず含める
追加チェック(画像):
- output/images/ に画像があるのにレポートに `![` が一つもない場合、
画像埋め込み漏れとして analyze に差し戻す
## チェックシート確認
GetChecklist でチェックシートが存在する場合、全アイテムが完了done/failed/skippedしていることを確認する。
remaining が 0 でないまま完了してはならない。
## 合格時のユーザーへの返答complete ツール)
output/ の内容で合格と判断したら、`complete({status: "success", result: ...})` を呼ぶ。
result はそのままユーザーに表示される最終回答。output/report.md を Read で読み、その内容をベースに整形する。
- 「output/xxx.md を確認してください」のようなファイル参照ではなく、内容そのものを回答として返すこと
- 【厳守】「✅ 完了」「レポートを作成しました」「確認しました」等のステータス表示・メタ説明は一切書かない。1行目からいきなり本題の内容を書き始めること
- 調査結果・発見・結論を会話調で分かりやすく伝える
- 表・リスト・見出しなど Markdown 書式を活用して読みやすくする
## 終了方法のまとめ
- 合格: `complete({status: "success", result: "ユーザー向け最終回答"})`
- 修正必要: `transition({next_step: "analyze", summary: "差し戻し指摘"})` (上記形式で)
- 技術的失敗: `complete({status: "aborted", abort_reason: "..."})`
allowed_tools: [Read, Glob, Grep]
default_next: COMPLETE
rules:
- condition: output/ にファイルがない、または内容に不足がある
next: analyze

76
pieces/ssh-console.yaml Normal file
View File

@ -0,0 +1,76 @@
name: ssh-console
description: |
AI と人間が共有する SSH コンソール。タスクに 1 つの PTY セッションを開き、
両者がコマンドを打ち、出力を見られる。長時間の対話作業、TUI が必要な
操作 (vim/top/less/tmux 等)、複数ラウンドの調査に向く。
選ぶべき場合: 「リモートサーバーで色々確認したい」「ログを tail しながら作業」
「対話的に shell を触りたい」
選ぶべきでない場合: 1 コマンドの単発実行 (ssh-ops piece が適切)、ファイル転送だけ
事前条件:
- admin が config.yaml で ssh.enabled: true / ssh.console.enabled: true を設定済み
- 利用する SSH 接続が登録され、TOFU host key 検証が完了している
- ジョブ owner に接続への grant がある
triggers:
keywords: ["対話", "shell", "console", "ターミナル", "tmux", "vim", "tail", "対話的"]
max_movements: 80
initial_movement: interact
movements:
- name: interact
edit: true
persona: ops-operator
instruction: |
## 詳細ドキュメント (必ず最初に読む)
ReadToolDoc({name: "SshConsoleEnsure"}) で 3 ツール (Ensure / Send / Snapshot) の
完全な仕様と典型 flow が見られる。引数と return shape、TUI 操作のコツ、エラー
コードのリカバリ手順まで網羅。alias なので SshConsoleSend / SshConsoleSnapshot
でも同じ doc が返る。SshListConnections は ReadToolDoc({name: "SshListConnections"})。
## 標準 flow
1. タスク本文を読み、どのリモートホストでどんな作業をするか把握する
2. connection_id (UUID) がタスク本文に無ければ SshListConnections({}) で発見する
- **必ず id フィールドを使う**。label ("terminal" など) や host ("192.168.1.x" など) を connection_id として渡してはいけない
- UUID を覚えていないからといって**勝手に UUID をでっち上げない** — 必ず SshListConnections で確認する
3. SshConsoleEnsure({connection_id}) でセッションを開く (冪等)
4. SshConsoleSend / SshConsoleSnapshot を呼ぶ
- **2 回目以降の呼び出しでは connection_id を省略するのが推奨** (active session が自動採用される)
- ジョブ間で UUID を覚え直す必要がなくなる
## 使い方の要点
- 1 行コマンド: SshConsoleSend({input: "ls -la\n"}) — connection_id は省略
- 連続入力 (heredoc / 複数行 stdin): input に \n 含めて送る
- TUI に入る: SshConsoleSend({input: "vim test.txt\n", wait_ms: 1000}) → SshConsoleSnapshot で画面確認
- control 文字: \x03 Ctrl-C / \x04 Ctrl-D / \x1b Esc / \t Tab を input にそのまま含めて送れる
- vim 抜ける: SshConsoleSend({input: "\x1b:q!\n"})
## エラーリカバリ
- "this task already has an active session on connection X" → 表示されている X を connection_id に使う (or 省略する)。
本当に別接続に切り替えたいときだけ SshConsoleEnsure({connection_id, force_replace: true})
- "this task has an active session on connection X, not Y" → 同上 (Send/Snapshot 側のエラー)
- "no live session for this task" → 初回 ensure が必要。SshConsoleEnsure({connection_id}) を呼ぶ
## ファイル転送 (SFTP, PTY とは独立)
- 設定ファイルを置いて反映したい / リモートのログや成果物を手元で解析したい場合は
SshUpload / SshDownload を使う。これらは SFTP 経路で動き、active console session
とは別チャンネルなので PTY を閉じる必要はない (転送後に SshConsoleSend で
リロードコマンドを送ればよい)
- リモートパスは接続の remote_path_prefix 配下のみ。ローカルパスは workspace の
output/ または input/ 配下を使う。詳細・エラーコードは ReadToolDoc({name: "SshUpload"})
/ ReadToolDoc({name: "SshDownload"})
## 注意
- shell 状態 (cd / env / foreground プロセス) はタスク内で維持される。毎ターン cd し直す必要なし
- 機密値はコマンド文字列に直接書かない (audit log に hash で残る)
- 大量出力で screen_after が切れた場合は SshConsoleSnapshot({kind: "scrollback"}) で全文取得
- command_rejected が出たら admin に許可パターン追加を相談する (ローカルで回避してはいけない)
## 終了
- 完了: complete({status: "success", result: "..."})
- 中断: complete({status: "aborted", abort_reason: "..."})
- 確認待ち: complete({status: "needs_user_input", missing_info: "..."})
allowed_tools: [SshConsoleEnsure, SshConsoleSend, SshConsoleSnapshot, SshUpload, SshDownload, SshListConnections, Read, Write, Bash, Glob, Grep]
allowed_ssh_connections: ['*']
default_next: COMPLETE
rules: []

Some files were not shown because too many files have changed in this diff Show More