peertable 0.8.54 → 0.8.56

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 (74) 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 +12 -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 +156 -0
  26. package/skill/scripts/launch-seat.sh +3 -797
  27. package/skill/scripts/launch-seat.test.mjs +159 -0
  28. package/skill/scripts/leave-seat.mjs +42 -0
  29. package/skill/scripts/leave-seat.sh +3 -92
  30. package/skill/scripts/leave-seat.test.mjs +53 -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-observer.mjs +54 -0
  49. package/skill/scripts/seat-observer.test.mjs +58 -0
  50. package/skill/scripts/seat-session.mjs +17 -0
  51. package/skill/scripts/seat-status-bridge.mjs +33 -206
  52. package/skill/scripts/seat-usage.mjs +3 -334
  53. package/skill/scripts/setup.sh +3 -215
  54. package/skill/scripts/teardown.mjs +113 -0
  55. package/skill/scripts/teardown.sh +3 -410
  56. package/skill/scripts/teardown.test.mjs +62 -0
  57. package/skill/scripts/upgrade-team-assets.mjs +268 -0
  58. package/skill/scripts/upgrade-team-assets.sh +2 -282
  59. package/skill/scripts/wakeup-bridge.mjs +24 -32
  60. package/skill/scripts/wakeup-delivery.mjs +7 -18
  61. package/skill/templates/charter.md +1 -1
  62. package/skill/templates/member-standalone.md +1 -1
  63. package/skill/templates/member.md +3 -3
  64. package/skill/scripts/agent-pane-status.mjs +0 -6
  65. package/skill/scripts/aiterm-configure.mjs +0 -31
  66. package/skill/scripts/aiterm-launch.mjs +0 -64
  67. package/skill/scripts/aiterm-send.mjs +0 -22
  68. package/skill/scripts/claude-dialog.mjs +0 -26
  69. package/skill/scripts/codex-dialog.mjs +0 -67
  70. package/skill/scripts/platform/windows/build-bridge-command.mjs +0 -52
  71. package/skill/scripts/seat-input.mjs +0 -36
  72. package/skill/scripts/tmux-at.bash +0 -22
  73. package/skill/scripts/tmux-socket.mjs +0 -19
  74. package/skill/scripts/vendors/grok/pane-status.mjs +0 -30
