peertable 0.8.6 → 0.8.8

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "peertable",
3
- "version": "0.8.6",
3
+ "version": "0.8.8",
4
4
  "description": "A round table of peer agents. No orchestrator at the head. Turn Claude Code, Codex, and Grok sessions into a team of equal, long-lived peers.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/room/client.mjs CHANGED
@@ -13,7 +13,7 @@ import { findModelsDoc, resolveSeatIdentity } from '../skill/scripts/resolve-sea
13
13
 
14
14
  // client.mjs 側のハードコード版数。package.json の version と一致していることを
15
15
  // diagnostics の version_consistency が見る(2 つの版数源の drift 検出。決定45)
16
- const MCP_VERSION = '0.8.6'
16
+ const MCP_VERSION = '0.8.8'
17
17
  const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
18
18
 
19
19
  const USAGE = `usage:
@@ -0,0 +1,6 @@
1
+ import { pathToFileURL } from 'node:url'
2
+
3
+ /** Windows絶対pathをNode ESM dynamic importが受理するfile URLへ変換する。 */
4
+ export function windowsImportSpecifier(path) {
5
+ return pathToFileURL(path).href
6
+ }
@@ -0,0 +1,9 @@
1
+ import { existsSync } from 'node:fs'
2
+ import { dirname, join } from 'node:path'
3
+
4
+ /** Windows npm global shimまたはsource treeのlattice CLIから契約moduleを解決する。 */
5
+ export function resolveWindowsLatticeContracts(latticeCli) {
6
+ const npmGlobal = join(dirname(latticeCli), 'node_modules', '@quolu', 'lattice', 'src', 'todo-contracts.mjs')
7
+ if (existsSync(npmGlobal)) return npmGlobal
8
+ return join(dirname(dirname(latticeCli)), 'src', 'todo-contracts.mjs')
9
+ }
@@ -145,6 +145,7 @@ function observeJob(socket, name) {
145
145
  const sessions = listed.split('\n').filter(s => s.startsWith(`peer-${name}-job`))
146
146
  if (sessions.length === 0) return { alive: false, active: false }
147
147
  let active = false
148
+ let psRows = null
148
149
  for (const session of sessions) {
149
150
  const pane = tmux(socket, 'capture-pane', '-t', session, '-p')
150
151
  if (pane === null) continue
@@ -152,6 +153,18 @@ function observeJob(socket, name) {
152
153
  const key = `${name}:${session}`
153
154
  const prev = jobPaneHash.get(key)
154
155
  if (!prev || prev.hash !== hash) { jobPaneHash.set(key, { hash }); active = true }
156
+ // 画面に何も出さず働くジョブ(checkpointだけ書く収集等)は画面hashでは稼働に見えない
157
+ // (実被弾 2026-08-25: 収集が毎分書き込み中なのにランプが点灯止まり)。jobセッションの
158
+ // プロセスツリーのCPU実働も稼働として合成する。セッション全体が預け仕事なので足場除外は不要。
159
+ if (!active) {
160
+ const jobPanePid = tmuxPanePid(socket, session)
161
+ if (jobPanePid) {
162
+ try {
163
+ psRows ??= execFileSync('ps', ['-axo', 'pid=,ppid=,pcpu=,etime='], { encoding: 'utf8' }).split('\n')
164
+ if (hasActiveDescendant(psRows, Number(jobPanePid), { minAgeGapSeconds: 0 })) active = true
165
+ } catch { /* psが使えない端末では画面hash判定だけで続行 */ }
166
+ }
167
+ }
155
168
  }
156
169
  return { alive: true, active }
157
170
  }
@@ -221,7 +234,8 @@ async function nudgeIfDropped(name, busySince) {
221
234
  const PATROL_INTERVAL_MS = 30_000
222
235
  const PATROL_NAG_INTERVAL_MS = 300_000 // 条件が続く席へは5分間隔で再吠えする(1回きりにしない)
223
236
  const patrolLastNag = new Map() // seat -> epoch_ms
224
- const busyStartedAt = new Map() // seat -> epoch_ms(このbridgeプロセスが観測した最後のターン開始)
237
+ const busyStartedAt = new Map() // seat -> epoch_ms(このbridgeプロセスが観測した最後のターン開始・pane基準)
238
+ const paneLast = new Map() // seat -> 直前周期のpane生status(番犬系専用。表示合成とは分離)
225
239
  let lastPatrolAt = 0
226
240
  async function patrolClaims() {
227
241
  if (setup.mode !== 'lattice' || !setup.plan_key) return
@@ -358,6 +372,10 @@ async function tick() {
358
372
  const observation = readSeat(member, prev, observedAt)
359
373
  // tmux 席を持たない member(親など)は一度も観測できないので送らない(deriveMissingSession が null を返す)
360
374
  if (observation === null) { skipped++; continue }
375
+ // 番犬(ターン終了検知・busy履歴)はpaneの生状態だけを読む。表示用の合成ランプ(job込み)を
376
+ // ここへ流すと、静かなジョブの画面出力の間欠でランプがbusy⇄idleに揺れ、その揺れを
377
+ // 「ターン終了」と誤認して正当待機の席へ[継続]を撃つ(実被弾 2026-08-25 #175: mio誤起床)。
378
+ const paneStatus = observation.status
361
379
  {
362
380
  const target = resolveSeatObservation(member, null) ?? resolveSeatObservation(member, defaultSocket())
363
381
  if (target !== null) {
@@ -367,7 +385,7 @@ async function tick() {
367
385
  const panePid = tmuxPanePid(target.socket, target.target)
368
386
  if (panePid) {
369
387
  try {
370
- const rows = execFileSync('ps', ['-axo', 'pid=,ppid=,pcpu='], { encoding: 'utf8' }).split('\n')
388
+ const rows = execFileSync('ps', ['-axo', 'pid=,ppid=,pcpu=,etime='], { encoding: 'utf8' }).split('\n')
371
389
  if (hasActiveDescendant(rows, Number(panePid))) job.active = true
372
390
  } catch { /* psが失敗する端末では子孫観測なしで続行(named session観測は生きている) */ }
373
391
  }
@@ -385,8 +403,10 @@ async function tick() {
385
403
  await send(name, observation, observedAt)
386
404
  last.set(name, { ...observation, at: now })
387
405
  sent++
388
- if ((observation.status === 'busy' || observation.status === 'blocked') && prev?.status !== 'busy' && prev?.status !== 'blocked') busyStartedAt.set(name, now)
389
- if (prev?.status === 'busy' && observation.status === 'idle') await nudgeIfDropped(name, prev.busySince)
406
+ const prevPane = paneLast.get(name)
407
+ if ((paneStatus === 'busy' || paneStatus === 'blocked') && prevPane !== 'busy' && prevPane !== 'blocked') busyStartedAt.set(name, now)
408
+ if (prevPane === 'busy' && paneStatus === 'idle') await nudgeIfDropped(name, prev?.busySince ?? null)
409
+ paneLast.set(name, paneStatus)
390
410
  if (changed) console.error(`seat-status-bridge: ${name} → ${observation.status}${prev ? `(${prev.status} から)` : ''}`)
391
411
  } catch (e) {
392
412
  failed++
@@ -4,6 +4,7 @@ import { execFileSync } from 'node:child_process'
4
4
  import { homedir, tmpdir } from 'node:os'
5
5
  import path, { join } from 'node:path'
6
6
  import { resolveWindowsLatticeCommand } from './platform/windows/resolve-lattice-command.mjs'
7
+ import { classifyGrokPaneTail, isGrokPrivacyBanner } from './vendors/grok/pane-status.mjs'
7
8
 
8
9
  const TOKEN_HINT = /[↓↑]\s*([0-9]+(?:\.[0-9]+)?)\s*([kKmM]?)\s*tokens\b/gu
9
10
 
@@ -191,13 +192,7 @@ export const BLOCKED_MARKERS = [
191
192
  'Press enter to confirm or esc to cancel',
192
193
  ]
193
194
 
194
- /** Grok TUI の SpaceXAI coding-data バナー(2026-08-21 実測。入力は通るが席が死んだように見える)。 */
195
- export function isGrokPrivacyBanner(tail) {
196
- return typeof tail === 'string'
197
- && tail.includes('Help improve Grok')
198
- && tail.includes('[Opt out]')
199
- && tail.includes('[Opt in]')
200
- }
195
+ export { isGrokPrivacyBanner }
201
196
 
202
197
  /**
203
198
  * pane 末尾の生文字列から画面状態を判定する。判定順は busy → blocked → idle
@@ -213,14 +208,9 @@ export function classifyPaneTail(tail) {
213
208
  if (tail.includes("without interrupting Claude's current work")) return 'busy'
214
209
  if (tail.includes('Calling tools')) return 'busy'
215
210
  if (/…\s*\(\d+(?:m \d+)?s\b/u.test(tail)) return 'busy'
216
- // Grok Build TUI は esc to interrupt を出さない(2026-08-21 実測)。
217
- // 生成中は `Waiting for response…` / `Responding…` と `[stop]`。
218
- // 完了後の `Worked for 38s` は idle。未完了 hook だけ `[hooks: 1/3]`。
219
- if (tail.includes('Waiting for response') || tail.includes('Responding…') || tail.includes('Responding...')) return 'busy'
220
- if (tail.includes('[stop]')) return 'busy'
221
- if (/\[hooks:\s*\d+\/\d+\]/u.test(tail)) return 'busy'
211
+ const grokStatus = classifyGrokPaneTail(tail)
212
+ if (grokStatus !== null) return grokStatus
222
213
  if (BLOCKED_MARKERS.some(marker => tail.includes(marker))) return 'blocked'
223
- if (isGrokPrivacyBanner(tail)) return 'blocked'
224
214
  return 'idle'
225
215
  }
226
216
 
@@ -280,10 +270,27 @@ export function parsePaneTokenHint(pane) {
280
270
  export const STOP_DECLARATION = /\[待機\]|\[監査提出\]|待機します|散会/u
281
271
 
282
272
  export function patrolTargets({ activeTasks, messages, statusOf, lastBusyStartAt, now, lastNag, nagIntervalMs }) {
283
- const owner = new Map() // task_id -> 最新のclaim発言者
273
+ // task_id -> claim発言者の履歴。[claim]で積み、[claim撤回]で本人の最新claimを取り消す
274
+ // (実被弾 2026-08-25 #160: 撤回を読めず、撤回済みの後発claim者へ番犬が誤って吠えた。
275
+ // roomのclaimは割当の正本(決定25)なので、撤回も同じ語彙で読む——正本の外の推定はしない)。
276
+ const claimHistory = new Map()
284
277
  for (const m of messages) {
285
- const match = /^\[claim\]\s+(\S+)/u.exec(m.body ?? '')
286
- if (match) owner.set(match[1], m.from)
278
+ const claim = /^\[claim\]\s+([0-9A-Za-z._-]+)/u.exec(m.body ?? '')
279
+ if (claim) {
280
+ if (!claimHistory.has(claim[1])) claimHistory.set(claim[1], [])
281
+ claimHistory.get(claim[1]).push(m.from)
282
+ continue
283
+ }
284
+ const retract = /^\[claim撤回\]\s+([0-9A-Za-z._-]+)/u.exec(m.body ?? '')
285
+ if (retract) {
286
+ const history = claimHistory.get(retract[1]) ?? []
287
+ const index = history.lastIndexOf(m.from)
288
+ if (index !== -1) history.splice(index, 1)
289
+ }
290
+ }
291
+ const owner = new Map()
292
+ for (const [task, history] of claimHistory) {
293
+ if (history.length > 0) owner.set(task, history.at(-1))
287
294
  }
288
295
  const targets = []
289
296
  for (const task of activeTasks) {
@@ -328,21 +335,36 @@ export function combineSeatLamp(paneStatus, job) {
328
335
  // tmuxセッションを作らずに走らせた預け仕事を、プロセスツリーの実観測で拾う(2026-08-25 オーナー裁定
329
336
  //「子プロセスも見る」)。nohup等でツリーから切り離された仕事はここでは見えない——それは
330
337
  // peer-<name>-job* セッション慣例の側が受け持つ(二本立て)。
331
- export function hasActiveDescendant(psRows, rootPid, { minCpu = 1.0 } = {}) {
338
+ export function hasActiveDescendant(psRows, rootPid, { minCpu = 1.0, minAgeGapSeconds = 60 } = {}) {
339
+ const parseEtime = (raw) => {
340
+ // ps etime: [[dd-]hh:]mm:ss
341
+ const m = /^(?:(\d+)-)?(?:(\d+):)?(\d+):(\d+)$/.exec(raw)
342
+ if (!m) return null
343
+ return (Number(m[1] ?? 0) * 86400) + (Number(m[2] ?? 0) * 3600) + (Number(m[3]) * 60) + Number(m[4])
344
+ }
332
345
  const children = new Map()
333
346
  const cpu = new Map()
347
+ const age = new Map()
334
348
  for (const row of psRows) {
335
- const m = /^\s*(\d+)\s+(\d+)\s+([\d.]+)/.exec(row)
349
+ const m = /^\s*(\d+)\s+(\d+)\s+([\d.]+)\s+(\S+)/.exec(row)
336
350
  if (!m) continue
337
351
  const [pid, ppid, pcpu] = [Number(m[1]), Number(m[2]), Number(m[3])]
338
352
  if (!children.has(ppid)) children.set(ppid, [])
339
353
  children.get(ppid).push(pid)
340
354
  cpu.set(pid, pcpu)
355
+ age.set(pid, parseEtime(m[4]))
341
356
  }
357
+ const rootAge = age.get(rootPid)
342
358
  const queue = [...(children.get(rootPid) ?? [])]
343
359
  while (queue.length) {
344
360
  const pid = queue.pop()
345
- if ((cpu.get(pid) ?? 0) >= minCpu) return true
361
+ // 席と同時に起動したプロセスは足場(CLI本体・MCP server群)であって預け仕事ではない。
362
+ // 足場はアイドルでも1%前後のCPUを食い、ランプを恒常的に点滅させる誤検知源になる
363
+ // (実被弾 2026-08-25: 監査待ちのkoharuが「ずっとアクティブ」に見えた)。
364
+ // 「paneより有意に後から生まれた」ことだけを預け仕事の観測条件にする——起動時刻は
365
+ // 観測できる事実であり、コマンド名の恣意的なリストを持たない。
366
+ const laterBorn = rootAge != null && age.get(pid) != null && rootAge - age.get(pid) >= minAgeGapSeconds
367
+ if (laterBorn && (cpu.get(pid) ?? 0) >= minCpu) return true
346
368
  queue.push(...(children.get(pid) ?? []))
347
369
  }
348
370
  return false
@@ -20,6 +20,8 @@
20
20
  import { accessSync, constants, existsSync, readFileSync, realpathSync, writeFileSync } from 'node:fs'
21
21
  import { execFileSync } from 'node:child_process'
22
22
  import { dirname, isAbsolute, join, relative, resolve } from 'node:path'
23
+ import { windowsImportSpecifier } from './platform/windows/import-specifier.mjs'
24
+ import { resolveWindowsLatticeContracts } from './platform/windows/resolve-lattice-contracts.mjs'
23
25
 
24
26
  function usage(message) {
25
27
  if (message) console.error(`ERROR: ${message}`)
@@ -102,8 +104,13 @@ try {
102
104
  : 'Peertable setupの正規手順で setup-state.json の lattice_cli を再生成する',
103
105
  )
104
106
  }
105
- const latticePkgSrc = join(dirname(dirname(latticeCli)), 'src', 'todo-contracts.mjs')
106
- const { todoSelfDigest } = await import(latticePkgSrc)
107
+ const latticePkgSrc = process.platform === 'win32'
108
+ ? resolveWindowsLatticeContracts(latticeCli)
109
+ : join(dirname(dirname(latticeCli)), 'src', 'todo-contracts.mjs')
110
+ const latticePkgSpecifier = process.platform === 'win32'
111
+ ? windowsImportSpecifier(latticePkgSrc)
112
+ : latticePkgSrc
113
+ const { todoSelfDigest } = await import(latticePkgSpecifier)
107
114
 
108
115
  const planPathFromRoot = relative(repoRoot, absolutePlanPath)
109
116
  if (isAbsolute(planPathFromRoot) || planPathFromRoot.startsWith(`..${process.platform === 'win32' ? '\\' : '/'}`) || planPathFromRoot === '..') {
@@ -0,0 +1,23 @@
1
+ /** Grok Build TUI固有のpane状態を返す。判定不能ならnull。 */
2
+ export function classifyGrokPaneTail(tail) {
3
+ if (typeof tail !== 'string') return null
4
+
5
+ if (isGrokPrivacyBanner(tail)) return 'blocked'
6
+
7
+ // 完了後も `stop [hooks: 1/3]` はステータス行へ残る。入力欄が戻った
8
+ // `Worked for ...` 行を先にidle扱いし、配達を永久保留しない。
9
+ if (/Worked for\s+\d+(?:m\d+)?s[\s\S]*?stop\s+\[hooks:\s*\d+\/\d+\]/u.test(tail)) return 'idle'
10
+
11
+ if (tail.includes('Waiting for response') || tail.includes('Responding…') || tail.includes('Responding...')) return 'busy'
12
+ if (tail.includes('[stop]')) return 'busy'
13
+ if (/\[hooks:\s*\d+\/\d+\]/u.test(tail)) return 'busy'
14
+ return null
15
+ }
16
+
17
+ /** Grok TUI の SpaceXAI coding-data バナー。 */
18
+ export function isGrokPrivacyBanner(tail) {
19
+ return typeof tail === 'string'
20
+ && tail.includes('Help improve Grok')
21
+ && tail.includes('[Opt out]')
22
+ && tail.includes('[Opt in]')
23
+ }