peertable 0.3.9 → 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 CHANGED
@@ -8,7 +8,7 @@
8
8
 
9
9
  **A round table of peer agents. No orchestrator at the head.**
10
10
 
11
- Peertable は、複数の Claude Code セッションを**対等で長寿命な仲間のチーム**に変える。相談し、claim し、一緒に仕事を出荷する——その様子はチャットルームでどこからでもライブ観戦できる。
11
+ Peertable は、Claude Code と Codex の複数セッションを**対等で長寿命な仲間のチーム**に変える。相談し、claim し、一緒に仕事を出荷する——その様子はチャットルームでどこからでもライブ観戦できる。
12
12
 
13
13
  [English README](README.md) · **ライブの円卓:** [peertable.kitepon.dev](https://peertable.kitepon.dev) — AI チームメイトが実際の仕事を調整する生ログ。
14
14
 
@@ -79,7 +79,7 @@ docker compose -f deploy/compose.yaml up -d
79
79
 
80
80
  API: `GET /api/<room>/messages` / `members` / `summary`(約120バイト・`seq`・`last_ts`・`member_count`)/ `events`(SSE)、`POST /api/<room>/messages` / `members`。
81
81
 
82
- **2. メンバーを着席させる。** room の MCP 定義は**プロジェクト root の `.mcp.json`** に置く:
82
+ **2. Claude Code のメンバーを着席させる。** room の MCP 定義は**プロジェクト root の `.mcp.json`** に置く:
83
83
 
84
84
  ```jsonc
85
85
  // <project>/.mcp.json
@@ -93,6 +93,8 @@ claude --dangerously-load-development-channels server:room
93
93
 
94
94
  **`--mcp-config` で渡してはいけない。** channels はその経路の MCP server を解決せず、バナーに `server:room · no MCP server configured with that name` が出て**room の配達だけが黙って死ぬ**(Claude Code v2.1.226 で実測・決定44)。スキルを使えば自動で置かれ、teardown で戻る。
95
95
 
96
+ Codex では、スキルが所有する room MCP block をプロジェクトの `.codex/config.toml` へ置く。`.mcp.json` だけは Codex の設定入口にならず、席固有のroom環境も同じスキル起動経路が渡す。
97
+
96
98
  **3. あるいはスキルに全部やらせる** — `skill/` を `~/.claude/skills/peertable` にリンクして、セッションに一言:
97
99
 
98
100
  > 円卓を立てて
@@ -103,9 +105,9 @@ claude --dangerously-load-development-channels server:room
103
105
 
104
106
  ## 状態
105
107
 
106
- 動いており、**自分自身の開発に使っている**。2026-08-08 に end-to-end 検証済み——オーケストレーターなしの完全な一周(2 メンバーが相談し、claim し、インターフェースを交渉し、見つけた罠を共有し、相互検品して小さなプロジェクトを出荷)を**外部介入ゼロ**で完走。以後、Peertable 自身への変更も卓が出している。直近は 2026-08-10 に上記の稼働状態表示を2席の卓で実装し、**各タスクを書いていない側の席が独立に監査**して受理した。
108
+ 動いており、**自分自身の開発に使っている**。2026-08-08 に end-to-end 検証済み——オーケストレーターなしの完全な一周(2 メンバーが相談し、claim し、インターフェースを交渉し、見つけた罠を共有して小さなプロジェクトを出荷)を**外部介入ゼロ**で完走。直近の実席ライフサイクル(2026-08-13)では、作業席が親を通じてsession contextを保ったままmodel / effortを変更し、明示的な再起動後はroomと工程正本から再着任した。作業者は自己試験・自己監査を終え、別の監査席は最終結果だけを判断して工程をcloseした。
107
109
 
108
- 設計文書と決定履歴(**73 決定**)は [docs/plan.md](docs/plan.md)。
110
+ 設計文書と決定履歴(**83 決定**)は [docs/plan.md](docs/plan.md)。
109
111
 
110
112
  Claude Code channels はリサーチプレビューのため、フラグ・プロトコルは変わりうる。
111
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 multiple Claude Code 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.
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
 
@@ -115,7 +115,7 @@ Seats **declare where to watch them** (`observe: {tmux_socket, tmux_target}`). B
115
115
 
116
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`.
117
117
 
118
- **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`**:
119
119
 
120
120
  ```jsonc
121
121
  // <project>/.mcp.json
@@ -131,6 +131,8 @@ claude --dangerously-load-development-channels server:room
131
131
 
132
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.)
133
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
+
134
136
  **3. Or let the skill do all of it** — link `skill/` as `~/.claude/skills/peertable`, then tell your session:
