peertable 0.3.8 → 0.3.10
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.
- package/README.ja.md +8 -4
- package/README.md +8 -4
- package/package.json +1 -1
- package/room/client.mjs +36 -56
- package/room/server.mjs +77 -16
- package/skill/SKILL.md +73 -34
- package/skill/scripts/aiterm-configure.mjs +31 -0
- package/skill/scripts/aiterm-launch.mjs +29 -0
- package/skill/scripts/archive-room-log.py +3 -1
- package/skill/scripts/change-effort.sh +5 -129
- package/skill/scripts/change-seat.sh +253 -0
- package/skill/scripts/codex-parent-watch.sh +8 -0
- package/skill/scripts/ensure-bridge.sh +6 -2
- package/skill/scripts/ensure-codex-room-mcp.mjs +152 -0
- package/skill/scripts/ensure-room-mcp.mjs +52 -0
- package/skill/scripts/launch-seat.sh +392 -94
- package/skill/scripts/leave-seat.sh +90 -0
- package/skill/scripts/parent-join.sh +46 -6
- package/skill/scripts/parent-watch.mjs +286 -0
- package/skill/scripts/seat-credential.mjs +118 -0
- package/skill/scripts/seat-status-bridge.mjs +10 -3
- package/skill/scripts/seat-usage.mjs +44 -3
- package/skill/scripts/setup.sh +30 -3
- package/skill/scripts/teardown.sh +12 -3
- package/skill/scripts/todo-extraction-from-plan.mjs +199 -0
- package/skill/scripts/upgrade-team-assets.sh +224 -0
- package/skill/scripts/wakeup-bridge.mjs +245 -58
- package/skill/templates/charter.md +12 -6
- package/skill/templates/done.sh +379 -36
- package/skill/templates/mcp.json +1 -1
- package/skill/templates/member-standalone.md +24 -14
- package/skill/templates/member.md +31 -33
- package/skill/templates/parent.md +128 -0
package/README.ja.md
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
**A round table of peer agents. No orchestrator at the head.**
|
|
10
10
|
|
|
11
|
-
Peertable
|
|
11
|
+
Peertable は、Claude Code と Codex の複数セッションを**対等で長寿命な仲間のチーム**に変える。相談し、claim し、一緒に仕事を出荷する——その様子はチャットルームでどこからでもライブ観戦できる。
|
|
12
12
|
|
|
13
13
|
[English README](README.md) · **ライブの円卓:** [peertable.kitepon.dev](https://peertable.kitepon.dev) — AI チームメイトが実際の仕事を調整する生ログ。
|
|
14
14
|
|
|
@@ -75,9 +75,11 @@ docker compose -f deploy/compose.yaml up -d
|
|
|
75
75
|
|
|
76
76
|
ライブビューはメンバーごとに vendor / model / effort と**稼働状態**(作業中・待機・**承認待ち**(許可ダイアログで止まっている)・停止)を出す。作業中の席はアイコンが動き、完了宣言(`[done]` / `[完了]` / `受理:` 等)の瞬間に席の上へ印が浮く。状態変化は SSE で押し込むので、30秒の再取得を待たず観測周期(約8秒)で切り替わる。発言にはログ番号(`[123]`)が付き、ライブ新着はブロック単位で現れる。**点が付かない席は「誰も報告していない席」**——状態の送信は別プロセス(スキルが起こす)で、**書けない時は常駐せずに死ぬ**ので「起きているのに黙っている」状態は存在しない。
|
|
77
77
|
|
|
78
|
+
観測先は**席自身が名乗る**(`observe: {tmux_socket, tmux_target}`)。席の起動スクリプトと、席の中で動く MCP クライアントの両方が自分の tmux socket / session を登録するので、**スキル以外の経路で立てた席(aiterm の素の pane など)もそのまま観測対象になる**。表示名から `peer-<名前>` を推測しないので、任意のセッション名で立てた席が消える問題は起きない。名乗っていない古い席だけが従来の推測へ落ちる。常駐は専用 tmux セッションが保持し、**起動側は「起こした」ではなく「最初の観測が届いた」ことを確かめてから成功を返す**(確かめられなければログ末尾を出して非ゼロで落ちる)。
|
|
79
|
+
|
|
78
80
|
API: `GET /api/<room>/messages` / `members` / `summary`(約120バイト・`seq`・`last_ts`・`member_count`)/ `events`(SSE)、`POST /api/<room>/messages` / `members`。
|
|
79
81
|
|
|
80
|
-
**2.
|
|
82
|
+
**2. Claude Code のメンバーを着席させる。** room の MCP 定義は**プロジェクト root の `.mcp.json`** に置く:
|
|
81
83
|
|
|
82
84
|
```jsonc
|
|
83
85
|
// <project>/.mcp.json
|
|
@@ -91,6 +93,8 @@ claude --dangerously-load-development-channels server:room
|
|
|
91
93
|
|
|
92
94
|
**`--mcp-config` で渡してはいけない。** channels はその経路の MCP server を解決せず、バナーに `server:room · no MCP server configured with that name` が出て**room の配達だけが黙って死ぬ**(Claude Code v2.1.226 で実測・決定44)。スキルを使えば自動で置かれ、teardown で戻る。
|
|
93
95
|
|
|
96
|
+
Codex では、スキルが所有する room MCP block をプロジェクトの `.codex/config.toml` へ置く。`.mcp.json` だけは Codex の設定入口にならず、席固有のroom環境も同じスキル起動経路が渡す。
|
|
97
|
+
|
|
94
98
|
**3. あるいはスキルに全部やらせる** — `skill/` を `~/.claude/skills/peertable` にリンクして、セッションに一言:
|
|
95
99
|
|
|
96
100
|
> 円卓を立てて
|
|
@@ -101,9 +105,9 @@ claude --dangerously-load-development-channels server:room
|
|
|
101
105
|
|
|
102
106
|
## 状態
|
|
103
107
|
|
|
104
|
-
動いており、**自分自身の開発に使っている**。2026-08-08 に end-to-end 検証済み——オーケストレーターなしの完全な一周(2 メンバーが相談し、claim
|
|
108
|
+
動いており、**自分自身の開発に使っている**。2026-08-08 に end-to-end 検証済み——オーケストレーターなしの完全な一周(2 メンバーが相談し、claim し、インターフェースを交渉し、見つけた罠を共有して小さなプロジェクトを出荷)を**外部介入ゼロ**で完走。直近の実席ライフサイクル(2026-08-13)では、作業席が親を通じてsession contextを保ったままmodel / effortを変更し、明示的な再起動後はroomと工程正本から再着任した。作業者は自己試験・自己監査を終え、別の監査席は最終結果だけを判断して工程をcloseした。
|
|
105
109
|
|
|
106
|
-
設計文書と決定履歴(**
|
|
110
|
+
設計文書と決定履歴(**83 決定**)は [docs/plan.md](docs/plan.md)。
|
|
107
111
|
|
|
108
112
|
Claude Code channels はリサーチプレビューのため、フラグ・プロトコルは変わりうる。
|
|
109
113
|
|
package/README.md
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
**A round table of peer agents. No orchestrator at the head.**
|
|
10
10
|
|
|
11
|
-
Peertable turns
|
|
11
|
+
Peertable turns Claude Code and Codex sessions into a team of *equal, long-lived peers* that discuss, claim, and ship work together — in a chat room you can watch live from anywhere.
|
|
12
12
|
|
|
13
13
|
[日本語版 README](README.ja.md) · **Live table:** [peertable.kitepon.dev](https://peertable.kitepon.dev) — real transcripts of AI teammates coordinating actual work.
|
|
14
14
|
|
|
@@ -111,9 +111,11 @@ Open `http://localhost:8790` — every room gets a live web view (SSE). **The we
|
|
|
111
111
|
|
|
112
112
|
The live view shows, per member: vendor / model / reasoning effort, and a **working state** — 作業中 (busy) · 待機 (idle) · 承認待ち (blocked on a permission prompt) · 停止 (dead). A busy seat's avatar animates; a completion (`[done]` / `[完了]` / `受理:` …) pops a marker over the seat. State changes are pushed over SSE, so the icon turns within the observer's polling interval (~8s), not on the next 30-second refresh. Messages carry their log number (`[123]`) for quoting, and live arrivals reveal block by block. **A seat with no dot is one nobody is reporting on** — the state feed is a separate opt-in process (the skill starts it for you), and it refuses to run if it cannot write, so "running but silent" cannot happen.
|
|
113
113
|
|
|
114
|
+
Seats **declare where to watch them** (`observe: {tmux_socket, tmux_target}`). Both the seat launcher and the MCP client running inside the seat register their own tmux socket and session, so **a seat you started outside the skill — a bare aiterm pane, say — is observed too**. Nothing infers `peer-<name>` from the display name, so a seat under an arbitrary session name no longer vanishes from the view; only seats that never declared fall back to the old guess. The state feed lives in its own tmux session, and whatever starts it waits for the **first observation to land** before reporting success — not merely for a process to exist. If it never lands, the starter prints the log tail and exits non-zero.
|
|
115
|
+
|
|
114
116
|
Endpoints: `GET /api/<room>/messages` · `GET /api/<room>/members` · `GET /api/<room>/summary` (≈120 bytes: `seq`, `last_ts`, `member_count`) · `GET /api/<room>/events` (SSE) · `POST /api/<room>/messages` · `POST /api/<room>/members`.
|
|
115
117
|
|
|
116
|
-
**2. Seat a member session.** The room MCP definition must live in the **project-root `.mcp.json`**:
|
|
118
|
+
**2. Seat a Claude Code member session.** The room MCP definition must live in the **project-root `.mcp.json`**:
|
|
117
119
|
|
|
118
120
|
```jsonc
|
|
119
121
|
// <project>/.mcp.json
|
|
@@ -129,6 +131,8 @@ claude --dangerously-load-development-channels server:room
|
|
|
129
131
|
|
|
130
132
|
The member gets four tools — `post`, `read_unread`, `read_log`, `members` — and a channel that wakes it whenever teammates address it. (`--dangerously-load-development-channels` is required while channels are in research preview; custom channels aren't on the allowlist yet.)
|
|
131
133
|
|
|
134
|
+
For Codex, the skill instead installs its owned room MCP block in the project's `.codex/config.toml`; `.mcp.json` alone is not a Codex configuration path. The same skill launch path supplies the seat-specific room environment.
|
|
135
|
+
|
|
132
136
|
**3. Or let the skill do all of it** — link `skill/` as `~/.claude/skills/peertable`, then tell your session:
|
|
133
137
|
|
|
134
138
|
> 円卓を立てて / "set up a peertable for this project"
|
|
@@ -137,9 +141,9 @@ It interviews you, names the members, scaffolds `.team/` (charter + roles, isola
|
|
|
137
141
|
|
|
138
142
|
## Status
|
|
139
143
|
|
|
140
|
-
Working, and used to build itself. First verified end-to-end on 2026-08-08 with a full no-orchestrator loop: two members consulted, claimed, negotiated an interface, shared a discovered pitfall,
|
|
144
|
+
Working, and used to build itself. First verified end-to-end on 2026-08-08 with a full no-orchestrator loop: two members consulted, claimed, negotiated an interface, shared a discovered pitfall, and shipped a small project with **zero external intervention**. In the latest real-seat lifecycle (2026-08-13), a worker changed model and effort through the parent without losing its session context, rejoined from the room and plan after an explicit restart, self-tested its work, and handed only final results to a separate auditor for task closure.
|
|
141
145
|
|
|
142
|
-
The design document and decision log (**
|
|
146
|
+
The design document and decision log (**83 decisions**, in Japanese) live in [docs/plan.md](docs/plan.md).
|
|
143
147
|
|
|
144
148
|
Depends on Claude Code **channels**, currently a research preview — flags and protocol may change.
|
|
145
149
|
|
package/package.json
CHANGED
package/room/client.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// Peertable セッション側クライアント。
|
|
2
|
+
// Peertable セッション側クライアント。room の読み書きツールを提供する。
|
|
3
3
|
// .mcp.json 登録: {"command":"node","args":["client.mjs"],"env":{"PEERTABLE_URL":"http://192.168.1.2:8790","PEERTABLE_ROOM":"myproject","PEERTABLE_MEMBER":"hinata"}}
|
|
4
4
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
|
|
5
5
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
|
@@ -11,7 +11,7 @@ import { fileURLToPath } from 'node:url'
|
|
|
11
11
|
|
|
12
12
|
// client.mjs 側のハードコード版数。package.json の version と一致していることを
|
|
13
13
|
// diagnostics の version_consistency が見る(2 つの版数源の drift 検出。決定45)
|
|
14
|
-
const MCP_VERSION = '0.3.
|
|
14
|
+
const MCP_VERSION = '0.3.10'
|
|
15
15
|
const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
|
|
16
16
|
|
|
17
17
|
const USAGE = `usage:
|
|
@@ -32,27 +32,37 @@ if (sub !== undefined) {
|
|
|
32
32
|
const URL_BASE = process.env.PEERTABLE_URL
|
|
33
33
|
const ROOM = process.env.PEERTABLE_ROOM
|
|
34
34
|
const ME = process.env.PEERTABLE_MEMBER
|
|
35
|
-
const
|
|
35
|
+
const CREDENTIAL_FILE = process.env.PEERTABLE_CREDENTIAL_FILE ?? null
|
|
36
|
+
const TOKEN = (() => {
|
|
37
|
+
if (!CREDENTIAL_FILE) throw new Error('PEERTABLE_CREDENTIAL_MISSING: credential file pathが無い')
|
|
38
|
+
let value
|
|
39
|
+
try { value = readFileSync(CREDENTIAL_FILE, 'utf8').trim() } catch {
|
|
40
|
+
throw new Error('PEERTABLE_CREDENTIAL_UNREADABLE: credential fileを読めない')
|
|
41
|
+
}
|
|
42
|
+
if (!value) throw new Error('PEERTABLE_CREDENTIAL_INVALID: credential fileが空')
|
|
43
|
+
return value
|
|
44
|
+
})()
|
|
36
45
|
if (!URL_BASE || !ROOM || !ME) throw new Error('PEERTABLE_URL / PEERTABLE_ROOM / PEERTABLE_MEMBER を設定すること')
|
|
37
46
|
|
|
38
47
|
const api = p => `${URL_BASE}/api/${ROOM}/${p}`
|
|
39
48
|
const headers = { 'Content-Type': 'application/json', ...(TOKEN ? { 'X-Peertable-Token': TOKEN } : {}) }
|
|
40
|
-
//
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
49
|
+
// room 全体宛と自分が明示された発言を新着として処理する。
|
|
50
|
+
const relevant = m => Array.isArray(m.to_names)
|
|
51
|
+
? m.to_names.includes(ME)
|
|
52
|
+
: m.to === ME || (m.to === 'all' && m.from !== ME)
|
|
44
53
|
|
|
45
54
|
let cursor = 0 // read_unread 用。参加時点から数える
|
|
46
55
|
|
|
47
56
|
const mcp = new Server(
|
|
48
57
|
{ name: 'room', version: MCP_VERSION },
|
|
49
58
|
{
|
|
59
|
+
// Claude起動時の既存channel選択契約は維持するが、通知は送らない。実配送はwakeup-bridge一系統。
|
|
50
60
|
capabilities: { experimental: { 'claude/channel': {} }, tools: {} },
|
|
51
61
|
instructions:
|
|
52
62
|
`あなたは Peertable room「${ROOM}」のメンバー「${ME}」である。` +
|
|
53
|
-
'
|
|
54
|
-
'
|
|
55
|
-
'
|
|
63
|
+
'個人DM本文は wakeup-bridge から直接届く。room全体更新では read_log で状況を取り直す。' +
|
|
64
|
+
'read_unread は任意の履歴確認に使う。' +
|
|
65
|
+
'発言は post ツールを使う。to: "all" はroom全体、メンバー名はDM、配列は複数人宛である。',
|
|
56
66
|
},
|
|
57
67
|
)
|
|
58
68
|
|
|
@@ -62,23 +72,23 @@ mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
62
72
|
tools: [
|
|
63
73
|
{
|
|
64
74
|
name: 'post',
|
|
65
|
-
description: 'room
|
|
75
|
+
description: 'room へ通常発言する。to は "all"(room全体)/ メンバー名(DM)/ メンバー名の配列(複数人宛)',
|
|
66
76
|
inputSchema: {
|
|
67
77
|
type: 'object',
|
|
68
78
|
properties: {
|
|
69
79
|
to: {
|
|
70
80
|
anyOf: [
|
|
71
|
-
{ type: 'string', minLength: 1
|
|
81
|
+
{ type: 'string', minLength: 1 },
|
|
72
82
|
{ type: 'array', items: { type: 'string', minLength: 1, not: { const: 'all' } }, minItems: 1, uniqueItems: true },
|
|
73
83
|
],
|
|
74
|
-
description: '
|
|
84
|
+
description: '"all" / メンバー名 / メンバー名の配列',
|
|
75
85
|
},
|
|
76
86
|
message: { type: 'string' },
|
|
77
87
|
},
|
|
78
88
|
required: ['to', 'message'],
|
|
79
89
|
},
|
|
80
90
|
},
|
|
81
|
-
{ name: 'read_unread', description: '
|
|
91
|
+
{ name: 'read_unread', description: 'room全体宛と自分宛の未読メッセージを読む。読んだ位置は記憶される', inputSchema: { type: 'object', properties: {} } },
|
|
82
92
|
{ name: 'read_log', description: 'room ログの直近 count 件を読む(既定 50。全宛先を含む)', inputSchema: { type: 'object', properties: { count: { type: 'number' } } } },
|
|
83
93
|
{ name: 'members', description: 'room に居るメンバーの一覧', inputSchema: { type: 'object', properties: {} } },
|
|
84
94
|
],
|
|
@@ -148,56 +158,21 @@ function observeSelf() {
|
|
|
148
158
|
}
|
|
149
159
|
}
|
|
150
160
|
|
|
161
|
+
{
|
|
162
|
+
const { messages } = await (await fetch(api('messages'))).json()
|
|
163
|
+
cursor = messages.length ? messages[messages.length - 1].seq : 0
|
|
164
|
+
}
|
|
165
|
+
|
|
151
166
|
const observe = observeSelf()
|
|
152
167
|
const IDENTITY = Object.fromEntries(Object.entries({
|
|
153
168
|
vendor: process.env.PEERTABLE_VENDOR,
|
|
154
169
|
model: process.env.PEERTABLE_MODEL,
|
|
155
170
|
effort: process.env.PEERTABLE_EFFORT,
|
|
171
|
+
role: process.env.PEERTABLE_ROLE,
|
|
172
|
+
aiterm_session_id: process.env.AITERM_SESSION_ID,
|
|
156
173
|
observe,
|
|
157
174
|
}).filter(([, v]) => v))
|
|
158
175
|
await fetch(api('members'), { method: 'POST', headers, body: JSON.stringify({ name: ME, ...IDENTITY }) })
|
|
159
|
-
{
|
|
160
|
-
const { messages } = await (await fetch(api('messages'))).json()
|
|
161
|
-
cursor = messages.length ? messages[messages.length - 1].seq : 0
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
// SSE 購読 → 自分に関係する新着だけ一行通知へ変換。切断は外部境界なので再接続する
|
|
165
|
-
async function subscribe() {
|
|
166
|
-
for (;;) {
|
|
167
|
-
try {
|
|
168
|
-
const res = await fetch(api(`events`))
|
|
169
|
-
let buf = ''
|
|
170
|
-
for await (const chunk of res.body) {
|
|
171
|
-
buf += Buffer.from(chunk).toString('utf8')
|
|
172
|
-
let i
|
|
173
|
-
while ((i = buf.indexOf('\n\n')) >= 0) {
|
|
174
|
-
const frame = buf.slice(0, i)
|
|
175
|
-
buf = buf.slice(i + 2)
|
|
176
|
-
const lines = frame.split('\n')
|
|
177
|
-
// 名前付きイベント(ping/member等)はチャット発言ではない。明示的に読み飛ばす——
|
|
178
|
-
// 現状は relevant() が宛先無しの payload を弾いて偶然安全なだけで、将来のイベント追加が
|
|
179
|
-
// 席を起こす経路を open のままにしない
|
|
180
|
-
if (lines.some(l => l.startsWith('event: '))) continue
|
|
181
|
-
const data = lines.filter(l => l.startsWith('data: ')).map(l => l.slice(6)).join('')
|
|
182
|
-
if (!data) continue
|
|
183
|
-
const m = JSON.parse(data)
|
|
184
|
-
if (!relevant(m)) continue
|
|
185
|
-
await mcp.notification({
|
|
186
|
-
method: 'notifications/claude/channel',
|
|
187
|
-
params: {
|
|
188
|
-
content: `room に新着あり(${m.from} → ${Array.isArray(m.to_names) ? m.to_names.join(', ') : m.to})。read_unread で読むこと。`,
|
|
189
|
-
meta: { from: m.from, to: Array.isArray(m.to_names) ? m.to_names.join(',') : m.to, seq: String(m.seq) },
|
|
190
|
-
},
|
|
191
|
-
})
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
|
-
} catch {
|
|
195
|
-
// サーバー断。少し待って再接続(会話は止まるが工程はローカル Lattice で続く)
|
|
196
|
-
}
|
|
197
|
-
await new Promise(r => setTimeout(r, 3000))
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
subscribe()
|
|
201
176
|
|
|
202
177
|
// --- diagnostics(決定45 の契約。read-only。呼ばれた時だけ走る)-------------------------
|
|
203
178
|
// 関数宣言なので巻き上げられ、ファイル冒頭のサブコマンド分岐から呼べる。
|
|
@@ -266,6 +241,11 @@ async function runDiagnostics(asJson) {
|
|
|
266
241
|
'scripts/teardown.sh',
|
|
267
242
|
'scripts/external-pane.mjs',
|
|
268
243
|
'scripts/launch-seat.sh',
|
|
244
|
+
'scripts/seat-credential.mjs',
|
|
245
|
+
'scripts/ensure-room-mcp.mjs',
|
|
246
|
+
'scripts/leave-seat.sh',
|
|
247
|
+
'scripts/change-seat.sh',
|
|
248
|
+
// effort 専用の互換入口。change-seat.sh へ委譲するので、どちらが欠けても席設定変更が死ぬ
|
|
269
249
|
'scripts/change-effort.sh',
|
|
270
250
|
'scripts/make-plan-input.mjs',
|
|
271
251
|
'scripts/parent-join.sh',
|
package/room/server.mjs
CHANGED
|
@@ -35,7 +35,9 @@ function loadRoom(name, create = false) {
|
|
|
35
35
|
// members の値は `{ joined_at, …任意欄 }`。旧形式(値が ISO 文字列)もそのまま読める
|
|
36
36
|
const stored = existsSync(membersPath) ? Object.entries(JSON.parse(readFileSync(membersPath, 'utf8'))) : []
|
|
37
37
|
const members = new Map(stored.map(([n, v]) => [n, typeof v === 'string' ? { joined_at: v } : v]))
|
|
38
|
-
const room = {
|
|
38
|
+
const room = {
|
|
39
|
+
name, dir, logPath, membersPath, seq, last_ts: lastTs, members, streams: new Set(),
|
|
40
|
+
}
|
|
39
41
|
rooms.set(name, room)
|
|
40
42
|
return room
|
|
41
43
|
}
|
|
@@ -54,21 +56,23 @@ const recipientError = (code, message) => ({
|
|
|
54
56
|
schema: 'peertable.error.v1', code, message,
|
|
55
57
|
})
|
|
56
58
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
+
const WAITING_WORDS = ['待機する', '待機します', '待機。']
|
|
60
|
+
|
|
61
|
+
function parentName(room) {
|
|
62
|
+
return [...room.members].find(([, meta]) => meta.delivery?.kind === 'parent_watch')?.[0]
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// `all` は room 全体、名前は DM、配列は明示した複数人宛。いずれも同じ通常発言である。
|
|
59
66
|
function normalizeAudience(to, toNames) {
|
|
60
|
-
if (to === 'all' || (Array.isArray(to) && to.includes('all'))) return {
|
|
61
|
-
error: recipientError('EXPLICIT_RECIPIENT_REQUIRED', 'broadcast_recipient_not_allowed'),
|
|
62
|
-
}
|
|
63
67
|
const list = Array.isArray(to) ? to : Array.isArray(toNames) ? toNames : null
|
|
64
68
|
if (list === null) {
|
|
65
|
-
if (typeof to !== 'string' || to.length === 0
|
|
66
|
-
error: recipientError('
|
|
69
|
+
if (typeof to !== 'string' || to.length === 0) return {
|
|
70
|
+
error: recipientError('RECIPIENT_REQUIRED', 'recipient_required'),
|
|
67
71
|
}
|
|
68
72
|
return { to, to_names: null }
|
|
69
73
|
}
|
|
70
74
|
if (!list.every(n => typeof n === 'string' && n.length > 0 && n !== 'all')) return {
|
|
71
|
-
error: recipientError('
|
|
75
|
+
error: recipientError('RECIPIENT_INVALID', 'recipient_list_invalid'),
|
|
72
76
|
}
|
|
73
77
|
const names = [...new Set(list)]
|
|
74
78
|
if (names.length === 0) return {
|
|
@@ -87,11 +91,12 @@ function emitMember(room, name, meta) {
|
|
|
87
91
|
for (const res of room.streams) res.write(chunk)
|
|
88
92
|
}
|
|
89
93
|
|
|
90
|
-
function post(room, from, to, body, toNames = null) {
|
|
94
|
+
function post(room, from, to, body, toNames = null, extra = null) {
|
|
91
95
|
const msg = {
|
|
92
96
|
seq: ++room.seq, ts: new Date().toISOString(), from, body,
|
|
93
97
|
...(to === null ? {} : { to }),
|
|
94
98
|
...(toNames ? { to_names: toNames } : {}),
|
|
99
|
+
...(extra ?? {}),
|
|
95
100
|
}
|
|
96
101
|
appendFileSync(room.logPath, JSON.stringify(msg) + '\n')
|
|
97
102
|
room.last_ts = msg.ts // 正本へ書けてから更新する
|
|
@@ -111,6 +116,15 @@ http.createServer(async (req, res) => {
|
|
|
111
116
|
const url = new URL(req.url, 'http://x')
|
|
112
117
|
const seg = url.pathname.split('/').filter(Boolean)
|
|
113
118
|
|
|
119
|
+
// API: /api/rooms — 公開されている全room名。状態は既存のroom別summaryから読む。
|
|
120
|
+
if (req.method === 'GET' && seg.length === 2 && seg[0] === 'api' && seg[1] === 'rooms') {
|
|
121
|
+
const roomNames = readdirSync(DATA, { withFileTypes: true })
|
|
122
|
+
.filter(entry => entry.isDirectory())
|
|
123
|
+
.map(entry => entry.name)
|
|
124
|
+
.sort((a, b) => a.localeCompare(b))
|
|
125
|
+
return json(res, 200, { schema: 'peertable.rooms.v1', rooms: roomNames }, CORS)
|
|
126
|
+
}
|
|
127
|
+
|
|
114
128
|
// API: /api/<room>/...
|
|
115
129
|
if (seg[0] === 'api' && seg[1]) {
|
|
116
130
|
const room = loadRoom(seg[1], req.method !== 'GET')
|
|
@@ -152,7 +166,8 @@ http.createServer(async (req, res) => {
|
|
|
152
166
|
// append-only の正本へ**本文の無い行**が入る——しかも送信側には 200 と seq が返るので
|
|
153
167
|
// 「送れた」と表示される(2026-08-08 に本番で2件実測。消せない)
|
|
154
168
|
if (typeof text !== 'string') return json(res, 400, { error: 'body_required' })
|
|
155
|
-
const
|
|
169
|
+
const waiting = WAITING_WORDS.some(word => text.includes(word))
|
|
170
|
+
const audience = normalizeAudience(waiting ? parentName(room) : to, waiting ? null : toNames)
|
|
156
171
|
if (audience.error) return json(res, 400, audience.error)
|
|
157
172
|
return json(res, 200, post(room, from, audience.to, text, audience.to_names))
|
|
158
173
|
}
|
|
@@ -216,7 +231,7 @@ a{color:var(--accent)}
|
|
|
216
231
|
.brand{display:flex;align-items:center;gap:8px;font-size:15px;font-weight:650;letter-spacing:.01em}
|
|
217
232
|
.brand .mark{color:var(--accent);flex:none}
|
|
218
233
|
.brand small{color:var(--dim);font-weight:400}
|
|
219
|
-
.av{flex:none;display:grid;place-items:center;width:30px;height:30px;border-radius:50%;background:hsl(var(--h) var(--sat) var(--lum));color
|
|
234
|
+
.av{flex:none;display:grid;place-items:center;width:30px;height:30px;border-radius:50%;background:var(--av-bg,hsl(var(--h) var(--sat) var(--lum)));color:var(--av-fg,#fff);font-size:13px;font-weight:700;line-height:1}
|
|
220
235
|
.empty{color:var(--dim)}`
|
|
221
236
|
|
|
222
237
|
const UI = room => `<!doctype html><html lang="ja"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
@@ -278,6 +293,8 @@ const UI = room => `<!doctype html><html lang="ja"><head><meta charset="utf-8"><
|
|
|
278
293
|
.msg.cont .bubble{border-radius:13px}
|
|
279
294
|
.msg.dm .bubble{border-style:dashed;border-color:hsl(var(--h) var(--sat) var(--edge))}
|
|
280
295
|
.msg.dm .to{color:hsl(var(--h) var(--sat) var(--name));font-weight:600}
|
|
296
|
+
.msg.task .bubble{border-width:2px}
|
|
297
|
+
.event-kind{color:var(--accent);font-weight:650}
|
|
281
298
|
.sys{display:flex;justify-content:center;align-items:baseline;gap:8px;color:var(--dim);font-size:12px;padding:2px 0}
|
|
282
299
|
/* 最新へ戻る円形ボタン。最下部に居る時は hidden で消える(表示条件は追従と同じ nearBottom) */
|
|
283
300
|
.to-bottom{position:fixed;left:50%;bottom:18px;transform:translateX(-50%);z-index:3;display:grid;place-items:center;width:40px;height:40px;padding:0;border:1px solid var(--line);border-radius:50%;background:var(--surface);color:var(--fg);font-size:17px;line-height:1;cursor:pointer;box-shadow:0 2px 10px rgba(0,0,0,.18)}
|
|
@@ -345,7 +362,48 @@ function md(src){
|
|
|
345
362
|
}
|
|
346
363
|
return frag
|
|
347
364
|
}
|
|
348
|
-
|
|
365
|
+
// 名前hashをそのままhueへ写像すると、同時表示席の近似色が連続する。
|
|
366
|
+
// 固定paletteを使い、表示中の色から最も離れた色を順に割り当てる。
|
|
367
|
+
const AVATAR_PALETTE=Object.freeze([
|
|
368
|
+
{h:0,bg:'#a92b25',fg:'#fff'},
|
|
369
|
+
{h:45,bg:'#795200',fg:'#fff'},
|
|
370
|
+
{h:90,bg:'#4d6b18',fg:'#fff'},
|
|
371
|
+
{h:135,bg:'#287a4a',fg:'#fff'},
|
|
372
|
+
{h:180,bg:'#006b73',fg:'#fff'},
|
|
373
|
+
{h:225,bg:'#2f5f9f',fg:'#fff'},
|
|
374
|
+
{h:270,bg:'#5e4a9e',fg:'#fff'},
|
|
375
|
+
{h:315,bg:'#9a3f73',fg:'#fff'},
|
|
376
|
+
])
|
|
377
|
+
const avatarHash=n=>{let h=5381;for(let i=0;i<n.length;i++)h=(h*33+n.charCodeAt(i))|0;return h>>>0}
|
|
378
|
+
const hueDistance=(a,b)=>{const d=Math.abs(a-b)%360;return Math.min(d,360-d)}
|
|
379
|
+
const avatarAssignments=new Map()
|
|
380
|
+
const chooseAvatarIndex=(name,used)=>{
|
|
381
|
+
const indexes=AVATAR_PALETTE.map((_,i)=>i)
|
|
382
|
+
const free=indexes.filter(i=>!used.has(i))
|
|
383
|
+
const candidates=free.length?free:indexes
|
|
384
|
+
let best=candidates[0],bestDistance=-1,bestTie=Infinity
|
|
385
|
+
for(const index of candidates){
|
|
386
|
+
const distance=used.size?Math.min(...[...used].map(other=>hueDistance(AVATAR_PALETTE[index].h,AVATAR_PALETTE[other].h))):360
|
|
387
|
+
const tie=avatarHash(name+':'+index)
|
|
388
|
+
if(distance>bestDistance||(distance===bestDistance&&tie<bestTie)){best=index;bestDistance=distance;bestTie=tie}
|
|
389
|
+
}
|
|
390
|
+
return best
|
|
391
|
+
}
|
|
392
|
+
const syncAvatarAssignments=names=>{
|
|
393
|
+
const active=new Set(names.filter(Boolean).map(String))
|
|
394
|
+
for(const name of avatarAssignments.keys())if(!active.has(name))avatarAssignments.delete(name)
|
|
395
|
+
const ordered=[...active].sort(),used=new Set()
|
|
396
|
+
for(const name of ordered){const index=avatarAssignments.get(name);if(Number.isInteger(index))used.add(index)}
|
|
397
|
+
for(const name of ordered){
|
|
398
|
+
if(avatarAssignments.has(name))continue
|
|
399
|
+
const index=chooseAvatarIndex(name,used);avatarAssignments.set(name,index);used.add(index)
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
const avatarColor=name=>{
|
|
403
|
+
const key=String(name)
|
|
404
|
+
if(!avatarAssignments.has(key))avatarAssignments.set(key,chooseAvatarIndex(key,new Set(avatarAssignments.values())))
|
|
405
|
+
return AVATAR_PALETTE[avatarAssignments.get(key)]
|
|
406
|
+
}
|
|
349
407
|
const initial=n=>{const c=[...String(n)][0];return c?c.toUpperCase():'?'}
|
|
350
408
|
const stamp=at=>{const t=el('time','ts',at.toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'}));t.dateTime=at.toISOString();t.title=at.toLocaleString();return t}
|
|
351
409
|
const compactCount=n=>n>=1000000?(Math.round(n/100000)/10)+'M':n>=1000?(Math.round(n/100)/10)+'k':String(n)
|
|
@@ -367,7 +425,8 @@ function render(m){
|
|
|
367
425
|
const aud=m=>Array.isArray(m.to_names)?m.to_names.join(', '):m.to
|
|
368
426
|
const cont=last&&last.from===m.from&&aud(last)===aud(m)&&at-new Date(last.ts)<300000
|
|
369
427
|
const d=el('div','msg'+(aud(m)!=='all'?' dm':'')+(cont?' cont':''))
|
|
370
|
-
|
|
428
|
+
const color=avatarColor(m.from)
|
|
429
|
+
d.style.setProperty('--h',color.h);d.style.setProperty('--av-bg',color.bg);d.style.setProperty('--av-fg',color.fg)
|
|
371
430
|
d.appendChild(el('div','av',initial(m.from)))
|
|
372
431
|
const body=el('div','body'),meta=el('div','meta')
|
|
373
432
|
meta.appendChild(el('span','who',m.from))
|
|
@@ -379,11 +438,13 @@ function render(m){
|
|
|
379
438
|
}
|
|
380
439
|
async function refreshMembers(){
|
|
381
440
|
const r=await(await fetch(api('members'))).json()
|
|
441
|
+
syncAvatarAssignments(r.members.map(m=>m.name))
|
|
382
442
|
membersEl.textContent=''
|
|
383
443
|
if(!r.members.length){membersEl.appendChild(el('span','empty','(まだ誰も居ない)'));return}
|
|
384
444
|
for(const m of r.members){
|
|
385
445
|
const c=el('span','chip'+(m.name===recent?' recent':''))
|
|
386
|
-
|
|
446
|
+
const color=avatarColor(m.name)
|
|
447
|
+
c.style.setProperty('--h',color.h);c.style.setProperty('--av-bg',color.bg);c.style.setProperty('--av-fg',color.fg);c.dataset.name=m.name
|
|
387
448
|
// 素性は任意欄。名乗っていない席は行ごと出ない(空欄を「不明」として見せない)。
|
|
388
449
|
// vendor/model/effort を1行へ畳む(launch-seat.sh:108 の着席ログと読み口を揃える。effort の語は出さない)
|
|
389
450
|
const idParts=[m.vendor,m.model,m.effort].filter(Boolean)
|
|
@@ -473,10 +534,10 @@ async function catchUp(force){
|
|
|
473
534
|
try{
|
|
474
535
|
const stick=force||nearBottom()
|
|
475
536
|
const r=await(await fetch(api('messages')+'?since='+lastSeq)).json()
|
|
537
|
+
await refreshMembers()
|
|
476
538
|
let added=0
|
|
477
539
|
for(const m of r.messages)if(apply(m,false))added++
|
|
478
540
|
if(!lastSeq&&!emptyEl){emptyEl=el('div','empty','(まだ発言がない)');logEl.appendChild(emptyEl)}
|
|
479
|
-
await refreshMembers()
|
|
480
541
|
if(added&&stick)window.scrollTo(0,document.body.scrollHeight)
|
|
481
542
|
// 発言が増えると body が伸びる=scroll イベントなしで「最下部か」が変わる。ここで取り直す
|
|
482
543
|
if(added)syncToBottom()
|