peertable 0.8.7 → 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.7",
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.7'
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++
@@ -335,21 +335,36 @@ export function combineSeatLamp(paneStatus, job) {
335
335
  // tmuxセッションを作らずに走らせた預け仕事を、プロセスツリーの実観測で拾う(2026-08-25 オーナー裁定
336
336
  //「子プロセスも見る」)。nohup等でツリーから切り離された仕事はここでは見えない——それは
337
337
  // peer-<name>-job* セッション慣例の側が受け持つ(二本立て)。
338
- 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
+ }
339
345
  const children = new Map()
340
346
  const cpu = new Map()
347
+ const age = new Map()
341
348
  for (const row of psRows) {
342
- const m = /^\s*(\d+)\s+(\d+)\s+([\d.]+)/.exec(row)
349
+ const m = /^\s*(\d+)\s+(\d+)\s+([\d.]+)\s+(\S+)/.exec(row)
343
350
  if (!m) continue
344
351
  const [pid, ppid, pcpu] = [Number(m[1]), Number(m[2]), Number(m[3])]
345
352
  if (!children.has(ppid)) children.set(ppid, [])
346
353
  children.get(ppid).push(pid)
347
354
  cpu.set(pid, pcpu)
355
+ age.set(pid, parseEtime(m[4]))
348
356
  }
357
+ const rootAge = age.get(rootPid)
349
358
  const queue = [...(children.get(rootPid) ?? [])]
350
359
  while (queue.length) {
351
360
  const pid = queue.pop()
352
- 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
353
368
  queue.push(...(children.get(pid) ?? []))
354
369
  }
355
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 === '..') {