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.
Files changed (73) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +296 -0
  3. package/README.zh-CN.md +306 -0
  4. package/dist/channel-cli.d.ts +18 -0
  5. package/dist/channel-cli.js +358 -0
  6. package/dist/channel-cli.js.map +1 -0
  7. package/dist/cli.d.ts +1 -0
  8. package/dist/cli.js +4585 -0
  9. package/dist/cli.js.map +1 -0
  10. package/package.json +62 -0
  11. package/src/channel/auto-daemon.ts +130 -0
  12. package/src/channel/daemon-client.ts +155 -0
  13. package/src/channel/proxy.ts +28 -0
  14. package/src/channel-cli.ts +122 -0
  15. package/src/cli.ts +136 -0
  16. package/src/daemon/auth.ts +17 -0
  17. package/src/daemon/channel-wake-fanout.ts +39 -0
  18. package/src/daemon/channel-wake-send.ts +38 -0
  19. package/src/daemon/cleanup.ts +38 -0
  20. package/src/daemon/errors.ts +18 -0
  21. package/src/daemon/pid.ts +33 -0
  22. package/src/daemon/port.ts +16 -0
  23. package/src/daemon/runtime-identity.ts +238 -0
  24. package/src/daemon/server.ts +64 -0
  25. package/src/daemon/shutdown.ts +12 -0
  26. package/src/daemon/sse-fanout.ts +96 -0
  27. package/src/daemon/tmux-cli.ts +61 -0
  28. package/src/daemon/tmux-pane-detect.ts +276 -0
  29. package/src/lib/client-kind.ts +1 -0
  30. package/src/lib/default-team.ts +18 -0
  31. package/src/lib/delivery-spec.ts +172 -0
  32. package/src/lib/schema-diff.ts +79 -0
  33. package/src/mcp/agent-public-row.ts +52 -0
  34. package/src/mcp/auto-bind-channel.ts +106 -0
  35. package/src/mcp/auto-bind-codex-pane.ts +170 -0
  36. package/src/mcp/auto-poke-fanout.ts +129 -0
  37. package/src/mcp/bind-channel.ts +39 -0
  38. package/src/mcp/bind-runtime-identity.ts +43 -0
  39. package/src/mcp/broadcast-to-role.ts +127 -0
  40. package/src/mcp/broadcast.ts +115 -0
  41. package/src/mcp/codex-appserver-dispatch.ts +169 -0
  42. package/src/mcp/codex-appserver-rpc.ts +227 -0
  43. package/src/mcp/codex-pane-pre-register-repo.ts +57 -0
  44. package/src/mcp/delivery-status.ts +114 -0
  45. package/src/mcp/diff-contracts.ts +25 -0
  46. package/src/mcp/echo.ts +8 -0
  47. package/src/mcp/fanout-with-retry.ts +56 -0
  48. package/src/mcp/get-contract.ts +24 -0
  49. package/src/mcp/get-inbox.ts +57 -0
  50. package/src/mcp/identity.ts +8 -0
  51. package/src/mcp/pending-contract-events.ts +36 -0
  52. package/src/mcp/poke-guard.ts +32 -0
  53. package/src/mcp/poke-retry.ts +159 -0
  54. package/src/mcp/poke.ts +190 -0
  55. package/src/mcp/pre-register-codex-pane.ts +65 -0
  56. package/src/mcp/register-agent.ts +84 -0
  57. package/src/mcp/register-codex-self.ts +276 -0
  58. package/src/mcp/register-contract.ts +60 -0
  59. package/src/mcp/send-message.ts +159 -0
  60. package/src/mcp/subscribe-channel-wake.ts +31 -0
  61. package/src/mcp/subscribe-contract.ts +24 -0
  62. package/src/mcp/task-add.ts +37 -0
  63. package/src/mcp/task-claim.ts +54 -0
  64. package/src/mcp/task-complete.ts +36 -0
  65. package/src/mcp/task-list.ts +33 -0
  66. package/src/mcp/tools.ts +1240 -0
  67. package/src/mcp/transport-dispatch.ts +171 -0
  68. package/src/mcp/transport.ts +204 -0
  69. package/src/mcp/unregister-self.ts +46 -0
  70. package/src/storage/agents-repo.ts +328 -0
  71. package/src/storage/db.ts +13 -0
  72. package/src/storage/events-outbox.ts +44 -0
  73. 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
+ }