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,169 @@
1
+ import type { DeliveryCodexAppserver } from '../lib/delivery-spec.js'
2
+ import {
3
+ JsonRpcSocketClient,
4
+ defaultWebSocketFactory,
5
+ describeError,
6
+ resolveAuthToken,
7
+ safeClose,
8
+ type CodexWebSocketFactory,
9
+ type JsonRpcResponse,
10
+ type WebSocketLike,
11
+ } from './codex-appserver-rpc.js'
12
+ export type { WebSocketLike } from './codex-appserver-rpc.js'
13
+
14
+ type Json = unknown
15
+
16
+ export interface CodexAppserverDispatchDeps {
17
+ env?: NodeJS.ProcessEnv
18
+ webSocketFactory?: CodexWebSocketFactory
19
+ }
20
+
21
+ export type CodexAppserverDispatchResult =
22
+ | {
23
+ ok: true
24
+ transport_used: 'codex-appserver'
25
+ thread_id: string
26
+ }
27
+ | {
28
+ error:
29
+ | 'missing_auth_token'
30
+ | 'codex_connect_failed'
31
+ | 'codex_initialize_failed'
32
+ | 'codex_resume_failed'
33
+ | 'codex_turn_start_failed'
34
+ detail?: unknown
35
+ transport_used?: 'codex-appserver'
36
+ }
37
+
38
+ async function requestStep(
39
+ client: JsonRpcSocketClient,
40
+ method: string,
41
+ params: Json
42
+ ): Promise<
43
+ | { ok: JsonRpcResponse }
44
+ | {
45
+ error:
46
+ | 'codex_initialize_failed'
47
+ | 'codex_resume_failed'
48
+ | 'codex_turn_start_failed'
49
+ detail: unknown
50
+ }
51
+ > {
52
+ try {
53
+ const response = await client.request(method, params)
54
+ if (response.error) {
55
+ const mappedError =
56
+ method === 'initialize'
57
+ ? 'codex_initialize_failed'
58
+ : method === 'thread/resume'
59
+ ? 'codex_resume_failed'
60
+ : 'codex_turn_start_failed'
61
+ return { error: mappedError, detail: response.error }
62
+ }
63
+ return { ok: response }
64
+ } catch (error) {
65
+ const mappedError =
66
+ method === 'initialize'
67
+ ? 'codex_initialize_failed'
68
+ : method === 'thread/resume'
69
+ ? 'codex_resume_failed'
70
+ : 'codex_turn_start_failed'
71
+ return { error: mappedError, detail: describeError(error) }
72
+ }
73
+ }
74
+
75
+ export async function dispatchCodexAppserverPoke(
76
+ input: {
77
+ delivery: DeliveryCodexAppserver
78
+ content: string
79
+ },
80
+ deps: CodexAppserverDispatchDeps = {}
81
+ ): Promise<CodexAppserverDispatchResult> {
82
+ const authToken = resolveAuthToken(
83
+ input.delivery.auth_token_ref,
84
+ deps.env ?? process.env
85
+ )
86
+ if ('error' in authToken) return authToken
87
+
88
+ const headers = authToken.ok === undefined
89
+ ? undefined
90
+ : { Authorization: `Bearer ${authToken.ok}` }
91
+
92
+ let ws: WebSocketLike
93
+ try {
94
+ ws = (deps.webSocketFactory ?? defaultWebSocketFactory)({
95
+ url: input.delivery.ws_url,
96
+ headers,
97
+ })
98
+ } catch (error) {
99
+ return {
100
+ error: 'codex_connect_failed',
101
+ detail: describeError(error),
102
+ transport_used: 'codex-appserver',
103
+ }
104
+ }
105
+
106
+ const client = new JsonRpcSocketClient(ws)
107
+ try {
108
+ await client.waitForOpen()
109
+
110
+ const init = await requestStep(client, 'initialize', {
111
+ clientInfo: {
112
+ name: 'cross-agent-teams-mcp',
113
+ title: null,
114
+ version: '0.1.0',
115
+ },
116
+ capabilities: {
117
+ experimentalApi: true,
118
+ optOutNotificationMethods: null,
119
+ },
120
+ })
121
+ if ('error' in init) {
122
+ return {
123
+ error: init.error,
124
+ detail: init.detail,
125
+ transport_used: 'codex-appserver',
126
+ }
127
+ }
128
+
129
+ client.notify('initialized')
130
+
131
+ const resume = await requestStep(client, 'thread/resume', {
132
+ threadId: input.delivery.thread_id,
133
+ persistExtendedHistory: false,
134
+ })
135
+ if ('error' in resume) {
136
+ return {
137
+ error: resume.error,
138
+ detail: resume.detail,
139
+ transport_used: 'codex-appserver',
140
+ }
141
+ }
142
+
143
+ const turnStart = await requestStep(client, 'turn/start', {
144
+ threadId: input.delivery.thread_id,
145
+ input: [{ type: 'text', text: input.content, text_elements: [] }],
146
+ })
147
+ if ('error' in turnStart) {
148
+ return {
149
+ error: turnStart.error,
150
+ detail: turnStart.detail,
151
+ transport_used: 'codex-appserver',
152
+ }
153
+ }
154
+
155
+ return {
156
+ ok: true,
157
+ transport_used: 'codex-appserver',
158
+ thread_id: input.delivery.thread_id,
159
+ }
160
+ } catch (error) {
161
+ return {
162
+ error: 'codex_connect_failed',
163
+ detail: describeError(error),
164
+ transport_used: 'codex-appserver',
165
+ }
166
+ } finally {
167
+ safeClose(ws)
168
+ }
169
+ }
@@ -0,0 +1,227 @@
1
+ type Json = unknown
2
+
3
+ interface JsonRpcRequest {
4
+ jsonrpc: '2.0'
5
+ id: number
6
+ method: string
7
+ params: Json
8
+ }
9
+
10
+ interface JsonRpcNotification {
11
+ jsonrpc: '2.0'
12
+ method: string
13
+ params?: Json
14
+ }
15
+
16
+ export interface JsonRpcResponse {
17
+ jsonrpc: '2.0'
18
+ id: number
19
+ result?: Json
20
+ error?: { code: number; message: string; data?: Json }
21
+ }
22
+
23
+ type MessageEventLike = { data: unknown }
24
+ type ErrorEventLike = { error?: unknown; message?: string }
25
+ type CloseEventLike = { code?: number; reason?: string }
26
+
27
+ export interface WebSocketLike {
28
+ addEventListener(
29
+ type: 'open' | 'message' | 'error' | 'close',
30
+ listener: (event: unknown) => void
31
+ ): void
32
+ removeEventListener?(
33
+ type: 'open' | 'message' | 'error' | 'close',
34
+ listener: (event: unknown) => void
35
+ ): void
36
+ send(data: string): void
37
+ close(): void
38
+ }
39
+
40
+ export interface CodexWebSocketFactoryArgs {
41
+ url: string
42
+ headers?: Record<string, string>
43
+ }
44
+
45
+ export type CodexWebSocketFactory = (
46
+ args: CodexWebSocketFactoryArgs
47
+ ) => WebSocketLike
48
+
49
+ type PendingResolver = {
50
+ resolve: (value: JsonRpcResponse) => void
51
+ reject: (reason?: unknown) => void
52
+ }
53
+
54
+ export function defaultWebSocketFactory(
55
+ args: CodexWebSocketFactoryArgs
56
+ ): WebSocketLike {
57
+ const ctor = globalThis.WebSocket as unknown as new (
58
+ url: string,
59
+ options?: { headers?: Record<string, string> }
60
+ ) => WebSocketLike
61
+ return new ctor(
62
+ args.url,
63
+ args.headers === undefined ? undefined : { headers: args.headers }
64
+ )
65
+ }
66
+
67
+ export function describeError(error: unknown): string {
68
+ if (error instanceof Error && error.message.length > 0) return error.message
69
+ if (typeof error === 'string' && error.length > 0) return error
70
+ if (error && typeof error === 'object') {
71
+ const record = error as Record<string, unknown>
72
+ const message = record.message
73
+ if (typeof message === 'string' && message.length > 0) return message
74
+ const reason = record.reason
75
+ if (typeof reason === 'string' && reason.length > 0) return reason
76
+ }
77
+ return String(error)
78
+ }
79
+
80
+ function closeDetail(event: CloseEventLike): string {
81
+ const code = typeof event.code === 'number' ? event.code : 'unknown'
82
+ const reason = typeof event.reason === 'string' && event.reason.length > 0
83
+ ? event.reason
84
+ : 'socket_closed'
85
+ return `close ${code}: ${reason}`
86
+ }
87
+
88
+ function decodeMessageData(data: unknown): string {
89
+ if (typeof data === 'string') return data
90
+ if (data instanceof ArrayBuffer) {
91
+ return new TextDecoder().decode(new Uint8Array(data))
92
+ }
93
+ if (ArrayBuffer.isView(data)) {
94
+ return new TextDecoder().decode(data)
95
+ }
96
+ return String(data)
97
+ }
98
+
99
+ export function safeClose(ws: WebSocketLike): void {
100
+ try {
101
+ ws.close()
102
+ } catch {
103
+ return
104
+ }
105
+ }
106
+
107
+ export function resolveAuthToken(
108
+ authTokenRef: string | undefined,
109
+ env: NodeJS.ProcessEnv
110
+ ): { ok: string | undefined } | { error: 'missing_auth_token'; detail: { ref: string } } {
111
+ if (authTokenRef === undefined) return { ok: undefined }
112
+ const token = env[authTokenRef]?.trim()
113
+ if (!token) {
114
+ return {
115
+ error: 'missing_auth_token',
116
+ detail: { ref: authTokenRef },
117
+ }
118
+ }
119
+ return { ok: token }
120
+ }
121
+
122
+ export class JsonRpcSocketClient {
123
+ private nextId = 1
124
+ private readonly pending = new Map<number, PendingResolver>()
125
+ private openState:
126
+ | { kind: 'pending'; promise: Promise<void> }
127
+ | { kind: 'open' }
128
+ | { kind: 'failed'; error: unknown }
129
+
130
+ constructor(private readonly ws: WebSocketLike) {
131
+ this.openState = {
132
+ kind: 'pending',
133
+ promise: new Promise<void>((resolve, reject) => {
134
+ const onOpen = () => {
135
+ cleanup()
136
+ this.openState = { kind: 'open' }
137
+ resolve()
138
+ }
139
+ const onError = (event: unknown) => {
140
+ cleanup()
141
+ const detail = event as ErrorEventLike
142
+ const error = detail.error ?? detail.message ?? 'websocket_error'
143
+ this.openState = { kind: 'failed', error }
144
+ reject(error)
145
+ }
146
+ const onClose = (event: unknown) => {
147
+ cleanup()
148
+ const closeEvent = event as CloseEventLike
149
+ const error = closeDetail(closeEvent)
150
+ this.openState = { kind: 'failed', error }
151
+ reject(error)
152
+ }
153
+ const cleanup = () => {
154
+ this.ws.removeEventListener?.('open', onOpen)
155
+ this.ws.removeEventListener?.('error', onError)
156
+ this.ws.removeEventListener?.('close', onClose)
157
+ }
158
+ this.ws.addEventListener('open', onOpen)
159
+ this.ws.addEventListener('error', onError)
160
+ this.ws.addEventListener('close', onClose)
161
+ }),
162
+ }
163
+
164
+ this.ws.addEventListener('message', (event) => {
165
+ let message: JsonRpcResponse
166
+ try {
167
+ message = JSON.parse(decodeMessageData((event as MessageEventLike).data))
168
+ } catch {
169
+ return
170
+ }
171
+ if (typeof message.id !== 'number') return
172
+ const pending = this.pending.get(message.id)
173
+ if (!pending) return
174
+ this.pending.delete(message.id)
175
+ pending.resolve(message)
176
+ })
177
+
178
+ this.ws.addEventListener('error', (event) => {
179
+ if (this.openState.kind !== 'open') return
180
+ const detail = event as ErrorEventLike
181
+ const error = detail.error ?? detail.message ?? 'websocket_error'
182
+ this.rejectAll(error)
183
+ })
184
+
185
+ this.ws.addEventListener('close', (event) => {
186
+ if (this.openState.kind !== 'open') return
187
+ this.rejectAll(closeDetail(event as CloseEventLike))
188
+ })
189
+ }
190
+
191
+ async waitForOpen(): Promise<void> {
192
+ if (this.openState.kind === 'open') return
193
+ if (this.openState.kind === 'failed') throw this.openState.error
194
+ await this.openState.promise
195
+ }
196
+
197
+ request(method: string, params: Json): Promise<JsonRpcResponse> {
198
+ const id = this.nextId++
199
+ const request: JsonRpcRequest = { jsonrpc: '2.0', id, method, params }
200
+ return new Promise<JsonRpcResponse>((resolve, reject) => {
201
+ this.pending.set(id, { resolve, reject })
202
+ try {
203
+ this.ws.send(JSON.stringify(request))
204
+ } catch (error) {
205
+ this.pending.delete(id)
206
+ reject(error)
207
+ }
208
+ })
209
+ }
210
+
211
+ notify(method: string, params?: Json): void {
212
+ const notification: JsonRpcNotification = {
213
+ jsonrpc: '2.0',
214
+ method,
215
+ ...(params === undefined ? {} : { params }),
216
+ }
217
+ this.ws.send(JSON.stringify(notification))
218
+ }
219
+
220
+ private rejectAll(error: unknown): void {
221
+ const pending = [...this.pending.values()]
222
+ this.pending.clear()
223
+ for (const entry of pending) {
224
+ entry.reject(error)
225
+ }
226
+ }
227
+ }
@@ -0,0 +1,57 @@
1
+ import type Database from 'better-sqlite3'
2
+
3
+ export interface CodexPanePreRegRow {
4
+ pane_id: string
5
+ xats_agent_id: string
6
+ expires_at: string
7
+ }
8
+
9
+ export interface UpsertInput {
10
+ pane_id: string
11
+ xats_agent_id: string
12
+ expires_at: string
13
+ }
14
+
15
+ export class CodexPanePreRegRepo {
16
+ constructor(private readonly db: Database.Database) {}
17
+
18
+ upsert(input: UpsertInput): void {
19
+ this.db
20
+ .prepare(
21
+ `INSERT INTO codex_pane_pre_registrations (pane_id, xats_agent_id, expires_at)
22
+ VALUES (?, ?, ?)
23
+ ON CONFLICT(pane_id) DO UPDATE SET
24
+ xats_agent_id = excluded.xats_agent_id,
25
+ expires_at = excluded.expires_at`
26
+ )
27
+ .run(input.pane_id, input.xats_agent_id, input.expires_at)
28
+ }
29
+
30
+ listUnexpired(now: string): CodexPanePreRegRow[] {
31
+ return this.db
32
+ .prepare(
33
+ `SELECT pane_id, xats_agent_id, expires_at
34
+ FROM codex_pane_pre_registrations
35
+ WHERE expires_at > ?`
36
+ )
37
+ .all(now) as CodexPanePreRegRow[]
38
+ }
39
+
40
+ takeByPaneId(pane_id: string): CodexPanePreRegRow | undefined {
41
+ const row = this.db
42
+ .prepare(
43
+ `DELETE FROM codex_pane_pre_registrations
44
+ WHERE pane_id = ?
45
+ RETURNING pane_id, xats_agent_id, expires_at`
46
+ )
47
+ .get(pane_id) as CodexPanePreRegRow | undefined
48
+ return row
49
+ }
50
+
51
+ deleteExpired(now: string): number {
52
+ const res = this.db
53
+ .prepare(`DELETE FROM codex_pane_pre_registrations WHERE expires_at <= ?`)
54
+ .run(now)
55
+ return res.changes
56
+ }
57
+ }
@@ -0,0 +1,114 @@
1
+ import type Database from 'better-sqlite3'
2
+ import type { AutoPokeSkipReason } from './auto-poke-fanout.js'
3
+
4
+ export type WakeStatus = 'delivered' | 'retrying' | 'skipped' | 'failed'
5
+ export type DeliverySkipReason =
6
+ | AutoPokeSkipReason
7
+ | 'auto_poke_disabled'
8
+ | 'recipient_active'
9
+ | 'retry_exhausted'
10
+
11
+ export interface DeliveryStatusRow {
12
+ agent_id: string
13
+ wake_status: WakeStatus
14
+ skip_reason: DeliverySkipReason | null
15
+ retry_attempts: number
16
+ updated_at: string
17
+ delivered_at: string | null
18
+ }
19
+
20
+ export function recordInitialDeliveryStatuses(
21
+ db: Database.Database,
22
+ args: {
23
+ messageId: string
24
+ recipients: string[]
25
+ delivered: Set<string>
26
+ skipped: Array<{ agent_id: string; reason: AutoPokeSkipReason }>
27
+ autoPokeDisabled?: boolean
28
+ }
29
+ ): void {
30
+ const now = new Date().toISOString()
31
+ const skipped = new Map(args.skipped.map(x => [x.agent_id, x.reason]))
32
+ const stmt = db.prepare(
33
+ `INSERT INTO message_delivery_status
34
+ (message_id, agent_id, wake_status, skip_reason, retry_attempts, updated_at, delivered_at)
35
+ VALUES (?, ?, ?, ?, ?, ?, ?)
36
+ ON CONFLICT(message_id, agent_id) DO UPDATE SET
37
+ wake_status=excluded.wake_status,
38
+ skip_reason=excluded.skip_reason,
39
+ retry_attempts=excluded.retry_attempts,
40
+ updated_at=excluded.updated_at,
41
+ delivered_at=excluded.delivered_at`
42
+ )
43
+ const tx = db.transaction(() => {
44
+ for (const agentId of args.recipients) {
45
+ const reason = args.autoPokeDisabled ? 'auto_poke_disabled' : skipped.get(agentId)
46
+ const delivered = args.delivered.has(agentId)
47
+ const status: WakeStatus = delivered
48
+ ? 'delivered'
49
+ : reason === 'guard_failed' ? 'retrying' : 'skipped'
50
+ stmt.run(
51
+ args.messageId,
52
+ agentId,
53
+ status,
54
+ delivered ? null : reason,
55
+ 0,
56
+ now,
57
+ delivered ? now : null
58
+ )
59
+ }
60
+ })
61
+ tx()
62
+ }
63
+
64
+ export function updateDeliveryStatus(
65
+ db: Database.Database,
66
+ messageId: string,
67
+ agentId: string,
68
+ args: {
69
+ wake_status: WakeStatus
70
+ skip_reason?: DeliverySkipReason | null
71
+ retry_attempts?: number
72
+ delivered_at?: string | null
73
+ }
74
+ ): void {
75
+ const now = new Date().toISOString()
76
+ db.prepare(
77
+ `UPDATE message_delivery_status
78
+ SET wake_status=?,
79
+ skip_reason=?,
80
+ retry_attempts=COALESCE(?, retry_attempts),
81
+ updated_at=?,
82
+ delivered_at=?
83
+ WHERE message_id=? AND agent_id=?`
84
+ ).run(
85
+ args.wake_status,
86
+ args.skip_reason ?? null,
87
+ args.retry_attempts ?? null,
88
+ now,
89
+ args.delivered_at === undefined ? null : args.delivered_at,
90
+ messageId,
91
+ agentId
92
+ )
93
+ }
94
+
95
+ export class GetDeliveryStatusService {
96
+ constructor(private db: Database.Database) {}
97
+
98
+ get(args: { caller: string; message_id: string }):
99
+ | { message_id: string; statuses: DeliveryStatusRow[] }
100
+ | { error: 'unknown_message' } {
101
+ const owned = this.db.prepare(
102
+ `SELECT 1 AS ok FROM messages WHERE id=? AND from_agent_id=? LIMIT 1`
103
+ ).get(args.message_id, args.caller) as { ok: number } | undefined
104
+ if (!owned) return { error: 'unknown_message' }
105
+
106
+ const rows = this.db.prepare(
107
+ `SELECT agent_id, wake_status, skip_reason, retry_attempts, updated_at, delivered_at
108
+ FROM message_delivery_status
109
+ WHERE message_id=?
110
+ ORDER BY agent_id ASC`
111
+ ).all(args.message_id) as DeliveryStatusRow[]
112
+ return { message_id: args.message_id, statuses: rows }
113
+ }
114
+ }
@@ -0,0 +1,25 @@
1
+ import type Database from 'better-sqlite3'
2
+ import type { AgentsRepo } from '../storage/agents-repo.js'
3
+ import { diffSchema, type ContractDiff } from '../lib/schema-diff.js'
4
+
5
+ export type DiffContractsResult =
6
+ | ContractDiff
7
+ | { error: 'unknown_contract' | 'unknown_version' | 'unknown_agent' }
8
+
9
+ export class DiffContractsService {
10
+ constructor(private db: Database.Database, private agents: AgentsRepo) {}
11
+
12
+ diff(args: { caller: string; name: string; from_version: number; to_version: number }): DiffContractsResult {
13
+ const caller = this.agents.findById(args.caller)
14
+ if (!caller) return { error: 'unknown_agent' }
15
+ const from = this.db.prepare('SELECT schema FROM contracts WHERE team=? AND name=? AND version=?')
16
+ .get(caller.team, args.name, args.from_version) as { schema: string } | undefined
17
+ const to = this.db.prepare('SELECT schema FROM contracts WHERE team=? AND name=? AND version=?')
18
+ .get(caller.team, args.name, args.to_version) as { schema: string } | undefined
19
+ if (!from || !to) {
20
+ const exists = this.db.prepare('SELECT 1 FROM contracts WHERE team=? AND name=? LIMIT 1').get(caller.team, args.name)
21
+ return exists ? { error: 'unknown_version' } : { error: 'unknown_contract' }
22
+ }
23
+ return diffSchema(JSON.parse(from.schema), JSON.parse(to.schema))
24
+ }
25
+ }
@@ -0,0 +1,8 @@
1
+ import { z } from 'zod'
2
+
3
+ export const echoSchema = { msg: z.string() }
4
+
5
+ export async function echoHandler(args: { msg: string }): Promise<{ content: Array<{ type: 'text'; text: string }> }> {
6
+ const out = { msg: args.msg, echoed_at: new Date().toISOString() }
7
+ return { content: [{ type: 'text', text: JSON.stringify(out) }] }
8
+ }
@@ -0,0 +1,56 @@
1
+ import type Database from 'better-sqlite3'
2
+ import { fanoutAutoPoke, type AutoPokeRecipient, type AutoPokeSkipReason, type FanoutDeps } from './auto-poke-fanout.js'
3
+ import { RETRY_DELAYS_S } from './poke-retry.js'
4
+ import { recordInitialDeliveryStatuses, updateDeliveryStatus } from './delivery-status.js'
5
+
6
+ export interface FanoutResultEnvelope {
7
+ poked: boolean
8
+ poke_skip_reasons?: Array<{ agent_id: string; reason: AutoPokeSkipReason }>
9
+ retry_scheduled: boolean
10
+ retry_delays_s?: number[]
11
+ }
12
+
13
+ // Shared fan-out + retry wiring used by both BroadcastService (all-team) and BroadcastToRoleService (same-team role-scoped).
14
+ // Same recipient-lookup SQL (agent_id-only, team-agnostic) keeps cross-team retry behaviour consistent.
15
+ export async function runFanoutWithRetry(args: {
16
+ db: Database.Database
17
+ team: string
18
+ fromAgentId: string
19
+ recipients: AutoPokeRecipient[]
20
+ body: string
21
+ deps: FanoutDeps
22
+ messageId: string
23
+ sentAt: string
24
+ }): Promise<FanoutResultEnvelope> {
25
+ const { db } = args
26
+ const fanout = await fanoutAutoPoke({
27
+ team: args.team,
28
+ fromAgentId: args.fromAgentId,
29
+ recipients: args.recipients,
30
+ body: args.body,
31
+ deps: args.deps,
32
+ retry: {
33
+ messageId: args.messageId,
34
+ sentAt: args.sentAt,
35
+ lookupAgentFn: (agentId: string) => db.prepare(
36
+ 'SELECT agent_id, tmux_pane_id, last_seen_at FROM agents WHERE agent_id=?'
37
+ ).get(agentId) as { agent_id: string; tmux_pane_id: string | null; last_seen_at: string } | undefined,
38
+ updateStatusFn: (status) => {
39
+ updateDeliveryStatus(db, args.messageId, status.agentId, status)
40
+ }
41
+ }
42
+ })
43
+ recordInitialDeliveryStatuses(db, {
44
+ messageId: args.messageId,
45
+ recipients: args.recipients.map(r => r.agent_id),
46
+ delivered: new Set(fanout.deliveredAgentIds),
47
+ skipped: fanout.skipReasons
48
+ })
49
+ const retry_scheduled = fanout.retryScheduledCount > 0
50
+ return {
51
+ poked: fanout.poked,
52
+ poke_skip_reasons: fanout.skipReasons,
53
+ retry_scheduled,
54
+ ...(retry_scheduled ? { retry_delays_s: [...RETRY_DELAYS_S] } : {})
55
+ }
56
+ }
@@ -0,0 +1,24 @@
1
+ import type Database from 'better-sqlite3'
2
+ import type { AgentsRepo } from '../storage/agents-repo.js'
3
+
4
+ export type GetContractResult =
5
+ | { name: string; version: number; schema: Record<string, unknown>; format: string; note: string | null; registered_at: string }
6
+ | { error: 'unknown_contract' | 'unknown_version' | 'unknown_agent' }
7
+
8
+ export class GetContractService {
9
+ constructor(private db: Database.Database, private agents: AgentsRepo) {}
10
+
11
+ get(args: { caller: string; name: string; version?: number }): GetContractResult {
12
+ const caller = this.agents.findById(args.caller)
13
+ if (!caller) return { error: 'unknown_agent' }
14
+ const row = args.version
15
+ ? this.db.prepare('SELECT * FROM contracts WHERE team=? AND name=? AND version=?').get(caller.team, args.name, args.version)
16
+ : this.db.prepare('SELECT * FROM contracts WHERE team=? AND name=? ORDER BY version DESC LIMIT 1').get(caller.team, args.name)
17
+ if (!row) {
18
+ const exists = this.db.prepare('SELECT 1 FROM contracts WHERE team=? AND name=? LIMIT 1').get(caller.team, args.name)
19
+ return exists ? { error: 'unknown_version' } : { error: 'unknown_contract' }
20
+ }
21
+ const r = row as { name: string; version: number; schema: string; format: string; note: string | null; registered_at: string }
22
+ return { name: r.name, version: r.version, schema: JSON.parse(r.schema), format: r.format, note: r.note, registered_at: r.registered_at }
23
+ }
24
+ }