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,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
|
+
}
|
package/src/mcp/poke.ts
ADDED
|
@@ -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
|
+
}
|