peertable 0.8.55 → 0.8.57

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.
Files changed (75) hide show
  1. package/README.ja.md +32 -33
  2. package/README.md +36 -37
  3. package/package.json +3 -1
  4. package/room/client.mjs +24 -33
  5. package/room/server.mjs +1 -1
  6. package/skill/SKILL.md +69 -94
  7. package/skill/scripts/aiterm-client.mjs +48 -0
  8. package/skill/scripts/aiterm-deliver.mjs +3 -1
  9. package/skill/scripts/change-seat.mjs +86 -0
  10. package/skill/scripts/change-seat.sh +3 -276
  11. package/skill/scripts/change-seat.test.mjs +129 -0
  12. package/skill/scripts/cli.mjs +77 -0
  13. package/skill/scripts/doctor.mjs +53 -0
  14. package/skill/scripts/doctor.sh +2 -213
  15. package/skill/scripts/ensure-all-bridges.mjs +42 -0
  16. package/skill/scripts/ensure-all-bridges.sh +3 -80
  17. package/skill/scripts/ensure-bridge.mjs +11 -0
  18. package/skill/scripts/ensure-bridge.sh +2 -95
  19. package/skill/scripts/ensure-codex-room-mcp.mjs +1 -1
  20. package/skill/scripts/ensure-project-runtime.mjs +85 -0
  21. package/skill/scripts/ensure-project-runtime.sh +2 -11
  22. package/skill/scripts/ensure-project-runtime.test.mjs +40 -0
  23. package/skill/scripts/install-skill.mjs +89 -0
  24. package/skill/scripts/install-skill.test.mjs +84 -0
  25. package/skill/scripts/launch-seat.mjs +163 -0
  26. package/skill/scripts/launch-seat.sh +3 -797
  27. package/skill/scripts/launch-seat.test.mjs +174 -0
  28. package/skill/scripts/leave-seat.mjs +44 -0
  29. package/skill/scripts/leave-seat.sh +3 -92
  30. package/skill/scripts/leave-seat.test.mjs +64 -0
  31. package/skill/scripts/legacy-entry.mjs +33 -0
  32. package/skill/scripts/platform/windows/resolve-lattice-command.mjs +12 -1
  33. package/skill/scripts/project-runtime.mjs +95 -0
  34. package/skill/scripts/project-runtime.test.mjs +29 -0
  35. package/skill/scripts/project-scaffold.mjs +179 -0
  36. package/skill/scripts/project-scaffold.test.mjs +129 -0
  37. package/skill/scripts/refresh-seat-identity.mjs +11 -8
  38. package/skill/scripts/remove-managed-room-mcp.mjs +9 -4
  39. package/skill/scripts/resume.sh +2 -225
  40. package/skill/scripts/room-api.mjs +28 -0
  41. package/skill/scripts/room-public-session.test.mjs +73 -0
  42. package/skill/scripts/runtime-contract.test.mjs +13 -146
  43. package/skill/scripts/runtime-launch-command.mjs +19 -0
  44. package/skill/scripts/runtime-launch-command.test.mjs +32 -0
  45. package/skill/scripts/seat-approval.mjs +24 -0
  46. package/skill/scripts/seat-approval.test.mjs +43 -0
  47. package/skill/scripts/seat-identity.mjs +2 -71
  48. package/skill/scripts/seat-launch-phase.mjs +11 -0
  49. package/skill/scripts/seat-observer.mjs +54 -0
  50. package/skill/scripts/seat-observer.test.mjs +58 -0
  51. package/skill/scripts/seat-session.mjs +17 -0
  52. package/skill/scripts/seat-status-bridge.mjs +33 -206
  53. package/skill/scripts/seat-usage.mjs +3 -334
  54. package/skill/scripts/setup.sh +3 -215
  55. package/skill/scripts/teardown.mjs +113 -0
  56. package/skill/scripts/teardown.sh +3 -410
  57. package/skill/scripts/teardown.test.mjs +62 -0
  58. package/skill/scripts/upgrade-team-assets.mjs +268 -0
  59. package/skill/scripts/upgrade-team-assets.sh +2 -282
  60. package/skill/scripts/wakeup-bridge.mjs +26 -32
  61. package/skill/scripts/wakeup-delivery.mjs +4 -14
  62. package/skill/templates/charter.md +1 -1
  63. package/skill/templates/member-standalone.md +1 -1
  64. package/skill/templates/member.md +3 -3
  65. package/skill/scripts/agent-pane-status.mjs +0 -6
  66. package/skill/scripts/aiterm-configure.mjs +0 -31
  67. package/skill/scripts/aiterm-launch.mjs +0 -64
  68. package/skill/scripts/aiterm-send.mjs +0 -22
  69. package/skill/scripts/claude-dialog.mjs +0 -26
  70. package/skill/scripts/codex-dialog.mjs +0 -67
  71. package/skill/scripts/platform/windows/build-bridge-command.mjs +0 -52
  72. package/skill/scripts/seat-input.mjs +0 -36
  73. package/skill/scripts/tmux-at.bash +0 -22
  74. package/skill/scripts/tmux-socket.mjs +0 -19
  75. package/skill/scripts/vendors/grok/pane-status.mjs +0 -30
