refactor: migrate all 7 bridge clusters to BackendAdapter (WIP)
70+ files changed, net -688 lines. Bridge files being replaced with BackendAdapter calls. Clusters 2-8 in progress: theme, groups/workspace, agent, PTY/terminal, files, orchestration, infrastructure.
This commit is contained in:
parent
579157f6da
commit
105107dd84
72 changed files with 1835 additions and 2523 deletions
|
|
@ -1,9 +1,14 @@
|
|||
// BackendAdapter — abstraction layer for Tauri and Electrobun backends
|
||||
|
||||
import type { AgentStartOptions, AgentMessage, AgentStatus } from './agent';
|
||||
import type { FileEntry, FileContent, PtyCreateOptions } from './protocol';
|
||||
import type { AgentStartOptions, AgentMessage, AgentStatus, ProviderId } from './agent';
|
||||
import type { FileEntry, FileContent, PtyCreateOptions, SearchResult } from './protocol';
|
||||
import type { SettingsMap } from './settings';
|
||||
import type { GroupsFile } from './project';
|
||||
import type { AgentId, GroupId, SessionId, ProjectId } from './ids';
|
||||
import type {
|
||||
BtmsgAgent, BtmsgMessage, BtmsgChannel, BtmsgChannelMessage, DeadLetterEntry, AuditEntry,
|
||||
} from './btmsg';
|
||||
import type { Task, TaskComment } from './bttask';
|
||||
|
||||
// ── Backend capabilities ─────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -31,9 +36,223 @@ export interface BackendCapabilities {
|
|||
/** Call to remove an event listener */
|
||||
export type UnsubscribeFn = () => void;
|
||||
|
||||
// ── Domain-specific sub-interfaces ──────────────────────────────────────────
|
||||
|
||||
export interface SessionPersistenceAdapter {
|
||||
listSessions(): Promise<PersistedSession[]>;
|
||||
saveSession(session: PersistedSession): Promise<void>;
|
||||
deleteSession(id: string): Promise<void>;
|
||||
updateSessionTitle(id: string, title: string): Promise<void>;
|
||||
touchSession(id: string): Promise<void>;
|
||||
updateSessionGroup(id: string, groupName: string): Promise<void>;
|
||||
saveLayout(layout: PersistedLayout): Promise<void>;
|
||||
loadLayout(): Promise<PersistedLayout>;
|
||||
}
|
||||
|
||||
export interface AgentPersistenceAdapter {
|
||||
saveAgentMessages(
|
||||
sessionId: SessionId, projectId: ProjectId, sdkSessionId: string | undefined,
|
||||
messages: AgentMessageRecord[],
|
||||
): Promise<void>;
|
||||
loadAgentMessages(projectId: ProjectId): Promise<AgentMessageRecord[]>;
|
||||
saveProjectAgentState(state: ProjectAgentState): Promise<void>;
|
||||
loadProjectAgentState(projectId: ProjectId): Promise<ProjectAgentState | null>;
|
||||
saveSessionMetric(metric: Omit<SessionMetricRecord, 'id'>): Promise<void>;
|
||||
loadSessionMetrics(projectId: ProjectId, limit?: number): Promise<SessionMetricRecord[]>;
|
||||
getCliGroup(): Promise<string | null>;
|
||||
discoverMarkdownFiles(cwd: string): Promise<MdFileEntry[]>;
|
||||
}
|
||||
|
||||
export interface BtmsgAdapter {
|
||||
btmsgGetAgents(groupId: GroupId): Promise<BtmsgAgent[]>;
|
||||
btmsgUnreadCount(agentId: AgentId): Promise<number>;
|
||||
btmsgUnreadMessages(agentId: AgentId): Promise<BtmsgMessage[]>;
|
||||
btmsgHistory(agentId: AgentId, otherId: AgentId, limit?: number): Promise<BtmsgMessage[]>;
|
||||
btmsgSend(fromAgent: AgentId, toAgent: AgentId, content: string): Promise<string>;
|
||||
btmsgSetStatus(agentId: AgentId, status: string): Promise<void>;
|
||||
btmsgEnsureAdmin(groupId: GroupId): Promise<void>;
|
||||
btmsgAllFeed(groupId: GroupId, limit?: number): Promise<BtmsgFeedMessage[]>;
|
||||
btmsgMarkRead(readerId: AgentId, senderId: AgentId): Promise<void>;
|
||||
btmsgGetChannels(groupId: GroupId): Promise<BtmsgChannel[]>;
|
||||
btmsgChannelMessages(channelId: string, limit?: number): Promise<BtmsgChannelMessage[]>;
|
||||
btmsgChannelSend(channelId: string, fromAgent: AgentId, content: string): Promise<string>;
|
||||
btmsgCreateChannel(name: string, groupId: GroupId, createdBy: AgentId): Promise<string>;
|
||||
btmsgAddChannelMember(channelId: string, agentId: AgentId): Promise<void>;
|
||||
btmsgRegisterAgents(config: GroupsFile): Promise<void>;
|
||||
btmsgUnseenMessages(agentId: AgentId, sessionId: string): Promise<BtmsgMessage[]>;
|
||||
btmsgMarkSeen(sessionId: string, messageIds: string[]): Promise<void>;
|
||||
btmsgPruneSeen(): Promise<number>;
|
||||
btmsgRecordHeartbeat(agentId: AgentId): Promise<void>;
|
||||
btmsgGetStaleAgents(groupId: GroupId, thresholdSecs?: number): Promise<string[]>;
|
||||
btmsgGetDeadLetters(groupId: GroupId, limit?: number): Promise<DeadLetterEntry[]>;
|
||||
btmsgClearDeadLetters(groupId: GroupId): Promise<void>;
|
||||
btmsgClearAllComms(groupId: GroupId): Promise<void>;
|
||||
}
|
||||
|
||||
export interface BttaskAdapter {
|
||||
bttaskList(groupId: GroupId): Promise<Task[]>;
|
||||
bttaskComments(taskId: string): Promise<TaskComment[]>;
|
||||
bttaskUpdateStatus(taskId: string, status: string, version: number): Promise<number>;
|
||||
bttaskAddComment(taskId: string, agentId: AgentId, content: string): Promise<string>;
|
||||
bttaskCreate(
|
||||
title: string, description: string, priority: string,
|
||||
groupId: GroupId, createdBy: AgentId, assignedTo?: AgentId,
|
||||
): Promise<string>;
|
||||
bttaskDelete(taskId: string): Promise<void>;
|
||||
bttaskReviewQueueCount(groupId: GroupId): Promise<number>;
|
||||
}
|
||||
|
||||
export interface AnchorsAdapter {
|
||||
saveSessionAnchors(anchors: SessionAnchorRecord[]): Promise<void>;
|
||||
loadSessionAnchors(projectId: string): Promise<SessionAnchorRecord[]>;
|
||||
deleteSessionAnchor(id: string): Promise<void>;
|
||||
clearProjectAnchors(projectId: string): Promise<void>;
|
||||
updateAnchorType(id: string, anchorType: string): Promise<void>;
|
||||
}
|
||||
|
||||
export interface SearchAdapter {
|
||||
searchInit(): Promise<void>;
|
||||
searchAll(query: string, limit?: number): Promise<SearchResult[]>;
|
||||
searchRebuild(): Promise<void>;
|
||||
searchIndexMessage(sessionId: string, role: string, content: string): Promise<void>;
|
||||
}
|
||||
|
||||
export interface AuditAdapter {
|
||||
logAuditEvent(agentId: AgentId, eventType: AuditEventType, detail: string): Promise<void>;
|
||||
getAuditLog(groupId: GroupId, limit?: number, offset?: number): Promise<AuditEntry[]>;
|
||||
getAuditLogForAgent(agentId: AgentId, limit?: number): Promise<AuditEntry[]>;
|
||||
}
|
||||
|
||||
export interface NotificationsAdapter {
|
||||
sendDesktopNotification(title: string, body: string, urgency?: NotificationUrgency): void;
|
||||
}
|
||||
|
||||
export interface TelemetryAdapter {
|
||||
telemetryLog(level: TelemetryLevel, message: string, context?: Record<string, unknown>): void;
|
||||
}
|
||||
|
||||
export interface SecretsAdapter {
|
||||
storeSecret(key: string, value: string): Promise<void>;
|
||||
getSecret(key: string): Promise<string | null>;
|
||||
deleteSecret(key: string): Promise<void>;
|
||||
listSecrets(): Promise<string[]>;
|
||||
hasKeyring(): Promise<boolean>;
|
||||
knownSecretKeys(): Promise<string[]>;
|
||||
}
|
||||
|
||||
export interface FsWatcherAdapter {
|
||||
fsWatchProject(projectId: string, cwd: string): Promise<void>;
|
||||
fsUnwatchProject(projectId: string): Promise<void>;
|
||||
onFsWriteDetected(callback: (event: FsWriteEvent) => void): UnsubscribeFn;
|
||||
fsWatcherStatus(): Promise<FsWatcherStatus>;
|
||||
}
|
||||
|
||||
export interface CtxAdapter {
|
||||
ctxInitDb(): Promise<void>;
|
||||
ctxRegisterProject(name: string, description: string, workDir?: string): Promise<void>;
|
||||
ctxGetContext(project: string): Promise<CtxEntry[]>;
|
||||
ctxGetShared(): Promise<CtxEntry[]>;
|
||||
ctxGetSummaries(project: string, limit?: number): Promise<CtxSummary[]>;
|
||||
ctxSearch(query: string): Promise<CtxEntry[]>;
|
||||
}
|
||||
|
||||
export interface MemoraAdapter {
|
||||
memoraAvailable(): Promise<boolean>;
|
||||
memoraList(options?: { tags?: string[]; limit?: number; offset?: number }): Promise<MemoraSearchResult>;
|
||||
memoraSearch(query: string, options?: { tags?: string[]; limit?: number }): Promise<MemoraSearchResult>;
|
||||
memoraGet(id: number): Promise<MemoraNode | null>;
|
||||
}
|
||||
|
||||
export interface SshAdapter {
|
||||
listSshSessions(): Promise<SshSessionRecord[]>;
|
||||
saveSshSession(session: SshSessionRecord): Promise<void>;
|
||||
deleteSshSession(id: string): Promise<void>;
|
||||
}
|
||||
|
||||
export interface PluginsAdapter {
|
||||
discoverPlugins(): Promise<PluginMeta[]>;
|
||||
readPluginFile(pluginId: string, filename: string): Promise<string>;
|
||||
}
|
||||
|
||||
export interface ClaudeProviderAdapter {
|
||||
listProfiles(): Promise<ClaudeProfile[]>;
|
||||
listSkills(): Promise<ClaudeSkill[]>;
|
||||
readSkill(path: string): Promise<string>;
|
||||
}
|
||||
|
||||
export interface RemoteMachineAdapter {
|
||||
listRemoteMachines(): Promise<RemoteMachineInfo[]>;
|
||||
addRemoteMachine(config: RemoteMachineConfig): Promise<string>;
|
||||
removeRemoteMachine(machineId: string): Promise<void>;
|
||||
connectRemoteMachine(machineId: string): Promise<void>;
|
||||
disconnectRemoteMachine(machineId: string): Promise<void>;
|
||||
probeSpki(url: string): Promise<string>;
|
||||
addSpkiPin(machineId: string, pin: string): Promise<void>;
|
||||
removeSpkiPin(machineId: string, pin: string): Promise<void>;
|
||||
onRemoteSidecarMessage(callback: (msg: RemoteSidecarMessage) => void): UnsubscribeFn;
|
||||
onRemotePtyData(callback: (msg: RemotePtyData) => void): UnsubscribeFn;
|
||||
onRemotePtyExit(callback: (msg: RemotePtyExit) => void): UnsubscribeFn;
|
||||
onRemoteMachineReady(callback: (msg: RemoteMachineEvent) => void): UnsubscribeFn;
|
||||
onRemoteMachineDisconnected(callback: (msg: RemoteMachineEvent) => void): UnsubscribeFn;
|
||||
onRemoteStateSync(callback: (msg: RemoteMachineEvent) => void): UnsubscribeFn;
|
||||
onRemoteError(callback: (msg: RemoteMachineEvent) => void): UnsubscribeFn;
|
||||
onRemoteMachineReconnecting(callback: (msg: RemoteReconnectingEvent) => void): UnsubscribeFn;
|
||||
onRemoteMachineReconnectReady(callback: (msg: RemoteMachineEvent) => void): UnsubscribeFn;
|
||||
onRemoteSpkiTofu(callback: (msg: RemoteSpkiTofuEvent) => void): UnsubscribeFn;
|
||||
}
|
||||
|
||||
export interface FileWatcherAdapter {
|
||||
watchFile(paneId: string, path: string): Promise<string>;
|
||||
unwatchFile(paneId: string): Promise<void>;
|
||||
readWatchedFile(path: string): Promise<string>;
|
||||
onFileChanged(callback: (payload: FileChangedPayload) => void): UnsubscribeFn;
|
||||
}
|
||||
|
||||
export interface AgentBridgeAdapter {
|
||||
/** Direct agent IPC — supports remote machines and resume */
|
||||
queryAgent(options: AgentQueryOptions): Promise<void>;
|
||||
stopAgentDirect(sessionId: string, remoteMachineId?: string): Promise<void>;
|
||||
isAgentReady(): Promise<boolean>;
|
||||
restartAgentSidecar(): Promise<void>;
|
||||
setSandbox(projectCwds: string[], worktreeRoots: string[], enabled: boolean): Promise<void>;
|
||||
onSidecarMessage(callback: (msg: SidecarMessagePayload) => void): UnsubscribeFn;
|
||||
onSidecarExited(callback: () => void): UnsubscribeFn;
|
||||
}
|
||||
|
||||
export interface PtyBridgeAdapter {
|
||||
/** Per-session PTY — supports remote machines */
|
||||
spawnPty(options: PtySpawnOptions): Promise<string>;
|
||||
writePtyDirect(id: string, data: string, remoteMachineId?: string): Promise<void>;
|
||||
resizePtyDirect(id: string, cols: number, rows: number, remoteMachineId?: string): Promise<void>;
|
||||
killPty(id: string, remoteMachineId?: string): Promise<void>;
|
||||
onPtyData(id: string, callback: (data: string) => void): UnsubscribeFn;
|
||||
onPtyExit(id: string, callback: () => void): UnsubscribeFn;
|
||||
}
|
||||
|
||||
// ── Backend adapter interface ────────────────────────────────────────────────
|
||||
|
||||
export interface BackendAdapter {
|
||||
export interface BackendAdapter extends
|
||||
SessionPersistenceAdapter,
|
||||
AgentPersistenceAdapter,
|
||||
BtmsgAdapter,
|
||||
BttaskAdapter,
|
||||
AnchorsAdapter,
|
||||
SearchAdapter,
|
||||
AuditAdapter,
|
||||
NotificationsAdapter,
|
||||
TelemetryAdapter,
|
||||
SecretsAdapter,
|
||||
FsWatcherAdapter,
|
||||
CtxAdapter,
|
||||
MemoraAdapter,
|
||||
SshAdapter,
|
||||
PluginsAdapter,
|
||||
ClaudeProviderAdapter,
|
||||
RemoteMachineAdapter,
|
||||
FileWatcherAdapter,
|
||||
AgentBridgeAdapter,
|
||||
PtyBridgeAdapter {
|
||||
|
||||
readonly capabilities: BackendCapabilities;
|
||||
|
||||
// ── Lifecycle ────────────────────────────────────────────────────────────
|
||||
|
|
@ -55,13 +274,13 @@ export interface BackendAdapter {
|
|||
loadGroups(): Promise<GroupsFile>;
|
||||
saveGroups(groups: GroupsFile): Promise<void>;
|
||||
|
||||
// ── Agent ────────────────────────────────────────────────────────────────
|
||||
// ── Agent (simplified) ────────────────────────────────────────────────────
|
||||
|
||||
startAgent(options: AgentStartOptions): Promise<{ ok: boolean; error?: string }>;
|
||||
stopAgent(sessionId: string): Promise<{ ok: boolean; error?: string }>;
|
||||
sendPrompt(sessionId: string, prompt: string): Promise<{ ok: boolean; error?: string }>;
|
||||
|
||||
// ── PTY ──────────────────────────────────────────────────────────────────
|
||||
// ── PTY (simplified) ──────────────────────────────────────────────────────
|
||||
|
||||
createPty(options: PtyCreateOptions): Promise<string>;
|
||||
writePty(sessionId: string, data: string): Promise<void>;
|
||||
|
|
@ -82,3 +301,273 @@ export interface BackendAdapter {
|
|||
onPtyOutput(callback: (sessionId: string, data: string) => void): UnsubscribeFn;
|
||||
onPtyClosed(callback: (sessionId: string, exitCode: number | null) => void): UnsubscribeFn;
|
||||
}
|
||||
|
||||
// ── Shared record types ─────────────────────────────────────────────────────
|
||||
|
||||
export interface PersistedSession {
|
||||
id: string;
|
||||
type: string;
|
||||
title: string;
|
||||
shell?: string;
|
||||
cwd?: string;
|
||||
args?: string[];
|
||||
group_name?: string;
|
||||
created_at: number;
|
||||
last_used_at: number;
|
||||
}
|
||||
|
||||
export interface PersistedLayout {
|
||||
preset: string;
|
||||
pane_ids: string[];
|
||||
}
|
||||
|
||||
export interface AgentMessageRecord {
|
||||
id: number;
|
||||
session_id: SessionId;
|
||||
project_id: ProjectId;
|
||||
sdk_session_id: string | null;
|
||||
message_type: string;
|
||||
content: string;
|
||||
parent_id: string | null;
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
export interface ProjectAgentState {
|
||||
project_id: ProjectId;
|
||||
last_session_id: SessionId;
|
||||
sdk_session_id: string | null;
|
||||
status: string;
|
||||
cost_usd: number;
|
||||
input_tokens: number;
|
||||
output_tokens: number;
|
||||
last_prompt: string | null;
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
export interface SessionMetricRecord {
|
||||
id: number;
|
||||
project_id: ProjectId;
|
||||
session_id: SessionId;
|
||||
start_time: number;
|
||||
end_time: number;
|
||||
peak_tokens: number;
|
||||
turn_count: number;
|
||||
tool_call_count: number;
|
||||
cost_usd: number;
|
||||
model: string | null;
|
||||
status: string;
|
||||
error_message: string | null;
|
||||
}
|
||||
|
||||
export interface MdFileEntry {
|
||||
name: string;
|
||||
path: string;
|
||||
priority: boolean;
|
||||
}
|
||||
|
||||
export interface SessionAnchorRecord {
|
||||
id: string;
|
||||
project_id: string;
|
||||
message_id: string;
|
||||
anchor_type: string;
|
||||
content: string;
|
||||
estimated_tokens: number;
|
||||
turn_index: number;
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
export type AuditEventType =
|
||||
| 'prompt_injection'
|
||||
| 'wake_event'
|
||||
| 'btmsg_sent'
|
||||
| 'btmsg_received'
|
||||
| 'status_change'
|
||||
| 'heartbeat_missed'
|
||||
| 'dead_letter';
|
||||
|
||||
export type NotificationUrgency = 'low' | 'normal' | 'critical';
|
||||
export type TelemetryLevel = 'error' | 'warn' | 'info' | 'debug' | 'trace';
|
||||
|
||||
export interface FsWriteEvent {
|
||||
project_id: string;
|
||||
file_path: string;
|
||||
timestamp_ms: number;
|
||||
}
|
||||
|
||||
export interface FsWatcherStatus {
|
||||
max_watches: number;
|
||||
estimated_watches: number;
|
||||
usage_ratio: number;
|
||||
active_projects: number;
|
||||
warning: string | null;
|
||||
}
|
||||
|
||||
export interface CtxEntry {
|
||||
project: string;
|
||||
key: string;
|
||||
value: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface CtxSummary {
|
||||
project: string;
|
||||
summary: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface MemoraNode {
|
||||
id: number;
|
||||
content: string;
|
||||
tags: string[];
|
||||
metadata?: Record<string, unknown>;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export interface MemoraSearchResult {
|
||||
nodes: MemoraNode[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface SshSessionRecord {
|
||||
id: string;
|
||||
name: string;
|
||||
host: string;
|
||||
port: number;
|
||||
username: string;
|
||||
key_file: string;
|
||||
folder: string;
|
||||
color: string;
|
||||
created_at: number;
|
||||
last_used_at: number;
|
||||
}
|
||||
|
||||
export interface PluginMeta {
|
||||
id: string;
|
||||
name: string;
|
||||
version: string;
|
||||
description: string;
|
||||
main: string;
|
||||
permissions: string[];
|
||||
}
|
||||
|
||||
export interface ClaudeProfile {
|
||||
name: string;
|
||||
email: string | null;
|
||||
subscription_type: string | null;
|
||||
display_name: string | null;
|
||||
config_dir: string;
|
||||
}
|
||||
|
||||
export interface ClaudeSkill {
|
||||
name: string;
|
||||
description: string;
|
||||
source_path: string;
|
||||
}
|
||||
|
||||
export interface RemoteMachineConfig {
|
||||
label: string;
|
||||
url: string;
|
||||
token: string;
|
||||
auto_connect: boolean;
|
||||
spki_pins?: string[];
|
||||
}
|
||||
|
||||
export interface RemoteMachineInfo {
|
||||
id: string;
|
||||
label: string;
|
||||
url: string;
|
||||
status: string;
|
||||
auto_connect: boolean;
|
||||
spki_pins: string[];
|
||||
}
|
||||
|
||||
export interface RemoteSidecarMessage {
|
||||
machineId: string;
|
||||
sessionId?: string;
|
||||
event?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface RemotePtyData {
|
||||
machineId: string;
|
||||
sessionId?: string;
|
||||
data?: string;
|
||||
}
|
||||
|
||||
export interface RemotePtyExit {
|
||||
machineId: string;
|
||||
sessionId?: string;
|
||||
}
|
||||
|
||||
export interface RemoteMachineEvent {
|
||||
machineId: string;
|
||||
payload?: unknown;
|
||||
error?: unknown;
|
||||
}
|
||||
|
||||
export interface RemoteReconnectingEvent {
|
||||
machineId: string;
|
||||
backoffSecs: number;
|
||||
}
|
||||
|
||||
export interface RemoteSpkiTofuEvent {
|
||||
machineId: string;
|
||||
hash: string;
|
||||
}
|
||||
|
||||
export interface FileChangedPayload {
|
||||
pane_id: string;
|
||||
path: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface BtmsgFeedMessage {
|
||||
id: string;
|
||||
fromAgent: AgentId;
|
||||
toAgent: AgentId;
|
||||
content: string;
|
||||
createdAt: string;
|
||||
replyTo: string | null;
|
||||
senderName: string;
|
||||
senderRole: string;
|
||||
recipientName: string;
|
||||
recipientRole: string;
|
||||
}
|
||||
|
||||
export interface AgentQueryOptions {
|
||||
provider?: ProviderId;
|
||||
session_id: string;
|
||||
prompt: string;
|
||||
cwd?: string;
|
||||
max_turns?: number;
|
||||
max_budget_usd?: number;
|
||||
resume_session_id?: string;
|
||||
permission_mode?: string;
|
||||
setting_sources?: string[];
|
||||
system_prompt?: string;
|
||||
model?: string;
|
||||
claude_config_dir?: string;
|
||||
additional_directories?: string[];
|
||||
worktree_name?: string;
|
||||
provider_config?: Record<string, unknown>;
|
||||
extra_env?: Record<string, string>;
|
||||
remote_machine_id?: string;
|
||||
}
|
||||
|
||||
export interface SidecarMessagePayload {
|
||||
type: string;
|
||||
sessionId?: string;
|
||||
event?: Record<string, unknown>;
|
||||
message?: string;
|
||||
exitCode?: number | null;
|
||||
signal?: string | null;
|
||||
}
|
||||
|
||||
export interface PtySpawnOptions {
|
||||
shell?: string;
|
||||
cwd?: string;
|
||||
args?: string[];
|
||||
cols?: number;
|
||||
rows?: number;
|
||||
remote_machine_id?: string;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue