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,57 @@
1
+ import type Database from 'better-sqlite3'
2
+ import type { AgentsRepo } from '../storage/agents-repo.js'
3
+
4
+ export interface InboxMessage {
5
+ id: string
6
+ event_id: number
7
+ from_team: string
8
+ to_team: string
9
+ from_agent_id: string
10
+ from_role: string | null
11
+ to_agent_id: string | null
12
+ to_role: string | null
13
+ subject: string | null
14
+ body: string
15
+ need_reply: boolean
16
+ sent_at: string
17
+ }
18
+
19
+ export interface InboxResult {
20
+ messages: InboxMessage[]
21
+ has_more: boolean
22
+ last_event_id: number
23
+ }
24
+
25
+ export class GetInboxService {
26
+ constructor(private db: Database.Database, private agents: AgentsRepo) {}
27
+
28
+ get(args: { caller: string; since_event_id?: number; limit?: number }): InboxResult {
29
+ const caller = this.agents.findById(args.caller)
30
+ if (!caller) return { messages: [], has_more: false, last_event_id: args.since_event_id ?? 0 }
31
+ const callerTeam = caller.team
32
+ const callerRole = this.db.prepare('SELECT role FROM agents WHERE agent_id=?')
33
+ .get(args.caller) as { role: string } | undefined
34
+ const limit = Math.min(args.limit ?? 50, 200)
35
+ const since = args.since_event_id ?? 0
36
+ const rows = this.db.prepare(
37
+ `SELECT m.id, m.event_id, m.from_team, m.to_team, m.from_agent_id, m.to_agent_id, m.to_role, m.subject, m.body, m.need_reply, m.sent_at,
38
+ a.role as from_role
39
+ FROM messages m
40
+ LEFT JOIN agents a ON a.agent_id = m.from_agent_id
41
+ WHERE m.to_team = ?
42
+ AND m.event_id > ?
43
+ AND ( m.to_agent_id = ? OR (m.to_role IS NOT NULL AND m.to_role = ?) )
44
+ ORDER BY m.event_id ASC
45
+ LIMIT ?`
46
+ ).all(callerTeam, since, args.caller, callerRole?.role ?? '__none__', limit + 1) as Array<
47
+ Omit<InboxMessage, 'need_reply'> & { need_reply: number }
48
+ >
49
+ const has_more = rows.length > limit
50
+ const trimmed = (has_more ? rows.slice(0, limit) : rows).map(row => ({
51
+ ...row,
52
+ need_reply: row.need_reply === 1,
53
+ }))
54
+ const last_event_id = trimmed.length > 0 ? trimmed[trimmed.length - 1].event_id : since
55
+ return { messages: trimmed, has_more, last_event_id }
56
+ }
57
+ }
@@ -0,0 +1,8 @@
1
+ export class IdentityMismatchError extends Error {
2
+ readonly code = 'identity_mismatch'
3
+ constructor() { super('identity_mismatch') }
4
+ }
5
+
6
+ export function ensureCallerMatches(sessionId: string, claimedAgentId: string | undefined): void {
7
+ if (claimedAgentId && claimedAgentId !== sessionId) throw new IdentityMismatchError()
8
+ }
@@ -0,0 +1,36 @@
1
+ import type Database from 'better-sqlite3'
2
+ import type { AgentsRepo } from '../storage/agents-repo.js'
3
+
4
+ export interface ContractEventOut {
5
+ event_id: number
6
+ contract_name: string
7
+ version: number
8
+ diff: unknown | null
9
+ registered_at: string
10
+ }
11
+
12
+ export class PendingContractEventsService {
13
+ constructor(private db: Database.Database, private agents: AgentsRepo) {}
14
+
15
+ poll(args: { caller: string; since_event_id?: number; limit?: number }): {
16
+ events: ContractEventOut[]; has_more: boolean; last_event_id: number
17
+ } {
18
+ const caller = this.agents.findById(args.caller)
19
+ if (!caller) return { events: [], has_more: false, last_event_id: args.since_event_id ?? 0 }
20
+ const limit = Math.min(args.limit ?? 100, 500)
21
+ const since = args.since_event_id ?? 0
22
+ const rows = this.db.prepare(
23
+ `SELECT event_id, payload, created_at FROM events
24
+ WHERE to_team=? AND event_type='contract_registered' AND event_id > ?
25
+ ORDER BY event_id ASC LIMIT ?`
26
+ ).all(caller.team, since, limit + 1) as Array<{ event_id: number; payload: string; created_at: string }>
27
+ const has_more = rows.length > limit
28
+ const trimmed = has_more ? rows.slice(0, limit) : rows
29
+ const events = trimmed.map(r => {
30
+ const p = JSON.parse(r.payload) as { name: string; version: number; diff: unknown | null }
31
+ return { event_id: r.event_id, contract_name: p.name, version: p.version, diff: p.diff, registered_at: r.created_at }
32
+ })
33
+ const last_event_id = trimmed.length > 0 ? trimmed[trimmed.length - 1].event_id : since
34
+ return { events, has_more, last_event_id }
35
+ }
36
+ }
@@ -0,0 +1,32 @@
1
+ import { capturePaneTail as _capture } from '../daemon/tmux-cli.js'
2
+
3
+ const DEFAULT_QUIET_MS = 2000
4
+ const GUARD_TAIL_LINES = 8
5
+
6
+ type CaptureFn = (paneId: string, lines?: number) => Promise<string>
7
+
8
+ let _captureImpl: CaptureFn = _capture
9
+
10
+ export function __setCapturePaneTail(fn: CaptureFn): void {
11
+ _captureImpl = fn
12
+ }
13
+
14
+ export function __resetCapturePaneTail(): void {
15
+ _captureImpl = _capture
16
+ }
17
+
18
+ export function resolveQuietMs(opt?: number): number {
19
+ if (typeof opt === 'number' && Number.isInteger(opt) && opt > 0) return opt
20
+ const raw = process.env.POKE_QUIET_MS
21
+ if (raw === undefined) return DEFAULT_QUIET_MS
22
+ const n = Number(raw)
23
+ return Number.isInteger(n) && n > 0 ? n : DEFAULT_QUIET_MS
24
+ }
25
+
26
+ export async function runQuietGuard(paneId: string, quietMs?: number): Promise<'pass' | 'fail'> {
27
+ const ms = resolveQuietMs(quietMs)
28
+ const before = await _captureImpl(paneId, GUARD_TAIL_LINES)
29
+ await new Promise(r => setTimeout(r, ms))
30
+ const after = await _captureImpl(paneId, GUARD_TAIL_LINES)
31
+ return before === after ? 'pass' : 'fail'
32
+ }
@@ -0,0 +1,159 @@
1
+ export const RETRY_DELAYS_MS = [30_000, 180_000, 600_000] as const
2
+ export const RETRY_DELAYS_S = [30, 180, 600] as const
3
+
4
+ export interface RetryPokeArgs {
5
+ team: string
6
+ fromAgentId: string
7
+ targetAgentId: string
8
+ paneId: string
9
+ body: string
10
+ }
11
+
12
+ export interface RetryAgentLookup {
13
+ agent_id: string
14
+ tmux_pane_id: string | null
15
+ last_seen_at: string
16
+ }
17
+
18
+ export interface RetryContext {
19
+ agentId: string
20
+ messageId: string
21
+ fromAgentId: string
22
+ body: string
23
+ team: string
24
+ sentAt: string
25
+ paneId: string
26
+ paneGuardFn: (paneId: string) => Promise<'pass' | 'fail'>
27
+ pokeFn: (args: RetryPokeArgs) => Promise<void>
28
+ lookupAgentFn: (agentId: string) => RetryAgentLookup | undefined
29
+ updateStatusFn?: (args: {
30
+ agentId: string
31
+ wake_status: 'delivered' | 'retrying' | 'skipped' | 'failed'
32
+ skip_reason?: 'guard_failed' | 'no_pane' | 'recipient_active' | 'retry_exhausted' | null
33
+ retry_attempts?: number
34
+ delivered_at?: string | null
35
+ }) => void
36
+ }
37
+
38
+ interface RetryEntry {
39
+ timer?: ReturnType<typeof setTimeout>
40
+ attempt: number
41
+ ctx: RetryContext
42
+ }
43
+
44
+ const retryMap = new Map<string, RetryEntry>()
45
+
46
+ function keyOf(ctx: RetryContext): string {
47
+ return `${ctx.messageId}:${ctx.agentId}`
48
+ }
49
+
50
+ // Retry tick resolves the recipient via ctx.lookupAgentFn (caller-provided), which is team-agnostic; cross-team retries are supported.
51
+ export function scheduleRetry(ctx: RetryContext): void {
52
+ const key = keyOf(ctx)
53
+ cancelRetry(key)
54
+ retryMap.set(key, { attempt: 0, ctx })
55
+ enqueueNext(key)
56
+ }
57
+
58
+ function enqueueNext(key: string): void {
59
+ const entry = retryMap.get(key)
60
+ if (!entry) return
61
+ if (entry.attempt >= RETRY_DELAYS_MS.length) {
62
+ retryMap.delete(key)
63
+ return
64
+ }
65
+ const delay = RETRY_DELAYS_MS[entry.attempt]
66
+ entry.timer = setTimeout(() => { void tick(key) }, delay)
67
+ }
68
+
69
+ async function tick(key: string): Promise<void> {
70
+ const entry = retryMap.get(key)
71
+ if (!entry) return
72
+ const { ctx } = entry
73
+ try {
74
+ const agent = ctx.lookupAgentFn(ctx.agentId)
75
+ if (!agent || !agent.tmux_pane_id) {
76
+ ctx.updateStatusFn?.({
77
+ agentId: ctx.agentId,
78
+ wake_status: 'failed',
79
+ skip_reason: 'no_pane',
80
+ retry_attempts: entry.attempt,
81
+ })
82
+ retryMap.delete(key)
83
+ return
84
+ }
85
+ if (new Date(agent.last_seen_at).getTime() > new Date(ctx.sentAt).getTime()) {
86
+ ctx.updateStatusFn?.({
87
+ agentId: ctx.agentId,
88
+ wake_status: 'skipped',
89
+ skip_reason: 'recipient_active',
90
+ retry_attempts: entry.attempt,
91
+ })
92
+ retryMap.delete(key)
93
+ return
94
+ }
95
+ const guard = await ctx.paneGuardFn(agent.tmux_pane_id)
96
+ if (guard === 'pass') {
97
+ await ctx.pokeFn({
98
+ team: ctx.team,
99
+ fromAgentId: ctx.fromAgentId,
100
+ targetAgentId: ctx.agentId,
101
+ paneId: agent.tmux_pane_id,
102
+ body: ctx.body
103
+ })
104
+ ctx.updateStatusFn?.({
105
+ agentId: ctx.agentId,
106
+ wake_status: 'delivered',
107
+ skip_reason: null,
108
+ retry_attempts: entry.attempt + 1,
109
+ delivered_at: new Date().toISOString(),
110
+ })
111
+ retryMap.delete(key)
112
+ return
113
+ }
114
+ entry.attempt += 1
115
+ if (entry.attempt >= RETRY_DELAYS_MS.length) {
116
+ ctx.updateStatusFn?.({
117
+ agentId: ctx.agentId,
118
+ wake_status: 'failed',
119
+ skip_reason: 'retry_exhausted',
120
+ retry_attempts: entry.attempt,
121
+ })
122
+ retryMap.delete(key)
123
+ return
124
+ }
125
+ ctx.updateStatusFn?.({
126
+ agentId: ctx.agentId,
127
+ wake_status: 'retrying',
128
+ skip_reason: 'guard_failed',
129
+ retry_attempts: entry.attempt,
130
+ })
131
+ enqueueNext(key)
132
+ } catch {
133
+ ctx.updateStatusFn?.({
134
+ agentId: ctx.agentId,
135
+ wake_status: 'failed',
136
+ skip_reason: 'retry_exhausted',
137
+ retry_attempts: entry.attempt,
138
+ })
139
+ retryMap.delete(key)
140
+ }
141
+ }
142
+
143
+ export function cancelRetry(key: string): void {
144
+ const entry = retryMap.get(key)
145
+ if (!entry) return
146
+ if (entry.timer) clearTimeout(entry.timer)
147
+ retryMap.delete(key)
148
+ }
149
+
150
+ export function clearAllRetries(): void {
151
+ for (const [, v] of retryMap) if (v.timer) clearTimeout(v.timer)
152
+ retryMap.clear()
153
+ }
154
+
155
+ export function __peekRetryMap(): Map<string, { attempt: number; ctx: RetryContext }> {
156
+ const view = new Map<string, { attempt: number; ctx: RetryContext }>()
157
+ for (const [k, v] of retryMap) view.set(k, { attempt: v.attempt, ctx: v.ctx })
158
+ return view
159
+ }
@@ -0,0 +1,190 @@
1
+ import type Database from 'better-sqlite3'
2
+ import { randomBytes } from 'node:crypto'
3
+ import { parseDeliveryRow, type DeliverySpec } from '../lib/delivery-spec.js'
4
+ import {
5
+ isTmuxAvailable,
6
+ capturePaneTail,
7
+ loadBuffer,
8
+ pasteBuffer,
9
+ sendEnter
10
+ } from '../daemon/tmux-cli.js'
11
+ import type { ChannelWakeFanout } from '../daemon/channel-wake-fanout.js'
12
+ import { dispatchPoke, type TmuxPokeResult } from './transport-dispatch.js'
13
+
14
+ // allowCrossTeam is for internal auto-poke callers only; MCP tool entry MUST NOT pass it.
15
+ export interface PokeDeps {
16
+ db: Database.Database
17
+ callerAgentId: string | null
18
+ allowCrossTeam?: boolean
19
+ channelWakeFanout?: ChannelWakeFanout
20
+ }
21
+
22
+ export interface PokeInput {
23
+ target_agent_id: string
24
+ prompt: string
25
+ }
26
+
27
+ export type PokeResult =
28
+ | {
29
+ ok: true
30
+ transport_used: 'claude-channel'
31
+ channel_session_id: string
32
+ }
33
+ | {
34
+ ok: true
35
+ transport_used: 'tmux-poke'
36
+ pane_id: string
37
+ pane_tail_before: string
38
+ pane_tail_after: string
39
+ }
40
+ | {
41
+ ok: true
42
+ transport_used: 'codex-appserver'
43
+ thread_id: string
44
+ }
45
+ | {
46
+ error: string
47
+ detail?: unknown
48
+ transport_used?: 'tmux-poke' | 'codex-appserver'
49
+ }
50
+
51
+ interface TargetRow {
52
+ agent_id: string
53
+ client: import('../lib/client-kind.js').ClientKind | null
54
+ team: string
55
+ tmux_pane_id: string | null
56
+ delivery_kind: string
57
+ delivery_payload: string | null
58
+ }
59
+
60
+ export const PROMPT_MAX_BYTES = 8192
61
+ export const PASTE_SETTLE_MS = 400
62
+ export const TAIL_LINES = 8
63
+
64
+ type TmuxStage = 'capture_before' | 'load_buffer' | 'paste_buffer' | 'send_keys' | 'capture_after'
65
+
66
+ interface StageError {
67
+ stage: TmuxStage
68
+ cause: unknown
69
+ }
70
+
71
+ function delay(ms: number): Promise<void> {
72
+ return new Promise(resolve => setTimeout(resolve, ms))
73
+ }
74
+
75
+ function errorMessage(cause: unknown): string {
76
+ if (cause && typeof cause === 'object') {
77
+ const err = cause as { stderr?: string | Buffer; message?: string }
78
+ if (err.stderr) {
79
+ const s = typeof err.stderr === 'string' ? err.stderr : err.stderr.toString('utf8')
80
+ if (s.length > 0) return s
81
+ }
82
+ if (err.message) return err.message
83
+ }
84
+ return String(cause)
85
+ }
86
+
87
+ export function classifyTmuxError(err: StageError): { error: string; detail: unknown } {
88
+ const msg = errorMessage(err.cause)
89
+ const lower = msg.toLowerCase()
90
+ if (lower.includes("can't find pane") || lower.includes('pane not found') || lower.includes('no such pane')) {
91
+ return { error: 'pane_dead', detail: msg }
92
+ }
93
+ return { error: 'tmux_cmd_failed', detail: { stage: err.stage, stderr: msg } }
94
+ }
95
+
96
+ async function runStage<T>(stage: TmuxStage, fn: () => Promise<T>): Promise<T> {
97
+ try {
98
+ return await fn()
99
+ } catch (cause) {
100
+ throw { stage, cause } as StageError
101
+ }
102
+ }
103
+
104
+ async function tmuxPokeImpl(args: { pane_id: string; content: string }): Promise<TmuxPokeResult> {
105
+ if (!(await isTmuxAvailable())) {
106
+ return { error: 'tmux_unavailable', detail: 'tmux binary not available on PATH' }
107
+ }
108
+ const bufName = `poke-${randomBytes(3).toString('hex')}`
109
+ try {
110
+ const pane_tail_before = await runStage('capture_before', () => capturePaneTail(args.pane_id, TAIL_LINES))
111
+ await runStage('load_buffer', () => loadBuffer(bufName, args.content))
112
+ await runStage('paste_buffer', () => pasteBuffer(bufName, args.pane_id))
113
+ await delay(PASTE_SETTLE_MS)
114
+ await runStage('send_keys', () => sendEnter(args.pane_id))
115
+ await delay(PASTE_SETTLE_MS)
116
+ const pane_tail_after = await runStage('capture_after', () => capturePaneTail(args.pane_id, TAIL_LINES))
117
+ return { ok: true, pane_tail_before, pane_tail_after }
118
+ } catch (e) {
119
+ return classifyTmuxError(e as StageError)
120
+ }
121
+ }
122
+
123
+ export async function poke(deps: PokeDeps, input: PokeInput): Promise<PokeResult> {
124
+ if (!deps.callerAgentId) return { error: 'unknown_agent' }
125
+
126
+ const promptLen = Buffer.byteLength(input.prompt, 'utf8')
127
+ if (promptLen > PROMPT_MAX_BYTES) {
128
+ return { error: 'prompt_too_long', detail: { max: PROMPT_MAX_BYTES, got: promptLen } }
129
+ }
130
+
131
+ const target = deps.db
132
+ .prepare(
133
+ `SELECT
134
+ agent_id,
135
+ client,
136
+ team,
137
+ tmux_pane_id,
138
+ delivery_kind,
139
+ delivery_payload
140
+ FROM agents
141
+ WHERE agent_id = ?`
142
+ )
143
+ .get(input.target_agent_id) as TargetRow | undefined
144
+ if (!target) return { error: 'unknown_target' }
145
+
146
+ if (target.agent_id === deps.callerAgentId) return { error: 'self_poke_denied' }
147
+
148
+ const callerRow = deps.db
149
+ .prepare(`SELECT team FROM agents WHERE agent_id = ?`)
150
+ .get(deps.callerAgentId) as { team: string } | undefined
151
+ if (!callerRow) return { error: 'unknown_agent' }
152
+ if (callerRow.team !== target.team && !deps.allowCrossTeam) {
153
+ return { error: 'cross_team_denied' }
154
+ }
155
+
156
+ // Legacy callers may not have ChannelWakeFanout. Keep the historical tmux-only
157
+ // fallback for plain targets, but still allow non-tmux transports that are
158
+ // fully described by the target row itself.
159
+ const fanout = deps.channelWakeFanout
160
+ const delivery = parseDeliveryRow(target) as DeliverySpec
161
+ if (!fanout) {
162
+ if (delivery.kind === 'codex-appserver') {
163
+ return dispatchPoke(
164
+ { tmuxPoke: tmuxPokeImpl },
165
+ { client: target.client, delivery, tmux_pane_id: target.tmux_pane_id },
166
+ { content: input.prompt, meta: {} }
167
+ )
168
+ }
169
+
170
+ // Legacy tmux-only path preserved when no fanout supplied by caller.
171
+ if (!target.tmux_pane_id) return { error: 'tmux_pane_not_set' }
172
+ const tr = await tmuxPokeImpl({ pane_id: target.tmux_pane_id, content: input.prompt })
173
+ if ('ok' in tr && tr.ok) {
174
+ return {
175
+ ok: true,
176
+ transport_used: 'tmux-poke',
177
+ pane_id: target.tmux_pane_id,
178
+ pane_tail_before: tr.pane_tail_before,
179
+ pane_tail_after: tr.pane_tail_after
180
+ }
181
+ }
182
+ return { ...(tr as { error: string; detail?: unknown }), transport_used: 'tmux-poke' }
183
+ }
184
+
185
+ return dispatchPoke(
186
+ { channelWakeFanout: fanout, tmuxPoke: tmuxPokeImpl },
187
+ { client: target.client, delivery, tmux_pane_id: target.tmux_pane_id },
188
+ { content: input.prompt, meta: {} }
189
+ )
190
+ }
@@ -0,0 +1,65 @@
1
+ import { z } from 'zod'
2
+ import type { CodexPanePreRegRepo } from './codex-pane-pre-register-repo.js'
3
+
4
+ export const preRegisterCodexPaneInputSchema = z
5
+ .object({
6
+ pane_id: z
7
+ .string()
8
+ .min(1)
9
+ .refine(v => v.startsWith('%'), {
10
+ message: 'pane_id must be a tmux pane id starting with "%"',
11
+ }),
12
+ xats_agent_id: z.string().min(1),
13
+ ttl_seconds: z.number().int().positive().optional(),
14
+ })
15
+ .strict()
16
+
17
+ export type PreRegisterCodexPaneInput = z.infer<typeof preRegisterCodexPaneInputSchema>
18
+
19
+ export type PreRegisterCodexPaneResult =
20
+ | { ok: true; expires_at: string }
21
+ | { error: 'invalid_arguments'; detail: string }
22
+
23
+ const DEFAULT_TTL_SECONDS = 120
24
+ const MIN_TTL_SECONDS = 1
25
+ const MAX_TTL_SECONDS = 600
26
+
27
+ function clampTtl(ttl: number | undefined): number {
28
+ const raw = ttl ?? DEFAULT_TTL_SECONDS
29
+ if (raw < MIN_TTL_SECONDS) return MIN_TTL_SECONDS
30
+ if (raw > MAX_TTL_SECONDS) return MAX_TTL_SECONDS
31
+ return raw
32
+ }
33
+
34
+ export class PreRegisterCodexPaneService {
35
+ constructor(
36
+ private readonly repo: CodexPanePreRegRepo,
37
+ private readonly now: () => Date = () => new Date()
38
+ ) {}
39
+
40
+ register(args: unknown): PreRegisterCodexPaneResult {
41
+ const parsed = preRegisterCodexPaneInputSchema.safeParse(args)
42
+ if (!parsed.success) {
43
+ return {
44
+ error: 'invalid_arguments',
45
+ detail: parsed.error.issues
46
+ .map(issue => {
47
+ const path = issue.path.join('.')
48
+ return path ? `${path}: ${issue.message}` : issue.message
49
+ })
50
+ .join('; '),
51
+ }
52
+ }
53
+
54
+ const now = this.now()
55
+ const ttl = clampTtl(parsed.data.ttl_seconds)
56
+ const expires_at = new Date(now.getTime() + ttl * 1000).toISOString()
57
+ this.repo.deleteExpired(now.toISOString())
58
+ this.repo.upsert({
59
+ pane_id: parsed.data.pane_id,
60
+ xats_agent_id: parsed.data.xats_agent_id,
61
+ expires_at,
62
+ })
63
+ return { ok: true, expires_at }
64
+ }
65
+ }
@@ -0,0 +1,84 @@
1
+ import type Database from 'better-sqlite3'
2
+ import {
3
+ validateDeliveryForWrite,
4
+ type DeliveryValidationReason,
5
+ } from '../lib/delivery-spec.js'
6
+ import type { ClientKind } from '../lib/client-kind.js'
7
+ import { deriveDefaultTeam } from '../lib/default-team.js'
8
+ import { AgentsRepo } from '../storage/agents-repo.js'
9
+
10
+ export { deriveDefaultTeam } from '../lib/default-team.js'
11
+
12
+ export interface RegisterInput {
13
+ connection_id: string
14
+ client?: ClientKind
15
+ client_name?: string
16
+ model: string
17
+ name: string
18
+ role?: string
19
+ team?: string
20
+ project_dir?: string
21
+ tmux_pane_id?: string
22
+ delivery?: unknown
23
+ claude_ui_pid?: number
24
+ runtime_ui_pid?: number
25
+ }
26
+
27
+ export type RegisterResult =
28
+ | { agent_id: string; team: string }
29
+ | { error: 'agent_id_collision' }
30
+ | { error: 'invalid_delivery'; reason: DeliveryValidationReason }
31
+ | { error: 'claude_ui_pid_requires_channel_proxy' }
32
+
33
+ function identityKey(team: string, name: string): string {
34
+ return `${team}\u0000${name}`
35
+ }
36
+
37
+ export class RegisterAgentService {
38
+ private readonly repo: AgentsRepo
39
+ private readonly connections = new Map<string, string>()
40
+
41
+ constructor(db: Database.Database) { this.repo = new AgentsRepo(db) }
42
+
43
+ register(input: RegisterInput): RegisterResult {
44
+ const validated =
45
+ input.delivery === undefined
46
+ ? undefined
47
+ : validateDeliveryForWrite(input.delivery)
48
+ if (validated && 'error' in validated) return validated
49
+
50
+ const role = input.role ?? 'default'
51
+ if (input.claude_ui_pid !== undefined && role !== '__channel_proxy__') {
52
+ return { error: 'claude_ui_pid_requires_channel_proxy' }
53
+ }
54
+
55
+ const team = deriveDefaultTeam({
56
+ team: input.team,
57
+ project_dir: input.project_dir,
58
+ })
59
+ const key = identityKey(team, input.name)
60
+ const bound = this.connections.get(key)
61
+ if (bound && bound !== input.connection_id) return { error: 'agent_id_collision' }
62
+ this.connections.set(key, input.connection_id)
63
+ return this.repo.register({
64
+ client: input.client,
65
+ client_name: input.client_name,
66
+ model: input.model,
67
+ name: input.name,
68
+ role,
69
+ team,
70
+ tmux_pane_id: input.tmux_pane_id,
71
+ delivery: validated?.ok,
72
+ claude_ui_pid: input.claude_ui_pid,
73
+ runtime_ui_pid: input.runtime_ui_pid,
74
+ })
75
+ }
76
+
77
+ releaseConnection(agent_id: string, connection_id: string): void {
78
+ // Release by connection_id — scan and unbind any identity key mapped to this connection.
79
+ for (const [k, cid] of this.connections) {
80
+ if (cid === connection_id) this.connections.delete(k)
81
+ }
82
+ void agent_id
83
+ }
84
+ }