@@ -0,0 +1,73 @@
1
+ import test from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { spawn, execFileSync } from 'node:child_process'
4
+ import { createServer } from 'node:net'
5
+ import { once } from 'node:events'
6
+ import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'
7
+ import { tmpdir } from 'node:os'
8
+ import { join } from 'node:path'
9
+ import { fileURLToPath } from 'node:url'
10
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js'
11
+ import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
12
+
13
+ test('実room clientが公開session IDと席資格で参加・投稿し、serverが公開観測先への配達経路を認識する', { timeout: 20_000 }, async t => {
14
+ const root = fileURLToPath(new URL('../../', import.meta.url))
15
+ const dir = mkdtempSync(join(tmpdir(), 'peertable-room-public-'))
16
+ const socket = createServer()
17
+ socket.listen(0, '127.0.0.1')
18
+ await once(socket, 'listening')
19
+ const port = socket.address().port
20
+ await new Promise(resolve => socket.close(resolve))
21
+ const credential = join(dir, 'fixture.token')
22
+ writeFileSync(credential, 'fixture-token\n', { mode: 0o600 })
23
+ const server = spawn(process.execPath, [join(root, 'room/server.mjs')], {
24
+ env: { ...process.env, PEERTABLE_PORT: String(port), PEERTABLE_DATA: dir, PEERTABLE_POST_TOKEN: 'fixture-token' },
25
+ stdio: ['ignore', 'ignore', 'pipe'],
26
+ })
27
+ const mcp = new Client({ name: 'peertable-public-session-test', version: '1' })
28
+ t.after(async () => {
29
+ await mcp.close()
30
+ if (server.exitCode === null) { server.kill(); await once(server, 'exit') }
31
+ rmSync(dir, { recursive: true, force: true })
32
+ })
33
+ await new Promise((resolve, reject) => {
34
+ let output = ''
35
+ server.stderr.on('data', chunk => {
36
+ output += chunk
37
+ if (output.includes(`server on :${port}`)) resolve()
38
+ })
39
+ server.once('error', reject)
40
+ server.once('exit', code => reject(new Error(`room起動失敗: ${code}: ${output}`)))
41
+ })
42
+ const placement = JSON.parse(execFileSync(process.execPath, [join(root, 'skill/scripts/resolve-seat-placement.mjs'), '--roles', '実装'], { encoding: 'utf8' }))
43
+ const { harness, model, effort } = placement.settings
44
+ const env = { ...process.env, PEERTABLE_URL: `http://127.0.0.1:${port}`, PEERTABLE_ROOM: 'fixture',
45
+ PEERTABLE_MEMBER: 'alice', PEERTABLE_HARNESS: harness, PEERTABLE_VENDOR: harness, PEERTABLE_MODEL: model,
46
+ PEERTABLE_EFFORT: effort, PEERTABLE_ROLE: '実装', PEERTABLE_ROLES: '実装', PEERTABLE_CREDENTIAL_FILE: credential,
47
+ AITERM_SESSION_ID: '公開-session-日本語',
48
+ }
49
+ delete env.PEERTABLE_POST_TOKEN
50
+ await mcp.connect(new StdioClientTransport({ command: process.execPath, args: [join(root, 'room/client.mjs')], env, stderr: 'pipe' }))
51
+ const api = async (path, options = {}) => {
52
+ const response = await fetch(`${env.PEERTABLE_URL}/api/fixture/${path}`, {
53
+ ...options, headers: { 'content-type': 'application/json', 'X-Peertable-Token': 'fixture-token' },
54
+ })
55
+ assert.equal(response.ok, true, `${path}: ${response.status}`)
56
+ return response.json()
57
+ }
58
+ const alice = (await api('members')).members.find(member => member.name === 'alice')
59
+ assert.equal(alice.aiterm_session_id, env.AITERM_SESSION_ID)
60
+ assert.deepEqual(alice.observe, { aiterm_session_id: env.AITERM_SESSION_ID })
61
+ assert.deepEqual(alice.roles, ['実装'])
62
+ await api('members', { method: 'POST', body: JSON.stringify({ name: 'beta', observe: { aiterm_session_id: 'beta-public-session' } }) })
63
+ const posted = await mcp.callTool({ name: 'post', arguments: { to: 'beta', message: '公開APIとroomの境界確認' } })
64
+ assert.equal(posted.isError, undefined)
65
+ assert.match(posted.content[0].text, /room_saved/)
66
+ const { messages } = await api('messages')
67
+ const message = messages.find(item => item.body === '公開APIとroomの境界確認')
68
+ assert.ok(message)
69
+ const { delivery } = await api(`deliveries?seq=${message.seq}`)
70
+ assert.equal(delivery.beta.state, 'bridge_unavailable')
71
+ assert.notEqual(delivery.beta.reason, 'no_delivery_route')
72
+ assert.doesNotMatch(JSON.stringify({ alice, posted, message }), /fixture-token/)
73
+ })
@@ -7,116 +7,22 @@ import { spawnSync } from 'node:child_process'
7
7
  import { fileURLToPath } from 'node:url'
8
8
  import test from 'node:test'
9
9
 
10
- import { keysForCodexPane } from './codex-dialog.mjs'
11
- import { buildWindowsBridgeLaunch } from './platform/windows/build-bridge-command.mjs'
12
- import { paneStatusTail } from './seat-usage.mjs'
13
- import { classifyGrokPaneTail } from './vendors/grok/pane-status.mjs'
14
10
  import { latticeTaskAvailable } from './alarm-condition.mjs'
15
11
  import { boundedRecent, boundedUnread } from '../../room/message-bounds.mjs'
16
12
 
