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,170 @@
1
+ import { execFile } from 'node:child_process'
2
+ import { promisify } from 'node:util'
3
+ import type { CodexPanePreRegRepo, CodexPanePreRegRow } from './codex-pane-pre-register-repo.js'
4
+ import type { BindRuntimeIdentityService } from './bind-runtime-identity.js'
5
+
6
+ const TMUX_LIST_TIMEOUT_MS = 3_000
7
+ const PS_LIST_TIMEOUT_MS = 3_000
8
+
9
+ export interface PaneTtyEntry {
10
+ pane_id: string
11
+ tty: string
12
+ }
13
+
14
+ export interface AutoBindCodexPaneDeps {
15
+ listPanes?: () => Promise<PaneTtyEntry[]>
16
+ ttyProcesses?: (tty: string) => Promise<string[]>
17
+ now?: () => Date
18
+ }
19
+
20
+ export interface AutoBindCodexPaneInput {
21
+ callerAgentId: string
22
+ repo: CodexPanePreRegRepo
23
+ bindRuntimeIdentitySvc: BindRuntimeIdentityService
24
+ }
25
+
26
+ function normalizeTty(raw: string | undefined): string | undefined {
27
+ const value = raw?.trim()
28
+ if (!value) return undefined
29
+ const normalized = value.replace(/^\/dev\//, '')
30
+ if (!normalized || normalized === '?') return undefined
31
+ return normalized
32
+ }
33
+
34
+ async function defaultListPanes(): Promise<PaneTtyEntry[]> {
35
+ const exec = promisify(execFile)
36
+ const { stdout } = await exec(
37
+ 'tmux',
38
+ ['list-panes', '-a', '-F', '#{pane_id}\t#{pane_tty}'],
39
+ { timeout: TMUX_LIST_TIMEOUT_MS }
40
+ )
41
+ return stdout
42
+ .split('\n')
43
+ .map(line => line.trimEnd())
44
+ .filter(Boolean)
45
+ .map(line => {
46
+ const [pane_id, pane_tty] = line.split('\t')
47
+ return {
48
+ pane_id,
49
+ tty: normalizeTty(pane_tty) ?? '',
50
+ }
51
+ })
52
+ }
53
+
54
+ async function defaultTtyProcesses(tty: string): Promise<string[]> {
55
+ const exec = promisify(execFile)
56
+ const { stdout } = await exec(
57
+ 'ps',
58
+ ['-t', tty, '-o', 'pid=,ppid=,stat=,command='],
59
+ { timeout: PS_LIST_TIMEOUT_MS }
60
+ )
61
+ return stdout
62
+ .split('\n')
63
+ .map(line => line.trimEnd())
64
+ .filter(Boolean)
65
+ }
66
+
67
+ function parsePid(line: string): number | undefined {
68
+ const match = line.trim().match(/^(\d+)\s/)
69
+ if (!match) return undefined
70
+ const pid = Number(match[1])
71
+ if (!Number.isInteger(pid) || pid <= 0) return undefined
72
+ return pid
73
+ }
74
+
75
+ function isCodexRemoteProcess(line: string): boolean {
76
+ if (!/codex/i.test(line)) return false
77
+ if (/codex\s+app-server/i.test(line)) return false
78
+ return /codex(?:-aarch64-a)?\s+.*--remote/i.test(line) || /codex(?:-aarch64-a)?\s+--remote/i.test(line)
79
+ }
80
+
81
+ function argvContainsUuid(line: string, uuid: string): boolean {
82
+ return line.includes(`xats.agent_id="${uuid}"`)
83
+ }
84
+
85
+ interface Candidate {
86
+ row: CodexPanePreRegRow
87
+ pane_id: string
88
+ ui_pid: number
89
+ }
90
+
91
+ /**
92
+ * Scan pending pre-regs, look up tmux panes and their processes, and bind
93
+ * the caller agent row when exactly one pre-reg maps to a live codex --remote
94
+ * process whose argv contains the stored UUID.
95
+ *
96
+ * Returns true only when bind + consume succeeded. Any error path returns
97
+ * false without propagating.
98
+ */
99
+ // Test hook: allows integration tests to override the tmux/ps probes that
100
+ // would otherwise need a real tmux session. Production paths pass `deps`
101
+ // explicitly; when they do not, we fall through to these overrides, then to
102
+ // the real child_process-backed defaults.
103
+ export const __testOverrides: AutoBindCodexPaneDeps = {}
104
+
105
+ export async function autoBindCodexPane(
106
+ input: AutoBindCodexPaneInput,
107
+ deps: AutoBindCodexPaneDeps = {}
108
+ ): Promise<boolean> {
109
+ const listPanes = deps.listPanes ?? __testOverrides.listPanes ?? defaultListPanes
110
+ const ttyProcesses = deps.ttyProcesses ?? __testOverrides.ttyProcesses ?? defaultTtyProcesses
111
+ const now = deps.now ?? __testOverrides.now ?? (() => new Date())
112
+
113
+ try {
114
+ const nowIso = now().toISOString()
115
+ input.repo.deleteExpired(nowIso)
116
+ const pending = input.repo.listUnexpired(nowIso)
117
+ if (pending.length === 0) return false
118
+
119
+ let panes: PaneTtyEntry[]
120
+ try {
121
+ panes = await listPanes()
122
+ } catch {
123
+ return false
124
+ }
125
+
126
+ const paneIndex = new Map<string, PaneTtyEntry>()
127
+ for (const pane of panes) {
128
+ if (pane.pane_id) paneIndex.set(pane.pane_id, pane)
129
+ }
130
+
131
+ const ttyProcessCache = new Map<string, string[]>()
132
+ const candidates: Candidate[] = []
133
+
134
+ for (const row of pending) {
135
+ const pane = paneIndex.get(row.pane_id)
136
+ if (!pane || !pane.tty) continue
137
+ let procs = ttyProcessCache.get(pane.tty)
138
+ if (procs === undefined) {
139
+ try {
140
+ procs = await ttyProcesses(pane.tty)
141
+ } catch {
142
+ procs = []
143
+ }
144
+ ttyProcessCache.set(pane.tty, procs)
145
+ }
146
+ const matching = procs.filter(line =>
147
+ isCodexRemoteProcess(line) && argvContainsUuid(line, row.xats_agent_id)
148
+ )
149
+ if (matching.length !== 1) continue
150
+ const pid = parsePid(matching[0])
151
+ if (pid === undefined) continue
152
+ candidates.push({ row, pane_id: pane.pane_id, ui_pid: pid })
153
+ }
154
+
155
+ if (candidates.length !== 1) return false
156
+
157
+ const chosen = candidates[0]
158
+ const bindResult = await input.bindRuntimeIdentitySvc.bind({
159
+ callerAgentId: input.callerAgentId,
160
+ agent: 'codex',
161
+ ui_pid: chosen.ui_pid,
162
+ })
163
+ if (!('ok' in bindResult) || !bindResult.ok) return false
164
+
165
+ input.repo.takeByPaneId(chosen.pane_id)
166
+ return true
167
+ } catch {
168
+ return false
169
+ }
170
+ }
@@ -0,0 +1,129 @@
1
+ import { runQuietGuard } from './poke-guard.js'
2
+ import { isTmuxAvailable } from '../daemon/tmux-cli.js'
3
+ import { scheduleRetry as defaultScheduleRetry, type RetryAgentLookup, type RetryContext } from './poke-retry.js'
4
+ import type { DeliverySpec } from '../lib/delivery-spec.js'
5
+
6
+ export type AutoPokeSkipReason = 'no_pane' | 'guard_failed' | 'tmux_unavailable' | 'self'
7
+
8
+ export interface AutoPokeArgs {
9
+ team: string
10
+ fromAgentId: string
11
+ targetAgentId: string
12
+ paneId: string | null
13
+ body: string
14
+ }
15
+
16
+ export type AutoPokeFn = (args: AutoPokeArgs) => Promise<{ ok: true } | { ok: false; reason?: AutoPokeSkipReason }>
17
+
18
+ export interface AutoPokeRecipient {
19
+ agent_id: string
20
+ tmux_pane_id: string | null
21
+ delivery?: DeliverySpec
22
+ }
23
+
24
+ export interface FanoutDeps {
25
+ poke?: AutoPokeFn
26
+ tmuxAvailable?: () => Promise<boolean>
27
+ }
28
+
29
+ export interface RetryScheduleCtx {
30
+ messageId: string
31
+ sentAt: string
32
+ lookupAgentFn: (agentId: string) => RetryAgentLookup | undefined
33
+ scheduleRetryFn?: (ctx: RetryContext) => void
34
+ updateStatusFn?: RetryContext['updateStatusFn']
35
+ }
36
+
37
+ export interface FanoutResult {
38
+ poked: boolean
39
+ skipReasons: Array<{ agent_id: string; reason: AutoPokeSkipReason }>
40
+ deliveredAgentIds: string[]
41
+ retryScheduledCount: number
42
+ }
43
+
44
+ function hasNonTmuxTransport(recipient: AutoPokeRecipient): boolean {
45
+ return recipient.delivery !== undefined && recipient.delivery.kind !== 'none'
46
+ }
47
+
48
+ // Recipients are supplied by the caller; no team filter is applied here, so cross-team fan-out works transparently.
49
+ export async function fanoutAutoPoke(args: {
50
+ team: string
51
+ fromAgentId: string
52
+ recipients: AutoPokeRecipient[]
53
+ body: string
54
+ deps: FanoutDeps
55
+ retry?: RetryScheduleCtx
56
+ }): Promise<FanoutResult> {
57
+ const pokeFn = args.deps.poke
58
+ const tmuxAvail = args.deps.tmuxAvailable ?? isTmuxAvailable
59
+
60
+ const results = await Promise.all(args.recipients.map(async (r) => {
61
+ try {
62
+ const nonTmuxTransport = hasNonTmuxTransport(r)
63
+ if (r.agent_id === args.fromAgentId) {
64
+ return { agent_id: r.agent_id, poked: false, reason: 'self' as AutoPokeSkipReason, paneId: null as string | null }
65
+ }
66
+ if (!nonTmuxTransport && !r.tmux_pane_id) {
67
+ return { agent_id: r.agent_id, poked: false, reason: 'no_pane' as AutoPokeSkipReason, paneId: null }
68
+ }
69
+ if (!nonTmuxTransport && !(await tmuxAvail())) {
70
+ return { agent_id: r.agent_id, poked: false, reason: 'tmux_unavailable' as AutoPokeSkipReason, paneId: r.tmux_pane_id }
71
+ }
72
+ if (!pokeFn) {
73
+ return { agent_id: r.agent_id, poked: false, reason: 'tmux_unavailable' as AutoPokeSkipReason, paneId: r.tmux_pane_id }
74
+ }
75
+ if (!nonTmuxTransport) {
76
+ const guard = await runQuietGuard(r.tmux_pane_id!)
77
+ if (guard === 'fail') {
78
+ return { agent_id: r.agent_id, poked: false, reason: 'guard_failed' as AutoPokeSkipReason, paneId: r.tmux_pane_id }
79
+ }
80
+ }
81
+ const out = await pokeFn({
82
+ team: args.team,
83
+ fromAgentId: args.fromAgentId,
84
+ targetAgentId: r.agent_id,
85
+ paneId: r.tmux_pane_id,
86
+ body: args.body
87
+ })
88
+ if (out.ok) return { agent_id: r.agent_id, poked: true, reason: undefined, paneId: r.tmux_pane_id }
89
+ return {
90
+ agent_id: r.agent_id,
91
+ poked: false,
92
+ reason: (out.reason ?? 'guard_failed') as AutoPokeSkipReason,
93
+ paneId: r.tmux_pane_id
94
+ }
95
+ } catch {
96
+ return { agent_id: r.agent_id, poked: false, reason: 'guard_failed' as AutoPokeSkipReason, paneId: r.tmux_pane_id }
97
+ }
98
+ }))
99
+
100
+ let retryScheduledCount = 0
101
+ if (args.retry && pokeFn) {
102
+ const scheduleFn = args.retry.scheduleRetryFn ?? defaultScheduleRetry
103
+ for (const res of results) {
104
+ if (!res.poked && res.reason === 'guard_failed' && res.paneId) {
105
+ scheduleFn({
106
+ agentId: res.agent_id,
107
+ messageId: args.retry.messageId,
108
+ fromAgentId: args.fromAgentId,
109
+ body: args.body,
110
+ team: args.team,
111
+ sentAt: args.retry.sentAt,
112
+ paneId: res.paneId,
113
+ paneGuardFn: runQuietGuard,
114
+ pokeFn: async (pokeArgs) => { await pokeFn(pokeArgs) },
115
+ lookupAgentFn: args.retry.lookupAgentFn,
116
+ updateStatusFn: args.retry.updateStatusFn
117
+ })
118
+ retryScheduledCount += 1
119
+ }
120
+ }
121
+ }
122
+
123
+ const poked = results.some(x => x.poked)
124
+ const skipReasons = results
125
+ .filter(x => !x.poked && x.reason !== undefined)
126
+ .map(x => ({ agent_id: x.agent_id, reason: x.reason as AutoPokeSkipReason }))
127
+ const deliveredAgentIds = results.filter(x => x.poked).map(x => x.agent_id)
128
+ return { poked, skipReasons, deliveredAgentIds, retryScheduledCount }
129
+ }
@@ -0,0 +1,39 @@
1
+ import type Database from 'better-sqlite3'
2
+ import type { ChannelWakeFanout } from '../daemon/channel-wake-fanout.js'
3
+ import { AgentsRepo } from '../storage/agents-repo.js'
4
+ import { CHANNEL_PROXY_ROLE } from './subscribe-channel-wake.js'
5
+
6
+ export interface BindInput {
7
+ callerAgentId: string
8
+ channel_session_id: string
9
+ }
10
+
11
+ export type BindResult =
12
+ | { ok: true }
13
+ | { error: 'unknown_agent' | 'forbidden_role' | 'invalid_channel_session_id' | 'unknown_channel_session' }
14
+
15
+ export class BindChannelService {
16
+ private readonly repo: AgentsRepo
17
+
18
+ constructor(
19
+ db: Database.Database,
20
+ private readonly fanout: ChannelWakeFanout
21
+ ) {
22
+ this.repo = new AgentsRepo(db)
23
+ }
24
+
25
+ bind(input: BindInput): BindResult {
26
+ const csid = input.channel_session_id?.trim()
27
+ if (!csid) return { error: 'invalid_channel_session_id' }
28
+ const caller = this.repo.getById(input.callerAgentId)
29
+ if (!caller) return { error: 'unknown_agent' }
30
+ if (caller.role === CHANNEL_PROXY_ROLE) return { error: 'forbidden_role' }
31
+ if (!this.fanout.has(csid)) return { error: 'unknown_channel_session' }
32
+ this.repo.setClient(input.callerAgentId, 'claude-code')
33
+ this.repo.setDelivery(input.callerAgentId, {
34
+ kind: 'claude-channel',
35
+ channel_session_id: csid,
36
+ })
37
+ return { ok: true }
38
+ }
39
+ }
@@ -0,0 +1,43 @@
1
+ import type Database from 'better-sqlite3'
2
+ import {
3
+ bindRuntimeIdentity,
4
+ type BindRuntimeIdentityInput,
5
+ type BindRuntimeIdentityResult,
6
+ } from '../daemon/runtime-identity.js'
7
+ import { AgentsRepo } from '../storage/agents-repo.js'
8
+
9
+ export interface BindRuntimeIdentityServiceInput extends BindRuntimeIdentityInput {
10
+ callerAgentId: string
11
+ }
12
+
13
+ export type BindRuntimeIdentityServiceResult =
14
+ | ({ ok: true } & Omit<Extract<BindRuntimeIdentityResult, { ok: true }>, 'ok'>)
15
+ | Extract<BindRuntimeIdentityResult, { error: string }>
16
+ | { error: 'unknown_agent' }
17
+
18
+ export class BindRuntimeIdentityService {
19
+ private readonly repo: AgentsRepo
20
+
21
+ constructor(db: Database.Database) {
22
+ this.repo = new AgentsRepo(db)
23
+ }
24
+
25
+ async bind(
26
+ input: BindRuntimeIdentityServiceInput
27
+ ): Promise<BindRuntimeIdentityServiceResult> {
28
+ const caller = this.repo.getById(input.callerAgentId)
29
+ if (!caller) return { error: 'unknown_agent' }
30
+
31
+ const result = await bindRuntimeIdentity(input)
32
+ if (!('ok' in result) || !result.ok) return result
33
+
34
+ this.repo.setRuntimeBinding(input.callerAgentId, {
35
+ tmux_pane_id: result.tmux_pane_id,
36
+ runtime_ui_pid: result.ui_pid ?? null,
37
+ runtime_tty: result.tty,
38
+ runtime_verification_mode: result.verification_mode,
39
+ })
40
+
41
+ return result
42
+ }
43
+ }
@@ -0,0 +1,127 @@
1
+ import type Database from 'better-sqlite3'
2
+ import { randomUUID } from 'node:crypto'
3
+ import { ONLINE_MS, type AgentsRepo } from '../storage/agents-repo.js'
4
+ import type { EventsOutbox } from '../storage/events-outbox.js'
5
+ import type { FanoutDeps, AutoPokeSkipReason } from './auto-poke-fanout.js'
6
+ import { runFanoutWithRetry } from './fanout-with-retry.js'
7
+ import { parseDeliveryRow } from '../lib/delivery-spec.js'
8
+ import { recordInitialDeliveryStatuses } from './delivery-status.js'
9
+
10
+ export type BroadcastToRoleDeps = FanoutDeps
11
+
12
+ export interface BroadcastToRoleInput {
13
+ from: string
14
+ to_role: string
15
+ body: string
16
+ subject?: string
17
+ auto_poke?: boolean
18
+ }
19
+
20
+ interface SuccessResult {
21
+ message_id: string
22
+ event_id: number
23
+ recipients: string[]
24
+ poked: boolean
25
+ poke_skip_reasons?: Array<{ agent_id: string; reason: AutoPokeSkipReason }>
26
+ retry_scheduled: boolean
27
+ retry_delays_s?: number[]
28
+ }
29
+
30
+ export type BroadcastToRoleResult = SuccessResult | { error: 'unknown_recipient' }
31
+
32
+ export class BroadcastToRoleService {
33
+ constructor(
34
+ private db: Database.Database,
35
+ private agents: AgentsRepo,
36
+ private events: EventsOutbox,
37
+ private deps: BroadcastToRoleDeps = {}
38
+ ) {}
39
+
40
+ async broadcast(input: BroadcastToRoleInput): Promise<BroadcastToRoleResult> {
41
+ const fromRow = this.agents.findById(input.from)
42
+ if (!fromRow) return { error: 'unknown_recipient' }
43
+ const cutoffIso = new Date(Date.now() - ONLINE_MS).toISOString()
44
+ const rawRows = this.db.prepare(
45
+ `SELECT
46
+ agent_id,
47
+ tmux_pane_id,
48
+ delivery_kind,
49
+ delivery_payload
50
+ FROM agents
51
+ WHERE team=? AND role=? AND agent_id != ? AND last_seen_at > ?`
52
+ ).all(fromRow.team, input.to_role, input.from, cutoffIso) as Array<{
53
+ agent_id: string
54
+ tmux_pane_id: string | null
55
+ delivery_kind: string
56
+ delivery_payload: string | null
57
+ }>
58
+ const rows = rawRows.map((row) => ({
59
+ agent_id: row.agent_id,
60
+ tmux_pane_id: row.tmux_pane_id,
61
+ delivery: parseDeliveryRow(row),
62
+ }))
63
+ if (rows.length === 0) return { error: 'unknown_recipient' }
64
+
65
+ const recipients = rows.map(r => r.agent_id)
66
+ const baseId = randomUUID()
67
+ const inserted = this.insert(fromRow.team, input, recipients, baseId)
68
+
69
+ if (input.auto_poke === false) {
70
+ recordInitialDeliveryStatuses(this.db, {
71
+ messageId: inserted.message_id,
72
+ recipients,
73
+ delivered: new Set(),
74
+ skipped: [],
75
+ autoPokeDisabled: true,
76
+ })
77
+ return {
78
+ message_id: inserted.message_id,
79
+ event_id: inserted.event_id,
80
+ recipients,
81
+ poked: false,
82
+ retry_scheduled: false
83
+ }
84
+ }
85
+
86
+ const envelope = await runFanoutWithRetry({
87
+ db: this.db,
88
+ team: fromRow.team,
89
+ fromAgentId: input.from,
90
+ recipients: rows,
91
+ body: input.body,
92
+ deps: this.deps,
93
+ messageId: inserted.message_id,
94
+ sentAt: inserted.sent_at
95
+ })
96
+ return {
97
+ message_id: inserted.message_id,
98
+ event_id: inserted.event_id,
99
+ recipients,
100
+ ...envelope
101
+ }
102
+ }
103
+
104
+ private insert(team: string, input: BroadcastToRoleInput, recipients: string[], baseId: string):
105
+ { message_id: string; event_id: number; sent_at: string } {
106
+ const tx = this.db.transaction(() => {
107
+ const event_id = this.events.append({
108
+ from_team: team,
109
+ to_team: team,
110
+ event_type: 'message_sent',
111
+ actor_agent_id: input.from,
112
+ payload: { to_role: input.to_role, recipients, subject: input.subject ?? null }
113
+ })
114
+ const sent_at = new Date().toISOString()
115
+ const stmt = this.db.prepare(
116
+ `INSERT INTO messages (id, event_id, from_team, to_team, from_agent_id, to_agent_id, to_role, subject, body, need_reply, sent_at)
117
+ VALUES (?,?,?,?,?,?,?,?,?,?,?)`
118
+ )
119
+ for (let i = 0; i < recipients.length; i++) {
120
+ const id = i === 0 ? baseId : `${baseId}-${i}`
121
+ stmt.run(id, event_id, team, team, input.from, recipients[i], input.to_role, input.subject ?? null, input.body, 0, sent_at)
122
+ }
123
+ return { message_id: baseId, event_id, sent_at }
124
+ })
125
+ return tx()
126
+ }
127
+ }
@@ -0,0 +1,115 @@
1
+ import type Database from 'better-sqlite3'
2
+ import { randomUUID } from 'node:crypto'
3
+ import { ONLINE_MS, type AgentsRepo } from '../storage/agents-repo.js'
4
+ import type { AutoPokeSkipReason, FanoutDeps } from './auto-poke-fanout.js'
5
+ import { runFanoutWithRetry } from './fanout-with-retry.js'
6
+ import { parseDeliveryRow } from '../lib/delivery-spec.js'
7
+ import { recordInitialDeliveryStatuses } from './delivery-status.js'
8
+
9
+ export type BroadcastDeps = FanoutDeps
10
+
11
+ export interface BroadcastInput {
12
+ from: string
13
+ body: string
14
+ subject?: string
15
+ auto_poke?: boolean
16
+ }
17
+
18
+ interface SuccessResult {
19
+ message_id: string
20
+ event_id: number
21
+ recipients: string[]
22
+ poked: boolean
23
+ poke_skip_reasons?: Array<{ agent_id: string; reason: AutoPokeSkipReason }>
24
+ retry_scheduled: boolean
25
+ retry_delays_s?: number[]
26
+ }
27
+
28
+ export type BroadcastResult = SuccessResult | { error: 'unknown_recipient' }
29
+
30
+ export class BroadcastService {
31
+ constructor(
32
+ private db: Database.Database,
33
+ private agents: AgentsRepo,
34
+ private deps: BroadcastDeps = {}
35
+ ) {}
36
+
37
+ async broadcast(input: BroadcastInput): Promise<BroadcastResult> {
38
+ const fromRow = this.agents.findById(input.from)
39
+ if (!fromRow) return { error: 'unknown_recipient' }
40
+ const cutoffIso = new Date(Date.now() - ONLINE_MS).toISOString()
41
+ const rawRows = this.db.prepare(
42
+ `SELECT
43
+ agent_id,
44
+ tmux_pane_id,
45
+ delivery_kind,
46
+ delivery_payload
47
+ FROM agents
48
+ WHERE team=? AND agent_id != ? AND last_seen_at > ?`
49
+ ).all(fromRow.team, input.from, cutoffIso) as Array<{
50
+ agent_id: string
51
+ tmux_pane_id: string | null
52
+ delivery_kind: string
53
+ delivery_payload: string | null
54
+ }>
55
+ const rows = rawRows.map((row) => ({
56
+ agent_id: row.agent_id,
57
+ tmux_pane_id: row.tmux_pane_id,
58
+ delivery: parseDeliveryRow(row),
59
+ }))
60
+ if (rows.length === 0) return { error: 'unknown_recipient' }
61
+ const recipients = rows.map(r => r.agent_id)
62
+ const baseId = randomUUID()
63
+ const inserted = this.insertBroadcast(fromRow.team, input.from, recipients, input.body, input.subject, baseId)
64
+
65
+ if (input.auto_poke === false) {
66
+ recordInitialDeliveryStatuses(this.db, {
67
+ messageId: inserted.message_id,
68
+ recipients,
69
+ delivered: new Set(),
70
+ skipped: [],
71
+ autoPokeDisabled: true,
72
+ })
73
+ return { ...inserted, recipients, poked: false, retry_scheduled: false }
74
+ }
75
+
76
+ const envelope = await runFanoutWithRetry({
77
+ db: this.db,
78
+ team: fromRow.team,
79
+ fromAgentId: input.from,
80
+ recipients: rows,
81
+ body: input.body,
82
+ deps: this.deps,
83
+ messageId: inserted.message_id,
84
+ sentAt: inserted.sent_at
85
+ })
86
+ return {
87
+ message_id: inserted.message_id,
88
+ event_id: inserted.event_id,
89
+ recipients,
90
+ ...envelope
91
+ }
92
+ }
93
+
94
+ private insertBroadcast(team: string, from: string, recipients: string[], body: string,
95
+ subject: string | undefined, baseId: string): { message_id: string; event_id: number; sent_at: string } {
96
+ const tx = this.db.transaction(() => {
97
+ const event_id = Number(this.db.prepare(
98
+ `INSERT INTO events (from_team, to_team, event_type, actor_agent_id, payload, created_at) VALUES (?,?,?,?,?,?)`
99
+ ).run(team, team, 'message_sent', from,
100
+ JSON.stringify({ to_role: '*broadcast*', recipients, subject: subject ?? null }),
101
+ new Date().toISOString()).lastInsertRowid)
102
+ const sent_at = new Date().toISOString()
103
+ const insert = this.db.prepare(
104
+ `INSERT INTO messages (id, event_id, from_team, to_team, from_agent_id, to_agent_id, to_role, subject, body, need_reply, sent_at)
105
+ VALUES (?,?,?,?,?,?,?,?,?,?,?)`
106
+ )
107
+ for (let i = 0; i < recipients.length; i++) {
108
+ const id = i === 0 ? baseId : `${baseId}-${i}`
109
+ insert.run(id, event_id, team, team, from, recipients[i], '*broadcast*', subject ?? null, body, 0, sent_at)
110
+ }
111
+ return { message_id: baseId, event_id, sent_at }
112
+ })
113
+ return tx()
114
+ }
115
+ }