@geravant/sinain 1.14.0 → 1.15.0
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/.env.example +33 -29
- package/cli.js +30 -14
- package/config-shared.js +172 -30
- package/launcher.js +38 -21
- package/onboard.js +36 -20
- package/package.json +1 -1
- package/sinain-agent/run.sh +567 -126
- package/sinain-core/src/agents-loader.ts +254 -0
- package/sinain-core/src/config.ts +77 -15
- package/sinain-core/src/escalation/escalator.ts +178 -18
- package/sinain-core/src/index.ts +168 -12
- package/sinain-core/src/learning/local-curation.ts +81 -27
- package/sinain-core/src/overlay/commands.ts +25 -0
- package/sinain-core/src/overlay/ws-handler.ts +3 -0
- package/sinain-core/src/server.ts +101 -10
- package/sinain-core/src/types.ts +29 -3
package/sinain-core/src/index.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
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
|
-
|
|
763
|
-
|
|
764
|
-
|
|
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
|
|
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
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
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
|
-
|
|
398
|
-
|
|
399
|
-
if (!existsSync(scriptPath)) {
|
|
400
|
-
warn(TAG, `${script} not found — skipping`);
|
|
401
|
-
continue;
|
|
402
|
-
}
|
|
426
|
+
log(TAG, "periodic curation complete");
|
|
427
|
+
}
|
|
403
428
|
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
720
|
-
|
|
721
|
-
|
|
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:
|
|
753
|
-
permissionDecisionReason:
|
|
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
|
-
// ── /
|
|
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 {
|