17
- test('Grokの通信失敗はWaiting表示が残ってもbusyにしない', () => {
18
- const tail = [
19
- 'Connection failed — reqwest error stream: error sending request.',
20
- 'Check your network and try again.',
21
- 'Waiting for response… 29s [stop]',
22
- ].join('\n')
23
- assert.equal(classifyGrokPaneTail(tail), 'blocked')
24
- })
25
-
26
- test('通信失敗の無いGrok応答待ちはbusy', () => {
27
- assert.equal(classifyGrokPaneTail('Waiting for response… 12s [stop]'), 'busy')
28
- })
29
-
30
- test('状態判定窓は折返しで14行より上へ出た通信失敗を含む', () => {
31
- const pane = ['Connection failed — reqwest error stream', ...Array(20).fill('wrapped line'), 'Waiting for response…'].join('\n')
32
- assert.ok(paneStatusTail(pane).includes('Connection failed'))
33
- })
34
-
35
- test('Codexのroom許可とcommand許可は固定選択肢だけを通す', () => {
36
- assert.deepEqual(keysForCodexPane([
37
- 'Allow the room MCP server to run tool "members"?',
38
- '3. Always allow',
39
- ].join('\n')), { kind: 'mcp-allow', keys: ['Down', 'Down', 'Enter'] })
40
- assert.deepEqual(keysForCodexPane([
41
- 'Would you like to run the following command?',
42
- "2. Yes, and don't ask again for commands that start with Get-Content",
43
- ].join('\n')), { kind: 'command-approval', keys: ['Down', 'Enter'] })
44
- })
45
-
46
- test('Codexの現在busy/idle composerはscrollbackの古い許可文より優先する', () => {
47
- const stale = [
48
- 'Would you like to run the following command?',
49
- "2. Yes, and don't ask again for commands that start with rg",
50
- ]
51
- assert.equal(keysForCodexPane([...stale, '• Working (8s • esc to interrupt)', '› Ask Codex to do anything', 'gpt-5.6-terra high · ~/work'].join('\n')), null)
52
- assert.equal(keysForCodexPane([...stale, '› Ask Codex to do anything', 'gpt-5.6-terra high · ~/work'].join('\n')), null)
53
- })
54
-
55
- test('Codexの長い承認文は現在footerがあれば24行を越えても検出する', () => {
56
- const screen = [
57
- 'Would you like to run the following command?',
58
- "2. Yes, and don't ask again for commands that start with Get-Process",
59
- ...Array(28).fill('wrapped command line'),
60
- 'Press enter to confirm or esc to cancel',
61
- ].join('\n')
62
- assert.deepEqual(keysForCodexPane(screen), { kind: 'command-approval', keys: ['Down', 'Enter'] })
63
- })
64
-
65
- test('着座はAiterm dispatch前に独自prompt連続判定を重ねない', () => {
66
- const source = readFileSync(new URL('./launch-seat.sh', import.meta.url), 'utf8')
67
- const activeBranch = source.slice(
68
- source.indexOf('if [ "$brief_in_composer" != true ]; then'),
69
- source.indexOf(' else\n # Codex はヘッダを描いた後も MCP 初期化を続ける。'),
70
- )
71
- assert.ok(activeBranch.includes('aiterm-send.mjs'))
72
- assert.ok(!activeBranch.includes('brief_ready_streak'))
73
- assert.ok(source.includes('brief_turn_observed'))
74
- assert.ok(source.includes('agent-pane-status.mjs'))
75
- assert.ok(source.includes('aiterm-wait --session'))
76
- })
77
-
78
- test('wakeup bridgeはpending DMが無くてもCodex既知dialogを巡回する', () => {
79
- const source = readFileSync(new URL('./wakeup-bridge.mjs', import.meta.url), 'utf8')
80
- assert.ok(source.includes('dialogSweepRunning'))
81
- assert.ok(source.includes('await passKnownCodexDialog(member)'))
82
- })
83
-
84
- test('円卓runtimeは3bridgeを固定順で一括収束する', () => {
85
- const source = readFileSync(new URL('./ensure-project-runtime.sh', import.meta.url), 'utf8')
86
- assert.ok(source.includes('for kind in alarm seat-status wakeup'))
87
- })
88
-
89
- test('bridge更新は版数だけでなくruntime source digestへ束縛する', () => {
90
- const digest = spawnSync(process.execPath, [fileURLToPath(new URL('./runtime-digest.mjs', import.meta.url))], { encoding: 'utf8' })
91
- assert.equal(digest.status, 0, digest.stderr)
92
- assert.match(digest.stdout, /^[0-9a-f]{64}$/u)
93
- const source = readFileSync(new URL('./ensure-bridge.sh', import.meta.url), 'utf8')
94
- assert.ok(source.includes('peertable_runtime_digest'))
95
- })
96
-
97
- test('bridge起動は書込トークンをコマンドラインへ載せない', () => {
98
- const ensure = readFileSync(new URL('./ensure-bridge.sh', import.meta.url), 'utf8')
99
- // env_prefix の転送対象に生トークンを含めない(tmux session コマンドと ps に平文で残る・2026-08-30 実測)
100
- assert.ok(!ensure.includes('PEERTABLE_TMUX_SOCKET PEERTABLE_POST_TOKEN'))
101
- assert.ok(ensure.includes('bridge.token'))
102
- const launch = buildWindowsBridgeLaunch({
103
- script: 'bridge.mjs', project: 'C:\\work', log: 'C:\\work\\bridge.log',
104
- env: { PEERTABLE_POST_TOKEN: 'secret-raw-token', PEERTABLE_CREDENTIAL_FILE: 'C:\\work\\.team\\credentials\\bridge.token' },
105
- })
106
- const encodedAt = launch.argv.indexOf('-EncodedCommand')
107
- const decoded = Buffer.from(launch.argv[encodedAt + 1], 'base64').toString('utf16le')
108
- assert.ok(!decoded.includes('secret-raw-token'))
109
- assert.ok(decoded.includes('bridge.token'))
110
- })
111
-
112
- test('Windows bridgeはUTF-8を明示してログへ書く', () => {
113
- const launch = buildWindowsBridgeLaunch({ script: 'bridge.mjs', project: 'C:\\work', log: 'C:\\work\\bridge.log' })
114
- const encodedAt = launch.argv.indexOf('-EncodedCommand')
115
- const decoded = Buffer.from(launch.argv[encodedAt + 1], 'base64').toString('utf16le')
116
- assert.ok(decoded.includes('[Console]::OutputEncoding = $utf8'))
117
- assert.ok(decoded.includes('Out-File'))
118
- assert.ok(decoded.includes('-Encoding utf8'))
119
- })
13
+ // TUI判定の試験は所有者Aitermへ移し、ここでは公開契約とPeertableの操作結果を検証する。
14
+ import './install-skill.test.mjs'
15
+ import './project-scaffold.test.mjs'
16
+ import './project-runtime.test.mjs'
17
+ import './leave-seat.test.mjs'
18
+ import './launch-seat.test.mjs'
19
+ import './change-seat.test.mjs'
20
+ import './room-public-session.test.mjs'
21
+ import './seat-observer.test.mjs'
22
+ import './seat-approval.test.mjs'
23
+ import './runtime-launch-command.test.mjs'
24
+ import './ensure-project-runtime.test.mjs'
25
+ import './teardown.test.mjs'
120
26
 
