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 { execFile } from 'node:child_process'
|
|
2
|
+
import { normalize, sep } from 'node:path'
|
|
3
|
+
import { promisify } from 'node:util'
|
|
4
|
+
|
|
5
|
+
const pExecFile = promisify(execFile)
|
|
6
|
+
|
|
7
|
+
export type DetectAgentKind = 'codex' | 'claude-code' | 'opencode' | 'custom'
|
|
8
|
+
|
|
9
|
+
export interface DetectTmuxPaneInput {
|
|
10
|
+
agent: DetectAgentKind
|
|
11
|
+
cwd?: string
|
|
12
|
+
tty?: string
|
|
13
|
+
title_contains?: string
|
|
14
|
+
process_pattern?: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface DetectTmuxPaneCandidate {
|
|
18
|
+
pane_id: string
|
|
19
|
+
session_name: string
|
|
20
|
+
window_index: number
|
|
21
|
+
pane_index: number
|
|
22
|
+
active: boolean
|
|
23
|
+
tty: string
|
|
24
|
+
current_path: string
|
|
25
|
+
current_command: string
|
|
26
|
+
title: string
|
|
27
|
+
matched_processes: string[]
|
|
28
|
+
score: number
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type DetectTmuxPaneResult =
|
|
32
|
+
| { ok: true; pane: DetectTmuxPaneCandidate; candidates: DetectTmuxPaneCandidate[] }
|
|
33
|
+
| { error: 'not_found'; candidates: [] }
|
|
34
|
+
| { error: 'ambiguous_match'; candidates: DetectTmuxPaneCandidate[] }
|
|
35
|
+
| { error: 'tmux_unavailable'; detail: string }
|
|
36
|
+
|
|
37
|
+
export interface DetectTmuxPaneDeps {
|
|
38
|
+
execFile?: typeof execFile
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface PaneRow {
|
|
42
|
+
pane_id: string
|
|
43
|
+
session_name: string
|
|
44
|
+
window_index: number
|
|
45
|
+
pane_index: number
|
|
46
|
+
active: boolean
|
|
47
|
+
tty: string
|
|
48
|
+
current_path: string
|
|
49
|
+
current_command: string
|
|
50
|
+
title: string
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const TMUX_LIST_TIMEOUT_MS = 3_000
|
|
54
|
+
const PS_LIST_TIMEOUT_MS = 3_000
|
|
55
|
+
|
|
56
|
+
function normalizeTty(raw: string | undefined): string | undefined {
|
|
57
|
+
const value = raw?.trim()
|
|
58
|
+
if (!value) return undefined
|
|
59
|
+
return value.replace(/^\/dev\//, '')
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function normalizePath(raw: string | undefined): string | undefined {
|
|
63
|
+
const value = raw?.trim()
|
|
64
|
+
if (!value) return undefined
|
|
65
|
+
return normalize(value)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function pathRelated(candidatePath: string, inputPath: string): 'exact' | 'ancestor' | 'descendant' | 'none' {
|
|
69
|
+
const candidate = normalize(candidatePath)
|
|
70
|
+
const input = normalize(inputPath)
|
|
71
|
+
if (candidate === input) return 'exact'
|
|
72
|
+
if (candidate.startsWith(`${input}${sep}`)) return 'descendant'
|
|
73
|
+
if (input.startsWith(`${candidate}${sep}`)) return 'ancestor'
|
|
74
|
+
return 'none'
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function commandPattern(args: DetectTmuxPaneInput): RegExp {
|
|
78
|
+
if (args.agent === 'custom') {
|
|
79
|
+
const raw = args.process_pattern?.trim()
|
|
80
|
+
if (!raw) throw new Error('process_pattern is required when agent=custom')
|
|
81
|
+
return new RegExp(raw, 'i')
|
|
82
|
+
}
|
|
83
|
+
if (args.agent === 'codex') {
|
|
84
|
+
return /(^|[\s/])(codex|codex-aarch64-a)([\s]|$)/i
|
|
85
|
+
}
|
|
86
|
+
if (args.agent === 'claude-code') {
|
|
87
|
+
return /(^|[\s/])claude([\s]|$)/i
|
|
88
|
+
}
|
|
89
|
+
return /(^|[\s/])opencode([\s]|$)/i
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function commandHintScore(agent: DetectAgentKind, command: string): number {
|
|
93
|
+
if (agent === 'codex' && /codex/i.test(command)) return 6
|
|
94
|
+
if (agent === 'opencode' && /opencode/i.test(command)) return 6
|
|
95
|
+
if (agent === 'claude-code' && /^(\d+\.)+\d+$/.test(command)) return 4
|
|
96
|
+
return 0
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function isHelperProcess(agent: DetectAgentKind, command: string): boolean {
|
|
100
|
+
if (agent !== 'codex') return false
|
|
101
|
+
return /codex\s+app-server/i.test(command) ||
|
|
102
|
+
/Codex Computer Use\.app/i.test(command) ||
|
|
103
|
+
/SkyComputerUseClient/i.test(command)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function parsePaneRows(stdout: string): PaneRow[] {
|
|
107
|
+
return stdout
|
|
108
|
+
.split('\n')
|
|
109
|
+
.map(line => line.trimEnd())
|
|
110
|
+
.filter(Boolean)
|
|
111
|
+
.map((line) => {
|
|
112
|
+
const [
|
|
113
|
+
pane_id,
|
|
114
|
+
session_name,
|
|
115
|
+
window_index,
|
|
116
|
+
pane_index,
|
|
117
|
+
pane_active,
|
|
118
|
+
pane_tty,
|
|
119
|
+
pane_current_path,
|
|
120
|
+
pane_current_command,
|
|
121
|
+
pane_title,
|
|
122
|
+
] = line.split('\t')
|
|
123
|
+
return {
|
|
124
|
+
pane_id,
|
|
125
|
+
session_name,
|
|
126
|
+
window_index: Number(window_index),
|
|
127
|
+
pane_index: Number(pane_index),
|
|
128
|
+
active: pane_active === '1',
|
|
129
|
+
tty: normalizeTty(pane_tty) ?? '',
|
|
130
|
+
current_path: pane_current_path ?? '',
|
|
131
|
+
current_command: pane_current_command ?? '',
|
|
132
|
+
title: pane_title ?? '',
|
|
133
|
+
}
|
|
134
|
+
})
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function listPanes(execLike: typeof execFile): Promise<PaneRow[]> {
|
|
138
|
+
const exec = promisify(execLike)
|
|
139
|
+
const { stdout } = await exec(
|
|
140
|
+
'tmux',
|
|
141
|
+
[
|
|
142
|
+
'list-panes',
|
|
143
|
+
'-a',
|
|
144
|
+
'-F',
|
|
145
|
+
'#{pane_id}\t#{session_name}\t#{window_index}\t#{pane_index}\t#{pane_active}\t#{pane_tty}\t#{pane_current_path}\t#{pane_current_command}\t#{pane_title}',
|
|
146
|
+
],
|
|
147
|
+
{ timeout: TMUX_LIST_TIMEOUT_MS }
|
|
148
|
+
)
|
|
149
|
+
return parsePaneRows(stdout)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function ttyProcesses(execLike: typeof execFile, tty: string): Promise<string[]> {
|
|
153
|
+
const exec = promisify(execLike)
|
|
154
|
+
const { stdout } = await exec(
|
|
155
|
+
'ps',
|
|
156
|
+
['-t', tty, '-o', 'pid=,ppid=,stat=,command='],
|
|
157
|
+
{ timeout: PS_LIST_TIMEOUT_MS }
|
|
158
|
+
)
|
|
159
|
+
return stdout
|
|
160
|
+
.split('\n')
|
|
161
|
+
.map(line => line.trimEnd())
|
|
162
|
+
.filter(Boolean)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function collectCandidates(
|
|
166
|
+
panes: PaneRow[],
|
|
167
|
+
ttyMap: Map<string, string[]>,
|
|
168
|
+
input: DetectTmuxPaneInput
|
|
169
|
+
): DetectTmuxPaneCandidate[] {
|
|
170
|
+
const ttyFilter = normalizeTty(input.tty)
|
|
171
|
+
const cwdFilter = normalizePath(input.cwd)
|
|
172
|
+
const titleFilter = input.title_contains?.trim().toLowerCase()
|
|
173
|
+
const pattern = commandPattern(input)
|
|
174
|
+
const candidates: DetectTmuxPaneCandidate[] = []
|
|
175
|
+
|
|
176
|
+
for (const pane of panes) {
|
|
177
|
+
if (ttyFilter && pane.tty !== ttyFilter) continue
|
|
178
|
+
if (cwdFilter) {
|
|
179
|
+
const relation = pathRelated(pane.current_path, cwdFilter)
|
|
180
|
+
if (relation === 'none') continue
|
|
181
|
+
}
|
|
182
|
+
if (titleFilter && !pane.title.toLowerCase().includes(titleFilter)) continue
|
|
183
|
+
|
|
184
|
+
const matched_processes = (ttyMap.get(pane.tty) ?? []).filter((line) => {
|
|
185
|
+
if (isHelperProcess(input.agent, line)) return false
|
|
186
|
+
return pattern.test(line)
|
|
187
|
+
})
|
|
188
|
+
if (matched_processes.length === 0) continue
|
|
189
|
+
|
|
190
|
+
let score = matched_processes.length * 10
|
|
191
|
+
if (pane.active) score += 3
|
|
192
|
+
score += commandHintScore(input.agent, pane.current_command)
|
|
193
|
+
if (ttyFilter) score += 100
|
|
194
|
+
if (cwdFilter) {
|
|
195
|
+
const relation = pathRelated(pane.current_path, cwdFilter)
|
|
196
|
+
if (relation === 'exact') score += 60
|
|
197
|
+
else if (relation === 'descendant') score += 45
|
|
198
|
+
else if (relation === 'ancestor') score += 30
|
|
199
|
+
}
|
|
200
|
+
if (titleFilter) score += 15
|
|
201
|
+
|
|
202
|
+
candidates.push({
|
|
203
|
+
pane_id: pane.pane_id,
|
|
204
|
+
session_name: pane.session_name,
|
|
205
|
+
window_index: pane.window_index,
|
|
206
|
+
pane_index: pane.pane_index,
|
|
207
|
+
active: pane.active,
|
|
208
|
+
tty: pane.tty,
|
|
209
|
+
current_path: pane.current_path,
|
|
210
|
+
current_command: pane.current_command,
|
|
211
|
+
title: pane.title,
|
|
212
|
+
matched_processes,
|
|
213
|
+
score,
|
|
214
|
+
})
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return candidates.sort((a, b) => {
|
|
218
|
+
if (b.score !== a.score) return b.score - a.score
|
|
219
|
+
if (a.pane_id < b.pane_id) return -1
|
|
220
|
+
if (a.pane_id > b.pane_id) return 1
|
|
221
|
+
return 0
|
|
222
|
+
})
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export async function detectTmuxPane(
|
|
226
|
+
input: DetectTmuxPaneInput,
|
|
227
|
+
deps: DetectTmuxPaneDeps = {}
|
|
228
|
+
): Promise<DetectTmuxPaneResult> {
|
|
229
|
+
const execLike = deps.execFile ?? execFile
|
|
230
|
+
let panes: PaneRow[]
|
|
231
|
+
try {
|
|
232
|
+
panes = await listPanes(execLike)
|
|
233
|
+
} catch (error) {
|
|
234
|
+
return {
|
|
235
|
+
error: 'tmux_unavailable',
|
|
236
|
+
detail: error instanceof Error ? error.message : String(error),
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const ttyMap = new Map<string, string[]>()
|
|
241
|
+
for (const pane of panes) {
|
|
242
|
+
if (!pane.tty || ttyMap.has(pane.tty)) continue
|
|
243
|
+
try {
|
|
244
|
+
ttyMap.set(pane.tty, await ttyProcesses(execLike, pane.tty))
|
|
245
|
+
} catch {
|
|
246
|
+
ttyMap.set(pane.tty, [])
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
let candidates: DetectTmuxPaneCandidate[]
|
|
251
|
+
try {
|
|
252
|
+
candidates = collectCandidates(panes, ttyMap, input)
|
|
253
|
+
} catch (error) {
|
|
254
|
+
return {
|
|
255
|
+
error: 'not_found',
|
|
256
|
+
candidates: [],
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (candidates.length === 0) return { error: 'not_found', candidates: [] }
|
|
261
|
+
|
|
262
|
+
const topScore = candidates[0].score
|
|
263
|
+
const top = candidates.filter(candidate => candidate.score === topScore)
|
|
264
|
+
if (top.length > 1) {
|
|
265
|
+
return {
|
|
266
|
+
error: 'ambiguous_match',
|
|
267
|
+
candidates,
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
return {
|
|
272
|
+
ok: true,
|
|
273
|
+
pane: candidates[0],
|
|
274
|
+
candidates,
|
|
275
|
+
}
|
|
276
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export type ClientKind = 'codex' | 'claude-code' | 'opencode' | 'custom'
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { basename } from 'node:path'
|
|
2
|
+
|
|
3
|
+
export interface DeriveDefaultTeamInput {
|
|
4
|
+
team?: string
|
|
5
|
+
project_dir?: string
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function deriveDefaultTeam(input: DeriveDefaultTeamInput): string {
|
|
9
|
+
const explicitTeam = input.team?.trim()
|
|
10
|
+
if (explicitTeam) return explicitTeam
|
|
11
|
+
|
|
12
|
+
if (input.project_dir !== undefined) {
|
|
13
|
+
const projectTeam = basename(input.project_dir).trim().toLowerCase()
|
|
14
|
+
if (projectTeam) return projectTeam
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
return 'default'
|
|
18
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
export type DeliveryNone = {
|
|
2
|
+
kind: 'none';
|
|
3
|
+
};
|
|
4
|
+
|
|
5
|
+
export type DeliveryClaudeChannel = {
|
|
6
|
+
kind: 'claude-channel';
|
|
7
|
+
channel_session_id: string;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export type DeliveryCodexAppserver = {
|
|
11
|
+
kind: 'codex-appserver';
|
|
12
|
+
thread_id: string;
|
|
13
|
+
ws_url: string;
|
|
14
|
+
auth_token_ref?: string;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export type DeliverySpec =
|
|
18
|
+
| DeliveryNone
|
|
19
|
+
| DeliveryClaudeChannel
|
|
20
|
+
| DeliveryCodexAppserver;
|
|
21
|
+
|
|
22
|
+
export type DeliveryKind = DeliverySpec['kind'];
|
|
23
|
+
|
|
24
|
+
export const DELIVERY_KINDS: readonly DeliveryKind[] = [
|
|
25
|
+
'none',
|
|
26
|
+
'claude-channel',
|
|
27
|
+
'codex-appserver',
|
|
28
|
+
] as const;
|
|
29
|
+
|
|
30
|
+
export type DeliveryRow = {
|
|
31
|
+
delivery_kind: string;
|
|
32
|
+
delivery_payload: string | null;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export function parseDeliveryRow(row: DeliveryRow): DeliverySpec {
|
|
36
|
+
const kind = row.delivery_kind;
|
|
37
|
+
if (kind === 'none') {
|
|
38
|
+
return { kind: 'none' };
|
|
39
|
+
}
|
|
40
|
+
if (!(DELIVERY_KINDS as readonly string[]).includes(kind)) {
|
|
41
|
+
throw new Error('corrupt_delivery_payload');
|
|
42
|
+
}
|
|
43
|
+
let payload: unknown;
|
|
44
|
+
try {
|
|
45
|
+
payload = row.delivery_payload == null ? {} : JSON.parse(row.delivery_payload);
|
|
46
|
+
} catch {
|
|
47
|
+
throw new Error('corrupt_delivery_payload');
|
|
48
|
+
}
|
|
49
|
+
if (typeof payload !== 'object' || payload === null) {
|
|
50
|
+
throw new Error('corrupt_delivery_payload');
|
|
51
|
+
}
|
|
52
|
+
const record = payload as Record<string, unknown>;
|
|
53
|
+
if (kind === 'claude-channel') {
|
|
54
|
+
const csid = record.channel_session_id;
|
|
55
|
+
if (typeof csid !== 'string' || csid.length === 0) {
|
|
56
|
+
throw new Error('corrupt_delivery_payload');
|
|
57
|
+
}
|
|
58
|
+
return { kind: 'claude-channel', channel_session_id: csid };
|
|
59
|
+
}
|
|
60
|
+
if (kind === 'codex-appserver') {
|
|
61
|
+
const threadId = record.thread_id;
|
|
62
|
+
if (typeof threadId !== 'string' || threadId.length === 0) {
|
|
63
|
+
throw new Error('corrupt_delivery_payload');
|
|
64
|
+
}
|
|
65
|
+
const wsUrl = record.ws_url;
|
|
66
|
+
if (typeof wsUrl !== 'string' || wsUrl.length === 0) {
|
|
67
|
+
throw new Error('corrupt_delivery_payload');
|
|
68
|
+
}
|
|
69
|
+
const hasAuthTokenRef = Object.prototype.hasOwnProperty.call(record, 'auth_token_ref');
|
|
70
|
+
if (hasAuthTokenRef) {
|
|
71
|
+
const authTokenRef = record.auth_token_ref;
|
|
72
|
+
if (typeof authTokenRef !== 'string' || authTokenRef.length === 0) {
|
|
73
|
+
throw new Error('corrupt_delivery_payload');
|
|
74
|
+
}
|
|
75
|
+
return {
|
|
76
|
+
kind: 'codex-appserver',
|
|
77
|
+
thread_id: threadId,
|
|
78
|
+
ws_url: wsUrl,
|
|
79
|
+
auth_token_ref: authTokenRef,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
return { kind: 'codex-appserver', thread_id: threadId, ws_url: wsUrl };
|
|
83
|
+
}
|
|
84
|
+
throw new Error('corrupt_delivery_payload');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function serializeDelivery(spec: DeliverySpec): DeliveryRow {
|
|
88
|
+
if (spec.kind === 'none') {
|
|
89
|
+
return { delivery_kind: 'none', delivery_payload: null };
|
|
90
|
+
}
|
|
91
|
+
const { kind, ...rest } = spec;
|
|
92
|
+
return {
|
|
93
|
+
delivery_kind: kind,
|
|
94
|
+
delivery_payload: JSON.stringify(rest),
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export type DeliveryValidationReason =
|
|
99
|
+
| 'unknown_kind'
|
|
100
|
+
| 'missing_channel_session_id'
|
|
101
|
+
| 'invalid_thread_id'
|
|
102
|
+
| 'invalid_ws_url'
|
|
103
|
+
| 'invalid_auth_token_ref';
|
|
104
|
+
|
|
105
|
+
export type ValidateDeliveryResult =
|
|
106
|
+
| { ok: DeliverySpec }
|
|
107
|
+
| { error: 'invalid_delivery'; reason: DeliveryValidationReason };
|
|
108
|
+
|
|
109
|
+
const UUID_RE =
|
|
110
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
111
|
+
|
|
112
|
+
function readTrimmedString(
|
|
113
|
+
input: Record<string, unknown>,
|
|
114
|
+
key: string
|
|
115
|
+
): string | undefined {
|
|
116
|
+
const value = input[key];
|
|
117
|
+
if (typeof value !== 'string') return undefined;
|
|
118
|
+
const trimmed = value.trim();
|
|
119
|
+
return trimmed.length > 0 ? trimmed : '';
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function validateDeliveryForWrite(input: unknown): ValidateDeliveryResult {
|
|
123
|
+
if (typeof input !== 'object' || input === null) {
|
|
124
|
+
return { error: 'invalid_delivery', reason: 'unknown_kind' };
|
|
125
|
+
}
|
|
126
|
+
const record = input as Record<string, unknown>;
|
|
127
|
+
const kind = record.kind;
|
|
128
|
+
if (kind === 'none') {
|
|
129
|
+
return { ok: { kind: 'none' } };
|
|
130
|
+
}
|
|
131
|
+
if (kind === 'claude-channel') {
|
|
132
|
+
const csid = readTrimmedString(record, 'channel_session_id');
|
|
133
|
+
if (csid === undefined || csid.length === 0) {
|
|
134
|
+
return { error: 'invalid_delivery', reason: 'missing_channel_session_id' };
|
|
135
|
+
}
|
|
136
|
+
return { ok: { kind: 'claude-channel', channel_session_id: csid } };
|
|
137
|
+
}
|
|
138
|
+
if (kind === 'codex-appserver') {
|
|
139
|
+
const threadId = readTrimmedString(record, 'thread_id');
|
|
140
|
+
if (threadId === undefined || threadId.length === 0 || !UUID_RE.test(threadId)) {
|
|
141
|
+
return { error: 'invalid_delivery', reason: 'invalid_thread_id' };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const wsUrl = readTrimmedString(record, 'ws_url');
|
|
145
|
+
if (wsUrl === undefined || wsUrl.length === 0) {
|
|
146
|
+
return { error: 'invalid_delivery', reason: 'invalid_ws_url' };
|
|
147
|
+
}
|
|
148
|
+
try {
|
|
149
|
+
const parsed = new URL(wsUrl);
|
|
150
|
+
if (parsed.protocol !== 'ws:' && parsed.protocol !== 'wss:') {
|
|
151
|
+
return { error: 'invalid_delivery', reason: 'invalid_ws_url' };
|
|
152
|
+
}
|
|
153
|
+
} catch {
|
|
154
|
+
return { error: 'invalid_delivery', reason: 'invalid_ws_url' };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const authTokenRef = readTrimmedString(record, 'auth_token_ref');
|
|
158
|
+
if (authTokenRef === '') {
|
|
159
|
+
return { error: 'invalid_delivery', reason: 'invalid_auth_token_ref' };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return {
|
|
163
|
+
ok: {
|
|
164
|
+
kind: 'codex-appserver',
|
|
165
|
+
thread_id: threadId,
|
|
166
|
+
ws_url: wsUrl,
|
|
167
|
+
...(authTokenRef === undefined ? {} : { auth_token_ref: authTokenRef }),
|
|
168
|
+
},
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
return { error: 'invalid_delivery', reason: 'unknown_kind' };
|
|
172
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
export interface ContractDiff {
|
|
2
|
+
added_fields: Array<{ path: string; type_summary: string }>
|
|
3
|
+
removed_fields: Array<{ path: string; type_summary: string }>
|
|
4
|
+
changed_fields: Array<{
|
|
5
|
+
path: string
|
|
6
|
+
from: { type?: string; required?: boolean; enum?: unknown[]; raw: unknown }
|
|
7
|
+
to: { type?: string; required?: boolean; enum?: unknown[]; raw: unknown }
|
|
8
|
+
}>
|
|
9
|
+
breaking: boolean
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
type Schema = Record<string, unknown> & {
|
|
13
|
+
type?: string
|
|
14
|
+
properties?: Record<string, Schema>
|
|
15
|
+
required?: string[]
|
|
16
|
+
enum?: unknown[]
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function typeSummary(s: Schema | undefined): string {
|
|
20
|
+
if (!s) return 'unknown'
|
|
21
|
+
if (typeof s.type === 'string') return s.type
|
|
22
|
+
return 'unknown'
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function isRequired(parent: Schema, key: string): boolean {
|
|
26
|
+
return Array.isArray(parent.required) && parent.required.includes(key)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function walk(
|
|
30
|
+
fromParent: Schema, toParent: Schema, basePath: string,
|
|
31
|
+
added: ContractDiff['added_fields'], removed: ContractDiff['removed_fields'], changed: ContractDiff['changed_fields']
|
|
32
|
+
): void {
|
|
33
|
+
const fp = fromParent.properties ?? {}
|
|
34
|
+
const tp = toParent.properties ?? {}
|
|
35
|
+
const keys = new Set<string>([...Object.keys(fp), ...Object.keys(tp)])
|
|
36
|
+
for (const key of keys) {
|
|
37
|
+
const path = `${basePath}/properties/${key}`
|
|
38
|
+
const fromChild = fp[key]
|
|
39
|
+
const toChild = tp[key]
|
|
40
|
+
if (fromChild && !toChild) {
|
|
41
|
+
removed.push({ path, type_summary: typeSummary(fromChild) })
|
|
42
|
+
continue
|
|
43
|
+
}
|
|
44
|
+
if (!fromChild && toChild) {
|
|
45
|
+
added.push({ path, type_summary: typeSummary(toChild) })
|
|
46
|
+
continue
|
|
47
|
+
}
|
|
48
|
+
if (fromChild && toChild) {
|
|
49
|
+
const fromType = typeof fromChild.type === 'string' ? fromChild.type : undefined
|
|
50
|
+
const toType = typeof toChild.type === 'string' ? toChild.type : undefined
|
|
51
|
+
const fromReq = isRequired(fromParent, key)
|
|
52
|
+
const toReq = isRequired(toParent, key)
|
|
53
|
+
const typeDiff = fromType !== toType
|
|
54
|
+
const reqDiff = fromReq !== toReq
|
|
55
|
+
if (typeDiff || reqDiff) {
|
|
56
|
+
changed.push({
|
|
57
|
+
path,
|
|
58
|
+
from: { type: fromType, required: fromReq, enum: fromChild.enum, raw: fromChild },
|
|
59
|
+
to: { type: toType, required: toReq, enum: toChild.enum, raw: toChild }
|
|
60
|
+
})
|
|
61
|
+
}
|
|
62
|
+
if (toChild.type === 'object' || fromChild.type === 'object') {
|
|
63
|
+
walk(fromChild, toChild, path, added, removed, changed)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function diffSchema(from: Schema, to: Schema): ContractDiff {
|
|
70
|
+
const added: ContractDiff['added_fields'] = []
|
|
71
|
+
const removed: ContractDiff['removed_fields'] = []
|
|
72
|
+
const changed: ContractDiff['changed_fields'] = []
|
|
73
|
+
walk(from, to, '', added, removed, changed)
|
|
74
|
+
const breaking =
|
|
75
|
+
removed.length > 0 ||
|
|
76
|
+
changed.some(c => c.from.required === false && c.to.required === true) ||
|
|
77
|
+
changed.some(c => !!c.from.type && !!c.to.type && c.from.type !== c.to.type)
|
|
78
|
+
return { added_fields: added, removed_fields: removed, changed_fields: changed, breaking }
|
|
79
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { AgentListRow } from '../storage/agents-repo.js'
|
|
2
|
+
import type { DeliverySpec } from '../lib/delivery-spec.js'
|
|
3
|
+
|
|
4
|
+
export type PublicDelivery =
|
|
5
|
+
| { kind: 'none' }
|
|
6
|
+
| { kind: 'codex-appserver' }
|
|
7
|
+
| { kind: 'claude-channel'; channel_session_id: string }
|
|
8
|
+
|
|
9
|
+
export interface PublicAgentListRow {
|
|
10
|
+
agent_id: string
|
|
11
|
+
client: AgentListRow['client']
|
|
12
|
+
client_name: AgentListRow['client_name']
|
|
13
|
+
team: string
|
|
14
|
+
role: string
|
|
15
|
+
name: string
|
|
16
|
+
model: string | null
|
|
17
|
+
tmux_pane_id: string | null
|
|
18
|
+
delivery: PublicDelivery
|
|
19
|
+
channel_session_id: string | null
|
|
20
|
+
last_seen_at: string
|
|
21
|
+
online: boolean
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function projectDelivery(delivery: DeliverySpec): PublicDelivery {
|
|
25
|
+
if (delivery.kind === 'claude-channel') {
|
|
26
|
+
return {
|
|
27
|
+
kind: 'claude-channel',
|
|
28
|
+
channel_session_id: delivery.channel_session_id,
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return { kind: delivery.kind }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function toPublicAgentRow(row: AgentListRow): PublicAgentListRow {
|
|
35
|
+
return {
|
|
36
|
+
agent_id: row.agent_id,
|
|
37
|
+
client: row.client,
|
|
38
|
+
client_name: row.client_name,
|
|
39
|
+
team: row.team,
|
|
40
|
+
role: row.role,
|
|
41
|
+
name: row.name,
|
|
42
|
+
model: row.model,
|
|
43
|
+
tmux_pane_id: row.tmux_pane_id,
|
|
44
|
+
delivery: projectDelivery(row.delivery),
|
|
45
|
+
channel_session_id:
|
|
46
|
+
row.delivery.kind === 'claude-channel'
|
|
47
|
+
? row.delivery.channel_session_id
|
|
48
|
+
: null,
|
|
49
|
+
last_seen_at: row.last_seen_at,
|
|
50
|
+
online: row.online,
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import type Database from 'better-sqlite3'
|
|
2
|
+
import type { ChannelWakeFanout } from '../daemon/channel-wake-fanout.js'
|
|
3
|
+
import { CHANNEL_PROXY_ROLE } from './subscribe-channel-wake.js'
|
|
4
|
+
|
|
5
|
+
const LIVE_WINDOW_MS = 5 * 60 * 1000
|
|
6
|
+
|
|
7
|
+
export interface AutoBindInput {
|
|
8
|
+
callerAgentId: string
|
|
9
|
+
ui_pid: number
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface LookupInput {
|
|
13
|
+
ui_pid: number
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface AutoBindSuccess {
|
|
17
|
+
ok: true
|
|
18
|
+
channel_session_id: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface AutoBindMiss {
|
|
22
|
+
ok: false
|
|
23
|
+
reason: 'no_proxy_row' | 'proxy_payload_corrupt' | 'sink_not_live'
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type AutoBindResult = AutoBindSuccess | AutoBindMiss
|
|
27
|
+
|
|
28
|
+
export interface LookupSuccess {
|
|
29
|
+
ok: true
|
|
30
|
+
channel_session_id: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface LookupMiss {
|
|
34
|
+
ok: false
|
|
35
|
+
reason: 'no_proxy_row' | 'proxy_payload_corrupt'
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export type LookupResult = LookupSuccess | LookupMiss
|
|
39
|
+
|
|
40
|
+
interface ProxyRow {
|
|
41
|
+
delivery_payload: string | null
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Best-effort: match a live __channel_proxy__ row keyed on claude_ui_pid, and
|
|
46
|
+
* write the caller's delivery to that proxy's csid. Failure returns `ok:false`
|
|
47
|
+
* with a reason — the caller treats this as "no auto-bind performed" and leaves
|
|
48
|
+
* existing delivery unchanged.
|
|
49
|
+
*/
|
|
50
|
+
export class AutoBindChannelService {
|
|
51
|
+
constructor(
|
|
52
|
+
private readonly db: Database.Database,
|
|
53
|
+
private readonly fanout: ChannelWakeFanout
|
|
54
|
+
) {}
|
|
55
|
+
|
|
56
|
+
lookup(input: LookupInput): LookupResult {
|
|
57
|
+
return this.findLiveProxyCsid(input)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
run(input: AutoBindInput): AutoBindResult {
|
|
61
|
+
const found = this.findLiveProxyCsid({ ui_pid: input.ui_pid })
|
|
62
|
+
if (!found.ok) return found
|
|
63
|
+
const csid = found.channel_session_id
|
|
64
|
+
if (!this.fanout.has(csid)) return { ok: false, reason: 'sink_not_live' }
|
|
65
|
+
this.db
|
|
66
|
+
.prepare(
|
|
67
|
+
`UPDATE agents
|
|
68
|
+
SET delivery_kind = 'claude-channel',
|
|
69
|
+
delivery_payload = json_object('channel_session_id', ?)
|
|
70
|
+
WHERE agent_id = ?`
|
|
71
|
+
)
|
|
72
|
+
.run(csid, input.callerAgentId)
|
|
73
|
+
return { ok: true, channel_session_id: csid }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
private findLiveProxyCsid(input: LookupInput): LookupResult {
|
|
77
|
+
const cutoff = new Date(Date.now() - LIVE_WINDOW_MS).toISOString()
|
|
78
|
+
const row = this.db
|
|
79
|
+
.prepare(
|
|
80
|
+
`SELECT delivery_payload
|
|
81
|
+
FROM agents
|
|
82
|
+
WHERE role = ?
|
|
83
|
+
AND claude_ui_pid = ?
|
|
84
|
+
AND last_seen_at > ?
|
|
85
|
+
ORDER BY last_seen_at DESC
|
|
86
|
+
LIMIT 1`
|
|
87
|
+
)
|
|
88
|
+
.get(CHANNEL_PROXY_ROLE, input.ui_pid, cutoff) as ProxyRow | undefined
|
|
89
|
+
if (!row) return { ok: false, reason: 'no_proxy_row' }
|
|
90
|
+
const csid = extractCsid(row.delivery_payload)
|
|
91
|
+
if (!csid) return { ok: false, reason: 'proxy_payload_corrupt' }
|
|
92
|
+
return { ok: true, channel_session_id: csid }
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function extractCsid(payload: string | null): string | null {
|
|
97
|
+
if (payload === null) return null
|
|
98
|
+
try {
|
|
99
|
+
const parsed = JSON.parse(payload) as Record<string, unknown>
|
|
100
|
+
const csid = parsed.channel_session_id
|
|
101
|
+
if (typeof csid !== 'string' || csid.length === 0) return null
|
|
102
|
+
return csid
|
|
103
|
+
} catch {
|
|
104
|
+
return null
|
|
105
|
+
}
|
|
106
|
+
}
|