part of 'gateway_runtime_core.dart'; extension GatewayRuntimeApiInternal on GatewayRuntime { Future> _healthInternal() async { final payload = asMap(await request('health')); snapshotInternal = snapshotInternal.copyWith(healthPayload: payload); appendLogInternal(this, 'debug', 'health', 'health snapshot refreshed'); _notifyRuntimeChangedInternal(); return payload; } Future> _statusInternal() async { final payload = asMap(await request('status')); snapshotInternal = snapshotInternal.copyWith(statusPayload: payload); appendLogInternal(this, 'debug', 'health', 'status snapshot refreshed'); _notifyRuntimeChangedInternal(); return payload; } Future> _listAgentsInternal() async { final payload = asMap( await request('agents.list', params: const {}), ); final agents = asList(payload['agents']) .map((item) { final map = asMap(item); final identity = asMap(map['identity']); return GatewayAgentSummary( id: stringValue(map['id']) ?? 'unknown', name: stringValue(map['name']) ?? stringValue(identity['name']) ?? 'Agent', emoji: stringValue(identity['emoji']) ?? '·', theme: stringValue(identity['theme']) ?? 'default', ); }) .toList(growable: false); if (snapshotInternal.mainSessionKey == null || snapshotInternal.mainSessionKey!.trim().isEmpty) { snapshotInternal = snapshotInternal.copyWith( mainSessionKey: stringValue(payload['mainKey']) ?? 'main', ); _notifyRuntimeChangedInternal(); } return agents; } Future> _listSessionsInternal({ String? agentId, int limit = 24, }) async { final payload = asMap( await request( 'sessions.list', params: { 'includeGlobal': true, 'includeUnknown': false, 'includeDerivedTitles': true, 'includeLastMessage': true, 'limit': limit, if (agentId != null && agentId.trim().isNotEmpty) 'agentId': agentId.trim(), }, ), ); return asList(payload['sessions']) .map((item) { final map = asMap(item); return GatewaySessionSummary( key: stringValue(map['key']) ?? 'main', kind: stringValue(map['kind']), displayName: stringValue(map['displayName']) ?? stringValue(map['label']), surface: stringValue(map['surface']), subject: stringValue(map['subject']), room: stringValue(map['room']), space: stringValue(map['space']), updatedAtMs: doubleValue(map['updatedAt']), sessionId: stringValue(map['sessionId']), systemSent: boolValue(map['systemSent']), abortedLastRun: boolValue(map['abortedLastRun']), thinkingLevel: stringValue(map['thinkingLevel']), verboseLevel: stringValue(map['verboseLevel']), inputTokens: intValue(map['inputTokens']), outputTokens: intValue(map['outputTokens']), totalTokens: intValue(map['totalTokens']), model: stringValue(map['model']), contextTokens: intValue(map['contextTokens']), derivedTitle: stringValue(map['derivedTitle']), lastMessagePreview: stringValue(map['lastMessagePreview']), ); }) .toList(growable: false); } Future> _loadHistoryInternal( String sessionKey, { int limit = 120, }) async { final payload = asMap( await request( 'chat.history', params: {'sessionKey': sessionKey, 'limit': limit}, ), ); return asList(payload['messages']) .map((item) { final map = asMap(item); return GatewayChatMessage( id: randomIdInternal(), role: stringValue(map['role']) ?? 'assistant', text: extractMessageText(map), timestampMs: doubleValue(map['timestamp']), toolCallId: stringValue(map['toolCallId']) ?? stringValue(map['tool_call_id']), toolName: stringValue(map['toolName']) ?? stringValue(map['tool_name']), stopReason: stringValue(map['stopReason']), pending: false, error: false, ); }) .toList(growable: false); } Future _sendChatInternal({ required String sessionKey, required String message, required String thinking, List attachments = const [], String? agentId, Map? metadata, }) async { final runId = randomIdInternal(); final payload = asMap( await request( 'chat.send', params: { 'sessionKey': sessionKey, 'message': message, 'thinking': thinking, 'timeoutMs': 30000, 'idempotencyKey': runId, if (agentId != null && agentId.trim().isNotEmpty) 'agentId': agentId.trim(), if (attachments.isNotEmpty) 'attachments': attachments .map((attachment) => attachment.toJson()) .toList(growable: false), }, timeout: const Duration(seconds: 35), ), ); return stringValue(payload['runId']) ?? runId; } Future _abortChatInternal({ required String sessionKey, required String runId, }) async { await request( 'chat.abort', params: {'sessionKey': sessionKey, 'runId': runId}, timeout: const Duration(seconds: 10), ); } Future> _listInstancesInternal() async { final payload = await request( 'system-presence', params: const {}, ); return asList(payload) .map((item) { final map = asMap(item); return GatewayInstanceSummary( id: stringValue(map['id']) ?? randomIdInternal(), host: stringValue(map['host']), ip: stringValue(map['ip']), version: stringValue(map['version']), platform: stringValue(map['platform']), deviceFamily: stringValue(map['deviceFamily']), modelIdentifier: stringValue(map['modelIdentifier']), lastInputSeconds: intValue(map['lastInputSeconds']), mode: stringValue(map['mode']), reason: stringValue(map['reason']), text: stringValue(map['text']) ?? '', timestampMs: doubleValue(map['ts']) ?? DateTime.now().millisecondsSinceEpoch.toDouble(), ); }) .toList(growable: false); } Future> _listSkillsInternal({ String? agentId, }) async { final params = { if (agentId != null && agentId.trim().isNotEmpty) 'agentId': agentId.trim(), }; if (sessionClientInternal == null) { throw GatewayRuntimeException( 'skills.status requires bridge session (ACP transport)', code: 'BRIDGE_NOT_CONFIGURED', ); } // Use allowErrorPayload so the bridge can return cached skills even when // the upstream OpenClaw gateway is temporarily offline (ok:false + payload). final payload = asMap( await sessionClientInternal!.request( runtimeId: runtimeIdInternal, method: 'skills.status', params: params, allowErrorPayload: true, ), ); final skillsList = asList(payload['skills']); // When the skills key is entirely absent (not just an empty list), the // gateway may have returned a stub or error payload. Distinguish between // "genuinely no skills" and "gateway responded without skills data". if (!payload.containsKey('skills')) { final hasWorkspaceMeta = payload.containsKey('workspaceDir') || payload.containsKey('managedSkillsDir'); if (!hasWorkspaceMeta) { appendLogInternal( this, 'warn', 'skills', 'skills.status returned payload without skills key and without' ' workspace metadata — likely a gateway error or unimplemented method', ); throw GatewayRuntimeException( 'OpenClaw gateway did not return skills data.' ' The gateway may not have skills.status implemented.', code: 'SKILLS_STATUS_MISSING', ); } // Gateway responded with workspace metadata but no skills key — // genuinely no skills installed. Return empty list. appendLogInternal( this, 'debug', 'skills', 'skills.status returned workspace metadata with zero skills', ); return const []; } if (skillsList.isEmpty) { appendLogInternal( this, 'debug', 'skills', 'skills.status returned empty skills list (${payload.length} payload keys)', ); } return skillsList .map((item) { final map = asMap(item); return GatewaySkillSummary( name: stringValue(map['name']) ?? stringValue(map['title']) ?? stringValue(map['id']) ?? 'Skill', description: stringValue(map['description']) ?? '', source: stringValue(map['source']) ?? 'workspace', skillKey: stringValue(map['skillKey']) ?? stringValue(map['skill_key']) ?? stringValue(map['key']) ?? stringValue(map['id']) ?? stringValue(map['name']) ?? 'skill', primaryEnv: stringValue(map['primaryEnv']), eligible: boolValue(map['eligible']) ?? false, disabled: boolValue(map['disabled']) ?? false, missingBins: skillMissingListInternal(map, 'bins', 'missingBins'), missingEnv: skillMissingListInternal(map, 'env', 'missingEnv'), missingConfig: skillMissingListInternal( map, 'config', 'missingConfig', ), ); }) .toList(growable: false); } Future> _listConnectorsInternal() async { final payload = asMap( await request( 'channels.status', params: const {'probe': true, 'timeoutMs': 8000}, timeout: const Duration(seconds: 16), ), ); final channelMeta = >{ for (final entry in asList(payload['channelMeta'])) if (stringValue(asMap(entry)['id']) != null) stringValue(asMap(entry)['id'])!: asMap(entry), }; final labels = asMap(payload['channelLabels']); final detailLabels = asMap(payload['channelDetailLabels']); final accounts = asMap(payload['channelAccounts']); final order = stringList(payload['channelOrder']); final summaries = []; for (final channelId in order) { final channelAccounts = asList(accounts[channelId]); if (channelAccounts.isEmpty) { final meta = channelMeta[channelId] ?? const {}; summaries.add( GatewayConnectorSummary( id: channelId, label: stringValue(meta['label']) ?? stringValue(labels[channelId]) ?? channelId, detailLabel: stringValue(meta['detailLabel']) ?? stringValue(detailLabels[channelId]) ?? channelId, accountName: null, configured: false, enabled: false, running: false, connected: false, status: 'idle', lastError: null, meta: const [], ), ); continue; } for (final account in channelAccounts) { final map = asMap(account); final configured = boolValue(map['configured']) ?? false; final enabled = boolValue(map['enabled']) ?? configured; final running = boolValue(map['running']) ?? false; final connected = boolValue(map['connected']) ?? boolValue(map['linked']) ?? false; final lastError = stringValue(map['lastError']); final status = lastError != null && lastError.trim().isNotEmpty ? 'error' : connected ? 'connected' : running ? 'running' : configured ? 'configured' : 'idle'; final mode = stringValue(map['mode']); final tokenSource = stringValue(map['tokenSource']); final baseUrl = stringValue(map['baseUrl']); summaries.add( GatewayConnectorSummary( id: channelId, label: stringValue(channelMeta[channelId]?['label']) ?? stringValue(labels[channelId]) ?? channelId, detailLabel: stringValue(channelMeta[channelId]?['detailLabel']) ?? stringValue(detailLabels[channelId]) ?? channelId, accountName: stringValue(map['name']) ?? stringValue(map['accountId']), configured: configured, enabled: enabled, running: running, connected: connected, status: status, lastError: lastError, meta: [ ...?(mode == null ? null : [mode]), ...?(tokenSource == null ? null : [tokenSource]), ...?(baseUrl == null ? null : [baseUrl]), ], ), ); } } return summaries; } Future> _listModelsInternal() async { final payload = asMap( await request( 'models.list', params: const {}, timeout: const Duration(seconds: 16), ), ); return asList(payload['models']) .map((item) { final map = asMap(item); return GatewayModelSummary( id: stringValue(map['id']) ?? 'unknown', name: stringValue(map['name']) ?? stringValue(map['id']) ?? 'unknown', provider: stringValue(map['provider']) ?? 'unknown', contextWindow: intValue(map['contextWindow']), maxOutputTokens: intValue(map['maxOutputTokens']), ); }) .toList(growable: false); } Future> _listCronJobsInternal() async { final payload = asMap( await request( 'cron.list', params: const {'includeDisabled': true}, timeout: const Duration(seconds: 16), ), ); return asList(payload['jobs']) .map((item) { final map = asMap(item); final state = asMap(map['state']); return GatewayCronJobSummary( id: stringValue(map['id']) ?? randomIdInternal(), name: stringValue(map['name']) ?? 'Untitled job', description: stringValue(map['description']), enabled: boolValue(map['enabled']) ?? true, agentId: stringValue(map['agentId']), scheduleLabel: cronScheduleLabelInternal(asMap(map['schedule'])), nextRunAtMs: intValue(state['nextRunAtMs']), lastRunAtMs: intValue(state['lastRunAtMs']), lastStatus: stringValue(state['lastStatus']), lastError: stringValue(state['lastError']), ); }) .toList(growable: false); } Future _listDevicePairingInternal() async { final payload = asMap( await request( 'device.pair.list', params: const {}, timeout: const Duration(seconds: 12), ), ); final identity = await storeInternal.loadDeviceIdentity(); return GatewayDevicePairingList( pending: asList(payload['pending']) .map((item) => parsePendingDeviceInternal(asMap(item))) .toList(growable: false), paired: asList(payload['paired']) .map( (item) => parsePairedDeviceInternal( asMap(item), currentDeviceId: identity?.deviceId, ), ) .toList(growable: false), ); } Future _approveDevicePairingInternal( String requestId, ) async { appendLogInternal(this, 'info', 'pairing', 'approve request $requestId'); final payload = asMap( await request( 'device.pair.approve', params: {'requestId': requestId}, timeout: const Duration(seconds: 12), ), ); final identity = await storeInternal.loadDeviceIdentity(); final device = asMap(payload['device']); if (device.isEmpty) { return null; } return parsePairedDeviceInternal( device, currentDeviceId: identity?.deviceId, ); } Future _rejectDevicePairingInternal(String requestId) async { appendLogInternal(this, 'info', 'pairing', 'reject request $requestId'); await request( 'device.pair.reject', params: {'requestId': requestId}, timeout: const Duration(seconds: 12), ); } Future _removePairedDeviceInternal(String deviceId) async { appendLogInternal(this, 'info', 'pairing', 'remove device $deviceId'); await request( 'device.pair.remove', params: {'deviceId': deviceId}, timeout: const Duration(seconds: 12), ); } Future _rotateDeviceTokenInternal({ required String deviceId, required String role, List scopes = const [], }) async { appendLogInternal( this, 'info', 'token', 'rotate role token | device: $deviceId | role: $role', ); final payload = asMap( await request( 'device.token.rotate', params: { 'deviceId': deviceId, 'role': role, if (scopes.isNotEmpty) 'scopes': scopes, }, timeout: const Duration(seconds: 12), ), ); final token = stringValue(payload['token']) ?? ''; final identity = await storeInternal.loadDeviceIdentity(); final resolvedRole = stringValue(payload['role']) ?? role; if (token.isNotEmpty && identity != null && (stringValue(payload['deviceId']) ?? deviceId) == identity.deviceId) { await storeInternal.saveDeviceToken( deviceId: identity.deviceId, role: resolvedRole, token: token, ); } return token; } Future _revokeDeviceTokenInternal({ required String deviceId, required String role, }) async { appendLogInternal( this, 'info', 'token', 'revoke role token | device: $deviceId | role: $role', ); await request( 'device.token.revoke', params: {'deviceId': deviceId, 'role': role}, timeout: const Duration(seconds: 12), ); final identity = await storeInternal.loadDeviceIdentity(); if (identity != null && deviceId == identity.deviceId) { await storeInternal.clearDeviceToken( deviceId: identity.deviceId, role: role, ); } } Future _requestInternal( String method, { Map? params, Duration timeout = const Duration(seconds: 15), }) async { if (sessionClientInternal != null) { if (!isConnected) { appendLogInternal( this, 'warn', 'rpc', 'blocked request $method | offline', ); throw GatewayRuntimeException('gateway not connected', code: 'OFFLINE'); } return sessionClientInternal!.request( runtimeId: runtimeIdInternal, method: method, params: params, timeout: timeout, ); } if (channelInternal == null || !isConnected) { appendLogInternal( this, 'warn', 'rpc', 'blocked request $method | offline', ); throw GatewayRuntimeException('gateway not connected', code: 'OFFLINE'); } final result = await requestRawInternal( this, method, params: params, timeout: timeout, ); return result.payload; } } List skillMissingListInternal( Map skill, String nestedKey, String flatKey, ) { final flat = stringList(skill[flatKey]); if (flat.isNotEmpty) { return flat; } return stringList(asMap(skill['missing'])[nestedKey]); }