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