@geravant/sinain 1.14.0 → 1.15.1

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.
@@ -0,0 +1,254 @@
1
+ // agents-loader.ts
2
+ //
3
+ // sinain-core's view of sinain-agent/agents.json. Loaded once at startup,
4
+ // gives config.ts a typed snapshot of the bare-agent + OpenClaw config that
5
+ // used to live in .env.
6
+ //
7
+ // Why sinain-core reads it at all: the OpenClaw WS client is constructed
8
+ // before the bare agent ever registers, and stays connected across the
9
+ // session for heartbeat / situation push / feedback. So sinain-core needs
10
+ // the gateway URL/token at startup — it can't wait for run.sh to forward
11
+ // them via /bareagent/register.
12
+ //
13
+ // Path resolution:
14
+ // 1. AGENTS_CONFIG_PATH env var (explicit override)
15
+ // 2. <repo-root>/sinain-agent/agents.json (default)
16
+ // 3. <repo-root>/sinain-agent/agents.example.json (fresh-checkout fallback)
17
+ //
18
+ // Returns null if no candidate exists. config.ts treats null as "use env
19
+ // defaults" so the migration is non-breaking.
20
+ //
21
+ // Field expansion happens in two passes (shell-like ordering):
22
+ // 1. Cross-field literal expansion: ${allowedTools} inside escAllowedTools
23
+ // resolves against other top-level fields. Lets users compose
24
+ // whitelists by reference.
25
+ // 2. Env-var indirection: ${OPENROUTER_API_KEY}, ${HOME}, etc. resolve
26
+ // against process.env. Mirrors run.sh's apply_profile_env regex
27
+ // (\$\{[A-Za-z_][A-Za-z0-9_]*\}).
28
+
29
+ import { readFileSync, existsSync } from "node:fs";
30
+ import { resolve, dirname } from "node:path";
31
+ import { fileURLToPath } from "node:url";
32
+ import { homedir } from "node:os";
33
+
34
+ const __dirname = dirname(fileURLToPath(import.meta.url));
35
+
36
+ // Regex shared with run.sh's apply_profile_env. Keep the two implementations
37
+ // in lockstep when changing either side.
38
+ const VAR_REF_RE = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g;
39
+
40
+ export interface OpenClawProfileFields {
41
+ type: "openclaw";
42
+ wsUrl?: string;
43
+ wsToken?: string;
44
+ httpUrl?: string;
45
+ httpToken?: string;
46
+ sessionKey?: string;
47
+ phase1TimeoutMs?: number;
48
+ phase2TimeoutMs?: number;
49
+ pingIntervalMs?: number;
50
+ }
51
+
52
+ export interface AgentProfile {
53
+ type?: string;
54
+ bin?: string;
55
+ settings?: string;
56
+ model?: string;
57
+ env?: Record<string, string>;
58
+ // openclaw-specific fields (typed loose since profiles are heterogeneous)
59
+ wsUrl?: string;
60
+ wsToken?: string;
61
+ httpUrl?: string;
62
+ httpToken?: string;
63
+ sessionKey?: string;
64
+ phase1TimeoutMs?: number;
65
+ phase2TimeoutMs?: number;
66
+ pingIntervalMs?: number;
67
+ }
68
+
69
+ export interface AnalyzerBlock {
70
+ debounceMs?: number;
71
+ maxIntervalMs?: number;
72
+ }
73
+
74
+ export interface EscalationBlock {
75
+ mode?: string;
76
+ cooldownMs?: number;
77
+ staleMs?: number;
78
+ }
79
+
80
+ export interface AgentsConfig {
81
+ default?: string;
82
+ pollIntervalSec?: number;
83
+ agentMaxTurns?: number;
84
+ spawnMaxTurns?: number;
85
+ allowedTools?: string;
86
+ escAllowedTools?: string;
87
+ spawnAllowedTools?: string;
88
+ autoApproveTools?: string;
89
+ analyzer?: AnalyzerBlock;
90
+ escalation?: EscalationBlock;
91
+ profiles?: Record<string, AgentProfile>;
92
+ /** Absolute path of the file actually loaded (for debug logging). */
93
+ loadedFrom?: string;
94
+ }
95
+
96
+ function expandVars(value: string): string {
97
+ return value.replace(VAR_REF_RE, (_, name: string) => process.env[name] ?? "");
98
+ }
99
+
100
+ /** Recursively expand ${env} references in any string field of `obj`. */
101
+ function expandEnvDeep<T>(obj: T): T {
102
+ if (obj === null || obj === undefined) return obj;
103
+ if (typeof obj === "string") return expandVars(obj) as unknown as T;
104
+ if (Array.isArray(obj)) return obj.map((x) => expandEnvDeep(x)) as unknown as T;
105
+ if (typeof obj === "object") {
106
+ const out: Record<string, unknown> = {};
107
+ for (const [k, v] of Object.entries(obj as Record<string, unknown>)) {
108
+ out[k] = expandEnvDeep(v);
109
+ }
110
+ return out as T;
111
+ }
112
+ return obj;
113
+ }
114
+
115
+ /**
116
+ * Cross-field literal expansion at the top level. Lets users write
117
+ * "escAllowedTools": "${allowedTools} Bash(git:*) Edit"
118
+ * and have it compose with the value of `allowedTools`. Only string
119
+ * top-level fields are eligible to be referenced; nested blocks aren't.
120
+ */
121
+ function expandTopLevelRefs(cfg: AgentsConfig): void {
122
+ const refTargets: Record<string, string | undefined> = {
123
+ allowedTools: cfg.allowedTools,
124
+ escAllowedTools: cfg.escAllowedTools,
125
+ spawnAllowedTools: cfg.spawnAllowedTools,
126
+ autoApproveTools: cfg.autoApproveTools,
127
+ default: cfg.default,
128
+ };
129
+ const expand = (s?: string): string | undefined => {
130
+ if (!s) return s;
131
+ return s.replace(VAR_REF_RE, (m, name: string) => {
132
+ const ref = refTargets[name];
133
+ // Only substitute when the referenced field is a top-level field
134
+ // *that has a value*. Otherwise leave the placeholder untouched so
135
+ // the env-var pass can still pick it up (e.g. ${HOME}).
136
+ return ref !== undefined ? ref : m;
137
+ });
138
+ };
139
+ cfg.escAllowedTools = expand(cfg.escAllowedTools);
140
+ cfg.spawnAllowedTools = expand(cfg.spawnAllowedTools);
141
+ }
142
+
143
+ function candidatePaths(): string[] {
144
+ const explicit = process.env.AGENTS_CONFIG_PATH;
145
+ if (explicit) return [resolve(explicit)];
146
+ // ~/.sinain/agents.json is the wizard's write target — works on npm
147
+ // installs where the package dir is read-only. Highest priority after
148
+ // an explicit env override so user-home config wins over the shipped
149
+ // template.
150
+ const userHome = resolve(homedir(), ".sinain", "agents.json");
151
+ // sinain-core compiled output sits at sinain-core/dist/, source at
152
+ // sinain-core/src/. From either, sinain-agent is two levels up.
153
+ const repoRoot = resolve(__dirname, "..", "..");
154
+ return [
155
+ userHome,
156
+ resolve(repoRoot, "sinain-agent", "agents.json"),
157
+ resolve(repoRoot, "sinain-agent", "agents.example.json"),
158
+ ];
159
+ }
160
+
161
+ /**
162
+ * Load agents.json (or fall back to agents.example.json). Returns null if
163
+ * neither candidate file exists or parsing fails. Caller (config.ts) is
164
+ * expected to treat null as "use env defaults" so the migration is
165
+ * non-breaking.
166
+ */
167
+ export function loadAgentsConfig(): AgentsConfig | null {
168
+ for (const path of candidatePaths()) {
169
+ if (!existsSync(path)) continue;
170
+ try {
171
+ const raw = readFileSync(path, "utf-8");
172
+ const parsed = JSON.parse(raw) as AgentsConfig;
173
+ // Pass 1: cross-field literal substitution at top level. Has to run
174
+ // before env-var expansion so `${allowedTools}` doesn't accidentally
175
+ // resolve to process.env.allowedTools (which doesn't exist anyway,
176
+ // but the principle of layered substitution is what we want).
177
+ expandTopLevelRefs(parsed);
178
+ // Pass 2: ${ENV_VAR} expansion across the whole tree. Profile-level
179
+ // env blocks are deliberately skipped here — those are applied by
180
+ // run.sh in a subshell at invocation time, not by sinain-core.
181
+ const profiles = parsed.profiles;
182
+ delete parsed.profiles;
183
+ const expanded = expandEnvDeep(parsed);
184
+ // Restore profiles (with their own env blocks expanded for the few
185
+ // openclaw-specific fields that sinain-core does read).
186
+ if (profiles) {
187
+ const expandedProfiles: Record<string, AgentProfile> = {};
188
+ for (const [name, profile] of Object.entries(profiles)) {
189
+ // Don't expand the per-profile `env` block — those are evaluated
190
+ // at agent invocation time inside run.sh. But DO expand other
191
+ // profile fields (wsToken, etc.) since sinain-core reads them.
192
+ const { env: profileEnv, ...rest } = profile;
193
+ expandedProfiles[name] = {
194
+ ...expandEnvDeep(rest),
195
+ ...(profileEnv !== undefined ? { env: profileEnv } : {}),
196
+ };
197
+ }
198
+ expanded.profiles = expandedProfiles;
199
+ }
200
+ expanded.loadedFrom = path;
201
+ return expanded;
202
+ } catch (err) {
203
+ console.warn(`[agents-loader] failed to parse ${path}: ${(err as Error).message}`);
204
+ }
205
+ }
206
+ return null;
207
+ }
208
+
209
+ /** Convenience: pull the openclaw profile if present, else null.
210
+ * @deprecated Prefer findGatewayProfile() — it doesn't assume the name. */
211
+ export function getOpenClawProfile(cfg: AgentsConfig | null): AgentProfile | null {
212
+ return cfg?.profiles?.openclaw ?? null;
213
+ }
214
+
215
+ /**
216
+ * Find a gateway-style profile (any profile with `type: "openclaw"`,
217
+ * regardless of the profile's name). Used by sinain-core to:
218
+ * - load WS connection params at startup (config.ts)
219
+ * - decide whether a lane choice should route via WS or HTTP (escalator.ts)
220
+ *
221
+ * Preference order: the canonical name "openclaw" wins if present, otherwise
222
+ * the first openclaw-typed profile found in the JSON. This lets users add
223
+ * `nemoclaw`, `nanoclaw-prod`, etc. with their own URLs as drop-in replacements.
224
+ */
225
+ export function findGatewayProfile(
226
+ cfg: AgentsConfig | null,
227
+ ): { name: string; profile: AgentProfile } | null {
228
+ const profiles = cfg?.profiles;
229
+ if (!profiles) return null;
230
+ if (profiles.openclaw?.type === "openclaw") {
231
+ return { name: "openclaw", profile: profiles.openclaw };
232
+ }
233
+ for (const [name, profile] of Object.entries(profiles)) {
234
+ if (profile?.type === "openclaw") return { name, profile };
235
+ }
236
+ return null;
237
+ }
238
+
239
+ /** Is the given profile name a gateway-style (WS-dispatched) profile? */
240
+ export function isGatewayProfile(
241
+ cfg: AgentsConfig | null,
242
+ name: string,
243
+ ): boolean {
244
+ return cfg?.profiles?.[name]?.type === "openclaw";
245
+ }
246
+
247
+ /** All openclaw-typed profile names in the config. */
248
+ export function gatewayProfileNames(cfg: AgentsConfig | null): string[] {
249
+ const profiles = cfg?.profiles;
250
+ if (!profiles) return [];
251
+ return Object.entries(profiles)
252
+ .filter(([_, p]) => p?.type === "openclaw")
253
+ .map(([name]) => name);
254
+ }
@@ -2,8 +2,9 @@ import { readFileSync, existsSync } from "node:fs";
2
2
  import { resolve, dirname } from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