@@ -0,0 +1,159 @@
1
+ import test from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { EventEmitter } from 'node:events'
4
+ import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, rmSync } from 'node:fs'
5
+ import { tmpdir } from 'node:os'
6
+ import { join } from 'node:path'
7
+ import { launchSeat } from './launch-seat.mjs'
8
+ import { runScript } from './project-scaffold.mjs'
9
+
10
+ function fixture(t, harness = 'claude') {
11
+ const project = mkdtempSync(join(tmpdir(), 'peertable-launch-'))
12
+ t.after(() => rmSync(project, { recursive: true, force: true }))
13
+ mkdirSync(join(project, '.team'))
14
+ writeFileSync(join(project, '.mcp.json'), JSON.stringify({ mcpServers: { room: { command: 'node' }, other: { command: 'other' } } }))
15
+ writeFileSync(join(project, '.team', 'setup-state.json'), JSON.stringify({ room: 'room', server_url: 'http://example', mode: 'standalone' }))
16
+ const events = []
17
+ const member = { name: 'alice', harness, model: 'fixture-model', roles: ['実装者'], aiterm_session_id: 'peer-alice', pid: 23 }
18
+ const options = { project, name: 'alice', roles: '実装者', brief: '着任してください' }
19
+ const dependencies = {
20
+ resolveCommand: (command, argv) => ({ command, argv }),
21
+ runScript: (file, args) => {
22
+ events.push({ file, args })
23
+ if (file === 'resolve-seat-placement.mjs') return JSON.stringify({ settings: { model: 'fixture-model', harness, effort: 'high' }, roles: ['実装者'] })
24
+ return '/資格path'
25
+ },
26
+ execFileSync: () => { events.push({ preflight: true }) },
27
+ leaveSeat: async (_project, _name, rollback) => { events.push({ leave: true, rollback: Boolean(rollback) }) },
28
+ ensureProjectRuntime: async () => { events.push({ runtime: true }) },
29
+ spawn: (executable, args) => {
30
+ events.push({ wait: { executable, args } })
31
+ const waiter = new EventEmitter()
32
+ waiter.unref = () => {}
33
+ return waiter
34
+ },
35
+ api: { members: async () => [member], request: async (path, options) => { events.push({ path, options }) } },
36
+ aiterm: {
37
+ sessions: async () => [],
38
+ observe: async () => ({ state: 'busy', exists: true, harness_alive: true, process_identity: { pid: 23, started_identity: 'native-start', argv_digest: 'digest' }, observed_at: 'now' }),
39
+ structured: async (tool, args) => {
40
+ events.push({ tool, args })
41
+ if (tool === 'agent_launch') return { session_id: 'peer-alice', startup: { status: 'ready' } }
42
+ if (tool === 'agent_approval') return { status: 'none' }
43
+ if (tool === 'pty_send') return { mode: 'agent_dispatch', event_cursor: 0, submit_residue: false, wait_process: { executable: 'opaque-waiter', args: ['opaque-cursor'] } }
44
+ throw new Error(`予期しないAPI: ${tool}`)
45
+ },
46
+ },
47
+ }
48
+ return { options, dependencies, events, member }
49
+ }
50
+
51
+ test('起動準備・登録・runtimeの成立後に長い日本語briefを一度だけ公開APIへ渡す', async t => {
52
+ const f = fixture(t)
53
+ f.options.brief = '長い着任指示。'.repeat(2000)
54
+ const result = await launchSeat(f.options, f.dependencies)
55
+ assert.equal(result.status, 'ready')
56
+ assert.equal(result.turn_started, true)
57
+ const sent = f.events.filter(event => event.tool === 'pty_send')
58
+ assert.equal(sent.length, 1)
59
+ assert.equal(sent[0].args.text, f.options.brief)
60
+ assert.ok(f.events.findIndex(event => event.runtime) < f.events.indexOf(sent[0]))
61
+ const launch = f.events.find(event => event.tool === 'agent_launch')
62
+ assert.equal(launch.args.trust_project, true)
63
+ assert.equal(Object.hasOwn(launch.args, 'prompt'), false)
64
+ assert.equal(launch.args.env_vars.includes('PEERTABLE_POST_TOKEN'), false)
65
+ assert.deepEqual(f.events.find(event => event.wait).wait, { executable: 'opaque-waiter', args: ['opaque-cursor'] })
66
+ })
67
+
68
+ test('モデル実測失敗とサイズ超過は既存席へ触らない', async t => {
69
+ const f = fixture(t)
70
+ f.dependencies.execFileSync = () => { throw new Error('モデル利用不可') }
71
+ await assert.rejects(launchSeat(f.options, f.dependencies), /モデル利用不可/)
72
+ assert.equal(f.events.some(event => event.leave), false)
73
+ f.events.length = 0
74
+ await assert.rejects(launchSeat({ ...f.options, brief: 'あ'.repeat(22000) }, f.dependencies), { code: 'LAUNCH_BRIEF_TOO_LONG' })
75
+ assert.equal(f.events.length, 0)
76
+ })
77
+
78
+ test('起動未確認と別sessionの応答ではbriefを送らず撤去する', async t => {
79
+ for (const receipt of [{ session_id: 'peer-alice', startup: { status: 'not_checked' } }, { session_id: 'other', startup: { status: 'ready' } }]) {
80
+ const f = fixture(t)
81
+ f.dependencies.aiterm.structured = async (tool) => {
82
+ assert.equal(tool, 'agent_launch')
83
+ return receipt
84
+ }
85
+ await assert.rejects(launchSeat(f.options, f.dependencies), { code: 'SEAT_STARTUP_NOT_READY' })
86
+ assert.equal(f.events.filter(event => event.rollback).length, 1)
87
+ }
88
+ })
89
+
90
+ test('送信後の不成立は再送せず、撤去失敗も消さず返す', async t => {
91
+ const f = fixture(t)
92
+ const structured = f.dependencies.aiterm.structured
93
+ f.dependencies.aiterm.structured = async (tool, args) => tool === 'pty_send'
94
+ ? { ...await structured(tool, args), submit_residue: true } : structured(tool, args)
95
+ f.dependencies.leaveSeat = async (_project, _name, rollback) => { if (rollback) throw new Error('公開停止失敗') }
96
+ await assert.rejects(launchSeat(f.options, f.dependencies), error => error instanceof AggregateError && /公開停止失敗/.test(error.message))
97
+ assert.equal(f.events.filter(event => event.tool === 'pty_send').length, 1)
98
+ })
99
+
100
+ test('room台帳が不完全なら起動済みとして報告しない', async t => {
101
+ const f = fixture(t)
102
+ delete f.member.pid
103
+ f.options.brief = ''
104
+ await assert.rejects(launchSeat(f.options, f.dependencies), { code: 'SEAT_LEDGER_INCOMPLETE' })
105
+ assert.equal(f.events.filter(event => event.rollback).length, 1)
106
+ })
107
+ test('TUI準備完了でもroom未登録なら成功せず撤去する', async t => {
108
+ const f = fixture(t)
109
+ t.mock.timers.enable({ apis: ['Date'] })
110
+ f.dependencies.api.members = async () => {
111
+ t.mock.timers.tick(90_001)
112
+ return []
113
+ }
114
+ await assert.rejects(launchSeat(f.options, f.dependencies), { code: 'SEAT_ROOM_MCP_NOT_READY' })
115
+ assert.equal(f.events.filter(event => event.rollback).length, 1)
116
+ assert.equal(f.events.some(event => event.tool === 'pty_send'), false)
117
+ })
118
+ test('Lattice併用の席identityとactorを公開launcherのenvへ明示する', async t => {
119
+ const f = fixture(t)
120
+ writeFileSync(join(f.options.project, '.team', 'setup-state.json'), JSON.stringify({ room: 'room', server_url: 'http://example', mode: 'lattice', plan_key: 'plan' }))
121
+ f.dependencies.ensureProjectRuntime = async (_project, { env }) => {
122
+ assert.equal(env.PEERTABLE_PLAN, 'plan')
123
+ assert.equal(env.LATTICE_TODO_ACTOR_SESSION, 'alice')
124
+ assert.equal(env.LATTICE_TODO_ACTOR_AGENT, 'alice')
125
+ assert.ok(env.LATTICE_TODO_ACTOR_HOST)
126
+ }
127
+ await launchSeat(f.options, f.dependencies)
128
+ const keys = f.events.find(event => event.tool === 'agent_launch').args.env_vars
129
+ for (const key of ['PEERTABLE_MEMBER', 'PEERTABLE_PLAN', 'LATTICE_CLI', 'LATTICE_TODO_ACTOR_HOST', 'LATTICE_TODO_ACTOR_SESSION', 'LATTICE_TODO_ACTOR_AGENT']) assert.ok(keys.includes(key), key)
130
+ })
131
+ test('CodexとGrokは席専用homeへ設定し、共有設定を保つ', async t => {
132
+ for (const harness of ['codex', 'grok']) {
133
+ const f = fixture(t, harness)
134
+ const home = join(f.options.project, 'fixture-home')
135
+ const shared = join(home, `.${harness}`)
136
+ mkdirSync(shared, { recursive: true })
137
+ writeFileSync(join(shared, 'auth.json'), '{"fixture":"auth"}')
138
+ writeFileSync(join(shared, 'config.toml'), 'model = "shared-config"\n')
139
+ f.dependencies.home = home
140
+ const script = f.dependencies.runScript
141
+ f.dependencies.runScript = (file, args, options) => ['grok-seat-config.mjs', 'ensure-codex-room-mcp.mjs'].includes(file)
142
+ ? runScript(file, args, options) : script(file, args, options)
143
+ let preflightHome
144
+ f.dependencies.execFileSync = (_command, _args, options) => {
145
+ if (harness !== 'grok') return
146
+ preflightHome = options.env.GROK_HOME
147
+ assert.notEqual(preflightHome, shared)
148
+ assert.equal(readFileSync(join(preflightHome, 'auth.json'), 'utf8'), '{"fixture":"auth"}')
149
+ assert.doesNotMatch(readFileSync(join(preflightHome, 'config.toml'), 'utf8'), /shared-config/)
150
+ }
151
+ await launchSeat(f.options, f.dependencies)
152
+ assert.equal(readFileSync(join(shared, 'config.toml'), 'utf8'), 'model = "shared-config"\n')
153
+ const privateHome = join(f.options.project, '.team', 'seats', harness === 'grok' ? 'alice.grok-home' : 'alice.codex')
154
+ assert.equal(readFileSync(join(privateHome, 'auth.json'), 'utf8'), '{"fixture":"auth"}')
155
+ assert.doesNotMatch(readFileSync(join(privateHome, 'config.toml'), 'utf8'), /shared-config/)
156
+ if (preflightHome) assert.equal(existsSync(preflightHome), false)
157
+ assert.ok(f.events.find(event => event.tool === 'agent_launch').args.env_vars.includes(harness === 'grok' ? 'GROK_HOME' : 'CODEX_HOME'))
158
+ }
159
+ })
@@ -0,0 +1,42 @@
1
+ // 席の停止を公開APIで確かめてから、Peertableの登録とcredentialを撤去する。
2
+ import { existsSync, rmSync } from 'node:fs'
3
+ import { join } from 'node:path'
4
+ import { AitermClient } from './aiterm-client.mjs'
5
+ import { RoomApi } from './room-api.mjs'
6
+ import { findSeatSession } from './seat-session.mjs'
7
+ import { projectPath, readSetup, runScript, fail } from './project-scaffold.mjs'
8
+
9
+ export async function leaveSeat(project, name, { aiterm, api, credentialCommand = runScript } = {}) {
10
+ if (!name || !/^[A-Za-z0-9._:-]+$/.test(name)) fail('SEAT_LEAVE_ARGS_INVALID', 'member名に使えない文字があります')
11
+ project = projectPath(project)
12
+ const state = readSetup(project)
13
+ const env = { ...process.env }
14
+ delete env.PEERTABLE_POST_TOKEN
15
+ const credential = args => credentialCommand('seat-credential.mjs', args, { env }).trim()
16
+ let file = credential(['path', project, state.room, name])
17
+ if (!existsSync(file)) file = credential(['prepare', project, state.room, name])
18
+ api ??= new RoomApi(state, { credential: file })
19
+ const ownAiterm = !aiterm
20
+ aiterm ??= new AitermClient({ env })
21
+ try {
22
+ const member = (await api.members()).find(item => item.name === name)
23
+ const sessions = await aiterm.sessions(['PEERTABLE_MEMBER', 'PEERTABLE_ROOM'])
24
+ // 台帳が消えた直後の再実行でも、同じroomの残存席を公開環境情報で特定する。
25
+ const session = member
26
+ ? findSeatSession(member, sessions, state.room)
27
+ : sessions.find(item => item.environment?.PEERTABLE_MEMBER === name && item.environment?.PEERTABLE_ROOM === state.room)
28
+ if (session) {
29
+ await aiterm.call('pty_close', { session_id: session.session_id })
30
+ if ((await aiterm.sessions()).some(item => item.session_id === session.session_id)) {
31
+ fail('SEAT_LEAVE_SESSION_FAILED', `${session.session_id} が停止後も残っています`)
32
+ }
33
+ }
34
+ await api.request(`members/${encodeURIComponent(name)}`, { method: 'DELETE' })
35
+ if ((await api.members()).some(item => item.name === name)) fail('SEAT_LEAVE_MEMBER_FAILED', `${name} の登録が残っています`)
36
+ for (const suffix of ['.grok-home', '.codex']) rmSync(join(project, '.team', 'seats', name + suffix), { recursive: true, force: true })
37
+ credential(['remove', project, file])
38
+ return { schema: 'peertable.seat-leave.v1', status: 'left', member: name, session_id: session?.session_id ?? null }
39
+ } finally {
40
+ if (ownAiterm) await aiterm.close()
41
+ }
42
+ }
@@ -1,93 +1,4 @@
1
1
  #!/bin/bash