121
27
  test('alarm writerは日本語noteをUTF-8 stdinから保存する', () => {
122
28
  const dir = mkdtempSync(join(tmpdir(), 'peertable-alarm-'))
@@ -187,42 +93,3 @@ test('親post入口は明示envだけでなく共通token解決を使う', () =>
187
93
  assert.ok(source.includes("import { resolvePostToken } from './seat-usage.mjs'"))
188
94
  assert.ok(source.includes('resolvePostToken(process.env)'))
189
95
  })
190
-
191
- test('teardownはalarm-bridgeを停止してから.teamを削除する', () => {
192
- const source = readFileSync(new URL('./teardown.sh', import.meta.url), 'utf8')
193
- const stop = source.indexOf('alarm-bridge.mjs" "$proj" --stop')
194
- const remove = source.indexOf('rm -rf "$proj/.team"')
195
- assert.ok(stop >= 0)
196
- assert.ok(remove >= 0 && stop < remove)
197
- assert.ok(source.includes('did "alarm-bridge 停止"'))
198
- assert.ok(source.includes('miss "alarm-bridge 停止に失敗'))
199
- assert.ok(source.includes('-X DELETE "$url/api/$room/bridges"'))
200
- })
201
-
202
- test('archive room logは解散区切り前の控えであり原本をroomに残すと明記する', () => {
203
- const dir = mkdtempSync(join(tmpdir(), 'peertable-archive-room-log-'))
204
- const out = join(dir, 'room-log.md')
205
- const script = fileURLToPath(new URL('./archive-room-log.py', import.meta.url))
206
- const fixture = JSON.stringify({
207
- messages: [{ seq: 1, from: 'bell', to_names: ['all'], ts: '2026-08-30T00:00:00Z', body: '完了' }],
208
- })
209
- const python = [
210
- 'import importlib.util, json, sys',
211
- 'spec = importlib.util.spec_from_file_location("archive_room_log", sys.argv[1])',
212
- 'module = importlib.util.module_from_spec(spec)',
213
- 'spec.loader.exec_module(module)',
214
- 'fixture = json.loads(sys.argv[3])',
215
- 'module.fetch = lambda _url, _path: fixture',
216
- 'raise SystemExit(module.main(["https://room.example", "factory", sys.argv[2]]))',
217
- ].join('; ')
218
- try {
219
- const result = spawnSync('python3', ['-c', python, script, out, fixture], { encoding: 'utf8' })
220
- assert.equal(result.status, 0, result.stderr)
221
- const log = readFileSync(out, 'utf8')
222
- assert.match(log, /解散の区切りを投稿する前までの room ログを書き出した控え/u)
223
- assert.match(log, /room と過去ログの原本はサーバー側に残り、次の卓も同じ room で続く/u)
224
- assert.doesNotMatch(log, /削除済み|唯一の記録/u)
225
- } finally {
226
- rmSync(dir, { recursive: true, force: true })
227
- }
228
- })
@@ -0,0 +1,19 @@
1
+ // Peertableの常駐コマンドだけをOS標準shellの構文へ変換する。
2
+ // 永続PTYの作成・送信・終了・backend選択はAiterm公開APIが担当する。
3
+ export function runtimeLaunchCommand({ executable, script, project, args = [], log, env = {}, platform = process.platform }) {
4
+ const names = ['PEERTABLE_CREDENTIAL_FILE', 'PEERTABLE_URL', 'PEERTABLE_PARENT_NAME']
5
+ if (platform === 'win32') {
6
+ const quote = value => `'${String(value).replaceAll("'", "''")}'`
7
+ return [
8
+ '$utf8 = [System.Text.UTF8Encoding]::new($false)',
9
+ '[Console]::OutputEncoding = $utf8', '$OutputEncoding = $utf8',
10
+ 'Remove-Item Env:PEERTABLE_POST_TOKEN -ErrorAction SilentlyContinue',
11
+ ...names.filter(name => env[name]).map(name => `$env:${name} = ${quote(env[name])}`),
12
+ `Set-Location -LiteralPath ${quote(project)}`,
13
+ `& ${[executable, script, project, ...args].map(quote).join(' ')} 2>&1 | Out-File -FilePath ${quote(log)} -Encoding utf8 -Append`,
14
+ ].join('; ')
15
+ }
16
+ if (!['darwin', 'linux'].includes(platform)) throw Object.assign(new Error(`未対応OS: ${platform}`), { code: 'PEERTABLE_PLATFORM_UNSUPPORTED' })
17
+ const quote = value => `'${String(value).replaceAll("'", "'\\''")}'`
18
+ return `cd ${quote(project)} && exec env -u PEERTABLE_POST_TOKEN ${names.filter(name => env[name]).map(name => `${name}=${quote(env[name])}`).join(' ')} ${[executable, script, project, ...args].map(quote).join(' ')} >> ${quote(log)} 2>&1`
19
+ }
@@ -0,0 +1,32 @@
1
+ import assert from 'node:assert/strict'
2
+ import { execFileSync } from 'node:child_process'
3
+ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
4
+ import { tmpdir } from 'node:os'
5
+ import { join } from 'node:path'
6
+ import test from 'node:test'
7
+ import { runtimeLaunchCommand } from './runtime-launch-command.mjs'
8
+
9
+ test('runtimeのshell輸送はcredential pathだけを渡し秘密値を載せない', () => {
10
+ for (const platform of ['darwin', 'linux', 'win32']) {
11
+ const command = runtimeLaunchCommand({
12
+ executable: 'node', script: '/a b/bridge.mjs', project: "/project's dir", log: '/log.txt', platform,
13
+ env: { PEERTABLE_POST_TOKEN: 'secret-value', PEERTABLE_CREDENTIAL_FILE: '/credential path' },
14
+ })
15
+ assert.ok(!command.includes('secret-value'))
16
+ assert.ok(command.includes('/credential path'))
17
+ assert.ok(!command.includes('tmux') && !command.includes('psmux'))
18
+ }
19
+ })
20
+
21
+ test('実OSのshellで日本語・引用符・置換構文を文字列として渡す', t => {
22
+ const dir = mkdtempSync(join(tmpdir(), 'peertable-runtime-quote-'))
23
+ t.after(() => rmSync(dir, { recursive: true, force: true }))
24
+ const project = join(dir, "日本語 owner's project")
25
+ mkdirSync(project)
26
+ const script = join(dir, 'bridge.mjs'), log = join(dir, 'log.txt')
27
+ writeFileSync(script, 'console.log(JSON.stringify({args:process.argv.slice(2),credential:process.env.PEERTABLE_CREDENTIAL_FILE,token:process.env.PEERTABLE_POST_TOKEN??null}))')
28
+ const arg = "引用 ' $(echo unexpected) `echo unexpected`"
29
+ const command = runtimeLaunchCommand({ executable: process.execPath, script, project, log, args: [arg], env: { PEERTABLE_CREDENTIAL_FILE: arg } })
30
+ execFileSync(process.platform === 'win32' ? 'pwsh' : 'bash', process.platform === 'win32' ? ['-NoProfile', '-Command', command] : ['-c', command], { env: { ...process.env, PEERTABLE_POST_TOKEN: 'not-forwarded' } })
31
+ assert.deepEqual(JSON.parse(readFileSync(log, 'utf8').replace(/^\uFEFF/u, '').trim()), { args: [project, arg], credential: arg, token: null })
32
+ })
@@ -0,0 +1,24 @@
1
+ // 席に許可した作業の既知承認だけを、Aitermが示した現在の確認へ単発で返す。
2
+ export async function passSeatApproval(aiterm, sessionId, harness, observation) {
3
+ if (!['codex', 'claude'].includes(harness)) return false
4
+ if (harness === 'claude') {
5
+ const current = observation ?? await aiterm.observe(sessionId)
6
+ if (current.state !== 'blocked' || current.reason !== 'tool_approval') return false
7
+ }
8
+ const tool = harness === 'claude' ? 'claude_approval' : 'agent_approval'
9
+ const schema = harness === 'claude' ? 'aiterm.claude-approval-result.v1' : 'aiterm.agent-approval-result.v1'
10
+ const inspection = await aiterm.structured(tool, {
11
+ action: 'inspect', session_id: sessionId,
12
+ }, schema)
13
+ if (inspection.status === 'none') return false
14
+ if (inspection.status !== 'approval_required' || !inspection.choices.some(choice => choice.decision === 'approve_once')) {
15
+ throw Object.assign(new Error(`席 ${sessionId} の承認を解決できません: ${inspection.reason}`), { code: 'PEERTABLE_SEAT_APPROVAL_BLOCKED' })
16
+ }
17
+ const response = await aiterm.structured(tool, {
18
+ action: 'respond', session_id: sessionId, observed_prompt_digest: inspection.prompt_digest, approval_choice: 'approve_once',
19
+ }, schema)
20
+ if (response.status !== 'submitted') {
21
+ throw Object.assign(new Error(`席 ${sessionId} の承認応答が成立していません`), { code: 'PEERTABLE_SEAT_APPROVAL_BLOCKED' })
22
+ }
23
+ return true
24
+ }
@@ -0,0 +1,43 @@
1
+ import test from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { passSeatApproval } from './seat-approval.mjs'
4
+
5
+ test('公開APIのdigestと単発許可だけを返す', async () => {
6
+ const calls = []
7
+ const aiterm = { structured: async (tool, args) => {
8
+ calls.push({ tool, args })
9
+ return args.action === 'inspect'
10
+ ? { status: 'approval_required', prompt_digest: 'current-digest', choices: [{ decision: 'approve_once', label: '今回だけ許可' }] }
11
+ : { status: 'submitted' }
12
+ } }
13
+ assert.equal(await passSeatApproval(aiterm, 'seat', 'codex'), true)
14
+ assert.deepEqual(calls[1], { tool: 'agent_approval', args: { action: 'respond', session_id: 'seat', observed_prompt_digest: 'current-digest', approval_choice: 'approve_once' } })
15
+ })
16
+
17
+ test('未知の確認・digest不一致を握りつぶさず、再送しない', async () => {
18
+ let count = 0
19
+ const aiterm = { structured: async () => { count++; throw new Error('dialog_changed') } }
20
+ await assert.rejects(passSeatApproval(aiterm, 'seat', 'codex'), /dialog_changed/)
21
+ assert.equal(count, 1)
22
+ })
23
+
24
+ test('承認が無ければ何も送らない', async () => {
25
+ let count = 0
26
+ assert.equal(await passSeatApproval({ structured: async () => { count++; return { status: 'none' } } }, 'seat', 'codex'), false)
27
+ assert.equal(count, 1)
28
+ })
29
+ test('Claudeのtool承認だけを既存の相関付き公開APIへ渡す', async () => {
30
+ const calls = []
31
+ const aiterm = { structured: async (tool, args, schema) => {
32
+ calls.push({ tool, args, schema })
33
+ return args.action === 'inspect' ? { status: 'approval_required', prompt_digest: 'claude-digest', choices: [{ decision: 'approve_once' }] } : { status: 'submitted' }
34
+ } }
35
+ for (const observation of [{ state: 'idle' }, { state: 'blocked', reason: 'project_mcp_consent' }]) {
36
+ assert.equal(await passSeatApproval(aiterm, 'seat', 'claude', observation), false)
37
+ }
38
+ assert.equal(calls.length, 0)
39
+ assert.equal(await passSeatApproval(aiterm, 'seat', 'claude', { state: 'blocked', reason: 'tool_approval' }), true)
40
+ assert.equal(calls[1].tool, 'claude_approval')
41
+ assert.equal(calls[1].schema, 'aiterm.claude-approval-result.v1')
42
+ assert.equal(calls[1].args.observed_prompt_digest, 'claude-digest')
43
+ })
@@ -1,14 +1,6 @@
1
- #!/usr/bin/env node
2
- // Peertable スクリプト群の OS プロセス観測ライブラリ(唯一の所有者)。
3
- // pane_pid から Lattice attach 用の pid / lstart / argv を1件に決める CLI を兼ねる。
4
- // POSIX は ps。Windows は Win32_Process(ps -Ao は Git Bash で unknown option -- A)。
5
- // ps の観測規約(LC_ALL=C・/bin/ps 絶対パス・trim)と argv digest の計算はこのファイルだけが持つ。
6
- // 他スクリプトはここから import し、ps を手書きしない(locale 罠の再発防止。caveat:
7
- // ps-locale-lstart-lc-time-ascii-argv-lc-ctype-digest)。
1
+ // Peertable自身のbridge processを照合するためのOS観測。席のPTYはAitermが観測する。
8
2
  import { createHash } from 'node:crypto'
9
3
  import { execFileSync } from 'node:child_process'
10
- import { resolve } from 'node:path'
11
- import { fileURLToPath } from 'node:url'
12
4
  import { observeWindowsPidCommand, parseWindowsCreationDate } from './platform/windows/observe-pid-command.mjs'
13
5
 
14
6
  export function hashArgv(argv) {
@@ -16,7 +8,7 @@ export function hashArgv(argv) {
16
8
  }
17
9
 
18
10
  // 記録済み pid そのものの lstart / argv を観測する(pane からの席特定はしない)。
19
- // refresh-seat-identity・doctor が使う。
11
+ // 自製bridgeの停止前照合に使う。
20
12
  export function observePidCommand(pid) {
21
13
  if (!Number.isSafeInteger(pid) || pid < 1) {
22
14
  const error = new Error('pid が正整数でない')
@@ -34,66 +26,5 @@ export function observePidCommand(pid) {
34
26
  return { pid, started_identity: started, argv, argv_digest: hashArgv(argv) }
35
27
  }
36
28
 
37
- const AGENT = /(?:claude|codex|grok|composer)(?:\.cmd|\.exe)?(?:\s|$)/iu
38
-
39
29
  export const parseWinCreationDate = parseWindowsCreationDate
40
-
41
- // ps の lstart 書式(LC_TIME)と非ASCII argv のエスケープ(LC_CTYPE)は locale で変わり、
42
- // 観測者ごとに digest が割れる(2026-08-22 実測)。Lattice 側の観測も LC_ALL=C に固定している。
43
30
  const psEnv = () => ({ ...process.env, LC_ALL: 'C' })
44
-
45
- function posixIdentity(pid) {
46
- const table = execFileSync('/bin/ps', ['-Ao', 'pid=,ppid=,pgid='], { encoding: 'utf8', env: psEnv() })
47
- const leaders = table.split('\n').flatMap((line) => {
48
- const match = /^\s*(\d+)\s+(\d+)\s+(\d+)\s*$/u.exec(line)
49
- if (!match) return []
50
- const [, child, ppid, pgid] = match
51
- return ppid === String(pid) && child === pgid ? [child] : []
52
- })
53
- const chosen = leaders.length === 1 ? leaders[0] : String(pid)
54
- const started = execFileSync('/bin/ps', ['-o', 'lstart=', '-p', chosen], { encoding: 'utf8', env: psEnv() }).trim()
55
- const argv = execFileSync('/bin/ps', ['-o', 'command=', '-p', chosen], { encoding: 'utf8', env: psEnv() }).trim()
56
- if (!started || !argv) throw new Error('pid の lstart/args を観測できない')
57
- return { pid: Number(chosen), started_identity: started, argv, argv_digest: hashArgv(argv) }
58
- }
59
-
60
- function winQuery(filter) {
61
- const script = `Get-CimInstance Win32_Process -Filter ${JSON.stringify(filter)} | Select-Object ProcessId,ParentProcessId,CreationDate,CommandLine | ConvertTo-Json -Compress`
62
- const out = execFileSync('pwsh.exe', ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', script], {
63
- encoding: 'utf8',
64
- }).trim()
65
- if (!out) return []
66
- const parsed = JSON.parse(out)
67
- return Array.isArray(parsed) ? parsed : [parsed]
68
- }
69
-
70
- function winIdentity(pid) {
71
- const self = winQuery(`ProcessId=${pid}`)[0]
72
- if (!self) throw new Error('Win32_Process を観測できない')
73
- const children = winQuery(`ParentProcessId=${pid}`)
74
- const agentKids = children.filter((row) => AGENT.test(String(row.CommandLine || '')))
75
- let chosen = self
76
- if (AGENT.test(String(self.CommandLine || ''))) chosen = self
77
- else if (agentKids.length === 1) chosen = agentKids[0]
78
- else if (children.length === 1) chosen = children[0]
79
- const started = chosen.CreationDate ? parseWinCreationDate(chosen.CreationDate) : ''
80
- const argv = String(chosen.CommandLine || '').trim()
81
- if (!started || !argv) throw new Error('pid の CreationDate/CommandLine を観測できない')
82
- return { pid: Number(chosen.ProcessId), started_identity: started, argv, argv_digest: hashArgv(argv) }
83
- }
84
-
85
- const isCli = process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1])
86
- if (isCli) {
87
- const panePid = process.argv[2]
88
- if (!/^\d+$/.test(panePid || '')) {
89
- console.error('usage: seat-identity.mjs <pane_pid>')
90
- process.exit(2)
91
- }
92
- try {
93
- const ident = process.platform === 'win32' ? winIdentity(panePid) : posixIdentity(panePid)
94
- process.stdout.write(`${JSON.stringify(ident)}\n`)
95
- } catch (error) {
96
- console.error(error.message || error)
97
- process.exit(1)
98
- }
99
- }
@@ -0,0 +1,11 @@
1
+ // 着任指示を渡すまで、その席へのroom配達を保留する。状態はPeertableが所有する。
2
+ import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
3
+ import { join } from 'node:path'
4
+
5
+ const phasePath = (project, name) => join(project, '.team', 'seats', `${name}.launching`)
6
+ export const isSeatLaunching = (project, name) => existsSync(phasePath(project, name))
7
+ export function beginSeatLaunch(project, name) {
8
+ mkdirSync(join(project, '.team', 'seats'), { recursive: true })
9
+ writeFileSync(phasePath(project, name), `${process.pid}\n`, { flag: 'wx' })
10
+ }
11
+ export function endSeatLaunch(project, name) { rmSync(phasePath(project, name), { force: true }) }
@@ -0,0 +1,54 @@
1
+ // 状態と活動の観測はAitermが所有し、Peertableはroom表示へ対応づける。
2
+ import { seatSessionId, findSeatSession } from './seat-session.mjs'
3
+ import { combineSeatLamp } from './seat-usage.mjs'
4
+
5
+ export class SeatObserver {
6
+ constructor(aiterm, room) {
7
+ this.aiterm = aiterm
8
+ this.room = room
9
+ this.cursors = new Map()
10
+ }
11
+ async read(sessionId) {
12
+ const result = await this.aiterm.observe(sessionId, this.cursors.get(sessionId))
13
+ if (result.activity.cursor) this.cursors.set(sessionId, result.activity.cursor)
14
+ else this.cursors.delete(sessionId)
15
+ return result
16
+ }
17
+ async cycle(members, previous, observedAt) {
18
+ const sessions = await this.aiterm.sessions(['PEERTABLE_MEMBER', 'PEERTABLE_ROOM'])
19
+ const live = new Set(sessions.map(item => item.session_id))
20
+ for (const id of this.cursors.keys()) if (!live.has(id)) this.cursors.delete(id)
21
+ const result = new Map()
22
+ const mainIds = new Set(members.map(seatSessionId).filter(Boolean))
23
+ for (const member of members) {
24
+ if (member.delivery?.kind === 'parent_watch') continue
25
+ const id = seatSessionId(member)
26
+ if (!id) continue
27
+ try { findSeatSession(member, sessions, this.room) }
28
+ catch (error) {
29
+ if (error.code !== 'PEERTABLE_SEAT_SESSION_IDENTITY_CONFLICT') throw error
30
+ result.set(member.name, { error })
31
+ continue
32
+ }
33
+ const raw = await this.read(id)
34
+ const paneStatus = raw.state === 'missing' ? 'dead' : raw.state
35
+ const prev = previous.get(member.name)
36
+ const busySince = ['busy', 'blocked'].includes(paneStatus)
37
+ ? (prev?.busySince ?? observedAt) : null
38
+ const job = { alive: false, active: (raw.activity.background_cpu_delta_seconds ?? 0) > 0 }
39
+ const jobs = sessions.filter(item => !mainIds.has(item.session_id)
40
+ && item.environment?.PEERTABLE_MEMBER === member.name && item.environment?.PEERTABLE_ROOM === this.room)
41
+ for (const session of jobs) {
42
+ const work = await this.read(session.session_id)
43
+ if (!work.exists || work.pane_alive === false) continue
44
+ job.alive = true
45
+ if (work.activity.output_changed === true || (work.activity.cpu_delta_seconds ?? 0) > 0) job.active = true
46
+ }
47
+ result.set(member.name, {
48
+ status: combineSeatLamp(paneStatus, job), paneStatus, busySince,
49
+ paneTokenHint: raw.token_hint, identity: raw.process_identity,
50
+ })
51
+ }
52
+ return result
53
+ }
54
+ }
@@ -0,0 +1,58 @@
1
+ import test from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { SeatObserver } from './seat-observer.mjs'
4
+
5
+ const result = (state, activity = {}) => ({ state, exists: state !== 'missing', pane_alive: true, token_hint: 1200,
6
+ process_identity: { pid: 42 }, activity: { cursor: 'opaque', output_changed: null, cpu_delta_seconds: null, background_cpu_delta_seconds: null, ...activity } })
7
+ function fixture(extra = [], observations = {}) {
8
+ const member = { name: 'alice', aiterm_session_id: 'seat' }
9
+ const calls = []
10
+ const aiterm = {
11
+ sessions: async () => [{ session_id: 'seat', environment: { PEERTABLE_MEMBER: 'alice', PEERTABLE_ROOM: 'r' } }, ...extra],
12
+ observe: async (id, cursor) => { calls.push({ id, cursor }); return observations[id] ?? result('idle') },
13
+ }
14
+ return { observer: new SeatObserver(aiterm, 'r'), member, calls }
15
+ }
16
+ test('Aitermの状態とtokenを渡し、opaque cursorを再観測へ返す', async () => {
17
+ const f = fixture([], { seat: result('blocked') })
18
+ const first = await f.observer.cycle([f.member], new Map(), 'time')
19
+ assert.equal(first.get('alice').status, 'blocked')
20
+ assert.equal(first.get('alice').paneTokenHint, 1200)
21
+ await f.observer.cycle([f.member], first, 'later')
22
+ assert.equal(f.calls[1].cursor, 'opaque')
23
+ })
24
+ test('roomが一致する預け仕事だけを合成し、番犬用の席状態を保つ', async () => {
25
+ const f = fixture([
26
+ { session_id: 'job', environment: { PEERTABLE_MEMBER: 'alice', PEERTABLE_ROOM: 'r' } },
27
+ { session_id: 'elsewhere', environment: { PEERTABLE_MEMBER: 'alice', PEERTABLE_ROOM: 'other' } },
28
+ ], { job: result('unknown', { output_changed: true }) })
29
+ const row = (await f.observer.cycle([f.member], new Map(), 'time')).get('alice')
30
+ assert.equal(row.status, 'busy')
31
+ assert.equal(row.paneStatus, 'idle')
32
+ assert.deepEqual(f.calls.map(x => x.id), ['seat', 'job'])
33
+ })
34
+ test('足場CPUを稼働に数えず、席内background CPUを稼働へ合成する', async () => {
35
+ for (const [background, expected] of [[null, 'idle'], [0.01, 'busy']]) {
36
+ const f = fixture([], { seat: result('idle', { cpu_delta_seconds: 0.3, background_cpu_delta_seconds: background }) })
37
+ assert.equal((await f.observer.cycle([f.member], new Map(), 'time')).get('alice').status, expected)
38
+ }
39
+ })
40
+ test('消失はdead、観測失敗は例外になりidleへ丸めない', async () => {
41
+ const f = fixture([], { seat: result('missing') })
42
+ assert.equal((await f.observer.cycle([f.member], new Map(), 'time')).get('alice').status, 'dead')
43
+ f.observer.aiterm.observe = async () => { throw new Error('API failure') }
44
+ await assert.rejects(f.observer.cycle([f.member], new Map(), 'time'), /API failure/)
45
+ })
46
+ test('他roomの身元不一致を明示し、同じ観測回の正常席を残す', async () => {
47
+ const f = fixture([{ session_id: 'wrong', environment: { PEERTABLE_MEMBER: 'bob', PEERTABLE_ROOM: 'other' } }])
48
+ const rows = await f.observer.cycle([f.member, { name: 'bob', aiterm_session_id: 'wrong' }], new Map(), 'time')
49
+ assert.equal(rows.get('alice').status, 'idle')
50
+ assert.equal(rows.get('bob').error.code, 'PEERTABLE_SEAT_SESSION_IDENTITY_CONFLICT')
51
+ assert.deepEqual(f.calls.map(call => call.id), ['seat'])
52
+ })
53
+ test('harnessのnative停止はblockedとして保持する', async () => {
54
+ const f = fixture([], { seat: { ...result('blocked'), reason: 'harness_stopped', pane_alive: true, harness_alive: true } })
55
+ const row = (await f.observer.cycle([f.member], new Map(), 'time')).get('alice')
56
+ assert.equal(row.status, 'blocked')
57
+ assert.equal(row.paneStatus, 'blocked')
58
+ })
@@ -0,0 +1,17 @@
1
+ // 既存台帳のtargetは移行時の識別子として読むだけで、backendへ渡さない。
2
+ export function seatSessionId(member) {
3
+ return member.aiterm_session_id || member.observe?.aiterm_session_id || member.observe?.tmux_target || null
4
+ }
5
+
6
+ export function findSeatSession(member, sessions, room) {
7
+ const id = seatSessionId(member)
8
+ const session = id ? sessions.find(item => item.session_id === id) : null
9
+ if (!session) return null
10
+ const env = session.environment ?? {}
11
+ if ((env.PEERTABLE_MEMBER && env.PEERTABLE_MEMBER !== member.name) || (env.PEERTABLE_ROOM && env.PEERTABLE_ROOM !== room)) {
12
+ throw Object.assign(new Error(`席 ${member.name} のAitermセッションが対象roomの本人だと確認できません`), {
13
+ code: 'PEERTABLE_SEAT_SESSION_IDENTITY_CONFLICT',
14
+ })
15
+ }
16
+ return session
17
+ }