threadkeeper 0.18.1__tar.gz → 0.18.2__tar.gz
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.
- {threadkeeper-0.18.1/threadkeeper.egg-info → threadkeeper-0.18.2}/PKG-INFO +6 -1
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/README.md +5 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/pyproject.toml +1 -1
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/tests/test_adapters.py +103 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/tests/test_github_safety.py +35 -0
- threadkeeper-0.18.2/tests/test_hook_child_guard.py +38 -0
- threadkeeper-0.18.2/tests/test_spawn_child_env.py +101 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/threadkeeper/adapters/base.py +27 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/threadkeeper/adapters/codex.py +132 -13
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/threadkeeper/github_safety.py +24 -7
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/threadkeeper/spawn_config.py +18 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/threadkeeper/tools/spawn.py +15 -6
- {threadkeeper-0.18.1 → threadkeeper-0.18.2/threadkeeper.egg-info}/PKG-INFO +6 -1
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/threadkeeper.egg-info/SOURCES.txt +2 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/LICENSE +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/setup.cfg +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/tests/test_agent_status.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/tests/test_antigravity_ingest.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/tests/test_auto_update.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/tests/test_backup.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/tests/test_brief_footprint.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/tests/test_brief_sections.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/tests/test_candidate_reviewer.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/tests/test_ci_parallel.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/tests/test_concepts.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/tests/test_config_settings.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/tests/test_config_watcher.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/tests/test_core_memory.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/tests/test_curator.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/tests/test_daemon_host_config.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/tests/test_daemon_host_integration.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/tests/test_daemon_liveness.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/tests/test_daemon_state.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/tests/test_dashboard.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/tests/test_db_compact.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/tests/test_db_contention.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/tests/test_db_leak_guard.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/tests/test_db_migration_fts.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/tests/test_db_runtime.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/tests/test_db_schema.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/tests/test_delegated_search.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/tests/test_dialectic.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/tests/test_dialectic_feed_tools.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/tests/test_dialectic_miner.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/tests/test_dialectic_observation_resolve.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/tests/test_dialectic_recompute.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/tests/test_dialectic_tier.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/tests/test_dialectic_validator.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/tests/test_dialog_fts_external.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/tests/test_documentation_inventory.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/tests/test_egress_policy.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/tests/test_embed_routing.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/tests/test_embedding_generation.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/tests/test_embedding_model_revision.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/tests/test_embedding_storage_dedup.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/tests/test_error_paths.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/tests/test_eval_harness.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/tests/test_evolve_applier.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/tests/test_evolve_apply_2.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/tests/test_evolve_apply_3.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/tests/test_evolve_daemon.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/tests/test_extract_daemon.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/tests/test_extract_dedup.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/tests/test_forget.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/tests/test_github_budget.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/tests/test_helpers.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/tests/test_host_embed.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/tests/test_host_env.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/tests/test_host_main.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/tests/test_host_wedged.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/tests/test_hybrid_retrieval.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/tests/test_i18n_multilang.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/tests/test_identity.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/tests/test_ingest_cursor.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/tests/test_ingest_denylist.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/tests/test_ingest_secret_redaction.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/tests/test_ingest_status.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/tests/test_ingest_transactions.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/tests/test_lesson_violations.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/tests/test_lessons.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/tests/test_mcp_resources_prompts.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/tests/test_mcp_sdk_compat.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/tests/test_memory_eval.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/tests/test_memory_guard.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/tests/test_memory_guard_host.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/tests/test_menubar_app.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/tests/test_missed_spawns.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/tests/test_model_catalog.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/tests/test_notify_daemon.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/tests/test_nudges.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/tests/test_onnx_embeddings.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/tests/test_panel.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/tests/test_permissions.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/tests/test_pickup.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/tests/test_probe_daemon.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/tests/test_process_health.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/tests/test_release_workflows.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/tests/test_retention.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/tests/test_review_prompts.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/tests/test_search_fts_punctuation.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/tests/test_session_read_purity.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/tests/test_setup.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/tests/test_shadow_review.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/tests/test_single_flight_prompt_prefixes.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/tests/test_skill_hint.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/tests/test_skill_passive_tier.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/tests/test_skill_tier.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/tests/test_skill_updater.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/tests/test_skill_use_parser.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/tests/test_skill_watcher.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/tests/test_skills.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/tests/test_spawn_background_cwd.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/tests/test_spawn_budget.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/tests/test_spawn_codex_stdin.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/tests/test_spawn_config.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/tests/test_spawn_hint.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/tests/test_spawn_permission_gate.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/tests/test_spawn_reap.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/tests/test_spawn_result.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/tests/test_spawn_slim.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/tests/test_spawn_watchdog.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/tests/test_spawn_worktree_isolation.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/tests/test_spawn_wrap.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/tests/test_sync_capture.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/tests/test_sync_identity.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/tests/test_sync_migrate.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/tests/test_sync_protocol.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/tests/test_sync_reset_node.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/tests/test_sync_transport.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/tests/test_task_retention.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/tests/test_task_spool.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/tests/test_thin_session.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/tests/test_thread_janitor.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/tests/test_threads.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/tests/test_tool_annotations.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/tests/test_tools_smoke.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/tests/test_validate_threads.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/tests/test_vec_search.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/tests/test_verify_ingest.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/threadkeeper/__init__.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/threadkeeper/_mcp.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/threadkeeper/_setup.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/threadkeeper/_spawn_wrap.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/threadkeeper/adapters/__init__.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/threadkeeper/adapters/_hook_helpers.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/threadkeeper/adapters/antigravity.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/threadkeeper/adapters/claude_code.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/threadkeeper/adapters/claude_desktop.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/threadkeeper/adapters/copilot.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/threadkeeper/adapters/vscode.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/threadkeeper/agent_metadata.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/threadkeeper/agent_status.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/threadkeeper/assets/macos-agent-status/Info.plist +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/threadkeeper/assets/macos-agent-status/README.md +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/threadkeeper/assets/macos-agent-status/ThreadKeeperAgentStatus.swift +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/threadkeeper/assets/macos-agent-status/build.sh +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/threadkeeper/assets/macos-agent-status/install.sh +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/threadkeeper/auto_update.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/threadkeeper/backup.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/threadkeeper/brief.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/threadkeeper/candidate_reviewer.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/threadkeeper/config.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/threadkeeper/config_io.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/threadkeeper/config_watcher.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/threadkeeper/curator.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/threadkeeper/curator_snapshots.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/threadkeeper/daemon_liveness.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/threadkeeper/daemon_state.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/threadkeeper/daemon_supervisor.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/threadkeeper/db.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/threadkeeper/dialectic_miner.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/threadkeeper/dialectic_validator.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/threadkeeper/egress.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/threadkeeper/elicitation.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/threadkeeper/embeddings.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/threadkeeper/eval/__init__.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/threadkeeper/eval/__main__.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/threadkeeper/eval/harness.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/threadkeeper/evolve_applier.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/threadkeeper/evolve_daemon.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/threadkeeper/extract_daemon.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/threadkeeper/forget.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/threadkeeper/github_budget.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/threadkeeper/harvest.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/threadkeeper/helpers.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/threadkeeper/host.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/threadkeeper/host_embed.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/threadkeeper/i18n.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/threadkeeper/identity.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/threadkeeper/ingest.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/threadkeeper/lesson_violations.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/threadkeeper/lessons.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/threadkeeper/link_health.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/threadkeeper/memory_guard.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/threadkeeper/menubar_app.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/threadkeeper/migrate_embeddings.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/threadkeeper/model_catalog.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/threadkeeper/notify.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/threadkeeper/nudges.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/threadkeeper/permissions.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/threadkeeper/probe_daemon.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/threadkeeper/process_health.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/threadkeeper/retention.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/threadkeeper/retrieval.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/threadkeeper/review_prompts.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/threadkeeper/search_proxy.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/threadkeeper/server.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/threadkeeper/shadow_review.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/threadkeeper/skill_audit.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/threadkeeper/skill_updater.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/threadkeeper/skill_watcher.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/threadkeeper/spawn_budget.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/threadkeeper/spawn_result.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/threadkeeper/sync/__init__.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/threadkeeper/sync/capture.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/threadkeeper/sync/daemon.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/threadkeeper/sync/identity.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/threadkeeper/sync/migrate.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/threadkeeper/sync/protocol.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/threadkeeper/sync/reset_node.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/threadkeeper/sync/server.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/threadkeeper/task_spool.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/threadkeeper/thread_janitor.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/threadkeeper/tool_schemas.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/threadkeeper/tools/__init__.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/threadkeeper/tools/agent_status.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/threadkeeper/tools/candidate_reviewer.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/threadkeeper/tools/concepts.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/threadkeeper/tools/config_watch.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/threadkeeper/tools/consolidate.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/threadkeeper/tools/core_memory.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/threadkeeper/tools/correlation.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/threadkeeper/tools/curator.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/threadkeeper/tools/dashboard.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/threadkeeper/tools/db_maintenance.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/threadkeeper/tools/dialectic.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/threadkeeper/tools/dialectic_feed.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/threadkeeper/tools/dialog.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/threadkeeper/tools/distill.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/threadkeeper/tools/evolve_applier.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/threadkeeper/tools/evolve_research.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/threadkeeper/tools/extract.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/threadkeeper/tools/forget.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/threadkeeper/tools/graph.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/threadkeeper/tools/invariants.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/threadkeeper/tools/lessons.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/threadkeeper/tools/memory_guard.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/threadkeeper/tools/missed_spawns.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/threadkeeper/tools/panel.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/threadkeeper/tools/peers.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/threadkeeper/tools/pickup.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/threadkeeper/tools/probes.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/threadkeeper/tools/process_health.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/threadkeeper/tools/prompts.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/threadkeeper/tools/resources.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/threadkeeper/tools/session.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/threadkeeper/tools/shadow_review.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/threadkeeper/tools/skills.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/threadkeeper/tools/style.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/threadkeeper/tools/sync.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/threadkeeper/tools/threads.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/threadkeeper/tools/validate.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/threadkeeper/trash.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/threadkeeper/verify_ingest.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/threadkeeper/wikilinks.py +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/threadkeeper.egg-info/dependency_links.txt +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/threadkeeper.egg-info/entry_points.txt +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/threadkeeper.egg-info/requires.txt +0 -0
- {threadkeeper-0.18.1 → threadkeeper-0.18.2}/threadkeeper.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: threadkeeper
|
|
3
|
-
Version: 0.18.
|
|
3
|
+
Version: 0.18.2
|
|
4
4
|
Summary: Multi-agent shared brain across Claude Code/Desktop, Codex, Antigravity CLI, Copilot, and VS Code. Cross-session memory, self-improving skill loops, inter-agent signaling — one local MCP server.
|
|
5
5
|
Author: thread-keeper contributors
|
|
6
6
|
License: MIT
|
|
@@ -313,6 +313,11 @@ servers. ~500 MB RSS versus ~1.3 GB for a full child. Heuristic for the
|
|
|
313
313
|
parent: N≥2 modular independent units of ≥5 min each = spawn signal.
|
|
314
314
|
Spawn also marks children with `THREADKEEPER_SPAWNED_CHILD=1`, so
|
|
315
315
|
autonomous learning daemons cannot recursively start inside review forks.
|
|
316
|
+
Codex children get the same isolation: `codex exec --ignore-user-config`
|
|
317
|
+
with only the user's provider settings and thread-keeper entry carried over,
|
|
318
|
+
and `enabled_tools` limited to the child's granted thread-keeper tools. Other
|
|
319
|
+
MCP servers, plugins and hooks from `~/.codex/config.toml` never start inside
|
|
320
|
+
a background child.
|
|
316
321
|
|
|
317
322
|
A daemon in the foreground parent measures combined child RSS every 10 s;
|
|
318
323
|
spawned children do not start their own `ps` polling loop, failed `ps` RSS
|
|
@@ -269,6 +269,11 @@ servers. ~500 MB RSS versus ~1.3 GB for a full child. Heuristic for the
|
|
|
269
269
|
parent: N≥2 modular independent units of ≥5 min each = spawn signal.
|
|
270
270
|
Spawn also marks children with `THREADKEEPER_SPAWNED_CHILD=1`, so
|
|
271
271
|
autonomous learning daemons cannot recursively start inside review forks.
|
|
272
|
+
Codex children get the same isolation: `codex exec --ignore-user-config`
|
|
273
|
+
with only the user's provider settings and thread-keeper entry carried over,
|
|
274
|
+
and `enabled_tools` limited to the child's granted thread-keeper tools. Other
|
|
275
|
+
MCP servers, plugins and hooks from `~/.codex/config.toml` never start inside
|
|
276
|
+
a background child.
|
|
272
277
|
|
|
273
278
|
A daemon in the foreground parent measures combined child RSS every 10 s;
|
|
274
279
|
spawned children do not start their own `ps` polling loop, failed `ps` RSS
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "threadkeeper"
|
|
7
|
-
version = "0.18.
|
|
7
|
+
version = "0.18.2"
|
|
8
8
|
description = "Multi-agent shared brain across Claude Code/Desktop, Codex, Antigravity CLI, Copilot, and VS Code. Cross-session memory, self-improving skill loops, inter-agent signaling — one local MCP server."
|
|
9
9
|
requires-python = ">=3.11"
|
|
10
10
|
authors = [{ name = "thread-keeper contributors" }]
|
|
@@ -476,9 +476,112 @@ def test_codex_spawn_argv_forwards_child_identity_to_its_mcp_server(
|
|
|
476
476
|
):
|
|
477
477
|
assert key in names
|
|
478
478
|
assert "THREADKEEPER_ROLE" not in names # the host's role must not leak
|
|
479
|
+
assert "THREADKEEPER_REAL_GH" in names # gh wrapper must reach real gh
|
|
479
480
|
assert all("=" not in name for name in names) # names only, no values
|
|
480
481
|
|
|
481
482
|
|
|
483
|
+
def _codex_overrides(argv):
|
|
484
|
+
return [argv[i + 1] for i, arg in enumerate(argv[:-1]) if arg == "-c"]
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def _write_codex_config(path):
|
|
488
|
+
path.write_text(
|
|
489
|
+
'model = "gpt-6-sol"\n'
|
|
490
|
+
'model_reasoning_effort = "xhigh"\n'
|
|
491
|
+
'personality = "pragmatic"\n'
|
|
492
|
+
'notify = ["/Applications/Tool.app/notify"]\n'
|
|
493
|
+
'[model_providers.corp]\n'
|
|
494
|
+
'base_url = "https://llm.example/v1"\n'
|
|
495
|
+
'[mcp_servers.trello]\n'
|
|
496
|
+
'command = "trello-mcp"\n'
|
|
497
|
+
'[mcp_servers.thread-keeper]\n'
|
|
498
|
+
'command = "/opt/tk/python"\n'
|
|
499
|
+
'args = ["-m", "threadkeeper.server"]\n'
|
|
500
|
+
'tool_timeout_sec = 120\n'
|
|
501
|
+
'[mcp_servers.thread-keeper.env]\n'
|
|
502
|
+
'PYTHONPATH = "/opt/tk"\n'
|
|
503
|
+
'SECRET_TOKEN = "do-not-copy"\n'
|
|
504
|
+
'THREADKEEPER_FORCE_CID = "stale-cid"\n'
|
|
505
|
+
'[plugins."computer-use@openai-bundled"]\n'
|
|
506
|
+
'enabled = true\n',
|
|
507
|
+
encoding="utf-8",
|
|
508
|
+
)
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def test_codex_child_runs_without_the_user_codex_config(tmp_path, monkeypatch):
|
|
512
|
+
"""Children get thread-keeper only, like Claude's --strict-mcp-config:
|
|
513
|
+
no other MCP server, plugin, hook or notify program from config.toml."""
|
|
514
|
+
pkg = _bootstrap(tmp_path, monkeypatch)
|
|
515
|
+
import threadkeeper.adapters.codex as codex_mod
|
|
516
|
+
monkeypatch.setattr(codex_mod.shutil, "which", lambda _bin: "/usr/local/bin/codex")
|
|
517
|
+
monkeypatch.setattr(codex_mod, "_supports_ignore_user_config", lambda _bin: True)
|
|
518
|
+
cfg = tmp_path / "config.toml"
|
|
519
|
+
_write_codex_config(cfg)
|
|
520
|
+
monkeypatch.setattr(pkg["codex"], "config_path", cfg)
|
|
521
|
+
|
|
522
|
+
argv = pkg["codex"].spawn_argv(
|
|
523
|
+
"research",
|
|
524
|
+
extra_allowed_tools=(
|
|
525
|
+
"Read,WebSearch,mcp__thread-keeper__broadcast,"
|
|
526
|
+
"mcp__thread-keeper__evolve_research_handoff"
|
|
527
|
+
),
|
|
528
|
+
)
|
|
529
|
+
|
|
530
|
+
assert argv[:5] == [
|
|
531
|
+
"/usr/local/bin/codex", "--search", "exec", "--skip-git-repo-check",
|
|
532
|
+
"--ignore-user-config",
|
|
533
|
+
]
|
|
534
|
+
overrides = _codex_overrides(argv)
|
|
535
|
+
text = "\n".join(overrides)
|
|
536
|
+
for expected in (
|
|
537
|
+
'model="gpt-6-sol"',
|
|
538
|
+
'model_reasoning_effort="xhigh"',
|
|
539
|
+
'model_providers.corp.base_url="https://llm.example/v1"',
|
|
540
|
+
'mcp_servers.thread-keeper.command="/opt/tk/python"',
|
|
541
|
+
'mcp_servers.thread-keeper.args=["-m", "threadkeeper.server"]',
|
|
542
|
+
"mcp_servers.thread-keeper.tool_timeout_sec=120",
|
|
543
|
+
'mcp_servers.thread-keeper.env.PYTHONPATH="/opt/tk"',
|
|
544
|
+
'mcp_servers.thread-keeper.env.PYTHONSAFEPATH="1"',
|
|
545
|
+
'mcp_servers.thread-keeper.enabled_tools=["broadcast", "evolve_research_handoff"]',
|
|
546
|
+
):
|
|
547
|
+
assert expected in overrides, expected
|
|
548
|
+
for leaked in ("trello", "SECRET_TOKEN", "stale-cid", "computer-use", "notify", "personality"):
|
|
549
|
+
assert leaked not in text, leaked
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
def test_codex_child_disables_other_servers_on_older_codex(tmp_path, monkeypatch):
|
|
553
|
+
pkg = _bootstrap(tmp_path, monkeypatch)
|
|
554
|
+
import threadkeeper.adapters.codex as codex_mod
|
|
555
|
+
monkeypatch.setattr(codex_mod.shutil, "which", lambda _bin: "/usr/local/bin/codex")
|
|
556
|
+
monkeypatch.setattr(codex_mod, "_supports_ignore_user_config", lambda _bin: False)
|
|
557
|
+
cfg = tmp_path / "config.toml"
|
|
558
|
+
_write_codex_config(cfg)
|
|
559
|
+
monkeypatch.setattr(pkg["codex"], "config_path", cfg)
|
|
560
|
+
|
|
561
|
+
argv = pkg["codex"].spawn_argv("audit", extra_allowed_tools="mcp__thread-keeper__note")
|
|
562
|
+
|
|
563
|
+
assert "--ignore-user-config" not in argv
|
|
564
|
+
overrides = _codex_overrides(argv)
|
|
565
|
+
assert "mcp_servers.trello.enabled=false" in overrides
|
|
566
|
+
assert not any(o.startswith("mcp_servers.thread-keeper.enabled=") for o in overrides)
|
|
567
|
+
assert 'mcp_servers.thread-keeper.enabled_tools=["note"]' in overrides
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
def test_codex_child_synthesizes_a_thread_keeper_entry(tmp_path, monkeypatch):
|
|
571
|
+
import sys as _sys
|
|
572
|
+
|
|
573
|
+
pkg = _bootstrap(tmp_path, monkeypatch)
|
|
574
|
+
import threadkeeper.adapters.codex as codex_mod
|
|
575
|
+
monkeypatch.setattr(codex_mod.shutil, "which", lambda _bin: "/usr/local/bin/codex")
|
|
576
|
+
monkeypatch.setattr(codex_mod, "_supports_ignore_user_config", lambda _bin: True)
|
|
577
|
+
monkeypatch.setattr(pkg["codex"], "config_path", tmp_path / "missing.toml")
|
|
578
|
+
|
|
579
|
+
overrides = _codex_overrides(pkg["codex"].spawn_argv("audit"))
|
|
580
|
+
|
|
581
|
+
assert f"mcp_servers.thread-keeper.command={json.dumps(_sys.executable)}" in overrides
|
|
582
|
+
assert any(o.startswith("mcp_servers.thread-keeper.env.PYTHONPATH=") for o in overrides)
|
|
583
|
+
|
|
584
|
+
|
|
482
585
|
def test_codex_iter_messages_filters_developer_turns(tmp_path, monkeypatch):
|
|
483
586
|
pkg = _bootstrap(tmp_path, monkeypatch)
|
|
484
587
|
fp = tmp_path / "rollout-2026-05-14T10-00-00.jsonl"
|
|
@@ -101,3 +101,38 @@ def test_wrapped_gh_honors_shared_budget_cooldown(
|
|
|
101
101
|
|
|
102
102
|
assert rc == gb.GITHUB_RATE_COOLDOWN_EXIT
|
|
103
103
|
assert not capture.exists()
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _exe(path, body):
|
|
107
|
+
path.write_text(body, encoding="utf-8")
|
|
108
|
+
path.chmod(0o700)
|
|
109
|
+
return path
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def test_real_gh_resolution_skips_the_safety_wrapper(tmp_path, monkeypatch):
|
|
113
|
+
"""A Codex child's MCP server gets the wrapper-first PATH without the
|
|
114
|
+
wrapper variables. Resolving `gh` must not find the wrapper itself, which
|
|
115
|
+
used to re-run itself until the dedup gate timed out."""
|
|
116
|
+
from threadkeeper import github_safety
|
|
117
|
+
|
|
118
|
+
wrapper_dir = tmp_path / "gh-safe-tk_x"
|
|
119
|
+
real_dir = tmp_path / "bin"
|
|
120
|
+
wrapper_dir.mkdir()
|
|
121
|
+
real_dir.mkdir()
|
|
122
|
+
wrapper = _exe(
|
|
123
|
+
wrapper_dir / "gh",
|
|
124
|
+
"#!/bin/sh\nexec python -m threadkeeper.github_safety \"$@\"\n",
|
|
125
|
+
)
|
|
126
|
+
real = _exe(real_dir / "gh", "#!/bin/sh\necho real\n")
|
|
127
|
+
monkeypatch.delenv("THREADKEEPER_GH_WRAPPER_DIR", raising=False)
|
|
128
|
+
monkeypatch.setenv("PATH", f"{wrapper_dir}:{real_dir}")
|
|
129
|
+
|
|
130
|
+
monkeypatch.delenv("THREADKEEPER_REAL_GH", raising=False)
|
|
131
|
+
assert github_safety._resolve_real_gh() == str(real)
|
|
132
|
+
|
|
133
|
+
monkeypatch.setenv("THREADKEEPER_REAL_GH", str(wrapper))
|
|
134
|
+
assert github_safety._resolve_real_gh() == str(real)
|
|
135
|
+
|
|
136
|
+
monkeypatch.setenv("PATH", str(wrapper_dir))
|
|
137
|
+
assert github_safety._resolve_real_gh() == ""
|
|
138
|
+
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Session-protocol hooks stay silent inside spawned background children."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
import subprocess
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
HOOKS = Path(__file__).resolve().parents[1] / "scripts" / "hooks"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@pytest.mark.parametrize(
|
|
14
|
+
"script", ["tk-brief.sh", "tk-thread-nudge.sh", "tk-session-end.sh"],
|
|
15
|
+
)
|
|
16
|
+
def test_session_hooks_exit_before_work_in_spawned_children(tmp_path, script):
|
|
17
|
+
marker = tmp_path / "python-ran"
|
|
18
|
+
fake_python = tmp_path / "python"
|
|
19
|
+
fake_python.write_text(f"#!/bin/sh\ntouch {marker}\n", encoding="utf-8")
|
|
20
|
+
fake_python.chmod(0o700)
|
|
21
|
+
env = {
|
|
22
|
+
"PATH": os.environ.get("PATH", "/usr/bin:/bin"),
|
|
23
|
+
"HOME": str(tmp_path),
|
|
24
|
+
"THREADKEEPER_PYTHON": str(fake_python),
|
|
25
|
+
"THREADKEEPER_STATE_DIR": str(tmp_path / "state"),
|
|
26
|
+
"THREADKEEPER_SPAWNED_CHILD": "1",
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
proc = subprocess.run(
|
|
30
|
+
["bash", str(HOOKS / script)],
|
|
31
|
+
input='{"session_id": "s1", "prompt": "hello"}',
|
|
32
|
+
capture_output=True, text=True, env=env, timeout=30,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
assert proc.returncode == 0, proc.stderr
|
|
36
|
+
assert proc.stdout == ""
|
|
37
|
+
assert not marker.exists()
|
|
38
|
+
assert not (tmp_path / "state").exists()
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""What a spawned child inherits and which CLI runs it."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
_FAKE_CID = "44445555-6666-7777-8888-999900001111"
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _setup(mp_with_cid, monkeypatch, routed_cli="codex"):
|
|
8
|
+
pkg = mp_with_cid(_FAKE_CID)
|
|
9
|
+
import threadkeeper.identity as identity
|
|
10
|
+
import threadkeeper.spawn_config as spawn_config
|
|
11
|
+
import threadkeeper.tools.spawn as spawn_mod
|
|
12
|
+
|
|
13
|
+
monkeypatch.setattr(spawn_mod, "_claude_bin", lambda: "/bin/true")
|
|
14
|
+
monkeypatch.setattr(identity, "_active_cli", "claude")
|
|
15
|
+
monkeypatch.setattr(
|
|
16
|
+
spawn_config, "resolve_agent", lambda role, active_cli=None: routed_cli
|
|
17
|
+
)
|
|
18
|
+
monkeypatch.setattr(spawn_config, "resolve_model", lambda cli, role="": "")
|
|
19
|
+
launched: list[dict] = []
|
|
20
|
+
|
|
21
|
+
class _Popen:
|
|
22
|
+
def __init__(self, args, **kwargs):
|
|
23
|
+
launched.append({"args": list(args), "env": dict(kwargs.get("env") or {})})
|
|
24
|
+
self.pid = 4545
|
|
25
|
+
|
|
26
|
+
monkeypatch.setattr(spawn_mod.subprocess, "Popen", _Popen)
|
|
27
|
+
return pkg, spawn_mod, launched
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _chosen_cli(pkg):
|
|
31
|
+
conn = pkg["db"].get_db()
|
|
32
|
+
try:
|
|
33
|
+
return conn.execute("SELECT chosen_cli FROM tasks").fetchone()[0]
|
|
34
|
+
finally:
|
|
35
|
+
conn.close()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_explicit_claude_model_runs_on_claude(mp_with_cid, monkeypatch):
|
|
39
|
+
"""`spawn(model="opus")` from a Codex-routed setup used to launch codex
|
|
40
|
+
with a Claude model, which the provider rejects at once."""
|
|
41
|
+
pkg, spawn_mod, launched = _setup(mp_with_cid, monkeypatch, routed_cli="codex")
|
|
42
|
+
|
|
43
|
+
out = spawn_mod.spawn(
|
|
44
|
+
prompt="implement the task", cwd=str(pkg["tmp"]), model="opus",
|
|
45
|
+
visible=False, capture_output=False,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
assert out.startswith("ok task="), out
|
|
49
|
+
assert _chosen_cli(pkg) == "claude"
|
|
50
|
+
args = launched[0]["args"]
|
|
51
|
+
assert "--model" in args and args[args.index("--model") + 1] == "opus"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_explicit_cli_with_a_foreign_model_is_refused(mp_with_cid, monkeypatch):
|
|
55
|
+
pkg, spawn_mod, launched = _setup(mp_with_cid, monkeypatch)
|
|
56
|
+
|
|
57
|
+
out = spawn_mod._spawn_impl(
|
|
58
|
+
prompt="x", cwd=str(pkg["tmp"]), cli="codex", model="sonnet",
|
|
59
|
+
visible=False, capture_output=False,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
assert out == "ERR model_cli_mismatch model=sonnet cli=codex"
|
|
63
|
+
assert launched == []
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_child_env_drops_the_host_role(mp_with_cid, monkeypatch):
|
|
67
|
+
"""The daemon host runs with THREADKEEPER_ROLE=host; a child that kept it
|
|
68
|
+
would start its MCP server believing it is the host."""
|
|
69
|
+
pkg, spawn_mod, launched = _setup(mp_with_cid, monkeypatch, routed_cli="claude")
|
|
70
|
+
monkeypatch.setenv("THREADKEEPER_ROLE", "host")
|
|
71
|
+
|
|
72
|
+
out = spawn_mod.spawn(
|
|
73
|
+
prompt="x", cwd=str(pkg["tmp"]), visible=False, capture_output=False,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
assert out.startswith("ok task="), out
|
|
77
|
+
env = launched[0]["env"]
|
|
78
|
+
assert "THREADKEEPER_ROLE" not in env
|
|
79
|
+
assert env["THREADKEEPER_SPAWNED_CHILD"] == "1"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def test_child_preamble_skips_the_user_session_protocol(mp_with_cid, monkeypatch):
|
|
83
|
+
pkg, spawn_mod, launched = _setup(mp_with_cid, monkeypatch, routed_cli="claude")
|
|
84
|
+
|
|
85
|
+
spawn_mod.spawn(prompt="x", cwd=str(pkg["tmp"]), visible=False, capture_output=False)
|
|
86
|
+
|
|
87
|
+
args = launched[0]["args"]
|
|
88
|
+
preamble = args[args.index("--append-system-prompt") + 1]
|
|
89
|
+
assert "not a user session" in preamble
|
|
90
|
+
assert "session_end" in preamble
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def test_model_home_cli():
|
|
94
|
+
from threadkeeper.spawn_config import model_home_cli
|
|
95
|
+
|
|
96
|
+
assert model_home_cli("opus") == "claude"
|
|
97
|
+
assert model_home_cli("claude-sonnet-5") == "claude"
|
|
98
|
+
assert model_home_cli("gpt-5.6-terra") == "codex"
|
|
99
|
+
assert model_home_cli("o3") == "codex"
|
|
100
|
+
assert model_home_cli("gemini-3-pro") == ""
|
|
101
|
+
assert model_home_cli("") == ""
|
|
@@ -37,8 +37,35 @@ CHILD_MCP_ENV_KEYS: tuple[str, ...] = (
|
|
|
37
37
|
"THREADKEEPER_CURATOR_PASS_ID",
|
|
38
38
|
"THREADKEEPER_CURATOR_SNAPSHOT_DIR",
|
|
39
39
|
"THREADKEEPER_EGRESS_CONSUMER",
|
|
40
|
+
# A privileged child's PATH starts with the gh safety wrapper; its MCP
|
|
41
|
+
# server needs these to reach the real gh instead of looping on itself.
|
|
42
|
+
"THREADKEEPER_GH_WRAPPER_DIR",
|
|
43
|
+
"THREADKEEPER_REAL_GH",
|
|
40
44
|
)
|
|
41
45
|
|
|
46
|
+
# Environment a user's thread-keeper MCP entry may carry into a spawned
|
|
47
|
+
# child's own MCP config: package discovery plus thread-keeper knobs. Anything
|
|
48
|
+
# else in that entry (credentials for other tools) stays out of the child.
|
|
49
|
+
CHILD_MCP_ENTRY_ENV_ALLOW = frozenset({
|
|
50
|
+
"PYTHONPATH", "PYTHONSAFEPATH", "VIRTUAL_ENV", "PYTHONHOME",
|
|
51
|
+
})
|
|
52
|
+
CHILD_MCP_ENTRY_ENV_PREFIXES = ("THREADKEEPER_",)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def child_mcp_entry_env(env: dict | None) -> dict[str, str]:
|
|
56
|
+
"""Filter a thread-keeper MCP entry's env for a spawned child's config.
|
|
57
|
+
|
|
58
|
+
Per-run identity keys are dropped: the child's live values must win.
|
|
59
|
+
"""
|
|
60
|
+
return {
|
|
61
|
+
str(k): str(v) for k, v in (env or {}).items()
|
|
62
|
+
if (
|
|
63
|
+
k in CHILD_MCP_ENTRY_ENV_ALLOW
|
|
64
|
+
or any(str(k).startswith(p) for p in CHILD_MCP_ENTRY_ENV_PREFIXES)
|
|
65
|
+
)
|
|
66
|
+
and k not in CHILD_MCP_ENV_KEYS
|
|
67
|
+
}
|
|
68
|
+
|
|
42
69
|
|
|
43
70
|
def find_cli_executable(*names: str) -> str:
|
|
44
71
|
"""Find a CLI even when a sandboxed macOS app has a minimal PATH."""
|
|
@@ -17,12 +17,14 @@ import os
|
|
|
17
17
|
import re
|
|
18
18
|
import shutil
|
|
19
19
|
import subprocess
|
|
20
|
+
import sys
|
|
20
21
|
from datetime import datetime
|
|
21
22
|
from pathlib import Path
|
|
22
23
|
from typing import Iterator
|
|
23
24
|
|
|
24
25
|
from .base import (
|
|
25
|
-
CHILD_MCP_ENV_KEYS, CLIAdapter, NormalizedMessage,
|
|
26
|
+
CHILD_MCP_ENV_KEYS, CLIAdapter, NormalizedMessage, child_mcp_entry_env,
|
|
27
|
+
find_cli_executable,
|
|
26
28
|
)
|
|
27
29
|
from ..config_io import mutate_text_file
|
|
28
30
|
|
|
@@ -156,6 +158,92 @@ def _approval_blocks(name: str) -> str:
|
|
|
156
158
|
return "\n".join(lines) + "\n"
|
|
157
159
|
|
|
158
160
|
|
|
161
|
+
# A spawned child runs without the rest of ~/.codex/config.toml (see
|
|
162
|
+
# spawn_argv), but it still needs the user's provider and account settings,
|
|
163
|
+
# and their default model/effort for roles that do not pin their own.
|
|
164
|
+
_CHILD_CARRIED_CONFIG_KEYS = (
|
|
165
|
+
"model", "model_reasoning_effort", "model_provider", "model_providers",
|
|
166
|
+
"openai_base_url", "forced_login_method", "forced_chatgpt_workspace_id",
|
|
167
|
+
"cli_auth_credentials_store",
|
|
168
|
+
)
|
|
169
|
+
_THREAD_KEEPER_ENTRY_KEYS = (
|
|
170
|
+
"command", "args", "cwd", "startup_timeout_sec", "startup_timeout_ms",
|
|
171
|
+
"tool_timeout_sec",
|
|
172
|
+
)
|
|
173
|
+
_BARE_KEY_RE = re.compile(r"^[A-Za-z0-9_-]+$")
|
|
174
|
+
_IGNORE_USER_CONFIG_SUPPORT: dict[str, bool] = {}
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _toml_key(key: str) -> str:
|
|
178
|
+
return key if _BARE_KEY_RE.fullmatch(key) else json.dumps(key)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _toml_literal(value) -> str | None:
|
|
182
|
+
if isinstance(value, bool):
|
|
183
|
+
return "true" if value else "false"
|
|
184
|
+
if isinstance(value, (int, float)):
|
|
185
|
+
return repr(value)
|
|
186
|
+
if isinstance(value, str):
|
|
187
|
+
return json.dumps(value)
|
|
188
|
+
if isinstance(value, list):
|
|
189
|
+
items = [_toml_literal(item) for item in value]
|
|
190
|
+
if any(item is None for item in items):
|
|
191
|
+
return None
|
|
192
|
+
return "[" + ", ".join(items) + "]"
|
|
193
|
+
return None # dates and other exotic values are not carried
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _config_overrides(prefix: str, value) -> list[str]:
|
|
197
|
+
"""`-c` strings that recreate one config value; tables are flattened."""
|
|
198
|
+
if isinstance(value, dict):
|
|
199
|
+
out: list[str] = []
|
|
200
|
+
for key, item in value.items():
|
|
201
|
+
out += _config_overrides(f"{prefix}.{_toml_key(str(key))}", item)
|
|
202
|
+
return out
|
|
203
|
+
literal = _toml_literal(value)
|
|
204
|
+
return [] if literal is None else [f"{prefix}={literal}"]
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _thread_keeper_entry_overrides(entry: dict) -> list[str]:
|
|
208
|
+
"""The user's thread-keeper MCP entry, minus env the child should not get.
|
|
209
|
+
|
|
210
|
+
Without an entry the child runs the same interpreter and package as the
|
|
211
|
+
spawning server, like the synthesized slim config for Claude children.
|
|
212
|
+
"""
|
|
213
|
+
command = entry.get("command")
|
|
214
|
+
if not isinstance(command, str) or not command.strip():
|
|
215
|
+
entry = {
|
|
216
|
+
"command": sys.executable,
|
|
217
|
+
"args": ["-m", "threadkeeper.server"],
|
|
218
|
+
"env": {"PYTHONPATH": str(Path(__file__).resolve().parents[2])},
|
|
219
|
+
}
|
|
220
|
+
prefix = "mcp_servers.thread-keeper"
|
|
221
|
+
out: list[str] = []
|
|
222
|
+
for key in _THREAD_KEEPER_ENTRY_KEYS:
|
|
223
|
+
if key in entry:
|
|
224
|
+
out += _config_overrides(f"{prefix}.{key}", entry[key])
|
|
225
|
+
for key, value in child_mcp_entry_env(entry.get("env")).items():
|
|
226
|
+
out.append(f"{prefix}.env.{_toml_key(key)}={json.dumps(value)}")
|
|
227
|
+
return out
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _supports_ignore_user_config(bin_path: str) -> bool:
|
|
231
|
+
"""Whether this codex build has `exec --ignore-user-config` (cached)."""
|
|
232
|
+
cached = _IGNORE_USER_CONFIG_SUPPORT.get(bin_path)
|
|
233
|
+
if cached is not None:
|
|
234
|
+
return cached
|
|
235
|
+
try:
|
|
236
|
+
probe = subprocess.run(
|
|
237
|
+
[bin_path, "exec", "--help"], capture_output=True, text=True,
|
|
238
|
+
timeout=10, check=False,
|
|
239
|
+
)
|
|
240
|
+
except Exception:
|
|
241
|
+
return False # not cached: a later spawn probes again
|
|
242
|
+
supported = "--ignore-user-config" in (probe.stdout or "") + (probe.stderr or "")
|
|
243
|
+
_IGNORE_USER_CONFIG_SUPPORT[bin_path] = supported
|
|
244
|
+
return supported
|
|
245
|
+
|
|
246
|
+
|
|
159
247
|
def _parse_toml(body: str) -> dict:
|
|
160
248
|
try:
|
|
161
249
|
import tomllib # py3.11+
|
|
@@ -346,6 +434,12 @@ class CodexAdapter(CLIAdapter):
|
|
|
346
434
|
thread-keeper identity variables in ``CHILD_MCP_ENV_KEYS`` are
|
|
347
435
|
forwarded by name through ``env_vars``.
|
|
348
436
|
|
|
437
|
+
The child runs with ``--ignore-user-config`` when the codex build has
|
|
438
|
+
it: only the user's provider/account keys and their thread-keeper
|
|
439
|
+
entry are carried over, so no other MCP server, plugin, or hook starts
|
|
440
|
+
inside an autonomous child. ``enabled_tools`` then exposes exactly the
|
|
441
|
+
granted thread-keeper tools.
|
|
442
|
+
|
|
349
443
|
`extra_allowed_tools` is the child's full Claude-style allowlist.
|
|
350
444
|
`codex exec` runs with approval policy "never", so a thread-keeper
|
|
351
445
|
write tool that is not pre-approved fails with "MCP tool call requires
|
|
@@ -371,29 +465,54 @@ class CodexAdapter(CLIAdapter):
|
|
|
371
465
|
if {"websearch", "webfetch"} & requested_tools:
|
|
372
466
|
argv.append("--search")
|
|
373
467
|
argv += ["exec", "--skip-git-repo-check"]
|
|
468
|
+
user_config = _read_toml(self.config_path)
|
|
469
|
+
overrides: list[str] = []
|
|
470
|
+
# Like Claude's --strict-mcp-config: the child gets thread-keeper and
|
|
471
|
+
# nothing else from the user's setup. With the user config loaded,
|
|
472
|
+
# every configured MCP server, plugin (computer use, browser, Drive)
|
|
473
|
+
# and hook started inside autonomous children, including the web
|
|
474
|
+
# researcher that reads untrusted pages.
|
|
475
|
+
if _supports_ignore_user_config(bin_path):
|
|
476
|
+
argv.append("--ignore-user-config")
|
|
477
|
+
for key in _CHILD_CARRIED_CONFIG_KEYS:
|
|
478
|
+
if key in user_config:
|
|
479
|
+
overrides += _config_overrides(_toml_key(key), user_config[key])
|
|
480
|
+
entry = (user_config.get("mcp_servers") or {}).get("thread-keeper")
|
|
481
|
+
overrides += _thread_keeper_entry_overrides(
|
|
482
|
+
entry if isinstance(entry, dict) else {}
|
|
483
|
+
)
|
|
484
|
+
else:
|
|
485
|
+
for name in (user_config.get("mcp_servers") or {}):
|
|
486
|
+
if name != "thread-keeper":
|
|
487
|
+
overrides.append(f"mcp_servers.{_toml_key(name)}.enabled=false")
|
|
374
488
|
# Codex launches configured stdio MCP servers from the agent cwd. A
|
|
375
489
|
# repository checkout can therefore shadow the installed package used
|
|
376
490
|
# by thread-keeper's MCP entry. Scope safe-path mode to the MCP server
|
|
377
491
|
# only; shell/test Python processes in the child keep normal semantics.
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
]
|
|
492
|
+
safepath = 'mcp_servers.thread-keeper.env.PYTHONSAFEPATH="1"'
|
|
493
|
+
if safepath not in overrides:
|
|
494
|
+
overrides.append(safepath)
|
|
382
495
|
# Codex also starts MCP servers with a scrubbed environment, so the
|
|
383
496
|
# child's thread-keeper server would not know it is this child: no
|
|
384
497
|
# forced cid, write origin, or Curator pass, and every report write
|
|
385
498
|
# was refused as unauthorized. Forward those names from the child's
|
|
386
499
|
# own environment; the values never land on argv.
|
|
387
|
-
|
|
388
|
-
"-c",
|
|
500
|
+
overrides.append(
|
|
389
501
|
"mcp_servers.thread-keeper.env_vars="
|
|
390
|
-
+ json.dumps(list(CHILD_MCP_ENV_KEYS))
|
|
502
|
+
+ json.dumps(list(CHILD_MCP_ENV_KEYS))
|
|
503
|
+
)
|
|
504
|
+
# Expose exactly the granted tools, as --allowedTools does for Claude;
|
|
505
|
+
# approvals alone left every tool the user pre-approved callable.
|
|
506
|
+
granted = _granted_thread_keeper_tools(extra_allowed_tools)
|
|
507
|
+
overrides.append(
|
|
508
|
+
"mcp_servers.thread-keeper.enabled_tools=" + json.dumps(granted)
|
|
509
|
+
)
|
|
510
|
+
overrides += [
|
|
511
|
+
f'mcp_servers.thread-keeper.tools.{tool}.approval_mode="approve"'
|
|
512
|
+
for tool in granted
|
|
391
513
|
]
|
|
392
|
-
for
|
|
393
|
-
argv += [
|
|
394
|
-
"-c",
|
|
395
|
-
f'mcp_servers.thread-keeper.tools.{tool}.approval_mode="approve"',
|
|
396
|
-
]
|
|
514
|
+
for override in overrides:
|
|
515
|
+
argv += ["-c", override]
|
|
397
516
|
if model:
|
|
398
517
|
argv += ["-m", model]
|
|
399
518
|
if effort:
|
|
@@ -170,17 +170,34 @@ def sanitize_gh_body_args(args: Sequence[str]) -> tuple[list[str], list[Path]]:
|
|
|
170
170
|
return out, cleanup
|
|
171
171
|
|
|
172
172
|
|
|
173
|
+
def _is_safety_wrapper(path: str) -> bool:
|
|
174
|
+
"""Whether `path` is one of spawn()'s gh wrapper scripts, not gh itself."""
|
|
175
|
+
try:
|
|
176
|
+
with open(path, "rb") as fh:
|
|
177
|
+
return b"threadkeeper.github_safety" in fh.read(512)
|
|
178
|
+
except OSError:
|
|
179
|
+
return False
|
|
180
|
+
|
|
181
|
+
|
|
173
182
|
def _resolve_real_gh() -> str:
|
|
183
|
+
"""The real gh binary, never a safety wrapper.
|
|
184
|
+
|
|
185
|
+
A child's MCP server can inherit the wrapper-first PATH without
|
|
186
|
+
THREADKEEPER_REAL_GH (Codex scrubs MCP server environments). Taking the
|
|
187
|
+
first `gh` on PATH then found the wrapper itself, which re-ran itself
|
|
188
|
+
until the caller's timeout, so every issue dedup check failed.
|
|
189
|
+
"""
|
|
174
190
|
env = os.environ.get("THREADKEEPER_REAL_GH", "").strip()
|
|
175
|
-
if env:
|
|
191
|
+
if env and not _is_safety_wrapper(env):
|
|
176
192
|
return env
|
|
177
193
|
wrapper_dir = os.environ.get("THREADKEEPER_GH_WRAPPER_DIR", "").strip()
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
194
|
+
for part in os.environ.get("PATH", "").split(os.pathsep):
|
|
195
|
+
if not part or (wrapper_dir and Path(part) == Path(wrapper_dir)):
|
|
196
|
+
continue
|
|
197
|
+
found = shutil.which("gh", path=part)
|
|
198
|
+
if found and not _is_safety_wrapper(found):
|
|
199
|
+
return found
|
|
200
|
+
return ""
|
|
184
201
|
|
|
185
202
|
|
|
186
203
|
def run_wrapped_gh(args: Sequence[str], real_gh: str | None = None) -> int:
|
|
@@ -42,6 +42,24 @@ _CLAUDE_MODEL_RE = re.compile(r"^(?:opus|sonnet|haiku|claude)\b", re.IGNORECASE)
|
|
|
42
42
|
def _looks_like_claude_model(model: str) -> bool:
|
|
43
43
|
return bool(isinstance(model, str) and _CLAUDE_MODEL_RE.match(model.strip()))
|
|
44
44
|
|
|
45
|
+
|
|
46
|
+
# OpenAI model names Codex serves. Narrow on purpose, like the Claude pattern.
|
|
47
|
+
_OPENAI_MODEL_RE = re.compile(r"^(?:gpt-|o\d\b|codex-)", re.IGNORECASE)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def model_home_cli(model: str) -> str:
|
|
51
|
+
"""The CLI whose provider owns an explicitly requested model, or "".
|
|
52
|
+
|
|
53
|
+
`spawn(model="opus")` routed to Codex (or a GPT model routed to Claude)
|
|
54
|
+
launches a child the provider rejects at once, so an explicit model
|
|
55
|
+
decides between those two CLIs when the caller did not pick one.
|
|
56
|
+
"""
|
|
57
|
+
if _looks_like_claude_model(model):
|
|
58
|
+
return "claude"
|
|
59
|
+
if isinstance(model, str) and _OPENAI_MODEL_RE.match(model.strip()):
|
|
60
|
+
return "codex"
|
|
61
|
+
return ""
|
|
62
|
+
|
|
45
63
|
SUPPORTED_CLIS = ("claude", "codex", "antigravity", "copilot")
|
|
46
64
|
CLI_ALIASES = {
|
|
47
65
|
"agy": "antigravity",
|