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,276 @@
1
+ import { RegisterAgentService } from './register-agent.js'
2
+ import {
3
+ JsonRpcSocketClient,
4
+ defaultWebSocketFactory,
5
+ describeError,
6
+ resolveAuthToken,
7
+ safeClose,
8
+ type CodexWebSocketFactory,
9
+ type JsonRpcResponse,
10
+ } from './codex-appserver-rpc.js'
11
+
12
+ export interface RegisterCodexSelfInput {
13
+ connection_id: string
14
+ name: string
15
+ model?: string
16
+ role?: string
17
+ team?: string
18
+ project_dir?: string
19
+ ws_url?: string
20
+ auth_token_ref?: string
21
+ thread_id?: string
22
+ tmux_pane_id?: string
23
+ cwd?: string
24
+ tty?: string
25
+ title_contains?: string
26
+ }
27
+
28
+ export type RegisterCodexSelfResult =
29
+ | {
30
+ agent_id: string
31
+ team: string
32
+ thread_id: string
33
+ ws_url: string
34
+ }
35
+ | { error: 'agent_id_collision' }
36
+ | { error: 'invalid_delivery'; reason: string }
37
+ | { error: 'claude_ui_pid_requires_channel_proxy' }
38
+ | { error: 'missing_auth_token'; detail: { ref: string } }
39
+ | { error: 'unsupported_client'; detail: { expected: 'codex'; reason: 'codex_appserver_unreachable' | 'codex_protocol_unavailable'; ws_url: string; cause?: unknown } }
40
+ | { error: 'codex_connect_failed'; detail?: unknown }
41
+ | { error: 'codex_initialize_failed'; detail?: unknown }
42
+ | { error: 'codex_loaded_list_failed'; detail?: unknown }
43
+ | { error: 'no_loaded_threads'; detail?: unknown }
44
+ | { error: 'thread_id_required'; detail: { ws_url: string; thread_ids: string[] } }
45
+ | { error: 'codex_resume_failed'; detail?: unknown }
46
+
47
+ export interface RegisterCodexSelfDeps {
48
+ env?: NodeJS.ProcessEnv
49
+ webSocketFactory?: CodexWebSocketFactory
50
+ }
51
+
52
+ type RpcErrorCode =
53
+ | 'codex_initialize_failed'
54
+ | 'codex_loaded_list_failed'
55
+ | 'codex_resume_failed'
56
+
57
+ const DEFAULT_CODEX_WS_URL = 'ws://127.0.0.1:8799'
58
+
59
+ async function requestStep(
60
+ client: JsonRpcSocketClient,
61
+ method: string,
62
+ params: unknown,
63
+ errorCode: RpcErrorCode
64
+ ): Promise<{ ok: JsonRpcResponse } | { error: RpcErrorCode; detail: unknown }> {
65
+ try {
66
+ const response = await client.request(method, params)
67
+ if (response.error) return { error: errorCode, detail: response.error }
68
+ return { ok: response }
69
+ } catch (error) {
70
+ return { error: errorCode, detail: describeError(error) }
71
+ }
72
+ }
73
+
74
+ function resolveWsUrl(
75
+ input: RegisterCodexSelfInput,
76
+ env: NodeJS.ProcessEnv
77
+ ): string {
78
+ const explicit = input.ws_url?.trim()
79
+ if (explicit) return explicit
80
+ const fromEnv = env.CROSS_AGENT_TEAMS_CODEX_WS_URL?.trim()
81
+ if (fromEnv) return fromEnv
82
+ return DEFAULT_CODEX_WS_URL
83
+ }
84
+
85
+ function extractThreadIds(response: JsonRpcResponse): string[] {
86
+ const result = response.result as { data?: unknown } | undefined
87
+ if (!result || !Array.isArray(result.data)) return []
88
+ return result.data.filter((value): value is string => typeof value === 'string')
89
+ }
90
+
91
+ function trimToUndefined(value: string | undefined): string | undefined {
92
+ const trimmed = value?.trim()
93
+ return trimmed ? trimmed : undefined
94
+ }
95
+
96
+ export class RegisterCodexSelfService {
97
+ constructor(
98
+ private readonly registerSvc: RegisterAgentService,
99
+ private readonly deps: RegisterCodexSelfDeps = {}
100
+ ) {}
101
+
102
+ async register(
103
+ input: RegisterCodexSelfInput
104
+ ): Promise<RegisterCodexSelfResult> {
105
+ const env = this.deps.env ?? process.env
106
+ const wsUrl = resolveWsUrl(input, env)
107
+ const token = resolveAuthToken(input.auth_token_ref, env)
108
+ if ('error' in token) return token
109
+ const headers = token.ok === undefined
110
+ ? undefined
111
+ : { Authorization: `Bearer ${token.ok}` }
112
+
113
+ let ws
114
+ try {
115
+ ws = (this.deps.webSocketFactory ?? defaultWebSocketFactory)({
116
+ url: wsUrl,
117
+ headers,
118
+ })
119
+ } catch (error) {
120
+ return {
121
+ error: 'unsupported_client',
122
+ detail: {
123
+ expected: 'codex',
124
+ reason: 'codex_appserver_unreachable',
125
+ ws_url: wsUrl,
126
+ cause: describeError(error),
127
+ },
128
+ }
129
+ }
130
+
131
+ const client = new JsonRpcSocketClient(ws)
132
+ try {
133
+ await client.waitForOpen()
134
+
135
+ const init = await requestStep(
136
+ client,
137
+ 'initialize',
138
+ {
139
+ clientInfo: {
140
+ name: 'cross-agent-teams-mcp',
141
+ title: null,
142
+ version: '0.1.0',
143
+ },
144
+ capabilities: {
145
+ experimentalApi: true,
146
+ optOutNotificationMethods: null,
147
+ },
148
+ },
149
+ 'codex_initialize_failed'
150
+ )
151
+ if ('error' in init) {
152
+ return {
153
+ error: 'unsupported_client',
154
+ detail: {
155
+ expected: 'codex',
156
+ reason: 'codex_protocol_unavailable',
157
+ ws_url: wsUrl,
158
+ cause: init.detail,
159
+ },
160
+ }
161
+ }
162
+
163
+ client.notify('initialized')
164
+
165
+ const explicitThreadId = trimToUndefined(input.thread_id)
166
+ let threadId = explicitThreadId
167
+
168
+ if (!threadId) {
169
+ const list = await requestStep(
170
+ client,
171
+ 'thread/loaded/list',
172
+ { cursor: null, limit: 20 },
173
+ 'codex_loaded_list_failed'
174
+ )
175
+ if ('error' in list) return list
176
+
177
+ const threadIds = extractThreadIds(list.ok)
178
+ if (threadIds.length === 0) {
179
+ return {
180
+ error: 'no_loaded_threads',
181
+ detail: { ws_url: wsUrl },
182
+ }
183
+ }
184
+
185
+ const liveThreadIds: string[] = []
186
+ const failures: Array<{ thread_id: string; detail: unknown }> = []
187
+ for (const candidateThreadId of threadIds) {
188
+ const resume = await requestStep(
189
+ client,
190
+ 'thread/resume',
191
+ {
192
+ threadId: candidateThreadId,
193
+ persistExtendedHistory: false,
194
+ },
195
+ 'codex_resume_failed'
196
+ )
197
+ if ('error' in resume) {
198
+ failures.push({ thread_id: candidateThreadId, detail: resume.detail })
199
+ continue
200
+ }
201
+ liveThreadIds.push(candidateThreadId)
202
+ }
203
+
204
+ if (liveThreadIds.length === 0) {
205
+ return {
206
+ error: 'codex_resume_failed',
207
+ detail: failures,
208
+ }
209
+ }
210
+
211
+ return {
212
+ error: 'thread_id_required',
213
+ detail: {
214
+ ws_url: wsUrl,
215
+ thread_ids: liveThreadIds,
216
+ },
217
+ }
218
+ }
219
+
220
+ const resume = await requestStep(
221
+ client,
222
+ 'thread/resume',
223
+ {
224
+ threadId,
225
+ persistExtendedHistory: false,
226
+ },
227
+ 'codex_resume_failed'
228
+ )
229
+ if ('error' in resume) {
230
+ return {
231
+ error: 'codex_resume_failed',
232
+ detail: { thread_id: threadId, cause: resume.detail },
233
+ }
234
+ }
235
+
236
+ const tmuxPaneId = trimToUndefined(input.tmux_pane_id)
237
+
238
+ const result = this.registerSvc.register({
239
+ connection_id: input.connection_id,
240
+ client: 'codex',
241
+ model: input.model ?? 'codex',
242
+ name: input.name,
243
+ role: input.role,
244
+ team: input.team,
245
+ project_dir: input.project_dir,
246
+ tmux_pane_id: tmuxPaneId,
247
+ delivery: {
248
+ kind: 'codex-appserver',
249
+ thread_id: threadId,
250
+ ws_url: wsUrl,
251
+ ...(input.auth_token_ref === undefined
252
+ ? {}
253
+ : { auth_token_ref: input.auth_token_ref }),
254
+ },
255
+ })
256
+ if ('error' in result) return result
257
+ return {
258
+ ...result,
259
+ thread_id: threadId,
260
+ ws_url: wsUrl,
261
+ }
262
+ } catch (error) {
263
+ return {
264
+ error: 'unsupported_client',
265
+ detail: {
266
+ expected: 'codex',
267
+ reason: 'codex_appserver_unreachable',
268
+ ws_url: wsUrl,
269
+ cause: describeError(error),
270
+ },
271
+ }
272
+ } finally {
273
+ safeClose(ws)
274
+ }
275
+ }
276
+ }
@@ -0,0 +1,60 @@
1
+ import type Database from 'better-sqlite3'
2
+ import type { AgentsRepo } from '../storage/agents-repo.js'
3
+ import type { EventsOutbox } from '../storage/events-outbox.js'
4
+ import { diffSchema, type ContractDiff } from '../lib/schema-diff.js'
5
+
6
+ export interface RegisterContractMeta {
7
+ team: string
8
+ event_id: number
9
+ diff: ContractDiff | null
10
+ }
11
+
12
+ export type RegisterContractResult =
13
+ | { name: string; version: number; diff?: ContractDiff; _meta?: RegisterContractMeta }
14
+ | { error: 'unknown_agent' | 'invalid_format' }
15
+
16
+ export class RegisterContractService {
17
+ constructor(
18
+ private db: Database.Database,
19
+ private agents: AgentsRepo,
20
+ private events: EventsOutbox
21
+ ) {}
22
+
23
+ register(args: {
24
+ caller: string; name: string; schema: Record<string, unknown>;
25
+ format?: 'jsonschema'; note?: string
26
+ }): RegisterContractResult {
27
+ const caller = this.agents.findById(args.caller)
28
+ if (!caller) return { error: 'unknown_agent' }
29
+ const format = args.format ?? 'jsonschema'
30
+ if (format !== 'jsonschema') return { error: 'invalid_format' }
31
+
32
+ // better-sqlite3's .transaction() wraps BEGIN DEFERRED by default. We need IMMEDIATE
33
+ // so multiple writers serialize cleanly. Use a raw BEGIN IMMEDIATE.
34
+ const txFn = this.db.transaction((): RegisterContractResult => {
35
+ const prev = this.db.prepare(
36
+ `SELECT schema, version FROM contracts WHERE team=? AND name=? ORDER BY version DESC LIMIT 1`
37
+ ).get(caller.team, args.name) as { schema: string; version: number } | undefined
38
+ const version = prev ? prev.version + 1 : 1
39
+ const now = new Date().toISOString()
40
+ this.db.prepare(
41
+ `INSERT INTO contracts (team, name, version, format, schema, note, registered_by, registered_at)
42
+ VALUES (?,?,?,?,?,?,?,?)`
43
+ ).run(caller.team, args.name, version, format, JSON.stringify(args.schema), args.note ?? null, args.caller, now)
44
+ let diff: ContractDiff | undefined
45
+ if (prev) diff = diffSchema(JSON.parse(prev.schema), args.schema as any)
46
+ const event_id = this.events.append({
47
+ from_team: caller.team,
48
+ to_team: caller.team,
49
+ event_type: 'contract_registered',
50
+ actor_agent_id: args.caller,
51
+ payload: { name: args.name, version, diff: diff ?? null }
52
+ })
53
+ const meta: RegisterContractMeta = { team: caller.team, event_id, diff: diff ?? null }
54
+ return prev
55
+ ? { name: args.name, version, diff: diff!, _meta: meta }
56
+ : { name: args.name, version, _meta: meta }
57
+ })
58
+ return txFn.immediate()
59
+ }
60
+ }
@@ -0,0 +1,159 @@
1
+ import type Database from 'better-sqlite3'
2
+ import { randomUUID } from 'node:crypto'
3
+ import type { AgentsRepo } from '../storage/agents-repo.js'
4
+ import type { EventsOutbox } from '../storage/events-outbox.js'
5
+ import type { AutoPokeSkipReason, FanoutDeps } from './auto-poke-fanout.js'
6
+ import { parseDeliveryRow, type DeliverySpec } from '../lib/delivery-spec.js'
7
+ import { runFanoutWithRetry } from './fanout-with-retry.js'
8
+ import { recordInitialDeliveryStatuses } from './delivery-status.js'
9
+
10
+ export type { AutoPokeFn, AutoPokeSkipReason } from './auto-poke-fanout.js'
11
+
12
+ export type SendMessageDeps = FanoutDeps
13
+
14
+ export interface SendInput {
15
+ from: string
16
+ to_agent_id?: string
17
+ to_agent_name?: string
18
+ to_team?: string
19
+ subject?: string
20
+ body: string
21
+ auto_poke?: boolean
22
+ need_reply?: boolean
23
+ }
24
+
25
+ interface SuccessResult {
26
+ message_id: string
27
+ event_id: number
28
+ recipients: string[]
29
+ poked: boolean
30
+ poke_skip_reasons?: Array<{ agent_id: string; reason: AutoPokeSkipReason }>
31
+ retry_scheduled: boolean
32
+ retry_delays_s?: number[]
33
+ }
34
+
35
+ export type SendResult =
36
+ | SuccessResult
37
+ | { error: 'unknown_recipient' }
38
+ | { error: 'ambiguous_recipient' }
39
+ | { error: 'missing_recipient' }
40
+
41
+ interface RecipientPokeRow {
42
+ agent_id: string
43
+ tmux_pane_id: string | null
44
+ delivery: DeliverySpec
45
+ }
46
+
47
+ interface RecipientLookupRow {
48
+ agent_id: string
49
+ team: string
50
+ tmux_pane_id: string | null
51
+ delivery_kind: string
52
+ delivery_payload: string | null
53
+ }
54
+
55
+ export class SendMessageService {
56
+ constructor(
57
+ private db: Database.Database,
58
+ private agents: AgentsRepo,
59
+ private events: EventsOutbox,
60
+ private deps: SendMessageDeps = {}
61
+ ) {}
62
+
63
+ async send(input: SendInput): Promise<SendResult> {
64
+ const hasId = typeof input.to_agent_id === 'string' && input.to_agent_id.length > 0
65
+ const hasName = typeof input.to_agent_name === 'string' && input.to_agent_name.length > 0
66
+ if (!hasId && !hasName) return { error: 'missing_recipient' }
67
+ if (hasId && hasName) return { error: 'ambiguous_recipient' }
68
+ const fromRow = this.agents.findById(input.from)
69
+ if (!fromRow) return { error: 'unknown_recipient' }
70
+ const fromTeam = fromRow.team
71
+ const toTeam = input.to_team ?? fromTeam
72
+
73
+ let resolvedId: string
74
+ if (hasId) {
75
+ resolvedId = input.to_agent_id!
76
+ } else {
77
+ const hit = this.agents.findByIdentity({ team: toTeam, name: input.to_agent_name! })
78
+ if (!hit) return { error: 'unknown_recipient' }
79
+ resolvedId = hit.agent_id
80
+ }
81
+
82
+ const rcpt = this.db.prepare(
83
+ `SELECT
84
+ agent_id,
85
+ team,
86
+ tmux_pane_id,
87
+ delivery_kind,
88
+ delivery_payload
89
+ FROM agents
90
+ WHERE agent_id=?`
91
+ )
92
+ .get(resolvedId) as RecipientLookupRow | undefined
93
+ if (!rcpt || rcpt.team !== toTeam) return { error: 'unknown_recipient' }
94
+ const recipientRow: RecipientPokeRow = {
95
+ agent_id: rcpt.agent_id,
96
+ tmux_pane_id: rcpt.tmux_pane_id,
97
+ delivery: parseDeliveryRow(rcpt),
98
+ }
99
+
100
+ const baseResult = this.insert({ fromTeam, toTeam, from: input.from, toAgentId: rcpt.agent_id, input })
101
+
102
+ const autoPokeEnabled = input.auto_poke !== false
103
+ if (!autoPokeEnabled) {
104
+ recordInitialDeliveryStatuses(this.db, {
105
+ messageId: baseResult.message_id,
106
+ recipients: [rcpt.agent_id],
107
+ delivered: new Set(),
108
+ skipped: [],
109
+ autoPokeDisabled: true,
110
+ })
111
+ return { ...baseResult, poked: false, retry_scheduled: false }
112
+ }
113
+
114
+ const envelope = await runFanoutWithRetry({
115
+ db: this.db,
116
+ team: toTeam,
117
+ fromAgentId: input.from,
118
+ recipients: [recipientRow],
119
+ body: input.body,
120
+ deps: this.deps,
121
+ messageId: baseResult.message_id,
122
+ sentAt: baseResult.sent_at
123
+ })
124
+ return {
125
+ message_id: baseResult.message_id,
126
+ event_id: baseResult.event_id,
127
+ recipients: baseResult.recipients,
128
+ ...envelope
129
+ }
130
+ }
131
+
132
+ private insert(args: {
133
+ fromTeam: string; toTeam: string; from: string; toAgentId: string; input: SendInput
134
+ }): { message_id: string; event_id: number; recipients: string[]; sent_at: string } {
135
+ const tx = this.db.transaction(() => {
136
+ const needReply = args.input.need_reply !== false ? 1 : 0
137
+ const event_id = this.events.append({
138
+ from_team: args.fromTeam, to_team: args.toTeam,
139
+ event_type: 'message_sent', actor_agent_id: args.from,
140
+ payload: {
141
+ recipients: [args.toAgentId],
142
+ subject: args.input.subject ?? null,
143
+ need_reply: needReply === 1,
144
+ }
145
+ })
146
+ const sent_at = new Date().toISOString()
147
+ const id = randomUUID()
148
+ this.db.prepare(
149
+ `INSERT INTO messages (id, event_id, from_team, to_team, from_agent_id, to_agent_id, to_role, subject, body, need_reply, sent_at)
150
+ VALUES (?,?,?,?,?,?,?,?,?,?,?)`
151
+ ).run(id, event_id, args.fromTeam, args.toTeam, args.from,
152
+ args.toAgentId,
153
+ null, args.input.subject ?? null, args.input.body, needReply, sent_at)
154
+ return { message_id: id, event_id, sent_at }
155
+ })
156
+ const { message_id, event_id, sent_at } = tx()
157
+ return { message_id, event_id, recipients: [args.toAgentId], sent_at }
158
+ }
159
+ }
@@ -0,0 +1,31 @@
1
+ import type Database from 'better-sqlite3'
2
+ import type { ChannelWakeFanout, ChannelWakeSink } from '../daemon/channel-wake-fanout.js'
3
+
4
+ export const CHANNEL_PROXY_ROLE = '__channel_proxy__'
5
+
6
+ export interface SubscribeInput {
7
+ callerAgentId: string
8
+ channel_session_id: string
9
+ sessionId: string
10
+ sink: ChannelWakeSink
11
+ }
12
+
13
+ export type SubscribeResult =
14
+ | { ok: true }
15
+ | { error: 'unknown_agent' | 'forbidden_role' | 'invalid_channel_session_id' }
16
+
17
+ export class SubscribeChannelWakeService {
18
+ constructor(private readonly db: Database.Database, private readonly fanout: ChannelWakeFanout) {}
19
+
20
+ subscribe(input: SubscribeInput): SubscribeResult {
21
+ const csid = input.channel_session_id?.trim()
22
+ if (!csid) return { error: 'invalid_channel_session_id' }
23
+ const row = this.db
24
+ .prepare(`SELECT role FROM agents WHERE agent_id=?`)
25
+ .get(input.callerAgentId) as { role: string } | undefined
26
+ if (!row) return { error: 'unknown_agent' }
27
+ if (row.role !== CHANNEL_PROXY_ROLE) return { error: 'forbidden_role' }
28
+ this.fanout.attach(csid, input.sink, input.sessionId)
29
+ return { ok: true }
30
+ }
31
+ }
@@ -0,0 +1,24 @@
1
+ import type Database from 'better-sqlite3'
2
+ import type { AgentsRepo } from '../storage/agents-repo.js'
3
+
4
+ export type SubscribeResult =
5
+ | { ok: true; current_version: number | null }
6
+ | { error: 'unknown_agent' }
7
+
8
+ export class SubscribeContractService {
9
+ constructor(private db: Database.Database, private agents: AgentsRepo) {}
10
+
11
+ subscribe(args: { caller: string; name: string }): SubscribeResult {
12
+ const caller = this.agents.findById(args.caller)
13
+ if (!caller) return { error: 'unknown_agent' }
14
+ this.db.prepare(
15
+ `INSERT INTO contract_subscriptions (agent_id, team, contract_name, subscribed_at)
16
+ VALUES (?,?,?,?)
17
+ ON CONFLICT(agent_id, team, contract_name) DO UPDATE SET subscribed_at=excluded.subscribed_at`
18
+ ).run(args.caller, caller.team, args.name, new Date().toISOString())
19
+ const latest = this.db.prepare(
20
+ 'SELECT MAX(version) AS v FROM contracts WHERE team=? AND name=?'
21
+ ).get(caller.team, args.name) as { v: number | null }
22
+ return { ok: true, current_version: latest.v ?? null }
23
+ }
24
+ }
@@ -0,0 +1,37 @@
1
+ import type Database from 'better-sqlite3'
2
+ import { randomUUID } from 'node:crypto'
3
+ import type { AgentsRepo } from '../storage/agents-repo.js'
4
+ import type { EventsOutbox } from '../storage/events-outbox.js'
5
+
6
+ export type AddResult = { task_id: string } | { error: 'unknown_agent' }
7
+
8
+ export class TaskAddService {
9
+ constructor(
10
+ private db: Database.Database,
11
+ private agents: AgentsRepo,
12
+ private events: EventsOutbox
13
+ ) {}
14
+
15
+ add(args: { caller: string; title: string; description?: string; depends_on?: string[] }): AddResult {
16
+ const caller = this.agents.findById(args.caller)
17
+ if (!caller) return { error: 'unknown_agent' }
18
+ const id = randomUUID()
19
+ const depends_on = JSON.stringify(args.depends_on ?? [])
20
+ const created_at = new Date().toISOString()
21
+ const tx = this.db.transaction(() => {
22
+ this.db.prepare(
23
+ `INSERT INTO tasks (id, team, title, description, status, depends_on, created_at)
24
+ VALUES (?,?,?,?, 'pending', ?, ?)`
25
+ ).run(id, caller.team, args.title, args.description ?? null, depends_on, created_at)
26
+ this.events.append({
27
+ from_team: caller.team,
28
+ to_team: caller.team,
29
+ event_type: 'task_added',
30
+ actor_agent_id: args.caller,
31
+ payload: { task_id: id, title: args.title }
32
+ })
33
+ })
34
+ tx()
35
+ return { task_id: id }
36
+ }
37
+ }
@@ -0,0 +1,54 @@
1
+ import type Database from 'better-sqlite3'
2
+ import type { AgentsRepo } from '../storage/agents-repo.js'
3
+ import type { EventsOutbox } from '../storage/events-outbox.js'
4
+
5
+ export type ClaimResult =
6
+ | { ok: true }
7
+ | { error: 'already_claimed'; owner: string }
8
+ | { error: 'dependencies_pending' }
9
+ | { error: 'unknown_task' }
10
+ | { error: 'unknown_agent' }
11
+
12
+ export class TaskClaimService {
13
+ constructor(
14
+ private db: Database.Database,
15
+ private agents: AgentsRepo,
16
+ private events: EventsOutbox
17
+ ) {}
18
+
19
+ claim(args: { caller: string; task_id: string }): ClaimResult {
20
+ const caller = this.agents.findById(args.caller)
21
+ if (!caller) return { error: 'unknown_agent' }
22
+ const row = this.db.prepare(
23
+ `SELECT status, claimed_by, depends_on FROM tasks WHERE id=? AND team=?`
24
+ ).get(args.task_id, caller.team) as
25
+ { status: string; claimed_by: string | null; depends_on: string } | undefined
26
+ if (!row) return { error: 'unknown_task' }
27
+ if (row.status !== 'pending') {
28
+ if (row.claimed_by) return { error: 'already_claimed', owner: row.claimed_by }
29
+ return { error: 'already_claimed', owner: '' }
30
+ }
31
+ const deps = JSON.parse(row.depends_on) as string[]
32
+ if (deps.length > 0) {
33
+ const pending = this.db.prepare(
34
+ `SELECT COUNT(*) as c FROM tasks WHERE id IN (${deps.map(() => '?').join(',')}) AND team=? AND status != 'completed'`
35
+ ).get(...deps, caller.team) as { c: number }
36
+ if (pending.c > 0) return { error: 'dependencies_pending' }
37
+ }
38
+ const upd = this.db.prepare(
39
+ `UPDATE tasks SET status='in_progress', claimed_by=?, claimed_at=?
40
+ WHERE id=? AND team=? AND status='pending'`
41
+ ).run(args.caller, new Date().toISOString(), args.task_id, caller.team)
42
+ if (upd.changes !== 1) {
43
+ const post = this.db.prepare(`SELECT claimed_by FROM tasks WHERE id=?`).get(args.task_id) as
44
+ { claimed_by: string | null } | undefined
45
+ return { error: 'already_claimed', owner: post?.claimed_by ?? '' }
46
+ }
47
+ this.events.append({
48
+ from_team: caller.team, to_team: caller.team,
49
+ event_type: 'task_claimed', actor_agent_id: args.caller,
50
+ payload: { task_id: args.task_id }
51
+ })
52
+ return { ok: true }
53
+ }
54
+ }
@@ -0,0 +1,36 @@
1
+ import type Database from 'better-sqlite3'
2
+ import type { AgentsRepo } from '../storage/agents-repo.js'
3
+ import type { EventsOutbox } from '../storage/events-outbox.js'
4
+
5
+ export type CompleteResult =
6
+ | { ok: true }
7
+ | { error: 'not_owner' | 'invalid_status' | 'unknown_task' | 'unknown_agent' }
8
+
9
+ export class TaskCompleteService {
10
+ constructor(
11
+ private db: Database.Database,
12
+ private agents: AgentsRepo,
13
+ private events: EventsOutbox
14
+ ) {}
15
+
16
+ complete(args: { caller: string; task_id: string; result?: string }): CompleteResult {
17
+ const caller = this.agents.findById(args.caller)
18
+ if (!caller) return { error: 'unknown_agent' }
19
+ const row = this.db.prepare(`SELECT status, claimed_by FROM tasks WHERE id=? AND team=?`)
20
+ .get(args.task_id, caller.team) as { status: string; claimed_by: string | null } | undefined
21
+ if (!row) return { error: 'unknown_task' }
22
+ if (row.status !== 'in_progress') return { error: 'invalid_status' }
23
+ if (row.claimed_by !== args.caller) return { error: 'not_owner' }
24
+ const upd = this.db.prepare(
25
+ `UPDATE tasks SET status='completed', completed_at=?, result=?
26
+ WHERE id=? AND team=? AND claimed_by=? AND status='in_progress'`
27
+ ).run(new Date().toISOString(), args.result ?? null, args.task_id, caller.team, args.caller)
28
+ if (upd.changes !== 1) return { error: 'invalid_status' }
29
+ this.events.append({
30
+ from_team: caller.team, to_team: caller.team,
31
+ event_type: 'task_completed', actor_agent_id: args.caller,
32
+ payload: { task_id: args.task_id, result: args.result ?? null }
33
+ })
34
+ return { ok: true }
35
+ }
36
+ }