duet-mcp 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +367 -0
  3. package/doc/README.jp.md +347 -0
  4. package/lib/blob.d.ts +16 -0
  5. package/lib/blob.js +57 -0
  6. package/lib/boot.d.ts +2 -0
  7. package/lib/boot.js +134 -0
  8. package/lib/client-store.d.ts +28 -0
  9. package/lib/client-store.js +147 -0
  10. package/lib/client.d.ts +25 -0
  11. package/lib/client.js +58 -0
  12. package/lib/diff.d.ts +27 -0
  13. package/lib/diff.js +103 -0
  14. package/lib/doc.d.ts +30 -0
  15. package/lib/doc.js +221 -0
  16. package/lib/edit.d.ts +25 -0
  17. package/lib/edit.js +63 -0
  18. package/lib/http.d.ts +9 -0
  19. package/lib/http.js +151 -0
  20. package/lib/index.d.ts +3 -0
  21. package/lib/index.js +2 -0
  22. package/lib/mcp.d.ts +5 -0
  23. package/lib/mcp.js +109 -0
  24. package/lib/op.d.ts +10 -0
  25. package/lib/op.js +19 -0
  26. package/lib/paths.d.ts +4 -0
  27. package/lib/paths.js +9 -0
  28. package/lib/protocol.d.ts +36 -0
  29. package/lib/protocol.js +10 -0
  30. package/lib/server.d.ts +1 -0
  31. package/lib/server.js +1 -0
  32. package/lib/shot.d.ts +8 -0
  33. package/lib/shot.js +85 -0
  34. package/lib/transport.d.ts +7 -0
  35. package/lib/transport.js +22 -0
  36. package/lib/types.d.ts +66 -0
  37. package/lib/types.js +1 -0
  38. package/lib/wire.d.ts +14 -0
  39. package/lib/wire.js +42 -0
  40. package/package.json +97 -0
  41. package/template/app.ts +17 -0
  42. package/template/doc.ts +18 -0
  43. package/template/main.ts +8 -0
  44. package/template/ops.ts +42 -0
  45. package/template/start.ts +4 -0
  46. package/template/ui/canvas.tsx +91 -0
  47. package/template/ui/card-editing.tsx +48 -0
  48. package/template/ui/edit-actions.tsx +30 -0
  49. package/template/ui/index.html +15 -0
  50. package/template/ui/main.tsx +53 -0
  51. package/template/ui/style.css +15 -0
  52. package/template/ui/tsconfig.json +15 -0
  53. package/template/ui/vite.config.ts +24 -0
@@ -0,0 +1,347 @@
1
+ # duet-mcp
2
+
3
+ [English](../README.md) | 日本語
4
+
5
+ **一つの JSON 文書を、人は GUI から、LLM は MCP から編集するアプリの基盤。**
6
+
7
+ 操作を一度定義すると HTTP と MCP の両方から呼べる。文書は一つの daemon が所有し、
8
+ 観測後の変更を待つ仕組みを提供する。盤面、タスクボード、図、スライド構成など、
9
+ 小〜中規模の文書に対する離散的な操作を対象にする。
10
+
11
+ ## 操作を一度定義する
12
+
13
+ ```ts
14
+ const op = opFactory<Doc>();
15
+
16
+ op({
17
+ name: "set_text",
18
+ description: "テキストを差し替える。",
19
+ input: { text: z.string() },
20
+ handler: ({ doc, reject }, { text }) => {
21
+ if (text.length > 100) return reject("100文字以内にしてください");
22
+ doc.text = text;
23
+ },
24
+ });
25
+ ```
26
+
27
+ これが MCP の `set_text({ text, baseRevision })` と HTTP の
28
+ `POST /api/op/set_text` になる。GUI は購読した snapshot から `snap.run("set_text", { text })` を呼ぶ。
29
+
30
+ **操作は、意図を作るために見た文書の版に対して適用する。** その版から文書が変わっていたら、
31
+ ハンドラを実行せず `conflict` と最新の doc を返す。変更箇所が別でも競合する。
32
+ 新しい版で同じ引数を自動再送せず、最新の doc を読んで意図を見直す。
33
+
34
+ ## npm パッケージとして使う
35
+
36
+ Node.js 22以上、ESMを対象にする。ReactアダプターはReact 18.3、操作の入力定義はZod 3を対象にする。
37
+ CIはNode.js 22 / 24とUbuntu / macOS。Windowsでの起動・ビルド手順は未検証。
38
+
39
+ パッケージ名は `duet-mcp`。以下のレジストリからのインストールは初回公開後に利用できる。
40
+ 公開前は `npm pack --pack-destination /tmp` で作った `.tgz` の絶対パスを、
41
+ `duet-mcp` の代わりに `npm install` へ渡す。
42
+
43
+ ```bash
44
+ mkdir my-duet-app
45
+ cd my-duet-app
46
+ npm init -y
47
+ npm pkg set type=module
48
+ npm install duet-mcp react@^18.3.1 react-dom@^18.3.1 zod@^3.23.8
49
+ npm install -D typescript@^5.7.2 vite@^6.0.5 @vitejs/plugin-react@^4.3.4 tailwindcss@^4.3.3 @tailwindcss/vite@^4.3.3 @types/node@^22.10.2 @types/react@^18.3.17 @types/react-dom@^18.3.5
50
+ npx playwright install chromium
51
+ cp -R node_modules/duet-mcp/template ./template
52
+ ```
53
+
54
+ LinuxでChromiumのシステムライブラリも必要なら `npx playwright install --with-deps chromium` を使う。
55
+ ブラウザの取得はインストール時に自動実行しない。Playwrightを更新した場合も再度取得する。
56
+
57
+ プロジェクト直下に `tsconfig.json` を作る。
58
+
59
+ ```json
60
+ {
61
+ "compilerOptions": {
62
+ "target": "ES2022",
63
+ "module": "NodeNext",
64
+ "moduleResolution": "NodeNext",
65
+ "rootDir": ".",
66
+ "outDir": "dist",
67
+ "strict": true,
68
+ "skipLibCheck": true
69
+ },
70
+ "include": ["template/*.ts"]
71
+ }
72
+ ```
73
+
74
+ ```bash
75
+ npx tsc
76
+ npx vite build --config template/ui/vite.config.ts
77
+ node dist/template/main.js
78
+ ```
79
+
80
+ GUIの開発中は `npx vite --config template/ui/vite.config.ts` を別ターミナルで実行する。
81
+ MCPには `node` と `<project>/dist/template/main.js` の絶対パスを登録する。
82
+ アプリを改名する場合は、フォルダ名・`app.id`・`webDist`・ビルド対象と起動パスを揃える。
83
+
84
+ 公開する入口は以下の4つ。`lib/` 内部への直接importは公開APIではない。
85
+
86
+ ```ts
87
+ import { defineApp, opFactory, type AppDef, type Op } from "duet-mcp";
88
+ import { runApp } from "duet-mcp/server";
89
+ import { useDoc, useEdit, EditSession, refreshDoc } from "duet-mcp/react";
90
+ import { portFor, baseUrlFor } from "duet-mcp/wire";
91
+ ```
92
+
93
+ `rootDir` はアプリのルートを示す絶対パス。文書・blobはその下の `data/` に保存する。
94
+ `webDist` は `rootDir` からの相対パス、または絶対パス。
95
+ 省略時の `rootDir` はプロセスの作業ディレクトリだが、MCPクライアントは任意の作業ディレクトリで
96
+ 起動しうるため、テンプレートのように `import.meta.url` から明示的に指定する。
97
+ パッケージを更新しても同じ `rootDir` と `app.id` を使えば同じ保存データを読む。
98
+ 既存のコピー方式から移る場合も、以前のプロジェクトルートを指定すると保存先を維持できる。
99
+
100
+ 基盤の更新は `npm install duet-mcp@<version>`。コピーしたテンプレートは利用アプリが管理する。
101
+
102
+ ## リポジトリで動かす
103
+
104
+ ```bash
105
+ npm ci
106
+ npx playwright install chromium
107
+ npm run build
108
+ npm start
109
+ ```
110
+
111
+ stderr に GUI の URL が出る。`template/` がコピー用の雛形。
112
+
113
+ テンプレートは Tailwind CSS と React で作った小さなデザインスタジオ。
114
+ 見出し・メモ、チェックボックス、ラジオボタン、セレクト、不透明度スライダーと、
115
+ 実際の canvas 上で移動・四隅からリサイズできるボックスを含む。
116
+ フォームは「Apply」でまとめて確定し、canvas はドラッグ終了時に確定する。
117
+ 座標・サイズは数値入力でも変更できる。Esc / pointercancel は進行中のドラッグを中止する。
118
+
119
+ MCP からは `set_settings`、`set_box`、`set_text` で同じ文書を操作する。
120
+ 競合時は下書きと現在値を表示して明示的な見直しを求める。
121
+ canvas の座標系は720×480、ボックスの最小サイズは64×64で、境界はハンドラでも検証する。
122
+ `ui/canvas.tsx` に描画とジェスチャー、`ui/edit-actions.tsx` に確定と競合表示を分けている。
123
+ スタイルは `ui/style.css` と各コンポーネントの Tailwind クラスで変更できる。
124
+ 旧テンプレートのテキストのみの保存データは、追加項目を初期値で表示する。
125
+
126
+
127
+ ```json
128
+ {
129
+ "mcpServers": {
130
+ "duet": {
131
+ "command": "node",
132
+ "args": ["<repo>/dist/template/main.js"]
133
+ }
134
+ }
135
+ }
136
+ ```
137
+
138
+ `<repo>` は絶対パス。接続後は `gui_url` で GUI の URL、`await_change` で現在の文書を取得できる。
139
+ MCP 設定形式はクライアントに合わせる。
140
+
141
+ ```bash
142
+ DUET_APP=myapp npm start
143
+ DUET_APP=myapp npm run dev:web
144
+ # 開発中の GUI を撮影する場合
145
+ DUET_SHOT_ORIGIN=http://127.0.0.1:5173 DUET_APP=myapp npm start
146
+ ```
147
+
148
+ ## 文書・操作・観測
149
+
150
+ | 名前 | 契約 |
151
+ |---|---|
152
+ | doc | アプリが定義する JSON。変更は op から行う |
153
+ | op | 名前・説明・Zod 入力・同期ハンドラを一度定義する |
154
+ | ctx | `{ doc, actor, reject }`。doc は書き換えてよい複製 |
155
+ | revision | 不透明な文字列。受け取った値を解析・加算せず渡す |
156
+ | snapshot | `{ doc, revision, actor, activity }` を組で持つ観測 |
157
+ | await_change | 今を読む、または観測した版以後のコミットを待つ |
158
+
159
+ ハンドラが `reject` した場合は複製を捨てる。doc が変わらなければ revision は変わらない。
160
+ **古い版の問い合わせ op も conflict** になる。ハンドラを先に動かして読み取りかどうかを判定しない。
161
+
162
+ ハンドラは短い同期処理に限定する。戻り値は `Json | void`。
163
+ Promise は型と実行時の両方で拒否する。外部 API、ファイルへの書き込み、タイマー、
164
+ 後から draft を変更する処理はハンドラの契約外。doc の複製では外部副作用を取り消せない。
165
+ 外部で計算した結果を適用する場合も、計算の元にした snapshot の版を使う。
166
+
167
+ doc と結果は JSON として検証する。`undefined` な項目、NaN、Map、Date、循環参照などはエラー。
168
+ 項目の削除は `delete`、空の値は `null` を使う。戻り値の `undefined` は結果なしとして扱う。
169
+ `doc = next` はローカル変数の再代入で文書を置き換えない。複製のプロパティを更新すること。
170
+
171
+ 入力違反・アプリの `reject` は説明付きで返す。基盤やハンドラの予期しない例外・保存失敗は
172
+ 通信経路のエラーとして伝わる。HTTP 入力の検証に加え、MCP 側でも SDK が入力を検証する。
173
+
174
+ ## GUI の操作と下書き
175
+
176
+ ```tsx
177
+ const snap = useDoc<Doc>(); // 初回取得前は null
178
+ if (!snap) return <p>接続中…</p>;
179
+
180
+ // run はこの snapshot の revision を使う。
181
+ const result = await snap.run("move_card", { cardId, beforeCardId });
182
+ ```
183
+
184
+ 複数の `useDoc` は一つの購読を共有する。応答の doc と revision は一緒に反映され、
185
+ 遅れて届いた古い応答で巻き戻らない。保存しておいた古い `snap.run` は古い版を送り続ける。
186
+
187
+ 入力やドラッグなど、観測から確定まで時間がある編集には `useEdit` を使う。
188
+
189
+ ```tsx
190
+ const snap = useDoc<Doc>();
191
+ const edit = useEdit<string>();
192
+
193
+ // 最初に入力を変えるとき
194
+ edit.begin(snap, snap.doc.text);
195
+ edit.setValue(nextText);
196
+
197
+ // 確定時。begin したときの版で送る。
198
+ const result = await edit.run("set_text", { text: edit.value });
199
+ ```
200
+
201
+ - `active` / `value` / `pending` / `result` / `error` が現在の編集状態。
202
+ - 既に編集中の `begin` は拒否する。購読更新は下書きと基準版を変更しない。
203
+ - 送信中の重複 `run` は同じ Promise を返す。入力は `pending` の間無効にする。
204
+ - 成功時に編集を終了する。conflict、rejected、通信失敗では下書きを残す。
205
+ - `cancel()` は下書きを破棄する。
206
+ - `restart(latestSnapshot, revisedValue)` は見直した内容と基準を明示的に更新する。
207
+ - `refreshDoc()` は再取得を要求し、購読で現在の状態を確認したら解決する Promise を返す。
208
+
209
+ テンプレートは現在値と下書きを並べ、取り消しと見直し後の適用を示している。
210
+ 通信失敗時には結果が不明なことがあるため、現在値を再取得してから再適用を判断する。
211
+ 下書きをリストの行の中だけに置くと、相手の操作による行の移動や削除で失われる。
212
+
213
+ [移動可能なリストの例](../template/ui/card-editing.tsx) は、カード id をキーに `EditSession` を
214
+ 列の外の Map に保持する。各行は `useSyncExternalStore` でその編集を購読するので、
215
+ 別の列に移動して行が再マウントされても下書きが残る。これはカード用アプリへ組み込む例で、
216
+ テキストのテンプレートには表示していない。`EditSession` は `useEdit` と同じ状態管理の実体。
217
+
218
+ ## 応答と待機
219
+
220
+ 正常な op 応答は三種類。どれにも doc と revision を同梱する。
221
+
222
+ ```jsonc
223
+ { "revision": "epoch-a:12", "actor": "llm", "doc": { "text": "hello" },
224
+ "activity": {}, "ok": true }
225
+ { "revision": "epoch-a:12", "actor": "llm", "doc": { "text": "hello" },
226
+ "activity": {}, "rejected": "100文字以内にしてください" }
227
+ { "revision": "epoch-a:14", "actor": "llm", "doc": { "text": "new" },
228
+ "activity": {}, "conflict": true, "changes": [], "truncated": true }
229
+ ```
230
+
231
+ revision の文字列は例示。形式は公開契約ではない。daemon が再起動すると、文書が同じでも
232
+ revision は変わる。古い操作は conflict、古い待機は最新 doc と `truncated: true` を即返す。
233
+
234
+ - `await_change()` は現在の doc と revision を即時取得する。
235
+ - `await_change({ sinceRevision })` はその版以後のコミットを待つ。既にあれば即返す。
236
+ - `until: ["move_card"]` は待つ op 名を絞る。変更説明もその対象のみで、doc は全体の現状。
237
+ - `timeoutMs` は既定25秒、下限1秒、上限120秒。
238
+ - 時間切れでも対象外の変更はありうる。返された doc と revision を組で採用する。
239
+
240
+ `changes` は op・actor・count・touched(JSON Pointer)の説明。連続する同じ参加者の同じ op を
241
+ まとめる。書き込み可否や自動再送の根拠には使わない。100件または32 KiBを超える説明、
242
+ 保持範囲外の履歴は空配列と `truncated: true` で返す。メモリには現在の daemon の1000コミットを保持する。
243
+ 文書全体のサイズはこの説明サイズ制限とは別なので、かさばる画像などは blob の id で参照する。
244
+
245
+ `activity` は参加者の最終活動からの経過ミリ秒。`useDoc` は pointerdown / keydown をまとめて申告し、
246
+ GUI の経過時間表示も更新する。`touch()` で明示的な申告もできる。
247
+ これは助言情報で、編集完了・優先権・公平性は保証しない。revision を変えず、待機も起こさない。
248
+
249
+ ## アプリの構造
250
+
251
+ `template/` を `<app>/` にコピーする。ディレクトリ名は `app.id` と一致させる。
252
+ リポジトリの `build` と `typecheck` は全アプリを対象にする。
253
+ npm導入例のビルド対象は `template/` のみなので、追加アプリに合わせて設定する。
254
+
255
+ ```text
256
+ <app>/
257
+ doc.ts JSON 型・初期値・共有する純粋関数
258
+ ops.ts op 定義
259
+ app.ts defineApp({ id, version, rootDir?, initialDoc, ops, webDist, shot? })
260
+ start.ts runApp(app)
261
+ main.ts stdout を保護して起動
262
+ ui/ index.html / main.tsx / vite.config.ts / tsconfig.json
263
+ rules.ts 必要な場合のドメインロジック
264
+ ```
265
+
266
+ サーバ側から読む相対 import は `.js` を付ける。UI のみのファイルは bundler 解決。
267
+ UI と共有するファイルに重いサーバ依存を入れると UI にも取り込まれるため、依存の置き場を分ける。
268
+ 派生値は doc に重複保存する必要はない。例えば盤面からFENを作る純粋関数を UI と問い合わせ op で共有できる。
269
+
270
+ ## 参加者と手番
271
+
272
+ MCP 設定の `env: { "DUET_ACTOR": "gpt" }` で参加者名を変えられる。既定は `llm`、ブラウザは `human`。
273
+ 同じ MCP 接続を使うサブエージェントは同じ actor になる。認証や複数ユーザー管理は提供しない。
274
+
275
+ 席や手番は doc に置き、共通の判定関数を UI とハンドラから使う。
276
+ 席のあるアプリは、追加の actor が座れる `sit` などの op も用意する。
277
+ `reject` では「誰の席か、自分は誰か」など、呼び手が見直せる理由を返す。
278
+
279
+ ## GUI の撮影と blob
280
+
281
+ `render_screenshot({ path? })` は、同じ GUI を headless Chromium の別セッションで描画して撮る。
282
+ 人間の下書き、ホバー、選択、スクロール位置は共有しない。初回だけページを開き、その後は再利用する。
283
+
284
+ ```ts
285
+ shot: { selector: "#board", viewport: { w: 1024, h: 768 } }
286
+ ```
287
+
288
+ `useDoc` は DOM の反映後に `data-duet-revision` を更新する。撮影側は同じ daemon の要求時点以降の版を待つ。
289
+ 厳密に要求時の snapshot を固定した画像とは限らない。非同期画像やアプリ固有の描画完了まで
290
+ この属性だけで保証するものではない。
291
+ 開発中は `DUET_SHOT_ORIGIN` を dev server に向ける。既定の撮影対象は `webDist` のビルド成果物。
292
+
293
+ `uploadBlob(file)` で実体を保存し、返された id を op で doc に入れる。GUI は `blobUrl(id)`、
294
+ LLM は `read_blob({ id })` で参照する。不変な blob は各 MCP プロセスから直読みする。
295
+
296
+ 組み込みツール名 `gui_url` / `await_change` / `render_screenshot` / `read_blob` は op に使えない。
297
+
298
+ ## 保存と再接続
299
+
300
+ ```text
301
+ data/<id>.json 文書・アプリ版・内部のコミット連番
302
+ data/<id>.log 調査用の補助ログ(完全な監査・復元ログではない)
303
+ data/<id>-blobs/ 不変の実体
304
+ ```
305
+
306
+ snapshot の一時ファイルを rename できた時点で確定する。ログ追記の失敗で確定済みの操作を取り消さない。
307
+ ログは競合判定に使わず、再起動後の差分復元にも使わない。電源断に対する完全な耐久性は保証しない。
308
+ 読めない snapshot は退避する。アプリ版が違う場合は固有名の控えを作って警告する。
309
+ アプリ固有の doc スキーマ検証・マイグレーションはアプリ側の責任。
310
+
311
+ ポートは app.id から8000〜8999へ導出する。衝突した場合は `DUET_PORT` で変更する
312
+ (daemon と vite に同じ値を設定)。委譲先の app.id を確認し、別アプリには操作を渡さない。
313
+ version 不一致は警告。ポートを所有するプロセスが唯一の状態を持ち、他の MCP プロセスは HTTP で委譲する。
314
+
315
+ 所有者が落ちたら、生き残った MCP プロセスが次の呼び出し時に再接続・昇格を試す。
316
+ GUI だけでは daemon を起動できない。GUI を使い続けるならアプリのプロセスを残す。
317
+ **操作の POST は自動で再送しない。** 保存後に応答が途切れた場合は成功か不明なので、
318
+ 現在の doc を取得して判断する。exactly-once、ロック、人間優先は保証しない。
319
+
320
+ ## 更新と検証
321
+
322
+ 数値 revision、独立した `runOp`、async handler を使う旧 API とは互換性がない。
323
+ daemon・MCP・GUI を同時に更新する。旧 snapshot の整数 revision は内部連番として読み込める。
324
+
325
+ ```bash
326
+ npm run typecheck
327
+ npm test # ビルドと全テスト。localhost と Chromium を使用
328
+ ```
329
+
330
+ テストは一時ディレクトリを使い、アプリの保存済み文書を変更しない。
331
+
332
+ 文書全体の版を照合するため、無関係な変更でも競合する。人が更新し続ける間の LLM の進行や、
333
+ 文字単位の同時編集には向かない。doc は毎回全量で複製・保存・送信する。
334
+ undo / redo、CRDT / OT、外部副作用のトランザクションは提供しない。
335
+
336
+ ## CI とライセンス
337
+
338
+ GitHub Actions は push / pull request 時に、Ubuntu・macOS と Node.js 22・24 の組み合わせで
339
+ `npm ci`、Chromium の導入、型チェック、ビルドと全テストを実行する。
340
+ `package-lock.json` を共有し、CI と同じ依存バージョンを入れる場合は `npm ci` を使う。
341
+
342
+ [MIT License](../LICENSE) — Copyright (c) 2026 Taniguchi Ryoga (SabaCan0141)。
343
+
344
+
345
+ 配布物の独立インストール検証は `npm run test:package` で実行する。
346
+ 一時プロジェクトで型定義・GUI・MCP・保存・blob・撮影・再起動を確認し、終了後に削除する。
347
+ この検証は依存パッケージとChromiumをダウンロードする。CIでも実行する。
package/lib/blob.d.ts ADDED
@@ -0,0 +1,16 @@
1
+ export type BlobMeta = {
2
+ id: string;
3
+ mime: string;
4
+ size: number;
5
+ };
6
+ export declare class BlobStore {
7
+ private readonly dir;
8
+ constructor(appId: string, dataDir: string);
9
+ private resolve;
10
+ put(bytes: Uint8Array, rawMime: string): BlobMeta;
11
+ get(id: string): {
12
+ bytes: Buffer;
13
+ mime: string;
14
+ } | null;
15
+ list(): BlobMeta[];
16
+ }
package/lib/blob.js ADDED
@@ -0,0 +1,57 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ /**
5
+ * 画像などの実体を置く場所。id は不変で、内容は上書きされない。
6
+ * doc には id だけを持たせ、GUI からは /blob/<id> で参照する。
7
+ * ディスク上の追記のみなので、daemon 以外のプロセスからも読める。
8
+ */
9
+ const EXT = {
10
+ "image/png": ".png",
11
+ "image/jpeg": ".jpg",
12
+ "image/webp": ".webp",
13
+ "image/svg+xml": ".svg",
14
+ "application/json": ".json",
15
+ "text/plain": ".txt",
16
+ };
17
+ const MIME = Object.fromEntries(Object.entries(EXT).map(([mime, ext]) => [ext, mime]));
18
+ export class BlobStore {
19
+ dir;
20
+ constructor(appId, dataDir) {
21
+ this.dir = path.join(dataDir, `${appId}-blobs`);
22
+ }
23
+ resolve(id) {
24
+ // id は自分で採番した uuid + 拡張子のみ。外から来た文字列を信用しない。
25
+ if (!/^[0-9a-f-]{36}\.[a-z0-9+]{2,5}$/.test(id))
26
+ return null;
27
+ const file = path.join(this.dir, id);
28
+ return fs.existsSync(file) ? file : null;
29
+ }
30
+ put(bytes, rawMime) {
31
+ // "application/json; charset=utf-8" のような値がそのまま来る。
32
+ // 正規化しないと put の mime と get の mime が食い違う。
33
+ const mime = rawMime.split(";")[0].trim();
34
+ fs.mkdirSync(this.dir, { recursive: true });
35
+ const id = `${crypto.randomUUID()}${EXT[mime] ?? ".bin"}`;
36
+ fs.writeFileSync(path.join(this.dir, id), bytes);
37
+ return { id, mime, size: bytes.byteLength };
38
+ }
39
+ get(id) {
40
+ const file = this.resolve(id);
41
+ if (!file)
42
+ return null;
43
+ return {
44
+ bytes: fs.readFileSync(file),
45
+ mime: MIME[path.extname(file)] ?? "application/octet-stream",
46
+ };
47
+ }
48
+ list() {
49
+ if (!fs.existsSync(this.dir))
50
+ return [];
51
+ return fs.readdirSync(this.dir).map((id) => ({
52
+ id,
53
+ mime: MIME[path.extname(id)] ?? "application/octet-stream",
54
+ size: fs.statSync(path.join(this.dir, id)).size,
55
+ }));
56
+ }
57
+ }
package/lib/boot.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ import type { AppDef } from "./types.js";
2
+ export declare function runApp<Doc>(app: AppDef<Doc>): Promise<void>;
package/lib/boot.js ADDED
@@ -0,0 +1,134 @@
1
+ import { serve } from "@hono/node-server";
2
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import { DocStore } from "./doc.js";
5
+ import { createHttpApp } from "./http.js";
6
+ import { registerTools } from "./mcp.js";
7
+ import { Reached, requestWithRecovery } from "./transport.js";
8
+ import { baseUrlFor, portFor } from "./wire.js";
9
+ function listen(http, port) {
10
+ return new Promise((resolve) => {
11
+ try {
12
+ // 127.0.0.1 に閉じる。LAN から doc を読み書きされないため。
13
+ const server = serve({ fetch: http.fetch, port, hostname: "127.0.0.1" });
14
+ server.once("error", () => {
15
+ // close() にコールバックを渡さないと、listen していないサーバの close が
16
+ // リスナの居ない 'error' を投げて無関係なスタックトレースが出る。
17
+ server.close(() => { });
18
+ resolve(null);
19
+ });
20
+ server.once("listening", () => resolve(server));
21
+ }
22
+ catch {
23
+ resolve(null);
24
+ }
25
+ });
26
+ }
27
+ export async function runApp(app) {
28
+ const port = portFor(app.id);
29
+ const base = baseUrlFor(app.id);
30
+ const actor = process.env.DUET_ACTOR ?? "llm";
31
+ let owned = null;
32
+ let wrongApp = null;
33
+ // ポートを握れなければリクエストは来ないので、DocStore は結局作られない。
34
+ // client プロセスがデータファイルに触らないのはこのため。
35
+ let store = null;
36
+ const getStore = () => (store ??= new DocStore(app));
37
+ /**
38
+ * ポートを握れたプロセスが状態の所有者になる。
39
+ * 決めるのは「所有者は誰か」だけで、呼び出し経路は分岐しない。
40
+ * だから daemon が死んだ後の昇格が、この関数を呼び直すだけで済む。
41
+ */
42
+ const bind = async () => {
43
+ if (owned)
44
+ return false;
45
+ const bound = await listen(createHttpApp(app, getStore), port);
46
+ if (!bound)
47
+ return false;
48
+ owned = bound;
49
+ wrongApp = null;
50
+ console.error(`[duet] daemon: ${base} (${app.id} ${app.version})`);
51
+ return true;
52
+ };
53
+ /** 相手が本当に同じアプリの daemon かを確かめる。 */
54
+ const verify = async () => {
55
+ // 相手は入れ替わりうるので、毎回まっさらから判定し直す。
56
+ wrongApp = null;
57
+ const hello = (await fetch(`${base}/api/hello`)
58
+ .then((r) => (r.ok ? r.json() : null))
59
+ .catch(() => null));
60
+ if (!hello) {
61
+ wrongApp = `${base} は duet の daemon ではない。別のプロセスがこのポートを使っている。`;
62
+ }
63
+ else if (hello.id !== app.id) {
64
+ wrongApp = `${base} の daemon は別のアプリ (${hello.id})。${app.id} の状態はそこに無い。`;
65
+ }
66
+ else if (hello.version !== app.version) {
67
+ console.error(`[duet] daemon の version が違う: ${hello.version} / 自分は ${app.version}`);
68
+ }
69
+ if (wrongApp)
70
+ console.error(`[duet] ${wrongApp}`);
71
+ };
72
+ if (!(await bind())) {
73
+ console.error(`[duet] client: ${base} の daemon に委譲する(状態は持たない)`);
74
+ await verify();
75
+ }
76
+ const request = async (pathname, init) => {
77
+ const headers = new Headers(init?.headers);
78
+ headers.set("x-duet-actor", actor);
79
+ headers.set("x-duet-app-id", app.id);
80
+ const res = await fetch(`${base}${pathname}`, { ...init, headers });
81
+ if (!res.ok) {
82
+ const body = (await res.json().catch(() => null));
83
+ // 応答が返っている以上 daemon は生きている。昇格の対象ではない。
84
+ throw new Reached(body?.error ?? `${init?.method ?? "GET"} ${pathname} -> ${res.status}`);
85
+ }
86
+ return (await res.json());
87
+ };
88
+ /**
89
+ * 経路は常に HTTP。daemon 自身も自分を叩く。
90
+ * 接続が切れたら所有者を再確立する。読み取りだけ再試行し、POST は再送しない。
91
+ */
92
+ const call = async (pathname, init) => {
93
+ // 相手が別物だったときも、そいつが消えていれば握り直して自分が所有者になる。
94
+ if (wrongApp) {
95
+ if (!(await bind()))
96
+ await verify();
97
+ if (wrongApp)
98
+ throw new Error(wrongApp);
99
+ }
100
+ // client は接続先の交代を見落とさないよう、各呼び出し前にも確認する。
101
+ if (!owned) {
102
+ await verify();
103
+ if (wrongApp) {
104
+ if (!(await bind()))
105
+ throw new Error(wrongApp);
106
+ }
107
+ }
108
+ return requestWithRecovery(() => request(pathname, init), async () => {
109
+ if (!(await bind())) {
110
+ await verify();
111
+ if (wrongApp)
112
+ throw new Error(wrongApp);
113
+ }
114
+ }, init?.method);
115
+ };
116
+ const mcp = new McpServer({ name: app.id, version: app.version });
117
+ registerTools(mcp, app, call);
118
+ await mcp.connect(new StdioServerTransport());
119
+ console.error(`[duet] mcp connected over stdio as "${actor}"`);
120
+ // 登録はここで行う。**これより前に移動させないこと。**
121
+ //
122
+ // 冒頭で登録すると、op 名の衝突のような起動時の失敗が stderr に 1 行出るだけになる。
123
+ // bind() は既に成功しているのでイベントループは生き続け、GUI は動くのに
124
+ // mcp.connect() には到達しない。「アプリは動いているのに LLM だけ何も見えない」は
125
+ // 一番原因を疑いにくい壊れ方である。接続前に落ちれば MCP クライアントは
126
+ // 起動失敗として扱うので、そちらの方が短く終わる。
127
+ //
128
+ // 握る目的は、稼働中のプロセスを例外で落として "Server disconnected" にしないこと。
129
+ process.on("uncaughtException", (err) => console.error("[uncaught]", err));
130
+ process.on("unhandledRejection", (err) => console.error("[unhandled]", err));
131
+ // プロセスが終了したら daemon も終わる。それだけ。
132
+ // GUI を長く使いたいときは、自分のプロセスを 1 つ立てればそれが daemon になる。
133
+ process.stdin.on("close", () => process.exit(0));
134
+ }
@@ -0,0 +1,28 @@
1
+ import { type Snapshot, type RunResult } from "./protocol.js";
2
+ export type Observed<Doc> = Snapshot<Doc> & {
3
+ run(name: string, args?: Record<string, unknown>): Promise<RunResult<Doc>>;
4
+ };
5
+ /** ブラウザで一つ共有する購読。React に依存せず応答の順序を管理する。 */
6
+ export declare class ClientStore {
7
+ private readonly request;
8
+ private current;
9
+ private listeners;
10
+ private running;
11
+ private ctrl;
12
+ private polling;
13
+ private generation;
14
+ private pollVersion;
15
+ private force;
16
+ private received;
17
+ private refreshWaiters;
18
+ constructor(request?: typeof fetch);
19
+ getSnapshot: () => Observed<unknown> | null;
20
+ get receivedAt(): number;
21
+ subscribe: (listener: () => void) => (() => void);
22
+ refresh: () => Promise<void>;
23
+ private emit;
24
+ private start;
25
+ private accept;
26
+ private run;
27
+ private loop;
28
+ }