2
- # 1席だけを通常退席させ、runtime credentialまで同じ境界で撤去する。
3
- # usage: leave-seat.sh <project_dir> <member>
4
- set -u
5
-
6
- unset PEERTABLE_POST_TOKEN
7
- proj="${1:-}"
8
- name="${2:-}"
9
- [ -n "$proj" ] && [ -n "$name" ] || {
10
- echo "SEAT_LEAVE_ARGS_INVALID: leave-seat.sh <project_dir> <member>" >&2
11
- exit 2
12
- }
13
- case "$name" in
14
- *[!A-Za-z0-9._:-]*) echo "SEAT_LEAVE_ARGS_INVALID: member名に使えない文字がある" >&2; exit 2 ;;
15
- esac
16
-
17
- script_dir=$(cd "$(dirname "$0")" && pwd -P)
18
- # shellcheck disable=SC1091
19
- . "$script_dir/tmux-at.bash"
20
- credential_helper="${PEERTABLE_CREDENTIAL_HELPER:-$script_dir/seat-credential.mjs}"
21
- state="$proj/.team/setup-state.json"
22
- [ -f "$state" ] || { echo "SEAT_LEAVE_STATE_MISSING: $state" >&2; exit 1; }
23
- read -r room url <<EOF
24
- $(python3 -c "import json;d=json.load(open('$state'));print(d['room'],d['server_url'])")
25
- EOF
26
-
27
- credential_file=$(env -u PEERTABLE_POST_TOKEN node "$credential_helper" path "$proj" "$room" "$name") || exit 1
28
- # 旧席などでcredentialだけ欠けている時は、sessionを止める前に復元する。
29
- # ここで失敗すれば席は生きたままなので、member登録だけ残る半退席を作らない。
30
- if [ ! -r "$credential_file" ] || [ ! -s "$credential_file" ]; then
31
- credential_file=$(env -u PEERTABLE_POST_TOKEN node "$credential_helper" prepare "$proj" "$room" "$name") || {
32
- echo "SEAT_LEAVE_CREDENTIAL_PREPARE_FAILED: $name" >&2
33
- exit 1
34
- }
35
- fi
36
- default_socket=$(node "$script_dir/tmux-socket.mjs" 2>/dev/null || true)
37
- members=$(curl -sf "$url/api/$room/members" 2>/dev/null || true)
38
- read -r member_socket target <<EOF
39
- $(python3 - "$name" "$default_socket" "$members" <<'PY'
40
- import json, sys
41
- name, default_socket, raw = sys.argv[1:4]
42
- try:
43
- member = next((x for x in json.loads(raw).get('members', []) if x.get('name') == name), {})
44
- except Exception:
45
- member = {}
46
- observe = member.get('observe') or {}
47
- print(observe.get('tmux_socket') or default_socket, observe.get('tmux_target') or f'peer-{name}')
48
- PY
49
- )
50
- EOF
51
-
52
- failed=0
53
- if tmux_at has-session -t "$target" 2>/dev/null; then
54
- if ! tmux_at kill-session -t "$target"; then
55
- echo "SEAT_LEAVE_SESSION_FAILED: $target" >&2
56
- failed=1
57
- elif tmux_at has-session -t "$target" 2>/dev/null; then
58
- echo "SEAT_LEAVE_SESSION_FAILED: $target が撤去後も残っている" >&2
59
- failed=1
60
- fi
61
- elif tmux_at list-sessions >/dev/null 2>&1; then
62
- : # serverへ到達でき、対象sessionが無い
63
- elif [ -n "${member_socket:-}" ] && [ ! -S "${member_socket}" ]; then
64
- # socketファイル自体が消えている=tmux serverごと死んでいる(OS再起動等)。
65
- # 席のprocessは存在しえないので「畳めない」ではなく「畳む対象が無い」が正
66
- # (実被弾 2026-08-29: Mac再起動後のresumeが全席でここに落ちて再起動できなかった)
67
- echo "SEAT_LEAVE_SESSION_GONE: tmux socket消失(${member_socket})。畳む対象なしとして続行" >&2
68
- else
69
- echo "SEAT_LEAVE_SESSION_UNREADABLE: ${member_socket:-tmux}" >&2
70
- failed=1
71
- fi
72
-
73
- # live clientを止めたと確認できない時は、member/identity/credentialを先に消して
74
- # 「退席済み」に見せない。観測面を残したままtyped failureで止める。
75
- [ "$failed" -eq 0 ] || exit 1
76
-
77
- encoded_name=$(python3 -c 'import sys,urllib.parse;print(urllib.parse.quote(sys.argv[1],safe=""))' "$name")
78
- if ! env -u PEERTABLE_POST_TOKEN node "$credential_helper" request "$credential_file" DELETE \
79
- "$url/api/$room/members/$encoded_name" >/dev/null; then
80
- echo "SEAT_LEAVE_MEMBER_FAILED: $name" >&2
81
- failed=1
82
- fi
83
-
84
- # 席の本人性は台帳の member 行が持つ(席file廃止 2026-08-22)。上の member DELETE が identity も撤去する
85
- rm -rf "$proj/.team/seats/${name}.grok-home"
86
- rm -rf "$proj/.team/seats/${name}.codex"
87
- if ! env -u PEERTABLE_POST_TOKEN node "$credential_helper" remove "$proj" "$credential_file"; then
88
- echo "SEAT_LEAVE_CREDENTIAL_FAILED: $name" >&2
89
- failed=1
90
- fi
91
-
92
- [ "$failed" -eq 0 ] || exit 1
93
- echo "SEAT_LEAVE_OK: ${name}(session / room member / seat identity / credential を撤去)"
2
+ # 既存の引数を共通の製品入口へ渡す。
3
+ set -euo pipefail
4
+ exec node "$(dirname "$0")/legacy-entry.mjs" leave-seat "$@"
@@ -0,0 +1,53 @@
1
+ import test from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync, existsSync } from 'node:fs'
4
+ import { tmpdir } from 'node:os'
5
+ import { join } from 'node:path'
6
+ import { leaveSeat } from './leave-seat.mjs'
7
+
8
+ function fixture(t, failure) {
9
+ const project = mkdtempSync(join(tmpdir(), 'peertable-leave-'))
10
+ t.after(() => rmSync(project, { recursive: true, force: true }))
11
+ mkdirSync(join(project, '.team', 'seats', 'alice.grok-home'), { recursive: true })
12
+ writeFileSync(join(project, '.team', 'setup-state.json'), JSON.stringify({ room: 'room-a', server_url: 'http://localhost', mode: 'standalone' }))
13
+ const token = join(project, '.team', 'token')
14
+ writeFileSync(token, 'test')
15
+ const events = []
16
+ let members = [{ name: 'alice', aiterm_session_id: 'seat-a' }]
17
+ let sessions = [{ session_id: 'seat-a', environment: { PEERTABLE_MEMBER: 'alice', PEERTABLE_ROOM: 'room-a' } }, { session_id: 'other', environment: { PEERTABLE_MEMBER: 'bob', PEERTABLE_ROOM: 'room-b' } }]
18
+ const api = {
19
+ members: async () => members,
20
+ request: async (path, options) => { events.push(options.method); members = []; return {} },
21
+ }
22
+ const aiterm = {
23
+ sessions: async () => { if (failure === 'list') throw new Error('API停止'); return sessions },
24
+ call: async (tool, args) => { events.push(tool); if (failure === 'close') throw new Error('停止失敗'); sessions = sessions.filter(item => item.session_id !== args.session_id) },
25
+ }
26
+ const credentialCommand = (_script, args) => { if (args[0] === 'remove') { events.push('credential-remove'); rmSync(token) }; return token }
27
+ return { project, token, events, api, aiterm, credentialCommand, sessions: () => sessions, members: () => members }
28
+ }
29
+
30
+ test('公開APIで停止を確認してから登録と秘密を消し、他のroomを保つ', async t => {
31
+ const f = fixture(t)
32
+ const result = await leaveSeat(f.project, 'alice', f)
33
+ assert.equal(result.status, 'left')
34
+ assert.deepEqual(f.events, ['pty_close', 'DELETE', 'credential-remove'])
35
+ assert.deepEqual(f.sessions().map(item => item.session_id), ['other'])
36
+ assert.equal(existsSync(f.token), false)
37
+ assert.equal(existsSync(join(f.project, '.team', 'seats', 'alice.grok-home')), false)
38
+ })
39
+
40
+ for (const failure of ['list', 'close']) test(`Aitermの${failure}失敗では登録とcredentialを保つ`, async t => {
41
+ const f = fixture(t, failure)
42
+ await assert.rejects(leaveSeat(f.project, 'alice', f))
43
+ assert.equal(f.members().length, 1)
44
+ assert.equal(existsSync(f.token), true)
45
+ assert.equal(f.events.includes('DELETE'), false)
46
+ })
47
+
48
+ test('同じ識別子でも別roomの席は停止しない', async t => {
49
+ const f = fixture(t)
50
+ f.sessions()[0].environment.PEERTABLE_ROOM = 'another-room'
51
+ await assert.rejects(leaveSeat(f.project, 'alice', f), { code: 'PEERTABLE_SEAT_SESSION_IDENTITY_CONFLICT' })
52
+ assert.deepEqual(f.events, [])
53
+ })
@@ -0,0 +1,33 @@
1
+ #!/usr/bin/env node
2
+ // 既存のshell入口は引数だけを翻訳し、処理順序を共通CLIへ集める。
3
+ const [entry, ...args] = process.argv.slice(2)
4
+ let command = entry
5
+ let translated = args
6
+ if (entry === 'setup') {
7
+ const [project, room, url, plan, _legacyRoot, ...tail] = args
8
+ translated = [project, '--room', room, '--url', url]
9
+ if (plan && plan !== '-') translated.push('--plan', plan)
10
+ if (tail[0] && !tail[0].startsWith('--')) {
11
+ const tasks = tail.shift()
12
+ if (tasks !== '-') translated.push('--tasks', tasks)
13
+ }
14
+ translated.push(...tail)
15
+ } else if (entry === 'launch-seat') {
16
+ command = 'launch'
17
+ const [project, name, ...tail] = args
18
+ translated = [project, name]
19
+ let roles = false
20
+ while (tail.length) {
21
+ const arg = tail.shift()
22
+ if (arg.startsWith('--')) {
23
+ const value = tail.shift()
24
+ translated.push(arg, value)
25
+ if (arg === '--roles') roles = true
26
+ } else if (!roles) { translated.push('--roles', arg); roles = true }
27
+ else translated.push('--brief', arg)
28
+ }
29
+ } else if (entry === 'leave-seat') command = 'leave'
30
+ else if (entry === 'change-seat') command = 'change'
31
+ else if (entry === 'doctor') command = 'diagnostics'
32
+ process.argv = [process.execPath, new URL('./cli.mjs', import.meta.url).pathname, command, ...translated]
33
+ await import('./cli.mjs')
@@ -1,9 +1,20 @@
1
+ import { existsSync } from 'node:fs'
2
+ import { win32 } from 'node:path'
3
+
1
4
  export function resolveWindowsCommand(cli, argv, {
2
5
  platform = process.platform,
3
- exists,
6
+ exists = existsSync,
7
+ pathEnv = process.env.PATH ?? '',
4
8
  pwsh = 'pwsh.exe',
5
9
  } = {}) {
6
10
  if (platform !== 'win32') return { command: cli, argv }
11
+ if (!/[\\/]/u.test(cli)) {
12
+ for (const directory of pathEnv.split(';').filter(Boolean)) {
13
+ const candidates = /\.(?:exe|ps1|cmd|bat)$/iu.test(cli) ? [cli] : [`${cli}.exe`, `${cli}.ps1`]
14
+ const found = candidates.map(name => win32.join(directory, name)).find(exists)
15
+ if (found) { cli = found; break }
16
+ }
17
+ }
7
18
  const lower = cli.toLowerCase()
8
19
  if (lower.endsWith('.exe')) return { command: cli, argv }
9
20
 
@@ -0,0 +1,95 @@
1
+ // setup/resumeは、対象projectの生成物更新とruntimeの確認までを連続して行う。
2
+ import { join } from 'node:path'
3
+ import { AitermClient } from './aiterm-client.mjs'
4
+ import { RoomApi } from './room-api.mjs'
5
+ import { findSeatSession, seatSessionId } from './seat-session.mjs'
6
+ import { scaffoldProject, ensureProjectRoomMcp, projectPath, readSetup, runScript, writeJson, fail } from './project-scaffold.mjs'
7
+ import { ensureProjectRuntime } from './ensure-project-runtime.mjs'
8
+
9
+ const delay = ms => new Promise(resolve => setTimeout(resolve, ms))
10
+ const targets = members => members.filter(member => member.delivery?.kind !== 'parent_watch' && (member.harness ?? member.vendor) && seatSessionId(member))
11
+
12
+ export async function seatsToResume(aiterm, members, sessions, room) {
13
+ const relaunch = []
14
+ for (const member of members) {
15
+ if (member.delivery?.kind === 'parent_watch' || !(member.harness ?? member.vendor)) continue
16
+ const session = findSeatSession(member, sessions, room)
17
+ if (session) {
18
+ const observation = await aiterm.observe(session.session_id)
19
+ if (observation.harness_alive === true && !['dead', 'missing'].includes(observation.state)) continue
20
+ if (observation.exists && observation.harness_alive !== false && !['dead', 'missing'].includes(observation.state)) {
21
+ fail('RESUME_MEMBER_OBSERVATION_UNKNOWN', `${member.name}: ${observation.reason}`)
22
+ }
23
+ }
24
+ if (!member.roles?.length) fail('RESUME_MEMBER_ROLES_MISSING', `${member.name} の役割がありません`)
25
+ relaunch.push(member)
26
+ }
27
+ return relaunch
28
+ }
29
+
30
+ export async function verifyProjectRuntime(api, { probe = true, heartbeatTimeout = 90_000, probeTimeout = 120_000 } = {}) {
31
+ let members
32
+ const deadline = Date.now() + heartbeatTimeout
33
+ for (;;) {
34
+ members = targets(await api.members())
35
+ const pending = members.filter(member => member.status_reason !== 'fresh')
36
+ if (!pending.length) break
37
+ if (Date.now() >= deadline) fail('RESUME_HEARTBEAT_STALE', pending.map(member => `${member.name}(${member.status_reason})`).join(', '))
38
+ await delay(3000)
39
+ }
40
+ if (!probe || !members.length) return { heartbeat: members.length, probe: probe ? 'no_targets' : 'disabled' }
41
+ const names = members.map(member => member.name)
42
+ const saved = await api.request('messages', { method: 'POST', body: {
43
+ from: 'resume', to: names.length === 1 ? names[0] : names,
44
+ body: '[resume-probe] 配達経路の確認。応答不要・読み流してよい。',
45
+ } })
46
+ const probeDeadline = Date.now() + probeTimeout
47
+ for (;;) {
48
+ const { delivery } = await api.request(`deliveries?seq=${saved.seq}`)
49
+ const pending = names.filter(name => delivery?.[name]?.state !== 'delivered')
50
+ if (!pending.length) return { heartbeat: members.length, probe: 'delivered', seq: saved.seq }
51
+ if (Date.now() >= probeDeadline) fail('RESUME_PROBE_UNDELIVERED', pending.map(name => `${name}=${delivery?.[name]?.state ?? 'unknown'}`).join(', '))
52
+ await delay(3000)
53
+ }
54
+ }
55
+
56
+ export async function setupProject(options) {
57
+ const project = projectPath(options.project)
58
+ const aiterm = new AitermClient()
59
+ try {
60
+ // 公開依存の不足は、projectへ生成物を置く前に明示する。
61
+ await aiterm.sessions()
62
+ const scaffold = scaffoldProject({ ...options, project })
63
+ if (scaffold.action === 'resume') return await resumeProject({ ...options, project }, { aiterm })
64
+ const runtime = await ensureProjectRuntime(project, { aiterm })
65
+ return { schema: 'peertable.setup-result.v1', status: 'ready', project, room: readSetup(project).room, runtime }
66
+ } finally { await aiterm.close() }
67
+ }
68
+
69
+ export async function resumeProject(options, dependencies = {}) {
70
+ const project = projectPath(options.project)
71
+ let state = readSetup(project)
72
+ const aiterm = dependencies.aiterm ?? new AitermClient()
73
+ try {
74
+ const sessions = await aiterm.sessions(['PEERTABLE_MEMBER', 'PEERTABLE_ROOM'])
75
+ const credential = runScript('seat-credential.mjs', ['prepare', project, state.room, 'runtime'])
76
+ const api = new RoomApi(state, { credential })
77
+ const members = await api.members()
78
+ const relaunch = await seatsToResume(aiterm, members, sessions, state.room)
79
+ ensureProjectRoomMcp(project, state)
80
+ if (options.plan) {
81
+ state = { ...state, mode: 'lattice', plan_key: options.plan, phases: options.phases ?? [] }
82
+ writeJson(join(project, '.team', 'setup-state.json'), state)
83
+ runScript('external-pane.mjs', [project, state.room, process.env.PEERTABLE_PUBLIC_URL || state.public_url || state.server_url])
84
+ }
85
+ runScript('upgrade-team-assets.mjs', [project])
86
+ for (const member of relaunch) {
87
+ const { launchSeat } = await import('./launch-seat.mjs')
88
+ await launchSeat({ project, name: member.name, roles: member.roles.join(','), harness: member.harness ?? member.vendor,
89
+ model: member.model, effort: member.effort, mission: member.mission })
90
+ }
91
+ const runtime = await ensureProjectRuntime(project, { aiterm, env: { ...process.env, PEERTABLE_CREDENTIAL_FILE: credential } })
92
+ const verified = await verifyProjectRuntime(api, { probe: options.probe !== false })
93
+ return { schema: 'peertable.resume-result.v1', status: 'ready', project, room: state.room, relaunched: relaunch.map(member => member.name), runtime, verified }
94
+ } finally { if (!dependencies.aiterm) await aiterm.close() }
95
+ }
@@ -0,0 +1,29 @@
1
+ import test from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { seatsToResume, verifyProjectRuntime } from './project-runtime.mjs'
4
+
5
+ const member = { name: 'alice', harness: 'codex', aiterm_session_id: 'seat', roles: ['実装者'] }
6
+ const sessions = [{ session_id: 'seat', environment: { PEERTABLE_MEMBER: 'alice', PEERTABLE_ROOM: 'room' } }]
7
+ test('sessionが残っていてもharness死亡なら復帰し、生存席は保つ', async () => {
8
+ for (const [state, alive, count] of [['dead', false, 1], ['idle', true, 0], ['busy', true, 0]]) {
9
+ const aiterm = { observe: async () => ({ exists: true, state, harness_alive: alive }) }
10
+ assert.equal((await seatsToResume(aiterm, [member], sessions, 'room')).length, count)
11
+ }
12
+ assert.deepEqual(await seatsToResume({}, [member], [], 'room'), [member])
13
+ })
14
+ test('判定不能と役割不足は副作用前に明示し、親は復帰対象にしない', async () => {
15
+ const aiterm = { observe: async () => ({ exists: true, state: 'unknown', harness_alive: null, reason: 'harness_process_unresolved' }) }
16
+ await assert.rejects(seatsToResume(aiterm, [member], sessions, 'room'), { code: 'RESUME_MEMBER_OBSERVATION_UNKNOWN' })
17
+ await assert.rejects(seatsToResume({}, [{ ...member, roles: [] }], [], 'room'), { code: 'RESUME_MEMBER_ROLES_MISSING' })
18
+ assert.deepEqual(await seatsToResume({}, [{ ...member, delivery: { kind: 'parent_watch' } }], [], 'room'), [])
19
+ })
20
+ test('fresh heartbeatと宛先別deliveredを読んで再開の配達を確認する', async () => {
21
+ const calls = []
22
+ const api = { members: async () => [{ ...member, status_reason: 'fresh' }], request: async (path, options) => {
23
+ calls.push({ path, options })
24
+ return path === 'messages' ? { seq: 7 } : { delivery: { alice: { state: 'delivered' } } }
25
+ } }
26
+ assert.deepEqual(await verifyProjectRuntime(api), { heartbeat: 1, probe: 'delivered', seq: 7 })
27
+ assert.equal(calls[0].options.body.to, 'alice')
28
+ assert.equal(calls[1].path, 'deliveries?seq=7')
29
+ })