@geravant/sinain 1.22.7 → 1.22.9

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/onboard.js CHANGED
@@ -267,15 +267,39 @@ export async function runOnboard(args = {}) {
267
267
  }
268
268
  }
269
269
 
270
- // stepGateway returns { envVars, agentsPatch }: tokens go to .env,
271
- // URLs + session + escalation mode go to agents.json's openclaw profile.
272
- const gatewayResult = await stepGateway(base, "[3/6] OpenClaw gateway");
273
- Object.assign(vars, gatewayResult.envVars);
274
- Object.assign(agentsPatch, gatewayResult.agentsPatch);
275
- if (gatewayResult.agentsPatch.escalationMode === "off") {
276
- p.log.info("Standalone mode (no gateway).");
270
+ // OpenClaw gateway is opt-in: most users run sinain in standalone mode
271
+ // and never need a gateway. Default the prompt to "No" on fresh installs
272
+ // (when no openclaw profile already exists) so accepting all wizard
273
+ // defaults never silently provisions a gateway profile or starts a WS
274
+ // reconnect loop. Mirrors the Quick-path gate at line ~177.
275
+ const hasExistingGateway = (() => {
276
+ try {
277
+ const agentsPath = path.join(SINAIN_DIR, "agents.json");
278
+ if (!fs.existsSync(agentsPath)) return false;
279
+ const cfg = JSON.parse(fs.readFileSync(agentsPath, "utf-8"));
280
+ return !!cfg?.profiles?.openclaw;
281
+ } catch { return false; }
282
+ })();
283
+ const enableGateway = guard(await p.confirm({
284
+ message: "[3/6] Configure OpenClaw gateway?",
285
+ initialValue: hasExistingGateway,
286
+ }));
287
+ if (enableGateway) {
288
+ // stepGateway returns { envVars, agentsPatch }: tokens go to .env,
289
+ // URLs + session + escalation mode go to agents.json's openclaw profile.
290
+ const gatewayResult = await stepGateway(base, "[3/6] OpenClaw gateway");
291
+ Object.assign(vars, gatewayResult.envVars);
292
+ Object.assign(agentsPatch, gatewayResult.agentsPatch);
293
+ if (gatewayResult.agentsPatch.escalationMode === "off") {
294
+ p.log.info("Standalone mode (no gateway).");
295
+ } else {
296
+ p.log.success("Gateway configured.");
297
+ }
277
298
  } else {
278
- p.log.success("Gateway configured.");
299
+ // Explicitly clear any inherited openclaw profile so the runtime doesn't
300
+ // auto-register the gateway or attempt WS reconnects (matches Quick path).
301
+ agentsPatch.openclawProfile = null;
302
+ p.log.info("Standalone mode (no gateway).");
279
303
  }
280
304
 
281
305
  const privacy = await stepPrivacy(base, "[4/6] Privacy mode");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geravant/sinain",
3
- "version": "1.22.7",
3
+ "version": "1.22.9",
4
4
  "description": "Ambient intelligence that sees what you see, hears what you hear, and acts on your behalf",
5
5
  "type": "module",
6
6
  "bin": {
@@ -31,6 +31,12 @@ export interface AgentLoopDeps {
31
31
  profiler?: Profiler;
32
32
  /** Called after each successful SITUATION.md write with the content string. */
33
33
  onSituationUpdate?: (content: string) => void;
34
+ /** Predicate to skip SITUATION.md writes entirely when no consumer is
35
+ * listening. Today only the openclaw module reads SITUATION.md, so when
36
+ * no gateway-typed agent is selected this returns false and the disk
37
+ * write is skipped on every tick. Defaults to "always write" if absent
38
+ * (preserves prior behavior for callers that don't pass the predicate). */
39
+ shouldWriteSituation?: () => boolean;
34
40
  /** Optional: path to sinain-knowledge.md for startup recap. */
35
41
  getKnowledgeDocPath?: () => string | null;
36
42
  /** Optional: feedback store for startup recap context. */
@@ -376,9 +382,14 @@ export class AgentLoop extends EventEmitter {
376
382
  // Calculate escalation score for both SITUATION.md and escalation check
377
383
  const escalationScore = calculateEscalationScore(digest, contextWindow);
378
384
 
379
- // Write SITUATION.md (enhanced with escalation context and recorder status)
380
- const situationContent = writeSituationMd(this.deps.situationMdPath, contextWindow, digest, entry, escalationScore, recorderStatus);
381
- this.deps.onSituationUpdate?.(situationContent);
385
+ // Write SITUATION.md only when something consumes it (today: an openclaw
386
+ // gateway lane is selected). Without a consumer, skip the disk write to
387
+ // avoid pinning ~/.openclaw/workspace/SITUATION.md on every tick of users
388
+ // who chose claude/openclaude/etc as both lanes.
389
+ if (this.deps.shouldWriteSituation?.() ?? true) {
390
+ const situationContent = writeSituationMd(this.deps.situationMdPath, contextWindow, digest, entry, escalationScore, recorderStatus);
391
+ this.deps.onSituationUpdate?.(situationContent);
392
+ }
382
393
 
383
394
  // Notify for escalation check
384
395
  traceCtx?.startSpan("escalation-check");
@@ -291,7 +291,7 @@ export function loadConfig(): CoreConfig {
291
291
  const autoApproveRaw = fromCfgStr(
292
292
  agentsCfg?.autoApproveTools,
293
293
  "SINAIN_AUTO_APPROVE_TOOLS",
294
- "Read Glob Grep Ls Cat mcp__sinain*",
294
+ "Read Glob Grep Ls Cat mcp__sinain* ToolSearch",
295
295
  );
296
296
  const permissionsConfig = {
297
297
  autoApproveTools: autoApproveRaw.split(/\s+/).filter((t) => t.length > 0),
@@ -170,17 +170,38 @@ export class Escalator {
170
170
  log(TAG, `user command set: "${preview}"`);
171
171
  }
172
172
 
173
+ /** True iff a gateway-typed profile is the active agent on at least one
174
+ * lane. WS-bearing operations (connect, reset, situation push) are gated
175
+ * on this so a user with a configured-but-unselected gateway pays no
176
+ * reconnect tax. */
177
+ private isGatewayLaneSelected(): boolean {
178
+ const isGw = this.deps.isGatewayAgent;
179
+ if (!isGw) return false;
180
+ const esc = this.deps.getEscalationAgent?.() ?? "";
181
+ const spawn = this.deps.getSpawnAgent?.() ?? "";
182
+ return isGw(esc) || isGw(spawn);
183
+ }
184
+
185
+ /** Public predicate so the agent loop / index.ts can ask "should I do
186
+ * openclaw-only side-effects on this tick?" without depending on the
187
+ * internals. Mirrors isGatewayLaneSelected. */
188
+ shouldDriveGateway(): boolean {
189
+ const wsConfigured = !!this.deps.openclawConfig.gatewayWsUrl;
190
+ return wsConfigured && this.isGatewayLaneSelected();
191
+ }
192
+
173
193
  /** Start the WS connection to OpenClaw.
174
194
  *
175
- * Connects whenever the gateway URL is configured AND escalation isn't
176
- * fully off. WS is the transport for the openclaw lane the user selects
177
- * it via the overlay's agent picker, and dispatch routes accordingly.
178
- * Removing the openclaw profile from agents.json (and unsetting the env
179
- * vars) leaves gatewayWsUrl empty no connect attempt.
195
+ * Connects whenever the gateway URL is configured AND a gateway-typed
196
+ * profile is selected on a lane AND escalation isn't fully off. WS is
197
+ * the transport for the openclaw lane the user selects it via the
198
+ * overlay's agent picker, and dispatch routes accordingly. Removing the
199
+ * openclaw profile from agents.json (and unsetting the env vars) leaves
200
+ * gatewayWsUrl empty → no connect attempt. Likewise, if the profile
201
+ * exists but no lane selects it, no connect attempt.
180
202
  */
181
203
  start(): void {
182
- const wsConfigured = !!this.deps.openclawConfig.gatewayWsUrl;
183
- if (this.deps.escalationConfig.mode !== "off" && wsConfigured) {
204
+ if (this.deps.escalationConfig.mode !== "off" && this.shouldDriveGateway()) {
184
205
  this.wsClient.connect();
185
206
  const tokenHash = this.deps.openclawConfig.gatewayToken
186
207
  ? createHash("sha256").update(this.deps.openclawConfig.gatewayToken).digest("hex").slice(0, 12)
@@ -194,11 +215,26 @@ export class Escalator {
194
215
  this.wsClient.disconnect();
195
216
  }
196
217
 
218
+ /** Re-evaluate WS lifecycle after lane selection changes. Connects when a
219
+ * gateway lane just got selected; disconnects when the user moved off
220
+ * every gateway lane. Called from the set_agent overlay handler. */
221
+ evaluateGatewayLifecycle(): void {
222
+ const shouldConnect =
223
+ this.deps.escalationConfig.mode !== "off" && this.shouldDriveGateway();
224
+ if (shouldConnect && !this.wsClient.isConnected) {
225
+ log(TAG, "lane switched to gateway — connecting WS");
226
+ this.wsClient.resetConnection();
227
+ } else if (!shouldConnect && this.wsClient.isConnected) {
228
+ log(TAG, "lane switched off gateway — disconnecting WS");
229
+ this.wsClient.disconnect();
230
+ }
231
+ }
232
+
197
233
  /** Update escalation mode at runtime. */
198
234
  setMode(mode: EscalatorDeps["escalationConfig"]["mode"]): void {
199
235
  const wasOff = this.deps.escalationConfig.mode === "off";
200
236
  this.deps.escalationConfig.mode = mode;
201
- if (mode !== "off" && !this.wsClient.isConnected) {
237
+ if (mode !== "off" && !this.wsClient.isConnected && this.shouldDriveGateway()) {
202
238
  this.wsClient.resetConnection();
203
239
  }
204
240
  if (mode === "off") {
@@ -743,6 +743,11 @@ async function main() {
743
743
  onSituationUpdate: (content) => {
744
744
  escalator.pushSituationMd(content);
745
745
  },
746
+ // Gate SITUATION.md writes (and the subsequent push) on a gateway lane
747
+ // being active — see escalator.shouldDriveGateway. Users with no openclaw
748
+ // profile, or with the profile but no lane selecting it, pay zero disk
749
+ // I/O on every tick.
750
+ shouldWriteSituation: () => escalator.shouldDriveGateway(),
746
751
  onHudUpdate: (text) => {
747
752
  wsHandler.broadcastRaw({ type: "thinking", active: false } as any);
748
753
  wsHandler.broadcast(text, "normal", "stream");
@@ -1241,6 +1246,12 @@ async function main() {
1241
1246
  // Spawn "off" just means run.sh won't poll /spawn/pending; no
1242
1247
  // server-side state to flip. Queued spawn tasks TTL out naturally.
1243
1248
  }
1249
+ // Re-evaluate WS lifecycle: connect when a gateway lane just got
1250
+ // selected (zero attempts before this point), disconnect when the user
1251
+ // moved off every gateway lane. This is what makes the "no resources
1252
+ // when not in use" guarantee hold across runtime selection changes,
1253
+ // not just startup config.
1254
+ escalator.evaluateGatewayLifecycle();
1244
1255
  // Rebroadcast state so the overlay sees the switch immediately, and
1245
1256
  // the bare agent sees it on its next poll-response config piggyback.
1246
1257
  // `escalation` field reflects the current escalator mode so the flash
@@ -0,0 +1,69 @@
1
+ import { EventEmitter } from "node:events";
2
+ import type { AgentEntry, ContextWindow } from "../types.js";
3
+
4
+ /**
5
+ * Domain-generic events emitted by sinain-core. Optional agent modules
6
+ * (currently just openclaw, future bridges to other gateways) subscribe
7
+ * to these instead of being wired directly into Escalator. The contract
8
+ * here is the only stable surface modules can rely on.
9
+ */
10
+ export interface CoreEvents {
11
+ /** An escalation has been routed to an agent. Modules whose roster
12
+ * matches `agent` should run their delivery (WS, HTTP, etc). */
13
+ "escalation:dispatched": {
14
+ entry: AgentEntry;
15
+ contextWindow: ContextWindow;
16
+ message: string;
17
+ agent: string;
18
+ };
19
+
20
+ /** User typed a direct message in the overlay command input. */
21
+ "user:direct": { text: string; ts: number };
22
+
23
+ /** Periodic feedback summary ready for forwarding to long-running agents. */
24
+ "feedback:periodic": { summary: string; lastNTicks: number };
25
+
26
+ /** Agent loop completed an analysis tick. Used by SITUATION.md writers
27
+ * and other context consumers. */
28
+ "tick:complete": { entry: AgentEntry };
29
+ }
30
+
31
+ export interface CoreEventBus {
32
+ on<K extends keyof CoreEvents>(
33
+ event: K,
34
+ listener: (payload: CoreEvents[K]) => void,
35
+ ): () => void;
36
+ emit<K extends keyof CoreEvents>(event: K, payload: CoreEvents[K]): void;
37
+ removeAllListeners(): void;
38
+ }
39
+
40
+ class TypedEventBus implements CoreEventBus {
41
+ private readonly emitter = new EventEmitter();
42
+
43
+ constructor() {
44
+ // Default Node limit (10) is too low for a long-running bus with many
45
+ // subscribers across the lifetime of the process. Disable the warning;
46
+ // we control the listener set internally.
47
+ this.emitter.setMaxListeners(0);
48
+ }
49
+
50
+ on<K extends keyof CoreEvents>(
51
+ event: K,
52
+ listener: (payload: CoreEvents[K]) => void,
53
+ ): () => void {
54
+ this.emitter.on(event, listener as (...args: unknown[]) => void);
55
+ return () => this.emitter.off(event, listener as (...args: unknown[]) => void);
56
+ }
57
+
58
+ emit<K extends keyof CoreEvents>(event: K, payload: CoreEvents[K]): void {
59
+ this.emitter.emit(event, payload);
60
+ }
61
+
62
+ removeAllListeners(): void {
63
+ this.emitter.removeAllListeners();
64
+ }
65
+ }
66
+
67
+ export function createCoreEventBus(): CoreEventBus {
68
+ return new TypedEventBus();
69
+ }