@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.
@@ -1,5 +1,6 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import { loadConfig } from "./config.js";
3
+ import { loadAgentsConfig, isGatewayProfile, gatewayProfileNames } from "./agents-loader.js";
3
4
  import { FeedBuffer } from "./buffers/feed-buffer.js";
4
5
  import { SenseBuffer } from "./buffers/sense-buffer.js";
5
6
  import { WsHandler } from "./overlay/ws-handler.js";
@@ -375,7 +376,12 @@ async function main() {
375
376
  embeddingService.loadAsync(); // ~9s background load, server starts immediately
376
377
 
377
378
  // ── Initialize local knowledge pipeline ──
378
- const localCuration = new LocalCurationService();
379
+ // Pass wsHandler.broadcast so the periodic curator (insight_synthesizer)
380
+ // can push suggestions/insights directly to HUD without going through the
381
+ // bare-agent heartbeat. Replaces the old sinain_post_feed MCP roundtrip.
382
+ const localCuration = new LocalCurationService(
383
+ (text) => wsHandler.broadcast(text),
384
+ );
379
385
  // Distill pending session in background — don't block server startup
380
386
  setImmediate(() => {
381
387
  localCuration.distillPendingSession();
@@ -390,6 +396,14 @@ async function main() {
390
396
  });
391
397
 
392
398
  // ── Initialize escalation ──
399
+ // getSpawnAgent reads bareAgentState (declared later in this function) via
400
+ // closure at call-time, NOT at construction time. Safe because
401
+ // dispatchSpawnTask only fires after an overlay message, which can't
402
+ // happen before server setup completes.
403
+ // Load agents.json once for lookup helpers passed to escalator. Same file
404
+ // config.ts reads at startup; re-loading here keeps the dispatch lookup
405
+ // contained to a closure (no need to expose agentsCfg through CoreConfig).
406
+ const escalatorAgentsCfg = loadAgentsConfig();
393
407
  const escalator = new Escalator({
394
408
  feedBuffer,
395
409
  wsHandler,
@@ -398,6 +412,12 @@ async function main() {
398
412
  profiler,
399
413
  feedbackStore: feedbackStore ?? undefined,
400
414
  queryKnowledgeFacts: queryKnowledgeFactsMulti,
415
+ getSpawnAgent: () => bareAgentState.spawnAgent,
416
+ getEscalationAgent: () => bareAgentState.escalationAgent,
417
+ // Type-based gateway lookup. Routing key is agents.json `profiles[name].type`,
418
+ // so any custom profile with `type: "openclaw"` (e.g. "nemoclaw",
419
+ // "nanoclaw-prod") gets WS dispatch automatically — no name-matching.
420
+ isGatewayAgent: (name: string) => isGatewayProfile(escalatorAgentsCfg, name),
401
421
  });
402
422
 
403
423
  // ── Initialize agent loop (event-driven) ──
@@ -579,6 +599,88 @@ async function main() {
579
599
  // ── Escalation pause/resume state ──
580
600
  let savedEscalationMode: typeof config.escalationConfig.mode | null = null;
581
601
 
602
+ /** Pause escalation (idempotent). Caches the pre-pause mode so resume
603
+ * can restore it. Re-entering while already paused is a no-op — crucially,
604
+ * does NOT overwrite savedEscalationMode with "off". */
605
+ function pauseEscalationInternal(): void {
606
+ const current = config.escalationConfig.mode;
607
+ if (current === "off") return;
608
+ savedEscalationMode = current;
609
+ escalator.setMode("off");
610
+ log(TAG, `escalation paused (was: ${savedEscalationMode})`);
611
+ }
612
+
613
+ /** Resume escalation (idempotent). Restores the saved mode or falls back
614
+ * to "rich" if no saved mode exists. Re-entering while already active
615
+ * is a no-op. */
616
+ function resumeEscalationInternal(): void {
617
+ const current = config.escalationConfig.mode;
618
+ if (current !== "off") return;
619
+ const mode = savedEscalationMode ?? "rich";
620
+ savedEscalationMode = null;
621
+ escalator.setMode(mode);
622
+ log(TAG, `escalation resumed (mode: ${mode})`);
623
+ }
624
+
625
+ // ── Bare-agent roster & per-lane current agent ──
626
+ // In-memory only (matches escalation-mode lifecycle). Populated when the
627
+ // bare agent POSTs /bareagent/register on startup; mutated by set_agent
628
+ // command from the overlay. Empty-string lane values = "Off" (disabled).
629
+ // Profile names from agents.json may be custom (e.g. "pclaude",
630
+ // "openclaude-spawn"), so the server validates by character class
631
+ // rather than a fixed whitelist. The bare agent owns the source of
632
+ // truth for which profiles actually exist on its host. The validator
633
+ // just rejects names that could break shell logging, paths, or be
634
+ // injection vectors.
635
+ const AGENT_NAME_RE = /^[a-zA-Z][a-zA-Z0-9_-]{0,63}$/;
636
+ // "openclaw" is reserved-injected below when gatewayWsUrl is set —
637
+ // it's not a local CLI, it's a routing choice that sends tasks to the
638
+ // remote OpenClaw gateway via WS RPC instead of the local bare agent.
639
+ const bareAgentState: {
640
+ available: string[];
641
+ escalationAgent: string;
642
+ spawnAgent: string;
643
+ } = { available: [], escalationAgent: "", spawnAgent: "" };
644
+
645
+ function registerBareAgent(availableList: string[], current: string): void {
646
+ const clean = availableList.filter((a) => typeof a === "string" && AGENT_NAME_RE.test(a));
647
+ // Inject every gateway-style profile (any agents.json profile with
648
+ // `type: "openclaw"`) into the roster — they have no local binary, so
649
+ // run.sh's PATH filter drops them, but sinain-core knows they exist
650
+ // and routes them via WS RPC.
651
+ //
652
+ // This generalizes the legacy "auto-inject the literal name 'openclaw'"
653
+ // behavior: now custom gateway profiles like "nemoclaw" or
654
+ // "nanoclaw-prod" appear in the overlay roster automatically as soon
655
+ // as you add them to agents.json. The single WS client uses the first
656
+ // gateway profile's connection params (config.ts findGatewayProfile);
657
+ // simultaneous multi-gateway is a follow-up.
658
+ if (config.openclawConfig.gatewayWsUrl) {
659
+ for (const gwName of gatewayProfileNames(escalatorAgentsCfg)) {
660
+ if (!clean.includes(gwName)) clean.push(gwName);
661
+ }
662
+ // Legacy fallback: if no gateway profiles are defined in agents.json
663
+ // but gatewayWsUrl is set via env, still inject the canonical name.
664
+ if (clean.filter((n) => isGatewayProfile(escalatorAgentsCfg, n)).length === 0
665
+ && !clean.includes("openclaw")) {
666
+ clean.push("openclaw");
667
+ }
668
+ }
669
+ bareAgentState.available = clean;
670
+ // If neither lane is set yet (fresh boot), adopt the bare agent's
671
+ // reported current. If state survives from a prior register call AND
672
+ // the agent still exists in the roster, keep it; otherwise fall back
673
+ // to the new current.
674
+ if (!bareAgentState.escalationAgent || !clean.includes(bareAgentState.escalationAgent)) {
675
+ bareAgentState.escalationAgent = clean.includes(current) ? current : (clean[0] ?? "");
676
+ }
677
+ if (!bareAgentState.spawnAgent || !clean.includes(bareAgentState.spawnAgent)) {
678
+ bareAgentState.spawnAgent = clean.includes(current) ? current : (clean[0] ?? "");
679
+ }
680
+ wsHandler.updateState({ agents: { ...bareAgentState } });
681
+ log(TAG, `bareagent register: available=[${clean.join(",")}] current=${current} → lanes esc=${bareAgentState.escalationAgent} spawn=${bareAgentState.spawnAgent}`);
682
+ }
683
+
582
684
  // ── Create HTTP + WS server ──
583
685
  const server = createAppServer({
584
686
  config,
@@ -692,6 +794,18 @@ async function main() {
692
794
  isEscalationPaused: () => savedEscalationMode !== null,
693
795
  respondEscalation: (id: string, response: string) => escalator.respondHttp(id, response),
694
796
 
797
+ // Bare-agent roster & config (wired to server endpoints in step 2).
798
+ registerBareAgent,
799
+ getBareAgentConfig: () => ({
800
+ escalationAgent: bareAgentState.escalationAgent,
801
+ spawnAgent: bareAgentState.spawnAgent,
802
+ // Tells the bare agent whether core still has its roster. On core
803
+ // restart this flips to false until the next /bareagent/register POST
804
+ // — distinguishes "user picked Off/Off" (registered=true, lanes="")
805
+ // from "core forgot about us" (registered=false).
806
+ registered: bareAgentState.available.length > 0,
807
+ }),
808
+
695
809
  // Knowledge graph integration (checks both local and workspace DBs)
696
810
  getKnowledgeDocPath: () => {
697
811
  // Check local first, then workspace
@@ -752,20 +866,62 @@ async function main() {
752
866
  return screenActive;
753
867
  },
754
868
  onToggleEscalation: () => {
755
- if (savedEscalationMode === null) {
756
- // Pause: save current mode, switch to off
757
- savedEscalationMode = config.escalationConfig.mode;
758
- escalator.setMode("off");
759
- log(TAG, `escalation paused (was: ${savedEscalationMode})`);
869
+ // Routes through the shared helpers so the set_agent("escalation","")
870
+ // path and the flash-icon-toggle path share a single source of truth
871
+ // for savedEscalationMode. Kept for WS backward-compat; new UI uses
872
+ // the agent selector.
873
+ if (config.escalationConfig.mode === "off") {
874
+ resumeEscalationInternal();
875
+ return true;
876
+ } else {
877
+ pauseEscalationInternal();
760
878
  return false;
879
+ }
880
+ },
881
+ onSetAgent: (lane: "escalation" | "spawn", agent: string): { ok: boolean; error?: string } => {
882
+ // Empty-string agent = Off (lane disabled). Non-empty agent must be
883
+ // in the current roster; stale overlay state can send something that
884
+ // isn't available — reject with a clear error.
885
+ if (agent !== "" && !bareAgentState.available.includes(agent)) {
886
+ return { ok: false, error: `Agent "${agent}" not available` };
887
+ }
888
+ if (lane === "escalation") {
889
+ const prevAgent = bareAgentState.escalationAgent;
890
+ bareAgentState.escalationAgent = agent;
891
+ if (agent === "") {
892
+ pauseEscalationInternal();
893
+ } else {
894
+ resumeEscalationInternal();
895
+ }
896
+ // If the user flipped to a gateway-typed agent (openclaw, nemoclaw,
897
+ // ...) and there's a stale httpPending escalation queued from BEFORE
898
+ // the switch, re-dispatch it through the WS path. Without this, the
899
+ // bare agent picks up the stale entry on its next poll and posts a
900
+ // "[skipped: gateway-routed]" message to the HUD — confusing for
901
+ // the user, who just told us "use the gateway".
902
+ const wasGateway = isGatewayProfile(escalatorAgentsCfg, prevAgent);
903
+ const isGateway = isGatewayProfile(escalatorAgentsCfg, agent);
904
+ if (!wasGateway && isGateway) {
905
+ const did = escalator.redispatchHttpPendingToWs();
906
+ if (did) log(TAG, `lane switch ${prevAgent || "<empty>"} → ${agent}: stale httpPending redispatched`);
907
+ }
761
908
  } else {
762
- // Resume: restore saved mode
763
- const mode = savedEscalationMode;
764
- savedEscalationMode = null;
765
- escalator.setMode(mode);
766
- log(TAG, `escalation resumed (mode: ${mode})`);
767
- return true;
909
+ bareAgentState.spawnAgent = agent;
910
+ // Spawn "off" just means run.sh won't poll /spawn/pending; no
911
+ // server-side state to flip. Queued spawn tasks TTL out naturally.
768
912
  }
913
+ // Rebroadcast state so the overlay sees the switch immediately, and
914
+ // the bare agent sees it on its next poll-response config piggyback.
915
+ // `escalation` field reflects the current escalator mode so the flash
916
+ // icon's color (active/paused) updates on Off-for-escalation.
917
+ wsHandler.updateState({
918
+ agents: { ...bareAgentState },
919
+ escalation: config.escalationConfig.mode === "off" ? "paused" : "active",
920
+ });
921
+ const displayAgent = agent || "off";
922
+ wsHandler.broadcast(`Agent switched: ${lane} → ${displayAgent}`, "normal", "stream");
923
+ log(TAG, `set_agent lane=${lane} agent=${displayAgent}`);
924
+ return { ok: true };
769
925
  },
770
926
  });
771
927
 
@@ -60,11 +60,17 @@ export class LocalCurationService {
60
60
  private _incrementalRunning = false;
61
61
  private _rearmCb: (() => void) | null = null; // callback to re-arm feed buffer onFull
62
62
  private _senseBuffer: SenseBuffer | null = null;
63
+ // Optional HUD broadcast callback — when insight_synthesizer emits a
64
+ // suggestion/insight, we push it here so the overlay sees it. Replaces the
65
+ // bare-agent's prior `sinain_post_feed` roundtrip during heartbeat.
66
+ // Always posts at default priority; curation isn't the place for urgent pings.
67
+ private _broadcast: ((text: string) => void) | null = null;
63
68
 
64
- constructor() {
69
+ constructor(broadcast?: (text: string) => void) {
65
70
  this.memoryDir = resolveMemoryDir();
66
71
  this.scriptsDir = resolveScriptsDir();
67
72
  this.sessionStartTs = Date.now();
73
+ if (broadcast) this._broadcast = broadcast;
68
74
 
69
75
  // Ensure memory directory exists
70
76
  for (const subdir of ["", "playbook-logs", "playbook-archive", "eval-logs", "eval-reports"]) {
@@ -384,39 +390,87 @@ export class LocalCurationService {
384
390
  }
385
391
  }
386
392
 
387
- /** Run the periodic curation pipeline (feedback → mining → curation). */
393
+ /** Run the periodic curation pipeline.
394
+ * Replaces the bare-agent heartbeat entirely:
395
+ * signal_analyzer + insight_synthesizer (formerly heartbeat-only)
396
+ * + feedback_analyzer + memory_miner + playbook_curator (existing).
397
+ * insight_synthesizer output is parsed and, if it emits a suggestion or
398
+ * insight, broadcast to HUD via the constructor's broadcast callback.
399
+ */
388
400
  private runCurationPipeline(): void {
389
401
  log(TAG, "running periodic curation...");
390
402
 
391
- const scripts = [
392
- "feedback_analyzer.py",
393
- "memory_miner.py",
394
- "playbook_curator.py",
395
- ];
403
+ const sessionSummary = "Periodic curation cycle";
404
+ const currentTime = new Date().toISOString();
405
+
406
+ // Step 1: signal_analyzer — detects actionable signals from session memory
407
+ this.runScript("signal_analyzer.py", [
408
+ "--memory-dir", this.memoryDir,
409
+ "--session-summary", sessionSummary,
410
+ "--current-time", currentTime,
411
+ ]);
412
+
413
+ // Step 2: insight_synthesizer — produces {suggestion, insight}, broadcast to HUD
414
+ const insightOut = this.runScript("insight_synthesizer.py", [
415
+ "--memory-dir", this.memoryDir,
416
+ "--session-summary", sessionSummary,
417
+ "--current-time", currentTime,
418
+ ], /* captureStdout */ true);
419
+ if (insightOut) this.maybeBroadcastInsight(insightOut);
420
+
421
+ // Steps 3-5: existing periodic pipeline (feedback → mining → curation)
422
+ for (const script of ["feedback_analyzer.py", "memory_miner.py", "playbook_curator.py"]) {
423
+ this.runScript(script, ["--memory-dir", this.memoryDir]);
424
+ }
396
425
 
397
- for (const script of scripts) {
398
- const scriptPath = resolve(this.scriptsDir, script);
399
- if (!existsSync(scriptPath)) {
400
- warn(TAG, `${script} not found — skipping`);
401
- continue;
402
- }
426
+ log(TAG, "periodic curation complete");
427
+ }
403
428
 
404
- try {
405
- execFileSync("python3", [
406
- scriptPath,
407
- "--memory-dir", this.memoryDir,
408
- ], {
409
- timeout: 60_000,
410
- encoding: "utf-8",
411
- env: { ...process.env, PYTHONPATH: this.scriptsDir },
412
- });
413
- log(TAG, ` ✓ ${script}`);
414
- } catch (err: any) {
415
- warn(TAG, ` ✗ ${script}: ${err.message?.slice(0, 100)}`);
416
- }
429
+ /** Run a single curation script. Returns captured stdout when requested, else empty. */
430
+ private runScript(script: string, args: string[], captureStdout = false): string {
431
+ const scriptPath = resolve(this.scriptsDir, script);
432
+ if (!existsSync(scriptPath)) {
433
+ warn(TAG, `${script} not found — skipping`);
434
+ return "";
435
+ }
436
+ try {
437
+ const stdout = execFileSync("python3", [scriptPath, ...args], {
438
+ timeout: 60_000,
439
+ encoding: "utf-8",
440
+ env: { ...process.env, PYTHONPATH: this.scriptsDir },
441
+ });
442
+ log(TAG, ` ✓ ${script}`);
443
+ return captureStdout ? (stdout || "") : "";
444
+ } catch (err: any) {
445
+ warn(TAG, ` ✗ ${script}: ${err.message?.slice(0, 100)}`);
446
+ return "";
417
447
  }
448
+ }
418
449
 
419
- log(TAG, "periodic curation complete");
450
+ /** Parse insight_synthesizer stdout (expects JSON with {suggestion?, insight?})
451
+ * and broadcast non-empty fields to the HUD feed. Safe no-op if JSON parsing
452
+ * fails or if no broadcast callback was wired. */
453
+ private maybeBroadcastInsight(stdout: string): void {
454
+ if (!this._broadcast) return;
455
+ const lines = stdout.trim().split("\n").filter((l) => l.trim().length > 0);
456
+ for (let i = lines.length - 1; i >= 0; i--) {
457
+ const line = lines[i].trim();
458
+ if (!line.startsWith("{")) continue;
459
+ try {
460
+ const parsed = JSON.parse(line);
461
+ const suggestion = typeof parsed.suggestion === "string" ? parsed.suggestion.trim() : "";
462
+ const insight = typeof parsed.insight === "string" ? parsed.insight.trim() : "";
463
+ if (suggestion) {
464
+ log(TAG, ` posting suggestion to HUD (${suggestion.length} chars)`);
465
+ this._broadcast(suggestion);
466
+ }
467
+ if (insight && insight !== suggestion) {
468
+ log(TAG, ` posting insight to HUD (${insight.length} chars)`);
469
+ this._broadcast(insight);
470
+ }
471
+ return;
472
+ } catch { /* try previous line */ }
473
+ }
420
474
  }
421
475
 
422
476
  /** Write distilled session notes to daily file. */
@@ -23,6 +23,9 @@ export interface CommandDeps {
23
23
  onToggleScreen: () => boolean;
24
24
  /** Toggle escalation pause/resume — returns true if now active */
25
25
  onToggleEscalation: () => boolean;
26
+ /** Set the agent for a lane. agent="" means Off (lane disabled).
27
+ * Returns { ok: false, error } if agent isn't in the current roster. */
28
+ onSetAgent?: (lane: "escalation" | "spawn", agent: string) => { ok: boolean; error?: string };
26
29
  }
27
30
 
28
31
  /**
@@ -186,6 +189,28 @@ function handleCommand(msg: InboundMessage & { action: string }, deps: CommandDe
186
189
  }
187
190
  break;
188
191
  }
192
+ case "set_agent": {
193
+ const lane = (msg as any).lane as "escalation" | "spawn" | undefined;
194
+ const agent = (msg as any).agent;
195
+ if (lane !== "escalation" && lane !== "spawn") {
196
+ log(TAG, `set_agent: invalid lane "${lane}"`);
197
+ break;
198
+ }
199
+ if (typeof agent !== "string") {
200
+ log(TAG, `set_agent: missing or non-string agent field`);
201
+ break;
202
+ }
203
+ if (!deps.onSetAgent) {
204
+ log(TAG, `set_agent: no handler wired`);
205
+ break;
206
+ }
207
+ const result = deps.onSetAgent(lane, agent);
208
+ if (!result.ok) {
209
+ wsHandler.broadcast(`⚠ ${result.error ?? "set_agent failed"}`, "normal");
210
+ }
211
+ log(TAG, `set_agent lane=${lane} agent=${agent || "<off>"} (ok=${result.ok})`);
212
+ break;
213
+ }
189
214
  case "open_settings": {
190
215
  const envPath = loadedEnvPath || `${process.env.HOME || process.env.USERPROFILE}/.sinain/.env`;
191
216
  const cmd = process.platform === "win32" ? "notepad" : "open";
@@ -40,6 +40,7 @@ export class WsHandler {
40
40
  escalation: "active",
41
41
  connection: "disconnected",
42
42
  responseSize: "medium",
43
+ agents: { available: [], escalationAgent: "", spawnAgent: "" },
43
44
  };
44
45
  private replayBuffer: FeedMessage[] = [];
45
46
  private spawnTaskBuffer: Map<string, SpawnTaskMessage> = new Map();
@@ -77,6 +78,7 @@ export class WsHandler {
77
78
  escalation: this.state.escalation,
78
79
  connection: this.state.connection,
79
80
  responseSize: this.state.responseSize,
81
+ agents: this.state.agents,
80
82
  });
81
83
 
82
84
  // Replay recent feed messages for late-joining clients
@@ -161,6 +163,7 @@ export class WsHandler {
161
163
  escalation: this.state.escalation,
162
164
  connection: this.state.connection,
163
165
  responseSize: this.state.responseSize,
166
+ agents: this.state.agents,
164
167
  };
165
168
  if (loadedEnvPath) msg.envPath = loadedEnvPath;
166
169
  this.broadcastMessage(msg);
@@ -186,6 +186,15 @@ export interface ServerDeps {
186
186
  respondSpawn?: (id: string, result: string) => { ok: boolean; error?: string };
187
187
  embedTexts?: (texts: string[]) => Promise<Float32Array[]>;
188
188
  isEmbeddingReady?: () => boolean;
189
+
190
+ /** Bare-agent announced its roster on startup. */
191
+ registerBareAgent?: (available: string[], current: string) => void;
192
+ /** Current per-lane agent choice; read by run.sh via the piggyback field
193
+ * on /escalation/pending and /spawn/pending responses, and by manual
194
+ * debug via GET /bareagent/config. `registered` distinguishes "user
195
+ * chose Off" (registered=true, lanes="") from "core forgot our
196
+ * registration" (registered=false) so run.sh heals only on the latter. */
197
+ getBareAgentConfig?: () => { escalationAgent: string; spawnAgent: string; registered: boolean };
189
198
  }
190
199
 
191
200
  function readBody(req: IncomingMessage, maxBytes: number): Promise<string> {
@@ -209,6 +218,17 @@ function readBody(req: IncomingMessage, maxBytes: number): Promise<string> {
209
218
  /** Pending spawn questions/permissions — resolve callbacks keyed by "ask:{taskId}" or "perm:{taskId}" */
210
219
  const pendingSpawnQuestions = new Map<string, (answer: string) => void>();
211
220
 
221
+ // YOLO mode: "allow all" for the current agent session. Keyed on the openclaude
222
+ // session_id from the PreToolUse hook input (stable across tool calls within
223
+ // one invoke_agent run, discarded when that run ends). User enters YOLO by
224
+ // clicking the YOLO button on any permission prompt. Session id is cleared
225
+ // implicitly when the bare agent restarts (new session_id on next run).
226
+ const yoloSessions = new Set<string>();
227
+ // Map permission-request id (perm-<ts>) -> session id it came from. Used so
228
+ // that /spawn/permission-reply can flag the right session as YOLO when the
229
+ // user picks the YOLO button. Cleaned on resolve/timeout.
230
+ const permissionToSession = new Map<string, string>();
231
+
212
232
  export function createAppServer(deps: ServerDeps) {
213
233
  const { config, feedBuffer, senseBuffer, wsHandler } = deps;
214
234
  let senseInBytes = 0;
@@ -596,14 +616,17 @@ export function createAppServer(deps: ServerDeps) {
596
616
  }
597
617
 
598
618
  // ── /escalation/pending ──
619
+ // Response piggybacks the per-lane agent config so run.sh learns
620
+ // about overlay-side agent switches without a separate poll.
599
621
  if (req.method === "GET" && url.pathname === "/escalation/pending") {
622
+ const config = deps.getBareAgentConfig?.() ?? { escalationAgent: "", spawnAgent: "", registered: false };
600
623
  const paused = deps.isEscalationPaused?.() ?? false;
601
624
  if (paused) {
602
- res.end(JSON.stringify({ ok: true, escalation: null, paused: true }));
625
+ res.end(JSON.stringify({ ok: true, escalation: null, paused: true, config }));
603
626
  return;
604
627
  }
605
628
  const pending = deps.getEscalationPending?.();
606
- res.end(JSON.stringify({ ok: true, escalation: pending ?? null }));
629
+ res.end(JSON.stringify({ ok: true, escalation: pending ?? null, config }));
607
630
  return;
608
631
  }
609
632
 
@@ -640,9 +663,11 @@ export function createAppServer(deps: ServerDeps) {
640
663
  }
641
664
 
642
665
  // ── /spawn/pending (bare agent polls for queued tasks) ──
666
+ // Response piggybacks the per-lane agent config (see /escalation/pending).
643
667
  if (req.method === "GET" && url.pathname === "/spawn/pending") {
668
+ const config = deps.getBareAgentConfig?.() ?? { escalationAgent: "", spawnAgent: "", registered: false };
644
669
  const task = deps.getSpawnPending?.() ?? null;
645
- res.end(JSON.stringify({ ok: true, task }));
670
+ res.end(JSON.stringify({ ok: true, task, config }));
646
671
  return;
647
672
  }
648
673
 
@@ -715,17 +740,43 @@ export function createAppServer(deps: ServerDeps) {
715
740
  const hookInput = JSON.parse(body);
716
741
  const tool = hookInput?.tool_name || hookInput?.toolName || "unknown";
717
742
  const input = hookInput?.tool_input || hookInput?.input || {};
718
-
719
- // Auto-approve safe read-only tools
720
- const safeTools = ["Read", "Glob", "Grep", "Ls", "Cat"];
721
- if (safeTools.includes(tool) || tool.startsWith("mcp__sinain")) {
743
+ // session_id is provided by Claude Code / openclaude per the standard
744
+ // PreToolUse hook contract — stable within one CLI invocation. Falls
745
+ // back to sinainTaskId (injected by approve-tool.sh from env) for
746
+ // hosts that don't emit session_id.
747
+ const sessionId: string =
748
+ (typeof hookInput?.session_id === "string" && hookInput.session_id) ||
749
+ (typeof hookInput?.sinainTaskId === "string" && hookInput.sinainTaskId) ||
750
+ "";
751
+
752
+ // Auto-approve safe tools (configured via SINAIN_AUTO_APPROVE_TOOLS).
753
+ // Tokens are exact matches, or prefix patterns ending with "*".
754
+ const autoApproveTools = deps.config.permissionsConfig.autoApproveTools;
755
+ const autoApproved = autoApproveTools.some((p) =>
756
+ p.endsWith("*") ? tool.startsWith(p.slice(0, -1)) : tool === p,
757
+ );
758
+ if (autoApproved) {
722
759
  res.end(JSON.stringify({
723
760
  hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow" },
724
761
  }));
725
762
  return;
726
763
  }
727
764
 
765
+ // YOLO short-circuit: if this session previously clicked YOLO, auto-allow
766
+ // without routing to overlay. No user interaction needed.
767
+ if (sessionId && yoloSessions.has(sessionId)) {
768
+ res.end(JSON.stringify({
769
+ hookSpecificOutput: {
770
+ hookEventName: "PreToolUse",
771
+ permissionDecision: "allow",
772
+ permissionDecisionReason: "YOLO mode active for this session",
773
+ },
774
+ }));
775
+ return;
776
+ }
777
+
728
778
  const taskId = `perm-${Date.now()}`;
779
+ if (sessionId) permissionToSession.set(taskId, sessionId);
729
780
  // Broadcast permission request to overlay
730
781
  deps.wsHandler?.broadcastRaw({
731
782
  type: "spawn_task",
@@ -746,17 +797,49 @@ export function createAppServer(deps: ServerDeps) {
746
797
  }
747
798
  }, 2 * 60_000);
748
799
  });
800
+ // Clean up the permission→session mapping once we're done with it.
801
+ permissionToSession.delete(taskId);
802
+ const allowed = decision === "allow" || decision === "yolo";
749
803
  res.end(JSON.stringify({
750
804
  hookSpecificOutput: {
751
805
  hookEventName: "PreToolUse",
752
- permissionDecision: decision === "allow" ? "allow" : "deny",
753
- permissionDecisionReason: decision === "allow" ? "User approved via HUD" : "User denied or timed out",
806
+ permissionDecision: allowed ? "allow" : "deny",
807
+ permissionDecisionReason:
808
+ decision === "yolo" ? "User engaged YOLO mode — allow all for this session"
809
+ : decision === "allow" ? "User approved via HUD"
810
+ : "User denied or timed out",
754
811
  },
755
812
  }));
756
813
  return;
757
814
  }
758
815
 
759
- // ── /spawn/permission-reply (overlay sends allow/deny) ──
816
+ // ── /bareagent/register (bare agent announces its roster on startup) ──
817
+ if (req.method === "POST" && url.pathname === "/bareagent/register") {
818
+ const body = await readBody(req, 4096);
819
+ let parsed: any;
820
+ try { parsed = JSON.parse(body); }
821
+ catch { res.writeHead(400); res.end(JSON.stringify({ ok: false, error: "invalid json" })); return; }
822
+ const available = Array.isArray(parsed?.available) ? parsed.available : null;
823
+ const current = typeof parsed?.current === "string" ? parsed.current : "";
824
+ if (!available) {
825
+ res.writeHead(400);
826
+ res.end(JSON.stringify({ ok: false, error: "missing available[]" }));
827
+ return;
828
+ }
829
+ deps.registerBareAgent?.(available, current);
830
+ res.end(JSON.stringify({ ok: true }));
831
+ return;
832
+ }
833
+
834
+ // ── /bareagent/config (debug; the hot path uses the config field
835
+ // piggybacked on /escalation/pending and /spawn/pending responses) ──
836
+ if (req.method === "GET" && url.pathname === "/bareagent/config") {
837
+ const cfg = deps.getBareAgentConfig?.() ?? { escalationAgent: "", spawnAgent: "", registered: false };
838
+ res.end(JSON.stringify({ ok: true, ...cfg }));
839
+ return;
840
+ }
841
+
842
+ // ── /spawn/permission-reply (overlay sends allow | deny | yolo) ──
760
843
  if (req.method === "POST" && url.pathname === "/spawn/permission-reply") {
761
844
  const body = await readBody(req, 1024);
762
845
  const { taskId, decision } = JSON.parse(body);
@@ -764,6 +847,14 @@ export function createAppServer(deps: ServerDeps) {
764
847
  const resolve = pendingSpawnQuestions.get(key);
765
848
  if (resolve) {
766
849
  pendingSpawnQuestions.delete(key);
850
+ // YOLO: flag the session so subsequent permission requests for the
851
+ // same openclaude invocation auto-allow without routing to overlay.
852
+ // Session id was captured in permissionToSession when we broadcast
853
+ // the request; it's cleared by the /spawn/approve handler after resolve.
854
+ if (decision === "yolo") {
855
+ const sid = permissionToSession.get(taskId);
856
+ if (sid) yoloSessions.add(sid);
857
+ }
767
858
  resolve(decision || "deny");
768
859
  res.end(JSON.stringify({ ok: true }));
769
860
  } else {