4
  import os from "node:os";
5
- import type { CoreConfig, AudioPipelineConfig, TranscriptionConfig, AnalysisConfig, EscalationConfig, OpenClawConfig, EscalationMode, EscalationTransport, LearningConfig, PrivacyConfig, PrivacyMatrix, PrivacyLevel, PrivacyRow } from "./types.js";
5
+ import type { CoreConfig, AudioPipelineConfig, TranscriptionConfig, AnalysisConfig, EscalationConfig, OpenClawConfig, EscalationMode, LearningConfig, PrivacyConfig, PrivacyMatrix, PrivacyLevel, PrivacyRow } from "./types.js";
6
6
  import { PRESETS } from "./privacy/presets.js";
7
+ import { loadAgentsConfig, findGatewayProfile, type AgentsConfig } from "./agents-loader.js";
7
8
 
8
9
  const __dirname = dirname(fileURLToPath(import.meta.url));
9
10
 
@@ -140,6 +141,26 @@ function loadPrivacyConfig(): PrivacyConfig {
140
141
  }
141
142
 
142
143
  export function loadConfig(): CoreConfig {
144
+ // sinain-agent/agents.json is the new single source of truth for the
145
+ // bare-agent + OpenClaw config that used to live in .env. Loading
146
+ // happens once at startup; null means the file isn't there (fresh
147
+ // checkout, custom layout, etc.) and we fall back to env defaults.
148
+ const agentsCfg = loadAgentsConfig();
149
+ if (agentsCfg) {
150
+ console.log(`[config] loaded agents config from ${agentsCfg.loadedFrom}`);
151
+ }
152
+
153
+ // Helpers that prefer agents.json values, then env, then a hardcoded
154
+ // default. Keeps .env working as a safety net during the migration.
155
+ const fromCfgStr = (cfgVal: string | undefined, envKey: string, def: string): string => {
156
+ if (cfgVal !== undefined && cfgVal !== "") return cfgVal;
157
+ return env(envKey, def);
158
+ };
159
+ const fromCfgInt = (cfgVal: number | undefined, envKey: string, def: number): number => {
160
+ if (cfgVal !== undefined) return cfgVal;
161
+ return intEnv(envKey, def);
162
+ };
163
+
143
164
  const audioConfig: AudioPipelineConfig = {
144
165
  device: env("AUDIO_DEVICE", "BlackHole 2ch"),
145
166
  sampleRate: intEnv("AUDIO_SAMPLE_RATE", 16000),
@@ -196,30 +217,57 @@ export function loadConfig(): CoreConfig {
196
217
  .split(",").map(s => s.trim()).filter(Boolean),
197
218
  timeout: intEnv("ANALYSIS_TIMEOUT", 15000),
198
219
  pushToFeed: boolEnv("AGENT_PUSH_TO_FEED", true),
199
- debounceMs: intEnv("AGENT_DEBOUNCE_MS", 3000),
200
- maxIntervalMs: intEnv("AGENT_MAX_INTERVAL_MS", 30000),
220
+ // analyzer pacing: agents.json `analyzer` block, fall back to env
221
+ debounceMs: fromCfgInt(agentsCfg?.analyzer?.debounceMs, "AGENT_DEBOUNCE_MS", 3000),
222
+ maxIntervalMs: fromCfgInt(agentsCfg?.analyzer?.maxIntervalMs, "AGENT_MAX_INTERVAL_MS", 30000),
201
223
  cooldownMs: intEnv("AGENT_COOLDOWN_MS", 10000),
202
224
  maxAgeMs: intEnv("AGENT_MAX_AGE_MS", 120000),
203
225
  historyLimit: intEnv("AGENT_HISTORY_LIMIT", 50),
204
226
  };
205
227
 
206
- const escalationMode = env("ESCALATION_MODE", "rich") as EscalationMode;
228
+ // escalation policy: agents.json `escalation` block, fall back to env.
229
+ // Mode is runtime-mutable via the overlay's flash-icon selector; this only
230
+ // sets the boot-time default. (Transport is no longer a setting — per-lane
231
+ // agent choice in the overlay determines WS vs HTTP dispatch.)
232
+ const escalationMode = fromCfgStr(agentsCfg?.escalation?.mode, "ESCALATION_MODE", "rich") as EscalationMode;
207
233
  const escalationConfig: EscalationConfig = {
208
234
  mode: escalationMode,
209
- cooldownMs: intEnv("ESCALATION_COOLDOWN_MS", 30000),
210
- staleMs: intEnv("ESCALATION_STALE_MS", 90000),
211
- transport: env("ESCALATION_TRANSPORT", "auto") as EscalationTransport,
235
+ cooldownMs: fromCfgInt(agentsCfg?.escalation?.cooldownMs, "ESCALATION_COOLDOWN_MS", 30000),
236
+ staleMs: fromCfgInt(agentsCfg?.escalation?.staleMs, "ESCALATION_STALE_MS", 90000),
212
237
  };
213
238
 
239
+ // OpenClaw gateway config: pick the first openclaw-TYPED profile
240
+ // (preferring the canonical name "openclaw"). This lets users add
241
+ // server-side variants — `nemoclaw`, `nanoclaw-prod`, etc. — as full
242
+ // peers; the WS client connects to whichever shows up first in the
243
+ // profiles list. Removing all openclaw-typed profiles (and unsetting
244
+ // the env vars) leaves gatewayWsUrl empty → registerBareAgent skips
245
+ // injecting any gateway entry into the roster, and escalator.start()
246
+ // skips the WS connect.
247
+ //
248
+ // Limitation: only ONE gateway URL gets a live WS client today. Adding
249
+ // multiple openclaw-typed profiles with different URLs lets the user
250
+ // pick them in the overlay roster, but dispatch always uses the first
251
+ // one's connection params. True per-profile WS clients is a follow-up.
252
+ const gateway = findGatewayProfile(agentsCfg);
253
+ const openclawProfile = gateway?.profile;
254
+ // wsUrl/hookUrl default to empty so "delete the openclaw profile" genuinely
255
+ // disables the gateway. The shipped agents.example.json includes the
256
+ // profile with localhost defaults, so fresh installs still work out of
257
+ // the box; removal is an explicit opt-out.
214
258
  const openclawConfig: OpenClawConfig = {
215
- gatewayWsUrl: envAllowEmpty("OPENCLAW_WS_URL", "OPENCLAW_GATEWAY_WS_URL", "ws://localhost:18789"),
216
- gatewayToken: env("OPENCLAW_WS_TOKEN", env("OPENCLAW_GATEWAY_TOKEN", "")),
217
- hookUrl: envAllowEmpty("OPENCLAW_HTTP_URL", "OPENCLAW_HOOK_URL", "http://localhost:18789/hooks/agent"),
218
- hookToken: env("OPENCLAW_HTTP_TOKEN", env("OPENCLAW_HOOK_TOKEN", "")),
219
- sessionKey: env("OPENCLAW_SESSION_KEY", "agent:main:sinain"),
220
- phase1TimeoutMs: intEnv("OPENCLAW_PHASE1_TIMEOUT_MS", 30_000),
221
- phase2TimeoutMs: intEnv("OPENCLAW_PHASE2_TIMEOUT_MS", 120_000),
222
- pingIntervalMs: intEnv("OPENCLAW_PING_INTERVAL_MS", 30_000),
259
+ gatewayWsUrl: fromCfgStr(openclawProfile?.wsUrl, "OPENCLAW_WS_URL",
260
+ envAllowEmpty("OPENCLAW_GATEWAY_WS_URL", undefined, "")),
261
+ gatewayToken: fromCfgStr(openclawProfile?.wsToken, "OPENCLAW_WS_TOKEN",
262
+ env("OPENCLAW_GATEWAY_TOKEN", "")),
263
+ hookUrl: fromCfgStr(openclawProfile?.httpUrl, "OPENCLAW_HTTP_URL",
264
+ envAllowEmpty("OPENCLAW_HOOK_URL", undefined, "")),
265
+ hookToken: fromCfgStr(openclawProfile?.httpToken, "OPENCLAW_HTTP_TOKEN",
266
+ env("OPENCLAW_HOOK_TOKEN", "")),
267
+ sessionKey: fromCfgStr(openclawProfile?.sessionKey, "OPENCLAW_SESSION_KEY", "agent:main:sinain"),
268
+ phase1TimeoutMs: fromCfgInt(openclawProfile?.phase1TimeoutMs, "OPENCLAW_PHASE1_TIMEOUT_MS", 30_000),
269
+ phase2TimeoutMs: fromCfgInt(openclawProfile?.phase2TimeoutMs, "OPENCLAW_PHASE2_TIMEOUT_MS", 120_000),
270
+ pingIntervalMs: fromCfgInt(openclawProfile?.pingIntervalMs, "OPENCLAW_PING_INTERVAL_MS", 30_000),
223
271
  };
224
272
 
225
273
  const situationDir = env("OPENCLAW_WORKSPACE_DIR", "~/.openclaw/workspace");
@@ -236,6 +284,19 @@ export function loadConfig(): CoreConfig {
236
284
 
237
285
  const privacyConfig = loadPrivacyConfig();
238
286
 
287
+ // autoApproveTools: agents.json top-level field, falls back to
288
+ // SINAIN_AUTO_APPROVE_TOOLS env. Space-separated tool names or prefix
289
+ // patterns (suffix *) that sinain-core auto-approves at /spawn/approve
290
+ // without routing to the overlay.
291
+ const autoApproveRaw = fromCfgStr(
292
+ agentsCfg?.autoApproveTools,
293
+ "SINAIN_AUTO_APPROVE_TOOLS",
294
+ "Read Glob Grep Ls Cat mcp__sinain*",
295
+ );
296
+ const permissionsConfig = {
297
+ autoApproveTools: autoApproveRaw.split(/\s+/).filter((t) => t.length > 0),
298
+ };
299
+
239
300
  return {
240
301
  port: intEnv("PORT", 9500),
241
302
  audioConfig,
@@ -251,5 +312,6 @@ export function loadConfig(): CoreConfig {
251
312
  costDisplayEnabled: boolEnv("COST_DISPLAY_ENABLED", false),
252
313
  learningConfig,
253
314
  privacyConfig,
315
+ permissionsConfig,
254
316
  };
255
317
  }
@@ -33,6 +33,23 @@ export interface EscalatorDeps {
33
33
  feedbackStore?: FeedbackStore;
34
34
  signalCollector?: SignalCollector;
35
35
  queryKnowledgeFacts?: (entities: string[], maxFacts: number) => Promise<string>;
36
+ /** Returns the currently-selected spawn-lane agent from the bare-agent
37
+ * roster ("" = Off). When a local agent is selected, dispatchSpawnTask
38
+ * prefers the HTTP bare-agent path over the OpenClaw gateway WS path,
39
+ * so the overlay's agent-selector choice is respected even when the
40
+ * gateway is connected. */
41
+ getSpawnAgent?: () => string;
42
+ /** Returns the currently-selected escalation-lane agent. Gateway-typed
43
+ * profiles (any agent whose `type` is "openclaw" — see isGatewayAgent)
44
+ * route via WS; any other non-empty value routes to the local bare
45
+ * agent via HTTP httpPending. */
46
+ getEscalationAgent?: () => string;
47
+ /** Returns true if the named profile is a gateway-style profile
48
+ * (i.e. dispatched via WS RPC, not invoked as a local CLI). Lookup is
49
+ * by `agentsCfg.profiles[name].type === "openclaw"`. Custom profiles
50
+ * like "nemoclaw" or "nanoclaw-prod" with that type get WS dispatch
51
+ * automatically — the routing key is type, not name. */
52
+ isGatewayAgent?: (name: string) => boolean;
36
53
  }
37
54
 
38
55
  /**
@@ -52,6 +69,14 @@ export class Escalator {
52
69
  private slot: EscalationSlot;
53
70
  private httpPending: HttpPendingEscalation | null = null;
54
71
 
72
+ // Grace window for stale escalation IDs — when analyzer rotates the pending
73
+ // slot mid-response (agent takes 10-30s on MCP flow while ticks fire every
74
+ // 3-6s), the agent's respondHttp(oldId) would fail. Keep last 5 IDs for ~60s
75
+ // so those responses still land on HUD instead of being silently dropped.
76
+ private recentHttpIds: Array<{ id: string; ts: number }> = [];
77
+ private static readonly STALE_ID_GRACE_MS = 60_000;
78
+ private static readonly STALE_ID_BUFFER_SIZE = 5;
79
+
55
80
  private lastEscalationTs = 0;
56
81
  private lastEscalatedDigest = "";
57
82
 
@@ -139,9 +164,17 @@ export class Escalator {
139
164
  log(TAG, `user command set: "${preview}"`);
140
165
  }
141
166
 
142
- /** Start the WS connection to OpenClaw (skipped when transport=http). */
167
+ /** Start the WS connection to OpenClaw.
168
+ *
169
+ * Connects whenever the gateway URL is configured AND escalation isn't
170
+ * fully off. WS is the transport for the openclaw lane — the user selects
171
+ * it via the overlay's agent picker, and dispatch routes accordingly.
172
+ * Removing the openclaw profile from agents.json (and unsetting the env
173
+ * vars) leaves gatewayWsUrl empty → no connect attempt.
174
+ */
143
175
  start(): void {
144
- if (this.deps.escalationConfig.mode !== "off" && this.deps.escalationConfig.transport !== "http") {
176
+ const wsConfigured = !!this.deps.openclawConfig.gatewayWsUrl;
177
+ if (this.deps.escalationConfig.mode !== "off" && wsConfigured) {
145
178
  this.wsClient.connect();
146
179
  const tokenHash = this.deps.openclawConfig.gatewayToken
147
180
  ? createHash("sha256").update(this.deps.openclawConfig.gatewayToken).digest("hex").slice(0, 12)
@@ -185,11 +218,16 @@ export class Escalator {
185
218
  this.pendingUserCommand = null;
186
219
  }
187
220
 
188
- // Skip WS escalations when circuit is open (HTTP transport bypasses this)
189
- const transport = this.deps.escalationConfig.transport;
190
- if (this.wsClient.isCircuitOpen && transport !== "http") {
191
- log(TAG, `tick #${entry.id}: skipped circuit breaker open`);
192
- return;
221
+ // Early skip when circuit is open AND the user has selected openclaw —
222
+ // saves the cost of building the escalation message just to drop it.
223
+ // Local-agent lanes (claude, openclaude, etc.) bypass this since they
224
+ // route via HTTP and don't depend on WS.
225
+ if (this.wsClient.isCircuitOpen) {
226
+ const escalationAgent = this.deps.getEscalationAgent?.() || "";
227
+ if (this.deps.isGatewayAgent?.(escalationAgent)) {
228
+ log(TAG, `tick #${entry.id}: skipped — circuit breaker open and gateway agent "${escalationAgent}" selected`);
229
+ return;
230
+ }
193
231
  }
194
232
 
195
233
  // If user command is pending, force escalation (bypass score + cooldown)
@@ -275,9 +313,48 @@ export class Escalator {
275
313
  ts: entry.ts,
276
314
  };
277
315
 
278
- const useHttp = transport === "http" || (transport === "auto" && !this.wsClient.isConnected);
316
+ // Per-lane dispatch: agent identity *is* the transport.
317
+ // - profile.type === "openclaw" (gateway-style) → WS dispatch
318
+ // - any other non-empty agent (local CLI: claude, openclaude, ...) → HTTP
319
+ // - empty (Off) → escalator.setMode("off") should have stopped us
320
+ // upstream; defensive bailout.
321
+ //
322
+ // Routing keys off the profile's `type` field, not its name, so custom
323
+ // gateway profiles like "nemoclaw" or "nanoclaw-prod" route via WS
324
+ // automatically as long as they declare `type: "openclaw"` in agents.json.
325
+ //
326
+ // openclaw + WS-disconnected drops with a toast (no HTTP fallback),
327
+ // because the bare agent can't run a gateway profile as a local CLI —
328
+ // the fallback caused infinite skip loops historically.
329
+ const escalationAgent = this.deps.getEscalationAgent?.() || "";
330
+ const isGateway = this.deps.isGatewayAgent?.(escalationAgent) ?? false;
331
+ let useHttp: boolean;
332
+ if (isGateway) {
333
+ if (!this.wsClient.isConnected) {
334
+ log(TAG, `escalation dropped: gateway agent "${escalationAgent}" selected but WS disconnected`);
335
+ this.deps.wsHandler.broadcast(
336
+ `⚠ Gateway disconnected — escalation dropped. Pick a local agent or check the ${escalationAgent} gateway.`,
337
+ "high",
338
+ );
339
+ return;
340
+ }
341
+ useHttp = false;
342
+ } else if (escalationAgent) {
343
+ useHttp = true;
344
+ } else {
345
+ log(TAG, `escalation dropped: lane is Off (escalationAgent="")`);
346
+ return;
347
+ }
279
348
 
280
349
  if (useHttp) {
350
+ // Remember the outgoing ID before overwriting so late-arriving responses
351
+ // still find a valid match in respondHttp's grace window.
352
+ if (this.httpPending) {
353
+ this.recentHttpIds.push({ id: this.httpPending.id, ts: this.httpPending.ts });
354
+ if (this.recentHttpIds.length > Escalator.STALE_ID_BUFFER_SIZE) {
355
+ this.recentHttpIds.shift();
356
+ }
357
+ }
281
358
  // Store in HTTP pending slot (newest wins, like EscalationSlot)
282
359
  this.httpPending = {
283
360
  id: slotId,
@@ -287,13 +364,50 @@ export class Escalator {
287
364
  ts: entry.ts,
288
365
  feedbackCtx: slotEntry.feedbackCtx,
289
366
  };
290
- log(TAG, `tick #${entry.id} → httpPending id=${slotId} (transport=${transport})`);
367
+ log(TAG, `tick #${entry.id} → httpPending id=${slotId} (lane=${escalationAgent || "<default>"})`);
291
368
  } else {
292
369
  log(TAG, `tick #${entry.id} → slot.insert id=${slotId} depth=${this.slot.depth}`);
293
370
  this.slot.insert(slotEntry);
294
371
  }
295
372
  }
296
373
 
374
+ /** Redispatch a stale httpPending escalation through the WS slot.
375
+ *
376
+ * Called by index.ts when the escalation lane flips to a gateway-typed
377
+ * agent (e.g., openclaude → openclaw): an escalation queued for HTTP
378
+ * before the switch is now mis-routed. Rather than letting the bare
379
+ * agent skip it (which posts a confusing "[skipped: gateway-routed]"
380
+ * to the user's HUD), we move it into the WS slot so the gateway
381
+ * actually handles the user's pending question.
382
+ *
383
+ * If WS isn't connected, silently clear httpPending — the agent loop
384
+ * will produce a new escalation through the proper drop-with-toast
385
+ * path on the next tick. Better than the user seeing the skip message
386
+ * AND the gateway-disconnect toast for the same logical event.
387
+ *
388
+ * Returns true if a redispatch (or clear) actually happened, so the
389
+ * caller can log meaningfully.
390
+ */
391
+ redispatchHttpPendingToWs(): boolean {
392
+ if (!this.httpPending) return false;
393
+ const stale = this.httpPending;
394
+ this.httpPending = null;
395
+ if (!this.wsClient.isConnected) {
396
+ log(TAG, `redispatch skipped: WS not connected — cleared stale httpPending id=${stale.id}`);
397
+ return true;
398
+ }
399
+ const slotEntry: SlotEntry = {
400
+ id: stale.id,
401
+ message: stale.message,
402
+ sessionKey: this.deps.openclawConfig.sessionKey,
403
+ feedbackCtx: stale.feedbackCtx,
404
+ ts: stale.ts,
405
+ };
406
+ log(TAG, `redispatching stale httpPending id=${stale.id} → WS slot (lane switched to gateway)`);
407
+ this.slot.insert(slotEntry);
408
+ return true;
409
+ }
410
+
297
411
  /** Push fresh SITUATION.md content to the gateway server (fire-and-forget). */
298
412
  pushSituationMd(content: string): void {
299
413
  if (!this.wsClient.isConnected) return;
@@ -381,11 +495,29 @@ ${recentLines.join("\n")}`;
381
495
 
382
496
  /** Respond to an HTTP pending escalation. */
383
497
  respondHttp(id: string, response: string): { ok: boolean; error?: string } {
384
- if (!this.httpPending) {
385
- return { ok: false, error: "no pending escalation" };
386
- }
387
- if (this.httpPending.id !== id) {
388
- return { ok: false, error: `id mismatch: expected ${this.httpPending.id}` };
498
+ // Grace path: the agent's response arrived for a stale ID because the
499
+ // analyzer rotated the pending slot mid-flight. Still push to HUD — the
500
+ // response was written against context that was fresh seconds ago and is
501
+ // almost certainly still relevant — but don't clear the current pending,
502
+ // so the agent can still address the newer escalation on its next poll.
503
+ if (!this.httpPending || this.httpPending.id !== id) {
504
+ const recent = this.recentHttpIds.find((e) => e.id === id);
505
+ if (recent && Date.now() - recent.ts < Escalator.STALE_ID_GRACE_MS) {
506
+ // Grace path: response was generated against a context that's now
507
+ // stale (analyzer rotated the slot mid-flight) but still recent
508
+ // enough that the answer is almost certainly still relevant.
509
+ // Push to HUD and return a clean ok=true — don't surface the
510
+ // grace marker on the wire, because generic LLM clients read
511
+ // any non-empty `error` field as a failure signal and write
512
+ // apologetic meta-messages to the user. The breadcrumb stays
513
+ // in this log for debug.
514
+ log(TAG, `respondHttp grace: id=${id} is stale (rotated ${((Date.now() - recent.ts) / 1000).toFixed(1)}s ago) — pushing to HUD anyway`);
515
+ this.pushResponse(response, this.lastEscalationContext);
516
+ return { ok: true };
517
+ }
518
+ return this.httpPending
519
+ ? { ok: false, error: `id mismatch: expected ${this.httpPending.id}` }
520
+ : { ok: false, error: "no pending escalation" };
389
521
  }
390
522
 
391
523
  this.pushResponse(response, this.lastEscalationContext);
@@ -446,7 +578,6 @@ ${recentLines.join("\n")}`;
446
578
  getStats(): Record<string, unknown> {
447
579
  return {
448
580
  mode: this.deps.escalationConfig.mode,
449
- transport: this.deps.escalationConfig.transport,
450
581
  gatewayConnected: this.wsClient.isConnected,
451
582
  circuitOpen: this.wsClient.isCircuitOpen,
452
583
  slotDepth: this.slot.depth,
@@ -501,13 +632,42 @@ ${recentLines.join("\n")}`;
501
632
  // ★ Broadcast "spawned" BEFORE the RPC — TSK tab shows ··· immediately
502
633
  this.broadcastTaskEvent(taskId, "spawned", label, startedAt);
503
634
 
504
- if (!this.wsClient.isConnected) {
505
- // No OpenClaw gateway queue for bare agent HTTP polling
635
+ // Route explicitly by the overlay's spawn-agent selection:
636
+ // "openclaw" (or "" with WS connected) send to remote gateway via WS RPC
637
+ // any other non-empty value → queue for local bare agent HTTP poll
638
+ // "" with WS disconnected → queue for HTTP fallback (same)
639
+ // This makes the overlay's choice authoritative. Before openclaw was a
640
+ // roster option, the old heuristic "if WS connected, use gateway" hijacked
641
+ // every spawn regardless of user intent, which surfaced as 401/credential
642
+ // errors from the gateway's stale OpenRouter key.
643
+ // Per-lane dispatch (mirror of escalation routing above):
644
+ // - profile.type === "openclaw" → WS to gateway (drop with toast if
645
+ // WS down — bare agent can't run gateway profiles as local CLIs)
646
+ // - any other non-empty agent → HTTP queue for bare agent polling
647
+ // - empty (Off) → drop; the spawn poll skip in run.sh should already
648
+ // prevent us from getting here.
649
+ const spawnAgent = this.deps.getSpawnAgent?.() || "";
650
+ const spawnIsGateway = this.deps.isGatewayAgent?.(spawnAgent) ?? false;
651
+ if (spawnIsGateway) {
652
+ if (!this.wsClient.isConnected) {
653
+ log(TAG, `spawn-task ${taskId}: dropped — gateway agent "${spawnAgent}" selected but WS disconnected`);
654
+ this.deps.wsHandler.broadcast(
655
+ `⚠ Gateway disconnected — spawn task dropped. Pick a local agent or check the ${spawnAgent} gateway.`,
656
+ "high",
657
+ );
658
+ return;
659
+ }
660
+ // Fall through to gateway dispatch below.
661
+ } else if (spawnAgent) {
662
+ // Local bare-agent path: queue for polling.
506
663
  this.spawnHttpPending = { id: taskId, task, label: label || "background-task", ts: startedAt };
507
664
  const preview = task.length > 60 ? task.slice(0, 60) + "…" : task;
508
665
  this.deps.feedBuffer.push(`🔧 Task queued for agent: ${preview}`, "normal", "system", "stream");
509
666
  this.deps.wsHandler.broadcast(`🔧 Task queued for agent: ${preview}`, "normal");
510
- log(TAG, `spawn-task ${taskId}: WS disconnected — queued for bare agent polling`);
667
+ log(TAG, `spawn-task ${taskId}: queued for bare agent (lane=${spawnAgent})`);
668
+ return;
669
+ } else {
670
+ log(TAG, `spawn-task ${taskId}: dropped — lane is Off (spawnAgent="")`);
511
671
  return;
512
672
  }
513
673