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