@panorama-ai/gateway 2.24.133 → 2.24.137

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/dist/index.js CHANGED
@@ -28,6 +28,7 @@ const DEFAULT_GATEWAY_CONCURRENCY = 10;
28
28
  const MAX_SUBAGENT_OUTPUT_BYTES = 5_000_000;
29
29
  const MAX_SUBAGENT_EVENT_PAYLOAD_BYTES = 200_000;
30
30
  const MAX_GATEWAY_LOG_BYTES = 200_000;
31
+ const MAX_GATEWAY_EVENT_MESSAGE_CHARS = 2000;
31
32
  const SUBAGENT_EVENT_BATCH_SIZE = 200;
32
33
  const SUBAGENT_CANCEL_KILL_TIMEOUT_MS = 5_000;
33
34
  const PROCESS_KILL_GRACE_MS = 2_000;
@@ -83,6 +84,11 @@ const GATEWAY_CONCURRENCY = (() => {
83
84
  const activeSubagentRuns = new Map();
84
85
  const pendingCancelByRunId = new Map();
85
86
  const pendingCancelBySubagentId = new Map();
87
+ const pendingGatewayEvents = [];
88
+ let CURRENT_CAPABILITIES = null;
89
+ let CURRENT_DEVICE_NAME = null;
90
+ let CURRENT_CONFIG = null;
91
+ let CURRENT_PROVIDER_HEALTH = null;
86
92
  let ACTIVE_OPTIONS = null;
87
93
  function parseArgs(argv) {
88
94
  const positional = [];
@@ -121,7 +127,7 @@ function parseArgs(argv) {
121
127
  return { command, positional, options };
122
128
  }
123
129
  function printHelp() {
124
- console.log(`\nPanorama Gateway\n\nUsage:\n panorama-gateway pair <PAIRING_CODE> [--device-name \"My Mac\"] [--supabase-url URL] [--anon-key KEY]\n panorama-gateway start [--device-name \"My Mac\"] [--foreground|--daemon]\n panorama-gateway stop\n panorama-gateway status\n panorama-gateway logs [--lines 200] [--no-follow]\n\nNotes:\n Start runs in the background by default for the built CLI. Use --foreground to keep it attached.\n\nOutput options:\n --verbose, -v Show technical details (paths, IDs, PIDs)\n\nEnvironment options:\n --env <local|dev|test|stage|prod> Load .env.<env> from repo root (defaults to .env)\n --env-file <path> Load a specific env file\n PANORAMA_ENV Same as --env\n PANORAMA_ENV_FILE Same as --env-file\n\nCLI overrides:\n --config-dir <path> Override config directory (default: ~/.panorama/gateway)\n --config-path <path> Override gateway config file\n --log-path <path> Override gateway log file\n --pid-path <path> Override gateway pid file\n --claude-cli <path> Override Claude CLI command\n --codex-cli <path> Override Codex CLI command\n --gemini-cli <path> Override Gemini CLI command\n --claude-home <path> Override Claude home directory\n\nEnvironment overrides:\n PANORAMA_SUPABASE_URL or SUPABASE_URL\n PANORAMA_SUPABASE_ANON_KEY or SUPABASE_ANON_KEY or SUPABASE_PUBLISHABLE_KEY\n PANORAMA_GATEWAY_CONFIG_DIR\n PANORAMA_GATEWAY_CONFIG_PATH\n PANORAMA_GATEWAY_LOG_PATH\n PANORAMA_GATEWAY_PID_PATH\n PANORAMA_CLAUDE_CLI or CLAUDE_CLI\n PANORAMA_CODEX_CLI or CODEX_CLI\n PANORAMA_GEMINI_CLI or GEMINI_CLI\n PANORAMA_CLAUDE_HOME\n`);
130
+ console.log(`\nPanorama Gateway\n\nUsage:\n panorama-gateway pair <PAIRING_CODE> [--device-name \"My Mac\"] [--supabase-url URL] [--anon-key KEY]\n panorama-gateway start [--device-name \"My Mac\"] [--foreground|--daemon]\n panorama-gateway stop\n panorama-gateway status\n panorama-gateway logs [--lines 200] [--no-follow]\n panorama-gateway doctor\n\nNotes:\n Start runs in the background by default for the built CLI. Use --foreground to keep it attached.\n\nOutput options:\n --verbose, -v Show technical details (paths, IDs, PIDs)\n\nEnvironment options:\n --env <local|dev|test|stage|prod> Load .env.<env> from repo root (defaults to .env)\n --env-file <path> Load a specific env file\n PANORAMA_ENV Same as --env\n PANORAMA_ENV_FILE Same as --env-file\n\nCLI overrides:\n --config-dir <path> Override config directory (default: ~/.panorama/gateway)\n --config-path <path> Override gateway config file\n --log-path <path> Override gateway log file\n --pid-path <path> Override gateway pid file\n --claude-cli <path> Override Claude CLI command\n --codex-cli <path> Override Codex CLI command\n --gemini-cli <path> Override Gemini CLI command\n --claude-home <path> Override Claude home directory\n\nEnvironment overrides:\n PANORAMA_SUPABASE_URL or SUPABASE_URL\n PANORAMA_SUPABASE_ANON_KEY or SUPABASE_ANON_KEY or SUPABASE_PUBLISHABLE_KEY\n PANORAMA_GATEWAY_CONFIG_DIR\n PANORAMA_GATEWAY_CONFIG_PATH\n PANORAMA_GATEWAY_LOG_PATH\n PANORAMA_GATEWAY_PID_PATH\n PANORAMA_CLAUDE_CLI or CLAUDE_CLI\n PANORAMA_CODEX_CLI or CODEX_CLI\n PANORAMA_GEMINI_CLI or GEMINI_CLI\n PANORAMA_CLAUDE_HOME\n`);
125
131
  }
126
132
  function getStringOption(options, key) {
127
133
  const value = options[key];
@@ -177,6 +183,83 @@ function resolveGatewayPaths(options) {
177
183
  }
178
184
  return paths;
179
185
  }
186
+ function resolveGatewayFallbackConfigDir() {
187
+ if (process.platform === 'darwin') {
188
+ return path.join(os.homedir(), 'Library', 'Application Support', 'Panorama Gateway');
189
+ }
190
+ if (process.platform === 'win32') {
191
+ const base = process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming');
192
+ return path.join(base, 'Panorama Gateway');
193
+ }
194
+ const dataHome = process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local', 'share');
195
+ return path.join(dataHome, 'panorama-gateway');
196
+ }
197
+ async function ensureWritableDirectory(dir) {
198
+ try {
199
+ await fs.mkdir(dir, { recursive: true });
200
+ const probe = path.join(dir, `.panorama-write-${randomUUID()}`);
201
+ await fs.writeFile(probe, 'ok', 'utf-8');
202
+ await fs.unlink(probe);
203
+ return { ok: true };
204
+ }
205
+ catch (error) {
206
+ return { ok: false, error: error instanceof Error ? error.message : String(error) };
207
+ }
208
+ }
209
+ async function prepareGatewayConfigDir(options, allowFallback) {
210
+ const resolvedOptions = getActiveOptions(options);
211
+ const overrideDir = getStringOption(resolvedOptions, 'config-dir') || process.env.PANORAMA_GATEWAY_CONFIG_DIR;
212
+ const overridePath = getStringOption(resolvedOptions, 'config-path') || process.env.PANORAMA_GATEWAY_CONFIG_PATH;
213
+ const explicitOverride = Boolean(overrideDir || overridePath);
214
+ const { configDir, configPath, logPath, pidPath } = resolveGatewayPaths(resolvedOptions);
215
+ const writable = await ensureWritableDirectory(configDir);
216
+ if (writable.ok) {
217
+ process.env.PANORAMA_GATEWAY_CONFIG_DIR = configDir;
218
+ process.env.PANORAMA_GATEWAY_CONFIG_PATH = configPath;
219
+ process.env.PANORAMA_GATEWAY_LOG_PATH = logPath;
220
+ process.env.PANORAMA_GATEWAY_PID_PATH = pidPath;
221
+ return { configDir, configPath };
222
+ }
223
+ if (explicitOverride || !allowFallback) {
224
+ throw new GatewayCliError('Gateway config directory is not writable.', writable.error ??
225
+ `Unable to write to ${configDir}. Set --config-dir or PANORAMA_GATEWAY_CONFIG_DIR to a writable location.`);
226
+ }
227
+ const fallbackDir = resolveGatewayFallbackConfigDir();
228
+ const fallbackWritable = await ensureWritableDirectory(fallbackDir);
229
+ if (!fallbackWritable.ok) {
230
+ throw new GatewayCliError('Gateway config directory is not writable.', fallbackWritable.error ??
231
+ `Unable to write to ${configDir} or fallback ${fallbackDir}.`);
232
+ }
233
+ const fallbackOptions = { ...resolvedOptions, 'config-dir': fallbackDir };
234
+ const fallbackPaths = resolveGatewayPaths(fallbackOptions);
235
+ if (configPath !== fallbackPaths.configPath && fsSync.existsSync(configPath)) {
236
+ try {
237
+ await fs.mkdir(path.dirname(fallbackPaths.configPath), { recursive: true });
238
+ await fs.copyFile(configPath, fallbackPaths.configPath);
239
+ }
240
+ catch (error) {
241
+ logError('Failed to migrate gateway config to fallback directory', {
242
+ from: configPath,
243
+ to: fallbackPaths.configPath,
244
+ error: error instanceof Error ? error.message : String(error),
245
+ });
246
+ }
247
+ }
248
+ resolvedOptions['config-dir'] = fallbackDir;
249
+ process.env.PANORAMA_GATEWAY_CONFIG_DIR = fallbackDir;
250
+ process.env.PANORAMA_GATEWAY_CONFIG_PATH = fallbackPaths.configPath;
251
+ process.env.PANORAMA_GATEWAY_LOG_PATH = fallbackPaths.logPath;
252
+ process.env.PANORAMA_GATEWAY_PID_PATH = fallbackPaths.pidPath;
253
+ cliWarn('Gateway config directory not writable; using fallback location.', resolvedOptions, [
254
+ { label: 'Fallback', value: formatPathForDisplay(fallbackDir), verboseOnly: true },
255
+ { label: 'Previous', value: formatPathForDisplay(configDir), verboseOnly: true },
256
+ ]);
257
+ return {
258
+ configDir: fallbackPaths.configDir,
259
+ configPath: fallbackPaths.configPath,
260
+ migratedFrom: configDir,
261
+ };
262
+ }
180
263
  function resolveSubagentWorkdirRoot(options) {
181
264
  const { configDir } = resolveGatewayPaths(options);
182
265
  return resolveSubagentWorkdirRootBase(configDir);
@@ -209,6 +292,71 @@ function formatPathForDisplay(value) {
209
292
  }
210
293
  return value;
211
294
  }
295
+ function truncateText(value, maxLength) {
296
+ if (value.length <= maxLength)
297
+ return value;
298
+ return `${value.slice(0, maxLength)}...`;
299
+ }
300
+ async function emitGatewayEvent(supabase, config, payload) {
301
+ if (!config.gatewayId || !config.teamId)
302
+ return;
303
+ const message = truncateText(payload.message, MAX_GATEWAY_EVENT_MESSAGE_CHARS);
304
+ const details = payload.details ?? {};
305
+ const { error } = await supabase
306
+ .from('gateway_events')
307
+ .insert({
308
+ gateway_id: config.gatewayId,
309
+ team_id: config.teamId,
310
+ provider: payload.provider ?? null,
311
+ level: payload.level,
312
+ code: payload.code ?? null,
313
+ message,
314
+ details,
315
+ });
316
+ if (error) {
317
+ logError('Failed to record gateway event', {
318
+ error: error.message,
319
+ code: payload.code ?? null,
320
+ provider: payload.provider ?? null,
321
+ });
322
+ }
323
+ }
324
+ function queueGatewayEvent(payload) {
325
+ pendingGatewayEvents.push(payload);
326
+ }
327
+ async function flushGatewayEvents(supabase, config) {
328
+ if (pendingGatewayEvents.length === 0)
329
+ return;
330
+ const queued = pendingGatewayEvents.splice(0, pendingGatewayEvents.length);
331
+ for (const payload of queued) {
332
+ await emitGatewayEvent(supabase, config, payload);
333
+ }
334
+ }
335
+ async function emitProviderHealthEvents(supabase, config, providerHealth, context) {
336
+ const entries = Object.entries(providerHealth);
337
+ for (const [providerId, health] of entries) {
338
+ if (health.status === 'healthy')
339
+ continue;
340
+ const error = typeof health.error === 'string' ? health.error : undefined;
341
+ const errorSummary = error ? truncateText(error, 500) : undefined;
342
+ const message = errorSummary
343
+ ? `${providerId} validation failed: ${errorSummary}`
344
+ : `${providerId} validation status: ${health.status}`;
345
+ await emitGatewayEvent(supabase, config, {
346
+ level: health.status === 'unhealthy' ? 'error' : 'warn',
347
+ code: 'provider_validation',
348
+ provider: providerId,
349
+ message,
350
+ details: {
351
+ context,
352
+ status: health.status,
353
+ check_type: health.check_type,
354
+ error: errorSummary ?? null,
355
+ duration_ms: health.duration_ms ?? null,
356
+ },
357
+ });
358
+ }
359
+ }
212
360
  function writeCliDetails(details, options, writer) {
213
361
  if (!details || details.length === 0)
214
362
  return;
@@ -475,6 +623,28 @@ function logError(message, data) {
475
623
  console.error(`[gateway] ${message}`);
476
624
  }
477
625
  }
626
+ async function createGatewayEventClient(config) {
627
+ if (!config.supabaseUrl || !config.supabaseAnonKey)
628
+ return null;
629
+ const supabase = createClient(config.supabaseUrl, config.supabaseAnonKey, {
630
+ auth: {
631
+ persistSession: false,
632
+ autoRefreshToken: false,
633
+ detectSessionInUrl: false,
634
+ },
635
+ });
636
+ const { data: sessionData, error: sessionError } = await supabase.auth.setSession({
637
+ access_token: config.accessToken,
638
+ refresh_token: config.refreshToken,
639
+ });
640
+ if (sessionError || !sessionData?.session) {
641
+ logError('Failed to authenticate gateway event session', {
642
+ error: sessionError?.message || 'No session returned',
643
+ });
644
+ return null;
645
+ }
646
+ return supabase;
647
+ }
478
648
  function readPidFile(options) {
479
649
  const { pidPath } = resolveGatewayPaths(options);
480
650
  try {
@@ -517,7 +687,7 @@ async function safeJson(response) {
517
687
  return null;
518
688
  }
519
689
  }
520
- async function pairGateway(code, options) {
690
+ async function pairGateway(code, options, configResolution) {
521
691
  const supabaseUrl = resolveSupabaseUrl(options);
522
692
  const supabaseAnonKey = resolveSupabaseAnonKey(options);
523
693
  if (!supabaseUrl || !supabaseAnonKey) {
@@ -557,12 +727,14 @@ async function pairGateway(code, options) {
557
727
  await saveConfig(config, options);
558
728
  let providerHealthSummary = null;
559
729
  let anyProviderReady = false;
730
+ let providerHealth = null;
560
731
  try {
561
732
  await ensureClaudeGatewayHome();
562
- const { providerHealth, anyProviderReady: ready } = await buildCapabilities({
733
+ const { providerHealth: resolvedHealth, anyProviderReady: ready } = await buildCapabilities({
563
734
  validationMode: 'full',
564
735
  });
565
- config.providerHealth = providerHealth;
736
+ config.providerHealth = resolvedHealth;
737
+ providerHealth = resolvedHealth;
566
738
  await saveConfig(config, options);
567
739
  providerHealthSummary = Object.entries(providerHealth)
568
740
  .map(([id, health]) => `${id}:${health.status}`)
@@ -587,6 +759,24 @@ async function pairGateway(code, options) {
587
759
  if (providerHealthSummary && !anyProviderReady) {
588
760
  cliWarn('No gateway providers passed validation. Install/authenticate a provider before starting the gateway.', options, [{ label: 'Provider health', value: providerHealthSummary, verboseOnly: true }]);
589
761
  }
762
+ const eventClient = await createGatewayEventClient(config);
763
+ if (eventClient) {
764
+ await flushGatewayEvents(eventClient, config);
765
+ if (configResolution?.migratedFrom) {
766
+ await emitGatewayEvent(eventClient, config, {
767
+ level: 'warn',
768
+ code: 'config_dir_fallback',
769
+ message: 'Gateway config directory was not writable; using fallback location.',
770
+ details: {
771
+ previous: configResolution.migratedFrom,
772
+ current: configResolution.configDir,
773
+ },
774
+ });
775
+ }
776
+ if (providerHealth) {
777
+ await emitProviderHealthEvents(eventClient, config, providerHealth, 'pairing');
778
+ }
779
+ }
590
780
  }
591
781
  async function execClaudeVersion() {
592
782
  const start = Date.now();
@@ -665,54 +855,110 @@ function resolveRunPlanEnv(env, workDir) {
665
855
  return [key, value];
666
856
  }));
667
857
  }
668
- async function runProviderValidation(provider, model) {
669
- const workRoot = resolveValidationWorkdirRoot();
670
- const workDir = await createWorkDir(workRoot, [
671
- { value: provider.id, label: 'provider id' },
672
- { value: randomUUID(), label: 'validation run id' },
673
- ]);
858
+ function isCodexConfigError(message) {
859
+ if (!message)
860
+ return false;
861
+ const normalized = message.toLowerCase();
862
+ return (normalized.includes('config.toml') ||
863
+ normalized.includes('mcp_servers') ||
864
+ normalized.includes('invalid transport'));
865
+ }
866
+ async function resetCodexConfig() {
867
+ const home = resolveGatewayProviderHome('codex');
868
+ const candidates = [
869
+ path.join(home, '.codex', 'config.toml'),
870
+ path.join(home, 'xdg', 'config', 'codex', 'config.toml'),
871
+ ];
872
+ const resetPaths = [];
674
873
  try {
675
- const runPlan = provider.buildModelRunPlan({
676
- model,
677
- prompt: PROVIDER_VALIDATION_PROMPT,
678
- jsonSchema: PROVIDER_VALIDATION_SCHEMA,
679
- timeoutMs: PROVIDER_VALIDATION_TIMEOUT_MS,
680
- });
681
- assertSafeOutputPath(workDir, runPlan.outputPath);
682
- await writeRunPlanFiles(workDir, runPlan.files);
683
- const runResult = await runSubagentCommand(runPlan.command, runPlan.args, {
684
- timeoutMs: runPlan.timeoutMs ?? PROVIDER_VALIDATION_TIMEOUT_MS,
685
- cwd: workDir,
686
- env: resolveRunPlanEnv(runPlan.env, workDir),
687
- });
688
- if (!runResult.ok) {
689
- return {
690
- ok: false,
691
- durationMs: runResult.durationMs,
692
- error: runResult.error || runResult.stderr || 'Provider validation failed',
693
- };
874
+ for (const configPath of candidates) {
875
+ if (!fsSync.existsSync(configPath))
876
+ continue;
877
+ const backupPath = `${configPath}.backup.${Date.now()}`;
878
+ await fs.rename(configPath, backupPath);
879
+ resetPaths.push(configPath);
694
880
  }
881
+ return { reset: resetPaths.length > 0, paths: resetPaths };
882
+ }
883
+ catch (error) {
884
+ return {
885
+ reset: resetPaths.length > 0,
886
+ paths: resetPaths,
887
+ error: error instanceof Error ? error.message : String(error),
888
+ };
889
+ }
890
+ }
891
+ async function runProviderValidation(provider, model) {
892
+ const attempt = async () => {
893
+ const workRoot = resolveValidationWorkdirRoot();
894
+ const workDir = await createWorkDir(workRoot, [
895
+ { value: provider.id, label: 'provider id' },
896
+ { value: randomUUID(), label: 'validation run id' },
897
+ ]);
695
898
  try {
696
- provider.normalizeModelRunResult({
697
- stdout: runResult.stdout,
698
- outputFormat: runPlan.outputFormat,
699
- outputMode: runPlan.outputMode,
700
- outputPath: runPlan.outputPath,
701
- workDir,
899
+ const runPlan = provider.buildModelRunPlan({
900
+ model,
901
+ prompt: PROVIDER_VALIDATION_PROMPT,
902
+ jsonSchema: PROVIDER_VALIDATION_SCHEMA,
903
+ timeoutMs: PROVIDER_VALIDATION_TIMEOUT_MS,
702
904
  });
905
+ assertSafeOutputPath(workDir, runPlan.outputPath);
906
+ await writeRunPlanFiles(workDir, runPlan.files);
907
+ const runResult = await runSubagentCommand(runPlan.command, runPlan.args, {
908
+ timeoutMs: runPlan.timeoutMs ?? PROVIDER_VALIDATION_TIMEOUT_MS,
909
+ cwd: workDir,
910
+ env: resolveRunPlanEnv(runPlan.env, workDir),
911
+ });
912
+ if (!runResult.ok) {
913
+ return {
914
+ ok: false,
915
+ durationMs: runResult.durationMs,
916
+ error: runResult.error || runResult.stderr || 'Provider validation failed',
917
+ };
918
+ }
919
+ try {
920
+ provider.normalizeModelRunResult({
921
+ stdout: runResult.stdout,
922
+ outputFormat: runPlan.outputFormat,
923
+ outputMode: runPlan.outputMode,
924
+ outputPath: runPlan.outputPath,
925
+ workDir,
926
+ });
927
+ }
928
+ catch (error) {
929
+ return {
930
+ ok: false,
931
+ durationMs: runResult.durationMs,
932
+ error: error instanceof Error ? error.message : 'Failed to parse validation output',
933
+ };
934
+ }
935
+ return { ok: true, durationMs: runResult.durationMs };
703
936
  }
704
- catch (error) {
705
- return {
706
- ok: false,
707
- durationMs: runResult.durationMs,
708
- error: error instanceof Error ? error.message : 'Failed to parse validation output',
709
- };
937
+ finally {
938
+ await cleanupWorkDir(workDir, workRoot);
939
+ }
940
+ };
941
+ const result = await attempt();
942
+ if (result.ok)
943
+ return result;
944
+ if (provider.id === 'codex' && isCodexConfigError(result.error)) {
945
+ const reset = await resetCodexConfig();
946
+ if (reset.reset) {
947
+ logInfo('Reset Codex config after validation error', { paths: reset.paths });
948
+ queueGatewayEvent({
949
+ level: 'warn',
950
+ code: 'provider_config_reset',
951
+ provider: provider.id,
952
+ message: 'Codex config reset after validation error.',
953
+ details: {
954
+ paths: reset.paths,
955
+ error: reset.error ?? null,
956
+ },
957
+ });
958
+ return await attempt();
710
959
  }
711
- return { ok: true, durationMs: runResult.durationMs };
712
- }
713
- finally {
714
- await cleanupWorkDir(workDir, workRoot);
715
960
  }
961
+ return result;
716
962
  }
717
963
  function coerceHealthStatus(value) {
718
964
  if (value === 'healthy' || value === 'unhealthy' || value === 'unknown' || value === 'unavailable') {
@@ -751,8 +997,10 @@ async function resolveProviderHealth(params) {
751
997
  duration_ms: validation.durationMs,
752
998
  };
753
999
  }
1000
+ const fallbackStatus = coerceHealthStatus(lastFullStatus);
1001
+ const previousStatus = previous?.status ? coerceHealthStatus(previous.status) : undefined;
754
1002
  return {
755
- status: coerceHealthStatus(lastFullStatus),
1003
+ status: previousStatus ?? fallbackStatus,
756
1004
  check_type: 'light',
757
1005
  checked_at: now,
758
1006
  last_full_checked_at: lastFullCheckedAt,
@@ -853,6 +1101,129 @@ function appendClaudeIsolationArgs(args, support) {
853
1101
  args.push('--disable-slash-commands');
854
1102
  }
855
1103
  }
1104
+ function formatProviderLabel(providerId) {
1105
+ switch (providerId) {
1106
+ case 'claude_code':
1107
+ return 'Claude';
1108
+ case 'gemini':
1109
+ return 'Gemini';
1110
+ case 'codex':
1111
+ return 'Codex';
1112
+ default:
1113
+ return providerId;
1114
+ }
1115
+ }
1116
+ function normalizeProviderErrorSummary(message) {
1117
+ if (!message)
1118
+ return undefined;
1119
+ const normalized = message.replace(/\s+/g, ' ').trim();
1120
+ if (!normalized)
1121
+ return undefined;
1122
+ return truncateText(normalized, 500);
1123
+ }
1124
+ function buildProviderFailureMessage(providerId, detail) {
1125
+ const label = formatProviderLabel(providerId);
1126
+ const detailSuffix = detail ? ` Details: ${detail}` : '';
1127
+ return `Gateway provider ${label} is unhealthy. Try another provider or run "panorama-gateway doctor".${detailSuffix}`;
1128
+ }
1129
+ function isProviderReady(entry) {
1130
+ if (!entry)
1131
+ return false;
1132
+ if (entry.health?.status === 'healthy')
1133
+ return true;
1134
+ if (!entry.available)
1135
+ return false;
1136
+ if (!entry.health)
1137
+ return true;
1138
+ return entry.health.status !== 'unavailable' && entry.health.status !== 'unhealthy';
1139
+ }
1140
+ function computeGatewayStatusFromCapabilities(providers) {
1141
+ const anyReady = Object.values(providers).some((entry) => isProviderReady(entry));
1142
+ return anyReady ? 'ready' : 'error';
1143
+ }
1144
+ function resolveRuntimeProviderHealth(providerId) {
1145
+ if (CURRENT_PROVIDER_HEALTH && CURRENT_PROVIDER_HEALTH[providerId]) {
1146
+ return CURRENT_PROVIDER_HEALTH[providerId];
1147
+ }
1148
+ const fromConfig = CURRENT_CONFIG?.providerHealth?.[providerId];
1149
+ if (fromConfig)
1150
+ return fromConfig;
1151
+ return PROVIDER_CAPABILITIES?.[providerId]?.health;
1152
+ }
1153
+ async function updateRuntimeProviderHealth(params) {
1154
+ const providerCapabilities = PROVIDER_CAPABILITIES;
1155
+ if (!providerCapabilities || !providerCapabilities[params.providerId])
1156
+ return;
1157
+ const previous = resolveRuntimeProviderHealth(params.providerId);
1158
+ const nextStatus = params.status;
1159
+ const errorSummary = normalizeProviderErrorSummary(params.error);
1160
+ const nowIso = new Date().toISOString();
1161
+ const nextHealth = {
1162
+ status: nextStatus,
1163
+ check_type: 'light',
1164
+ checked_at: nowIso,
1165
+ last_full_checked_at: previous?.last_full_checked_at,
1166
+ last_full_status: previous?.last_full_status,
1167
+ error: nextStatus === 'healthy' ? undefined : errorSummary ?? previous?.error,
1168
+ };
1169
+ providerCapabilities[params.providerId] = {
1170
+ ...providerCapabilities[params.providerId],
1171
+ health: nextHealth,
1172
+ };
1173
+ if (CURRENT_CAPABILITIES) {
1174
+ const rawProviders = CURRENT_CAPABILITIES.providers;
1175
+ if (rawProviders && typeof rawProviders === 'object') {
1176
+ const providers = rawProviders;
1177
+ if (providers[params.providerId]) {
1178
+ providers[params.providerId] = {
1179
+ ...providers[params.providerId],
1180
+ health: nextHealth,
1181
+ };
1182
+ }
1183
+ CURRENT_CAPABILITIES = {
1184
+ ...CURRENT_CAPABILITIES,
1185
+ providers,
1186
+ };
1187
+ }
1188
+ }
1189
+ if (CURRENT_CONFIG) {
1190
+ if (!CURRENT_CONFIG.providerHealth) {
1191
+ CURRENT_CONFIG.providerHealth = {};
1192
+ }
1193
+ CURRENT_CONFIG.providerHealth[params.providerId] = nextHealth;
1194
+ void saveConfig(CURRENT_CONFIG).catch((error) => {
1195
+ logError('Failed to persist gateway provider health', {
1196
+ error: error instanceof Error ? error.message : String(error),
1197
+ provider: params.providerId,
1198
+ });
1199
+ });
1200
+ }
1201
+ CURRENT_PROVIDER_HEALTH = {
1202
+ ...(CURRENT_PROVIDER_HEALTH ?? {}),
1203
+ [params.providerId]: nextHealth,
1204
+ };
1205
+ const nextStatusOverall = computeGatewayStatusFromCapabilities(providerCapabilities);
1206
+ if (CURRENT_CAPABILITIES && CURRENT_DEVICE_NAME) {
1207
+ await sendHeartbeat(params.supabase, nextStatusOverall, CURRENT_CAPABILITIES, CURRENT_DEVICE_NAME);
1208
+ }
1209
+ const statusChanged = previous?.status !== nextStatus;
1210
+ if (statusChanged && nextStatus !== 'healthy' && CURRENT_CONFIG) {
1211
+ const label = formatProviderLabel(params.providerId);
1212
+ await emitGatewayEvent(params.supabase, CURRENT_CONFIG, {
1213
+ level: nextStatus === 'unhealthy' ? 'error' : 'warn',
1214
+ code: 'provider_unhealthy',
1215
+ provider: params.providerId,
1216
+ message: `${label} provider marked ${nextStatus}.`,
1217
+ details: {
1218
+ context: params.context,
1219
+ status: nextStatus,
1220
+ error: errorSummary ?? null,
1221
+ job_id: params.jobId ?? null,
1222
+ run_id: params.runId ?? null,
1223
+ },
1224
+ });
1225
+ }
1226
+ }
856
1227
  async function ensureClaudeGatewayHome() {
857
1228
  const claudeHome = resolveClaudeGatewayHome();
858
1229
  await fs.mkdir(claudeHome, { recursive: true });
@@ -2065,9 +2436,38 @@ async function handleSubagentRunJob(supabase, job) {
2065
2436
  const exitCodeMessage = activeResult.exitCode === null
2066
2437
  ? 'Subagent process did not exit cleanly'
2067
2438
  : `Subagent exited with code ${activeResult.exitCode}`;
2068
- const errorMessage = !activeResult.ok
2439
+ let errorMessage = !activeResult.ok
2069
2440
  ? (activeResult.error ?? stderrMessage ?? exitCodeMessage)
2070
2441
  : parseError ?? null;
2442
+ const providerId = adapter.id;
2443
+ if (!wasCancelled) {
2444
+ if (shouldFail) {
2445
+ const parsed = activeResult.stderr
2446
+ ? extractGatewayErrorSummary(activeResult.stderr)?.message
2447
+ : null;
2448
+ const providerSummary = normalizeProviderErrorSummary(parsed ?? errorMessage ?? null);
2449
+ await updateRuntimeProviderHealth({
2450
+ supabase,
2451
+ providerId,
2452
+ status: 'unhealthy',
2453
+ error: providerSummary ?? errorMessage,
2454
+ context: 'subagent_run',
2455
+ jobId: job.id,
2456
+ runId: resolvedRunId,
2457
+ });
2458
+ errorMessage = buildProviderFailureMessage(providerId, providerSummary ?? errorMessage);
2459
+ }
2460
+ else {
2461
+ await updateRuntimeProviderHealth({
2462
+ supabase,
2463
+ providerId,
2464
+ status: 'healthy',
2465
+ context: 'subagent_run',
2466
+ jobId: job.id,
2467
+ runId: resolvedRunId,
2468
+ });
2469
+ }
2470
+ }
2071
2471
  const failureOutput = buildErrorOutput(wasCancelled ? cancelReason : errorMessage ?? 'Subagent run failed');
2072
2472
  const metadataUpdate = {
2073
2473
  schema_version: SUBAGENT_SCHEMA_VERSION,
@@ -2197,6 +2597,16 @@ async function handleModelRunJob(_supabase, job) {
2197
2597
  const providerId = (providerFromPayload === 'claude_code' || providerFromPayload === 'codex' || providerFromPayload === 'gemini')
2198
2598
  ? providerFromPayload
2199
2599
  : inferGatewayProviderFromModel(model);
2600
+ const updateProviderHealth = async (status, errorMessage) => {
2601
+ await updateRuntimeProviderHealth({
2602
+ supabase: _supabase,
2603
+ providerId,
2604
+ status,
2605
+ error: errorMessage ?? null,
2606
+ context: 'model_run',
2607
+ jobId: job.id,
2608
+ });
2609
+ };
2200
2610
  const provider = getGatewayCliProvider(providerId);
2201
2611
  let normalizedPrompt = prompt;
2202
2612
  let normalizedSchema = jsonSchema;
@@ -2260,17 +2670,20 @@ async function handleModelRunJob(_supabase, job) {
2260
2670
  }
2261
2671
  await updateJobDebug(_supabase, job.id, debugInfo);
2262
2672
  if (!runResult.ok) {
2673
+ const summary = normalizeProviderErrorSummary(errorSummary?.message ?? runResult.error ?? 'Gateway model run failed');
2674
+ await updateProviderHealth('unhealthy', summary ?? 'Gateway model run failed');
2675
+ const failureMessage = buildProviderFailureMessage(providerId, summary);
2263
2676
  return {
2264
2677
  ok: false,
2265
2678
  result: {
2266
- message: 'Gateway CLI failed for model run',
2679
+ message: failureMessage,
2267
2680
  exit_code: runResult.exitCode,
2268
2681
  stderr: stderrInfo.value,
2269
2682
  stdout: stdoutInfo.value,
2270
2683
  command: commandLine,
2271
2684
  error_summary: errorSummary ?? null,
2272
2685
  },
2273
- error: runResult.error ?? 'Gateway model run failed',
2686
+ error: failureMessage,
2274
2687
  };
2275
2688
  }
2276
2689
  let normalized;
@@ -2285,12 +2698,21 @@ async function handleModelRunJob(_supabase, job) {
2285
2698
  }
2286
2699
  catch (error) {
2287
2700
  const message = error instanceof Error ? error.message : 'Failed to normalize gateway output';
2701
+ const summary = normalizeProviderErrorSummary(message);
2702
+ await updateProviderHealth('unhealthy', summary ?? message);
2703
+ const failureMessage = buildProviderFailureMessage(providerId, summary ?? message);
2288
2704
  return {
2289
2705
  ok: false,
2290
- result: { message, stdout: stdoutInfo.value, stderr: stderrInfo.value, command: commandLine },
2291
- error: message,
2706
+ result: {
2707
+ message: failureMessage,
2708
+ stdout: stdoutInfo.value,
2709
+ stderr: stderrInfo.value,
2710
+ command: commandLine,
2711
+ },
2712
+ error: failureMessage,
2292
2713
  };
2293
2714
  }
2715
+ await updateProviderHealth('healthy');
2294
2716
  return {
2295
2717
  ok: true,
2296
2718
  result: {
@@ -2455,7 +2877,7 @@ async function processJob(supabase, config, job) {
2455
2877
  job_type: claimed.job_type,
2456
2878
  }, `Unsupported job type: ${claimed.job_type}`);
2457
2879
  }
2458
- async function startGateway(options) {
2880
+ async function startGateway(options, configResolution) {
2459
2881
  const paths = resolveGatewayPaths(options);
2460
2882
  const foregroundRequested = options.foreground === true;
2461
2883
  const explicitDaemon = options.daemon === true || options.background === true;
@@ -2533,6 +2955,8 @@ async function startGateway(options) {
2533
2955
  deviceName,
2534
2956
  };
2535
2957
  await saveConfig(config, options);
2958
+ CURRENT_CONFIG = config;
2959
+ CURRENT_DEVICE_NAME = deviceName;
2536
2960
  const supabase = createClient(supabaseUrl, supabaseAnonKey, {
2537
2961
  auth: {
2538
2962
  persistSession: false,
@@ -2583,6 +3007,8 @@ async function startGateway(options) {
2583
3007
  }
2584
3008
  let currentCapabilities = initialCapabilities;
2585
3009
  let currentProviderHealth = providerHealth;
3010
+ CURRENT_CAPABILITIES = initialCapabilities;
3011
+ CURRENT_PROVIDER_HEALTH = providerHealth;
2586
3012
  const providerStatus = (currentCapabilities.providers ?? {});
2587
3013
  const initialStatus = anyProviderReady ? 'ready' : 'error';
2588
3014
  logInfo('Gateway capabilities loaded', {
@@ -2596,6 +3022,22 @@ async function startGateway(options) {
2596
3022
  ])),
2597
3023
  concurrency: GATEWAY_CONCURRENCY,
2598
3024
  });
3025
+ if (configResolution?.migratedFrom) {
3026
+ await emitGatewayEvent(supabase, config, {
3027
+ level: 'warn',
3028
+ code: 'config_dir_fallback',
3029
+ message: 'Gateway config directory was not writable; using fallback location.',
3030
+ details: {
3031
+ previous: configResolution.migratedFrom,
3032
+ current: configResolution.configDir,
3033
+ },
3034
+ });
3035
+ }
3036
+ await flushGatewayEvents(supabase, config);
3037
+ const ranFullValidation = Object.values(providerHealth).some((health) => health.check_type === 'full');
3038
+ if (ranFullValidation) {
3039
+ await emitProviderHealthEvents(supabase, config, providerHealth, 'startup');
3040
+ }
2599
3041
  await sendHeartbeat(supabase, initialStatus, currentCapabilities, deviceName);
2600
3042
  let heartbeatStatus = initialStatus;
2601
3043
  await writePidFile(process.pid, options);
@@ -2607,11 +3049,13 @@ async function startGateway(options) {
2607
3049
  try {
2608
3050
  const { capabilities: nextCapabilities, providerHealth: nextProviderHealth, anyProviderReady: nextReady, } = await buildCapabilities({
2609
3051
  validationMode: 'light',
2610
- previousHealth: currentProviderHealth,
3052
+ previousHealth: CURRENT_PROVIDER_HEALTH ?? currentProviderHealth,
2611
3053
  });
2612
3054
  currentCapabilities = nextCapabilities;
2613
3055
  currentProviderHealth = nextProviderHealth;
2614
3056
  config.providerHealth = nextProviderHealth;
3057
+ CURRENT_CAPABILITIES = nextCapabilities;
3058
+ CURRENT_PROVIDER_HEALTH = nextProviderHealth;
2615
3059
  heartbeatStatus = nextReady ? 'ready' : 'error';
2616
3060
  await sendHeartbeat(supabase, heartbeatStatus, currentCapabilities, deviceName);
2617
3061
  }
@@ -2827,6 +3271,64 @@ async function statusGateway(options) {
2827
3271
  { label: 'PID Path', value: formatPathForDisplay(pidPath), verboseOnly: true },
2828
3272
  ]);
2829
3273
  }
3274
+ async function doctorGateway(options, configResolution) {
3275
+ const { configPath, configDir } = resolveGatewayPaths(options);
3276
+ const configExists = fsSync.existsSync(configPath);
3277
+ let config = null;
3278
+ if (configExists) {
3279
+ try {
3280
+ config = await loadConfig(options);
3281
+ }
3282
+ catch (error) {
3283
+ cliWarn('Unable to read gateway config.', options, [
3284
+ { label: 'Config path', value: formatPathForDisplay(configPath), verboseOnly: true },
3285
+ { label: 'Error', value: error instanceof Error ? error.message : String(error), verboseOnly: true },
3286
+ ]);
3287
+ }
3288
+ }
3289
+ cliInfo('Gateway doctor report', options, [
3290
+ { label: 'Config dir', value: formatPathForDisplay(configDir) },
3291
+ { label: 'Paired', value: configExists ? 'Yes' : 'No' },
3292
+ { label: 'Gateway ID', value: config?.gatewayId ?? undefined, verboseOnly: true },
3293
+ { label: 'Team ID', value: config?.teamId ?? undefined, verboseOnly: true },
3294
+ ]);
3295
+ await ensureClaudeGatewayHome();
3296
+ const { providerHealth, anyProviderReady } = await buildCapabilities({
3297
+ validationMode: 'full',
3298
+ previousHealth: config?.providerHealth,
3299
+ });
3300
+ if (config) {
3301
+ config.providerHealth = providerHealth;
3302
+ await saveConfig(config, options);
3303
+ }
3304
+ const providerEntries = Object.entries(providerHealth);
3305
+ console.log('Provider status:');
3306
+ for (const [providerId, health] of providerEntries) {
3307
+ const error = health.error ? ` (${truncateText(health.error, 200)})` : '';
3308
+ console.log(` - ${providerId}: ${health.status}${error}`);
3309
+ }
3310
+ if (!anyProviderReady) {
3311
+ cliWarn('No providers passed validation. Install/authenticate a provider before starting the gateway.', options);
3312
+ }
3313
+ if (config) {
3314
+ const eventClient = await createGatewayEventClient(config);
3315
+ if (eventClient) {
3316
+ await flushGatewayEvents(eventClient, config);
3317
+ if (configResolution?.migratedFrom) {
3318
+ await emitGatewayEvent(eventClient, config, {
3319
+ level: 'warn',
3320
+ code: 'config_dir_fallback',
3321
+ message: 'Gateway config directory was not writable; using fallback location.',
3322
+ details: {
3323
+ previous: configResolution.migratedFrom,
3324
+ current: configResolution.configDir,
3325
+ },
3326
+ });
3327
+ }
3328
+ await emitProviderHealthEvents(eventClient, config, providerHealth, 'doctor');
3329
+ }
3330
+ }
3331
+ }
2830
3332
  async function showLogs(options) {
2831
3333
  const linesRaw = getStringOption(options, 'lines');
2832
3334
  const lines = linesRaw ? Number.parseInt(linesRaw, 10) : 200;
@@ -2897,16 +3399,20 @@ async function run() {
2897
3399
  }
2898
3400
  if (parsed.command === 'pair') {
2899
3401
  loadEnvironment(parsed.options);
3402
+ const configResolution = await prepareGatewayConfigDir(parsed.options, true);
3403
+ applyOptionEnvOverrides(parsed.options);
2900
3404
  const code = getStringOption(parsed.options, 'code') || parsed.positional[0];
2901
3405
  if (!code) {
2902
3406
  throw new Error('Pairing code is required');
2903
3407
  }
2904
- await pairGateway(code, parsed.options);
3408
+ await pairGateway(code, parsed.options, configResolution);
2905
3409
  return;
2906
3410
  }
2907
3411
  if (parsed.command === 'start') {
2908
3412
  loadEnvironment(parsed.options);
2909
- await startGateway(parsed.options);
3413
+ const configResolution = await prepareGatewayConfigDir(parsed.options, true);
3414
+ applyOptionEnvOverrides(parsed.options);
3415
+ await startGateway(parsed.options, configResolution);
2910
3416
  return;
2911
3417
  }
2912
3418
  if (parsed.command === 'stop') {
@@ -2921,6 +3427,13 @@ async function run() {
2921
3427
  await showLogs(parsed.options);
2922
3428
  return;
2923
3429
  }
3430
+ if (parsed.command === 'doctor') {
3431
+ loadEnvironment(parsed.options);
3432
+ const configResolution = await prepareGatewayConfigDir(parsed.options, true);
3433
+ applyOptionEnvOverrides(parsed.options);
3434
+ await doctorGateway(parsed.options, configResolution);
3435
+ return;
3436
+ }
2924
3437
  printHelp();
2925
3438
  process.exitCode = 1;
2926
3439
  }