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,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
|
+
}
|