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 +1 -1
- package/room/client.mjs +1 -1
- package/skill/scripts/platform/windows/import-specifier.mjs +6 -0
- package/skill/scripts/platform/windows/resolve-lattice-contracts.mjs +9 -0
- package/skill/scripts/seat-status-bridge.mjs +24 -4
- package/skill/scripts/seat-usage.mjs +42 -20
- package/skill/scripts/todo-extraction-from-plan.mjs +9 -2
- package/skill/scripts/vendors/grok/pane-status.mjs +23 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "peertable",
|
|
3
|
-
"version": "0.8.
|
|
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.
|
|
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,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
|
-
|
|
389
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
217
|
-
|
|
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
|
-
|
|
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
|
|
286
|
-
if (
|
|
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
|
-
|
|
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 =
|
|
106
|
-
|
|
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
|
+
}
|