@hera-al/server 1.6.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.
- package/LICENSE +21 -0
- package/README.md +325 -0
- package/bundled/apple-notes/SKILL.md +77 -0
- package/bundled/apple-reminders/SKILL.md +96 -0
- package/bundled/blogwatcher/SKILL.md +69 -0
- package/bundled/camsnap/SKILL.md +45 -0
- package/bundled/discord/SKILL.md +578 -0
- package/bundled/gemini/SKILL.md +43 -0
- package/bundled/gifgrep/SKILL.md +79 -0
- package/bundled/github/SKILL.md +77 -0
- package/bundled/gog/SKILL.md +116 -0
- package/bundled/goplaces/SKILL.md +52 -0
- package/bundled/himalaya/SKILL.md +257 -0
- package/bundled/himalaya/references/configuration.md +184 -0
- package/bundled/himalaya/references/message-composition.md +199 -0
- package/bundled/homebrew/SKILL.md +82 -0
- package/bundled/local-places/SERVER_README.md +101 -0
- package/bundled/local-places/SKILL.md +102 -0
- package/bundled/local-places/pyproject.toml +21 -0
- package/bundled/local-places/src/local_places/__init__.py +2 -0
- package/bundled/local-places/src/local_places/google_places.py +314 -0
- package/bundled/local-places/src/local_places/main.py +65 -0
- package/bundled/local-places/src/local_places/schemas.py +107 -0
- package/bundled/markitdown/SKILL.md +96 -0
- package/bundled/mcporter/SKILL.md +61 -0
- package/bundled/merge-pr/SKILL.md +187 -0
- package/bundled/merge-pr/agents/openai.yaml +4 -0
- package/bundled/nano-banana-pro/SKILL.md +58 -0
- package/bundled/nano-banana-pro/scripts/generate_image.py +184 -0
- package/bundled/nano-pdf/SKILL.md +38 -0
- package/bundled/open-prose/README.md +25 -0
- package/bundled/open-prose/index.ts +5 -0
- package/bundled/open-prose/openclaw.plugin.json +11 -0
- package/bundled/open-prose/package.json +15 -0
- package/bundled/open-prose/skills/prose/LICENSE +21 -0
- package/bundled/open-prose/skills/prose/SKILL.md +323 -0
- package/bundled/open-prose/skills/prose/alt-borges.md +141 -0
- package/bundled/open-prose/skills/prose/alts/arabian-nights.md +358 -0
- package/bundled/open-prose/skills/prose/alts/borges.md +360 -0
- package/bundled/open-prose/skills/prose/alts/folk.md +322 -0
- package/bundled/open-prose/skills/prose/alts/homer.md +346 -0
- package/bundled/open-prose/skills/prose/alts/kafka.md +373 -0
- package/bundled/open-prose/skills/prose/compiler.md +2971 -0
- package/bundled/open-prose/skills/prose/examples/01-hello-world.prose +4 -0
- package/bundled/open-prose/skills/prose/examples/02-research-and-summarize.prose +6 -0
- package/bundled/open-prose/skills/prose/examples/03-code-review.prose +17 -0
- package/bundled/open-prose/skills/prose/examples/04-write-and-refine.prose +14 -0
- package/bundled/open-prose/skills/prose/examples/05-debug-issue.prose +20 -0
- package/bundled/open-prose/skills/prose/examples/06-explain-codebase.prose +17 -0
- package/bundled/open-prose/skills/prose/examples/07-refactor.prose +20 -0
- package/bundled/open-prose/skills/prose/examples/08-blog-post.prose +20 -0
- package/bundled/open-prose/skills/prose/examples/09-research-with-agents.prose +25 -0
- package/bundled/open-prose/skills/prose/examples/10-code-review-agents.prose +32 -0
- package/bundled/open-prose/skills/prose/examples/11-skills-and-imports.prose +27 -0
- package/bundled/open-prose/skills/prose/examples/12-secure-agent-permissions.prose +43 -0
- package/bundled/open-prose/skills/prose/examples/13-variables-and-context.prose +51 -0
- package/bundled/open-prose/skills/prose/examples/14-composition-blocks.prose +48 -0
- package/bundled/open-prose/skills/prose/examples/15-inline-sequences.prose +23 -0
- package/bundled/open-prose/skills/prose/examples/16-parallel-reviews.prose +19 -0
- package/bundled/open-prose/skills/prose/examples/17-parallel-research.prose +19 -0
- package/bundled/open-prose/skills/prose/examples/18-mixed-parallel-sequential.prose +36 -0
- package/bundled/open-prose/skills/prose/examples/19-advanced-parallel.prose +71 -0
- package/bundled/open-prose/skills/prose/examples/20-fixed-loops.prose +20 -0
- package/bundled/open-prose/skills/prose/examples/21-pipeline-operations.prose +35 -0
- package/bundled/open-prose/skills/prose/examples/22-error-handling.prose +51 -0
- package/bundled/open-prose/skills/prose/examples/23-retry-with-backoff.prose +63 -0
- package/bundled/open-prose/skills/prose/examples/24-choice-blocks.prose +86 -0
- package/bundled/open-prose/skills/prose/examples/25-conditionals.prose +114 -0
- package/bundled/open-prose/skills/prose/examples/26-parameterized-blocks.prose +100 -0
- package/bundled/open-prose/skills/prose/examples/27-string-interpolation.prose +105 -0
- package/bundled/open-prose/skills/prose/examples/28-automated-pr-review.prose +37 -0
- package/bundled/open-prose/skills/prose/examples/28-gas-town.prose +1572 -0
- package/bundled/open-prose/skills/prose/examples/29-captains-chair.prose +218 -0
- package/bundled/open-prose/skills/prose/examples/30-captains-chair-simple.prose +42 -0
- package/bundled/open-prose/skills/prose/examples/31-captains-chair-with-memory.prose +145 -0
- package/bundled/open-prose/skills/prose/examples/33-pr-review-autofix.prose +168 -0
- package/bundled/open-prose/skills/prose/examples/34-content-pipeline.prose +204 -0
- package/bundled/open-prose/skills/prose/examples/35-feature-factory.prose +296 -0
- package/bundled/open-prose/skills/prose/examples/36-bug-hunter.prose +237 -0
- package/bundled/open-prose/skills/prose/examples/37-the-forge.prose +1474 -0
- package/bundled/open-prose/skills/prose/examples/38-skill-scan.prose +455 -0
- package/bundled/open-prose/skills/prose/examples/39-architect-by-simulation.prose +277 -0
- package/bundled/open-prose/skills/prose/examples/40-rlm-self-refine.prose +32 -0
- package/bundled/open-prose/skills/prose/examples/41-rlm-divide-conquer.prose +38 -0
- package/bundled/open-prose/skills/prose/examples/42-rlm-filter-recurse.prose +46 -0
- package/bundled/open-prose/skills/prose/examples/43-rlm-pairwise.prose +50 -0
- package/bundled/open-prose/skills/prose/examples/44-run-endpoint-ux-test.prose +261 -0
- package/bundled/open-prose/skills/prose/examples/45-plugin-release.prose +159 -0
- package/bundled/open-prose/skills/prose/examples/45-run-endpoint-ux-test-with-remediation.prose +637 -0
- package/bundled/open-prose/skills/prose/examples/46-run-endpoint-ux-test-fast.prose +148 -0
- package/bundled/open-prose/skills/prose/examples/46-workflow-crystallizer.prose +225 -0
- package/bundled/open-prose/skills/prose/examples/47-language-self-improvement.prose +356 -0
- package/bundled/open-prose/skills/prose/examples/48-habit-miner.prose +445 -0
- package/bundled/open-prose/skills/prose/examples/49-prose-run-retrospective.prose +210 -0
- package/bundled/open-prose/skills/prose/examples/README.md +391 -0
- package/bundled/open-prose/skills/prose/examples/roadmap/README.md +22 -0
- package/bundled/open-prose/skills/prose/examples/roadmap/iterative-refinement.prose +20 -0
- package/bundled/open-prose/skills/prose/examples/roadmap/parallel-review.prose +18 -0
- package/bundled/open-prose/skills/prose/examples/roadmap/simple-pipeline.prose +17 -0
- package/bundled/open-prose/skills/prose/examples/roadmap/syntax/open-prose-syntax.prose +223 -0
- package/bundled/open-prose/skills/prose/guidance/antipatterns.md +951 -0
- package/bundled/open-prose/skills/prose/guidance/patterns.md +700 -0
- package/bundled/open-prose/skills/prose/guidance/system-prompt.md +180 -0
- package/bundled/open-prose/skills/prose/help.md +144 -0
- package/bundled/open-prose/skills/prose/lib/README.md +108 -0
- package/bundled/open-prose/skills/prose/lib/calibrator.prose +215 -0
- package/bundled/open-prose/skills/prose/lib/cost-analyzer.prose +174 -0
- package/bundled/open-prose/skills/prose/lib/error-forensics.prose +250 -0
- package/bundled/open-prose/skills/prose/lib/inspector.prose +196 -0
- package/bundled/open-prose/skills/prose/lib/profiler.prose +460 -0
- package/bundled/open-prose/skills/prose/lib/program-improver.prose +275 -0
- package/bundled/open-prose/skills/prose/lib/project-memory.prose +118 -0
- package/bundled/open-prose/skills/prose/lib/user-memory.prose +93 -0
- package/bundled/open-prose/skills/prose/lib/vm-improver.prose +243 -0
- package/bundled/open-prose/skills/prose/primitives/session.md +593 -0
- package/bundled/open-prose/skills/prose/prose.md +1237 -0
- package/bundled/open-prose/skills/prose/state/filesystem.md +498 -0
- package/bundled/open-prose/skills/prose/state/in-context.md +384 -0
- package/bundled/open-prose/skills/prose/state/postgres.md +880 -0
- package/bundled/open-prose/skills/prose/state/sqlite.md +574 -0
- package/bundled/peekaboo/SKILL.md +190 -0
- package/bundled/prepare-pr/SKILL.md +277 -0
- package/bundled/prepare-pr/agents/openai.yaml +4 -0
- package/bundled/review-pr/SKILL.md +228 -0
- package/bundled/review-pr/agents/openai.yaml +4 -0
- package/bundled/sag/SKILL.md +87 -0
- package/bundled/skill-creator/SKILL.md +370 -0
- package/bundled/skill-creator/license.txt +202 -0
- package/bundled/skill-creator/scripts/init_skill.py +378 -0
- package/bundled/skill-creator/scripts/package_skill.py +111 -0
- package/bundled/skill-creator/scripts/quick_validate.py +101 -0
- package/bundled/spotify-player/SKILL.md +64 -0
- package/bundled/ssh/SKILL.md +119 -0
- package/bundled/summarize/SKILL.md +87 -0
- package/bundled/video-frames/SKILL.md +46 -0
- package/bundled/video-frames/scripts/frame.sh +81 -0
- package/bundled/voice-call/SKILL.md +45 -0
- package/bundled/wacli/SKILL.md +72 -0
- package/bundled/weather/SKILL.md +54 -0
- package/dist/agent/agent-service.d.ts +88 -0
- package/dist/agent/agent-service.js +1 -0
- package/dist/agent/message-queue.d.ts +24 -0
- package/dist/agent/message-queue.js +1 -0
- package/dist/agent/prompt-builder.d.ts +58 -0
- package/dist/agent/prompt-builder.js +1 -0
- package/dist/agent/session-agent.d.ts +197 -0
- package/dist/agent/session-agent.js +1 -0
- package/dist/agent/session-db.d.ts +26 -0
- package/dist/agent/session-db.js +1 -0
- package/dist/agent/session-error-handler.d.ts +37 -0
- package/dist/agent/session-error-handler.js +1 -0
- package/dist/agent/session-manager.d.ts +19 -0
- package/dist/agent/session-manager.js +1 -0
- package/dist/agent/workspace-files.d.ts +51 -0
- package/dist/agent/workspace-files.js +1 -0
- package/dist/auth/auth-middleware.d.ts +9 -0
- package/dist/auth/auth-middleware.js +1 -0
- package/dist/auth/node-signature-db.d.ts +30 -0
- package/dist/auth/node-signature-db.js +1 -0
- package/dist/auth/token-db.d.ts +38 -0
- package/dist/auth/token-db.js +1 -0
- package/dist/browser/browser-service.d.ts +9 -0
- package/dist/browser/browser-service.js +1 -0
- package/dist/channels/channel.d.ts +2 -0
- package/dist/channels/channel.js +1 -0
- package/dist/channels/responses.d.ts +21 -0
- package/dist/channels/responses.js +1 -0
- package/dist/commands/clear.d.ts +7 -0
- package/dist/commands/clear.js +1 -0
- package/dist/commands/cmd.d.ts +7 -0
- package/dist/commands/cmd.js +1 -0
- package/dist/commands/coder.d.ts +12 -0
- package/dist/commands/coder.js +1 -0
- package/dist/commands/command-registry.d.ts +12 -0
- package/dist/commands/command-registry.js +1 -0
- package/dist/commands/command.d.ts +22 -0
- package/dist/commands/command.js +1 -0
- package/dist/commands/compact.d.ts +7 -0
- package/dist/commands/compact.js +1 -0
- package/dist/commands/customsubagents.d.ts +15 -0
- package/dist/commands/customsubagents.js +1 -0
- package/dist/commands/help.d.ts +9 -0
- package/dist/commands/help.js +1 -0
- package/dist/commands/mcp.d.ts +9 -0
- package/dist/commands/mcp.js +1 -0
- package/dist/commands/model.d.ts +22 -0
- package/dist/commands/model.js +1 -0
- package/dist/commands/models.d.ts +11 -0
- package/dist/commands/models.js +1 -0
- package/dist/commands/new.d.ts +7 -0
- package/dist/commands/new.js +1 -0
- package/dist/commands/plugin.d.ts +7 -0
- package/dist/commands/plugin.js +1 -0
- package/dist/commands/sandbox.d.ts +12 -0
- package/dist/commands/sandbox.js +1 -0
- package/dist/commands/showtool.d.ts +12 -0
- package/dist/commands/showtool.js +1 -0
- package/dist/commands/status.d.ts +24 -0
- package/dist/commands/status.js +1 -0
- package/dist/commands/stop.d.ts +10 -0
- package/dist/commands/stop.js +1 -0
- package/dist/commands/subagents.d.ts +12 -0
- package/dist/commands/subagents.js +1 -0
- package/dist/commands/usage.d.ts +25 -0
- package/dist/commands/usage.js +1 -0
- package/dist/commands/useplugin.d.ts +7 -0
- package/dist/commands/useplugin.js +1 -0
- package/dist/config-watcher.d.ts +14 -0
- package/dist/config-watcher.js +1 -0
- package/dist/config.d.ts +267 -0
- package/dist/config.js +1 -0
- package/dist/cron/cron-service.d.ts +57 -0
- package/dist/cron/cron-service.js +1 -0
- package/dist/cron/heartbeat-token.d.ts +29 -0
- package/dist/cron/heartbeat-token.js +1 -0
- package/dist/cron/schedule.d.ts +3 -0
- package/dist/cron/schedule.js +1 -0
- package/dist/cron/store.d.ts +4 -0
- package/dist/cron/store.js +1 -0
- package/dist/cron/types.d.ts +47 -0
- package/dist/cron/types.js +1 -0
- package/dist/gateway/bridge.d.ts +38 -0
- package/dist/gateway/bridge.js +1 -0
- package/dist/gateway/channel-manager.d.ts +45 -0
- package/dist/gateway/channel-manager.js +1 -0
- package/dist/gateway/channels/qr-image.d.ts +5 -0
- package/dist/gateway/channels/qr-image.js +1 -0
- package/dist/gateway/channels/telegram.d.ts +39 -0
- package/dist/gateway/channels/telegram.js +1 -0
- package/dist/gateway/channels/webchat.d.ts +51 -0
- package/dist/gateway/channels/webchat.js +1 -0
- package/dist/gateway/channels/whatsapp.d.ts +40 -0
- package/dist/gateway/channels/whatsapp.js +1 -0
- package/dist/gateway/node-registry.d.ts +38 -0
- package/dist/gateway/node-registry.js +1 -0
- package/dist/heracli/index.d.ts +3 -0
- package/dist/heracli/index.js +2 -0
- package/dist/heracli/logs.d.ts +13 -0
- package/dist/heracli/logs.js +1 -0
- package/dist/heracli/security/audit.d.ts +17 -0
- package/dist/heracli/security/audit.js +1 -0
- package/dist/heracli/security/checks/channel-policies.d.ts +6 -0
- package/dist/heracli/security/checks/channel-policies.js +1 -0
- package/dist/heracli/security/checks/credentials.d.ts +6 -0
- package/dist/heracli/security/checks/credentials.js +1 -0
- package/dist/heracli/security/checks/fs-permissions.d.ts +6 -0
- package/dist/heracli/security/checks/fs-permissions.js +1 -0
- package/dist/heracli/security/checks/network.d.ts +4 -0
- package/dist/heracli/security/checks/network.js +1 -0
- package/dist/heracli/security/report.d.ts +4 -0
- package/dist/heracli/security/report.js +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +2 -0
- package/dist/installer/hera.d.ts +3 -0
- package/dist/installer/hera.js +2 -0
- package/dist/media/message-processor.d.ts +23 -0
- package/dist/media/message-processor.js +1 -0
- package/dist/memory/memory-manager.d.ts +21 -0
- package/dist/memory/memory-manager.js +1 -0
- package/dist/memory/memory-provider.d.ts +22 -0
- package/dist/memory/memory-provider.js +1 -0
- package/dist/memory/memory-search.d.ts +102 -0
- package/dist/memory/memory-search.js +1 -0
- package/dist/memory/recall-strategies.d.ts +2 -0
- package/dist/memory/recall-strategies.js +1 -0
- package/dist/nostromo/auth.d.ts +29 -0
- package/dist/nostromo/auth.js +1 -0
- package/dist/nostromo/nostromo.d.ts +23 -0
- package/dist/nostromo/nostromo.js +1 -0
- package/dist/nostromo/ui-html-layout.d.ts +3 -0
- package/dist/nostromo/ui-html-layout.js +1 -0
- package/dist/nostromo/ui-html-modals.d.ts +3 -0
- package/dist/nostromo/ui-html-modals.js +1 -0
- package/dist/nostromo/ui-js-agent.d.ts +3 -0
- package/dist/nostromo/ui-js-agent.js +1 -0
- package/dist/nostromo/ui-js-channels.d.ts +3 -0
- package/dist/nostromo/ui-js-channels.js +1 -0
- package/dist/nostromo/ui-js-competences.d.ts +3 -0
- package/dist/nostromo/ui-js-competences.js +1 -0
- package/dist/nostromo/ui-js-config.d.ts +3 -0
- package/dist/nostromo/ui-js-config.js +1 -0
- package/dist/nostromo/ui-js-core.d.ts +3 -0
- package/dist/nostromo/ui-js-core.js +1 -0
- package/dist/nostromo/ui-js-ops.d.ts +3 -0
- package/dist/nostromo/ui-js-ops.js +1 -0
- package/dist/nostromo/ui-js-prompts.d.ts +3 -0
- package/dist/nostromo/ui-js-prompts.js +1 -0
- package/dist/nostromo/ui-styles.d.ts +3 -0
- package/dist/nostromo/ui-styles.js +1 -0
- package/dist/nostromo/ui.d.ts +2 -0
- package/dist/nostromo/ui.js +1 -0
- package/dist/server.d.ts +80 -0
- package/dist/server.js +1 -0
- package/dist/stt/local-whisper.d.ts +9 -0
- package/dist/stt/local-whisper.js +1 -0
- package/dist/stt/openai-whisper.d.ts +14 -0
- package/dist/stt/openai-whisper.js +1 -0
- package/dist/stt/stt-loader.d.ts +4 -0
- package/dist/stt/stt-loader.js +1 -0
- package/dist/stt/stt-provider.d.ts +4 -0
- package/dist/stt/stt-provider.js +1 -0
- package/dist/tools/browser-tools.d.ts +9 -0
- package/dist/tools/browser-tools.js +1 -0
- package/dist/tools/cron-tools.d.ts +4 -0
- package/dist/tools/cron-tools.js +1 -0
- package/dist/tools/memory-tools.d.ts +3 -0
- package/dist/tools/memory-tools.js +1 -0
- package/dist/tools/message-tools.d.ts +5 -0
- package/dist/tools/message-tools.js +1 -0
- package/dist/tools/node-tools.d.ts +3 -0
- package/dist/tools/node-tools.js +1 -0
- package/dist/tools/server-tools.d.ts +2 -0
- package/dist/tools/server-tools.js +1 -0
- package/dist/tools/tts-tools.d.ts +3 -0
- package/dist/tools/tts-tools.js +1 -0
- package/dist/tts/tts-service.d.ts +19 -0
- package/dist/tts/tts-service.js +1 -0
- package/dist/utils/chunk.d.ts +3 -0
- package/dist/utils/chunk.js +1 -0
- package/dist/utils/logger.d.ts +16 -0
- package/dist/utils/logger.js +1 -0
- package/dist/utils/markdown/fences.d.ts +11 -0
- package/dist/utils/markdown/fences.js +1 -0
- package/dist/utils/markdown/ir.d.ts +33 -0
- package/dist/utils/markdown/ir.js +1 -0
- package/dist/utils/markdown/render.d.ts +19 -0
- package/dist/utils/markdown/render.js +1 -0
- package/dist/utils/markdown/tables.d.ts +3 -0
- package/dist/utils/markdown/tables.js +1 -0
- package/dist/utils/media-response.d.ts +29 -0
- package/dist/utils/media-response.js +1 -0
- package/dist/utils/package-paths.d.ts +5 -0
- package/dist/utils/package-paths.js +1 -0
- package/dist/utils/telegram-format.d.ts +13 -0
- package/dist/utils/telegram-format.js +1 -0
- package/installationPkg/.env.example +26 -0
- package/installationPkg/AGENTS.md +143 -0
- package/installationPkg/BOOTSTRAP.md +45 -0
- package/installationPkg/CBINT.json +16 -0
- package/installationPkg/HEARTBEAT.md +5 -0
- package/installationPkg/IDENTITY.md +7 -0
- package/installationPkg/SOUL.md +36 -0
- package/installationPkg/SYSTEM_PROMPT.md +55 -0
- package/installationPkg/SYSTEM_PROMPT_SUBAGENT.md +40 -0
- package/installationPkg/TOOLS.md +36 -0
- package/installationPkg/USER.md +11 -0
- package/installationPkg/config.example.yaml +291 -0
- package/package.json +95 -0
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/** Read the current key from disk. Creates the file with default key if missing. */
|
|
2
|
+
export declare function readKey(): string;
|
|
3
|
+
/** Returns true when the stored key is still the default "0000". */
|
|
4
|
+
export declare function isDefaultKey(): boolean;
|
|
5
|
+
/** Validate an input key against the stored key. */
|
|
6
|
+
export declare function validateKey(input: string): boolean;
|
|
7
|
+
/**
|
|
8
|
+
* Rotate the key: generate a new XXXX-YYYY-ZZZZ-WWWW key, persist it, return the new key.
|
|
9
|
+
* Called automatically after the first login with the default key,
|
|
10
|
+
* or manually from the Settings page.
|
|
11
|
+
*/
|
|
12
|
+
export declare function regenerateKey(): string;
|
|
13
|
+
/** Reset the key to "0000" (used by masterkey.js). */
|
|
14
|
+
export declare function resetKey(): void;
|
|
15
|
+
export declare function createSession(): {
|
|
16
|
+
sessionToken: string;
|
|
17
|
+
csrfToken: string;
|
|
18
|
+
};
|
|
19
|
+
export declare function validateSession(token: string | undefined): boolean;
|
|
20
|
+
export declare function getCsrfToken(sessionToken: string | undefined): string | null;
|
|
21
|
+
export declare function validateCsrf(sessionToken: string | undefined, csrfHeader: string | undefined): boolean;
|
|
22
|
+
export declare function destroySession(token: string): void;
|
|
23
|
+
export declare function checkRateLimit(ip: string): {
|
|
24
|
+
allowed: boolean;
|
|
25
|
+
retryAfter?: number;
|
|
26
|
+
};
|
|
27
|
+
export declare function recordLoginFailure(ip: string): void;
|
|
28
|
+
export declare function resetLoginAttempts(ip: string): void;
|
|
29
|
+
//# sourceMappingURL=auth.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{readFileSync as e,writeFileSync as t,existsSync as o}from"node:fs";import n from"node:crypto";import{getNostromoKeyPath as r}from"../config.js";import{createLogger as i}from"../utils/logger.js";const c=i("NostromoAuth"),s="0000";function u(){return r()}export function readKey(){const n=u();return o(n)?e(n,"utf-8").trim():(t(n,s,"utf-8"),c.info("Created .nostromo-key with default key"),s)}export function isDefaultKey(){return readKey()===s}export function validateKey(e){return e.trim()===readKey()}export function regenerateKey(){const e=function(){const e="ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789",t=()=>{let t="";for(let o=0;o<4;o++)t+=e[n.randomInt(36)];return t};return`${t()}-${t()}-${t()}-${t()}`}();return t(u(),e,"utf-8"),c.info("Nostromo key regenerated"),e}export function resetKey(){t(u(),s,"utf-8"),c.info("Nostromo key reset to default")}const f=new Map;export function createSession(){const e=n.randomBytes(32).toString("hex"),t=n.randomBytes(32).toString("hex");return f.set(e,{createdAt:Date.now(),csrfToken:t}),{sessionToken:e,csrfToken:t}}export function validateSession(e){if(!e)return!1;const t=f.get(e);return!!t&&(!(Date.now()-t.createdAt>864e5)||(f.delete(e),!1))}export function getCsrfToken(e){if(!e)return null;const t=f.get(e);return t?t.csrfToken:null}export function validateCsrf(e,t){if(!e||!t)return!1;const o=f.get(e);return!!o&&o.csrfToken===t}export function destroySession(e){f.delete(e)}const l=new Map;export function checkRateLimit(e){const t=l.get(e);return t?Date.now()<t.blockedUntil?{allowed:!1,retryAfter:Math.ceil((t.blockedUntil-Date.now())/1e3)}:(t.blockedUntil>0&&l.delete(e),{allowed:!0}):{allowed:!0}}export function recordLoginFailure(e){const t=l.get(e)||{count:0,blockedUntil:0};t.count++,t.count>=5&&(t.blockedUntil=Date.now()+9e5,c.warn(`Login rate limit triggered for ${e} — blocked for 15 minutes`)),l.set(e,t)}export function resetLoginAttempts(e){l.delete(e)}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { Server } from "../server.js";
|
|
2
|
+
import type { NodeRegistry } from "../gateway/node-registry.js";
|
|
3
|
+
export declare class Nostromo {
|
|
4
|
+
private app;
|
|
5
|
+
private httpServer;
|
|
6
|
+
private wss;
|
|
7
|
+
private host;
|
|
8
|
+
private port;
|
|
9
|
+
private server;
|
|
10
|
+
private nodeRegistry;
|
|
11
|
+
private startedAt;
|
|
12
|
+
private pendingNodes;
|
|
13
|
+
private configHash;
|
|
14
|
+
private configPath;
|
|
15
|
+
private basePath;
|
|
16
|
+
constructor(server: Server, host: string, port: number, nodeRegistry: NodeRegistry, basePath?: string);
|
|
17
|
+
private hashConfigFile;
|
|
18
|
+
private setupRoutes;
|
|
19
|
+
start(): Promise<void>;
|
|
20
|
+
private notifyPairingChange;
|
|
21
|
+
stop(): Promise<void>;
|
|
22
|
+
}
|
|
23
|
+
//# sourceMappingURL=nostromo.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{Hono as e}from"hono";import{serve as t}from"@hono/node-server";import{getCookie as r,setCookie as n,deleteCookie as o}from"hono/cookie";import{stringify as s}from"yaml";import{writeFileSync as i,readFileSync as a,readdirSync as c,statSync as l,existsSync as d,rmSync as u,mkdirSync as f,unlinkSync as p,cpSync as m}from"node:fs";import{execFileSync as h}from"node:child_process";import{createHash as g}from"node:crypto";import{resolve as y,join as j,basename as v}from"node:path";import{WebSocketServer as k}from"ws";import{isHeartbeatContentEffectivelyEmpty as w}from"../cron/heartbeat-token.js";import{buildWebChatId as S}from"../gateway/channels/webchat.js";import{loadConfig as C,loadRawConfig as $,backupConfig as b}from"../config.js";import{renderSPA as N}from"./ui.js";import{buildSystemPrompt as T}from"../agent/prompt-builder.js";import{loadWorkspaceFiles as q}from"../agent/workspace-files.js";import{resolveBundledDir as I}from"../utils/package-paths.js";import{readKey as P,isDefaultKey as D,validateKey as A,regenerateKey as O,createSession as x,validateSession as E,validateCsrf as R,getCsrfToken as K,destroySession as F,checkRateLimit as L,recordLoginFailure as _,resetLoginAttempts as B}from"./auth.js";import{createLogger as z,getLogsDir as M,getLogLevel as U,setLogLevel as H}from"../utils/logger.js";const W=z("Nostromo"),J="nostromo_session";export class Nostromo{app;httpServer=null;wss=null;host;port;server;nodeRegistry;startedAt;pendingNodes=new Map;configHash="";configPath;basePath;constructor(t,r,n,o,s="/nostromo"){this.server=t,this.host=r,this.port=n,this.nodeRegistry=o,this.basePath=s.replace(/\/+$/,"")||"/",this.startedAt=Date.now(),this.app=new e,this.configPath=y(process.cwd(),"config.yaml"),this.configHash=this.hashConfigFile(),P(),this.setupRoutes()}hashConfigFile(){try{const e=a(this.configPath,"utf-8");return g("sha256").update(e).digest("hex")}catch{return""}}setupRoutes(){const e="/"===this.basePath?"":this.basePath,t=e?this.app.basePath(e):this.app;e&&this.app.get("/",t=>t.redirect(e+"/")),t.use("*",async(t,r)=>{const n=Date.now();await r();const o=Date.now()-n;t.req.path===e+"/api/logs/current"||t.req.path===e+"/api/config/check"||t.req.path===e+"/api/config"||t.req.path===e+"/api/memory-files"||t.req.path===e+"/api/status"||t.req.path===e+"/api/auth/session"?W.debug(`${t.req.method} ${t.req.path} ${t.res.status} ${o}ms`):W.info(`${t.req.method} ${t.req.path} ${t.res.status} ${o}ms`)}),t.use("/api/*",async(e,t)=>{const r=e.req.header("Origin");return r&&(e.header("Access-Control-Allow-Origin",r),e.header("Access-Control-Allow-Credentials","true"),e.header("Access-Control-Allow-Methods","GET, POST, PUT, DELETE, OPTIONS"),e.header("Access-Control-Allow-Headers","Content-Type, Authorization, X-CSRF-Token")),"OPTIONS"===e.req.method?new Response(null,{status:204}):t()}),t.get("/",t=>t.html(N(D(),e))),t.post("/api/auth/login",async t=>{const r=t.req.header("x-forwarded-for")||t.req.header("x-real-ip")||"unknown",o=L(r);if(!o.allowed)return t.json({error:`Too many attempts. Try again in ${o.retryAfter}s`},429);const s=(await t.req.json()).key;if(!A(s))return _(r),t.json({error:"Invalid access key"},401);B(r);const{sessionToken:i,csrfToken:a}=x();if(n(t,J,i,{httpOnly:!0,sameSite:"Lax",path:e||"/",maxAge:86400}),D()){const e=O();return W.info("Default key used; rotated to new key"),t.json({ok:!0,newKey:e,csrfToken:a})}return t.json({ok:!0,csrfToken:a})}),t.post("/api/auth/logout",async t=>{const n=r(t,J);return n&&F(n),o(t,J,{path:e||"/"}),t.json({ok:!0})}),t.get("/api/auth/session",e=>{const t=r(e,J);if(!E(t))return e.json({valid:!1},401);const n=K(t);return e.json({valid:!0,csrfToken:n})}),t.use("/api/*",async(t,n)=>{if(t.req.path.startsWith(e+"/api/auth/"))return n();const o=t.req.header("Authorization");if(o?.startsWith("Bearer ")){const e=o.slice(7);return this.server.getTokenDb().validateToken(e,"nostromo")?n():t.json({error:"Invalid API token"},401)}const s=r(t,J);if(!E(s))return t.json({error:"Unauthorized"},401);if("POST"===t.req.method||"PUT"===t.req.method||"DELETE"===t.req.method){const e=t.req.header("X-CSRF-Token");if(!R(s,e))return t.json({error:"Invalid CSRF token"},403)}return n()}),t.get("/api/config",e=>{const t=$(),r=this.server.getConfig();return t.dataDir=r.dataDir,t.gmabPath=r.gmabPath,e.json(t)}),t.put("/api/config",async e=>{try{const t=await e.req.json();if(t.cron?.heartbeat?.enabled&&(!t.cron.heartbeat.message||t.cron.heartbeat.message.trim().length<15))return e.json({error:"Heartbeat message is required and must be at least 15 characters"},400);const{dataDir:r,dbPath:n,memoryDir:o,cronStorePath:c,...l}=t,u=function(e){const t={};if(e.channels)for(const[r,n]of Object.entries(V)){const o=e.channels[r];if(o?.accounts)for(const[e,s]of Object.entries(o.accounts))for(const o of n){const n=s[o.field];if("string"==typeof n&&n.length>0&&!Y(n)){const i=`${r.toUpperCase()}_${e.toUpperCase().replace(/-/g,"_")}_${o.envSuffix}`;t[i]=n,s[o.field]=`\${${i}}`}}}if(Array.isArray(e.models))for(const r of e.models){if((r.types||["external"]).includes("internal"))continue;const e=r.apiKey;if("string"==typeof e&&e.length>0&&!Y(e)){const n=r.useEnvVar||"OPENAI_API_KEY";t[n]=e,r.apiKey=`\${${n}}`,r.useEnvVar||(r.useEnvVar=n)}const n=r.fastProxyApiKey;if("string"==typeof n&&n.length>0&&!Y(n)){const e="FAST_PROXY_APIKEY";t[e]=n,r.fastProxyApiKey=`\${${e}}`}}if(e.stt?.["openai-whisper"]?.apiKey){const r=e.stt["openai-whisper"].apiKey;if("string"==typeof r&&r.length>0&&!Y(r)){const n="STT_OPENAI_WHISPER_API_KEY";t[n]=r,e.stt["openai-whisper"].apiKey=`\${${n}}`}}if(Object.keys(t).length>0){!function(e){const t=y(process.cwd(),".env");let r=d(t)?a(t,"utf-8"):"";for(const[t,n]of Object.entries(e)){const e=new RegExp(`^${t}=.*$`,"m");e.test(r)?r=r.replace(e,`${t}=${n}`):(r.length>0&&!r.endsWith("\n")&&(r+="\n"),r+=`${t}=${n}\n`)}i(t,r,"utf-8")}(t);for(const[e,r]of Object.entries(t))process.env[e]=r;W.info(`Protected ${Object.keys(t).length} secret(s) → .env`)}return e}(l),f=s(u),p=y(process.cwd(),"config.yaml");return b(p),i(p,f,"utf-8"),W.info("Config updated via Nostromo"),e.json({ok:!0})}catch(t){return W.error(`Config save failed: ${t}`),e.json({error:"Failed to save configuration"},500)}}),t.get("/api/tokens",e=>{const t=this.server.getTokenDb().listTokens();return e.json(t)}),t.post("/api/tokens",async e=>{const t=await e.req.json(),r=this.server.getTokenDb().createToken({userId:t.userId,channel:t.channel,label:t.label});return e.json(r,201)}),t.delete("/api/tokens/:id",e=>{const t=parseInt(e.req.param("id"));return this.server.getTokenDb().deleteToken(t),e.json({ok:!0})}),t.post("/api/tokens/:id/revoke",e=>{const t=parseInt(e.req.param("id"));return this.server.getTokenDb().revokeToken(t),e.json({ok:!0})}),t.post("/api/tokens/:id/approve",e=>{const t=parseInt(e.req.param("id"));return this.server.getTokenDb().approveToken(t),e.json({ok:!0})}),t.get("/api/key",e=>e.json({key:P()})),t.post("/api/key/regenerate",e=>{const t=O();return e.json({key:t})}),t.get("/api/whatsapp/qr",e=>{const t=this.server.getWhatsAppQrState();return e.json(t)}),t.get("/api/status",e=>{const t=Date.now()-this.startedAt,r=Math.floor(t/1e3),n=`${Math.floor(r/3600)}h ${Math.floor(r%3600/60)}m ${r%60}s`;return e.json({status:"online",uptime:n,uptimeMs:t})}),t.post("/api/server/restart",async e=>{try{W.info("Server restart requested via Nostromo");const t=C();return await this.server.reconfigure(t),this.startedAt=Date.now(),this.configHash=this.hashConfigFile(),W.info("Server restarted successfully via Nostromo"),e.json({ok:!0})}catch(t){return W.error(`Server restart failed: ${t}`),e.json({error:`Restart failed: ${t instanceof Error?t.message:String(t)}`},500)}}),t.get("/api/config/check",e=>{const t=this.hashConfigFile(),r=t!==this.configHash&&""!==t;return e.json({restartNeeded:r})}),t.get("/api/sessions",e=>{const t=this.server.getSessionDb().listSessions().map(e=>{const[t,...r]=e.sessionKey.split(":"),n=r.join(":"),o=new Date(e.createdAt+"Z"),s=e=>String(e).padStart(2,"0"),i=`${o.getFullYear()}${s(o.getMonth()+1)}${s(o.getDate())}_${s(o.getHours())}${s(o.getMinutes())}`,a=`${e.sdkSessionId??"pending"}:${n}:${t}:${i}`;return{sessionKey:e.sessionKey,channelName:t,chatId:n,sdkSessionId:e.sdkSessionId,modelOverride:e.modelOverride,createdAt:e.createdAt,lastActivity:e.lastActivity,displayId:a}});return e.json(t)}),t.post("/api/sessions/:sessionKey/messages",async e=>{try{const t=e.req.param("sessionKey"),r=(await e.req.json()).message;if(!r)return e.json({error:"message is required"},400);const n=this.server.getSessionDb();if(!n.getBySessionKey(t))return e.json({error:"Session not found"},404);const[o,...s]=t.split(":"),i=s.join(":"),a={chatId:i,userId:"nostromo",channelName:o,text:r,attachments:[]},c=await this.server.handleMessage(a),l=this.server.getChannelManager();return await l.sendToChannel(o,i,c),e.json({ok:!0,sessionKey:t,response:c})}catch(t){return W.error(`Cross-session message failed: ${t}`),e.json({error:"Failed to process message"},500)}}),t.get("/api/nodes",e=>{const t=this.nodeRegistry.listNodes();return e.json(t)}),t.get("/api/nodes/signatures",e=>{const t=this.server.getNodeSignatureDb(),r=e.req.query("status"),n=r?t.listByStatus(r):t.listAll();return e.json(n)}),t.post("/api/nodes/signatures/:id/approve",e=>{const t=parseInt(e.req.param("id")),r=this.server.getNodeSignatureDb();return r.getById(t)?(r.approve(t),this.notifyPairingChange(t,"approved"),e.json({ok:!0})):e.json({error:"Not found"},404)}),t.post("/api/nodes/signatures/:id/revoke",e=>{const t=parseInt(e.req.param("id")),r=this.server.getNodeSignatureDb();return r.getById(t)?(r.revoke(t),this.notifyPairingChange(t,"revoked"),e.json({ok:!0})):e.json({error:"Not found"},404)}),t.delete("/api/nodes/signatures/:id",e=>{const t=parseInt(e.req.param("id")),r=this.server.getNodeSignatureDb();return r.getById(t)?(this.notifyPairingChange(t,"revoked"),r.delete(t),e.json({ok:!0})):e.json({error:"Not found"},404)}),t.get("/api/cron/status",async e=>{const t=this.server.getCronService();if(!t)return e.json({error:"Cron not available"},503);const r=await t.status();return e.json(r)}),t.get("/api/cron/jobs",async e=>{const t=this.server.getCronService();if(!t)return e.json({error:"Cron not available"},503);const r="true"===e.req.query("includeDisabled"),n=await t.list({includeDisabled:r});return e.json(n)}),t.post("/api/cron/jobs",async e=>{const t=this.server.getCronService();if(!t)return e.json({error:"Cron not available"},503);try{const r=await e.req.json(),n=await t.add(r);return e.json(n,201)}catch(t){return W.error(`Cron job add failed: ${t}`),e.json({error:String(t)},400)}}),t.put("/api/cron/jobs/:id",async e=>{const t=this.server.getCronService();if(!t)return e.json({error:"Cron not available"},503);try{const r=e.req.param("id"),n=await e.req.json(),o=await t.update(r,n);return e.json(o)}catch(t){return W.error(`Cron job update failed: ${t}`),e.json({error:String(t)},400)}}),t.delete("/api/cron/jobs/:id",async e=>{const t=this.server.getCronService();if(!t)return e.json({error:"Cron not available"},503);try{const r=e.req.param("id"),n=await t.remove(r);return e.json(n)}catch(t){return W.error(`Cron job remove failed: ${t}`),e.json({error:String(t)},400)}}),t.post("/api/cron/jobs/:id/run",async e=>{const t=this.server.getCronService();if(!t)return e.json({error:"Cron not available"},503);try{const r=e.req.param("id"),n="due"===(await e.req.json().catch(()=>({}))).mode?"due":"force",o=await t.run(r,n);return e.json(o)}catch(t){return W.error(`Cron job run failed: ${t}`),e.json({error:String(t)},400)}}),t.post("/api/heartbeat/simulate",async e=>{const t=this.server.getConfig(),r=j(t.dataDir,"HEARTBEAT.md");let n="",o=!1;try{d(r)&&(o=!0,n=a(r,"utf-8"))}catch{}const s=!(o&&w(n));return e.json({accepted:s,fileExists:o,content:n})}),t.post("/api/inject",async e=>{try{const t=await e.req.json(),{channel:r,chatId:n,text:o}=t;if(!r||!n||!o)return e.json({error:"channel, chatId, and text are required"},400);const s={chatId:n,userId:"inject",channelName:r,text:o,attachments:[]},i=await this.server.handleMessage(s),a=this.server.getChannelManager();await a.sendToChannel(r,n,i);const c=`${r}:${n}`;return e.json({ok:!0,sessionKey:c,response:i})}catch(t){return W.error(`Inject message failed: ${t}`),e.json({error:"Failed to inject message"},500)}}),t.get("/api/internal-tools",e=>{const t=this.server.getAgentService().getToolServers(),r=[];for(const e of t){const t=e,n=t.name||"unknown",o=t.instance?._registeredTools;if(!o)continue;const s=[];for(const[e,t]of Object.entries(o)){const r=[],n=t.inputSchema?.def?.shape;if(n)for(const[e,t]of Object.entries(n)){let n=t.type||"unknown";const o=t.isOptional?.()??!1;"optional"===n&&t.def?.innerType&&(n=t.def.innerType.type||"unknown","enum"===n&&t.def.innerType.def?.entries&&(n=Object.keys(t.def.innerType.def.entries).join("|"))),"enum"===n&&t.def?.entries&&(n=Object.keys(t.def.entries).join("|")),r.push({name:e,type:n,required:!o,description:t.description||""})}s.push({name:e,description:t.description||"",params:r})}r.push({server:n,tools:s})}return e.json(r)});const g=["AGENTS.md","SOUL.md","TOOLS.md","IDENTITY.md","USER.md","HEARTBEAT.md","BOOTSTRAP.md","MEMORY.md"],k=["SYSTEM_PROMPT.md","SYSTEM_PROMPT_SUBAGENT.md","CBINT.json"];t.get("/api/workspace-files",e=>{const t=this.server.getConfig().dataDir,r=[];for(const e of g){const n=j(t,e);if(d(n))try{r.push({name:e,content:a(n,"utf-8"),exists:!0,isTemplate:!1})}catch{r.push({name:e,content:"",exists:!1,isTemplate:!1})}else r.push({name:e,content:"",exists:!1,isTemplate:!1})}for(const e of k){const n=j(t,".templates",e);if(d(n))try{r.push({name:e,content:a(n,"utf-8"),exists:!0,isTemplate:!0})}catch{r.push({name:e,content:"",exists:!1,isTemplate:!0})}else r.push({name:e,content:"",exists:!1,isTemplate:!0})}return e.json(r)}),t.put("/api/workspace-files/:name",async e=>{const t=e.req.param("name"),r=await e.req.json(),n=r.content,o=!0===r.isTemplate;if(null==n)return e.json({error:"content is required"},400);const s=this.server.getConfig().dataDir;let a;if(o){if(!k.includes(t))return e.json({error:"Invalid template name"},400);a=j(s,".templates",t)}else{if(!g.includes(t))return e.json({error:"Invalid file name"},400);a=j(s,t)}try{return i(a,n,"utf-8"),W.info(`Workspace file saved via Nostromo: ${a}`),e.json({ok:!0})}catch(t){return W.error(`Failed to save workspace file: ${t}`),e.json({error:"Failed to save file"},500)}}),t.post("/api/plugins/upload",async e=>{try{const t=this.server.getConfig(),r=await e.req.formData(),n=(r.get("folderName")||"").trim();if(!n)return e.json({error:"folderName is required"},400);if(n.includes("..")||n.includes("/")||n.includes("\\"))return e.json({error:"Invalid folder name"},400);const o=r.getAll("files"),s=r.getAll("paths");if(0===o.length)return e.json({error:"No files provided"},400);if(o.length!==s.length)return e.json({error:"files and paths count mismatch"},400);const c=(r.get("basePath")||"").trim()||j(t.agent.workspacePath,".plugins"),l=j(c,n);f(l,{recursive:!0});for(let e=0;e<o.length;e++){const t=o[e],r=s[e];if(r.includes(".."))continue;const n=j(l,r),a=j(n,"..");f(a,{recursive:!0});const c=Buffer.from(await t.arrayBuffer());i(n,c)}let u=n,p=n;const m=j(l,".claude-plugin","plugin.json");if(d(m))try{const e=a(m,"utf-8"),t=JSON.parse(e);t.name&&(u=t.name),t.description&&(p=t.description)}catch{}return e.json({ok:!0,path:l,name:u,description:p})}catch(t){return W.error(`Plugin upload failed: ${t}`),e.json({error:"Upload failed"},500)}}),t.post("/api/plugins/delete-folder",async e=>{try{const t=((await e.req.json()).path||"").trim();if(!t)return e.json({error:"path is required"},400);const r=y(t);return d(r)?l(r).isDirectory()?(u(r,{recursive:!0,force:!0}),W.info(`Plugin folder deleted via Nostromo: ${r}`),e.json({ok:!0})):e.json({error:"Path is not a directory"},400):e.json({ok:!0,message:"Directory already gone"})}catch(t){return W.error(`Plugin folder delete failed: ${t}`),e.json({error:"Failed to delete folder"},500)}}),t.get("/api/plugins/download/:index",e=>{try{const t=parseInt(e.req.param("index"),10),r=this.server.getConfig(),n=r.agent?.plugins||[];if(isNaN(t)||t<0||t>=n.length)return e.json({error:"Invalid plugin index"},400);const o=n[t].path;if(!o)return e.json({error:"Plugin has no path"},400);const s=y(o);if(!d(s)||!l(s).isDirectory())return e.json({error:"Plugin folder not found"},404);const i=v(s),a=j(s,".."),c=h("zip",["-r","-q","-",i],{cwd:a,maxBuffer:104857600});return new Response(c,{headers:{"Content-Type":"application/zip","Content-Disposition":`attachment; filename="${i}.zip"`}})}catch(t){return W.error(`Plugin download failed: ${t}`),e.json({error:"Download failed"},500)}}),t.post("/api/plugins/verify-path",async e=>{try{const t=((await e.req.json()).path||"").trim();if(!t)return e.json({ok:!1,error:"Path is empty"});const r=y(t);return d(r)?l(r).isDirectory()?e.json({ok:!0,path:r}):e.json({ok:!1,error:"Path is not a directory"}):e.json({ok:!1,error:"Directory does not exist"})}catch(t){return e.json({ok:!1,error:"Invalid path"})}}),t.get("/api/plugins/info",e=>{const t=this.server.getConfig().agent.plugins||[],r=[];for(let e=0;e<t.length;e++){const n=t[e];let o=n.name,s=n.description||"",i=!1;try{if(d(n.path)&&l(n.path).isDirectory()){i=c(n.path).length>0;const e=j(n.path,".claude-plugin","plugin.json");if(d(e))try{const t=a(e,"utf-8"),r=JSON.parse(t);r.name&&(o=r.name),r.description&&!s&&(s=r.description)}catch{}}}catch{}r.push({index:e,name:o,description:s,valid:i})}return e.json(r)}),t.get("/api/commands",e=>{const t=this.server.getConfig(),r=j(t.agent.workspacePath,".claude","commands"),n=[];if(!d(r))return e.json(n);const o=(e,t)=>{try{const r=c(e);for(const s of r){const r=j(e,s);if(l(r).isDirectory())o(r,t?`${t}/${s}`:s);else if(s.endsWith(".md")){const e=t?`${t}/${s}`:s;let o=s.replace(/\.md$/,""),i="",c="";try{const e=Z(a(r,"utf-8"));e.name&&(o=e.name),e.description&&(i=e.description),e.model&&(c=e.model)}catch{}n.push({file:e,folder:t,name:o,description:i,model:c})}}}catch{}};return o(r,""),n.sort((e,t)=>e.folder!==t.folder?e.folder.localeCompare(t.folder):e.name.localeCompare(t.name)),e.json(n)}),t.post("/api/commands/delete",async e=>{try{const t=((await e.req.json()).path||"").trim();if(!t||t.includes(".."))return e.json({error:"Invalid path"},400);const r=this.server.getConfig(),n=j(r.agent.workspacePath,".claude","commands",t);if(!d(n))return e.json({error:"Command not found"},404);if(l(n).isDirectory())u(n,{recursive:!0,force:!0});else{p(n);const e=j(n,"..");if(e!==j(r.agent.workspacePath,".claude","commands"))try{0===c(e).length&&u(e,{recursive:!0,force:!0})}catch{}}return W.info(`Command deleted via Nostromo: ${t}`),e.json({ok:!0})}catch(t){return W.error(`Failed to delete command: ${t}`),e.json({error:"Failed to delete command"},500)}}),t.post("/api/commands/upload",async e=>{try{const t=this.server.getConfig(),r=await e.req.formData(),n=(r.get("folderName")||"").trim();if(!n)return e.json({error:"folderName is required"},400);if(n.includes("..")||n.includes("/")||n.includes("\\"))return e.json({error:"Invalid folder name"},400);const o=r.getAll("files"),s=r.getAll("paths");if(0===o.length)return e.json({error:"No files provided"},400);if(o.length!==s.length)return e.json({error:"files and paths count mismatch"},400);const a=j(t.agent.workspacePath,".claude","commands",n);f(a,{recursive:!0});for(let e=0;e<o.length;e++){const t=o[e],r=s[e];if(r.includes(".."))continue;const n=j(a,r),c=j(n,"..");f(c,{recursive:!0});const l=Buffer.from(await t.arrayBuffer());i(n,l)}return W.info(`Command folder uploaded via Nostromo: ${n}`),e.json({ok:!0,name:n})}catch(t){return W.error(`Command upload failed: ${t}`),e.json({error:"Upload failed"},500)}}),t.get("/api/commands/download/:folder",e=>{try{const t=e.req.param("folder");if(!t||t.includes("..")||t.includes("/")||t.includes("\\"))return e.json({error:"Invalid folder name"},400);const r=this.server.getConfig(),n=j(r.agent.workspacePath,".claude","commands"),o=j(n,t);if(!d(o)||!l(o).isDirectory())return e.json({error:"Command folder not found"},404);const s=h("zip",["-r","-q","-",t],{cwd:n,maxBuffer:104857600});return new Response(s,{headers:{"Content-Type":"application/zip","Content-Disposition":`attachment; filename="${t}.zip"`}})}catch(t){return W.error(`Command download failed: ${t}`),e.json({error:"Download failed"},500)}}),t.get("/api/commands/download-file",e=>{try{const t=(e.req.query("path")||"").trim();if(!t||t.includes(".."))return e.json({error:"Invalid path"},400);const r=this.server.getConfig(),n=j(r.agent.workspacePath,".claude","commands",t);if(!d(n)||!l(n).isFile())return e.json({error:"File not found"},404);const o=a(n),s=v(n);return new Response(o,{headers:{"Content-Type":"application/octet-stream","Content-Disposition":`attachment; filename="${s}"`}})}catch(t){return W.error(`Command file download failed: ${t}`),e.json({error:"Download failed"},500)}}),t.get("/api/commands/file",e=>{try{const t=(e.req.query("path")||"").trim();if(!t||t.includes(".."))return e.json({error:"Invalid path"},400);const r=this.server.getConfig(),n=j(r.agent.workspacePath,".claude","commands",t);if(!d(n)||!l(n).isFile())return e.json({error:"File not found"},404);const o=a(n,"utf-8");return e.json({path:t,content:o})}catch(t){return W.error(`Command file read failed: ${t}`),e.json({error:"Read failed"},500)}}),t.post("/api/commands/file",async e=>{try{const t=await e.req.json(),r=(t.path||"").trim(),n=t.content;if(!r||r.includes(".."))return e.json({error:"Invalid path"},400);if("string"!=typeof n)return e.json({error:"Content is required"},400);const o=this.server.getConfig(),s=j(o.agent.workspacePath,".claude","commands",r),a=j(s,"..");return f(a,{recursive:!0}),i(s,n,"utf-8"),W.info(`Command file saved via Nostromo: ${r}`),e.json({ok:!0})}catch(t){return W.error(`Command file save failed: ${t}`),e.json({error:"Save failed"},500)}}),t.post("/api/commands/create",async e=>{try{const t=await e.req.json(),r=(t.name||"").trim();if(!r||!/^[a-zA-Z0-9_-]+$/.test(r))return e.json({error:"Invalid name. Use only letters, digits, hyphens, and underscores."},400);const n=(t.description||"").trim(),o=(t.model||"").trim(),s=this.server.getConfig(),a=j(s.agent.workspacePath,".claude","commands");f(a,{recursive:!0});const c=j(a,r+".md");if(d(c))return e.json({error:"A command with this name already exists"},409);let l="---\n";return l+=`name: ${r}\n`,n&&(l+=`description: ${n}\n`),o&&(l+=`model: ${o}\n`),l+="---\n\n",i(c,l,"utf-8"),W.info(`Standalone command created via Nostromo: ${r}.md`),e.json({ok:!0,file:r+".md"})}catch(t){return W.error(`Command creation failed: ${t}`),e.json({error:"Creation failed"},500)}}),t.get("/api/skills",e=>{const t=this.server.getConfig(),r=j(t.agent.workspacePath,".claude","skills"),n=[];if(!d(r))return e.json(n);try{const e=c(r);for(const t of e){const e=j(r,t);if(!l(e).isDirectory())continue;const o=j(e,"SKILL.md");let s=t,i="";if(d(o))try{const e=Z(a(o,"utf-8"));e.name&&(s=e.name),e.description&&(i=e.description)}catch{}n.push({folder:t,name:s,description:i})}}catch(e){W.error(`Failed to list skills: ${e}`)}return e.json(n)}),t.post("/api/skills/delete",async e=>{try{const t=((await e.req.json()).folder||"").trim();if(!t||t.includes("..")||t.includes("/"))return e.json({error:"Invalid folder name"},400);const r=this.server.getConfig(),n=j(r.agent.workspacePath,".claude","skills",t);return d(n)?(l(n).isDirectory()?u(n,{recursive:!0,force:!0}):p(n),W.info(`Skill deleted via Nostromo: ${t}`),e.json({ok:!0})):e.json({error:"Skill not found"},404)}catch(t){return W.error(`Failed to delete skill: ${t}`),e.json({error:"Failed to delete skill"},500)}}),t.post("/api/skills/upload",async e=>{try{const t=this.server.getConfig(),r=await e.req.formData(),n=(r.get("folderName")||"").trim();if(!n)return e.json({error:"folderName is required"},400);if(n.includes("..")||n.includes("/")||n.includes("\\"))return e.json({error:"Invalid folder name"},400);const o=r.getAll("files"),s=r.getAll("paths");if(0===o.length)return e.json({error:"No files provided"},400);if(o.length!==s.length)return e.json({error:"files and paths count mismatch"},400);const a=j(t.agent.workspacePath,".claude","skills",n);f(a,{recursive:!0});for(let e=0;e<o.length;e++){const t=o[e],r=s[e];if(r.includes(".."))continue;const n=j(a,r),c=j(n,"..");f(c,{recursive:!0});const l=Buffer.from(await t.arrayBuffer());i(n,l)}return W.info(`Skill folder uploaded via Nostromo: ${n}`),e.json({ok:!0,name:n})}catch(t){return W.error(`Skill upload failed: ${t}`),e.json({error:"Upload failed"},500)}}),t.get("/api/skills/download/:folder",e=>{try{const t=e.req.param("folder");if(!t||t.includes("..")||t.includes("/")||t.includes("\\"))return e.json({error:"Invalid folder name"},400);const r=this.server.getConfig(),n=j(r.agent.workspacePath,".claude","skills"),o=j(n,t);if(!d(o)||!l(o).isDirectory())return e.json({error:"Skill folder not found"},404);const s=h("zip",["-r","-q","-",t],{cwd:n,maxBuffer:104857600});return new Response(s,{headers:{"Content-Type":"application/zip","Content-Disposition":`attachment; filename="${t}.zip"`}})}catch(t){return W.error(`Skill download failed: ${t}`),e.json({error:"Download failed"},500)}}),t.get("/api/skills/file",e=>{try{const t=(e.req.query("folder")||"").trim();if(!t||t.includes("..")||t.includes("/")||t.includes("\\"))return e.json({error:"Invalid folder name"},400);const r=this.server.getConfig(),n=j(r.agent.workspacePath,".claude","skills",t,"SKILL.md");if(!d(n))return e.json({path:t+"/SKILL.md",content:""});const o=a(n,"utf-8");return e.json({path:t+"/SKILL.md",content:o})}catch(t){return W.error(`Skill file read failed: ${t}`),e.json({error:"Read failed"},500)}}),t.post("/api/skills/file",async e=>{try{const t=await e.req.json(),r=(t.folder||"").trim(),n=t.content;if(!r||r.includes("..")||r.includes("/")||r.includes("\\"))return e.json({error:"Invalid folder name"},400);if("string"!=typeof n)return e.json({error:"Content is required"},400);const o=this.server.getConfig(),s=j(o.agent.workspacePath,".claude","skills",r);f(s,{recursive:!0});const a=j(s,"SKILL.md");return i(a,n,"utf-8"),W.info(`Skill file saved via Nostromo: ${r}/SKILL.md`),e.json({ok:!0})}catch(t){return W.error(`Skill file save failed: ${t}`),e.json({error:"Save failed"},500)}}),t.get("/api/skills/bundled",e=>{try{const t=I();if(!t)return e.json([]);const r=c(t,{withFileTypes:!0}),n=[];for(const e of r){if(!e.isDirectory())continue;const r=j(t,e.name,"SKILL.md");if(!d(r))continue;const o=Z(a(r,"utf-8"));n.push({folder:e.name,name:o.name||e.name,description:o.description||""})}return n.sort((e,t)=>e.name.localeCompare(t.name)),e.json(n)}catch(t){return W.error(`Failed to list bundled skills: ${t}`),e.json({error:"Failed to list bundled skills"},500)}}),t.post("/api/skills/use-bundled",async e=>{try{const t=((await e.req.json()).folder||"").trim();if(!t||t.includes("..")||t.includes("/")||t.includes("\\"))return e.json({error:"Invalid folder name"},400);const r=I();if(!r)return e.json({error:"Bundled directory not found"},404);const n=j(r,t);if(!d(n))return e.json({error:"Bundled skill not found"},404);const o=this.server.getConfig(),s=j(o.agent.workspacePath,".claude","skills",t);return m(n,s,{recursive:!0}),W.info(`Bundled skill installed via Nostromo: ${t}`),e.json({ok:!0,name:t})}catch(t){return W.error(`Use bundled skill failed: ${t}`),e.json({error:"Install failed"},500)}}),t.get("/api/skills/bundled/file",e=>{try{const t=(e.req.query("folder")||"").trim();if(!t||t.includes("..")||t.includes("/")||t.includes("\\"))return e.json({error:"Invalid folder name"},400);const r=I();if(!r)return e.json({error:"Bundled directory not found"},404);const n=j(r,t,"SKILL.md");if(!d(n))return e.json({error:"File not found"},404);const o=a(n,"utf-8");return e.json({content:o})}catch(t){return W.error(`Bundled skill file read failed: ${t}`),e.json({error:"Read failed"},500)}}),t.post("/api/skills/bundled/file",async e=>{try{const t=await e.req.json(),r=(t.folder||"").trim(),n=t.content;if(!r||r.includes("..")||r.includes("/")||r.includes("\\"))return e.json({error:"Invalid folder name"},400);if("string"!=typeof n)return e.json({error:"Content is required"},400);const o=I();if(!o)return e.json({error:"Bundled directory not found"},404);const s=j(o,r);if(!d(s))return e.json({error:"Bundled skill folder not found"},404);const a=j(s,"SKILL.md");return i(a,n,"utf-8"),W.info(`Bundled skill file saved via Nostromo: ${r}/SKILL.md`),e.json({ok:!0})}catch(t){return W.error(`Bundled skill file save failed: ${t}`),e.json({error:"Save failed"},500)}}),t.get("/api/prompt-simulate",e=>{try{const t=C(),r=q(t.dataDir),n={sessionKey:"simulate:preview",channel:"simulate",chatId:"preview",sessionId:"(simulated)",memoryFile:j(t.dataDir,"memory","simulate_preview","2026-01-01.md"),attachmentsDir:j(t.dataDir,"memory","simulate_preview","2026-01-01")},o=this.server.getAgentService().getToolServers(),s=T({config:t,sessionContext:n,workspaceFiles:r,mode:"full",hasNodeTools:this.server.getAgentService().hasNodeTools(),hasMessageTools:this.server.getAgentService().hasMessageTools(),coderSkill:t.agent.builtinCoderSkill,toolServers:o}),i=T({config:t,sessionContext:n,workspaceFiles:r,mode:"minimal",hasNodeTools:this.server.getAgentService().hasNodeTools(),hasMessageTools:this.server.getAgentService().hasMessageTools(),coderSkill:t.agent.builtinCoderSkill,subagentTask:"(example subagent task description)",toolServers:o});return e.json({main:s,subagent:i})}catch(t){return W.error(`Prompt simulation failed: ${t}`),e.json({error:"Failed to simulate prompt: "+String(t)},500)}}),t.post("/api/memory-search/test-embedding",async e=>{try{const t=await e.req.json(),r=t.modelRef||"",n=t.embeddingModel||"text-embedding-3-small",o=t.embeddingDimensions||1536,s=this.server.getConfig(),i=s.models?.find(e=>e.name===r),a=(i?.useEnvVar?process.env[i.useEnvVar]:i?.apiKey)||"",c=i?.baseURL||"";if(!a)return e.json({ok:!1,error:`No API key found for modelRef "${r}"`},400);const{default:l}=await import("openai"),d=new l({apiKey:a,...c?{baseURL:c}:{}}),u=Date.now(),f=await d.embeddings.create({model:n,input:"test embedding connection",dimensions:o}),p=Date.now()-u,m=f.data?.[0]?.embedding?.length??0;return e.json({ok:!0,model:n,dimensions:m,latencyMs:p})}catch(t){const r=t instanceof Error?t.message:String(t);return W.error(`Embedding test failed: ${r}`),e.json({ok:!1,error:r},500)}}),t.get("/api/memory-files",e=>{const t=this.server.getConfig().memoryDir;if(!d(t))return e.json([]);try{const r=[],n=c(t,{withFileTypes:!0});for(const e of n){if(!e.isDirectory())continue;const n=j(t,e.name),o=c(n).filter(e=>e.endsWith(".md")).map(e=>{const t=l(j(n,e));return{name:e,size:t.size,modified:t.mtime.toISOString()}}).sort((e,t)=>t.name.localeCompare(e.name));o.length>0&&r.push({sessionKey:e.name,files:o})}return r.sort((e,t)=>e.sessionKey.localeCompare(t.sessionKey)),e.json(r)}catch(t){return W.error(`Failed to list memory files: ${t}`),e.json([])}}),t.get("/api/memory-files/:sessionKey/:fileName",e=>{const t=e.req.param("sessionKey"),r=e.req.param("fileName");if(!/^[\w-]+\.md$/.test(r))return e.json({error:"Invalid file name"},400);if(/[/\\.]/.test(t.replace(/:/g,"")))return e.json({error:"Invalid session key"},400);const n=this.server.getConfig(),o=j(n.memoryDir,t.replace(/:/g,"_"),r);if(!d(o))return e.json({error:"File not found"},404);try{const n=a(o,"utf-8");return e.json({sessionKey:t,fileName:r,content:n})}catch(t){return e.json({error:"Failed to read file"},500)}}),t.get("/api/logs/current",e=>{const t=M();if(!t)return e.json({lines:[],total:0});const r=j(t,"gmab.log");try{const t=a(r,"utf-8").split("\n").filter(e=>e.length>0),n=Math.min(Math.max(parseInt(e.req.query("lines")||"200")||200,1),1e3),o=t.slice(-n);return e.json({lines:o,total:t.length})}catch{return e.json({lines:[],total:0})}}),t.get("/api/logs/current/download",async e=>{const t=M();if(!t)return e.json({error:"Logs not initialized"},503);const r=j(t,"gmab.log"),n="true"===e.req.query("compress");try{if(n){const e=a(r),{gzipSync:t}=await import("node:zlib"),n=t(e);return new Response(n,{headers:{"Content-Type":"application/gzip","Content-Disposition":'attachment; filename="gmab.log.gz"'}})}const e=a(r);return new Response(e,{headers:{"Content-Type":"text/plain","Content-Disposition":'attachment; filename="gmab.log"'}})}catch{return e.json({error:"Log file not found"},404)}}),t.get("/api/logs/files",e=>{const t=M();if(!t)return e.json([]);try{const r=c(t).filter(e=>/^gmab\.\d+\.log$/.test(e)).map(e=>{const r=l(j(t,e));return{name:e,size:r.size,modified:r.mtime.toISOString()}}).sort((e,t)=>e.name.localeCompare(t.name,void 0,{numeric:!0}));return e.json(r)}catch{return e.json([])}}),t.get("/api/logs/files/:name/download",async e=>{const t=M();if(!t)return e.json({error:"Logs not initialized"},503);const r=e.req.param("name");if(!/^gmab\.\d+\.log$/.test(r))return e.json({error:"Invalid file name"},400);const n=j(t,r);try{const e=a(n),{gzipSync:t}=await import("node:zlib"),o=t(e);return new Response(o,{headers:{"Content-Type":"application/gzip","Content-Disposition":`attachment; filename="${r}.gz"`}})}catch{return e.json({error:"File not found"},404)}}),t.get("/api/logs/level",e=>e.json({level:U()})),t.put("/api/logs/level",async e=>{try{const t=(await e.req.json()).level,r=["debug","info","warn","error"];if(!r.includes(t))return e.json({error:"Invalid level. Must be one of: "+r.join(", ")},400);H(t);try{const e=y(process.cwd(),"config.yaml"),{parse:r}=await import("yaml"),n=r(a(e,"utf-8"))||{};n.logLevel=t;const o=s(n);b(e),i(e,o,"utf-8")}catch(e){W.warn(`Failed to persist logLevel to config.yaml: ${e}`)}return W.info(`Log level changed to ${t}`),e.json({ok:!0,level:t})}catch(t){return e.json({error:"Failed to set log level"},400)}}),t.put("/api/logs/verbose",async e=>{try{const t=!!(await e.req.json()).enabled;try{const e=y(process.cwd(),"config.yaml"),{parse:r}=await import("yaml"),n=r(a(e,"utf-8"))||{};n.verboseDebugLogs=t;const o=s(n);b(e),i(e,o,"utf-8")}catch(e){W.warn(`Failed to persist verboseDebugLogs to config.yaml: ${e}`)}return W.info("Verbose debug logs "+(t?"enabled":"disabled")),e.json({ok:!0,enabled:t})}catch(t){return e.json({error:"Failed to set verbose logs"},400)}})}async start(){this.httpServer=t({fetch:this.app.fetch,hostname:this.host,port:this.port}),this.wss=new k({noServer:!0}),this.httpServer.on("upgrade",(e,t,r)=>{const n=new URL(e.url??"/",`http://localhost:${this.port}`),o="/"===this.basePath?"":this.basePath;n.pathname===o+"/ws/nodes"?this.wss.handleUpgrade(e,t,r,t=>{this.wss.emit("connection",t,e)}):t.destroy()}),this.wss.on("connection",e=>{let t=null,r=null,n=!1;const o=()=>{n&&t&&(this.nodeRegistry.unregister(t),n=!1),r&&this.pendingNodes.delete(r);const o=this.server.getWebChatChannel();o&&o.unregisterByWs(e)};e.on("message",o=>{try{const s=JSON.parse(o.toString());if("hello"===s.type){t=s.nodeId;const o=s.signature;if(!t)return void e.close(1008,"Missing nodeId in hello");if(!o)return void e.close(1008,"Missing signature");r=o;const i=this.server.getNodeSignatureDb(),a=i.createOrUpdatePending(t,o,s.hostname??"",s.displayName??"",s.platform??"",s.arch??""),c={displayName:s.displayName,platform:s.platform,arch:s.arch,hostname:s.hostname,capabilities:s.capabilities??[],commands:s.commands??[]};switch(a.status){case"pending":e.send(JSON.stringify({type:"pairing_status",status:"pending"})),this.pendingNodes.set(o,{ws:e,nodeId:t,info:c}),W.info(`Node ${t} (${s.displayName??"unnamed"}) awaiting approval`);break;case"approved":e.send(JSON.stringify({type:"pairing_status",status:"approved"})),this.nodeRegistry.register(t,e,c),n=!0,i.updateLastSeen(a.id),W.info(`Node ${t} (${s.displayName??"unnamed"}) approved and registered`);break;case"revoked":e.send(JSON.stringify({type:"pairing_status",status:"revoked"})),e.close(1008,"Signature revoked")}}else if("command_result"===s.type)this.nodeRegistry.handleCommandResult(s.id,{ok:s.ok,result:s.result,error:s.error});else if("chat"===s.type){if(!n||!t)return;const r=this.server.getWebChatChannel();if(!r)return;const o=this.nodeRegistry.getNode(t),i=S(o?.displayName??"node",t),a=s.chatId,c=a?`${i}/${a}`:i;r.registerConnection(c,e),r.handleNodeChat(c,t,{text:s.text,attachments:s.attachments})}else"ping"===s.type&&e.send(JSON.stringify({type:"pong"}))}catch{}}),e.on("close",()=>{o()}),e.on("error",e=>{W.error(`Node WS error: ${e.message}`),o()})}),W.info(`Nostromo listening on http://${this.host}:${this.port}${this.basePath}`)}notifyPairingChange(e,t){const r=this.server.getNodeSignatureDb(),n=r.getById(e);if(!n)return;const o=n.signature;if("approved"===t){const t=this.pendingNodes.get(o);t&&(t.ws.send(JSON.stringify({type:"pairing_status",status:"approved"})),this.nodeRegistry.register(t.nodeId,t.ws,t.info),this.pendingNodes.delete(o),r.updateLastSeen(e),W.info(`Node ${t.nodeId} approved via Nostromo`))}else if("revoked"===t){const e=this.pendingNodes.get(o);if(e)return e.ws.send(JSON.stringify({type:"pairing_status",status:"revoked"})),e.ws.close(1008,"Signature revoked"),void this.pendingNodes.delete(o);const t=this.nodeRegistry.listNodes();for(const e of t){const t=this.nodeRegistry.getNode(e.nodeId);if(t&&e.nodeId===n.nodeId){t.ws.send(JSON.stringify({type:"pairing_status",status:"revoked"})),t.ws.close(1008,"Signature revoked"),this.nodeRegistry.unregister(e.nodeId);break}}}}async stop(){this.wss&&this.wss.close(),this.httpServer&&this.httpServer.close(),W.info("Nostromo stopped")}}function Y(e){return"string"==typeof e&&/^\$\{[A-Za-z_][A-Za-z0-9_]*\}$/.test(e)}const V={telegram:[{field:"botToken",envSuffix:"BOT_TOKEN"}],discord:[{field:"token",envSuffix:"TOKEN"}],slack:[{field:"botToken",envSuffix:"BOT_TOKEN"},{field:"appToken",envSuffix:"APP_TOKEN"}],msteams:[{field:"appSecret",envSuffix:"APP_SECRET"}],line:[{field:"channelAccessToken",envSuffix:"CHANNEL_ACCESS_TOKEN"},{field:"channelSecret",envSuffix:"CHANNEL_SECRET"}],matrix:[{field:"accessToken",envSuffix:"ACCESS_TOKEN"}]};function Z(e){const t=e.match(/^---\r?\n([\s\S]*?)\r?\n---/);if(!t)return{};const r={};for(const e of t[1].split("\n")){const t=e.indexOf(":");if(t<0)continue;const n=e.slice(0,t).trim(),o=e.slice(t+1).trim().replace(/^["']|["']$/g,"");n&&(r[n]=o)}return r}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{hostname as e}from"node:os";export function renderLayout(n){return`\n\x3c!-- Login view --\x3e\n<div id="loginView" class="login-wrap">\n <div class="login-card">\n <div style="margin:0 auto 12px;width:150px;height:150px">\n <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 260 240" width="150" height="138">\n <defs>\n <linearGradient id="gBody" x1="0" y1="0" x2=".3" y2="1"><stop offset="0" stop-color="#f472b6"/><stop offset="1" stop-color="#9d174d"/></linearGradient>\n <linearGradient id="gTop" x1=".5" y1="0" x2=".5" y2="1"><stop offset="0" stop-color="#fce7f3"/><stop offset="1" stop-color="#f9a8d4"/></linearGradient>\n <linearGradient id="gScr" x1=".5" y1="0" x2=".5" y2="1"><stop offset="0" stop-color="#1e293b"/><stop offset="1" stop-color="#0f172a"/></linearGradient>\n </defs>\n \x3c!-- monitor body --\x3e\n <rect x="18" y="8" width="224" height="172" rx="14" fill="#334155"/>\n <rect x="20" y="10" width="220" height="168" rx="13" fill="#1e293b" stroke="#475569" stroke-width="1.5"/>\n \x3c!-- screen bezel --\x3e\n <rect x="32" y="20" width="196" height="148" rx="6" fill="url(#gScr)"/>\n \x3c!-- screen glow --\x3e\n <rect x="34" y="22" width="192" height="144" rx="5" fill="#0f172a" opacity=".6"/>\n \x3c!-- power led --\x3e\n <circle cx="130" cy="186" r="2.5" fill="#4ade80" opacity=".7"/>\n \x3c!-- stand --\x3e\n <path d="M105 188 L95 218 H165 L155 188" fill="#334155"/>\n <rect x="85" y="218" width="90" height="8" rx="3" fill="#475569"/>\n \x3c!-- gem centered on screen --\x3e\n <g transform="translate(130,96) scale(.56)">\n <path d="M0 90 L-82 0 -62 -46 Q-42 -74 0 -38 Q42 -74 62 -46 L82 0 Z" fill="url(#gBody)"/>\n <path d="M-62 -46 Q-42 -74 0 -38 Q42 -74 62 -46 L82 0 H-82 Z" fill="url(#gTop)" opacity=".55"/>\n <path d="M-82 0 L-62 -46 0 -38 Z" fill="#ec4899" opacity=".7"/>\n <path d="M82 0 L62 -46 0 -38 Z" fill="#be185d" opacity=".7"/>\n <path d="M0 -38 L-32 0 32 0 Z" fill="#fce7f3" opacity=".35"/>\n <path d="M-62 -46 L-82 0 -32 0 0 -38 Z" fill="#f9a8d4" opacity=".30"/>\n <path d="M62 -46 L82 0 32 0 0 -38 Z" fill="#db2777" opacity=".30"/>\n <path d="M-82 0 H-32 L0 90 Z" fill="#ec4899" opacity=".45"/>\n <path d="M-32 0 H32 L0 90 Z" fill="#db2777" opacity=".85"/>\n <path d="M32 0 H82 L0 90 Z" fill="#9d174d" opacity=".55"/>\n <path d="M-20 -28 L-14 -38 -8 -28 -14 -32Z" fill="#fff" opacity=".7"/>\n <circle cx="-22" cy="-40" r="2" fill="#fff" opacity=".6"/>\n </g>\n \x3c!-- subtle screen scanline overlay --\x3e\n <line x1="34" y1="60" x2="226" y2="60" stroke="#94a3b8" stroke-width=".3" opacity=".15"/>\n <line x1="34" y1="100" x2="226" y2="100" stroke="#94a3b8" stroke-width=".3" opacity=".12"/>\n <line x1="34" y1="140" x2="226" y2="140" stroke="#94a3b8" stroke-width=".3" opacity=".10"/>\n </svg>\n </div>\n <h1>Nostromo</h1>\n <p class="subtitle" id="loginSubtitle">Enter your access key</p>\n <div class="field">\n <label>Access Key</label>\n <div class="key-row">\n <div class="key-inputs" id="keyInputs">\n <input id="k0" type="password" maxlength="4" autocomplete="off" spellcheck="false" placeholder="">\n <span class="key-sep">–</span>\n <input id="k1" type="password" maxlength="4" autocomplete="off" spellcheck="false" placeholder="">\n <span class="key-sep">–</span>\n <input id="k2" type="password" maxlength="4" autocomplete="off" spellcheck="false" placeholder="">\n <span class="key-sep">–</span>\n <input id="k3" type="password" maxlength="4" autocomplete="off" spellcheck="false" placeholder="">\n </div>\n <button type="button" class="eye-btn" id="eyeBtn" onclick="toggleKeyVis()" title="Show/hide key">\n <svg id="eyeOff" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94"/><path d="M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19"/><line x1="1" y1="1" x2="23" y2="23"/><path d="M14.12 14.12a3 3 0 1 1-4.24-4.24"/></svg>\n <svg id="eyeOn" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" style="display:none"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>\n </button>\n </div>\n </div>\n <button class="btn" id="loginBtn" style="width:100%;margin-top:4px" onclick="doLogin()">Unlock</button>\n <div id="loginError" class="login-error"></div>\n </div>\n</div>\n\n\x3c!-- App shell (hidden until authenticated) --\x3e\n<div id="appShell" class="shell" style="display:none">\n <aside class="sidebar">\n <div class="logo">\n <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>\n <span>Nostromo</span>\n </div>\n <nav id="navMenu">\n <a href="#dashboard" data-section="dashboard" class="active">\n <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg>\n <span>Dashboard</span>\n </a>\n <div class="nav-group collapsed" id="navGroupEngine">\n <div class="nav-group-header" onclick="toggleNavGroup('Engine')">\n <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>\n <span>Engine</span>\n <svg class="nav-group-chevron" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>\n </div>\n <div class="nav-group-items">\n <a href="#models" data-section="models">\n <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/></svg>\n <span>Models</span>\n </a>\n <a href="#agent" data-section="agent">\n <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>\n <span>Agent</span>\n </a>\n <a href="#subagents" data-section="subagents">\n <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>\n <span>Sub Agents</span>\n </a>\n <a href="#vars" data-section="vars">\n <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/><circle cx="12" cy="16" r="1"/></svg>\n <span>Vars</span>\n </a>\n </div>\n </div>\n <div class="nav-group collapsed" id="navGroupIdentity">\n <div class="nav-group-header" onclick="toggleNavGroup('Identity')">\n <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>\n <span>Artificial Identity</span>\n <svg class="nav-group-chevron" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>\n </div>\n <div class="nav-group-items">\n <a href="#memory" data-section="memory">\n <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M9.5 2A2.5 2.5 0 0 1 12 4.5v15a2.5 2.5 0 0 1-4.96.44 2.5 2.5 0 0 1-2.96-3.08 3 3 0 0 1-.34-5.58 2.5 2.5 0 0 1 1.32-4.24 2.5 2.5 0 0 1 1.98-3A2.5 2.5 0 0 1 9.5 2Z"/><path d="M14.5 2A2.5 2.5 0 0 0 12 4.5v15a2.5 2.5 0 0 0 4.96.44 2.5 2.5 0 0 0 2.96-3.08 3 3 0 0 0 .34-5.58 2.5 2.5 0 0 0-1.32-4.24 2.5 2.5 0 0 0-1.98-3A2.5 2.5 0 0 0 14.5 2Z"/></svg>\n <span>Memories</span>\n </a>\n <a href="#prompts" data-section="prompts">\n <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>\n <span>Prompts</span>\n </a>\n </div>\n </div>\n <div class="nav-group collapsed" id="navGroupCompetences">\n <div class="nav-group-header" onclick="toggleNavGroup('Competences')">\n <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/><path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/></svg>\n <span>Competences</span>\n <svg class="nav-group-chevron" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>\n </div>\n <div class="nav-group-items">\n <a href="#commands" data-section="commands">\n <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/></svg>\n <span>Commands</span>\n </a>\n <a href="#skills" data-section="skills">\n <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>\n <span>Skills</span>\n </a>\n <a href="#plugins" data-section="plugins">\n <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2v6m0 0l3-3m-3 3L9 5"/><rect x="4" y="8" width="16" height="12" rx="2"/><path d="M9 8V6a3 3 0 0 1 6 0v2"/></svg>\n <span>Plugins</span>\n </a>\n </div>\n </div>\n <div class="nav-group collapsed" id="navGroupInteractions">\n <div class="nav-group-header" onclick="toggleNavGroup('Interactions')">\n <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>\n <span>Interactions</span>\n <svg class="nav-group-chevron" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>\n </div>\n <div class="nav-group-items">\n <a href="#channels" data-section="channels">\n <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>\n <span>Channels</span>\n </a>\n <a href="#stt" data-section="stt">\n <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" y1="19" x2="12" y2="23"/><line x1="8" y1="23" x2="16" y2="23"/></svg>\n <span>STT</span>\n </a>\n <a href="#tts" data-section="tts">\n <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><path d="M19.07 4.93a10 10 0 0 1 0 14.14"/><path d="M15.54 8.46a5 5 0 0 1 0 7.07"/></svg>\n <span>TTS</span>\n </a>\n </div>\n </div>\n <div class="nav-group collapsed" id="navGroupOperations">\n <div class="nav-group-header" onclick="toggleNavGroup('Operations')">\n <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/></svg>\n <span>Operations</span>\n <svg class="nav-group-chevron" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>\n </div>\n <div class="nav-group-items">\n <a href="#logs" data-section="logs">\n <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/></svg>\n <span>Logs</span>\n </a>\n <a href="#cron" data-section="cron">\n <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>\n <span>Cron</span>\n </a>\n <a href="#nodes" data-section="nodes">\n <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="2" width="20" height="8" rx="2" ry="2"/><rect x="2" y="14" width="20" height="8" rx="2" ry="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/></svg>\n <span>Nodes</span>\n </a>\n <a href="#tokens" data-section="tokens">\n <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>\n <span>Tokens</span>\n </a>\n </div>\n </div>\n <a href="#settings" data-section="settings">\n <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/><circle cx="5" cy="12" r="1"/></svg>\n <span>Settings</span>\n </a>\n <span style="display:block;padding:8px 18px 0;font-size:12px;color:var(--text-muted);opacity:.6">Host: ${e()}</span>\n </nav>\n </aside>\n <main class="main">\n\n \x3c!-- Dashboard --\x3e\n <div id="sec-dashboard" class="section active">\n <div class="section-header"><h1>Dashboard</h1></div>\n <div class="card">\n <div class="card-header"><span class="card-title">Server Status</span><div style="display:flex;align-items:center;gap:8px"><span id="restartPending" class="restart-pending" style="display:none">Restart pending</span><span id="statusBadge" class="badge badge-green">Online</span><button class="btn-danger btn-sm" onclick="showRestartModal()" style="font-size:12px;padding:3px 8px">Restart</button></div></div>\n <div style="display:flex;justify-content:space-between;font-size:14px">\n <div><span style="color:var(--text-muted)">Uptime:</span> <span id="statusUptime">--</span></div>\n <div style="text-align:right">\n <div><span style="color:var(--text-muted)">Agent:</span> <span id="statusModel">--</span></div>\n <div style="margin-top:4px"><span style="color:var(--text-muted)">Fallback:</span> <span id="statusFallback">--</span></div>\n </div>\n </div>\n <div style="margin-top:10px;font-size:14px"><span style="color:var(--text-muted)">Auto-Restart:</span> <span id="statusAutoRestart">--</span></div>\n </div>\n <div class="card">\n <div class="card-title" style="margin-bottom:10px">Enabled Channels</div>\n <div id="dashChannels" style="display:flex;flex-wrap:wrap;gap:8px"></div>\n </div>\n </div>\n\n \x3c!-- Channels --\x3e\n <div id="sec-channels" class="section">\n <div class="section-header"><h1>Channels</h1><button class="btn save-btn" onclick="saveConfig()" disabled>Save Changes</button></div>\n <div id="channelCards"></div>\n </div>\n\n \x3c!-- Models --\x3e\n <div id="sec-models" class="section">\n <div class="section-header"><h1>Models</h1></div>\n <div class="card">\n <div class="card-header">\n <span class="card-title">Model Registry</span>\n <button class="btn btn-sm" onclick="showAddModel()">\n <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:4px"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>\n Add Model\n </button>\n </div>\n <div id="addModelForm" style="display:none;margin-bottom:16px;padding:14px;border:1px solid var(--border);border-radius:var(--radius)">\n <div class="field"><label>Model ID</label><input id="newModelId" type="text" placeholder="claude-sonnet-4-5-20250514"></div>\n <div class="field"><label>Display Name</label><input id="newModelName" type="text" placeholder="Friendly label for this model"></div>\n <div class="field"><label>Type</label><select id="newModelType" onchange="updateNewModelApiFields()">\n <option value="internal">internal</option>\n <option value="external">external</option>\n </select></div>\n <div id="newModelProxyField" style="display:none">\n <div class="field"><label>Other LLM Provider</label><select id="newModelProxy" onchange="updateNewModelProxyFields()">\n <option value="not-used">not-used</option>\n <option value="direct">direct</option>\n <option value="proxied">proxied</option>\n </select></div>\n <div class="field"><label>OpenAI Compatible Proxy URL</label><input id="newModelFastUrl" type="text" placeholder="http://localhost:4181" disabled></div>\n <div class="field"><label>Proxy API Key</label><input id="newModelFastProxyApiKey" type="text" placeholder="sk-..." disabled></div>\n </div>\n <div id="newModelApiFields" style="display:none">\n <div class="field"><label>Use Env Var <span style="color:var(--text-muted);font-weight:400">(if empty, OPENAI_API_KEY is used)</span></label><input id="newModelEnvVar" type="text" placeholder="Force Variable Name" oninput="sanitizeEnvVarInput(this)" style="text-transform:uppercase"></div>\n <div id="newModelBaseURLField" class="field"><label>API Base URL <span style="color:var(--text-muted);font-weight:400">(leave empty for provider default)</span></label><input id="newModelBaseURL" type="text" placeholder="https://api.openai.com/v1"></div>\n <div class="field"><label>API Key</label><input id="newModelApiKey" type="text" placeholder="The actual value for this variable"></div>\n </div>\n <div style="display:flex;gap:8px">\n <button class="btn btn-sm" onclick="addModel()">Add</button>\n <button class="btn-ghost btn-sm" onclick="hideAddModel()">Cancel</button>\n </div>\n </div>\n <table class="tbl">\n <thead><tr><th>Name</th><th>Model ID</th><th>Types</th><th></th></tr></thead>\n <tbody id="modelsBody"></tbody>\n </table>\n <div id="editModelForm" style="display:none;margin:12px 0;padding:14px;border:1px solid var(--border);border-radius:var(--radius)">\n <div class="field"><label>Model ID</label><input id="editModelId" type="text"></div>\n <div class="field"><label>Display Name</label><input id="editModelName" type="text"></div>\n <div class="field"><label>Type</label><select id="editModelType" onchange="updateEditModelApiFields()">\n <option value="internal">internal</option>\n <option value="external">external</option>\n </select></div>\n <div id="editModelProxyField" style="display:none">\n <div class="field"><label>Other LLM Provider</label><select id="editModelProxy" onchange="updateEditModelProxyFields()">\n <option value="not-used">not-used</option>\n <option value="direct">direct</option>\n <option value="proxied">proxied</option>\n </select></div>\n <div class="field"><label>OpenAI Compatible Proxy URL</label><input id="editModelFastUrl" type="text" placeholder="http://localhost:4181" disabled></div>\n <div class="field"><label>Proxy API Key</label><input id="editModelFastProxyApiKey" type="text" placeholder="sk-..." disabled></div>\n </div>\n <div id="editModelApiFields" style="display:none">\n <div class="field"><label>Use Env Var <span style="color:var(--text-muted);font-weight:400">(if empty, OPENAI_API_KEY is used)</span></label><input id="editModelEnvVar" type="text" placeholder="Force Variable Name" oninput="sanitizeEnvVarInput(this)" style="text-transform:uppercase"></div>\n <div id="editModelBaseURLField" class="field"><label>API Base URL</label><input id="editModelBaseURL" type="text"></div>\n <div class="field"><label>API Key</label><input id="editModelApiKey" type="text"></div>\n </div>\n <div style="display:flex;gap:8px">\n <button class="btn btn-sm" onclick="finishEditModel()">Save</button>\n <button class="btn-ghost btn-sm" onclick="cancelEditModel()">Cancel</button>\n </div>\n </div>\n </div>\n </div>\n\n \x3c!-- Vars --\x3e\n <div id="sec-vars" class="section">\n <div class="section-header"><h1>Vars</h1></div>\n <div class="card">\n <div class="card-header">\n <span class="card-title">Vars Registry</span>\n <button class="btn btn-sm" onclick="showAddVar()">\n <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:4px"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>\n Add Var\n </button>\n </div>\n <div id="addVarForm" style="display:none;margin-bottom:16px;padding:14px;border:1px solid var(--border);border-radius:var(--radius)">\n <div class="field"><label>Display Name</label><input id="newVarName" type="text" placeholder="Friendly label for this variable"></div>\n <div class="field"><label>Use Env Var</label><input id="newVarEnvVar" type="text" placeholder="VARIABLE_NAME" oninput="sanitizeEnvVarInput(this)" style="text-transform:uppercase"></div>\n <div class="field"><label>Value</label><input id="newVarApiKey" type="text" placeholder="The actual value for this variable"></div>\n <div style="display:flex;gap:8px">\n <button class="btn btn-sm" onclick="addVar()">Add</button>\n <button class="btn-ghost btn-sm" onclick="hideAddVar()">Cancel</button>\n </div>\n </div>\n <table class="tbl">\n <thead><tr><th>Name</th><th>Env Var</th><th></th></tr></thead>\n <tbody id="varsBody"></tbody>\n </table>\n <div id="editVarForm" style="display:none;margin:12px 0;padding:14px;border:1px solid var(--border);border-radius:var(--radius)">\n <div class="field"><label>Display Name</label><input id="editVarName" type="text"></div>\n <div class="field"><label>Use Env Var</label><input id="editVarEnvVar" type="text" placeholder="VARIABLE_NAME" oninput="sanitizeEnvVarInput(this)" style="text-transform:uppercase"></div>\n <div class="field"><label>Value</label><input id="editVarApiKey" type="text"></div>\n <div style="display:flex;gap:8px">\n <button class="btn btn-sm" onclick="finishEditVar()">Save</button>\n <button class="btn-ghost btn-sm" onclick="cancelEditVar()">Cancel</button>\n </div>\n </div>\n </div>\n </div>\n\n \x3c!-- Agent --\x3e\n <div id="sec-agent" class="section">\n <div class="section-header"><h1>Agent</h1><div style="display:flex;gap:8px"><button class="btn btn-ghost btn-sm" onclick="document.getElementById('agentHelpModal').classList.add('open')">Help</button><button class="btn save-btn" onclick="saveConfig()" disabled>Save Changes</button></div></div>\n <h2>Main</h2>\n <div class="card">\n <div class="field-row">\n <div class="field" style="flex:1">\n <label>Default Model</label>\n <select id="agentModel"></select>\n </div>\n <div class="field" style="flex:1">\n <label>Fallback Model <span style="color:var(--text-muted);font-weight:400">— used on invocation errors</span></label>\n <select id="agentMainFallback"></select>\n </div>\n </div>\n </div>\n <h2>Configuration</h2>\n <div class="card" id="agentCard">\n <div class="field-row">\n <div class="field" style="flex:1">\n <label>Max Turns</label>\n <input id="agentMaxTurns" type="number" min="1" max="100">\n </div>\n <div class="field" style="flex:1">\n <label>Permission Mode</label>\n <select id="agentPermMode">\n <option value="default">default</option>\n <option value="acceptEdits">acceptEdits</option>\n <option value="bypassPermissions">bypassPermissions</option>\n <option value="plan">plan</option>\n </select>\n </div>\n </div>\n <div class="field-row">\n <div class="field" style="flex:1">\n <label>Session TTL (seconds)</label>\n <input id="agentSessionTTL" type="number" min="60">\n </div>\n <div class="field" style="flex:1">\n <label>Setting Sources <span style="color:var(--text-muted);font-weight:400">— SDK settings to load</span></label>\n <select id="agentSettingSources">\n <option value="nothing">nothing</option>\n <option value="user">user</option>\n <option value="project">project</option>\n <option value="both">both</option>\n </select>\n </div>\n </div>\n <div class="field-row">\n <div class="field" style="flex:1">\n <label>Coder Skill <span style="color:var(--text-muted);font-weight:400">— always prepend builtin coding prompt</span></label>\n <div style="margin-top:6px"><label class="toggle"><input type="checkbox" id="agentCoderSkill"><span></span></label></div>\n </div>\n <div class="field" style="flex:1">\n <label>AutoNew by inactivity (hr)</label>\n <select id="agentAutoRenew">\n <option value="0">Never</option>\n <option value="2">2</option>\n <option value="4">4</option>\n <option value="6">6</option>\n <option value="8">8</option>\n <option value="12">12</option>\n <option value="24">24</option>\n </select>\n </div>\n </div>\n <div class="field">\n <label>Allowed Tools</label>\n <div id="agentToolsGrid" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:8px;margin-top:6px">\n <label class="tool-toggle"><label class="toggle"><input type="checkbox" data-tool="Read"><span></span></label> Read</label>\n <label class="tool-toggle"><label class="toggle"><input type="checkbox" data-tool="Write"><span></span></label> Write</label>\n <label class="tool-toggle"><label class="toggle"><input type="checkbox" data-tool="Edit"><span></span></label> Edit</label>\n <label class="tool-toggle"><label class="toggle"><input type="checkbox" data-tool="Bash"><span></span></label> Bash</label>\n <label class="tool-toggle"><label class="toggle"><input type="checkbox" data-tool="Glob"><span></span></label> Glob</label>\n <label class="tool-toggle"><label class="toggle"><input type="checkbox" data-tool="Grep"><span></span></label> Grep</label>\n <label class="tool-toggle"><label class="toggle"><input type="checkbox" data-tool="WebSearch"><span></span></label> WebSearch</label>\n <label class="tool-toggle"><label class="toggle"><input type="checkbox" data-tool="WebFetch"><span></span></label> WebFetch</label>\n <label class="tool-toggle"><label class="toggle"><input type="checkbox" data-tool="Task"><span></span></label> Task</label>\n <label class="tool-toggle"><label class="toggle"><input type="checkbox" data-tool="Skill"><span></span></label> Skill</label>\n </div>\n <button class="btn btn-ghost btn-sm" style="margin-top:8px" onclick="openInternalToolsModal()">Discover Internal Tools</button>\n </div>\n </div>\n <h2>Message Queue</h2>\n <div class="card" id="queueCard">\n <div class="field">\n <label>Queue Mode</label>\n <select id="agentQueueMode" onchange="updateQueueFields()">\n <option value="queue">queue — simple FIFO, one message = one response</option>\n <option value="collect">collect — batch messages while agent is busy</option>\n <option value="steer">steer — new message interrupts the current one</option>\n </select>\n </div>\n <div id="queueCollectFields">\n <div class="field">\n <label>Debounce (ms) <span style="color:var(--text-muted);font-weight:400">— quiet period after last message before flushing the batch</span></label>\n <input id="agentDebounceMs" type="number" min="0" step="100">\n </div>\n </div>\n <div class="field-row">\n <div class="field" style="flex:1">\n <label>Queue Cap <span style="color:var(--text-muted);font-weight:400">(0 = unlimited)</span></label>\n <input id="agentQueueCap" type="number" min="0">\n </div>\n <div class="field" style="flex:1">\n <label>Drop Policy <span style="color:var(--text-muted);font-weight:400">— when cap is exceeded</span></label>\n <select id="agentDropPolicy">\n <option value="new">new — reject incoming message</option>\n <option value="old">old — drop oldest buffered message</option>\n <option value="summarize">summarize — drop oldest, keep summary</option>\n </select>\n </div>\n </div>\n <div class="field-row">\n <div class="field" style="flex:1">\n <label>Inflight Typing <span style="color:var(--text-muted);font-weight:400">— keep typing indicator active when multiple messages are in-flight</span></label>\n <div style="margin-top:6px"><label class="toggle"><input type="checkbox" id="agentInflightTyping"><span></span></label></div>\n </div>\n <div class="field" style="flex:1">\n <label>Auto Approve Tools <span style="color:var(--text-muted);font-weight:400">— auto-approve tool permissions; when off, asks user via channel buttons</span></label>\n <div style="margin-top:6px"><label class="toggle"><input type="checkbox" id="agentAutoApprove"><span></span></label></div>\n </div>\n </div>\n </div>\n </div>\n\n \x3c!-- SubAgents --\x3e\n <div id="sec-subagents" class="section">\n <div class="section-header"><h1>Custom Sub Agents</h1><div style="display:flex;gap:8px"><button class="btn btn-ghost btn-sm" onclick="loadSubAgents()">Refresh</button><button class="btn save-btn" onclick="saveConfig()" disabled>Save Changes</button></div></div>\n <div class="card">\n <div class="card-header">\n <span class="card-title">Sub Agents</span>\n <button class="btn btn-sm" onclick="showAddSubAgent()">\n <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:4px"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>\n Add Sub Agent\n </button>\n </div>\n <div id="addSubAgentForm" style="display:none;margin-bottom:16px;padding:14px;border:1px solid var(--border);border-radius:var(--radius)">\n <div class="field"><label>Name</label><input id="newSaName" type="text" placeholder="code-reviewer"></div>\n <div class="field"><label>Description <span style="color:var(--text-muted);font-weight:400">— tells Claude when to use this subagent</span></label><textarea id="newSaDesc" rows="2" placeholder="Reviews code for bugs, style issues, and security vulnerabilities"></textarea></div>\n <div class="field"><label>Prompt <span style="color:var(--text-muted);font-weight:400">— defines the subagent's behavior and expertise</span></label><textarea id="newSaPrompt" rows="3" placeholder="You are an expert code reviewer. Analyze the provided code for correctness, style, performance, and security..."></textarea></div>\n <div class="field"><label>Model</label><select id="newSaModel"><option value="inherit">inherit</option><option value="sonnet">sonnet</option><option value="opus">opus</option><option value="haiku">haiku</option></select></div>\n <div class="field"><label>Tools</label><div id="newSaToolsGrid" style="display:flex;flex-wrap:wrap;gap:4px;margin-top:4px">\n <label class="tool-toggle-sm"><label class="toggle-sm"><input type="checkbox" data-new-sa-tool="Read" checked><span></span></label> Read</label>\n <label class="tool-toggle-sm"><label class="toggle-sm"><input type="checkbox" data-new-sa-tool="Write" checked><span></span></label> Write</label>\n <label class="tool-toggle-sm"><label class="toggle-sm"><input type="checkbox" data-new-sa-tool="Edit" checked><span></span></label> Edit</label>\n <label class="tool-toggle-sm"><label class="toggle-sm"><input type="checkbox" data-new-sa-tool="Bash"><span></span></label> Bash</label>\n <label class="tool-toggle-sm"><label class="toggle-sm"><input type="checkbox" data-new-sa-tool="Glob" checked><span></span></label> Glob</label>\n <label class="tool-toggle-sm"><label class="toggle-sm"><input type="checkbox" data-new-sa-tool="Grep" checked><span></span></label> Grep</label>\n <label class="tool-toggle-sm"><label class="toggle-sm"><input type="checkbox" data-new-sa-tool="WebSearch" checked><span></span></label> WebSearch</label>\n <label class="tool-toggle-sm"><label class="toggle-sm"><input type="checkbox" data-new-sa-tool="WebFetch" checked><span></span></label> WebFetch</label>\n </div></div>\n <div style="display:flex;align-items:center;gap:12px;margin-bottom:10px">\n <span style="font-size:13px;font-weight:500">Expand Context <span style="color:var(--text-muted);font-weight:400">— prepend main system prompt to subagent prompt</span></span>\n <label class="toggle"><input type="checkbox" id="newSaExpandContext"><span></span></label>\n </div>\n <div style="display:flex;gap:8px">\n <button class="btn btn-sm" onclick="addSubAgent()">Add</button>\n <button class="btn-ghost btn-sm" onclick="hideAddSubAgent()">Cancel</button>\n </div>\n </div>\n <div id="subAgentCards" style="max-height:60vh;overflow-y:auto"></div>\n <div id="subAgentEmpty" style="font-size:14px;color:var(--text-muted);padding:8px 0">No custom sub agents configured. Click "Add Sub Agent" to create one.</div>\n </div>\n </div>\n\n \x3c!-- Commands --\x3e\n <div id="sec-commands" class="section">\n <div class="section-header"><h1>Commands</h1></div>\n <div class="card">\n <div class="card-header">\n <span class="card-title">Installed Commands</span>\n <div style="display:flex;gap:8px">\n <button class="btn-ghost btn-sm" onclick="loadCommands()">Refresh</button>\n <button class="btn btn-sm" onclick="showAddStandaloneCommand()">\n <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:4px"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>\n New Command\n </button>\n <button class="btn btn-sm" onclick="showAddCommand()">\n <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:4px"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>\n Upload Folder\n </button>\n </div>\n </div>\n <p style="font-size:13px;color:var(--text-muted);margin-bottom:14px">Project-scoped commands defined in the workspace <code>.claude/commands</code> directory.</p>\n <div id="commandCreateWrap" style="display:none;margin-bottom:16px;border:1px solid var(--border);border-radius:8px;padding:16px">\n <h4 style="margin:0 0 12px">New Standalone Command</h4>\n <div style="display:flex;flex-direction:column;gap:10px">\n <div class="field"><label>Name <span style="color:var(--text-muted);font-weight:400">(a-z, A-Z, 0-9, -, _)</span></label><input id="cmdCreateName" type="text" placeholder="my-command" pattern="[a-zA-Z0-9_-]+" style="font-family:monospace"></div>\n <div class="field"><label>Description <span style="color:var(--text-muted);font-weight:400">(optional)</span></label><input id="cmdCreateDesc" type="text" placeholder="What this command does"></div>\n <div class="field"><label>Model <span style="color:var(--text-muted);font-weight:400">(optional)</span></label><input id="cmdCreateModel" type="text" placeholder="e.g. claude-sonnet-4-20250514"></div>\n </div>\n <div style="display:flex;gap:8px;justify-content:flex-end;margin-top:14px">\n <button class="btn-ghost btn-sm" onclick="hideAddStandaloneCommand()">Cancel</button>\n <button class="btn btn-sm" onclick="doCreateStandaloneCommand()">Create</button>\n </div>\n </div>\n <div id="commandAddWrap" style="display:none;margin-bottom:16px">\n <div id="commandDropZone" class="drop-zone">\n <svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" style="color:var(--text-muted)"><path d="M12 16V4"/><path d="M8 8l4-4 4 4"/><path d="M20 21H4"/><path d="M20 17v4H4v-4"/></svg>\n <div style="font-weight:600;margin:8px 0 4px">Drop a command folder here</div>\n <div style="font-size:13px;color:var(--text-muted)">or click to select a folder</div>\n <input id="commandFolderInput" type="file" webkitdirectory style="display:none">\n <div id="commandUploadStatus" style="display:none;margin-top:12px;font-size:13px"></div>\n </div>\n <div style="display:flex;gap:8px;justify-content:flex-end;margin-top:10px">\n <button class="btn-ghost btn-sm" onclick="hideAddCommand()">Cancel</button>\n </div>\n </div>\n <div class="field" style="margin-bottom:10px;max-width:360px"><label>Filter</label><input id="commandsFilter" type="text" placeholder="type to filter..." oninput="applyCommandsFilter()" style="font-size:12px"></div>\n <div id="commandsList" style="max-height:400px;overflow-y:auto"></div>\n <div id="commandsEmpty" style="font-size:14px;color:var(--text-muted);padding:8px 0">No commands found.</div>\n </div>\n </div>\n\n \x3c!-- Skills --\x3e\n <div id="sec-skills" class="section">\n <div class="section-header"><h1>Skills</h1></div>\n <div class="card">\n <div class="card-header">\n <span class="card-title">Installed Skills</span>\n <div style="display:flex;gap:8px">\n <button class="btn-ghost btn-sm" onclick="loadSkills()">Refresh</button>\n <button class="btn btn-sm" onclick="showAddSkill()">\n <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:4px"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>\n Add Skill\n </button>\n </div>\n </div>\n <p style="font-size:13px;color:var(--text-muted);margin-bottom:14px">Project-scoped skills defined in the workspace <code>.claude/skills</code> directory.</p>\n <div id="skillAddWrap" style="display:none;margin-bottom:16px">\n <div id="skillDropZone" class="drop-zone">\n <svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" style="color:var(--text-muted)"><path d="M12 16V4"/><path d="M8 8l4-4 4 4"/><path d="M20 21H4"/><path d="M20 17v4H4v-4"/></svg>\n <div style="font-weight:600;margin:8px 0 4px">Drop a skill folder here</div>\n <div style="font-size:13px;color:var(--text-muted)">or click to select a folder</div>\n <input id="skillFolderInput" type="file" webkitdirectory style="display:none">\n <div id="skillUploadStatus" style="display:none;margin-top:12px;font-size:13px"></div>\n </div>\n <div style="display:flex;gap:8px;justify-content:flex-end;margin-top:10px">\n <button class="btn-ghost btn-sm" onclick="hideAddSkill()">Cancel</button>\n </div>\n </div>\n <div class="field" style="margin-bottom:10px;max-width:360px"><label>Filter</label><input id="skillsFilter" type="text" placeholder="type to filter..." oninput="applySkillsFilter()" style="font-size:12px"></div>\n <div id="skillsList" style="max-height:400px;overflow-y:auto"></div>\n <div id="skillsEmpty" style="font-size:14px;color:var(--text-muted);padding:8px 0">No skills installed.</div>\n </div>\n <div class="card">\n <div class="card-header">\n <span class="card-title">Bundled Skills</span>\n <button class="btn-ghost btn-sm" onclick="loadSkills()">Refresh</button>\n </div>\n <p style="font-size:13px;color:var(--text-muted);margin-bottom:14px">Pre-packaged skills ready to activate. Click <strong>Use</strong> to install a skill to your project.</p>\n <div class="field" style="margin-bottom:10px;max-width:360px"><label>Filter</label><input id="bundledSkillsFilter" type="text" placeholder="type to filter..." oninput="applyBundledSkillsFilter()" style="font-size:12px"></div>\n <div id="bundledSkillsList" style="max-height:400px;overflow-y:auto"></div>\n <div id="bundledSkillsEmpty" style="font-size:14px;color:var(--text-muted);padding:8px 0">No bundled skills found.</div>\n </div>\n </div>\n\n \x3c!-- Plugins --\x3e\n <div id="sec-plugins" class="section">\n <div class="section-header"><h1>Plugins</h1></div>\n <div class="card">\n <div class="card-header">\n <span class="card-title">Installed Plugins</span>\n <div style="display:flex;gap:8px">\n <button class="btn-ghost btn-sm" onclick="loadPlugins()">Refresh</button>\n <button class="btn btn-sm" onclick="showAddPlugin()">\n <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:4px"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>\n Add Plugin\n </button>\n </div>\n </div>\n <div id="pluginAddWrap" style="display:none;margin-bottom:16px">\n <div class="field" style="margin-bottom:10px">\n <label>Destination Path <span style="color:var(--text-muted);font-weight:400">— where the plugin folder will be created</span></label>\n <div style="display:flex;gap:8px;align-items:center">\n <input id="pluginDestPath" type="text" style="flex:1">\n <button class="btn btn-sm" onclick="verifyPluginDest()" id="pluginDestSetBtn">Set</button>\n </div>\n </div>\n <div id="pluginDropZone" class="drop-zone">\n <svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" style="color:var(--text-muted)"><path d="M12 16V4"/><path d="M8 8l4-4 4 4"/><path d="M20 21H4"/><path d="M20 17v4H4v-4"/></svg>\n <div style="font-weight:600;margin:8px 0 4px">Drop a plugin folder here</div>\n <div style="font-size:13px;color:var(--text-muted)">or click to select a folder</div>\n <input id="pluginFolderInput" type="file" webkitdirectory style="display:none">\n <div id="pluginUploadStatus" style="display:none;margin-top:12px;font-size:13px"></div>\n </div>\n <div style="display:flex;gap:8px;justify-content:flex-end;margin-top:10px">\n <button class="btn-ghost btn-sm" onclick="hideAddPlugin()">Cancel</button>\n </div>\n </div>\n <div id="pluginCards" style="max-height:400px;overflow-y:auto"></div>\n <div id="pluginEmpty" style="font-size:14px;color:var(--text-muted);padding:8px 0">No plugins configured. Click "Add Plugin" to add one.</div>\n </div>\n </div>\n\n \x3c!-- STT --\x3e\n <div id="sec-stt" class="section">\n <div class="section-header"><h1>Speech-to-Text</h1><button class="btn save-btn" onclick="saveConfig()" disabled>Save Changes</button></div>\n <div class="card">\n <div class="card-header">\n <span class="card-title">STT</span>\n <label class="toggle"><input type="checkbox" id="sttEnabled"><span></span></label>\n </div>\n <div class="field">\n <label>Provider</label>\n <select id="sttProvider">\n <option value="openai-whisper">openai-whisper</option>\n <option value="local-whisper">local-whisper</option>\n </select>\n </div>\n <div id="sttOpenAI">\n <div class="field-row">\n <div class="field" style="flex:1"><label>Model</label><select id="sttModelRef"></select></div>\n <div class="field" style="flex:1"><label>Language <span style="color:var(--text-muted);font-weight:400">— empty = auto-detect, or en, it, de, ...</span></label><input id="sttOAILang" type="text" placeholder="auto-detect"></div>\n </div>\n </div>\n <div id="sttLocal" style="display:none">\n <div class="field"><label>Binary Path</label><input id="sttLocalBin" type="text"></div>\n <div class="field"><label>Model</label><input id="sttLocalModel" type="text"></div>\n </div>\n </div>\n </div>\n\n \x3c!-- TTS --\x3e\n <div id="sec-tts" class="section">\n <div class="section-header"><h1>Text-to-Speech</h1><button class="btn save-btn" onclick="saveConfig()" disabled>Save Changes</button></div>\n <div class="card">\n <div class="card-header">\n <span class="card-title">TTS</span>\n <label class="toggle"><input type="checkbox" id="ttsEnabled"><span></span></label>\n </div>\n <div class="field">\n <label>Provider</label>\n <select id="ttsProvider">\n <option value="openai">OpenAI</option>\n <option value="edge">Edge TTS (free)</option>\n <option value="elevenlabs">ElevenLabs</option>\n </select>\n </div>\n <div id="ttsEdge" style="display:none">\n <div class="field"><label>Voice</label><input id="ttsEdgeVoice" type="text" placeholder="en-US-MichelleNeural"></div>\n </div>\n <div id="ttsOpenAI">\n <div class="field-row">\n <div class="field" style="flex:1"><label>Model Ref <span style="color:var(--text-muted);font-weight:400">— from registry (for API key)</span></label><select id="ttsOAIModelRef"></select></div>\n <div class="field" style="flex:1"><label>Model</label><input id="ttsOAIModel" type="text" placeholder="gpt-4o-mini-tts"></div>\n </div>\n <div class="field"><label>Voice</label><input id="ttsOAIVoice" type="text" placeholder="alloy"></div>\n </div>\n <div id="ttsElevenLabs" style="display:none">\n <div class="field"><label>Model Ref <span style="color:var(--text-muted);font-weight:400">— from registry (for API key)</span></label><select id="ttsELModelRef"></select></div>\n <div class="field-row">\n <div class="field" style="flex:1"><label>Voice ID</label><input id="ttsELVoiceId" type="text" placeholder="pMsXgVXv3BLzUgSXRplE"></div>\n <div class="field" style="flex:1"><label>Model ID</label><input id="ttsELModelId" type="text" placeholder="eleven_multilingual_v2"></div>\n </div>\n </div>\n <div class="field-row" style="margin-top:12px">\n <div class="field" style="flex:1"><label>Max text length</label><input id="ttsMaxTextLength" type="number" value="4096"></div>\n <div class="field" style="flex:1"><label>Timeout (ms)</label><input id="ttsTimeoutMs" type="number" value="30000"></div>\n </div>\n </div>\n </div>\n\n \x3c!-- Memories --\x3e\n <div id="sec-memory" class="section">\n <div class="section-header"><h1>Memories</h1><button class="btn save-btn" onclick="saveConfig()" disabled>Save Changes</button></div>\n <div class="card">\n <div class="card-header">\n <span class="card-title">Memories</span>\n <label class="toggle"><input type="checkbox" id="memEnabled"><span></span></label>\n </div>\n <p style="font-size:13px;color:var(--text-muted);margin-bottom:14px">These settings do not affect how MEMORY.md is updated. They control whether the agent can search through past conversation memories using dedicated memory tools. When Recall Strategy is set to <strong>search</strong>, the <code>{{SEARCH_IN_MEMORIES}}</code> placeholder in the system prompt template is populated with instructions for <code>memory_search</code> and <code>memory_get</code>. When set to <strong>builtin-only</strong>, the placeholder resolves to empty.</p>\n <div class="field"><label>Directory</label><input id="memDir" type="text"></div>\n <div class="field">\n <label>Recall Strategy</label>\n <select id="memStrategy" onchange="updateMemSearchFields()">\n <option value="builtin-only">builtin-only</option>\n <option value="search">search</option>\n </select>\n </div>\n <div id="memSearchSettings" style="display:none">\n <div style="border-top:1px solid var(--border);margin-top:12px;padding-top:12px">\n <span style="font-size:13px;font-weight:600;color:var(--text-primary)">Search Settings</span>\n <p style="font-size:12px;color:var(--text-muted);margin:4px 0 12px">Hybrid BM25 + semantic search over conversation memories. Requires an OpenAI-compatible embedding API.</p>\n <div class="field-row">\n <div class="field" style="flex:1"><label>Model (API key)</label><select id="memSearchModelRef"></select></div>\n <div class="field" style="flex:1"><label>Embedding Model</label><input id="memSearchEmbModel" type="text" value="text-embedding-3-small"></div>\n </div>\n <div class="field-row">\n <div class="field" style="flex:1"><label>Prefix Query</label><input id="memSearchPrefixQuery" type="text" placeholder=""></div>\n <div class="field" style="flex:1"><label>Prefix Document</label><input id="memSearchPrefixDocument" type="text" placeholder=""></div>\n </div>\n <div class="field-row">\n <div class="field" style="flex:1">\n <label>Embedding Dimensions: <strong id="memSearchDimsValue">1536</strong></label>\n <input id="memSearchDims" type="range" min="512" max="4096" step="1" value="1536"\n oninput="document.getElementById('memSearchDimsValue').textContent=this.value">\n <div style="display:flex;position:relative;margin-top:2px;height:14px;pointer-events:none;user-select:none">\n <span style="position:absolute;left:0;font-size:11px;color:var(--text-muted)">512</span>\n <span style="position:absolute;left:28.6%;font-size:11px;color:var(--text-muted);transform:translateX(-50%)">1536</span>\n <span style="position:absolute;right:0;font-size:11px;color:var(--text-muted)">4096</span>\n </div>\n </div>\n <div class="field" style="flex:1"><label>Max Results</label><input id="memSearchMaxResults" type="number" value="6" min="1" max="100"></div>\n </div>\n <div class="field-row">\n <div class="field" style="flex:1"><label>Index Debounce (ms)</label><input id="memSearchDebounce" type="number" value="3000" min="500"></div>\n <div class="field" style="flex:1"><label>Embed Interval (ms)</label><input id="memSearchEmbedInterval" type="number" value="300000" min="10000"></div>\n </div>\n <div class="field-row">\n <div class="field" style="flex:1"><label>Max Snippet Chars</label><input id="memSearchMaxSnippet" type="number" value="700" min="100"></div>\n <div class="field" style="flex:1"><label>Max Injected Chars <span style="color:var(--text-muted);font-weight:400">— total output cap for search results (0 = unlimited)</span></label><input id="memSearchMaxInjected" type="number" value="4000" min="0"></div>\n </div>\n <div class="field-row">\n <div class="field" style="flex:1"><label>RRF K <span style="color:var(--text-muted);font-weight:400">— fusion constant, higher values blend keyword & semantic more evenly (default 60)</span></label><input id="memSearchRrfK" type="number" value="60" min="1"></div>\n </div>\n <div style="margin-top:12px;display:flex;align-items:center;gap:12px">\n <button class="btn" onclick="testEmbedding()" id="memSearchTestBtn">Test Embedding</button>\n <span id="memSearchTestResult" style="font-size:13px"></span>\n </div>\n </div>\n </div>\n </div>\n </div>\n\n \x3c!-- Cron --\x3e\n <div id="sec-cron" class="section">\n <div class="section-header"><h1>Cron & Heartbeat</h1><button class="btn save-btn" onclick="saveConfig()" disabled>Save Changes</button></div>\n <div class="card">\n <div class="card-header">\n <span class="card-title">Cron Scheduler</span>\n <label class="toggle"><input type="checkbox" id="cronEnabled"><span></span></label>\n </div>\n <div style="display:flex;align-items:center;gap:12px;padding:0 16px 12px">\n <span style="font-size:13px;font-weight:500;color:var(--text-muted)">Isolated <span style="font-weight:400">— jobs run in their own session (<code>cron:name</code>) instead of sharing user chat context</span></span>\n <label class="toggle"><input type="checkbox" id="cronIsolated"><span></span></label>\n </div>\n <div style="display:flex;align-items:center;gap:12px;padding:0 16px 12px">\n <span style="font-size:13px;font-weight:500;color:var(--text-muted)">Broadcast Events <span style="font-weight:400">— deliver responses to all known chats across all active channels</span></span>\n <label class="toggle"><input type="checkbox" id="cronBroadcast"><span></span></label>\n </div>\n </div>\n\n <h2>Heartbeat</h2>\n <div class="card" id="heartbeatCard">\n <div class="card-header">\n <span class="card-title">Heartbeat</span>\n <label class="toggle"><input type="checkbox" id="hbEnabled"><span></span></label>\n </div>\n <div id="hbFields">\n <div class="field-row">\n <div class="field" style="flex:1">\n <label>Channel</label>\n <select id="hbChannel" onchange="onHbChannelChange();updateHbFields()">\n <option value="">-- select --</option>\n </select>\n </div>\n <div class="field" style="flex:1">\n <label>Chat ID</label>\n <select id="hbChatId" onchange="updateHbFields()">\n <option value="">-- select --</option>\n </select>\n </div>\n </div>\n <div id="hbWarning" style="display:none;font-size:12px;color:var(--warning);margin-top:4px"></div>\n <div class="field-row">\n <div class="field" style="flex:1">\n <label>Interval (ms) <span id="hbEveryHuman" style="font-weight:400;color:var(--text-muted)"></span></label>\n <input id="hbEvery" type="number" min="10000" step="1000" oninput="updateHbEveryHuman()">\n </div>\n <div class="field" style="flex:1">\n <label>Ack Max Chars</label>\n <input id="hbAckMaxChars" type="number" min="0">\n </div>\n </div>\n <div class="field" style="margin-top:8px">\n <label>Heartbeat Message</label>\n <textarea id="hbMessage" rows="4" oninput="updateHbFields()" style="width:100%;font-family:var(--mono);font-size:13px;resize:vertical;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:10px;color:var(--text)"></textarea>\n <div style="font-size:11px;color:var(--text-muted);margin-top:4px">The message sent to the agent on each heartbeat tick. The agent reads HEARTBEAT.md and follows its instructions.</div>\n </div>\n <div style="font-size:12px;color:var(--text-muted);margin-top:12px;line-height:1.5">\n <strong>How it works:</strong> Every N ms the system checks <code>HEARTBEAT.md</code> in the data directory.\n If the file is empty (only headers or comments), the check is skipped to save API calls.\n If it has actionable content, the agent is asked to evaluate the tasks and respond:\n <code>HEARTBEAT_OK</code> if nothing needs attention (suppressed, not delivered),\n or an alert message delivered to the configured channel.\n Heartbeat exchanges are never written to conversation memory.<br>\n <strong>Ack Max Chars:</strong> after stripping <code>HEARTBEAT_OK</code>, if the remaining text\n is shorter than this threshold it is treated as a courtesy acknowledgment and suppressed.\n Only responses exceeding this limit are delivered as real alerts.\n </div>\n <div style="margin-top:12px">\n <button class="btn btn-sm" onclick="simulateHeartbeat()">Simulate Heartbeat</button>\n </div>\n </div>\n </div>\n\n <h2>Cron Jobs</h2>\n <div class="card">\n <div class="card-header">\n <span class="card-title">Jobs</span>\n <div style="display:flex;gap:8px">\n <span id="cronStatus" style="font-size:13px;color:var(--text-muted);align-self:center"></span>\n <button class="btn-ghost btn-sm" onclick="loadCronJobs()" title="Refresh">\n <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0114.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0020.49 15"/></svg>\n </button>\n <button class="btn btn-sm" onclick="showAddJob()">\n <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:4px"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>\n Add Job\n </button>\n </div>\n </div>\n\n <div id="addJobForm" style="display:none;margin-bottom:16px;padding:14px;border:1px solid var(--border);border-radius:var(--radius)">\n <div class="field"><label>Name</label><input id="newJobName" type="text" placeholder="my-job"></div>\n <div class="field"><label>Description</label><input id="newJobDesc" type="text" placeholder="optional"></div>\n <div class="field-row">\n <div class="field" style="flex:1">\n <label>Channel</label>\n <select id="newJobChannel" onchange="onNewJobChannelChange()">\n <option value="">-- select --</option>\n </select>\n </div>\n <div class="field" style="flex:1">\n <label>Chat ID</label>\n <select id="newJobChatId">\n <option value="">-- select --</option>\n </select>\n </div>\n </div>\n <div class="field">\n <label>Message</label>\n <textarea id="newJobMessage" rows="2" placeholder="Message to send to agent"></textarea>\n </div>\n <div class="field">\n <label>Schedule Type</label>\n <select id="newJobSchedKind" onchange="updateJobSchedFields()">\n <option value="every">Interval (every N ms)</option>\n <option value="cron">Cron expression</option>\n <option value="at">One-shot (at date/time)</option>\n </select>\n </div>\n <div id="newJobSchedEvery" class="field">\n <label>Interval (ms)</label>\n <input id="newJobEveryMs" type="number" min="1000" step="1000" value="60000">\n </div>\n <div id="newJobSchedCron" class="field" style="display:none">\n <label>Cron Expression <span style="color:var(--text-muted);font-weight:400">(e.g. 0 */5 * * * *)</span></label>\n <input id="newJobCronExpr" type="text" placeholder="0 */5 * * * *">\n </div>\n <div id="newJobSchedAt" class="field" style="display:none">\n <label>Run At (ISO datetime)</label>\n <input id="newJobAtTime" type="datetime-local">\n </div>\n <div style="display:flex;align-items:center;gap:12px;margin-bottom:10px">\n <span style="font-size:13px;font-weight:500;color:var(--text-muted)">Isolated <span style="font-weight:400">— job runs in its own session (<code>cron:name</code>) instead of sharing the user's chat context</span></span>\n <label class="toggle"><input type="checkbox" id="newJobIsolated" checked><span></span></label>\n </div>\n <div style="display:flex;align-items:center;gap:12px;margin-bottom:10px">\n <span style="font-size:13px;font-weight:500;color:var(--text-muted)">Suppress HEARTBEAT_OK <span style="font-weight:400">— if the agent replies with HEARTBEAT_OK the message is not delivered to the chat</span></span>\n <label class="toggle"><input type="checkbox" id="newJobSuppress"><span></span></label>\n </div>\n <div style="display:flex;gap:8px">\n <button class="btn btn-sm" onclick="addJob()">Create Job</button>\n <button class="btn-ghost btn-sm" onclick="hideAddJob()">Cancel</button>\n </div>\n </div>\n\n <div class="field" style="margin-bottom:10px;max-width:360px">\n <label>Filter</label>\n <input id="cronJobFilter" type="text" placeholder="type to filter..." oninput="applyCronJobFilter()" style="font-size:12px">\n </div>\n <div id="cronJobsWrap" style="position:relative;overflow-x:auto;overflow-y:auto;max-height:500px">\n <table class="tbl" style="min-width:max-content">\n <thead><tr><th>Name</th><th>Schedule</th><th>Session</th><th>Delivery</th><th>Status</th><th>Next Run</th><th></th></tr></thead>\n <tbody id="cronJobsBody"></tbody>\n </table>\n <div id="cronScrollHint" style="display:none;position:sticky;bottom:0;right:0;text-align:right;pointer-events:none;padding:4px 8px">\n <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="var(--text-muted)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" style="opacity:0.6"><polyline points="13 17 18 12 13 7"/><polyline points="6 17 11 12 6 7"/></svg>\n </div>\n </div>\n </div>\n </div>\n\n \x3c!-- Logs --\x3e\n <div id="sec-logs" class="section">\n <div class="section-header"><h1>Logs</h1></div>\n <div class="card">\n <div style="display:flex;flex-wrap:wrap;gap:12px;align-items:center;margin-bottom:14px">\n <div class="field" style="margin-bottom:0;flex:0 0 auto">\n <label>Log Level</label>\n <select id="logLevelSelect" onchange="changeLogLevel()" style="width:120px">\n <option value="debug">debug</option>\n <option value="info">info</option>\n <option value="warn">warn</option>\n <option value="error">error</option>\n </select>\n </div>\n <div class="field" style="margin-bottom:0;flex:0 0 auto">\n <label>Lines</label>\n <select id="logLinesSelect" onchange="changeLogLines()" style="width:100px">\n <option value="200">200</option>\n <option value="500">500</option>\n <option value="1000">1000</option>\n </select>\n </div>\n <div class="field" style="margin-bottom:0;flex:0 0 auto">\n <label>Auto Refresh</label>\n <label class="toggle"><input type="checkbox" id="logAutoRefresh" onchange="toggleLogAutoRefresh()"><span></span></label>\n </div>\n <div class="field" style="margin-bottom:0;flex:0 0 auto">\n <label>Verbose Logs</label>\n <label class="toggle"><input type="checkbox" id="logVerbose" onchange="toggleVerboseDebugLogs()"><span></span></label>\n </div>\n <div class="field" style="margin-bottom:0;flex:1;min-width:140px">\n <label>Filter</label>\n <input id="logFilter" type="text" placeholder="type to filter..." oninput="applyLogFilter()" style="font-size:12px">\n </div>\n <div style="margin-left:auto;display:flex;gap:8px;align-self:flex-end">\n <button class="btn btn-sm" onclick="loadLogLines()">Refresh</button>\n <button class="btn-ghost btn-sm" onclick="downloadCurrentLog()">Download</button>\n </div>\n </div>\n <pre id="logViewer" style="background:var(--bg);border:1px solid var(--border);border-radius:var(--radius);padding:12px;font-size:12px;line-height:1.6;max-height:500px;overflow-y:auto;white-space:pre-wrap;word-break:break-all;color:var(--text)"></pre>\n <div style="margin-top:6px;font-size:12px;color:var(--text-muted)" id="logTotal"></div>\n </div>\n\n <h2>Archived Logs</h2>\n <div class="card">\n <table class="tbl">\n <thead><tr><th>File</th><th>Size</th><th>Modified</th><th></th></tr></thead>\n <tbody id="logFilesBody"></tbody>\n </table>\n </div>\n\n <h2>Memory Logs</h2>\n <p style="font-size:13px;color:var(--text-muted);margin-bottom:12px">Conversation logs organized by session. Select a session folder to browse its log files.</p>\n <div class="card">\n <div class="field">\n <label>Session</label>\n <select id="memorySessionSelect" onchange="loadMemoryFiles()">\n <option value="">Select a session...</option>\n </select>\n </div>\n <div id="memoryFileList" style="margin-top:12px;max-height:400px;overflow-y:auto"></div>\n </div>\n </div>\n\n \x3c!-- Nodes --\x3e\n <div id="sec-nodes" class="section">\n <div class="section-header"><h1>Nodes</h1></div>\n\n <div class="card">\n <div class="card-header"><span class="card-title">Connected Nodes</span><button class="btn btn-sm" onclick="loadNodes()" style="font-size:12px;padding:3px 8px">Refresh</button></div>\n <table class="tbl">\n <thead><tr><th>Name</th><th>Hostname</th><th>Platform</th><th>Arch</th><th>Connected Since</th></tr></thead>\n <tbody id="connectedNodesBody"></tbody>\n </table>\n </div>\n\n <div class="card" id="pendingNodesCard" style="display:none">\n <div class="card-header"><span class="card-title">Pending Approvals</span></div>\n <table class="tbl">\n <thead><tr><th>Display Name</th><th>Hostname</th><th>Signature</th><th>Requested At</th><th></th></tr></thead>\n <tbody id="pendingNodesBody"></tbody>\n </table>\n </div>\n\n <div class="card" id="approvedNodesCard" style="display:none">\n <div class="card-header"><span class="card-title">Approved Nodes</span></div>\n <table class="tbl">\n <thead><tr><th>Display Name</th><th>Hostname</th><th>Signature</th><th>Approved At</th><th>Last Seen</th><th></th></tr></thead>\n <tbody id="approvedNodesBody"></tbody>\n </table>\n </div>\n\n <div class="card" id="revokedNodesCard" style="display:none">\n <div class="card-header"><span class="card-title">Revoked Nodes</span></div>\n <table class="tbl">\n <thead><tr><th>Display Name</th><th>Hostname</th><th>Revoked At</th><th></th></tr></thead>\n <tbody id="revokedNodesBody"></tbody>\n </table>\n </div>\n </div>\n\n \x3c!-- Tokens --\x3e\n <div id="sec-tokens" class="section">\n <div class="section-header"><h1>Tokens</h1></div>\n <div class="card">\n <div class="card-header">\n <span class="card-title">API Tokens</span>\n <button class="btn btn-sm" onclick="showCreateToken()">\n <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:4px"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>\n Create\n </button>\n </div>\n <div id="createTokenForm" style="display:none;margin-bottom:16px;padding:14px;border:1px solid var(--border);border-radius:var(--radius)">\n <div class="field"><label>User ID</label><input id="newTokenUser" type="text" placeholder="Unique user identifier (min 5 chars)"></div>\n <div class="field-row">\n <div class="field" style="flex:1"><label>Channel</label><select id="newTokenChannel"><option value="*">* (all)</option><option value="nostromo">nostromo</option><option value="responses">responses</option><option value="webchat">webchat</option></select></div>\n <div class="field" style="flex:1"><label>Label</label><input id="newTokenLabel" type="text" placeholder="optional label"></div>\n </div>\n <div style="display:flex;gap:8px;margin-top:4px">\n <button class="btn btn-sm" onclick="createToken()">Create Token</button>\n <button class="btn-ghost btn-sm" onclick="hideCreateToken()">Cancel</button>\n </div>\n </div>\n <div id="tokenTableWrap">\n <table class="tbl">\n <thead><tr><th>ID</th><th>Token</th><th>User</th><th>Channel</th><th>Label</th><th>Status</th><th></th></tr></thead>\n <tbody id="tokenBody"></tbody>\n </table>\n </div>\n </div>\n </div>\n\n \x3c!-- Prompts --\x3e\n <div id="sec-prompts" class="section">\n <div class="section-header">\n <h1>Prompts</h1>\n <button class="btn" onclick="simulatePrompt()">Simulate Building</button>\n </div>\n\n <div class="card">\n <div class="card-title" style="margin-bottom:4px">File Editor</div>\n <p style="font-size:13px;color:var(--text-muted);margin-bottom:12px">Edit system prompt templates and workspace files. Templates use <code>{{PLACEHOLDER}}</code> syntax.</p>\n <div class="file-select-row">\n <select id="promptFileSelect" onchange="selectPromptFile()">\n <option value="">Select a file...</option>\n </select>\n <span id="promptFileBadge" class="file-badge" style="display:none"></span>\n <button class="btn btn-sm" id="promptSaveBtn" onclick="saveCurrentFile()" style="display:none">Save</button>\n <button class="btn-ghost btn-sm" onclick="showPlaceholderRef()" title="Placeholder Reference">{{...}}</button>\n </div>\n <div id="promptEditorWrap" class="monaco-wrap" style="display:none"></div>\n </div>\n\n </div>\n\n \x3c!-- Settings --\x3e\n <div id="sec-settings" class="section">\n <div class="section-header"><h1>Settings</h1><button class="btn save-btn" onclick="saveConfig()" disabled>Save Changes</button></div>\n <div class="card">\n <div class="card-title" style="margin-bottom:12px">Appearance</div>\n <div style="display:flex;align-items:center;gap:12px">\n <span style="font-size:14px">Theme</span>\n <label class="toggle">\n <input type="checkbox" id="themeToggle" onchange="toggleTheme()">\n <span></span>\n </label>\n <span style="font-size:13px;color:var(--text-muted)" id="themeLabel">Light</span>\n </div>\n </div>\n <div class="card">\n <div class="card-title" style="margin-bottom:12px">Server</div>\n <div class="field-row">\n <div class="field"><label>Bind Host</label><input type="text" value="" id="settingsHost" placeholder="127.0.0.1"></div>\n <div class="field"><label>Port</label><input type="number" value="" id="settingsUiPort"></div>\n <div class="field"><label>Timezone</label><input type="text" value="" id="settingsTimezone" placeholder="Europe/Rome"></div>\n </div>\n <div style="margin-top:8px">\n <p style="font-size:12px;color:var(--text-muted)">CLI <code>--host</code> / <code>--port</code> override config values at runtime. All network changes require a restart.</p>\n </div>\n </div>\n <div class="card">\n <div class="card-title" style="margin-bottom:12px">Config Watcher</div>\n <div class="field-row" style="align-items:center">\n <div style="display:flex;align-items:center;gap:12px;flex:1">\n <label class="toggle">\n <input type="checkbox" id="settingsAutoRestart">\n <span></span>\n </label>\n <span style="font-size:14px">Auto-restart on config.yaml changes</span>\n </div>\n <div class="field" style="flex:0 0 160px;margin-bottom:0">\n <label>Check interval (sec)</label>\n <input type="number" id="settingsConfigCheckInterval" min="1" max="300">\n </div>\n </div>\n <div style="margin-top:8px">\n <p style="font-size:12px;color:var(--text-muted)">When auto-restart is disabled, config file changes are detected but the server does not restart. The check interval controls how often the config file is polled for changes.</p>\n </div>\n </div>\n <div class="card">\n <div class="card-title" style="margin-bottom:12px">Access Key</div>\n <div id="currentKeyRow" class="current-key-row" style="display:none">\n <code id="currentKeyValue" class="current-key-value"></code>\n <button class="icon-btn" onclick="toggleKeyVisibility()" title="Show / Hide" id="keyEyeBtn">\n <svg id="keyEyeOff" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94"/><path d="M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19"/><path d="M1 1l22 22"/><path d="M14.12 14.12a3 3 0 1 1-4.24-4.24"/></svg>\n <svg id="keyEyeOn" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:none"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>\n </button>\n <button class="icon-btn" onclick="copyKey()" title="Copy to clipboard">\n <svg id="keyCopyIcon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>\n <svg id="keyCheckIcon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:none"><polyline points="20 6 9 17 4 12"/></svg>\n </button>\n </div>\n <p style="font-size:14px;color:var(--text-muted);margin-bottom:12px">Generate a new access key. The current key will be invalidated immediately.</p>\n <button class="btn" onclick="confirmRegenKey()">Regenerate Key</button>\n <div id="newKeyDisplay"></div>\n </div>\n <div class="card">\n <div class="card-title" style="margin-bottom:12px">Session</div>\n <button class="btn-ghost" onclick="doLogout()">Log out</button>\n </div>\n </div>\n\n </main>\n</div>\n\n\x3c!-- Floating restart indicator --\x3e\n<div id="floatingRestart" class="floating-restart" style="display:none" onclick="showRestartModal()" title="Config changed — click to restart">\n <svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0114.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0020.49 15"/></svg>\n</div>\n\n\x3c!-- Toast --\x3e\n<div id="toast" class="toast"></div>\n`}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export function renderModals(){return'\n\x3c!-- Restart confirmation modal --\x3e\n<div class="modal-overlay" id="restartModal">\n <div class="modal">\n <button class="close-btn" onclick="closeRestartModal()">×</button>\n <h3>Restart Server</h3>\n <p>This will restart all channels, agents, and services. Active conversations will be interrupted.</p>\n <div style="display:flex;gap:8px;justify-content:center">\n <button class="btn" onclick="doRestart()">Restart</button>\n <button class="btn-ghost" onclick="closeRestartModal()">Cancel</button>\n </div>\n </div>\n</div>\n<div class="modal-overlay" id="placeholderRefModal">\n <div class="modal" style="max-width:620px;text-align:left;max-height:85vh;overflow-y:auto">\n <button class="close-btn" onclick="document.getElementById(\'placeholderRefModal\').classList.remove(\'open\')">×</button>\n <h3 style="margin-bottom:14px">Placeholder Reference</h3>\n <p style="font-size:13px;color:var(--text-muted);margin-bottom:14px">These placeholders are resolved at runtime when building the system prompt from templates.</p>\n <table class="tbl" style="font-size:13px">\n <thead><tr><th style="width:40%">Placeholder</th><th>Description</th></tr></thead>\n <tbody>\n <tr><td><code>{{ATTACHMENTS_DIR}}</code></td><td>Path to the session attachments folder, or <code>(memory disabled)</code></td></tr>\n <tr><td><code>{{AVAILABLE_TOOLS}}</code></td><td>Auto-discovered list of all registered MCP tools with name, description, and parameters</td></tr>\n <tr><td><code>{{CHANNEL}}</code></td><td>Channel name, e.g. <code>telegram</code>, <code>responses</code></td></tr>\n <tr><td><code>{{CHAT_ID}}</code></td><td>Chat / conversation ID within the channel</td></tr>\n <tr><td><code>{{DATA_DIR}}</code></td><td>Absolute path to the data directory (workspace files, templates)</td></tr>\n <tr><td><code>{{HEARTBEAT_INSTRUCTIONS}}</code></td><td>Heartbeat/cron behavioral instructions, or empty if cron is disabled</td></tr>\n <tr><td><code>{{HEARTBEAT_PROMPT}}</code></td><td>Raw heartbeat prompt message from config</td></tr>\n <tr><td><code>{{HOSTNAME}}</code></td><td>Server hostname</td></tr>\n <tr><td><code>{{MEMORY_FILE}}</code></td><td>Path to the current conversation memory file, or <code>(memory disabled)</code></td></tr>\n <tr><td><code>{{MESSAGE_TOOLS_INSTRUCTIONS}}</code></td><td>Instructions for cross-channel messaging tools, or empty if not available</td></tr>\n <tr><td><code>{{MODEL}}</code></td><td>Active model ID, e.g. <code>claude-opus-4-6</code></td></tr>\n <tr><td><code>{{NODE_TOOLS_INSTRUCTIONS}}</code></td><td>Instructions for node remote-execution tools, or empty if no nodes connected</td></tr>\n <tr><td><code>{{OS}}</code></td><td>Operating system info (type, release, arch)</td></tr>\n <tr><td><code>{{RUNTIME_LINE}}</code></td><td>Composed runtime info line (host, OS, model, channel, session)</td></tr>\n <tr><td><code>{{SEARCH_IN_MEMORIES}}</code></td><td>Memory search tools instructions (<code>memory_search</code>, <code>memory_get</code>). Populated when Recall Strategy is not <code>builtin-only</code>, empty otherwise.</td></tr>\n <tr><td><code>{{SESSION_ID}}</code></td><td>SDK session ID for resumption, or <code>(new session)</code></td></tr>\n <tr><td><code>{{SESSION_KEY}}</code></td><td>Current session identifier, e.g. <code>telegram:12345</code></td></tr>\n <tr><td><code>{{SUBAGENT_TASK}}</code></td><td>Task description for subagent mode (empty for main agent)</td></tr>\n <tr><td><code>{{TIMEZONE}}</code></td><td>Server timezone, e.g. <code>Europe/Rome</code></td></tr>\n <tr><td><code>{{WORKSPACE_DIR}}</code></td><td>Absolute path to the agent workspace directory</td></tr>\n <tr><td><code>{{WORKSPACE_FILES}}</code></td><td>All workspace .md files inlined as <code>## FileName.md</code> sections</td></tr>\n </tbody>\n </table>\n </div>\n</div>\n<div class="modal-overlay" id="agentHelpModal">\n <div class="modal" style="max-width:680px;text-align:left;max-height:85vh;overflow-y:auto">\n <button class="close-btn" onclick="document.getElementById(\'agentHelpModal\').classList.remove(\'open\')">×</button>\n <h3 style="margin-bottom:14px">Agent Configuration Reference</h3>\n\n <h4 style="margin:16px 0 8px;font-size:14px;color:var(--accent)">Main</h4>\n <table class="tbl" style="font-size:13px">\n <thead><tr><th style="width:30%">Field</th><th>Description</th></tr></thead>\n <tbody>\n <tr><td><strong>Default Model</strong></td><td>The model used for all conversations. Must be one of the models defined in the Models section.</td></tr>\n <tr><td><strong>Fallback Model</strong></td><td>If the default model fails (API error, overloaded, etc.), the agent automatically retries with this model. Leave empty to disable fallback.</td></tr>\n </tbody>\n </table>\n\n <h4 style="margin:16px 0 8px;font-size:14px;color:var(--accent)">Configuration</h4>\n <table class="tbl" style="font-size:13px">\n <thead><tr><th style="width:30%">Field</th><th>Description</th></tr></thead>\n <tbody>\n <tr><td><strong>Max Turns</strong></td><td>Maximum number of agentic turns (tool use round-trips) per single invocation. Prevents runaway loops. A typical value is 5–15.</td></tr>\n <tr><td><strong>Permission Mode</strong></td><td>Controls how the SDK handles tool permissions.<br><code>default</code> — ask the user before running tools.<br><code>bypassPermissions</code> — run all allowed tools without asking (recommended for automated agents).<br><code>plan</code> — the agent proposes a plan and waits for approval before executing.</td></tr>\n <tr><td><strong>Session TTL</strong></td><td>Time in seconds before an idle session expires. When a session expires, the next message starts a new conversation (no history carried over). Default: 3600 (1 hour).</td></tr>\n <tr><td><strong>Setting Sources</strong></td><td>Which SDK settings files to load.<br><code>nothing</code> — ignore all settings files.<br><code>user</code> — load user-level settings (~/.claude).<br><code>project</code> — load project-level settings from workspace.<br><code>both</code> — load both user and project settings.</td></tr>\n <tr><td><strong>Coder Skill</strong></td><td>When enabled, the builtin coding prompt is always prepended to the system prompt, adding coding-oriented instructions. When disabled, the system prompt is built entirely from your templates (SYSTEM_PROMPT.md).</td></tr>\n <tr><td><strong>Allowed Tools</strong></td><td>Which SDK tools the agent can use. Unchecked tools are not available to the agent. Common tools: <code>Read</code>, <code>Write</code>, <code>Edit</code> for file operations; <code>Bash</code> for shell commands; <code>Glob</code>/<code>Grep</code> for search; <code>WebSearch</code>/<code>WebFetch</code> for web access; <code>Task</code> for sub-agents; <code>Skill</code> for slash commands.</td></tr>\n <tr><td><strong>Auto Approve Tools</strong></td><td>Controls how tool permission requests from the SDK are handled.<br><strong>On (default)</strong> — all tool invocations are automatically approved. This prevents the SDK from blocking when <code>permissionMode</code> is set to <code>default</code>.<br><strong>Off</strong> — tool permission requests are forwarded to the user’s channel as interactive messages with Approve/Deny buttons. The SDK waits until the user responds before proceeding.</td></tr>\n </tbody>\n </table>\n\n <h4 style="margin:16px 0 8px;font-size:14px;color:var(--accent)">Message Queue</h4>\n <p style="font-size:13px;color:var(--text-muted);margin-bottom:10px">Controls how incoming messages are handled while the agent is already processing a response.</p>\n <table class="tbl" style="font-size:13px">\n <thead><tr><th style="width:30%">Field</th><th>Description</th></tr></thead>\n <tbody>\n <tr><td><strong>Queue Mode</strong></td><td>\n <code>queue</code> — Simple FIFO. Each message is processed one at a time in order. The user must wait for each response before the next message is handled.<br>\n <code>collect</code> — Messages sent while the agent is busy are buffered. After a debounce period, all buffered messages are merged into a single prompt and processed together.<br>\n <code>steer</code> — A new message interrupts the current processing. The agent stops its current response and immediately starts handling the new message, combining context from both.\n </td></tr>\n <tr><td><strong>Debounce (ms)</strong></td><td>Only applies in <code>collect</code> mode. After the last buffered message arrives, the system waits this many milliseconds before flushing the batch. This allows grouping rapid-fire messages into a single prompt. Set to 0 for immediate flush.</td></tr>\n <tr><td><strong>Queue Cap</strong></td><td>Maximum number of messages that can accumulate in the buffer. When this limit is reached, the Drop Policy kicks in. Set to 0 for unlimited.</td></tr>\n <tr><td><strong>Drop Policy</strong></td><td>What happens when Queue Cap is exceeded:<br>\n <code>new</code> — The incoming message is rejected; the user receives an error asking to wait.<br>\n <code>old</code> — The oldest buffered message is silently discarded to make room.<br>\n <code>summarize</code> — The oldest message is discarded but a truncated preview (first 140 chars) is preserved. When the batch is flushed, these previews are prepended so the agent knows messages were dropped and can see a summary of their content.\n </td></tr>\n <tr><td><strong>Inflight Typing</strong></td><td>When enabled, the typing indicator stays active as long as at least one message is being processed. Without this, a fast command (e.g. <code>/status</code>) finishing while the agent is still working would clear the typing indicator prematurely.</td></tr>\n </tbody>\n </table>\n </div>\n</div>\n<div class="modal-overlay" id="memViewModal">\n <div class="modal sim-modal">\n <button class="close-btn" onclick="closeMemViewModal()">×</button>\n <h3 id="memViewTitle">Memory Log</h3>\n <div id="memEditorWrap" class="monaco-wrap readonly" style="height:450px"></div>\n <div class="sim-info">\n <span id="memViewInfo"></span>\n <button class="btn btn-ghost btn-sm" onclick="copyMemView()">Copy</button>\n </div>\n </div>\n</div>\n<div class="modal-overlay" id="simModal">\n <div class="modal sim-modal">\n <button class="close-btn" onclick="closeSimModal()">×</button>\n <h3>Simulate Building</h3>\n <div class="sim-tabs">\n <button class="sim-tab active" onclick="switchSimTab(\'main\',this)">Main Agent</button>\n <button class="sim-tab" onclick="switchSimTab(\'subagent\',this)">Subagent</button>\n </div>\n <div id="simEditorWrap" class="monaco-wrap readonly" style="height:450px"></div>\n <div class="sim-info">\n <span id="simCharCount"></span>\n <button class="btn btn-ghost btn-sm" onclick="copySimPrompt()">Copy</button>\n </div>\n </div>\n</div>\n<div class="modal-overlay" id="memDisableModal">\n <div class="modal">\n <button class="close-btn" onclick="closeMemDisableModal(false)">×</button>\n <h3>Disable Memories</h3>\n <p>Disabling memories means the agent will no longer keep track of past conversations. Conversation logs will not be saved and the agent will have no continuity between sessions.</p>\n <p style="font-size:13px;color:var(--text-muted)">Existing memory files will not be deleted.</p>\n <div style="display:flex;gap:8px;justify-content:center;margin-top:16px">\n <button class="btn btn-danger" onclick="closeMemDisableModal(true)">Disable</button>\n <button class="btn-ghost" onclick="closeMemDisableModal(false)">Cancel</button>\n </div>\n </div>\n</div>\n<div class="modal-overlay" id="skillDeleteModal">\n <div class="modal">\n <button class="close-btn" onclick="closeSkillDeleteModal()">×</button>\n <h3>Delete Skill</h3>\n <p>Are you sure you want to delete <strong id="skillDeleteName"></strong>? This cannot be undone.</p>\n <div style="display:flex;gap:8px;justify-content:center">\n <button class="btn btn-danger" onclick="doDeleteSkill()">Delete</button>\n <button class="btn-ghost" onclick="closeSkillDeleteModal()">Cancel</button>\n </div>\n </div>\n</div>\n<div class="modal-overlay" id="commandDeleteModal">\n <div class="modal">\n <button class="close-btn" onclick="closeCommandDeleteModal()">×</button>\n <h3>Delete Command</h3>\n <p>Are you sure you want to delete <strong id="commandDeleteName"></strong>? This cannot be undone.</p>\n <div style="display:flex;gap:8px;justify-content:center">\n <button class="btn btn-danger" onclick="doDeleteCommand()">Delete</button>\n <button class="btn-ghost" onclick="closeCommandDeleteModal()">Cancel</button>\n </div>\n </div>\n</div>\n<div class="modal-overlay" id="pluginDeleteModal">\n <div class="modal">\n <button class="close-btn" onclick="closePluginDeleteModal()">×</button>\n <h3>Delete Plugin</h3>\n <p>Are you sure you want to delete <strong id="pluginDeleteName"></strong>?</p>\n <div style="display:flex;gap:8px;justify-content:center">\n <button class="btn btn-danger" onclick="doDeletePlugin()">Delete</button>\n <button class="btn-ghost" onclick="closePluginDeleteModal()">Cancel</button>\n </div>\n </div>\n</div>\n<div class="modal-overlay" id="pluginUploadedModal">\n <div class="modal">\n <button class="close-btn" onclick="closePluginUploadedModal()">×</button>\n <h3>Plugin Added</h3>\n <p><strong id="pluginUploadedName"></strong> has been uploaded and saved to the configuration.</p>\n <p style="font-size:13px;color:var(--text-muted)">The plugin will appear as <em>invalid</em> until the server is restarted. Restart the server from the Dashboard to activate it.</p>\n <div style="display:flex;gap:8px;justify-content:center;margin-top:16px">\n <button class="btn" onclick="closePluginUploadedModal()">OK</button>\n </div>\n </div>\n</div>\n<div class="modal-overlay" id="skillEditModal">\n <div class="modal" style="max-width:800px;width:90vw;text-align:left">\n <button class="close-btn" onclick="closeSkillEditModal()">×</button>\n <h3 style="margin-bottom:4px">Edit Skill</h3>\n <p style="font-size:13px;color:var(--text-muted);margin-bottom:12px"><code id="skillEditPath"></code></p>\n <div id="skillEditorWrap" class="monaco-wrap" style="height:400px"></div>\n <div style="display:flex;gap:8px;justify-content:flex-end;margin-top:14px">\n <button class="btn-ghost" onclick="closeSkillEditModal()">Close</button>\n <button class="btn" onclick="saveSkillEdit()">Save</button>\n </div>\n </div>\n</div>\n<div class="modal-overlay" id="commandEditModal">\n <div class="modal" style="max-width:800px;width:90vw;text-align:left">\n <button class="close-btn" onclick="closeCommandEditModal()">×</button>\n <h3 style="margin-bottom:4px">Edit Command</h3>\n <p style="font-size:13px;color:var(--text-muted);margin-bottom:12px"><code id="commandEditPath"></code></p>\n <div id="commandEditorWrap" class="monaco-wrap" style="height:400px"></div>\n <div style="display:flex;gap:8px;justify-content:flex-end;margin-top:14px">\n <button class="btn-ghost" onclick="closeCommandEditModal()">Close</button>\n <button class="btn" onclick="saveCommandEdit()">Save</button>\n </div>\n </div>\n</div>\n<div class="modal-overlay" id="commandUploadedModal">\n <div class="modal">\n <button class="close-btn" onclick="closeCommandUploadedModal()">×</button>\n <h3>Command Added</h3>\n <p><strong id="commandUploadedName"></strong> has been uploaded.</p>\n <p style="font-size:13px;color:var(--text-muted)">The command is ready to use immediately.</p>\n <div style="display:flex;gap:8px;justify-content:center;margin-top:16px">\n <button class="btn" onclick="closeCommandUploadedModal()">OK</button>\n </div>\n </div>\n</div>\n<div class="modal-overlay" id="skillUploadedModal">\n <div class="modal">\n <button class="close-btn" onclick="closeSkillUploadedModal()">×</button>\n <h3>Skill Added</h3>\n <p><strong id="skillUploadedName"></strong> has been uploaded.</p>\n <p style="font-size:13px;color:var(--text-muted)">The skill is ready to use immediately.</p>\n <div style="display:flex;gap:8px;justify-content:center;margin-top:16px">\n <button class="btn" onclick="closeSkillUploadedModal()">OK</button>\n </div>\n </div>\n</div>\n<div class="modal-overlay" id="varDeleteModal">\n <div class="modal">\n <button class="close-btn" onclick="closeVarDeleteModal()">×</button>\n <h3>Delete Var</h3>\n <p>Are you sure you want to delete <strong id="varDeleteName"></strong>?</p>\n <div style="display:flex;gap:8px;justify-content:center">\n <button class="btn btn-danger" onclick="doDeleteVar()">Delete</button>\n <button class="btn-ghost" onclick="closeVarDeleteModal()">Cancel</button>\n </div>\n </div>\n</div>\n<div class="modal-overlay" id="modelDeleteModal">\n <div class="modal">\n <button class="close-btn" onclick="closeModelDeleteModal()">×</button>\n <h3>Delete Model</h3>\n <p>Are you sure you want to delete <strong id="modelDeleteName"></strong>?</p>\n <div style="display:flex;gap:8px;justify-content:center">\n <button class="btn btn-danger" onclick="doDeleteModel()">Delete</button>\n <button class="btn-ghost" onclick="closeModelDeleteModal()">Cancel</button>\n </div>\n </div>\n</div>\n<div class="modal-overlay" id="saDeleteModal">\n <div class="modal">\n <button class="close-btn" onclick="closeSaDeleteModal()">×</button>\n <h3>Delete Sub Agent</h3>\n <p>Are you sure you want to delete <strong id="saDeleteName"></strong>?</p>\n <div style="display:flex;gap:8px;justify-content:center">\n <button class="btn btn-danger" onclick="doDeleteSubAgent()">Delete</button>\n <button class="btn-ghost" onclick="closeSaDeleteModal()">Cancel</button>\n </div>\n </div>\n</div>\n<div class="modal-overlay" id="tokenDeleteModal">\n <div class="modal">\n <button class="close-btn" onclick="closeTokenDeleteModal()">×</button>\n <h3>Delete Token</h3>\n <p>Are you sure you want to delete token <strong id="tokenDeleteId"></strong>? This cannot be undone.</p>\n <div style="display:flex;gap:8px;justify-content:center">\n <button class="btn btn-danger" onclick="doDeleteToken()">Delete</button>\n <button class="btn-ghost" onclick="closeTokenDeleteModal()">Cancel</button>\n </div>\n </div>\n</div>\n<div class="modal-overlay" id="regenKeyModal">\n <div class="modal">\n <button class="close-btn" onclick="closeRegenKeyModal()">×</button>\n <h3>Regenerate Access Key</h3>\n <p>This will invalidate the current access key. Any saved links or bookmarks using the old key will stop working.</p>\n <div style="display:flex;gap:8px;justify-content:center">\n <button class="btn btn-danger" onclick="closeRegenKeyModal();regenKey()">Regenerate</button>\n <button class="btn-ghost" onclick="closeRegenKeyModal()">Cancel</button>\n </div>\n </div>\n</div>\n<div class="modal-overlay" id="heartbeatSimModal">\n <div class="modal" style="max-width:600px;text-align:left">\n <button class="close-btn" onclick="closeHeartbeatSimModal()">×</button>\n <h3 id="heartbeatSimTitle">Heartbeat Simulation</h3>\n <p id="heartbeatSimResult" style="margin:12px 0"></p>\n <label style="font-size:13px;font-weight:600;display:block;margin-bottom:6px">HEARTBEAT.md</label>\n <textarea id="heartbeatSimContent" readonly style="width:100%;height:200px;font-family:var(--mono);font-size:13px;resize:vertical;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:10px;color:var(--text)"></textarea>\n <div style="display:flex;gap:8px;justify-content:center;margin-top:16px">\n <button class="btn" onclick="closeHeartbeatSimModal()">OK</button>\n </div>\n </div>\n</div>\n<div class="modal-overlay" id="qrModal">\n <div class="modal">\n <button class="close-btn" onclick="closeQrModal()">×</button>\n <h3>Connect WhatsApp</h3>\n <p id="qrMsg">Waiting for QR code...</p>\n <div id="qrSpinner"><span class="spinner"></span> Connecting...</div>\n <img id="qrImg" style="display:none" alt="WhatsApp QR Code">\n <div id="qrStatus"></div>\n <button class="btn btn-ghost" onclick="closeQrModal()" style="margin-top:8px">Close</button>\n </div>\n</div>\n<div class="modal-overlay" id="internalToolsModal">\n <div class="modal" style="max-width:780px;text-align:left;max-height:85vh;display:flex;flex-direction:column">\n <button class="close-btn" onclick="document.getElementById(\'internalToolsModal\').classList.remove(\'open\')">×</button>\n <h3 style="margin-bottom:6px">Internal MCP Tools</h3>\n <p style="font-size:13px;color:var(--text-muted);margin-bottom:14px">Built-in MCP tool servers registered for the agent.</p>\n <div style="overflow-y:auto;flex:1;min-height:0">\n <div id="internalToolsBody"><span class="spinner"></span> Loading...</div>\n </div>\n </div>\n</div>\n<div class="modal-overlay" id="cronMsgModal">\n <div class="modal" style="max-width:680px;text-align:left;max-height:80vh;display:flex;flex-direction:column">\n <button class="close-btn" onclick="document.getElementById(\'cronMsgModal\').classList.remove(\'open\')">×</button>\n <h3 id="cronMsgTitle" style="margin-bottom:10px">Job Message</h3>\n <textarea id="cronMsgBody" readonly style="flex:1;min-height:200px;width:100%;resize:none;font-family:monospace;font-size:13px;background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:var(--radius);padding:12px"></textarea>\n </div>\n</div>\n\x3c!-- Session expired modal --\x3e\n<div class="modal-overlay" id="sessionExpiredModal">\n <div class="modal" style="text-align:center">\n <h3>Session Expired</h3>\n <p style="margin:16px 0">The server was restarted or your session is no longer valid. Please re-authenticate.</p>\n <button class="btn" onclick="location.reload()">OK</button>\n </div>\n</div>\n'}
|