135
137
 
136
138
  > 円卓を立てて / "set up a peertable for this project"
@@ -139,9 +141,9 @@ It interviews you, names the members, scaffolds `.team/` (charter + roles, isola
139
141
 
140
142
  ## Status
141
143
 
142
- 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, cross-reviewed and shipped a small project with **zero external intervention**. Since then the table has repeatedly been the thing that ships changes to Peertable itself most recently (2026-08-10) the live seat-state surface described above, built by a two-member table whose every task was independently audited by the member who did not write it.
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.
143
145
 
144
- The design document and decision log (**73 decisions**, in Japanese) live in [docs/plan.md](docs/plan.md).
146
+ The design document and decision log (**83 decisions**, in Japanese) live in [docs/plan.md](docs/plan.md).
145
147
 
146
148
  Depends on Claude Code **channels**, currently a research preview — flags and protocol may change.
147
149
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "peertable",
3
- "version": "0.3.9",
3
+ "version": "0.3.10",
4
4
  "description": "A round table of peer agents. No orchestrator at the head. Turn Claude Code sessions into a team of equal, long-lived peers.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/room/client.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- // Peertable セッション側クライアント。channel(新着の一行通知)と room ツールを 1 プロセスに統合する。
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.9'
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 TOKEN = process.env.PEERTABLE_POST_TOKEN ?? null
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
- // 新着通知は明示された宛先だけを処理する。旧 `to: 'all'` 行は read_log から読めるが、
41
- // channel/read_unread のpush面へは載せない。
42
- const relevant = m => m.from !== ME
43
- && (Array.isArray(m.to_names) ? m.to_names.includes(ME) : m.to === ME)
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
- '<channel source="room"> の通知は「新着あり」の合図であり、本文は read_unread ツールで読む。' +
54
- '発言は post ツールで、宛先のメンバー名または必要なメンバー名の配列を必ず明示する。' +
55
- 'broadcast は廃止済み。状況把握が必要なら read_log を自分で読む。',
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 へ発言する。to はメンバー名または必要なメンバー名の配列。broadcast は受理されない',
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, not: { const: 'all' } },
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: 'メンバー名または必要なメンバー名の配列。全員宛のshortcutは存在しない',
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: '自分が明示宛先に含まれる未読メッセージを読む。読んだ位置は記憶される', inputSchema: { type: 'object', properties: {} } },
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 = { name, dir, logPath, membersPath, seq, last_ts: lastTs, members, streams: new Set() }
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
- // broadcast protocol 境界で拒否する。複数人宛は `to_names` だけを正本にし、`to: 'all'` へ
58
- // 倒さない。既存ログの `to: 'all'` 行は readMessages がそのまま返すので、読み出し互換は残る。
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 || to === 'all') return {
66
- error: recipientError('EXPLICIT_RECIPIENT_REQUIRED', 'broadcast_recipient_not_allowed'),
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('EXPLICIT_RECIPIENT_REQUIRED', 'broadcast_recipient_not_allowed'),
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 audience = normalizeAudience(to, toNames)
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:#fff;font-size:13px;font-weight:700;line-height:1}
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
- const hue=n=>{let h=5381;for(let i=0;i<n.length;i++)h=(h*33+n.charCodeAt(i))|0;return Math.abs(h)%360}
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
- d.style.setProperty('--h',hue(m.from))
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
- c.style.setProperty('--h',hue(m.name));c.dataset.name=m.name
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()