cross-agent-teams-mcp 0.2.0
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/LICENSE +21 -0
- package/README.md +296 -0
- package/README.zh-CN.md +306 -0
- package/dist/channel-cli.d.ts +18 -0
- package/dist/channel-cli.js +358 -0
- package/dist/channel-cli.js.map +1 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +4585 -0
- package/dist/cli.js.map +1 -0
- package/package.json +62 -0
- package/src/channel/auto-daemon.ts +130 -0
- package/src/channel/daemon-client.ts +155 -0
- package/src/channel/proxy.ts +28 -0
- package/src/channel-cli.ts +122 -0
- package/src/cli.ts +136 -0
- package/src/daemon/auth.ts +17 -0
- package/src/daemon/channel-wake-fanout.ts +39 -0
- package/src/daemon/channel-wake-send.ts +38 -0
- package/src/daemon/cleanup.ts +38 -0
- package/src/daemon/errors.ts +18 -0
- package/src/daemon/pid.ts +33 -0
- package/src/daemon/port.ts +16 -0
- package/src/daemon/runtime-identity.ts +238 -0
- package/src/daemon/server.ts +64 -0
- package/src/daemon/shutdown.ts +12 -0
- package/src/daemon/sse-fanout.ts +96 -0
- package/src/daemon/tmux-cli.ts +61 -0
- package/src/daemon/tmux-pane-detect.ts +276 -0
- package/src/lib/client-kind.ts +1 -0
- package/src/lib/default-team.ts +18 -0
- package/src/lib/delivery-spec.ts +172 -0
- package/src/lib/schema-diff.ts +79 -0
- package/src/mcp/agent-public-row.ts +52 -0
- package/src/mcp/auto-bind-channel.ts +106 -0
- package/src/mcp/auto-bind-codex-pane.ts +170 -0
- package/src/mcp/auto-poke-fanout.ts +129 -0
- package/src/mcp/bind-channel.ts +39 -0
- package/src/mcp/bind-runtime-identity.ts +43 -0
- package/src/mcp/broadcast-to-role.ts +127 -0
- package/src/mcp/broadcast.ts +115 -0
- package/src/mcp/codex-appserver-dispatch.ts +169 -0
- package/src/mcp/codex-appserver-rpc.ts +227 -0
- package/src/mcp/codex-pane-pre-register-repo.ts +57 -0
- package/src/mcp/delivery-status.ts +114 -0
- package/src/mcp/diff-contracts.ts +25 -0
- package/src/mcp/echo.ts +8 -0
- package/src/mcp/fanout-with-retry.ts +56 -0
- package/src/mcp/get-contract.ts +24 -0
- package/src/mcp/get-inbox.ts +57 -0
- package/src/mcp/identity.ts +8 -0
- package/src/mcp/pending-contract-events.ts +36 -0
- package/src/mcp/poke-guard.ts +32 -0
- package/src/mcp/poke-retry.ts +159 -0
- package/src/mcp/poke.ts +190 -0
- package/src/mcp/pre-register-codex-pane.ts +65 -0
- package/src/mcp/register-agent.ts +84 -0
- package/src/mcp/register-codex-self.ts +276 -0
- package/src/mcp/register-contract.ts +60 -0
- package/src/mcp/send-message.ts +159 -0
- package/src/mcp/subscribe-channel-wake.ts +31 -0
- package/src/mcp/subscribe-contract.ts +24 -0
- package/src/mcp/task-add.ts +37 -0
- package/src/mcp/task-claim.ts +54 -0
- package/src/mcp/task-complete.ts +36 -0
- package/src/mcp/task-list.ts +33 -0
- package/src/mcp/tools.ts +1240 -0
- package/src/mcp/transport-dispatch.ts +171 -0
- package/src/mcp/transport.ts +204 -0
- package/src/mcp/unregister-self.ts +46 -0
- package/src/storage/agents-repo.ts +328 -0
- package/src/storage/db.ts +13 -0
- package/src/storage/events-outbox.ts +44 -0
- package/src/storage/schema.ts +180 -0
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { ChannelWakeFanout } from './channel-wake-fanout.js'
|
|
2
|
+
|
|
3
|
+
const META_KEY_RE = /^[A-Za-z0-9_]+$/
|
|
4
|
+
|
|
5
|
+
export interface ChannelWakeInput {
|
|
6
|
+
content: string
|
|
7
|
+
meta: Record<string, string>
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export type SendChannelWakeResult =
|
|
11
|
+
| { ok: true }
|
|
12
|
+
| { ok: false; reason: 'no_subscriber' }
|
|
13
|
+
|
|
14
|
+
function sanitizeMeta(meta: Record<string, string>): Record<string, string> {
|
|
15
|
+
const out: Record<string, string> = {}
|
|
16
|
+
for (const [k, v] of Object.entries(meta)) {
|
|
17
|
+
if (META_KEY_RE.test(k)) out[k] = v
|
|
18
|
+
}
|
|
19
|
+
return out
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function sendChannelWake(
|
|
23
|
+
fanout: ChannelWakeFanout,
|
|
24
|
+
channel_session_id: string,
|
|
25
|
+
input: ChannelWakeInput
|
|
26
|
+
): SendChannelWakeResult {
|
|
27
|
+
if (!fanout.has(channel_session_id)) return { ok: false, reason: 'no_subscriber' }
|
|
28
|
+
const payload = {
|
|
29
|
+
jsonrpc: '2.0' as const,
|
|
30
|
+
method: 'notifications/channel_wake' as const,
|
|
31
|
+
params: {
|
|
32
|
+
content: input.content,
|
|
33
|
+
meta: sanitizeMeta(input.meta)
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
fanout.send(channel_session_id, payload)
|
|
37
|
+
return { ok: true }
|
|
38
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type Database from 'better-sqlite3'
|
|
2
|
+
|
|
3
|
+
export interface CleanupOpts {
|
|
4
|
+
maxAgeDays?: number
|
|
5
|
+
onlineWindowMs?: number
|
|
6
|
+
now?: Date
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
// Online cursor floor per destination team: an agent in team T with recent last_seen_at
|
|
10
|
+
// advances team T's inbox cursor. Events older than 7 days that target team T can be
|
|
11
|
+
// dropped once T's min cursor has moved past their event_id.
|
|
12
|
+
const DELETE_AGED_EVENTS_SQL = `
|
|
13
|
+
WITH online_cursor AS (
|
|
14
|
+
SELECT team AS to_team, MIN(last_processed_event_id) AS min_cursor
|
|
15
|
+
FROM agents
|
|
16
|
+
WHERE last_seen_at >= :cutoffOnline
|
|
17
|
+
GROUP BY team
|
|
18
|
+
)
|
|
19
|
+
DELETE FROM events
|
|
20
|
+
WHERE created_at < :ageCutoff
|
|
21
|
+
AND (
|
|
22
|
+
events.to_team NOT IN (SELECT to_team FROM online_cursor)
|
|
23
|
+
OR events.event_id < (
|
|
24
|
+
SELECT min_cursor FROM online_cursor WHERE online_cursor.to_team = events.to_team
|
|
25
|
+
)
|
|
26
|
+
)
|
|
27
|
+
`
|
|
28
|
+
|
|
29
|
+
export function runCleanup(db: Database.Database, opts: CleanupOpts = {}): { deleted: number } {
|
|
30
|
+
const now = opts.now ?? new Date()
|
|
31
|
+
const maxAgeDays = opts.maxAgeDays ?? 7
|
|
32
|
+
const onlineWindowMs = opts.onlineWindowMs ?? 5 * 60 * 1000
|
|
33
|
+
const ageCutoff = new Date(now.getTime() - maxAgeDays * 86400 * 1000).toISOString()
|
|
34
|
+
const cutoffOnline = new Date(now.getTime() - onlineWindowMs).toISOString()
|
|
35
|
+
|
|
36
|
+
const info = db.prepare(DELETE_AGED_EVENTS_SQL).run({ ageCutoff, cutoffOnline })
|
|
37
|
+
return { deleted: Number(info.changes) }
|
|
38
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
const STORAGE_CODES = new Set(['SQLITE_FULL','SQLITE_BUSY','SQLITE_IOERR','SQLITE_LOCKED','SQLITE_READONLY'])
|
|
2
|
+
|
|
3
|
+
export function isStorageError(err: unknown): boolean {
|
|
4
|
+
if (!err || typeof err !== 'object') return false
|
|
5
|
+
const anyErr = err as { name?: string; code?: string }
|
|
6
|
+
if (anyErr.name === 'SqliteError') return true
|
|
7
|
+
if (anyErr.code && STORAGE_CODES.has(anyErr.code)) return true
|
|
8
|
+
return false
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export async function wrapStorage<T>(fn: () => Promise<T> | T): Promise<T | { error: 'storage_unavailable' }> {
|
|
12
|
+
try {
|
|
13
|
+
return await fn()
|
|
14
|
+
} catch (err) {
|
|
15
|
+
if (isStorageError(err)) return { error: 'storage_unavailable' }
|
|
16
|
+
throw err
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { dirname } from 'node:path'
|
|
3
|
+
|
|
4
|
+
export type AcquireResult =
|
|
5
|
+
| { ok: true }
|
|
6
|
+
| { ok: false; reason: 'already_running'; pid: number; port: number }
|
|
7
|
+
|
|
8
|
+
function isAlive(pid: number): boolean {
|
|
9
|
+
try { process.kill(pid, 0); return true } catch (e) {
|
|
10
|
+
const err = e as NodeJS.ErrnoException
|
|
11
|
+
// EPERM means the process exists but we lack permission to signal it; still alive.
|
|
12
|
+
if (err.code === 'EPERM') return true
|
|
13
|
+
return false
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function acquirePidFile(path: string, port: number): AcquireResult {
|
|
18
|
+
mkdirSync(dirname(path), { recursive: true })
|
|
19
|
+
if (existsSync(path)) {
|
|
20
|
+
try {
|
|
21
|
+
const prev = JSON.parse(readFileSync(path, 'utf8')) as { pid: number; port: number }
|
|
22
|
+
if (isAlive(prev.pid) && prev.pid !== process.pid) {
|
|
23
|
+
return { ok: false, reason: 'already_running', pid: prev.pid, port: prev.port }
|
|
24
|
+
}
|
|
25
|
+
} catch { /* corrupt file, overwrite */ }
|
|
26
|
+
}
|
|
27
|
+
writeFileSync(path, JSON.stringify({ pid: process.pid, port }))
|
|
28
|
+
return { ok: true }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function releasePidFile(path: string): void {
|
|
32
|
+
if (existsSync(path)) rmSync(path, { force: true })
|
|
33
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { createServer } from 'node:net'
|
|
2
|
+
|
|
3
|
+
function tryBind(port: number, host: string): Promise<boolean> {
|
|
4
|
+
return new Promise(resolve => {
|
|
5
|
+
const s = createServer()
|
|
6
|
+
s.once('error', () => resolve(false))
|
|
7
|
+
s.listen(port, host, () => s.close(() => resolve(true)))
|
|
8
|
+
})
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export async function selectPort(candidates: number[], host = '127.0.0.1'): Promise<number> {
|
|
12
|
+
for (const p of candidates) {
|
|
13
|
+
if (await tryBind(p, host)) return p
|
|
14
|
+
}
|
|
15
|
+
throw new Error(`ports ${candidates[0]}-${candidates[candidates.length - 1]} unavailable`)
|
|
16
|
+
}
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process'
|
|
2
|
+
import { promisify } from 'node:util'
|
|
3
|
+
import type { DetectAgentKind } from './tmux-pane-detect.js'
|
|
4
|
+
|
|
5
|
+
const TMUX_LIST_TIMEOUT_MS = 3_000
|
|
6
|
+
const PS_LIST_TIMEOUT_MS = 3_000
|
|
7
|
+
|
|
8
|
+
export interface BindRuntimeIdentityInput {
|
|
9
|
+
agent: DetectAgentKind
|
|
10
|
+
ui_pid?: number
|
|
11
|
+
ui_tty?: string
|
|
12
|
+
tmux_pane_id?: string
|
|
13
|
+
process_pattern?: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type BindRuntimeIdentityResult =
|
|
17
|
+
| {
|
|
18
|
+
ok: true
|
|
19
|
+
tmux_pane_id: string
|
|
20
|
+
verification_mode: 'verified_pid_tty_pane' | 'verified_tty_pane'
|
|
21
|
+
tty: string
|
|
22
|
+
ui_pid?: number
|
|
23
|
+
}
|
|
24
|
+
| { error: 'invalid_runtime_identity' }
|
|
25
|
+
| { error: 'invalid_process_pattern' }
|
|
26
|
+
| { error: 'invalid_ui_pid' }
|
|
27
|
+
| { error: 'pid_not_found' }
|
|
28
|
+
| { error: 'pid_has_no_tty' }
|
|
29
|
+
| { error: 'agent_process_mismatch' }
|
|
30
|
+
| { error: 'invalid_ui_tty' }
|
|
31
|
+
| { error: 'tmux_unavailable'; detail: string }
|
|
32
|
+
| { error: 'tmux_pane_not_found' }
|
|
33
|
+
| { error: 'pid_pane_tty_mismatch'; detail: { pid_tty: string; pane_tty: string } }
|
|
34
|
+
| { error: 'tty_maps_to_no_agent_process' }
|
|
35
|
+
| { error: 'ambiguous_tty_match'; candidates: Array<{ pane_id: string; tty: string }> }
|
|
36
|
+
|
|
37
|
+
export interface BindRuntimeIdentityDeps {
|
|
38
|
+
execFile?: typeof execFile
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface PaneRow {
|
|
42
|
+
pane_id: string
|
|
43
|
+
tty: string
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function normalizeTty(raw: string | undefined): string | undefined {
|
|
47
|
+
const value = raw?.trim()
|
|
48
|
+
if (!value) return undefined
|
|
49
|
+
const normalized = value.replace(/^\/dev\//, '')
|
|
50
|
+
if (!normalized || normalized === '?') return undefined
|
|
51
|
+
return normalized
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function commandPattern(args: BindRuntimeIdentityInput): RegExp | null {
|
|
55
|
+
if (args.agent === 'custom') {
|
|
56
|
+
const raw = args.process_pattern?.trim()
|
|
57
|
+
if (!raw) return null
|
|
58
|
+
return new RegExp(raw, 'i')
|
|
59
|
+
}
|
|
60
|
+
if (args.agent === 'codex') {
|
|
61
|
+
return /(^|[\s/])(codex|codex-aarch64-a)([\s]|$)/i
|
|
62
|
+
}
|
|
63
|
+
if (args.agent === 'claude-code') {
|
|
64
|
+
return /(^|[\s/])claude([\s]|$)/i
|
|
65
|
+
}
|
|
66
|
+
return /(^|[\s/])opencode([\s]|$)/i
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function listPanes(execLike: typeof execFile): Promise<PaneRow[]> {
|
|
70
|
+
const exec = promisify(execLike)
|
|
71
|
+
const { stdout } = await exec(
|
|
72
|
+
'tmux',
|
|
73
|
+
['list-panes', '-a', '-F', '#{pane_id}\t#{pane_tty}'],
|
|
74
|
+
{ timeout: TMUX_LIST_TIMEOUT_MS }
|
|
75
|
+
)
|
|
76
|
+
return stdout
|
|
77
|
+
.split('\n')
|
|
78
|
+
.map(line => line.trimEnd())
|
|
79
|
+
.filter(Boolean)
|
|
80
|
+
.map((line) => {
|
|
81
|
+
const [pane_id, pane_tty] = line.split('\t')
|
|
82
|
+
return {
|
|
83
|
+
pane_id,
|
|
84
|
+
tty: normalizeTty(pane_tty) ?? '',
|
|
85
|
+
}
|
|
86
|
+
})
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function readPidInfo(
|
|
90
|
+
execLike: typeof execFile,
|
|
91
|
+
pid: number
|
|
92
|
+
): Promise<{ found: boolean; tty?: string; command?: string }> {
|
|
93
|
+
const exec = promisify(execLike)
|
|
94
|
+
try {
|
|
95
|
+
const { stdout } = await exec(
|
|
96
|
+
'ps',
|
|
97
|
+
['-p', String(pid), '-o', 'tty=,command='],
|
|
98
|
+
{ timeout: PS_LIST_TIMEOUT_MS }
|
|
99
|
+
)
|
|
100
|
+
const line = stdout
|
|
101
|
+
.split('\n')
|
|
102
|
+
.map(value => value.trim())
|
|
103
|
+
.find(Boolean)
|
|
104
|
+
if (!line) return { found: false }
|
|
105
|
+
const match = line.match(/^(\S+)\s+(.*)$/)
|
|
106
|
+
if (!match) return { found: false }
|
|
107
|
+
return {
|
|
108
|
+
found: true,
|
|
109
|
+
tty: normalizeTty(match[1]),
|
|
110
|
+
command: match[2]?.trim(),
|
|
111
|
+
}
|
|
112
|
+
} catch {
|
|
113
|
+
return { found: false }
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function ttyProcesses(
|
|
118
|
+
execLike: typeof execFile,
|
|
119
|
+
tty: string
|
|
120
|
+
): Promise<string[]> {
|
|
121
|
+
const exec = promisify(execLike)
|
|
122
|
+
const { stdout } = await exec(
|
|
123
|
+
'ps',
|
|
124
|
+
['-t', tty, '-o', 'pid=,ppid=,stat=,command='],
|
|
125
|
+
{ timeout: PS_LIST_TIMEOUT_MS }
|
|
126
|
+
)
|
|
127
|
+
return stdout
|
|
128
|
+
.split('\n')
|
|
129
|
+
.map(line => line.trimEnd())
|
|
130
|
+
.filter(Boolean)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function matchAgentProcess(
|
|
134
|
+
agent: DetectAgentKind,
|
|
135
|
+
lines: string[],
|
|
136
|
+
pattern: RegExp
|
|
137
|
+
): boolean {
|
|
138
|
+
return lines.some((line) => {
|
|
139
|
+
if (isHelperProcess(agent, line)) return false
|
|
140
|
+
return pattern.test(line)
|
|
141
|
+
})
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function isHelperProcess(agent: DetectAgentKind, command: string): boolean {
|
|
145
|
+
if (agent !== 'codex') return false
|
|
146
|
+
return /codex\s+app-server/i.test(command) ||
|
|
147
|
+
/Codex Computer Use\.app/i.test(command) ||
|
|
148
|
+
/SkyComputerUseClient/i.test(command)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export async function bindRuntimeIdentity(
|
|
152
|
+
input: BindRuntimeIdentityInput,
|
|
153
|
+
deps: BindRuntimeIdentityDeps = {}
|
|
154
|
+
): Promise<BindRuntimeIdentityResult> {
|
|
155
|
+
const execLike = deps.execFile ?? execFile
|
|
156
|
+
const pattern = commandPattern(input)
|
|
157
|
+
if (!pattern) return { error: 'invalid_process_pattern' }
|
|
158
|
+
|
|
159
|
+
let panes: PaneRow[]
|
|
160
|
+
try {
|
|
161
|
+
panes = await listPanes(execLike)
|
|
162
|
+
} catch (error) {
|
|
163
|
+
return {
|
|
164
|
+
error: 'tmux_unavailable',
|
|
165
|
+
detail: error instanceof Error ? error.message : String(error),
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (input.ui_pid !== undefined) {
|
|
170
|
+
if (!Number.isInteger(input.ui_pid) || input.ui_pid <= 0) {
|
|
171
|
+
return { error: 'invalid_ui_pid' }
|
|
172
|
+
}
|
|
173
|
+
const pidInfo = await readPidInfo(execLike, input.ui_pid)
|
|
174
|
+
if (!pidInfo.found) return { error: 'pid_not_found' }
|
|
175
|
+
if (
|
|
176
|
+
!pidInfo.command ||
|
|
177
|
+
isHelperProcess(input.agent, pidInfo.command) ||
|
|
178
|
+
!pattern.test(pidInfo.command)
|
|
179
|
+
) {
|
|
180
|
+
return { error: 'agent_process_mismatch' }
|
|
181
|
+
}
|
|
182
|
+
if (!pidInfo.tty) return { error: 'pid_has_no_tty' }
|
|
183
|
+
const candidates = panes.filter(pane => pane.tty === pidInfo.tty)
|
|
184
|
+
if (candidates.length === 0) return { error: 'tmux_pane_not_found' }
|
|
185
|
+
if (candidates.length > 1) {
|
|
186
|
+
return {
|
|
187
|
+
error: 'ambiguous_tty_match',
|
|
188
|
+
candidates: candidates.map(candidate => ({
|
|
189
|
+
pane_id: candidate.pane_id,
|
|
190
|
+
tty: candidate.tty,
|
|
191
|
+
})),
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
const candidate = candidates[0]
|
|
195
|
+
const explicitPane = input.tmux_pane_id?.trim()
|
|
196
|
+
if (explicitPane && explicitPane !== candidate.pane_id) {
|
|
197
|
+
return {
|
|
198
|
+
error: 'pid_pane_tty_mismatch',
|
|
199
|
+
detail: {
|
|
200
|
+
pid_tty: pidInfo.tty,
|
|
201
|
+
pane_tty: candidate.tty,
|
|
202
|
+
},
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return {
|
|
206
|
+
ok: true,
|
|
207
|
+
tmux_pane_id: candidate.pane_id,
|
|
208
|
+
verification_mode: 'verified_pid_tty_pane',
|
|
209
|
+
tty: pidInfo.tty,
|
|
210
|
+
ui_pid: input.ui_pid,
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const tty = normalizeTty(input.ui_tty)
|
|
215
|
+
const paneId = input.tmux_pane_id?.trim()
|
|
216
|
+
if (!tty || !paneId) return { error: 'invalid_runtime_identity' }
|
|
217
|
+
const pane = panes.find(candidate => candidate.pane_id === paneId)
|
|
218
|
+
if (!pane) return { error: 'tmux_pane_not_found' }
|
|
219
|
+
if (pane.tty !== tty) {
|
|
220
|
+
return {
|
|
221
|
+
error: 'pid_pane_tty_mismatch',
|
|
222
|
+
detail: {
|
|
223
|
+
pid_tty: tty,
|
|
224
|
+
pane_tty: pane.tty,
|
|
225
|
+
},
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
const processes = await ttyProcesses(execLike, tty)
|
|
229
|
+
if (!matchAgentProcess(input.agent, processes, pattern)) {
|
|
230
|
+
return { error: 'tty_maps_to_no_agent_process' }
|
|
231
|
+
}
|
|
232
|
+
return {
|
|
233
|
+
ok: true,
|
|
234
|
+
tmux_pane_id: paneId,
|
|
235
|
+
verification_mode: 'verified_tty_pane',
|
|
236
|
+
tty,
|
|
237
|
+
}
|
|
238
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import Fastify, { type FastifyInstance } from 'fastify'
|
|
2
|
+
import { openDb } from '../storage/db.js'
|
|
3
|
+
import { applySchema } from '../storage/schema.js'
|
|
4
|
+
import { makeAuthHook } from './auth.js'
|
|
5
|
+
import { mountMcp } from '../mcp/transport.js'
|
|
6
|
+
import { runCleanup } from './cleanup.js'
|
|
7
|
+
import { SseFanout } from './sse-fanout.js'
|
|
8
|
+
import { ChannelWakeFanout } from './channel-wake-fanout.js'
|
|
9
|
+
import { clearAllRetries } from '../mcp/poke-retry.js'
|
|
10
|
+
|
|
11
|
+
export interface ServerOpts {
|
|
12
|
+
dbPath: string
|
|
13
|
+
token?: string
|
|
14
|
+
cleanupIntervalMs?: number
|
|
15
|
+
fanout?: SseFanout
|
|
16
|
+
channelWakeFanout?: ChannelWakeFanout
|
|
17
|
+
}
|
|
18
|
+
export interface StartOpts extends ServerOpts { port: number; host?: string }
|
|
19
|
+
|
|
20
|
+
const DEFAULT_KEEP_ALIVE_TIMEOUT_MS = 120_000
|
|
21
|
+
|
|
22
|
+
function parsePositiveInt(raw: string | undefined, fallback: number): number {
|
|
23
|
+
const n = Number(raw)
|
|
24
|
+
return Number.isInteger(n) && n > 0 ? n : fallback
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function buildServer(opts: ServerOpts): Promise<FastifyInstance> {
|
|
28
|
+
const keepAliveTimeout = parsePositiveInt(process.env.KEEP_ALIVE_TIMEOUT_MS, DEFAULT_KEEP_ALIVE_TIMEOUT_MS)
|
|
29
|
+
const app = Fastify({ logger: false, keepAliveTimeout })
|
|
30
|
+
app.server.headersTimeout = keepAliveTimeout + 1000
|
|
31
|
+
const db = openDb(opts.dbPath)
|
|
32
|
+
applySchema(db)
|
|
33
|
+
const startedAt = Date.now()
|
|
34
|
+
const version = '0.1.0'
|
|
35
|
+
const fanout = opts.fanout ?? new SseFanout()
|
|
36
|
+
const channelWakeFanout = opts.channelWakeFanout ?? new ChannelWakeFanout()
|
|
37
|
+
app.addHook('onRequest', makeAuthHook(opts.token))
|
|
38
|
+
app.get('/health', async () => ({ ok: true, version, uptime_seconds: Math.floor((Date.now() - startedAt) / 1000) }))
|
|
39
|
+
mountMcp(app, db, fanout, channelWakeFanout)
|
|
40
|
+
|
|
41
|
+
const cleanupIntervalMs = opts.cleanupIntervalMs
|
|
42
|
+
?? Number(process.env.CLEANUP_INTERVAL_MS ?? 60 * 60 * 1000)
|
|
43
|
+
const interval = setInterval(() => {
|
|
44
|
+
try { runCleanup(db) } catch { /* best-effort */ }
|
|
45
|
+
}, cleanupIntervalMs)
|
|
46
|
+
if (typeof interval.unref === 'function') interval.unref()
|
|
47
|
+
|
|
48
|
+
app.addHook('onClose', async () => {
|
|
49
|
+
clearInterval(interval)
|
|
50
|
+
clearAllRetries()
|
|
51
|
+
fanout.stopAll()
|
|
52
|
+
db.close()
|
|
53
|
+
})
|
|
54
|
+
return app
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function startServer(opts: StartOpts): Promise<{ app: FastifyInstance; port: number; host: string }> {
|
|
58
|
+
const app = await buildServer(opts)
|
|
59
|
+
const host = opts.host ?? '127.0.0.1'
|
|
60
|
+
await app.listen({ port: opts.port, host })
|
|
61
|
+
const addr = app.server.address()
|
|
62
|
+
const port = addr && typeof addr === 'object' ? addr.port : opts.port
|
|
63
|
+
return { app, port, host }
|
|
64
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { FastifyInstance } from 'fastify'
|
|
2
|
+
import { releasePidFile } from './pid.js'
|
|
3
|
+
|
|
4
|
+
export function wireShutdown(app: FastifyInstance, pidPath: string): void {
|
|
5
|
+
const handler = async (_signal: NodeJS.Signals) => {
|
|
6
|
+
try { await app.close() } catch { /* ignore */ }
|
|
7
|
+
releasePidFile(pidPath)
|
|
8
|
+
process.exit(0)
|
|
9
|
+
}
|
|
10
|
+
process.once('SIGTERM', handler)
|
|
11
|
+
process.once('SIGINT', handler)
|
|
12
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import type Database from 'better-sqlite3'
|
|
2
|
+
|
|
3
|
+
export interface SseSink {
|
|
4
|
+
send(msg: Record<string, unknown>): void
|
|
5
|
+
sendHeartbeat(): void
|
|
6
|
+
close(): void
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
interface Session { agent_id: string; team: string; sink: SseSink }
|
|
10
|
+
|
|
11
|
+
const DEFAULT_HEARTBEAT_INTERVAL_MS = 30_000
|
|
12
|
+
|
|
13
|
+
function resolveHeartbeatIntervalMs(opt?: number): number {
|
|
14
|
+
if (typeof opt === 'number' && opt > 0) return opt
|
|
15
|
+
const n = Number(process.env.HEARTBEAT_INTERVAL_MS)
|
|
16
|
+
return Number.isInteger(n) && n > 0 ? n : DEFAULT_HEARTBEAT_INTERVAL_MS
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export class SseFanout {
|
|
20
|
+
private sessions = new Map<string, Session>()
|
|
21
|
+
private heartbeatTimer: ReturnType<typeof setInterval> | undefined
|
|
22
|
+
private readonly heartbeatIntervalMs: number
|
|
23
|
+
|
|
24
|
+
constructor(opts: { heartbeatIntervalMs?: number } = {}) {
|
|
25
|
+
this.heartbeatIntervalMs = resolveHeartbeatIntervalMs(opts.heartbeatIntervalMs)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
attach(agent_id: string, team: string, sink: SseSink): void {
|
|
29
|
+
const prior = this.sessions.get(agent_id)
|
|
30
|
+
if (prior && prior.sink !== sink) {
|
|
31
|
+
try { prior.sink.close() } catch { /* ignore */ }
|
|
32
|
+
}
|
|
33
|
+
const wasEmpty = this.sessions.size === 0
|
|
34
|
+
this.sessions.set(agent_id, { agent_id, team, sink })
|
|
35
|
+
if (wasEmpty) this.startHeartbeat()
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
rebind(agent_id: string, team: string): void {
|
|
39
|
+
const s = this.sessions.get(agent_id)
|
|
40
|
+
if (!s) return
|
|
41
|
+
this.sessions.set(agent_id, { agent_id, team, sink: s.sink })
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
detach(agent_id: string): void {
|
|
45
|
+
const s = this.sessions.get(agent_id)
|
|
46
|
+
if (s) { try { s.sink.close() } catch { /* ignore */ } this.sessions.delete(agent_id) }
|
|
47
|
+
if (this.sessions.size === 0) this.stopHeartbeat()
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
stopAll(): void {
|
|
51
|
+
this.stopHeartbeat()
|
|
52
|
+
for (const s of this.sessions.values()) { try { s.sink.close() } catch { /* ignore */ } }
|
|
53
|
+
this.sessions.clear()
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
peek(): Array<{ agent_id: string; team: string }> {
|
|
57
|
+
return Array.from(this.sessions.values()).map(s => ({ agent_id: s.agent_id, team: s.team }))
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
emitContractEvent(
|
|
61
|
+
db: Database.Database,
|
|
62
|
+
args: { to_team: string; contract_name: string; version: number; event_id: number; diff: unknown | null }
|
|
63
|
+
): void {
|
|
64
|
+
const subs = db.prepare(
|
|
65
|
+
`SELECT agent_id FROM contract_subscriptions WHERE team=? AND contract_name=?`
|
|
66
|
+
).all(args.to_team, args.contract_name) as Array<{ agent_id: string }>
|
|
67
|
+
const subscribedSet = new Set(subs.map(s => s.agent_id))
|
|
68
|
+
for (const session of this.sessions.values()) {
|
|
69
|
+
if (session.team !== args.to_team) continue
|
|
70
|
+
if (!subscribedSet.has(session.agent_id)) continue
|
|
71
|
+
try {
|
|
72
|
+
session.sink.send({
|
|
73
|
+
type: 'contract_event',
|
|
74
|
+
event_id: args.event_id,
|
|
75
|
+
contract_name: args.contract_name,
|
|
76
|
+
version: args.version,
|
|
77
|
+
diff: args.diff
|
|
78
|
+
})
|
|
79
|
+
} catch { /* broken sink; swallow */ }
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
private startHeartbeat(): void {
|
|
84
|
+
if (this.heartbeatTimer) return
|
|
85
|
+
this.heartbeatTimer = setInterval(() => {
|
|
86
|
+
for (const s of this.sessions.values()) {
|
|
87
|
+
try { s.sink.sendHeartbeat() } catch { /* ignore */ }
|
|
88
|
+
}
|
|
89
|
+
}, this.heartbeatIntervalMs)
|
|
90
|
+
if (typeof this.heartbeatTimer.unref === 'function') this.heartbeatTimer.unref()
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
private stopHeartbeat(): void {
|
|
94
|
+
if (this.heartbeatTimer) { clearInterval(this.heartbeatTimer); this.heartbeatTimer = undefined }
|
|
95
|
+
}
|
|
96
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { execFile, spawn } from 'node:child_process'
|
|
2
|
+
import { promisify } from 'node:util'
|
|
3
|
+
|
|
4
|
+
const pExecFile = promisify(execFile)
|
|
5
|
+
|
|
6
|
+
let _isTmuxAvailable: boolean | null = null
|
|
7
|
+
|
|
8
|
+
export async function isTmuxAvailable(): Promise<boolean> {
|
|
9
|
+
if (_isTmuxAvailable !== null) return _isTmuxAvailable
|
|
10
|
+
try {
|
|
11
|
+
await pExecFile('tmux', ['-V'])
|
|
12
|
+
_isTmuxAvailable = true
|
|
13
|
+
} catch {
|
|
14
|
+
_isTmuxAvailable = false
|
|
15
|
+
}
|
|
16
|
+
return _isTmuxAvailable
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const TMUX_CAPTURE_TIMEOUT_MS = 5_000
|
|
20
|
+
|
|
21
|
+
export async function capturePaneTail(paneId: string, lines = 8): Promise<string> {
|
|
22
|
+
const { stdout } = await pExecFile(
|
|
23
|
+
'tmux',
|
|
24
|
+
['capture-pane', '-t', paneId, '-p', '-S', `-${lines}`],
|
|
25
|
+
{ timeout: TMUX_CAPTURE_TIMEOUT_MS }
|
|
26
|
+
)
|
|
27
|
+
return stdout
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function loadBuffer(bufferName: string, prompt: string): Promise<void> {
|
|
31
|
+
return new Promise((resolve, reject) => {
|
|
32
|
+
const child = spawn('tmux', ['load-buffer', '-b', bufferName, '-'])
|
|
33
|
+
let stderr = ''
|
|
34
|
+
child.on('error', reject)
|
|
35
|
+
if (child.stderr) {
|
|
36
|
+
child.stderr.on('data', (b: Buffer) => { stderr += b.toString('utf8') })
|
|
37
|
+
}
|
|
38
|
+
child.on('close', (code: number) => {
|
|
39
|
+
if (code === 0) resolve()
|
|
40
|
+
else reject(new Error(`load-buffer exit ${code}: ${stderr}`))
|
|
41
|
+
})
|
|
42
|
+
child.stdin.write(Buffer.from(prompt, 'utf8'))
|
|
43
|
+
child.stdin.end()
|
|
44
|
+
})
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function pasteBuffer(bufferName: string, paneId: string): Promise<void> {
|
|
48
|
+
await pExecFile('tmux', ['paste-buffer', '-b', bufferName, '-t', paneId, '-p', '-d'])
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function sendEnter(paneId: string): Promise<void> {
|
|
52
|
+
await pExecFile('tmux', ['send-keys', '-t', paneId, 'Enter'])
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function _resetTmuxAvailableCache(): void {
|
|
56
|
+
_isTmuxAvailable = null
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function _setTmuxAvailableForTest(value: boolean): void {
|
|
60
|
+
_isTmuxAvailable = value
|
|
61
|
+
}
|