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.
- package/README.ja.md +32 -33
- package/README.md +36 -37
- package/package.json +3 -1
- package/room/client.mjs +24 -33
- package/room/server.mjs +1 -1
- package/skill/SKILL.md +69 -94
- package/skill/scripts/aiterm-client.mjs +48 -0
- package/skill/scripts/aiterm-deliver.mjs +12 -1
- package/skill/scripts/change-seat.mjs +86 -0
- package/skill/scripts/change-seat.sh +3 -276
- package/skill/scripts/change-seat.test.mjs +129 -0
- package/skill/scripts/cli.mjs +77 -0
- package/skill/scripts/doctor.mjs +53 -0
- package/skill/scripts/doctor.sh +2 -213
- package/skill/scripts/ensure-all-bridges.mjs +42 -0
- package/skill/scripts/ensure-all-bridges.sh +3 -80
- package/skill/scripts/ensure-bridge.mjs +11 -0
- package/skill/scripts/ensure-bridge.sh +2 -95
- package/skill/scripts/ensure-codex-room-mcp.mjs +1 -1
- package/skill/scripts/ensure-project-runtime.mjs +85 -0
- package/skill/scripts/ensure-project-runtime.sh +2 -11
- package/skill/scripts/ensure-project-runtime.test.mjs +40 -0
- package/skill/scripts/install-skill.mjs +89 -0
- package/skill/scripts/install-skill.test.mjs +84 -0
- package/skill/scripts/launch-seat.mjs +156 -0
- package/skill/scripts/launch-seat.sh +3 -797
- package/skill/scripts/launch-seat.test.mjs +159 -0
- package/skill/scripts/leave-seat.mjs +42 -0
- package/skill/scripts/leave-seat.sh +3 -92
- package/skill/scripts/leave-seat.test.mjs +53 -0
- package/skill/scripts/legacy-entry.mjs +33 -0
- package/skill/scripts/platform/windows/resolve-lattice-command.mjs +12 -1
- package/skill/scripts/project-runtime.mjs +95 -0
- package/skill/scripts/project-runtime.test.mjs +29 -0
- package/skill/scripts/project-scaffold.mjs +179 -0
- package/skill/scripts/project-scaffold.test.mjs +129 -0
- package/skill/scripts/refresh-seat-identity.mjs +11 -8
- package/skill/scripts/remove-managed-room-mcp.mjs +9 -4
- package/skill/scripts/resume.sh +2 -225
- package/skill/scripts/room-api.mjs +28 -0
- package/skill/scripts/room-public-session.test.mjs +73 -0
- package/skill/scripts/runtime-contract.test.mjs +13 -146
- package/skill/scripts/runtime-launch-command.mjs +19 -0
- package/skill/scripts/runtime-launch-command.test.mjs +32 -0
- package/skill/scripts/seat-approval.mjs +24 -0
- package/skill/scripts/seat-approval.test.mjs +43 -0
- package/skill/scripts/seat-identity.mjs +2 -71
- package/skill/scripts/seat-observer.mjs +54 -0
- package/skill/scripts/seat-observer.test.mjs +58 -0
- package/skill/scripts/seat-session.mjs +17 -0
- package/skill/scripts/seat-status-bridge.mjs +33 -206
- package/skill/scripts/seat-usage.mjs +3 -334
- package/skill/scripts/setup.sh +3 -215
- package/skill/scripts/teardown.mjs +113 -0
- package/skill/scripts/teardown.sh +3 -410
- package/skill/scripts/teardown.test.mjs +62 -0
- package/skill/scripts/upgrade-team-assets.mjs +268 -0
- package/skill/scripts/upgrade-team-assets.sh +2 -282
- package/skill/scripts/wakeup-bridge.mjs +24 -32
- package/skill/scripts/wakeup-delivery.mjs +7 -18
- package/skill/templates/charter.md +1 -1
- package/skill/templates/member-standalone.md +1 -1
- package/skill/templates/member.md +3 -3
- package/skill/scripts/agent-pane-status.mjs +0 -6
- package/skill/scripts/aiterm-configure.mjs +0 -31
- package/skill/scripts/aiterm-launch.mjs +0 -64
- package/skill/scripts/aiterm-send.mjs +0 -22
- package/skill/scripts/claude-dialog.mjs +0 -26
- package/skill/scripts/codex-dialog.mjs +0 -67
- package/skill/scripts/platform/windows/build-bridge-command.mjs +0 -52
- package/skill/scripts/seat-input.mjs +0 -36
- package/skill/scripts/tmux-at.bash +0 -22
- package/skill/scripts/tmux-socket.mjs +0 -19
- package/skill/scripts/vendors/grok/pane-status.mjs +0 -30
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// 明示されたprojectだけに、Peertableの生成物と所有記録を置く。
|
|
2
|
+
import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, writeFileSync } from 'node:fs'
|
|
3
|
+
import { execFileSync } from 'node:child_process'
|
|
4
|
+
import { basename, dirname, join, resolve } from 'node:path'
|
|
5
|
+
import { packageRoot } from './install-skill.mjs'
|
|
6
|
+
import { expectedRoomMcp, isExpectedRoomMcp } from './room-mcp-config.mjs'
|
|
7
|
+
import { resolveLatticeInvocation } from './seat-usage.mjs'
|
|
8
|
+
|
|
9
|
+
export const fail = (code, message) => { throw Object.assign(new Error(`${code}: ${message}`), { code }) }
|
|
10
|
+
export const readJson = path => JSON.parse(readFileSync(path, 'utf8'))
|
|
11
|
+
export const writeJson = (path, value) => writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`)
|
|
12
|
+
|
|
13
|
+
export function projectPath(input) {
|
|
14
|
+
if (!input) fail('PEERTABLE_PROJECT_REQUIRED', '対象projectのパスを指定してください')
|
|
15
|
+
const path = realpathSync(input)
|
|
16
|
+
if (!lstatSync(path).isDirectory()) fail('PEERTABLE_PROJECT_INVALID', path)
|
|
17
|
+
return path
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function readSetup(project) {
|
|
21
|
+
const file = join(project, '.team', 'setup-state.json')
|
|
22
|
+
if (!existsSync(file)) fail('RESUME_NOT_SET_UP', `${project}は未setupです`)
|
|
23
|
+
const state = readJson(file)
|
|
24
|
+
if (!state || !['standalone', 'lattice'].includes(state.mode) || !state.room || !state.server_url)
|
|
25
|
+
fail('PEERTABLE_SETUP_STATE_INVALID', file)
|
|
26
|
+
return state
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function runScript(script, args = [], { root = packageRoot, env = process.env, cwd } = {}) {
|
|
30
|
+
return execFileSync(process.execPath, [join(root, 'skill', 'scripts', script), ...args], {
|
|
31
|
+
encoding: 'utf8', cwd, env, stdio: ['ignore', 'pipe', 'pipe'],
|
|
32
|
+
}).trim()
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function excludePath(project) {
|
|
36
|
+
try {
|
|
37
|
+
return execFileSync('git', ['-C', project, 'rev-parse', '--path-format=absolute', '--git-path', 'info/exclude'], {
|
|
38
|
+
encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'],
|
|
39
|
+
}).trim()
|
|
40
|
+
} catch (error) {
|
|
41
|
+
if (error.code === 'ENOENT') throw error
|
|
42
|
+
return null // git管理外のprojectも単独モードで使える。
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function addExclude(project, rule) {
|
|
47
|
+
const path = excludePath(project)
|
|
48
|
+
if (!path) return false
|
|
49
|
+
const body = existsSync(path) ? readFileSync(path, 'utf8') : ''
|
|
50
|
+
if (body.split(/\r?\n/u).includes(rule)) return false
|
|
51
|
+
mkdirSync(dirname(path), { recursive: true })
|
|
52
|
+
writeFileSync(path, `${body}${body && !body.endsWith('\n') ? '\n' : ''}${rule}\n`)
|
|
53
|
+
return true
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function removeExclude(project, rule) {
|
|
57
|
+
const path = excludePath(project)
|
|
58
|
+
if (!path || !existsSync(path)) return
|
|
59
|
+
const body = readFileSync(path, 'utf8')
|
|
60
|
+
writeFileSync(path, body.split('\n').filter(line => line.replace(/\r$/u, '') !== rule).join('\n'))
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function ensureProjectRoomMcp(project, state, { root = packageRoot } = {}) {
|
|
64
|
+
const file = join(project, '.mcp.json')
|
|
65
|
+
if (existsSync(file) && lstatSync(file).isSymbolicLink()) fail('PEERTABLE_MCP_CONFLICT', '.mcp.jsonはsymlinkです')
|
|
66
|
+
const mcp = existsSync(file) ? readJson(file) : {}
|
|
67
|
+
if (!mcp || typeof mcp !== 'object' || Array.isArray(mcp)
|
|
68
|
+
|| (mcp.mcpServers !== undefined && (!mcp.mcpServers || typeof mcp.mcpServers !== 'object' || Array.isArray(mcp.mcpServers))))
|
|
69
|
+
fail('PEERTABLE_MCP_CONFLICT', '.mcp.jsonの形式が不正です')
|
|
70
|
+
const expected = expectedRoomMcp(root)
|
|
71
|
+
if (mcp.mcpServers?.room !== undefined) {
|
|
72
|
+
if (!isExpectedRoomMcp(mcp.mcpServers.room, expected)) fail('PEERTABLE_MCP_CONFLICT', '既存のroom MCP定義が異なります')
|
|
73
|
+
return
|
|
74
|
+
}
|
|
75
|
+
if (!(state.room_mcp_managed ?? state.added_root_mcp)) fail('PEERTABLE_MCP_CONFLICT', '利用者が管理するroom MCP定義がありません')
|
|
76
|
+
mcp.mcpServers ??= {}
|
|
77
|
+
mcp.mcpServers.room = expected
|
|
78
|
+
// 所有記録の保存後にだけ置換する。書込み失敗で利用者の設定を切り詰めない。
|
|
79
|
+
const temporary = `${file}.peertable-${process.pid}.tmp`
|
|
80
|
+
try {
|
|
81
|
+
writeFileSync(temporary, `${JSON.stringify(mcp, null, 2)}\n`, { mode: existsSync(file) ? lstatSync(file).mode & 0o777 : 0o600 })
|
|
82
|
+
renameSync(temporary, file)
|
|
83
|
+
} finally { rmSync(temporary, { force: true }) }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function scaffoldProject(options, { root = packageRoot } = {}) {
|
|
87
|
+
const project = projectPath(options.project)
|
|
88
|
+
const team = join(project, '.team')
|
|
89
|
+
try {
|
|
90
|
+
const info = lstatSync(team)
|
|
91
|
+
if (info.isSymbolicLink() || !info.isDirectory()) fail('PEERTABLE_SETUP_TEAM_CONFLICT', '.teamは通常ディレクトリではありません')
|
|
92
|
+
} catch (error) { if (error.code !== 'ENOENT') throw error }
|
|
93
|
+
if (existsSync(join(team, 'setup-state.json'))) {
|
|
94
|
+
const state = readSetup(project)
|
|
95
|
+
if ((options.room && options.room !== state.room)
|
|
96
|
+
|| (options.url && options.url.replace(/\/+$/u, '') !== state.server_url.replace(/\/+$/u, '')))
|
|
97
|
+
fail('PEERTABLE_SETUP_TARGET_CONFLICT', '既存projectのroomまたはserverと指定が違います')
|
|
98
|
+
ensureProjectRoomMcp(project, state, { root })
|
|
99
|
+
return { project, state, action: 'resume' }
|
|
100
|
+
}
|
|
101
|
+
if (existsSync(team) && (!lstatSync(team).isDirectory() || lstatSync(team).isSymbolicLink() || readdirSync(team).length))
|
|
102
|
+
fail('PEERTABLE_SETUP_TEAM_CONFLICT', '.teamに既存資産があります')
|
|
103
|
+
if (excludePath(project)) {
|
|
104
|
+
const tracked = execFileSync('git', ['-C', project, 'ls-files', '--cached', '--', '.team'], { encoding: 'utf8' })
|
|
105
|
+
if (tracked.trim()) fail('PEERTABLE_SETUP_TEAM_CONFLICT', '.teamにgit追跡済みの資産があります')
|
|
106
|
+
}
|
|
107
|
+
const room = options.room || basename(project)
|
|
108
|
+
if (!/^[A-Za-z0-9._-]+$/u.test(room)) fail('PEERTABLE_ROOM_INVALID', room)
|
|
109
|
+
const url = (options.url || '').replace(/\/+$/u, '')
|
|
110
|
+
let parsedUrl
|
|
111
|
+
try { parsedUrl = new URL(url) } catch { fail('PEERTABLE_SERVER_REQUIRED', '--urlにroom server URLを指定してください') }
|
|
112
|
+
if (!['http:', 'https:'].includes(parsedUrl.protocol) || parsedUrl.username || parsedUrl.password)
|
|
113
|
+
fail('PEERTABLE_SERVER_INVALID', 'HTTP/HTTPSのroom serverを指定してください')
|
|
114
|
+
const mode = options.plan && options.plan !== '-' ? 'lattice' : 'standalone'
|
|
115
|
+
const phases = options.phases ?? []
|
|
116
|
+
if (phases.some(phase => !/^[A-Za-z0-9._-]+$/u.test(phase)) || (mode === 'standalone' && phases.length))
|
|
117
|
+
fail('PEERTABLE_PHASE_INVALID', 'phaseはLatticeモードの有効なIDだけを指定できます')
|
|
118
|
+
let tasks
|
|
119
|
+
if (mode === 'standalone') {
|
|
120
|
+
if (!options.tasks) fail('PEERTABLE_TASKS_REQUIRED', '単独モードには--tasksで議題ファイルを指定してください')
|
|
121
|
+
tasks = readFileSync(resolve(options.tasks), 'utf8')
|
|
122
|
+
if (!tasks.trim()) fail('PEERTABLE_TASKS_REQUIRED', '議題ファイルが空です')
|
|
123
|
+
}
|
|
124
|
+
if (mode === 'lattice') {
|
|
125
|
+
const gitRoot = execFileSync('git', ['-C', project, 'rev-parse', '--show-toplevel'], { encoding: 'utf8' }).trim()
|
|
126
|
+
if (realpathSync(gitRoot) !== project) fail('PEERTABLE_PROJECT_INVALID', 'Latticeモードはgit rootを指定してください')
|
|
127
|
+
const invocation = resolveLatticeInvocation(options.latticeCli || process.env.LATTICE_CLI || 'lattice', ['--version'])
|
|
128
|
+
execFileSync(invocation.command, invocation.argv, { cwd: project, encoding: 'utf8', timeout: 15_000 })
|
|
129
|
+
}
|
|
130
|
+
const mcpPath = join(project, '.mcp.json')
|
|
131
|
+
try { if (lstatSync(mcpPath).isSymbolicLink()) fail('PEERTABLE_MCP_CONFLICT', '.mcp.jsonはsymlinkです') }
|
|
132
|
+
catch (error) { if (error.code !== 'ENOENT') throw error }
|
|
133
|
+
const preexisting = existsSync(mcpPath)
|
|
134
|
+
if (preexisting && (!lstatSync(mcpPath).isFile() || lstatSync(mcpPath).isSymbolicLink()))
|
|
135
|
+
fail('PEERTABLE_MCP_CONFLICT', '.mcp.jsonは通常ファイルである必要があります')
|
|
136
|
+
const mcp = preexisting ? readJson(mcpPath) : {}
|
|
137
|
+
if (!mcp || typeof mcp !== 'object' || Array.isArray(mcp)
|
|
138
|
+
|| (mcp.mcpServers !== undefined && (!mcp.mcpServers || typeof mcp.mcpServers !== 'object' || Array.isArray(mcp.mcpServers))))
|
|
139
|
+
fail('PEERTABLE_MCP_CONFLICT', '.mcp.jsonの形式が不正です')
|
|
140
|
+
const roomPreexisting = mcp.mcpServers?.room !== undefined
|
|
141
|
+
if (roomPreexisting && !isExpectedRoomMcp(mcp.mcpServers.room, expectedRoomMcp(root)))
|
|
142
|
+
fail('PEERTABLE_MCP_CONFLICT', '既存のroom MCP定義が異なります')
|
|
143
|
+
|
|
144
|
+
const state = {
|
|
145
|
+
room, server_url: url, public_url: options.publicUrl || url, mode,
|
|
146
|
+
plan_key: mode === 'lattice' ? options.plan : '', phases,
|
|
147
|
+
lattice_cli: options.latticeCli || process.env.LATTICE_CLI || '',
|
|
148
|
+
lattice_preexisting: existsSync(join(project, '.lattice')),
|
|
149
|
+
runtime_preexisting: existsSync(join(project, '.lattice', 'runtime')),
|
|
150
|
+
added_exclude: false, added_mcp_exclude: false, added_runtime_exclude: false,
|
|
151
|
+
added_root_mcp: !preexisting, room_mcp_managed: !roomPreexisting,
|
|
152
|
+
external_pane: false, project_json_preexisting: false,
|
|
153
|
+
work_order_adapter: false, work_order_spool_ref: '',
|
|
154
|
+
}
|
|
155
|
+
try {
|
|
156
|
+
mkdirSync(join(team, 'roles'), { recursive: true })
|
|
157
|
+
mkdirSync(join(team, 'scripts'), { recursive: true })
|
|
158
|
+
if (!roomPreexisting && preexisting) writeFileSync(join(team, 'root-mcp.original.json'), readFileSync(mcpPath))
|
|
159
|
+
if (mode === 'standalone') writeFileSync(join(team, 'tasks.md'), readFileSync(join(root, 'skill', 'templates', 'tasks.md'), 'utf8') + tasks)
|
|
160
|
+
writeJson(join(team, 'setup-state.json'), state)
|
|
161
|
+
} catch (error) {
|
|
162
|
+
// この時点では既存設定へ未着手。今回作った足場だけを戻して再実行可能にする。
|
|
163
|
+
try { rmSync(team, { recursive: true, force: true }) }
|
|
164
|
+
catch (cleanup) { throw new AggregateError([error, cleanup], 'setup記録の保存と足場の撤去が失敗しました') }
|
|
165
|
+
throw error
|
|
166
|
+
}
|
|
167
|
+
ensureProjectRoomMcp(project, state, { root })
|
|
168
|
+
state.added_exclude = addExclude(project, '.team/')
|
|
169
|
+
if (!preexisting) state.added_mcp_exclude = addExclude(project, '/.mcp.json')
|
|
170
|
+
if (mode === 'lattice') state.added_runtime_exclude = addExclude(project, '/.lattice/runtime/')
|
|
171
|
+
writeJson(join(team, 'setup-state.json'), state)
|
|
172
|
+
runScript('upgrade-team-assets.mjs', [project], { root })
|
|
173
|
+
if (mode === 'lattice') {
|
|
174
|
+
state.project_json_preexisting = runScript('external-pane.mjs', [project, room, state.public_url], { root }) === 'true'
|
|
175
|
+
state.external_pane = true
|
|
176
|
+
writeJson(join(team, 'setup-state.json'), state)
|
|
177
|
+
}
|
|
178
|
+
return { project, state, action: 'created' }
|
|
179
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import { execFileSync } from 'node:child_process'
|
|
3
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
|
|
4
|
+
import fs from 'node:fs'
|
|
5
|
+
import { syncBuiltinESMExports } from 'node:module'
|
|
6
|
+
import { tmpdir } from 'node:os'
|
|
7
|
+
import { join } from 'node:path'
|
|
8
|
+
import test from 'node:test'
|
|
9
|
+
import { scaffoldProject, runScript } from './project-scaffold.mjs'
|
|
10
|
+
|
|
11
|
+
function fixture(t) {
|
|
12
|
+
const dir = mkdtempSync(join(tmpdir(), 'peertable-project-'))
|
|
13
|
+
t.after(() => rmSync(dir, { recursive: true, force: true }))
|
|
14
|
+
const project = join(dir, 'project with spaces')
|
|
15
|
+
mkdirSync(project)
|
|
16
|
+
execFileSync('git', ['init', '--quiet', project])
|
|
17
|
+
const tasks = join(dir, 'tasks.md')
|
|
18
|
+
writeFileSync(tasks, '- smoke: 導入の確認\n')
|
|
19
|
+
return { dir, project, tasks, room: 'install-smoke', url: 'http://127.0.0.1:18860' }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
test('scaffoldは対象projectに生成し既存MCP設定と他projectを保持する', t => {
|
|
23
|
+
const options = fixture(t)
|
|
24
|
+
const mcp = { custom: true, mcpServers: { existing: { command: 'keep', args: ['a'] } } }
|
|
25
|
+
writeFileSync(join(options.project, '.mcp.json'), JSON.stringify(mcp))
|
|
26
|
+
mkdirSync(join(options.dir, 'other'))
|
|
27
|
+
writeFileSync(join(options.dir, 'other', 'setting'), '保持')
|
|
28
|
+
const result = scaffoldProject(options)
|
|
29
|
+
assert.equal(result.action, 'created')
|
|
30
|
+
assert.equal(result.state.mode, 'standalone')
|
|
31
|
+
assert.equal(result.state.added_root_mcp, false)
|
|
32
|
+
assert.equal(result.state.room_mcp_managed, true)
|
|
33
|
+
const after = JSON.parse(readFileSync(join(options.project, '.mcp.json'), 'utf8'))
|
|
34
|
+
assert.deepEqual(after.mcpServers.existing, mcp.mcpServers.existing)
|
|
35
|
+
assert.equal(after.custom, true)
|
|
36
|
+
assert.equal(after.mcpServers.room.command, 'node')
|
|
37
|
+
assert.equal(readFileSync(join(options.dir, 'other', 'setting'), 'utf8'), '保持')
|
|
38
|
+
assert.ok(existsSync(join(options.project, '.team', 'roles', 'member.md')))
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
test('setup再実行は既存roomと議題を保持してresumeを選ぶ', t => {
|
|
42
|
+
const options = fixture(t)
|
|
43
|
+
scaffoldProject(options)
|
|
44
|
+
const path = join(options.project, '.team', 'tasks.md')
|
|
45
|
+
const tasksBefore = readFileSync(path, 'utf8')
|
|
46
|
+
writeFileSync(options.tasks, '新しい議題')
|
|
47
|
+
const result = scaffoldProject({ project: options.project, tasks: options.tasks })
|
|
48
|
+
assert.equal(result.action, 'resume')
|
|
49
|
+
assert.equal(result.state.room, options.room)
|
|
50
|
+
assert.equal(readFileSync(path, 'utf8'), tasksBefore)
|
|
51
|
+
assert.throws(() => scaffoldProject({ project: options.project, room: 'different' }), { code: 'PEERTABLE_SETUP_TARGET_CONFLICT' })
|
|
52
|
+
assert.throws(() => scaffoldProject({ project: options.project, url: 'http://elsewhere' }), { code: 'PEERTABLE_SETUP_TARGET_CONFLICT' })
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
test('既存team・room設定の衝突は書込み前に拒否する', t => {
|
|
56
|
+
const options = fixture(t)
|
|
57
|
+
writeFileSync(join(options.project, '.mcp.json'), '{"mcpServers":{"room":{"command":"user-owned"}}}')
|
|
58
|
+
assert.throws(() => scaffoldProject(options), { code: 'PEERTABLE_MCP_CONFLICT' })
|
|
59
|
+
assert.equal(existsSync(join(options.project, '.team')), false)
|
|
60
|
+
rmSync(join(options.project, '.mcp.json'))
|
|
61
|
+
mkdirSync(join(options.project, '.team'))
|
|
62
|
+
writeFileSync(join(options.project, '.team', 'owned'), '独自資産')
|
|
63
|
+
assert.throws(() => scaffoldProject(options), { code: 'PEERTABLE_SETUP_TEAM_CONFLICT' })
|
|
64
|
+
assert.equal(readFileSync(join(options.project, '.team', 'owned'), 'utf8'), '独自資産')
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
test('team symlinkへ既存状態をたどらず、別projectへ書かない', t => {
|
|
68
|
+
const options = fixture(t)
|
|
69
|
+
const target = join(options.dir, 'outside')
|
|
70
|
+
mkdirSync(target)
|
|
71
|
+
writeFileSync(join(target, 'setup-state.json'), '{"mode":"standalone","room":"outside","server_url":"http://localhost"}')
|
|
72
|
+
symlinkSync(target, join(options.project, '.team'), process.platform === 'win32' ? 'junction' : 'dir')
|
|
73
|
+
assert.throws(() => scaffoldProject(options), { code: 'PEERTABLE_SETUP_TEAM_CONFLICT' })
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
test('単独modeの議題と明示projectを要求する', t => {
|
|
77
|
+
const options = fixture(t)
|
|
78
|
+
assert.throws(() => scaffoldProject({}), { code: 'PEERTABLE_PROJECT_REQUIRED' })
|
|
79
|
+
assert.throws(() => scaffoldProject({ ...options, tasks: undefined }), { code: 'PEERTABLE_TASKS_REQUIRED' })
|
|
80
|
+
assert.equal(existsSync(join(options.project, '.team')), false)
|
|
81
|
+
})
|
|
82
|
+
test('room block撤去は元の書式を戻し、運用中の他設定変更は保持する', t => {
|
|
83
|
+
for (const original of ['{ "mcpServers": {} }', '{"custom":true,"mcpServers":{"existing":{"command":"keep"}}}']) {
|
|
84
|
+
for (const changed of [false, true]) {
|
|
85
|
+
const options = fixture(t)
|
|
86
|
+
const file = join(options.project, '.mcp.json')
|
|
87
|
+
writeFileSync(file, original)
|
|
88
|
+
scaffoldProject(options)
|
|
89
|
+
if (changed) {
|
|
90
|
+
const current = JSON.parse(readFileSync(file, 'utf8'))
|
|
91
|
+
current.added_by_user = '運用中の変更'
|
|
92
|
+
writeFileSync(file, JSON.stringify(current))
|
|
93
|
+
}
|
|
94
|
+
runScript('remove-managed-room-mcp.mjs', [options.project])
|
|
95
|
+
const actual = readFileSync(file, 'utf8')
|
|
96
|
+
if (!changed) assert.equal(actual, original)
|
|
97
|
+
else {
|
|
98
|
+
assert.equal(JSON.parse(actual).added_by_user, '運用中の変更')
|
|
99
|
+
assert.equal(JSON.parse(actual).mcpServers?.room, undefined)
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
})
|
|
104
|
+
test('所有記録とMCP設定の書込み失敗から既存設定を保って再開する', t => {
|
|
105
|
+
for (const point of ['setup-state.json', '.mcp.json.peertable-']) {
|
|
106
|
+
const options = fixture(t)
|
|
107
|
+
const file = join(options.project, '.mcp.json')
|
|
108
|
+
const original = '{ "mcpServers": {} }\n'
|
|
109
|
+
writeFileSync(file, original)
|
|
110
|
+
const write = fs.writeFileSync
|
|
111
|
+
try {
|
|
112
|
+
fs.writeFileSync = (path, ...args) => {
|
|
113
|
+
if (String(path).includes(point)) throw Object.assign(new Error('注入した書込み失敗'), { code: 'ENOSPC' })
|
|
114
|
+
return write(path, ...args)
|
|
115
|
+
}
|
|
116
|
+
syncBuiltinESMExports()
|
|
117
|
+
assert.throws(() => scaffoldProject(options), { code: 'ENOSPC' })
|
|
118
|
+
} finally {
|
|
119
|
+
fs.writeFileSync = write
|
|
120
|
+
syncBuiltinESMExports()
|
|
121
|
+
}
|
|
122
|
+
assert.equal(readFileSync(file, 'utf8'), original)
|
|
123
|
+
const resumed = scaffoldProject(options)
|
|
124
|
+
assert.equal(resumed.action, point === 'setup-state.json' ? 'created' : 'resume')
|
|
125
|
+
assert.ok(JSON.parse(readFileSync(file, 'utf8')).mcpServers.room)
|
|
126
|
+
runScript('remove-managed-room-mcp.mjs', [options.project])
|
|
127
|
+
assert.equal(readFileSync(file, 'utf8'), original)
|
|
128
|
+
}
|
|
129
|
+
})
|
|
@@ -1,15 +1,14 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
//
|
|
2
|
+
// 台帳のargv_digestを、Aitermの公開プロセス観測へ揃える。
|
|
3
3
|
// pid / lstart が台帳と違うときは書き換えず終わる(pid 推定も再利用も禁止)。
|
|
4
|
-
import {
|
|
4
|
+
import { execFile } from 'node:child_process'
|
|
5
5
|
import { readFileSync } from 'node:fs'
|
|
6
6
|
import { dirname, join, resolve } from 'node:path'
|
|
7
7
|
import { fileURLToPath } from 'node:url'
|
|
8
8
|
import { promisify } from 'node:util'
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
import { observePidCommand } from './seat-identity.mjs'
|
|
10
|
+
import { AitermClient } from './aiterm-client.mjs'
|
|
11
|
+
import { seatSessionId } from './seat-session.mjs'
|
|
13
12
|
|
|
14
13
|
export function refreshSeatRecord(raw, observed, recordedAt) {
|
|
15
14
|
if (!Number.isSafeInteger(raw?.pid) || raw.pid < 1) {
|
|
@@ -37,7 +36,7 @@ export function refreshSeatRecord(raw, observed, recordedAt) {
|
|
|
37
36
|
}
|
|
38
37
|
|
|
39
38
|
function utcRecordedAt() {
|
|
40
|
-
return
|
|
39
|
+
return new Date().toISOString()
|
|
41
40
|
}
|
|
42
41
|
|
|
43
42
|
const isCli = process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1])
|
|
@@ -62,12 +61,16 @@ if (isCli) {
|
|
|
62
61
|
}
|
|
63
62
|
const raw = (await memberResponse.json()).member
|
|
64
63
|
let observed
|
|
64
|
+
const aiterm = new AitermClient()
|
|
65
65
|
try {
|
|
66
|
-
|
|
66
|
+
const id = seatSessionId(raw)
|
|
67
|
+
if (!id) throw new Error('Aitermのsession_idが台帳にありません')
|
|
68
|
+
observed = (await aiterm.observe(id)).process_identity
|
|
69
|
+
if (!observed) throw new Error('Aitermでプロセス本人性を確認できません')
|
|
67
70
|
} catch (error) {
|
|
68
71
|
process.stderr.write(`${error.code || 'SEAT_IDENTITY_UNOBSERVABLE'}: ${error.message}\n`)
|
|
69
72
|
process.exit(2)
|
|
70
|
-
}
|
|
73
|
+
} finally { await aiterm.close() }
|
|
71
74
|
let next
|
|
72
75
|
try {
|
|
73
76
|
next = refreshSeatRecord(raw, observed, utcRecordedAt())
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import {
|
|
3
|
-
closeSync, fsyncSync, lstatSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync,
|
|
3
|
+
closeSync, existsSync, fsyncSync, lstatSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync,
|
|
4
4
|
} from 'node:fs'
|
|
5
5
|
import { basename, dirname, join, resolve } from 'node:path'
|
|
6
6
|
import { randomBytes } from 'node:crypto'
|
|
7
|
+
import { isDeepStrictEqual } from 'node:util'
|
|
7
8
|
import { isPeertableRoomMcp } from './room-mcp-config.mjs'
|
|
8
9
|
|
|
9
10
|
const fail = (code, detail) => {
|
|
@@ -43,9 +44,13 @@ if (config.mcpServers.room === undefined) {
|
|
|
43
44
|
if (!isPeertableRoomMcp(config.mcpServers.room))
|
|
44
45
|
fail('PEERTABLE_MANAGED_MCP_REMOVE_CONFLICT', `${file}: room blockがPeertable所有形でない`)
|
|
45
46
|
|
|
47
|
+
const backup = join(resolve(project), '.team', 'root-mcp.original.json')
|
|
48
|
+
const originalText = existsSync(backup) ? readFileSync(backup, 'utf8') : null
|
|
49
|
+
const original = originalText === null ? null : JSON.parse(originalText)
|
|
46
50
|
delete config.mcpServers.room
|
|
47
|
-
if (Object.keys(config.mcpServers).length === 0) delete config.mcpServers
|
|
48
|
-
|
|
51
|
+
if (Object.keys(config.mcpServers).length === 0 && !Object.hasOwn(original ?? {}, 'mcpServers')) delete config.mcpServers
|
|
52
|
+
const restored = originalText !== null && isDeepStrictEqual(config, original)
|
|
53
|
+
if (!restored && Object.keys(config).length === 0) {
|
|
49
54
|
unlinkSync(file)
|
|
50
55
|
process.stdout.write(`${JSON.stringify({ schema: 'peertable.managed_room_mcp_remove_result.v1',
|
|
51
56
|
result: 'ok', action: 'file-deleted' })}\n`)
|
|
@@ -56,7 +61,7 @@ const temporary = join(dirname(file), `.${basename(file)}.teardown-${process.pid
|
|
|
56
61
|
let fd
|
|
57
62
|
try {
|
|
58
63
|
fd = openSync(temporary, 'wx', stat.mode & 0o777)
|
|
59
|
-
writeFileSync(fd, `${JSON.stringify(config, null, 2)}\n`, 'utf8')
|
|
64
|
+
writeFileSync(fd, restored ? originalText : `${JSON.stringify(config, null, 2)}\n`, 'utf8')
|
|
60
65
|
fsyncSync(fd)
|
|
61
66
|
closeSync(fd)
|
|
62
67
|
fd = undefined
|
package/skill/scripts/resume.sh
CHANGED
|
@@ -1,227 +1,4 @@
|
|
|
1
1
|
#!/bin/bash
|
|
2
|
-
#
|
|
3
|
-
# usage: resume.sh <project_dir> [--plan <plan_key>] [--phase <id>]... [--no-probe]
|
|
4
|
-
#
|
|
5
|
-
# 一回で行うこと:
|
|
6
|
-
# 1. 既存 .team/ と room の確認(無ければ setup.sh を案内して止まる)
|
|
7
|
-
# 2. --plan 指定時、setup-state.json / roles/member.md / 外部ペインを現行 plan へ再束縛
|
|
8
|
-
# 3. 死んだ bridge 記録(pid 不一致・停止)の除去
|
|
9
|
-
# 4. 台帳(room member 行)から現行メンバー構成を読み、席が死んでいるものだけ launch-seat.sh で再起動
|
|
10
|
-
# (credential の再生成・本人性の再記録は launch-seat.sh が持つ)
|
|
11
|
-
# 5. alarm / seat-status / wakeup bridge の再起動(ensure-bridge.sh)
|
|
12
|
-
# 6. fresh heartbeat の読み戻し(server 実効状態が fresh になるまで待つ)
|
|
13
|
-
# 7. テスト DM の配送 receipt 確認(delivered になるまで待つ。--no-probe で省略)
|
|
14
|
-
#
|
|
15
|
-
# 手書きのメンバー一覧・個別再起動 script には依存しない。親の再着卓(parent-join / 番犬)は別手順
|
|
16
|
-
#(SKILL.md「親の再着卓」)——resume は席と bridge の面だけを持つ。
|
|
2
|
+
# 既存の引数を共通の製品入口へ渡す。
|
|
17
3
|
set -euo pipefail
|
|
18
|
-
|
|
19
|
-
proj="${1:-}"
|
|
20
|
-
[ -n "$proj" ] || { echo "usage: resume.sh <project_dir> [--plan <plan_key>] [--phase <id>]... [--no-probe]" >&2; exit 1; }
|
|
21
|
-
shift
|
|
22
|
-
plan=""
|
|
23
|
-
phases=()
|
|
24
|
-
probe=true
|
|
25
|
-
while [ $# -gt 0 ]; do
|
|
26
|
-
case "$1" in
|
|
27
|
-
--plan) plan="${2:-}"; shift 2 ;;
|
|
28
|
-
--phase) [ -n "${2:-}" ] || { echo "ERROR: --phase には phase id が要る" >&2; exit 1; }; phases+=("$2"); shift 2 ;;
|
|
29
|
-
--no-probe) probe=false; shift ;;
|
|
30
|
-
*) echo "ERROR: 未知の引数: $1" >&2; exit 1 ;;
|
|
31
|
-
esac
|
|
32
|
-
done
|
|
33
|
-
proj=$(cd "$proj" && pwd)
|
|
34
|
-
script_dir=$(cd "$(dirname "$0")" && pwd -P)
|
|
35
|
-
repo="${PEERTABLE_REPO:-$(cd "$script_dir/../.." && pwd -P)}"
|
|
36
|
-
[ -f "$repo/room/client.mjs" ] || { echo "RESUME_PEERTABLE_TREE_UNRESOLVED: $repo が peertable tree でない" >&2; exit 1; }
|
|
37
|
-
setup="$proj/.team/setup-state.json"
|
|
38
|
-
if [ ! -f "$setup" ]; then
|
|
39
|
-
echo "RESUME_NOT_SET_UP: $setup が無い。新規の卓は setup.sh で立てること" >&2
|
|
40
|
-
exit 1
|
|
41
|
-
fi
|
|
42
|
-
|
|
43
|
-
# resumeは同じroomを現行Peertable treeへ再接続する入口である。席が全て生存していて再起動0件でも、
|
|
44
|
-
# Peertable所有のgenerated assetとroot room MCPを先に現行版へ更新する。
|
|
45
|
-
"$script_dir/upgrade-team-assets.sh" "$proj"
|
|
46
|
-
|
|
47
|
-
seats_file="$proj/.team/resume-seats.json"
|
|
48
|
-
|
|
49
|
-
# Phase A: 到達確認・plan 再束縛・死記録掃除・再起動対象の抽出(node が JSON を書き出す)
|
|
50
|
-
RESUME_PROJ="$proj" RESUME_REPO="$repo" RESUME_PLAN="$plan" RESUME_PHASES="${phases[*]:-}" \
|
|
51
|
-
RESUME_SEATS_FILE="$seats_file" node --input-type=module <<'NODE'
|
|
52
|
-
import { existsSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs'
|
|
53
|
-
import { execFileSync } from 'node:child_process'
|
|
54
|
-
import { join } from 'node:path'
|
|
55
|
-
import { pathToFileURL } from 'node:url'
|
|
56
|
-
|
|
57
|
-
const proj = process.env.RESUME_PROJ
|
|
58
|
-
const repo = process.env.RESUME_REPO
|
|
59
|
-
const team = join(proj, '.team')
|
|
60
|
-
const scriptDir = join(repo, 'skill', 'scripts')
|
|
61
|
-
const { bridgeRecordLive } = await import(pathToFileURL(join(scriptDir, 'bridge-record-live.mjs')))
|
|
62
|
-
const { resolveSeatObservation, tmuxArgv } = await import(pathToFileURL(join(scriptDir, 'seat-usage.mjs')))
|
|
63
|
-
|
|
64
|
-
const setupPath = join(team, 'setup-state.json')
|
|
65
|
-
const state = JSON.parse(readFileSync(setupPath, 'utf8'))
|
|
66
|
-
const { room, server_url: url } = state
|
|
67
|
-
|
|
68
|
-
// 1. room の存在確認(読み取りは room を作らないので、summary が member 0・seq 0 でも部屋の有無は API 一覧で見る)
|
|
69
|
-
const roomsRes = await fetch(`${url}/api/rooms`).catch((e) => { throw new Error(`RESUME_ROOM_UNREACHABLE: ${url} へ到達できない: ${e.message}`) })
|
|
70
|
-
if (!roomsRes.ok) throw new Error(`RESUME_ROOM_UNREACHABLE: GET /api/rooms -> HTTP ${roomsRes.status}`)
|
|
71
|
-
const { rooms } = await roomsRes.json()
|
|
72
|
-
if (!rooms.includes(room)) throw new Error(`RESUME_ROOM_MISSING: room「${room}」が server に無い。新規は setup.sh で立てること`)
|
|
73
|
-
console.log(`[実施] room 確認: ${room} @ ${url}`)
|
|
74
|
-
|
|
75
|
-
// 2. plan 再束縛(--plan 指定時だけ)
|
|
76
|
-
const plan = process.env.RESUME_PLAN
|
|
77
|
-
const phases = (process.env.RESUME_PHASES ?? '').split(' ').filter(Boolean)
|
|
78
|
-
if (plan) {
|
|
79
|
-
state.mode = 'lattice'
|
|
80
|
-
state.plan_key = plan
|
|
81
|
-
state.phases = phases
|
|
82
|
-
writeFileSync(setupPath, JSON.stringify(state) + '\n')
|
|
83
|
-
const scope = phases.length === 0
|
|
84
|
-
? 'この卓の claim 範囲は plan 全体(phase 指定なしで立っている)。'
|
|
85
|
-
: `**この卓の claim 範囲は phase ${phases.join(' ')} の task だけ**。範囲外の phase の task は、ready に見えていても取らない。範囲外に手を入れる必要が出たら room へ出して裁定を仰ぐ。`
|
|
86
|
-
const template = readFileSync(join(repo, 'skill', 'templates', 'member.md'), 'utf8')
|
|
87
|
-
writeFileSync(join(team, 'roles', 'member.md'), template.replaceAll('{{PLAN_KEY}}', plan).replaceAll('{{CLAIM_SCOPE}}', scope))
|
|
88
|
-
const publicUrl = process.env.PEERTABLE_PUBLIC_URL ?? state.public_url ?? url
|
|
89
|
-
execFileSync('node', [join(scriptDir, 'external-pane.mjs'), proj, room, publicUrl], { stdio: ['ignore', 'ignore', 'inherit'] })
|
|
90
|
-
console.log(`[実施] plan 再束縛: ${plan}${phases.length ? `(phase ${phases.join(',')})` : ''}`)
|
|
91
|
-
} else {
|
|
92
|
-
console.log(`[スキップ] plan 再束縛(--plan 未指定。現行 ${state.plan_key || state.mode} のまま)`)
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
// 3. 死んだ bridge 記録の除去(生きている記録は触らない。parent-watch.json は cursor 正本なので消さない)
|
|
96
|
-
for (const name of ['wakeup-bridge.json', 'seat-status-bridge.json', 'alarm-bridge.json']) {
|
|
97
|
-
const path = join(team, name)
|
|
98
|
-
if (!existsSync(path)) continue
|
|
99
|
-
let record = null
|
|
100
|
-
try { record = JSON.parse(readFileSync(path, 'utf8')) } catch {}
|
|
101
|
-
if (bridgeRecordLive(record)) { console.log(`[スキップ] ${name}: 生存中(pid ${record.pid})`); continue }
|
|
102
|
-
unlinkSync(path)
|
|
103
|
-
console.log(`[実施] ${name}: 死んだ記録を除去(pid ${record?.pid ?? '不明'})`)
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
// 4. 台帳から再起動対象を抽出。席の生死は tmux session の実在で判定する
|
|
107
|
-
const { members } = await (await fetch(`${url}/api/${encodeURIComponent(room)}/members`)).json()
|
|
108
|
-
const relaunch = []
|
|
109
|
-
const missingRoles = []
|
|
110
|
-
for (const member of members) {
|
|
111
|
-
if (member.delivery?.kind === 'parent_watch') continue
|
|
112
|
-
const harness = member.harness ?? member.vendor
|
|
113
|
-
if (!harness) continue // 素性の無い行(親の手動登録等)は席ではない
|
|
114
|
-
const observation = resolveSeatObservation(member, null)
|
|
115
|
-
let aliveSeat = false
|
|
116
|
-
if (observation) {
|
|
117
|
-
try {
|
|
118
|
-
execFileSync('tmux', tmuxArgv(['has-session', '-t', observation.target], { socket: observation.socket }), { stdio: 'ignore' })
|
|
119
|
-
aliveSeat = true
|
|
120
|
-
} catch {}
|
|
121
|
-
}
|
|
122
|
-
if (aliveSeat) { console.log(`[スキップ] 席 ${member.name}: tmux session 生存中`); continue }
|
|
123
|
-
if (!Array.isArray(member.roles) || member.roles.length === 0) { missingRoles.push(member.name); continue }
|
|
124
|
-
relaunch.push({
|
|
125
|
-
name: member.name, roles: member.roles.join(','), harness,
|
|
126
|
-
model: member.model ?? null, effort: member.effort ?? null, mission: member.mission ?? null,
|
|
127
|
-
})
|
|
128
|
-
}
|
|
129
|
-
if (missingRoles.length) {
|
|
130
|
-
throw new Error(`RESUME_MEMBER_ROLES_MISSING: 役割の無い member は再起動できない: ${missingRoles.join(', ')}。`
|
|
131
|
-
+ 'launch-seat.sh --roles で個別に立て直すか、member 行を整備すること')
|
|
132
|
-
}
|
|
133
|
-
writeFileSync(process.env.RESUME_SEATS_FILE, JSON.stringify(relaunch) + '\n')
|
|
134
|
-
console.log(`[実施] 再起動対象の抽出: ${relaunch.length} 席(${relaunch.map(s => s.name).join(', ') || 'なし'})`)
|
|
135
|
-
NODE
|
|
136
|
-
|
|
137
|
-
# Phase B: 席の再起動(credential 再生成・本人性再記録は launch-seat.sh が持つ)。
|
|
138
|
-
# 受け渡しは TSV(mission に tab を含む member は台帳整備の対象であり、ここでは想定しない)
|
|
139
|
-
while IFS=$'\t' read -r name roles harness model effort mission; do
|
|
140
|
-
[ -n "$name" ] || continue
|
|
141
|
-
argv=("$proj" "$name" --roles "$roles" --harness "$harness")
|
|
142
|
-
[ -n "$model" ] && argv+=(--model "$model")
|
|
143
|
-
[ -n "$effort" ] && argv+=(--effort "$effort")
|
|
144
|
-
[ -n "$mission" ] && argv+=(--mission "$mission")
|
|
145
|
-
echo "[実施] 席の再起動: $name(roles=$roles harness=$harness${model:+ model=$model}${effort:+ effort=$effort})"
|
|
146
|
-
env -u PEERTABLE_POST_TOKEN "$script_dir/launch-seat.sh" "${argv[@]}"
|
|
147
|
-
done < <(node -e '
|
|
148
|
-
const seats = JSON.parse(require("node:fs").readFileSync(process.argv[1], "utf8"))
|
|
149
|
-
for (const s of seats) console.log([s.name, s.roles, s.harness, s.model ?? "", s.effort ?? "", s.mission ?? ""].join("\t"))
|
|
150
|
-
' "$seats_file")
|
|
151
|
-
rm -f "$seats_file"
|
|
152
|
-
|
|
153
|
-
# Phase C: runtime を一回の入口で現行版へ収束
|
|
154
|
-
"$script_dir/ensure-project-runtime.sh" "$proj"
|
|
155
|
-
|
|
156
|
-
# Phase D: fresh heartbeat の読み戻しと probe DM の配送 receipt 確認
|
|
157
|
-
RESUME_PROJ="$proj" RESUME_PROBE="$probe" RESUME_SCRIPT_DIR="$script_dir" node --input-type=module <<'NODE'
|
|
158
|
-
import { readFileSync } from 'node:fs'
|
|
159
|
-
import { join } from 'node:path'
|
|
160
|
-
import { pathToFileURL } from 'node:url'
|
|
161
|
-
|
|
162
|
-
const proj = process.env.RESUME_PROJ
|
|
163
|
-
const { room, server_url: url } = JSON.parse(readFileSync(join(proj, '.team', 'setup-state.json'), 'utf8'))
|
|
164
|
-
const api = p => `${url}/api/${encodeURIComponent(room)}/${p}`
|
|
165
|
-
const sleep = ms => new Promise(r => setTimeout(r, ms))
|
|
166
|
-
|
|
167
|
-
// launch-seat.sh 直後の席(tmux session が居る非親 member)が対象
|
|
168
|
-
const { resolvePostToken } = await import(pathToFileURL(join(process.env.RESUME_SCRIPT_DIR, 'seat-usage.mjs')))
|
|
169
|
-
|
|
170
|
-
// 6. fresh heartbeat の読み戻し(seat-status bridge 心拍 30 秒 + 判定余裕)
|
|
171
|
-
const deadline = Date.now() + 90_000
|
|
172
|
-
let pendingSeats = []
|
|
173
|
-
for (;;) {
|
|
174
|
-
const { members, bridges } = await (await fetch(api('members'))).json()
|
|
175
|
-
const seats = members.filter(m => m.delivery?.kind !== 'parent_watch' && (m.harness ?? m.vendor) && m.observe?.tmux_target)
|
|
176
|
-
if (seats.length === 0) {
|
|
177
|
-
console.log('[スキップ] fresh heartbeat 読み戻し(観測対象の席なし)')
|
|
178
|
-
break
|
|
179
|
-
}
|
|
180
|
-
pendingSeats = seats.filter(m => m.status_reason !== 'fresh')
|
|
181
|
-
if (pendingSeats.length === 0) {
|
|
182
|
-
console.log(`[実施] fresh heartbeat 読み戻し: ${seats.length} 席すべて fresh`)
|
|
183
|
-
break
|
|
184
|
-
}
|
|
185
|
-
if (Date.now() > deadline) {
|
|
186
|
-
console.error(`RESUME_HEARTBEAT_STALE: fresh にならない席: ${pendingSeats.map(m => `${m.name}(${m.status_reason})`).join(', ') || '(席なし)'}`
|
|
187
|
-
+ `(bridges: seat_status=${bridges?.seat_status?.state} wakeup=${bridges?.wakeup?.state})`)
|
|
188
|
-
process.exit(1)
|
|
189
|
-
}
|
|
190
|
-
await sleep(3000)
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
// 7. probe DM の配送 receipt 確認
|
|
194
|
-
if (process.env.RESUME_PROBE !== 'true') {
|
|
195
|
-
console.log('[スキップ] probe DM(--no-probe 指定)')
|
|
196
|
-
process.exit(0)
|
|
197
|
-
}
|
|
198
|
-
const token = resolvePostToken(process.env)
|
|
199
|
-
const headers = { 'Content-Type': 'application/json', ...(token ? { 'X-Peertable-Token': token } : {}) }
|
|
200
|
-
const { members } = await (await fetch(api('members'))).json()
|
|
201
|
-
const targets = members.filter(m => m.delivery?.kind !== 'parent_watch' && (m.harness ?? m.vendor) && m.observe?.tmux_target).map(m => m.name)
|
|
202
|
-
if (!targets.length) { console.log('[スキップ] probe DM(配達対象の席なし)'); process.exit(0) }
|
|
203
|
-
const res = await fetch(api('messages'), {
|
|
204
|
-
method: 'POST', headers,
|
|
205
|
-
body: JSON.stringify({ from: 'resume', to: targets.length === 1 ? targets[0] : targets,
|
|
206
|
-
body: '[resume-probe] 配達経路の確認。応答不要・読み流してよい。' }),
|
|
207
|
-
})
|
|
208
|
-
const saved = await res.json()
|
|
209
|
-
if (!res.ok) { console.error(`RESUME_PROBE_POST_FAILED: ${JSON.stringify(saved)}`); process.exit(1) }
|
|
210
|
-
console.log(`[実施] probe DM 投稿: seq ${saved.seq} → ${targets.join(', ')}`)
|
|
211
|
-
const probeDeadline = Date.now() + 120_000
|
|
212
|
-
for (;;) {
|
|
213
|
-
const { delivery } = await (await fetch(api(`deliveries?seq=${saved.seq}`))).json()
|
|
214
|
-
const undelivered = targets.filter(t => delivery?.[t]?.state !== 'delivered')
|
|
215
|
-
if (undelivered.length === 0) {
|
|
216
|
-
console.log('[実施] probe DM の配送 receipt: 全席 delivered')
|
|
217
|
-
break
|
|
218
|
-
}
|
|
219
|
-
if (Date.now() > probeDeadline) {
|
|
220
|
-
console.error(`RESUME_PROBE_UNDELIVERED: delivered にならない席: ${undelivered.map(t => `${t}=${delivery?.[t]?.state ?? 'unknown'}${delivery?.[t]?.reason ? `(${delivery[t].reason})` : ''}`).join(', ')}`
|
|
221
|
-
+ '(Grok 席は idle 待ちで遅れることがある。.team/wakeup-bridge.log を確認すること)')
|
|
222
|
-
process.exit(1)
|
|
223
|
-
}
|
|
224
|
-
await sleep(3000)
|
|
225
|
-
}
|
|
226
|
-
console.log('resume 完了。親の再着卓(parent-join / 番犬)は SKILL.md「親の再着卓」の手順で別途行うこと')
|
|
227
|
-
NODE
|
|
4
|
+
exec node "$(dirname "$0")/legacy-entry.mjs" resume "$@"
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// roomのHTTP境界。失敗応答を空の台帳として扱わない。
|
|
2
|
+
import { readFileSync } from 'node:fs'
|
|
3
|
+
|
|
4
|
+
export class RoomApi {
|
|
5
|
+
constructor(state, { credential, fetchImpl = fetch } = {}) {
|
|
6
|
+
this.base = `${state.server_url.replace(/\/$/, '')}/api/${encodeURIComponent(state.room)}`
|
|
7
|
+
this.credential = credential
|
|
8
|
+
this.fetch = fetchImpl
|
|
9
|
+
}
|
|
10
|
+
async request(path, { method = 'GET', body } = {}) {
|
|
11
|
+
const headers = { 'Content-Type': 'application/json' }
|
|
12
|
+
if (method !== 'GET' && this.credential) {
|
|
13
|
+
headers['X-Peertable-Token'] = readFileSync(this.credential, 'utf8').trim()
|
|
14
|
+
}
|
|
15
|
+
let response
|
|
16
|
+
try {
|
|
17
|
+
response = await this.fetch(path ? `${this.base}/${path}` : this.base, {
|
|
18
|
+
method, headers, ...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
19
|
+
signal: AbortSignal.timeout(15_000),
|
|
20
|
+
})
|
|
21
|
+
} catch (error) {
|
|
22
|
+
throw Object.assign(new Error(`roomへ到達できません: ${error.message}`), { code: 'PEERTABLE_ROOM_UNREACHABLE' })
|
|
23
|
+
}
|
|
24
|
+
if (!response.ok) throw Object.assign(new Error(`${method} ${path}: HTTP ${response.status}`), { code: 'PEERTABLE_ROOM_REQUEST_FAILED' })
|
|
25
|
+
return response.json()
|
|
26
|
+
}
|
|
27
|
+
async members() { return (await this.request('members')).members }
|
|
28
|
+
}
|