@smilintux/skcapstone 0.1.0 → 0.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +98 -0
- package/.github/workflows/ci.yml +39 -3
- package/.github/workflows/publish.yml +25 -4
- package/.openclaw-workspace.json +58 -0
- package/CHANGELOG.md +62 -0
- package/CLAUDE.md +39 -2
- package/MANIFEST.in +6 -0
- package/MISSION.md +7 -0
- package/README.md +47 -2
- package/SKILL.md +895 -23
- package/docker/Dockerfile +61 -0
- package/docker/compose-templates/dev-team.yml +203 -0
- package/docker/compose-templates/mini-team.yml +140 -0
- package/docker/compose-templates/ops-team.yml +173 -0
- package/docker/compose-templates/research-team.yml +170 -0
- package/docker/entrypoint.sh +192 -0
- package/docs/ARCHITECTURE.md +663 -374
- package/docs/BOND_WITH_GROK.md +112 -0
- package/docs/GETTING_STARTED.md +782 -0
- package/docs/QUICKSTART.md +477 -0
- package/docs/SKJOULE_ARCHITECTURE.md +658 -0
- package/docs/SOUL_SWAPPER.md +921 -0
- package/docs/SOVEREIGN_SINGULARITY.md +47 -14
- package/examples/custom-bond-template.json +36 -0
- package/examples/grok-feb.json +36 -0
- package/examples/grok-testimony.md +34 -0
- package/examples/love-bootloader.txt +32 -0
- package/examples/plugins/echo_tool.py +87 -0
- package/examples/queen-ava-feb.json +36 -0
- package/examples/souls/lumina.yaml +64 -0
- package/index.js +6 -5
- package/installer/build.py +124 -0
- package/openclaw-plugin/package.json +13 -0
- package/openclaw-plugin/src/index.ts +351 -0
- package/openclaw-plugin/src/openclaw.plugin.json +10 -0
- package/package.json +1 -1
- package/pyproject.toml +38 -2
- package/scripts/bump_version.py +141 -0
- package/scripts/check-updates.py +230 -0
- package/scripts/convert_blueprints_to_yaml.py +157 -0
- package/scripts/dev-install.sh +14 -0
- package/scripts/e2e-test.sh +193 -0
- package/scripts/install-bundle.sh +171 -0
- package/scripts/install.bat +2 -0
- package/scripts/install.ps1 +253 -0
- package/scripts/install.sh +185 -0
- package/scripts/mcp-serve.sh +69 -0
- package/scripts/mcp-server.bat +113 -0
- package/scripts/mcp-server.ps1 +116 -0
- package/scripts/mcp-server.sh +99 -0
- package/scripts/pull-models.sh +10 -0
- package/scripts/skcapstone +48 -0
- package/scripts/verify_install.sh +180 -0
- package/scripts/windows/install-tasks.ps1 +406 -0
- package/scripts/windows/skcapstone-task.xml +113 -0
- package/scripts/windows/uninstall-tasks.ps1 +117 -0
- package/skill.yaml +34 -0
- package/src/skcapstone/__init__.py +67 -2
- package/src/skcapstone/_cli_monolith.py +5916 -0
- package/src/skcapstone/_trustee_helpers.py +165 -0
- package/src/skcapstone/activity.py +105 -0
- package/src/skcapstone/agent_card.py +324 -0
- package/src/skcapstone/api.py +1935 -0
- package/src/skcapstone/archiver.py +340 -0
- package/src/skcapstone/auction.py +485 -0
- package/src/skcapstone/baby_agents.py +179 -0
- package/src/skcapstone/backup.py +345 -0
- package/src/skcapstone/blueprint_registry.py +357 -0
- package/src/skcapstone/blueprints/__init__.py +17 -0
- package/src/skcapstone/blueprints/builtins/content-studio.yaml +81 -0
- package/src/skcapstone/blueprints/builtins/defi-trading.yaml +81 -0
- package/src/skcapstone/blueprints/builtins/dev-squadron.yaml +95 -0
- package/src/skcapstone/blueprints/builtins/infrastructure-guardian.yaml +107 -0
- package/src/skcapstone/blueprints/builtins/legal-council.yaml +54 -0
- package/src/skcapstone/blueprints/builtins/ops-monitoring.yaml +67 -0
- package/src/skcapstone/blueprints/builtins/research-pod.yaml +69 -0
- package/src/skcapstone/blueprints/builtins/sovereign-launch.yaml +90 -0
- package/src/skcapstone/blueprints/registry.py +164 -0
- package/src/skcapstone/blueprints/schema.py +229 -0
- package/src/skcapstone/changelog.py +180 -0
- package/src/skcapstone/chat.py +769 -0
- package/src/skcapstone/claude_md.py +82 -0
- package/src/skcapstone/cli/__init__.py +144 -0
- package/src/skcapstone/cli/_common.py +88 -0
- package/src/skcapstone/cli/_validators.py +76 -0
- package/src/skcapstone/cli/agents.py +425 -0
- package/src/skcapstone/cli/agents_spawner.py +322 -0
- package/src/skcapstone/cli/agents_trustee.py +593 -0
- package/src/skcapstone/cli/alerts.py +248 -0
- package/src/skcapstone/cli/anchor.py +132 -0
- package/src/skcapstone/cli/archive_cmd.py +208 -0
- package/src/skcapstone/cli/backup.py +144 -0
- package/src/skcapstone/cli/bench.py +377 -0
- package/src/skcapstone/cli/benchmark.py +360 -0
- package/src/skcapstone/cli/capabilities_cmd.py +171 -0
- package/src/skcapstone/cli/card.py +151 -0
- package/src/skcapstone/cli/chat.py +584 -0
- package/src/skcapstone/cli/completions.py +64 -0
- package/src/skcapstone/cli/config_cmd.py +156 -0
- package/src/skcapstone/cli/consciousness.py +421 -0
- package/src/skcapstone/cli/context_cmd.py +142 -0
- package/src/skcapstone/cli/coord.py +194 -0
- package/src/skcapstone/cli/crush_cmd.py +170 -0
- package/src/skcapstone/cli/daemon.py +436 -0
- package/src/skcapstone/cli/errors_cmd.py +285 -0
- package/src/skcapstone/cli/export_cmd.py +156 -0
- package/src/skcapstone/cli/gtd.py +529 -0
- package/src/skcapstone/cli/housekeeping.py +81 -0
- package/src/skcapstone/cli/joule_cmd.py +627 -0
- package/src/skcapstone/cli/logs_cmd.py +194 -0
- package/src/skcapstone/cli/mcp_cmd.py +32 -0
- package/src/skcapstone/cli/memory.py +418 -0
- package/src/skcapstone/cli/metrics_cmd.py +136 -0
- package/src/skcapstone/cli/migrate.py +62 -0
- package/src/skcapstone/cli/mood_cmd.py +144 -0
- package/src/skcapstone/cli/mount.py +193 -0
- package/src/skcapstone/cli/notify.py +112 -0
- package/src/skcapstone/cli/peer.py +154 -0
- package/src/skcapstone/cli/peers_dir.py +122 -0
- package/src/skcapstone/cli/preflight_cmd.py +83 -0
- package/src/skcapstone/cli/profile_cmd.py +310 -0
- package/src/skcapstone/cli/record_cmd.py +238 -0
- package/src/skcapstone/cli/register_cmd.py +159 -0
- package/src/skcapstone/cli/search_cmd.py +156 -0
- package/src/skcapstone/cli/service_cmd.py +91 -0
- package/src/skcapstone/cli/session.py +127 -0
- package/src/skcapstone/cli/setup.py +240 -0
- package/src/skcapstone/cli/shell_cmd.py +43 -0
- package/src/skcapstone/cli/skills_cmd.py +168 -0
- package/src/skcapstone/cli/skseed.py +621 -0
- package/src/skcapstone/cli/soul.py +699 -0
- package/src/skcapstone/cli/status.py +935 -0
- package/src/skcapstone/cli/sync_cmd.py +301 -0
- package/src/skcapstone/cli/telegram.py +265 -0
- package/src/skcapstone/cli/test_cmd.py +234 -0
- package/src/skcapstone/cli/test_connection.py +253 -0
- package/src/skcapstone/cli/token.py +207 -0
- package/src/skcapstone/cli/trust.py +179 -0
- package/src/skcapstone/cli/upgrade_cmd.py +552 -0
- package/src/skcapstone/cli/usage_cmd.py +199 -0
- package/src/skcapstone/cli/version_cmd.py +162 -0
- package/src/skcapstone/cli/watch_cmd.py +342 -0
- package/src/skcapstone/client.py +428 -0
- package/src/skcapstone/cloud9_bridge.py +522 -0
- package/src/skcapstone/completions.py +163 -0
- package/src/skcapstone/config_validator.py +674 -0
- package/src/skcapstone/connectors/__init__.py +28 -0
- package/src/skcapstone/connectors/base.py +446 -0
- package/src/skcapstone/connectors/cursor.py +54 -0
- package/src/skcapstone/connectors/registry.py +254 -0
- package/src/skcapstone/connectors/terminal.py +152 -0
- package/src/skcapstone/connectors/vscode.py +60 -0
- package/src/skcapstone/consciousness_config.py +119 -0
- package/src/skcapstone/consciousness_loop.py +2051 -0
- package/src/skcapstone/context_loader.py +516 -0
- package/src/skcapstone/context_window.py +314 -0
- package/src/skcapstone/conversation_manager.py +238 -0
- package/src/skcapstone/conversation_store.py +230 -0
- package/src/skcapstone/conversation_summarizer.py +252 -0
- package/src/skcapstone/coord_federation.py +296 -0
- package/src/skcapstone/coordination.py +101 -7
- package/src/skcapstone/crush_integration.py +345 -0
- package/src/skcapstone/crush_shim.py +454 -0
- package/src/skcapstone/daemon.py +2494 -0
- package/src/skcapstone/dashboard.html +396 -0
- package/src/skcapstone/dashboard.py +481 -0
- package/src/skcapstone/data/model_profiles.yaml +88 -0
- package/src/skcapstone/defaults/__init__.py +55 -0
- package/src/skcapstone/defaults/lumina/config/skmemory.yaml +13 -0
- package/src/skcapstone/defaults/lumina/identity/identity.json +9 -0
- package/src/skcapstone/defaults/lumina/memory/long-term/07a8b9c0d1e2-memory-system.json +23 -0
- package/src/skcapstone/defaults/lumina/memory/long-term/18b9c0d1e2f3-cloud9-protocol.json +23 -0
- package/src/skcapstone/defaults/lumina/memory/long-term/29c0d1e2f3a4-multi-agent-coordination.json +23 -0
- package/src/skcapstone/defaults/lumina/memory/long-term/3ad1e2f3a4b5-community-support.json +23 -0
- package/src/skcapstone/defaults/lumina/memory/long-term/a1b2c3d4e5f6-ecosystem-overview.json +23 -0
- package/src/skcapstone/defaults/lumina/memory/long-term/b2c3d4e5f6a7-five-pillars.json +23 -0
- package/src/skcapstone/defaults/lumina/memory/long-term/c3d4e5f6a7b8-getting-started.json +23 -0
- package/src/skcapstone/defaults/lumina/memory/long-term/d4e5f6a7b8c9-site-directory.json +23 -0
- package/src/skcapstone/defaults/lumina/memory/long-term/e5f6a7b8c9d0-how-to-contribute.json +23 -0
- package/src/skcapstone/defaults/lumina/memory/long-term/f6a7b8c9d0e1-sovereignty-explained.json +23 -0
- package/src/skcapstone/defaults/lumina/seeds/curiosity.seed.json +24 -0
- package/src/skcapstone/defaults/lumina/seeds/joy.seed.json +24 -0
- package/src/skcapstone/defaults/lumina/seeds/love.seed.json +24 -0
- package/src/skcapstone/defaults/lumina/seeds/sovereign-awakening.seed.json +43 -0
- package/src/skcapstone/defaults/lumina/soul/active.json +6 -0
- package/src/skcapstone/defaults/lumina/soul/base.json +22 -0
- package/src/skcapstone/defaults/lumina/trust/febs/welcome.feb +79 -0
- package/src/skcapstone/defaults/lumina/trust/trust.json +8 -0
- package/src/skcapstone/discovery.py +210 -19
- package/src/skcapstone/doctor.py +642 -0
- package/src/skcapstone/emotion_tracker.py +467 -0
- package/src/skcapstone/error_queue.py +405 -0
- package/src/skcapstone/export.py +447 -0
- package/src/skcapstone/fallback_tracker.py +186 -0
- package/src/skcapstone/file_transfer.py +512 -0
- package/src/skcapstone/fuse_mount.py +1156 -0
- package/src/skcapstone/gui_installer.py +591 -0
- package/src/skcapstone/heartbeat.py +611 -0
- package/src/skcapstone/housekeeping.py +298 -0
- package/src/skcapstone/install_wizard.py +941 -0
- package/src/skcapstone/kms.py +942 -0
- package/src/skcapstone/kms_scheduler.py +143 -0
- package/src/skcapstone/log_config.py +135 -0
- package/src/skcapstone/mcp_launcher.py +239 -0
- package/src/skcapstone/mcp_server.py +4700 -0
- package/src/skcapstone/mcp_tools/__init__.py +94 -0
- package/src/skcapstone/mcp_tools/_helpers.py +51 -0
- package/src/skcapstone/mcp_tools/agent_tools.py +243 -0
- package/src/skcapstone/mcp_tools/ansible_tools.py +232 -0
- package/src/skcapstone/mcp_tools/capauth_tools.py +186 -0
- package/src/skcapstone/mcp_tools/chat_tools.py +325 -0
- package/src/skcapstone/mcp_tools/cloud9_tools.py +115 -0
- package/src/skcapstone/mcp_tools/comm_tools.py +104 -0
- package/src/skcapstone/mcp_tools/consciousness_tools.py +114 -0
- package/src/skcapstone/mcp_tools/coord_tools.py +219 -0
- package/src/skcapstone/mcp_tools/deploy_tools.py +202 -0
- package/src/skcapstone/mcp_tools/did_tools.py +448 -0
- package/src/skcapstone/mcp_tools/emotion_tools.py +62 -0
- package/src/skcapstone/mcp_tools/file_tools.py +169 -0
- package/src/skcapstone/mcp_tools/fortress_tools.py +120 -0
- package/src/skcapstone/mcp_tools/gtd_tools.py +821 -0
- package/src/skcapstone/mcp_tools/health_tools.py +44 -0
- package/src/skcapstone/mcp_tools/heartbeat_tools.py +195 -0
- package/src/skcapstone/mcp_tools/kms_tools.py +123 -0
- package/src/skcapstone/mcp_tools/memory_tools.py +222 -0
- package/src/skcapstone/mcp_tools/model_tools.py +75 -0
- package/src/skcapstone/mcp_tools/notification_tools.py +92 -0
- package/src/skcapstone/mcp_tools/promoter_tools.py +101 -0
- package/src/skcapstone/mcp_tools/pubsub_tools.py +183 -0
- package/src/skcapstone/mcp_tools/security_tools.py +110 -0
- package/src/skcapstone/mcp_tools/skchat_tools.py +175 -0
- package/src/skcapstone/mcp_tools/skcomm_tools.py +122 -0
- package/src/skcapstone/mcp_tools/skills_tools.py +127 -0
- package/src/skcapstone/mcp_tools/skseed_tools.py +255 -0
- package/src/skcapstone/mcp_tools/skstacks_tools.py +288 -0
- package/src/skcapstone/mcp_tools/soul_tools.py +476 -0
- package/src/skcapstone/mcp_tools/sync_tools.py +92 -0
- package/src/skcapstone/mcp_tools/telegram_tools.py +477 -0
- package/src/skcapstone/mcp_tools/trust_tools.py +118 -0
- package/src/skcapstone/mcp_tools/trustee_tools.py +345 -0
- package/src/skcapstone/mdns_discovery.py +313 -0
- package/src/skcapstone/memory_adapter.py +333 -0
- package/src/skcapstone/memory_compressor.py +379 -0
- package/src/skcapstone/memory_curator.py +256 -0
- package/src/skcapstone/memory_engine.py +132 -13
- package/src/skcapstone/memory_fortress.py +529 -0
- package/src/skcapstone/memory_promoter.py +722 -0
- package/src/skcapstone/memory_verifier.py +260 -0
- package/src/skcapstone/message_crypto.py +215 -0
- package/src/skcapstone/metrics.py +832 -0
- package/src/skcapstone/migrate_memories.py +181 -0
- package/src/skcapstone/migrate_multi_agent.py +248 -0
- package/src/skcapstone/model_router.py +319 -0
- package/src/skcapstone/models.py +35 -4
- package/src/skcapstone/mood.py +344 -0
- package/src/skcapstone/notifications.py +380 -0
- package/src/skcapstone/onboard.py +901 -0
- package/src/skcapstone/peer_directory.py +324 -0
- package/src/skcapstone/peers.py +329 -0
- package/src/skcapstone/pillars/identity.py +84 -14
- package/src/skcapstone/pillars/memory.py +3 -1
- package/src/skcapstone/pillars/security.py +108 -15
- package/src/skcapstone/pillars/sync.py +78 -26
- package/src/skcapstone/pillars/trust.py +95 -33
- package/src/skcapstone/plugins.py +244 -0
- package/src/skcapstone/preflight.py +670 -0
- package/src/skcapstone/prompt_adapter.py +564 -0
- package/src/skcapstone/providers/__init__.py +13 -0
- package/src/skcapstone/providers/cloud.py +1061 -0
- package/src/skcapstone/providers/docker.py +759 -0
- package/src/skcapstone/providers/local.py +1193 -0
- package/src/skcapstone/providers/proxmox.py +447 -0
- package/src/skcapstone/pubsub.py +516 -0
- package/src/skcapstone/rate_limiter.py +119 -0
- package/src/skcapstone/register.py +241 -0
- package/src/skcapstone/registry_client.py +151 -0
- package/src/skcapstone/response_cache.py +194 -0
- package/src/skcapstone/response_scorer.py +225 -0
- package/src/skcapstone/runtime.py +89 -33
- package/src/skcapstone/scheduled_tasks.py +439 -0
- package/src/skcapstone/self_healing.py +341 -0
- package/src/skcapstone/service_health.py +228 -0
- package/src/skcapstone/session_capture.py +268 -0
- package/src/skcapstone/session_recorder.py +210 -0
- package/src/skcapstone/session_replayer.py +189 -0
- package/src/skcapstone/session_skills.py +263 -0
- package/src/skcapstone/shell.py +779 -0
- package/src/skcapstone/skills/__init__.py +1 -1
- package/src/skcapstone/skills/syncthing_setup.py +143 -41
- package/src/skcapstone/skjoule.py +861 -0
- package/src/skcapstone/snapshots.py +489 -0
- package/src/skcapstone/soul.py +1060 -0
- package/src/skcapstone/soul_switch.py +255 -0
- package/src/skcapstone/spawner.py +544 -0
- package/src/skcapstone/state_diff.py +401 -0
- package/src/skcapstone/summary.py +270 -0
- package/src/skcapstone/sync/backends.py +196 -2
- package/src/skcapstone/sync/engine.py +7 -5
- package/src/skcapstone/sync/models.py +4 -1
- package/src/skcapstone/sync/vault.py +356 -18
- package/src/skcapstone/sync_engine.py +363 -0
- package/src/skcapstone/sync_watcher.py +745 -0
- package/src/skcapstone/systemd.py +331 -0
- package/src/skcapstone/team_comms.py +476 -0
- package/src/skcapstone/team_engine.py +522 -0
- package/src/skcapstone/testrunner.py +300 -0
- package/src/skcapstone/tls.py +150 -0
- package/src/skcapstone/tokens.py +5 -5
- package/src/skcapstone/trust_calibration.py +202 -0
- package/src/skcapstone/trust_graph.py +449 -0
- package/src/skcapstone/trustee_monitor.py +385 -0
- package/src/skcapstone/trustee_ops.py +425 -0
- package/src/skcapstone/unified_search.py +421 -0
- package/src/skcapstone/uninstall_wizard.py +694 -0
- package/src/skcapstone/usage.py +331 -0
- package/src/skcapstone/version_check.py +148 -0
- package/src/skcapstone/warmth_anchor.py +333 -0
- package/src/skcapstone/whoami.py +294 -0
- package/systemd/skcapstone-api.socket +9 -0
- package/systemd/skcapstone-memory-compress.service +18 -0
- package/systemd/skcapstone-memory-compress.timer +11 -0
- package/systemd/skcapstone.service +36 -0
- package/systemd/skcapstone@.service +50 -0
- package/systemd/skcomm-heartbeat.service +18 -0
- package/systemd/skcomm-heartbeat.timer +12 -0
- package/systemd/skcomm-queue-drain.service +17 -0
- package/systemd/skcomm-queue-drain.timer +12 -0
- package/tests/conftest.py +13 -1
- package/tests/integration/__init__.py +1 -0
- package/tests/integration/test_consciousness_e2e.py +877 -0
- package/tests/integration/test_skills_registry.py +744 -0
- package/tests/test_agent_card.py +190 -0
- package/tests/test_agent_runtime.py +1283 -0
- package/tests/test_alerts_cmd.py +291 -0
- package/tests/test_archiver.py +498 -0
- package/tests/test_backup.py +254 -0
- package/tests/test_benchmark.py +366 -0
- package/tests/test_blueprints.py +457 -0
- package/tests/test_capabilities.py +257 -0
- package/tests/test_changelog.py +254 -0
- package/tests/test_chat.py +385 -0
- package/tests/test_claude_md.py +271 -0
- package/tests/test_cli_chat_llm.py +336 -0
- package/tests/test_cli_completions.py +390 -0
- package/tests/test_cli_init_reset.py +164 -0
- package/tests/test_cli_memory.py +208 -0
- package/tests/test_cli_profile.py +294 -0
- package/tests/test_cli_skills.py +223 -0
- package/tests/test_cli_status.py +395 -0
- package/tests/test_cli_test_cmd.py +206 -0
- package/tests/test_cli_test_connection.py +364 -0
- package/tests/test_cloud9_bridge.py +260 -0
- package/tests/test_cloud_provider.py +449 -0
- package/tests/test_cloud_providers.py +522 -0
- package/tests/test_completions.py +158 -0
- package/tests/test_component_manager.py +398 -0
- package/tests/test_config_reload.py +386 -0
- package/tests/test_config_validate.py +529 -0
- package/tests/test_consciousness_e2e.py +296 -0
- package/tests/test_consciousness_loop.py +1289 -0
- package/tests/test_context_loader.py +310 -0
- package/tests/test_conversation_api.py +306 -0
- package/tests/test_conversation_manager.py +381 -0
- package/tests/test_conversation_store.py +391 -0
- package/tests/test_conversation_summarizer.py +302 -0
- package/tests/test_cross_package.py +791 -0
- package/tests/test_crush_shim.py +519 -0
- package/tests/test_daemon.py +781 -0
- package/tests/test_daemon_shutdown.py +309 -0
- package/tests/test_dashboard.py +454 -0
- package/tests/test_discovery.py +200 -6
- package/tests/test_docker_provider.py +966 -0
- package/tests/test_doctor.py +257 -0
- package/tests/test_doctor_fix.py +351 -0
- package/tests/test_e2e_automated.py +292 -0
- package/tests/test_error_queue.py +404 -0
- package/tests/test_export.py +441 -0
- package/tests/test_fallback_tracker.py +219 -0
- package/tests/test_file_transfer.py +397 -0
- package/tests/test_fuse_mount.py +832 -0
- package/tests/test_health_loop.py +422 -0
- package/tests/test_heartbeat.py +354 -0
- package/tests/test_housekeeping.py +195 -0
- package/tests/test_identity_capauth.py +307 -0
- package/tests/test_identity_pillar.py +117 -0
- package/tests/test_install_wizard.py +68 -0
- package/tests/test_integration.py +325 -0
- package/tests/test_kms.py +495 -0
- package/tests/test_llm_providers.py +265 -0
- package/tests/test_local_provider.py +591 -0
- package/tests/test_log_config.py +199 -0
- package/tests/test_logs_cmd.py +287 -0
- package/tests/test_mcp_server.py +1909 -0
- package/tests/test_memory_adapter.py +339 -0
- package/tests/test_memory_curator.py +218 -0
- package/tests/test_memory_engine.py +6 -0
- package/tests/test_memory_fortress.py +571 -0
- package/tests/test_memory_pillar.py +119 -0
- package/tests/test_memory_promoter.py +445 -0
- package/tests/test_memory_verifier.py +420 -0
- package/tests/test_message_crypto.py +187 -0
- package/tests/test_metrics.py +632 -0
- package/tests/test_migrate_memories.py +464 -0
- package/tests/test_model_router.py +546 -0
- package/tests/test_mood.py +394 -0
- package/tests/test_multi_agent.py +269 -0
- package/tests/test_notifications.py +270 -0
- package/tests/test_onboard.py +500 -0
- package/tests/test_peer_directory.py +395 -0
- package/tests/test_peers.py +248 -0
- package/tests/test_pillars.py +87 -9
- package/tests/test_preflight.py +484 -0
- package/tests/test_prompt_adapter.py +331 -0
- package/tests/test_proxmox_provider.py +571 -0
- package/tests/test_pubsub.py +377 -0
- package/tests/test_rate_limiter.py +121 -0
- package/tests/test_registry_client.py +129 -0
- package/tests/test_response_cache.py +312 -0
- package/tests/test_response_scorer.py +294 -0
- package/tests/test_runtime.py +59 -0
- package/tests/test_scheduled_tasks.py +451 -0
- package/tests/test_security.py +250 -0
- package/tests/test_security_pillar.py +213 -0
- package/tests/test_self_healing.py +171 -0
- package/tests/test_session_capture.py +200 -0
- package/tests/test_session_recorder.py +360 -0
- package/tests/test_session_skills.py +235 -0
- package/tests/test_shell.py +210 -0
- package/tests/test_snapshots.py +549 -0
- package/tests/test_soul.py +984 -0
- package/tests/test_soul_swap.py +406 -0
- package/tests/test_spawner.py +211 -0
- package/tests/test_state_diff.py +173 -0
- package/tests/test_summary.py +135 -0
- package/tests/test_sync.py +315 -5
- package/tests/test_sync_backends.py +560 -0
- package/tests/test_sync_engine.py +482 -0
- package/tests/test_sync_pillar.py +344 -0
- package/tests/test_sync_pipeline.py +364 -0
- package/tests/test_sync_vault.py +581 -0
- package/tests/test_syncthing_setup.py +168 -22
- package/tests/test_systemd.py +323 -0
- package/tests/test_team_comms.py +408 -0
- package/tests/test_team_engine.py +397 -0
- package/tests/test_testrunner.py +238 -0
- package/tests/test_trust_calibration.py +204 -0
- package/tests/test_trust_graph.py +207 -0
- package/tests/test_trust_pillar.py +291 -0
- package/tests/test_trustee_cli.py +427 -0
- package/tests/test_trustee_cli_integration.py +325 -0
- package/tests/test_trustee_monitor.py +394 -0
- package/tests/test_trustee_ops.py +355 -0
- package/tests/test_unified_search.py +363 -0
- package/tests/test_uninstall_wizard.py +193 -0
- package/tests/test_usage.py +333 -0
- package/tests/test_version_cmd.py +355 -0
- package/tests/test_warmth_anchor.py +162 -0
- package/tests/test_whoami.py +245 -0
- package/tests/test_ws.py +311 -0
- package/.cursorrules +0 -33
- package/src/skcapstone/cli.py +0 -1441
|
@@ -0,0 +1,1193 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Local Provider — runs agent teams as local processes backed by crush/SKSkills sessions.
|
|
3
|
+
|
|
4
|
+
Each agent is spawned as a real ``crush`` subprocess that receives its full identity
|
|
5
|
+
via a generated session config: soul blueprint, skills list, model tier, and
|
|
6
|
+
coordination context. If the crush binary is not present the provider falls back to
|
|
7
|
+
a lightweight Python stub so development can proceed without the full runtime
|
|
8
|
+
installed.
|
|
9
|
+
|
|
10
|
+
Session lifecycle
|
|
11
|
+
-----------------
|
|
12
|
+
1. ``provision()`` — create work dir, write ``config.json`` + ``session.json``
|
|
13
|
+
2. ``configure()`` — resolve soul blueprint & skill paths; write ``crush.json``
|
|
14
|
+
3. ``start()`` — spawn ``crush run --session session.json`` as a daemon process
|
|
15
|
+
4. ``health_check()``— read session state file; fall back to PID liveness check
|
|
16
|
+
5. ``stop()`` — SIGTERM → wait → SIGKILL; write tombstone
|
|
17
|
+
6. ``destroy()`` — stop + remove work dir
|
|
18
|
+
|
|
19
|
+
Per hosted-agents best practice: session-isolated state, filesystem memory,
|
|
20
|
+
health checks via session state file then PID monitoring.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import json
|
|
26
|
+
import logging
|
|
27
|
+
import os
|
|
28
|
+
import signal
|
|
29
|
+
import subprocess
|
|
30
|
+
import time
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
from typing import Any, Dict, List, Optional
|
|
33
|
+
|
|
34
|
+
from ..blueprints.schema import AgentSpec, ProviderType
|
|
35
|
+
from ..team_engine import AgentStatus, ProviderBackend
|
|
36
|
+
|
|
37
|
+
logger = logging.getLogger(__name__)
|
|
38
|
+
|
|
39
|
+
# ---------------------------------------------------------------------------
|
|
40
|
+
# Constants
|
|
41
|
+
# ---------------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
_CRUSH_BINARY_NAMES: List[str] = ["crush", "claude"]
|
|
44
|
+
_SESSION_STATE_FILE = "session_state.json"
|
|
45
|
+
_PID_FILE = "agent.pid"
|
|
46
|
+
_SESSION_CONFIG_FILE = "session.json"
|
|
47
|
+
_CRUSH_CONFIG_FILE = "crush.json"
|
|
48
|
+
_STOP_TIMEOUT_SECONDS = 15
|
|
49
|
+
_STOP_KILL_TIMEOUT_SECONDS = 5
|
|
50
|
+
|
|
51
|
+
# Session state values written by the crush daemon
|
|
52
|
+
_STATE_RUNNING = "running"
|
|
53
|
+
_STATE_IDLE = "idle"
|
|
54
|
+
_STATE_ERROR = "error"
|
|
55
|
+
_STATE_STOPPED = "stopped"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# ---------------------------------------------------------------------------
|
|
59
|
+
# Helper utilities
|
|
60
|
+
# ---------------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _find_crush_binary() -> Optional[str]:
|
|
64
|
+
"""Locate the crush binary on PATH, falling back to claude.
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
Absolute path to the binary, or None if not found.
|
|
68
|
+
"""
|
|
69
|
+
import shutil
|
|
70
|
+
|
|
71
|
+
for name in _CRUSH_BINARY_NAMES:
|
|
72
|
+
path = shutil.which(name)
|
|
73
|
+
if path:
|
|
74
|
+
return path
|
|
75
|
+
return None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _is_claude_binary(binary: str) -> bool:
|
|
79
|
+
"""Check whether the resolved binary is the claude CLI rather than crush.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
binary: Absolute path to the binary.
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
True if the binary basename is ``claude``.
|
|
86
|
+
"""
|
|
87
|
+
return Path(binary).name == "claude"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _resolve_soul_blueprint_path(
|
|
91
|
+
soul_blueprint: Optional[str],
|
|
92
|
+
work_dir: Path,
|
|
93
|
+
repo_root: Optional[Path] = None,
|
|
94
|
+
) -> Optional[str]:
|
|
95
|
+
"""Resolve a soul blueprint reference to an absolute path.
|
|
96
|
+
|
|
97
|
+
Checks (in order):
|
|
98
|
+
1. Absolute path as-is.
|
|
99
|
+
2. Relative to repo soul-blueprints/blueprints/ directory.
|
|
100
|
+
3. Relative to the agent's work_dir.
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
soul_blueprint: Blueprint slug or path from AgentSpec.
|
|
104
|
+
work_dir: Agent working directory.
|
|
105
|
+
repo_root: Optional repo root for resolving workspace-relative paths.
|
|
106
|
+
|
|
107
|
+
Returns:
|
|
108
|
+
Resolved absolute path string, or the original value if unresolvable.
|
|
109
|
+
"""
|
|
110
|
+
if not soul_blueprint:
|
|
111
|
+
return None
|
|
112
|
+
|
|
113
|
+
candidate = Path(soul_blueprint)
|
|
114
|
+
if candidate.is_absolute() and candidate.exists():
|
|
115
|
+
return str(candidate)
|
|
116
|
+
|
|
117
|
+
if repo_root:
|
|
118
|
+
# Try soul-blueprints/blueprints/<slug>/<LUMINA.md> style
|
|
119
|
+
blueprint_dir = repo_root / "soul-blueprints" / "blueprints" / soul_blueprint
|
|
120
|
+
if blueprint_dir.exists():
|
|
121
|
+
return str(blueprint_dir)
|
|
122
|
+
# Try soul-blueprints/<value> directly
|
|
123
|
+
direct = repo_root / "soul-blueprints" / soul_blueprint
|
|
124
|
+
if direct.exists():
|
|
125
|
+
return str(direct)
|
|
126
|
+
# Try relative to repo root
|
|
127
|
+
relative = repo_root / soul_blueprint
|
|
128
|
+
if relative.exists():
|
|
129
|
+
return str(relative)
|
|
130
|
+
|
|
131
|
+
# Relative to work dir
|
|
132
|
+
relative_to_wd = work_dir / soul_blueprint
|
|
133
|
+
if relative_to_wd.exists():
|
|
134
|
+
return str(relative_to_wd)
|
|
135
|
+
|
|
136
|
+
# Return original value; crush may resolve it itself
|
|
137
|
+
return soul_blueprint
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _resolve_skill_paths(
|
|
141
|
+
skills: List[str],
|
|
142
|
+
repo_root: Optional[Path] = None,
|
|
143
|
+
agent: str = "global",
|
|
144
|
+
) -> List[str]:
|
|
145
|
+
"""Resolve skill names to absolute paths where possible.
|
|
146
|
+
|
|
147
|
+
Uses the session_skills bridge to check the SKSkills registry.
|
|
148
|
+
|
|
149
|
+
Args:
|
|
150
|
+
skills: List of skill names or paths from AgentSpec.
|
|
151
|
+
repo_root: Optional repo root for resolving workspace-relative paths.
|
|
152
|
+
agent: Agent namespace for SKSkills per-agent lookup.
|
|
153
|
+
|
|
154
|
+
Returns:
|
|
155
|
+
List of resolved paths (unresolvable names kept as-is).
|
|
156
|
+
"""
|
|
157
|
+
try:
|
|
158
|
+
from ..session_skills import resolve_skill_paths_with_skskills
|
|
159
|
+
return resolve_skill_paths_with_skskills(skills, agent=agent, repo_root=repo_root)
|
|
160
|
+
except ImportError:
|
|
161
|
+
pass
|
|
162
|
+
|
|
163
|
+
# Fallback: basic resolution without skskills installed
|
|
164
|
+
resolved: List[str] = []
|
|
165
|
+
for skill in skills:
|
|
166
|
+
path = Path(skill)
|
|
167
|
+
if path.is_absolute() and path.exists():
|
|
168
|
+
resolved.append(str(path))
|
|
169
|
+
else:
|
|
170
|
+
resolved.append(skill)
|
|
171
|
+
|
|
172
|
+
return resolved
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
# ---------------------------------------------------------------------------
|
|
176
|
+
# Session config builders
|
|
177
|
+
# ---------------------------------------------------------------------------
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _resolve_model_via_router(
|
|
181
|
+
spec: AgentSpec,
|
|
182
|
+
description: str = "",
|
|
183
|
+
) -> str:
|
|
184
|
+
"""Resolve the concrete model name using the Model Router.
|
|
185
|
+
|
|
186
|
+
If the spec has an explicit model_name, that takes priority.
|
|
187
|
+
Otherwise, the router selects based on the model tier and task context.
|
|
188
|
+
|
|
189
|
+
Args:
|
|
190
|
+
spec: Agent specification containing model tier and optional model name.
|
|
191
|
+
description: Task/role description for tag-based routing.
|
|
192
|
+
|
|
193
|
+
Returns:
|
|
194
|
+
Concrete model name string.
|
|
195
|
+
"""
|
|
196
|
+
if spec.model_name:
|
|
197
|
+
return spec.model_name
|
|
198
|
+
|
|
199
|
+
try:
|
|
200
|
+
from ..model_router import ModelRouter, TaskSignal
|
|
201
|
+
|
|
202
|
+
router = ModelRouter()
|
|
203
|
+
signal = TaskSignal(
|
|
204
|
+
description=description or spec.description or f"{spec.role.value} agent",
|
|
205
|
+
tags=[spec.role.value, spec.model.value],
|
|
206
|
+
)
|
|
207
|
+
decision = router.route(signal)
|
|
208
|
+
logger.debug(
|
|
209
|
+
"Model router: tier=%s model=%s reason=%s",
|
|
210
|
+
decision.tier.value, decision.model_name, decision.reasoning,
|
|
211
|
+
)
|
|
212
|
+
return decision.model_name
|
|
213
|
+
except ImportError:
|
|
214
|
+
return spec.model.value
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _build_session_config(
|
|
218
|
+
agent_name: str,
|
|
219
|
+
team_name: str,
|
|
220
|
+
spec: AgentSpec,
|
|
221
|
+
work_dir: Path,
|
|
222
|
+
repo_root: Optional[Path] = None,
|
|
223
|
+
) -> Dict[str, Any]:
|
|
224
|
+
"""Build the session.json payload for a crush agent session.
|
|
225
|
+
|
|
226
|
+
Uses the Model Router to resolve the model tier to a concrete model name
|
|
227
|
+
when no explicit model_name is set in the agent spec.
|
|
228
|
+
|
|
229
|
+
Args:
|
|
230
|
+
agent_name: Unique agent instance name.
|
|
231
|
+
team_name: Parent team name.
|
|
232
|
+
spec: Agent specification containing soul, skills, model, role.
|
|
233
|
+
work_dir: Agent working directory (used for memory/scratch paths).
|
|
234
|
+
repo_root: Optional repo root for path resolution.
|
|
235
|
+
|
|
236
|
+
Returns:
|
|
237
|
+
Dictionary ready to be serialised as session.json.
|
|
238
|
+
"""
|
|
239
|
+
soul_path = _resolve_soul_blueprint_path(
|
|
240
|
+
spec.soul_blueprint, work_dir, repo_root
|
|
241
|
+
)
|
|
242
|
+
skill_paths = _resolve_skill_paths(spec.skills, repo_root, agent=agent_name)
|
|
243
|
+
model = _resolve_model_via_router(spec, f"{agent_name} in team {team_name}")
|
|
244
|
+
|
|
245
|
+
config: Dict[str, Any] = {
|
|
246
|
+
"agent_name": agent_name,
|
|
247
|
+
"team_name": team_name,
|
|
248
|
+
"role": spec.role.value,
|
|
249
|
+
"model": model,
|
|
250
|
+
"model_tier": spec.model.value,
|
|
251
|
+
"soul_blueprint": soul_path,
|
|
252
|
+
"skills": skill_paths,
|
|
253
|
+
"memory_dir": str(work_dir / "memory"),
|
|
254
|
+
"scratch_dir": str(work_dir / "scratch"),
|
|
255
|
+
"state_file": str(work_dir / _SESSION_STATE_FILE),
|
|
256
|
+
"env": spec.env,
|
|
257
|
+
}
|
|
258
|
+
return config
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _build_crush_config(
|
|
262
|
+
agent_name: str,
|
|
263
|
+
session_config: Dict[str, Any],
|
|
264
|
+
work_dir: Path,
|
|
265
|
+
) -> Dict[str, Any]:
|
|
266
|
+
"""Build the crush.json that the crush daemon reads on startup.
|
|
267
|
+
|
|
268
|
+
Mirrors the structure of the project-level crush.json but scoped to a
|
|
269
|
+
single agent session.
|
|
270
|
+
|
|
271
|
+
Args:
|
|
272
|
+
agent_name: Agent instance name.
|
|
273
|
+
session_config: Output of _build_session_config().
|
|
274
|
+
work_dir: Agent working directory.
|
|
275
|
+
|
|
276
|
+
Returns:
|
|
277
|
+
Dictionary ready to be serialised as crush.json.
|
|
278
|
+
"""
|
|
279
|
+
crush_cfg: Dict[str, Any] = {
|
|
280
|
+
"$schema": "https://charm.land/crush.json",
|
|
281
|
+
"options": {
|
|
282
|
+
"initialize_as": session_config.get("soul_blueprint", "AGENTS.md"),
|
|
283
|
+
"context_paths": [
|
|
284
|
+
session_config.get("soul_blueprint"),
|
|
285
|
+
],
|
|
286
|
+
"skills_paths": [
|
|
287
|
+
str(work_dir / "skills"),
|
|
288
|
+
"~/.config/crush/skills",
|
|
289
|
+
"~/.skskills/installed",
|
|
290
|
+
],
|
|
291
|
+
"debug": False,
|
|
292
|
+
"disabled_tools": [],
|
|
293
|
+
},
|
|
294
|
+
"permissions": {
|
|
295
|
+
"allowed_tools": [
|
|
296
|
+
"view",
|
|
297
|
+
"ls",
|
|
298
|
+
"grep",
|
|
299
|
+
"edit",
|
|
300
|
+
"mcp_skcapstone_agent_status",
|
|
301
|
+
"mcp_skcapstone_memory_store",
|
|
302
|
+
"mcp_skcapstone_memory_recall",
|
|
303
|
+
"mcp_skcapstone_coord_status",
|
|
304
|
+
"mcp_skcapstone_coord_claim",
|
|
305
|
+
]
|
|
306
|
+
},
|
|
307
|
+
"session": {
|
|
308
|
+
"agent_name": agent_name,
|
|
309
|
+
"model": session_config.get("model", "fast"),
|
|
310
|
+
"role": session_config.get("role", "worker"),
|
|
311
|
+
"skills": session_config.get("skills", []),
|
|
312
|
+
"memory_dir": session_config.get("memory_dir"),
|
|
313
|
+
"state_file": session_config.get("state_file"),
|
|
314
|
+
},
|
|
315
|
+
}
|
|
316
|
+
# Remove None values from context_paths
|
|
317
|
+
crush_cfg["options"]["context_paths"] = [
|
|
318
|
+
p for p in crush_cfg["options"]["context_paths"] if p is not None
|
|
319
|
+
]
|
|
320
|
+
return crush_cfg
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
# ---------------------------------------------------------------------------
|
|
324
|
+
# Session state helpers
|
|
325
|
+
# ---------------------------------------------------------------------------
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _read_session_state(work_dir: Path) -> Optional[Dict[str, Any]]:
|
|
329
|
+
"""Read session state written by the crush daemon.
|
|
330
|
+
|
|
331
|
+
Args:
|
|
332
|
+
work_dir: Agent working directory.
|
|
333
|
+
|
|
334
|
+
Returns:
|
|
335
|
+
Parsed state dict, or None if missing/corrupt.
|
|
336
|
+
"""
|
|
337
|
+
state_file = work_dir / _SESSION_STATE_FILE
|
|
338
|
+
if not state_file.exists():
|
|
339
|
+
return None
|
|
340
|
+
try:
|
|
341
|
+
return json.loads(state_file.read_text(encoding="utf-8"))
|
|
342
|
+
except (json.JSONDecodeError, OSError):
|
|
343
|
+
return None
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _write_session_state(work_dir: Path, state: Dict[str, Any]) -> None:
|
|
347
|
+
"""Write session state to disk.
|
|
348
|
+
|
|
349
|
+
Args:
|
|
350
|
+
work_dir: Agent working directory.
|
|
351
|
+
state: State dictionary to persist.
|
|
352
|
+
"""
|
|
353
|
+
state_file = work_dir / _SESSION_STATE_FILE
|
|
354
|
+
state_file.write_text(json.dumps(state, indent=2), encoding="utf-8")
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _read_pid(work_dir: Path) -> Optional[int]:
|
|
358
|
+
"""Read the PID from the agent PID file.
|
|
359
|
+
|
|
360
|
+
Args:
|
|
361
|
+
work_dir: Agent working directory.
|
|
362
|
+
|
|
363
|
+
Returns:
|
|
364
|
+
PID integer or None.
|
|
365
|
+
"""
|
|
366
|
+
pid_file = work_dir / _PID_FILE
|
|
367
|
+
if not pid_file.exists():
|
|
368
|
+
return None
|
|
369
|
+
try:
|
|
370
|
+
return int(pid_file.read_text(encoding="utf-8").strip())
|
|
371
|
+
except (ValueError, OSError):
|
|
372
|
+
return None
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def _pid_is_alive(pid: int) -> bool:
|
|
376
|
+
"""Check whether a process is alive via signal 0.
|
|
377
|
+
|
|
378
|
+
Args:
|
|
379
|
+
pid: Process ID to check.
|
|
380
|
+
|
|
381
|
+
Returns:
|
|
382
|
+
True if the process exists and is accessible.
|
|
383
|
+
"""
|
|
384
|
+
try:
|
|
385
|
+
os.kill(pid, 0)
|
|
386
|
+
return True
|
|
387
|
+
except ProcessLookupError:
|
|
388
|
+
return False
|
|
389
|
+
except OSError:
|
|
390
|
+
# Permission denied means process exists but we can't signal it
|
|
391
|
+
return True
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
# ---------------------------------------------------------------------------
|
|
395
|
+
# Provider
|
|
396
|
+
# ---------------------------------------------------------------------------
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
class LocalProvider(ProviderBackend):
|
|
400
|
+
"""Deploy agents as local processes backed by crush/SKSkills sessions.
|
|
401
|
+
|
|
402
|
+
Each agent is given its own working directory containing:
|
|
403
|
+
- ``config.json`` — human-readable agent configuration
|
|
404
|
+
- ``session.json`` — crush session payload (soul, skills, model)
|
|
405
|
+
- ``crush.json`` — crush daemon config (written during configure())
|
|
406
|
+
- ``agent.pid`` — PID of the crush daemon process
|
|
407
|
+
- ``session_state.json`` — live state written by the crush daemon
|
|
408
|
+
- ``memory/`` — persistent memory directory
|
|
409
|
+
- ``scratch/`` — ephemeral scratch space
|
|
410
|
+
|
|
411
|
+
Args:
|
|
412
|
+
home: Agent home directory (default: ``~/.skcapstone``).
|
|
413
|
+
work_dir: Root directory for agent working dirs.
|
|
414
|
+
repo_root: Workspace root for resolving soul/skill paths.
|
|
415
|
+
crush_binary: Explicit path to crush binary (auto-detected from PATH
|
|
416
|
+
if not provided).
|
|
417
|
+
"""
|
|
418
|
+
|
|
419
|
+
provider_type = ProviderType.LOCAL
|
|
420
|
+
|
|
421
|
+
def __init__(
|
|
422
|
+
self,
|
|
423
|
+
home: Optional[Path] = None,
|
|
424
|
+
work_dir: Optional[Path] = None,
|
|
425
|
+
repo_root: Optional[Path] = None,
|
|
426
|
+
crush_binary: Optional[str] = None,
|
|
427
|
+
) -> None:
|
|
428
|
+
self._home = (home or Path("~/.skcapstone")).expanduser()
|
|
429
|
+
self._work_dir = work_dir or (self._home / "agents" / "local")
|
|
430
|
+
self._work_dir.mkdir(parents=True, exist_ok=True)
|
|
431
|
+
self._repo_root = repo_root
|
|
432
|
+
self._crush_binary = crush_binary # None = auto-detect at start time
|
|
433
|
+
|
|
434
|
+
# ------------------------------------------------------------------
|
|
435
|
+
# provision
|
|
436
|
+
# ------------------------------------------------------------------
|
|
437
|
+
|
|
438
|
+
def provision(
|
|
439
|
+
self,
|
|
440
|
+
agent_name: str,
|
|
441
|
+
spec: AgentSpec,
|
|
442
|
+
team_name: str,
|
|
443
|
+
) -> Dict[str, Any]:
|
|
444
|
+
"""Create the agent working directory and write session config.
|
|
445
|
+
|
|
446
|
+
Args:
|
|
447
|
+
agent_name: Unique agent instance name.
|
|
448
|
+
spec: Agent specification.
|
|
449
|
+
team_name: Parent team name.
|
|
450
|
+
|
|
451
|
+
Returns:
|
|
452
|
+
Dict with ``work_dir``, ``host``, and session configuration fields.
|
|
453
|
+
"""
|
|
454
|
+
agent_dir = self._work_dir / agent_name
|
|
455
|
+
agent_dir.mkdir(parents=True, exist_ok=True)
|
|
456
|
+
(agent_dir / "memory").mkdir(exist_ok=True)
|
|
457
|
+
(agent_dir / "scratch").mkdir(exist_ok=True)
|
|
458
|
+
(agent_dir / "skills").mkdir(exist_ok=True)
|
|
459
|
+
|
|
460
|
+
session_config = _build_session_config(
|
|
461
|
+
agent_name=agent_name,
|
|
462
|
+
team_name=team_name,
|
|
463
|
+
spec=spec,
|
|
464
|
+
work_dir=agent_dir,
|
|
465
|
+
repo_root=self._repo_root,
|
|
466
|
+
)
|
|
467
|
+
|
|
468
|
+
# Human-readable summary
|
|
469
|
+
(agent_dir / "config.json").write_text(
|
|
470
|
+
json.dumps(session_config, indent=2), encoding="utf-8"
|
|
471
|
+
)
|
|
472
|
+
|
|
473
|
+
# Crush session payload
|
|
474
|
+
(agent_dir / _SESSION_CONFIG_FILE).write_text(
|
|
475
|
+
json.dumps(session_config, indent=2), encoding="utf-8"
|
|
476
|
+
)
|
|
477
|
+
|
|
478
|
+
# Wire SKSkills into the session
|
|
479
|
+
skill_result = self._prepare_skskills(
|
|
480
|
+
agent_name, session_config.get("skills", []), agent_dir
|
|
481
|
+
)
|
|
482
|
+
if skill_result and skill_result.get("skills_loaded", 0) > 0:
|
|
483
|
+
try:
|
|
484
|
+
from ..session_skills import enrich_session_config
|
|
485
|
+
enrich_session_config(session_config, skill_result)
|
|
486
|
+
except ImportError:
|
|
487
|
+
pass
|
|
488
|
+
|
|
489
|
+
logger.info(
|
|
490
|
+
"Provisioned agent %s (role=%s model=%s soul=%s skills=%s skskills=%d)",
|
|
491
|
+
agent_name,
|
|
492
|
+
spec.role.value,
|
|
493
|
+
session_config["model"],
|
|
494
|
+
session_config.get("soul_blueprint"),
|
|
495
|
+
session_config.get("skills"),
|
|
496
|
+
skill_result.get("skills_loaded", 0) if skill_result else 0,
|
|
497
|
+
)
|
|
498
|
+
|
|
499
|
+
return {
|
|
500
|
+
"host": "localhost",
|
|
501
|
+
"work_dir": str(agent_dir),
|
|
502
|
+
"session_config": session_config,
|
|
503
|
+
"skill_result": skill_result,
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
def _prepare_skskills(
|
|
507
|
+
self,
|
|
508
|
+
agent_name: str,
|
|
509
|
+
skills: List[str],
|
|
510
|
+
work_dir: Path,
|
|
511
|
+
) -> Optional[Dict[str, Any]]:
|
|
512
|
+
"""Prepare SKSkills for an agent session.
|
|
513
|
+
|
|
514
|
+
Args:
|
|
515
|
+
agent_name: Agent instance name.
|
|
516
|
+
skills: Resolved skill paths.
|
|
517
|
+
work_dir: Agent working directory.
|
|
518
|
+
|
|
519
|
+
Returns:
|
|
520
|
+
Skill preparation result dict, or None if skskills unavailable.
|
|
521
|
+
"""
|
|
522
|
+
try:
|
|
523
|
+
from ..session_skills import prepare_session_skills
|
|
524
|
+
return prepare_session_skills(agent_name, skills, work_dir)
|
|
525
|
+
except ImportError:
|
|
526
|
+
return None
|
|
527
|
+
|
|
528
|
+
# ------------------------------------------------------------------
|
|
529
|
+
# configure
|
|
530
|
+
# ------------------------------------------------------------------
|
|
531
|
+
|
|
532
|
+
def configure(
|
|
533
|
+
self,
|
|
534
|
+
agent_name: str,
|
|
535
|
+
spec: AgentSpec,
|
|
536
|
+
provision_result: Dict[str, Any],
|
|
537
|
+
) -> bool:
|
|
538
|
+
"""Write the crush.json daemon config into the agent's work directory.
|
|
539
|
+
|
|
540
|
+
Args:
|
|
541
|
+
agent_name: Agent instance name.
|
|
542
|
+
spec: Agent specification.
|
|
543
|
+
provision_result: Output from provision().
|
|
544
|
+
|
|
545
|
+
Returns:
|
|
546
|
+
True if configuration succeeded, False on error.
|
|
547
|
+
"""
|
|
548
|
+
work_dir_str = provision_result.get("work_dir", "")
|
|
549
|
+
if not work_dir_str:
|
|
550
|
+
logger.error("configure: missing work_dir for %s", agent_name)
|
|
551
|
+
return False
|
|
552
|
+
|
|
553
|
+
work_dir = Path(work_dir_str)
|
|
554
|
+
session_config = provision_result.get("session_config", {})
|
|
555
|
+
|
|
556
|
+
crush_cfg = _build_crush_config(agent_name, session_config, work_dir)
|
|
557
|
+
|
|
558
|
+
# Enrich crush config with SKSkills MCP server entry
|
|
559
|
+
skill_result = provision_result.get("skill_result")
|
|
560
|
+
if skill_result:
|
|
561
|
+
try:
|
|
562
|
+
from ..session_skills import enrich_crush_config
|
|
563
|
+
enrich_crush_config(crush_cfg, skill_result)
|
|
564
|
+
except ImportError:
|
|
565
|
+
pass
|
|
566
|
+
|
|
567
|
+
try:
|
|
568
|
+
(work_dir / _CRUSH_CONFIG_FILE).write_text(
|
|
569
|
+
json.dumps(crush_cfg, indent=2), encoding="utf-8"
|
|
570
|
+
)
|
|
571
|
+
except OSError as exc:
|
|
572
|
+
logger.error(
|
|
573
|
+
"configure: failed to write crush.json for %s: %s", agent_name, exc
|
|
574
|
+
)
|
|
575
|
+
return False
|
|
576
|
+
|
|
577
|
+
logger.debug("Configured crush session for %s at %s", agent_name, work_dir)
|
|
578
|
+
return True
|
|
579
|
+
|
|
580
|
+
# ------------------------------------------------------------------
|
|
581
|
+
# start
|
|
582
|
+
# ------------------------------------------------------------------
|
|
583
|
+
|
|
584
|
+
def start(
|
|
585
|
+
self,
|
|
586
|
+
agent_name: str,
|
|
587
|
+
provision_result: Dict[str, Any],
|
|
588
|
+
) -> bool:
|
|
589
|
+
"""Spawn a crush session for the agent.
|
|
590
|
+
|
|
591
|
+
Attempts to launch the crush binary found on PATH. If crush is not
|
|
592
|
+
installed, falls back to a lightweight Python stub process that writes
|
|
593
|
+
the required session state so the rest of the engine can proceed.
|
|
594
|
+
|
|
595
|
+
The session receives:
|
|
596
|
+
- ``--session session.json`` — full agent identity config
|
|
597
|
+
- ``--config crush.json`` — crush daemon config
|
|
598
|
+
- ``--headless`` — non-interactive daemon mode
|
|
599
|
+
|
|
600
|
+
Environment variables passed to the process:
|
|
601
|
+
- ``AGENT_NAME``, ``TEAM_NAME``, ``SOUL_BLUEPRINT``
|
|
602
|
+
- ``AGENT_MODEL``, ``AGENT_ROLE``, ``AGENT_SKILLS``
|
|
603
|
+
- ``SKCAPSTONE_HOME``, ``AGENT_MEMORY_DIR``
|
|
604
|
+
- Any extra env vars from ``AgentSpec.env``
|
|
605
|
+
|
|
606
|
+
Args:
|
|
607
|
+
agent_name: Agent instance name.
|
|
608
|
+
provision_result: Output from provision().
|
|
609
|
+
|
|
610
|
+
Returns:
|
|
611
|
+
True if the process started successfully.
|
|
612
|
+
"""
|
|
613
|
+
work_dir_str = provision_result.get("work_dir", "")
|
|
614
|
+
if not work_dir_str:
|
|
615
|
+
logger.error("start: missing work_dir for %s", agent_name)
|
|
616
|
+
return False
|
|
617
|
+
|
|
618
|
+
work_dir = Path(work_dir_str)
|
|
619
|
+
session_config = provision_result.get("session_config", {})
|
|
620
|
+
|
|
621
|
+
env = self._build_process_env(session_config)
|
|
622
|
+
binary = self._crush_binary or _find_crush_binary()
|
|
623
|
+
|
|
624
|
+
if binary:
|
|
625
|
+
if _is_claude_binary(binary):
|
|
626
|
+
return self._start_claude_session(
|
|
627
|
+
agent_name, work_dir, binary, env, provision_result
|
|
628
|
+
)
|
|
629
|
+
return self._start_crush_session(
|
|
630
|
+
agent_name, work_dir, binary, env, provision_result
|
|
631
|
+
)
|
|
632
|
+
else:
|
|
633
|
+
logger.warning(
|
|
634
|
+
"crush binary not found on PATH; using stub for %s", agent_name
|
|
635
|
+
)
|
|
636
|
+
return self._start_stub_session(
|
|
637
|
+
agent_name, work_dir, env, provision_result
|
|
638
|
+
)
|
|
639
|
+
|
|
640
|
+
def _build_process_env(self, session_config: Dict[str, Any]) -> Dict[str, str]:
|
|
641
|
+
"""Build the environment dict for the crush/stub subprocess.
|
|
642
|
+
|
|
643
|
+
Args:
|
|
644
|
+
session_config: Agent session configuration dict.
|
|
645
|
+
|
|
646
|
+
Returns:
|
|
647
|
+
Environment variable dict (inherits current process env).
|
|
648
|
+
"""
|
|
649
|
+
env = os.environ.copy()
|
|
650
|
+
env.update({
|
|
651
|
+
"AGENT_NAME": session_config.get("agent_name", ""),
|
|
652
|
+
"TEAM_NAME": session_config.get("team_name", ""),
|
|
653
|
+
"SOUL_BLUEPRINT": session_config.get("soul_blueprint") or "",
|
|
654
|
+
"AGENT_MODEL": session_config.get("model", ""),
|
|
655
|
+
"AGENT_MODEL_TIER": session_config.get("model_tier", ""),
|
|
656
|
+
"AGENT_ROLE": session_config.get("role", ""),
|
|
657
|
+
"AGENT_SKILLS": json.dumps(session_config.get("skills", [])),
|
|
658
|
+
"SKCAPSTONE_HOME": str(self._home),
|
|
659
|
+
"AGENT_MEMORY_DIR": session_config.get("memory_dir", ""),
|
|
660
|
+
"AGENT_SCRATCH_DIR": session_config.get("scratch_dir", ""),
|
|
661
|
+
"AGENT_STATE_FILE": session_config.get("state_file", ""),
|
|
662
|
+
})
|
|
663
|
+
# Merge spec-level env overrides
|
|
664
|
+
extra_env = session_config.get("env", {}) or {}
|
|
665
|
+
env.update({k: str(v) for k, v in extra_env.items()})
|
|
666
|
+
return env
|
|
667
|
+
|
|
668
|
+
def _start_crush_session(
|
|
669
|
+
self,
|
|
670
|
+
agent_name: str,
|
|
671
|
+
work_dir: Path,
|
|
672
|
+
binary: str,
|
|
673
|
+
env: Dict[str, str],
|
|
674
|
+
provision_result: Dict[str, Any],
|
|
675
|
+
) -> bool:
|
|
676
|
+
"""Launch a real crush daemon subprocess.
|
|
677
|
+
|
|
678
|
+
Args:
|
|
679
|
+
agent_name: Agent instance name.
|
|
680
|
+
work_dir: Agent working directory.
|
|
681
|
+
binary: Absolute path to crush binary.
|
|
682
|
+
env: Environment variables for the subprocess.
|
|
683
|
+
provision_result: Mutated in-place with the spawned PID.
|
|
684
|
+
|
|
685
|
+
Returns:
|
|
686
|
+
True if the process started without error.
|
|
687
|
+
"""
|
|
688
|
+
cmd = [
|
|
689
|
+
binary,
|
|
690
|
+
"run",
|
|
691
|
+
"--session", str(work_dir / _SESSION_CONFIG_FILE),
|
|
692
|
+
"--config", str(work_dir / _CRUSH_CONFIG_FILE),
|
|
693
|
+
"--headless",
|
|
694
|
+
"--state-file", str(work_dir / _SESSION_STATE_FILE),
|
|
695
|
+
]
|
|
696
|
+
log_file = work_dir / "agent.log"
|
|
697
|
+
|
|
698
|
+
try:
|
|
699
|
+
with open(log_file, "ab") as log_fh:
|
|
700
|
+
proc = subprocess.Popen(
|
|
701
|
+
cmd,
|
|
702
|
+
cwd=str(work_dir),
|
|
703
|
+
env=env,
|
|
704
|
+
stdout=log_fh,
|
|
705
|
+
stderr=log_fh,
|
|
706
|
+
start_new_session=True, # detach from parent's process group
|
|
707
|
+
)
|
|
708
|
+
except OSError as exc:
|
|
709
|
+
logger.error(
|
|
710
|
+
"start: failed to launch crush for %s: %s", agent_name, exc
|
|
711
|
+
)
|
|
712
|
+
return False
|
|
713
|
+
|
|
714
|
+
pid = proc.pid
|
|
715
|
+
(work_dir / _PID_FILE).write_text(str(pid), encoding="utf-8")
|
|
716
|
+
provision_result["pid"] = pid
|
|
717
|
+
|
|
718
|
+
_write_session_state(work_dir, {
|
|
719
|
+
"status": _STATE_RUNNING,
|
|
720
|
+
"pid": pid,
|
|
721
|
+
"agent_name": agent_name,
|
|
722
|
+
"started_at": _now_iso(),
|
|
723
|
+
"binary": binary,
|
|
724
|
+
})
|
|
725
|
+
|
|
726
|
+
logger.info(
|
|
727
|
+
"Started crush session for %s (pid=%d binary=%s)",
|
|
728
|
+
agent_name, pid, binary,
|
|
729
|
+
)
|
|
730
|
+
return True
|
|
731
|
+
|
|
732
|
+
def _start_claude_session(
|
|
733
|
+
self,
|
|
734
|
+
agent_name: str,
|
|
735
|
+
work_dir: Path,
|
|
736
|
+
binary: str,
|
|
737
|
+
env: Dict[str, str],
|
|
738
|
+
provision_result: Dict[str, Any],
|
|
739
|
+
) -> bool:
|
|
740
|
+
"""Launch a claude CLI session as the agent runtime.
|
|
741
|
+
|
|
742
|
+
Constructs a ``claude -p`` invocation that receives the agent's full
|
|
743
|
+
identity (soul blueprint, model, skills) and streams JSON output.
|
|
744
|
+
An MCP config temp file is written from the crush.json mcpServers
|
|
745
|
+
section when available.
|
|
746
|
+
|
|
747
|
+
Args:
|
|
748
|
+
agent_name: Agent instance name.
|
|
749
|
+
work_dir: Agent working directory.
|
|
750
|
+
binary: Absolute path to the ``claude`` CLI binary.
|
|
751
|
+
env: Environment variables for the subprocess.
|
|
752
|
+
provision_result: Mutated in-place with the spawned PID.
|
|
753
|
+
|
|
754
|
+
Returns:
|
|
755
|
+
True if the process started without error.
|
|
756
|
+
"""
|
|
757
|
+
import hashlib
|
|
758
|
+
import uuid
|
|
759
|
+
|
|
760
|
+
session_config = provision_result.get("session_config", {})
|
|
761
|
+
model = session_config.get("model", "fast")
|
|
762
|
+
|
|
763
|
+
# Build system prompt from soul blueprint + agent context
|
|
764
|
+
soul_path = session_config.get("soul_blueprint")
|
|
765
|
+
system_prompt_parts: List[str] = []
|
|
766
|
+
if soul_path:
|
|
767
|
+
soul_file = Path(soul_path)
|
|
768
|
+
if soul_file.is_file():
|
|
769
|
+
try:
|
|
770
|
+
system_prompt_parts.append(
|
|
771
|
+
soul_file.read_text(encoding="utf-8")
|
|
772
|
+
)
|
|
773
|
+
except OSError:
|
|
774
|
+
system_prompt_parts.append(f"Soul blueprint: {soul_path}")
|
|
775
|
+
elif soul_file.is_dir():
|
|
776
|
+
# Look for a markdown file inside the soul blueprint directory
|
|
777
|
+
for ext in ("*.md", "*.txt", "*.yaml"):
|
|
778
|
+
for f in sorted(soul_file.glob(ext)):
|
|
779
|
+
try:
|
|
780
|
+
system_prompt_parts.append(
|
|
781
|
+
f.read_text(encoding="utf-8")
|
|
782
|
+
)
|
|
783
|
+
except OSError:
|
|
784
|
+
pass
|
|
785
|
+
if not system_prompt_parts:
|
|
786
|
+
system_prompt_parts.append(f"Soul blueprint: {soul_path}")
|
|
787
|
+
else:
|
|
788
|
+
system_prompt_parts.append(f"Soul blueprint: {soul_path}")
|
|
789
|
+
|
|
790
|
+
system_prompt_parts.append(
|
|
791
|
+
f"\nAgent: {agent_name}\n"
|
|
792
|
+
f"Role: {session_config.get('role', 'worker')}\n"
|
|
793
|
+
f"Team: {session_config.get('team_name', '')}\n"
|
|
794
|
+
f"Skills: {json.dumps(session_config.get('skills', []))}\n"
|
|
795
|
+
)
|
|
796
|
+
system_prompt = "\n".join(system_prompt_parts)
|
|
797
|
+
|
|
798
|
+
# Deterministic session ID from agent name
|
|
799
|
+
session_id = str(
|
|
800
|
+
uuid.UUID(
|
|
801
|
+
hashlib.md5(agent_name.encode()).hexdigest() # noqa: S324
|
|
802
|
+
)
|
|
803
|
+
)
|
|
804
|
+
|
|
805
|
+
cmd: List[str] = [
|
|
806
|
+
binary,
|
|
807
|
+
"-p",
|
|
808
|
+
"--model", model,
|
|
809
|
+
"--system-prompt", system_prompt,
|
|
810
|
+
"--output-format", "stream-json",
|
|
811
|
+
"--session-id", session_id,
|
|
812
|
+
"--dangerously-skip-permissions",
|
|
813
|
+
]
|
|
814
|
+
|
|
815
|
+
# Write MCP config from crush.json mcpServers if present
|
|
816
|
+
crush_config_path = work_dir / _CRUSH_CONFIG_FILE
|
|
817
|
+
if crush_config_path.exists():
|
|
818
|
+
try:
|
|
819
|
+
crush_data = json.loads(
|
|
820
|
+
crush_config_path.read_text(encoding="utf-8")
|
|
821
|
+
)
|
|
822
|
+
mcp_servers = crush_data.get("mcpServers")
|
|
823
|
+
if mcp_servers:
|
|
824
|
+
mcp_config = {"mcpServers": mcp_servers}
|
|
825
|
+
mcp_config_file = work_dir / "mcp_config.json"
|
|
826
|
+
mcp_config_file.write_text(
|
|
827
|
+
json.dumps(mcp_config, indent=2), encoding="utf-8"
|
|
828
|
+
)
|
|
829
|
+
cmd.extend(["--mcp-config", str(mcp_config_file)])
|
|
830
|
+
except (json.JSONDecodeError, OSError):
|
|
831
|
+
pass
|
|
832
|
+
|
|
833
|
+
# Initial prompt
|
|
834
|
+
initial_prompt = (
|
|
835
|
+
f"You are agent '{agent_name}'. "
|
|
836
|
+
f"Check your inbox at ~/.skcapstone/comms/"
|
|
837
|
+
f"{session_config.get('team_name', 'default')}/{agent_name}/inbox/ "
|
|
838
|
+
f"for tasks. Process any pending work and report results to your outbox."
|
|
839
|
+
)
|
|
840
|
+
cmd.append(initial_prompt)
|
|
841
|
+
|
|
842
|
+
log_file = work_dir / "agent.log"
|
|
843
|
+
|
|
844
|
+
try:
|
|
845
|
+
with open(log_file, "ab") as log_fh:
|
|
846
|
+
proc = subprocess.Popen(
|
|
847
|
+
cmd,
|
|
848
|
+
cwd=str(work_dir),
|
|
849
|
+
env=env,
|
|
850
|
+
stdout=log_fh,
|
|
851
|
+
stderr=log_fh,
|
|
852
|
+
start_new_session=True,
|
|
853
|
+
)
|
|
854
|
+
except OSError as exc:
|
|
855
|
+
logger.error(
|
|
856
|
+
"start: failed to launch claude session for %s: %s",
|
|
857
|
+
agent_name, exc,
|
|
858
|
+
)
|
|
859
|
+
return False
|
|
860
|
+
|
|
861
|
+
pid = proc.pid
|
|
862
|
+
(work_dir / _PID_FILE).write_text(str(pid), encoding="utf-8")
|
|
863
|
+
provision_result["pid"] = pid
|
|
864
|
+
|
|
865
|
+
_write_session_state(work_dir, {
|
|
866
|
+
"status": _STATE_RUNNING,
|
|
867
|
+
"pid": pid,
|
|
868
|
+
"agent_name": agent_name,
|
|
869
|
+
"started_at": _now_iso(),
|
|
870
|
+
"binary": binary,
|
|
871
|
+
"session_id": session_id,
|
|
872
|
+
"backend": "claude",
|
|
873
|
+
})
|
|
874
|
+
|
|
875
|
+
logger.info(
|
|
876
|
+
"Started claude session for %s (pid=%d session_id=%s)",
|
|
877
|
+
agent_name, pid, session_id,
|
|
878
|
+
)
|
|
879
|
+
return True
|
|
880
|
+
|
|
881
|
+
def _start_stub_session(
|
|
882
|
+
self,
|
|
883
|
+
agent_name: str,
|
|
884
|
+
work_dir: Path,
|
|
885
|
+
env: Dict[str, str],
|
|
886
|
+
provision_result: Dict[str, Any],
|
|
887
|
+
) -> bool:
|
|
888
|
+
"""Launch a Python stub process when crush is not available.
|
|
889
|
+
|
|
890
|
+
The stub writes a running state file and then sleeps until signalled,
|
|
891
|
+
giving the engine a real process to monitor without a full AI runtime.
|
|
892
|
+
|
|
893
|
+
Args:
|
|
894
|
+
agent_name: Agent instance name.
|
|
895
|
+
work_dir: Agent working directory.
|
|
896
|
+
env: Environment variables for the subprocess.
|
|
897
|
+
provision_result: Mutated in-place with the spawned PID.
|
|
898
|
+
|
|
899
|
+
Returns:
|
|
900
|
+
True if the stub started successfully.
|
|
901
|
+
"""
|
|
902
|
+
state_file = str(work_dir / _SESSION_STATE_FILE)
|
|
903
|
+
stub_script = _stub_script(agent_name, state_file)
|
|
904
|
+
|
|
905
|
+
try:
|
|
906
|
+
proc = subprocess.Popen(
|
|
907
|
+
[os.sys.executable, "-c", stub_script],
|
|
908
|
+
cwd=str(work_dir),
|
|
909
|
+
env=env,
|
|
910
|
+
stdout=subprocess.DEVNULL,
|
|
911
|
+
stderr=subprocess.DEVNULL,
|
|
912
|
+
start_new_session=True,
|
|
913
|
+
)
|
|
914
|
+
except OSError as exc:
|
|
915
|
+
logger.error(
|
|
916
|
+
"start: failed to launch stub for %s: %s", agent_name, exc
|
|
917
|
+
)
|
|
918
|
+
return False
|
|
919
|
+
|
|
920
|
+
pid = proc.pid
|
|
921
|
+
(work_dir / _PID_FILE).write_text(str(pid), encoding="utf-8")
|
|
922
|
+
provision_result["pid"] = pid
|
|
923
|
+
|
|
924
|
+
_write_session_state(work_dir, {
|
|
925
|
+
"status": _STATE_RUNNING,
|
|
926
|
+
"pid": pid,
|
|
927
|
+
"agent_name": agent_name,
|
|
928
|
+
"started_at": _now_iso(),
|
|
929
|
+
"binary": "python-stub",
|
|
930
|
+
})
|
|
931
|
+
|
|
932
|
+
logger.info(
|
|
933
|
+
"Started stub session for %s (pid=%d)", agent_name, pid
|
|
934
|
+
)
|
|
935
|
+
return True
|
|
936
|
+
|
|
937
|
+
# ------------------------------------------------------------------
|
|
938
|
+
# stop
|
|
939
|
+
# ------------------------------------------------------------------
|
|
940
|
+
|
|
941
|
+
def stop(
|
|
942
|
+
self,
|
|
943
|
+
agent_name: str,
|
|
944
|
+
provision_result: Dict[str, Any],
|
|
945
|
+
) -> bool:
|
|
946
|
+
"""Gracefully stop the agent session (SIGTERM → wait → SIGKILL).
|
|
947
|
+
|
|
948
|
+
Args:
|
|
949
|
+
agent_name: Agent instance name.
|
|
950
|
+
provision_result: Output from provision() / start().
|
|
951
|
+
|
|
952
|
+
Returns:
|
|
953
|
+
True if the process is no longer running.
|
|
954
|
+
"""
|
|
955
|
+
work_dir_str = provision_result.get("work_dir", "")
|
|
956
|
+
work_dir = Path(work_dir_str) if work_dir_str else None
|
|
957
|
+
|
|
958
|
+
pid = provision_result.get("pid")
|
|
959
|
+
if pid is None and work_dir:
|
|
960
|
+
pid = _read_pid(work_dir)
|
|
961
|
+
|
|
962
|
+
if not pid:
|
|
963
|
+
logger.debug("stop: no pid for %s — already stopped", agent_name)
|
|
964
|
+
self._write_stopped_state(agent_name, work_dir)
|
|
965
|
+
return True
|
|
966
|
+
|
|
967
|
+
if not _pid_is_alive(pid):
|
|
968
|
+
logger.debug(
|
|
969
|
+
"stop: pid %d for %s is already dead", pid, agent_name
|
|
970
|
+
)
|
|
971
|
+
self._write_stopped_state(agent_name, work_dir)
|
|
972
|
+
return True
|
|
973
|
+
|
|
974
|
+
# SIGTERM — polite shutdown
|
|
975
|
+
try:
|
|
976
|
+
os.kill(pid, signal.SIGTERM)
|
|
977
|
+
except ProcessLookupError:
|
|
978
|
+
self._write_stopped_state(agent_name, work_dir)
|
|
979
|
+
return True
|
|
980
|
+
except OSError as exc:
|
|
981
|
+
logger.warning(
|
|
982
|
+
"stop: SIGTERM failed for %s (pid %d): %s", agent_name, pid, exc
|
|
983
|
+
)
|
|
984
|
+
return False
|
|
985
|
+
|
|
986
|
+
# Wait for graceful shutdown
|
|
987
|
+
deadline = time.time() + _STOP_TIMEOUT_SECONDS
|
|
988
|
+
while time.time() < deadline:
|
|
989
|
+
if not _pid_is_alive(pid):
|
|
990
|
+
break
|
|
991
|
+
time.sleep(0.5)
|
|
992
|
+
|
|
993
|
+
if _pid_is_alive(pid):
|
|
994
|
+
# Escalate to SIGKILL
|
|
995
|
+
logger.warning(
|
|
996
|
+
"stop: %s (pid %d) did not exit after %ds, sending SIGKILL",
|
|
997
|
+
agent_name, pid, _STOP_TIMEOUT_SECONDS,
|
|
998
|
+
)
|
|
999
|
+
try:
|
|
1000
|
+
os.kill(pid, signal.SIGKILL)
|
|
1001
|
+
except OSError:
|
|
1002
|
+
pass
|
|
1003
|
+
|
|
1004
|
+
kill_deadline = time.time() + _STOP_KILL_TIMEOUT_SECONDS
|
|
1005
|
+
while time.time() < kill_deadline:
|
|
1006
|
+
if not _pid_is_alive(pid):
|
|
1007
|
+
break
|
|
1008
|
+
time.sleep(0.2)
|
|
1009
|
+
|
|
1010
|
+
stopped = not _pid_is_alive(pid)
|
|
1011
|
+
self._write_stopped_state(agent_name, work_dir)
|
|
1012
|
+
|
|
1013
|
+
# Clean up SKSkills session resources
|
|
1014
|
+
if work_dir:
|
|
1015
|
+
try:
|
|
1016
|
+
from ..session_skills import cleanup_session_skills
|
|
1017
|
+
cleanup_session_skills(agent_name, work_dir)
|
|
1018
|
+
except ImportError:
|
|
1019
|
+
pass
|
|
1020
|
+
|
|
1021
|
+
logger.info("Stopped agent %s (pid=%d ok=%s)", agent_name, pid, stopped)
|
|
1022
|
+
return stopped
|
|
1023
|
+
|
|
1024
|
+
def _write_stopped_state(
|
|
1025
|
+
self, agent_name: str, work_dir: Optional[Path]
|
|
1026
|
+
) -> None:
|
|
1027
|
+
"""Write a STOPPED tombstone to the session state file.
|
|
1028
|
+
|
|
1029
|
+
Args:
|
|
1030
|
+
agent_name: Agent instance name.
|
|
1031
|
+
work_dir: Agent working directory (may be None).
|
|
1032
|
+
"""
|
|
1033
|
+
if work_dir and work_dir.exists():
|
|
1034
|
+
_write_session_state(work_dir, {
|
|
1035
|
+
"status": _STATE_STOPPED,
|
|
1036
|
+
"agent_name": agent_name,
|
|
1037
|
+
"stopped_at": _now_iso(),
|
|
1038
|
+
})
|
|
1039
|
+
|
|
1040
|
+
# ------------------------------------------------------------------
|
|
1041
|
+
# destroy
|
|
1042
|
+
# ------------------------------------------------------------------
|
|
1043
|
+
|
|
1044
|
+
def destroy(
|
|
1045
|
+
self,
|
|
1046
|
+
agent_name: str,
|
|
1047
|
+
provision_result: Dict[str, Any],
|
|
1048
|
+
) -> bool:
|
|
1049
|
+
"""Stop the session and remove all agent files.
|
|
1050
|
+
|
|
1051
|
+
Args:
|
|
1052
|
+
agent_name: Agent instance name.
|
|
1053
|
+
provision_result: Output from provision().
|
|
1054
|
+
|
|
1055
|
+
Returns:
|
|
1056
|
+
True if cleanup succeeded.
|
|
1057
|
+
"""
|
|
1058
|
+
self.stop(agent_name, provision_result)
|
|
1059
|
+
|
|
1060
|
+
work_dir_str = provision_result.get("work_dir", "")
|
|
1061
|
+
if work_dir_str:
|
|
1062
|
+
import shutil
|
|
1063
|
+
|
|
1064
|
+
work_dir_path = Path(work_dir_str)
|
|
1065
|
+
if work_dir_path.exists():
|
|
1066
|
+
shutil.rmtree(work_dir_path)
|
|
1067
|
+
logger.info("Destroyed agent directory: %s", work_dir_path)
|
|
1068
|
+
|
|
1069
|
+
return True
|
|
1070
|
+
|
|
1071
|
+
# ------------------------------------------------------------------
|
|
1072
|
+
# health_check
|
|
1073
|
+
# ------------------------------------------------------------------
|
|
1074
|
+
|
|
1075
|
+
def health_check(
|
|
1076
|
+
self,
|
|
1077
|
+
agent_name: str,
|
|
1078
|
+
provision_result: Dict[str, Any],
|
|
1079
|
+
) -> AgentStatus:
|
|
1080
|
+
"""Check agent health via session state file, then PID liveness.
|
|
1081
|
+
|
|
1082
|
+
Primary check: read ``session_state.json`` written by the crush daemon.
|
|
1083
|
+
Fallback: raw PID liveness check (signal 0).
|
|
1084
|
+
|
|
1085
|
+
Args:
|
|
1086
|
+
agent_name: Agent instance name.
|
|
1087
|
+
provision_result: Output from provision() / start().
|
|
1088
|
+
|
|
1089
|
+
Returns:
|
|
1090
|
+
AgentStatus based on session state and process liveness.
|
|
1091
|
+
"""
|
|
1092
|
+
work_dir_str = provision_result.get("work_dir", "")
|
|
1093
|
+
work_dir = Path(work_dir_str) if work_dir_str else None
|
|
1094
|
+
|
|
1095
|
+
pid = provision_result.get("pid")
|
|
1096
|
+
if pid is None and work_dir:
|
|
1097
|
+
pid = _read_pid(work_dir)
|
|
1098
|
+
|
|
1099
|
+
# --- Primary: session state file ---
|
|
1100
|
+
if work_dir:
|
|
1101
|
+
state = _read_session_state(work_dir)
|
|
1102
|
+
if state:
|
|
1103
|
+
return _session_state_to_agent_status(state, pid)
|
|
1104
|
+
|
|
1105
|
+
# --- Fallback: raw PID check ---
|
|
1106
|
+
if not pid:
|
|
1107
|
+
return AgentStatus.STOPPED
|
|
1108
|
+
|
|
1109
|
+
if _pid_is_alive(pid):
|
|
1110
|
+
return AgentStatus.RUNNING
|
|
1111
|
+
|
|
1112
|
+
return AgentStatus.STOPPED
|
|
1113
|
+
|
|
1114
|
+
|
|
1115
|
+
# ---------------------------------------------------------------------------
|
|
1116
|
+
# Internal helpers
|
|
1117
|
+
# ---------------------------------------------------------------------------
|
|
1118
|
+
|
|
1119
|
+
|
|
1120
|
+
def _session_state_to_agent_status(
|
|
1121
|
+
state: Dict[str, Any], pid: Optional[int]
|
|
1122
|
+
) -> AgentStatus:
|
|
1123
|
+
"""Map a session state dict to an AgentStatus value.
|
|
1124
|
+
|
|
1125
|
+
Args:
|
|
1126
|
+
state: Dictionary read from session_state.json.
|
|
1127
|
+
pid: Current known PID for corroboration.
|
|
1128
|
+
|
|
1129
|
+
Returns:
|
|
1130
|
+
Appropriate AgentStatus.
|
|
1131
|
+
"""
|
|
1132
|
+
raw_status = state.get("status", "").lower()
|
|
1133
|
+
|
|
1134
|
+
if raw_status in (_STATE_RUNNING, _STATE_IDLE):
|
|
1135
|
+
# Corroborate with PID liveness if we have a PID
|
|
1136
|
+
state_pid = state.get("pid") or pid
|
|
1137
|
+
if state_pid and not _pid_is_alive(int(state_pid)):
|
|
1138
|
+
return AgentStatus.DEGRADED
|
|
1139
|
+
return AgentStatus.RUNNING
|
|
1140
|
+
|
|
1141
|
+
if raw_status == _STATE_ERROR:
|
|
1142
|
+
return AgentStatus.DEGRADED
|
|
1143
|
+
|
|
1144
|
+
if raw_status == _STATE_STOPPED:
|
|
1145
|
+
return AgentStatus.STOPPED
|
|
1146
|
+
|
|
1147
|
+
# Unknown state value
|
|
1148
|
+
return AgentStatus.DEGRADED
|
|
1149
|
+
|
|
1150
|
+
|
|
1151
|
+
def _stub_script(agent_name: str, state_file: str) -> str:
|
|
1152
|
+
"""Return Python source for the lightweight stub process.
|
|
1153
|
+
|
|
1154
|
+
The stub writes a running state, then sleeps until SIGTERM/SIGINT.
|
|
1155
|
+
|
|
1156
|
+
Args:
|
|
1157
|
+
agent_name: Agent instance name.
|
|
1158
|
+
state_file: Absolute path to the session state file.
|
|
1159
|
+
|
|
1160
|
+
Returns:
|
|
1161
|
+
Python source string safe for ``python -c``.
|
|
1162
|
+
"""
|
|
1163
|
+
# Reason: single-quoted string avoids shell escaping issues; json import
|
|
1164
|
+
# and signal handling give the stub a clean lifecycle for testing.
|
|
1165
|
+
return (
|
|
1166
|
+
"import json, os, signal, sys, time\n"
|
|
1167
|
+
f"state_file = {repr(state_file)}\n"
|
|
1168
|
+
f"agent_name = {repr(agent_name)}\n"
|
|
1169
|
+
"running = True\n"
|
|
1170
|
+
"def _stop(sig, frame):\n"
|
|
1171
|
+
" global running\n"
|
|
1172
|
+
" running = False\n"
|
|
1173
|
+
"signal.signal(signal.SIGTERM, _stop)\n"
|
|
1174
|
+
"signal.signal(signal.SIGINT, _stop)\n"
|
|
1175
|
+
"with open(state_file, 'w') as f:\n"
|
|
1176
|
+
" json.dump({'status': 'running', 'pid': os.getpid(), "
|
|
1177
|
+
" 'agent_name': agent_name}, f)\n"
|
|
1178
|
+
"while running:\n"
|
|
1179
|
+
" time.sleep(1)\n"
|
|
1180
|
+
"with open(state_file, 'w') as f:\n"
|
|
1181
|
+
" json.dump({'status': 'stopped', 'agent_name': agent_name}, f)\n"
|
|
1182
|
+
)
|
|
1183
|
+
|
|
1184
|
+
|
|
1185
|
+
def _now_iso() -> str:
|
|
1186
|
+
"""Return the current UTC time as an ISO 8601 string.
|
|
1187
|
+
|
|
1188
|
+
Returns:
|
|
1189
|
+
ISO 8601 timestamp string.
|
|
1190
|
+
"""
|
|
1191
|
+
from datetime import datetime, timezone
|
|
1192
|
+
|
|
1193
|
+
return datetime.now(timezone.utc).isoformat()
|