shikiru-cli 0.2.0__py3-none-any.whl
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.
- shikiru/__init__.py +1 -0
- shikiru/__main__.py +8 -0
- shikiru/agents/__init__.py +3 -0
- shikiru/agents/contracts.py +590 -0
- shikiru/agents/registry.py +87 -0
- shikiru/agents/sdk/__init__.py +17 -0
- shikiru/agents/sdk/reset_time.py +137 -0
- shikiru/agents/sdk/turn.py +335 -0
- shikiru/agents/settings.py +151 -0
- shikiru/agents/testing/__init__.py +26 -0
- shikiru/agents/testing/contract_suite.py +653 -0
- shikiru/agents/testing/resume.py +15 -0
- shikiru/bootstrap.py +1277 -0
- shikiru/cli/__init__.py +1 -0
- shikiru/cli/app.py +770 -0
- shikiru/cli/context.py +63 -0
- shikiru/cli/render.py +1475 -0
- shikiru/cli/report.py +156 -0
- shikiru/cli/session_prompt.py +63 -0
- shikiru/cli/view.py +131 -0
- shikiru/clock.py +41 -0
- shikiru/config/__init__.py +8 -0
- shikiru/config/defaults.toml +131 -0
- shikiru/config/loader.py +178 -0
- shikiru/config/settings.py +392 -0
- shikiru/dashboard/__init__.py +3 -0
- shikiru/dashboard/actions_list.py +799 -0
- shikiru/dashboard/activity.py +150 -0
- shikiru/dashboard/app.py +1765 -0
- shikiru/dashboard/blocker_evidence.py +122 -0
- shikiru/dashboard/blocker_rows.py +84 -0
- shikiru/dashboard/briefing_view.py +871 -0
- shikiru/dashboard/contract.py +1086 -0
- shikiru/dashboard/covered.py +32 -0
- shikiru/dashboard/detail.py +1209 -0
- shikiru/dashboard/detail_fold.py +244 -0
- shikiru/dashboard/detail_hint.py +68 -0
- shikiru/dashboard/details_list_view.py +150 -0
- shikiru/dashboard/diagnostics.py +80 -0
- shikiru/dashboard/diagnostics_sections.py +186 -0
- shikiru/dashboard/evidence_table.py +123 -0
- shikiru/dashboard/extra_details.py +214 -0
- shikiru/dashboard/fault_guidance.py +216 -0
- shikiru/dashboard/fold_bodies.py +276 -0
- shikiru/dashboard/help_keys.py +166 -0
- shikiru/dashboard/help_screen.py +204 -0
- shikiru/dashboard/interaction.py +17 -0
- shikiru/dashboard/ladder_editor.py +544 -0
- shikiru/dashboard/live_settings.py +62 -0
- shikiru/dashboard/motion.py +368 -0
- shikiru/dashboard/orchestration_fields.py +201 -0
- shikiru/dashboard/palette.py +1606 -0
- shikiru/dashboard/palette_app.py +89 -0
- shikiru/dashboard/palette_drafts.py +33 -0
- shikiru/dashboard/resolution.py +573 -0
- shikiru/dashboard/resolution_view.py +302 -0
- shikiru/dashboard/row_list.py +256 -0
- shikiru/dashboard/section_rows.py +173 -0
- shikiru/dashboard/services.py +665 -0
- shikiru/dashboard/session_history.py +402 -0
- shikiru/dashboard/shikiru_init_ascii_art.txt +40 -0
- shikiru/dashboard/task_picker.py +358 -0
- shikiru/dashboard/tick.py +59 -0
- shikiru/dashboard/timeline_filter.py +129 -0
- shikiru/dashboard/timeline_section.py +385 -0
- shikiru/dashboard/usage_body.py +269 -0
- shikiru/dashboard/views.py +2314 -0
- shikiru/dashboard/wizard.py +1389 -0
- shikiru/domain/__init__.py +1 -0
- shikiru/domain/acceptance.py +130 -0
- shikiru/domain/activity.py +28 -0
- shikiru/domain/attempts.py +333 -0
- shikiru/domain/briefing.py +257 -0
- shikiru/domain/commands.py +219 -0
- shikiru/domain/decisions.py +231 -0
- shikiru/domain/docker.py +262 -0
- shikiru/domain/engine_uptime.py +87 -0
- shikiru/domain/escalation.py +194 -0
- shikiru/domain/escalation_evidence.py +186 -0
- shikiru/domain/escalation_panel.py +144 -0
- shikiru/domain/events.py +71 -0
- shikiru/domain/evidence_export.py +89 -0
- shikiru/domain/faults.py +86 -0
- shikiru/domain/feedback.py +180 -0
- shikiru/domain/feedback_rounds.py +172 -0
- shikiru/domain/fencing.py +31 -0
- shikiru/domain/ladder.py +102 -0
- shikiru/domain/ladder_text.py +44 -0
- shikiru/domain/levels.py +44 -0
- shikiru/domain/motion.py +199 -0
- shikiru/domain/packet_evidence.py +377 -0
- shikiru/domain/pushes.py +47 -0
- shikiru/domain/quota_blocks.py +44 -0
- shikiru/domain/quota_waits.py +219 -0
- shikiru/domain/recovery.py +194 -0
- shikiru/domain/reports.py +498 -0
- shikiru/domain/results.py +217 -0
- shikiru/domain/runs.py +85 -0
- shikiru/domain/scheduling.py +231 -0
- shikiru/domain/status_tasks.py +40 -0
- shikiru/domain/steps.py +325 -0
- shikiru/domain/tasks.py +174 -0
- shikiru/domain/timeline.py +208 -0
- shikiru/domain/timeline_phrases.py +271 -0
- shikiru/domain/versions.py +18 -0
- shikiru/domain/workflow.py +372 -0
- shikiru/engine/__init__.py +0 -0
- shikiru/engine/acceptance.py +90 -0
- shikiru/engine/agents.py +61 -0
- shikiru/engine/cosmetic_decorator.py +99 -0
- shikiru/engine/daemon.py +409 -0
- shikiru/engine/dependency.py +37 -0
- shikiru/engine/escalation_filing.py +110 -0
- shikiru/engine/escalations.py +401 -0
- shikiru/engine/events.py +285 -0
- shikiru/engine/evidence.py +81 -0
- shikiru/engine/fault_reporter.py +144 -0
- shikiru/engine/feedback.py +577 -0
- shikiru/engine/feedback_audit.py +80 -0
- shikiru/engine/feedback_intake.py +95 -0
- shikiru/engine/feedback_snapshot.py +148 -0
- shikiru/engine/flow.py +87 -0
- shikiru/engine/handlers/__init__.py +33 -0
- shikiru/engine/handlers/base.py +148 -0
- shikiru/engine/handlers/engine.py +24 -0
- shikiru/engine/handlers/escalations.py +174 -0
- shikiru/engine/handlers/operator.py +81 -0
- shikiru/engine/handlers/tasks.py +237 -0
- shikiru/engine/history.py +128 -0
- shikiru/engine/integration.py +1234 -0
- shikiru/engine/integration_turns.py +337 -0
- shikiru/engine/layout_guard.py +362 -0
- shikiru/engine/lifecycle.py +60 -0
- shikiru/engine/monitor.py +317 -0
- shikiru/engine/notification_delivery.py +124 -0
- shikiru/engine/notification_feed.py +57 -0
- shikiru/engine/notifier.py +91 -0
- shikiru/engine/pull_request_closing.py +117 -0
- shikiru/engine/push_service.py +177 -0
- shikiru/engine/quota_probe_threads.py +79 -0
- shikiru/engine/quota_reconciler.py +210 -0
- shikiru/engine/quota_schedule.py +24 -0
- shikiru/engine/quota_waits.py +102 -0
- shikiru/engine/reconcile.py +91 -0
- shikiru/engine/recovery.py +284 -0
- shikiru/engine/retention.py +102 -0
- shikiru/engine/root_preparation.py +86 -0
- shikiru/engine/scheduler.py +320 -0
- shikiru/engine/session_guard.py +46 -0
- shikiru/engine/sessions.py +95 -0
- shikiru/engine/signalled.py +75 -0
- shikiru/engine/step_runner.py +140 -0
- shikiru/engine/supervisor.py +101 -0
- shikiru/engine/takeover.py +223 -0
- shikiru/engine/task_facts.py +46 -0
- shikiru/engine/tick_guard.py +95 -0
- shikiru/engine/titles.py +297 -0
- shikiru/engine/transitions.py +79 -0
- shikiru/engine/turn_context.py +206 -0
- shikiru/engine/turn_execution.py +30 -0
- shikiru/engine/turn_loop.py +230 -0
- shikiru/engine/turn_processes.py +79 -0
- shikiru/engine/turns.py +457 -0
- shikiru/errors.py +144 -0
- shikiru/helper/__init__.py +3 -0
- shikiru/helper/helper.py +182 -0
- shikiru/helper/models.py +11 -0
- shikiru/mcp/__init__.py +1 -0
- shikiru/mcp/server.py +196 -0
- shikiru/operator/__init__.py +33 -0
- shikiru/operator/attachment.py +24 -0
- shikiru/operator/commands.py +319 -0
- shikiru/operator/detectors.py +374 -0
- shikiru/operator/doctor.py +351 -0
- shikiru/operator/events.py +57 -0
- shikiru/operator/evidence_export.py +498 -0
- shikiru/operator/evidence_writer.py +78 -0
- shikiru/operator/lifecycle.py +294 -0
- shikiru/operator/plugin_checks.py +194 -0
- shikiru/operator/probe.py +287 -0
- shikiru/operator/queries.py +1601 -0
- shikiru/operator/services.py +36 -0
- shikiru/operator/sessions.py +378 -0
- shikiru/operator/strip_views.py +162 -0
- shikiru/operator/timeline.py +416 -0
- shikiru/operator/toml_text.py +62 -0
- shikiru/palette.py +18 -0
- shikiru/paths.py +91 -0
- shikiru/plugins/__init__.py +0 -0
- shikiru/plugins/agents/__init__.py +1 -0
- shikiru/plugins/agents/claude/__init__.py +5 -0
- shikiru/plugins/agents/claude/agent.py +251 -0
- shikiru/plugins/agents/claude/context.py +116 -0
- shikiru/plugins/agents/claude/headless.py +92 -0
- shikiru/plugins/agents/claude/hook.py +57 -0
- shikiru/plugins/agents/claude/model_catalog.py +105 -0
- shikiru/plugins/agents/claude/options.py +62 -0
- shikiru/plugins/agents/claude/turn.py +1018 -0
- shikiru/plugins/agents/codex/__init__.py +5 -0
- shikiru/plugins/agents/codex/agent.py +486 -0
- shikiru/plugins/agents/codex/headless.py +166 -0
- shikiru/plugins/agents/codex/home.py +14 -0
- shikiru/plugins/agents/codex/model_catalog.py +128 -0
- shikiru/plugins/agents/codex/options.py +94 -0
- shikiru/plugins/agents/codex/quota.py +195 -0
- shikiru/plugins/agents/codex/sandbox.py +31 -0
- shikiru/plugins/agents/codex/sandbox_probe.py +97 -0
- shikiru/plugins/agents/codex/turn.py +767 -0
- shikiru/plugins/agents/codex/usage.py +106 -0
- shikiru/plugins/agents/shared_settings.py +62 -0
- shikiru/plugins/widgets/__init__.py +3 -0
- shikiru/plugins/widgets/dialogue.py +144 -0
- shikiru/plugins/widgets/disk.py +123 -0
- shikiru/plugins/widgets/docker.py +225 -0
- shikiru/plugins/widgets/escalations.py +402 -0
- shikiru/plugins/widgets/faults.py +204 -0
- shikiru/plugins/widgets/header.py +321 -0
- shikiru/plugins/widgets/queue.py +1140 -0
- shikiru/plugins/widgets/queue_hint.py +93 -0
- shikiru/plugins/widgets/resources.py +105 -0
- shikiru/plugins/widgets/strips.py +82 -0
- shikiru/plugins/widgets/usage.py +176 -0
- shikiru/private.py +94 -0
- shikiru/process.py +535 -0
- shikiru/prompts/__init__.py +3 -0
- shikiru/prompts/renderer.py +85 -0
- shikiru/prompts/templates/_base.md.j2 +17 -0
- shikiru/prompts/templates/_macros.md.j2 +61 -0
- shikiru/prompts/templates/conflicts.md.j2 +22 -0
- shikiru/prompts/templates/consult.md.j2 +5 -0
- shikiru/prompts/templates/continue.md.j2 +4 -0
- shikiru/prompts/templates/feedback_analysis.md.j2 +29 -0
- shikiru/prompts/templates/feedback_rework.md.j2 +33 -0
- shikiru/prompts/templates/feedback_verify.md.j2 +33 -0
- shikiru/prompts/templates/handoff.md.j2 +23 -0
- shikiru/prompts/templates/implement.md.j2 +13 -0
- shikiru/prompts/templates/plan.md.j2 +30 -0
- shikiru/prompts/templates/rework.md.j2 +13 -0
- shikiru/prompts/templates/triage.md.j2 +20 -0
- shikiru/prompts/templates/verify.md.j2 +29 -0
- shikiru/store/__init__.py +1 -0
- shikiru/store/commands.py +242 -0
- shikiru/store/database.py +297 -0
- shikiru/store/escalations.py +240 -0
- shikiru/store/events.py +213 -0
- shikiru/store/feedback.py +266 -0
- shikiru/store/migrations/001_initial.sql +63 -0
- shikiru/store/migrations/002_headless_turns.sql +31 -0
- shikiru/store/migrations/003_drop_command_handled_at.sql +1 -0
- shikiru/store/migrations/004_repo_base_ref.sql +3 -0
- shikiru/store/migrations/005_complexity_explicit.sql +3 -0
- shikiru/store/migrations/006_events_kind_index.sql +1 -0
- shikiru/store/migrations/007_notification_cursor.sql +1 -0
- shikiru/store/migrations/008_reported_usage.sql +1 -0
- shikiru/store/migrations/009_billing_declarations.sql +2 -0
- shikiru/store/migrations/010_canonical_quota_windows.sql +3 -0
- shikiru/store/migrations/012_drop_task_quota_evidence.sql +2 -0
- shikiru/store/migrations/013_push_targets.sql +7 -0
- shikiru/store/migrations/014_agent_block_time.sql +6 -0
- shikiru/store/migrations/015_repair_orphan_agent_blocks.sql +3 -0
- shikiru/store/migrations/016_feedback_rounds.sql +29 -0
- shikiru/store/migrations/017_feedback_round_comments.sql +5 -0
- shikiru/store/migrations/__init__.py +1 -0
- shikiru/store/notification_cursor.py +29 -0
- shikiru/store/pushes.py +117 -0
- shikiru/store/quotas.py +153 -0
- shikiru/store/reader.py +310 -0
- shikiru/store/retention.py +92 -0
- shikiru/store/runs.py +227 -0
- shikiru/store/tasks.py +949 -0
- shikiru/telemetry/__init__.py +1 -0
- shikiru/telemetry/disk.py +167 -0
- shikiru/telemetry/docker.py +164 -0
- shikiru/telemetry/resources.py +296 -0
- shikiru/tmux/__init__.py +3 -0
- shikiru/tmux/client.py +637 -0
- shikiru/tmux/decorator.py +225 -0
- shikiru/tmux/layout.py +200 -0
- shikiru/tmux/session.py +381 -0
- shikiru/ui/__init__.py +9 -0
- shikiru/ui/age.py +45 -0
- shikiru/ui/bubbles.py +41 -0
- shikiru/ui/compact.py +1209 -0
- shikiru/ui/console.py +49 -0
- shikiru/ui/fold.py +50 -0
- shikiru/ui/gated_static.py +117 -0
- shikiru/ui/gauge.py +40 -0
- shikiru/ui/glyphs.py +109 -0
- shikiru/ui/keys.py +57 -0
- shikiru/ui/live_interval.py +42 -0
- shikiru/ui/popup.py +311 -0
- shikiru/ui/quota_status.py +26 -0
- shikiru/ui/recent_window.py +78 -0
- shikiru/ui/screen.py +57 -0
- shikiru/ui/section.py +193 -0
- shikiru/ui/session.py +65 -0
- shikiru/ui/size.py +17 -0
- shikiru/ui/splash.py +42 -0
- shikiru/ui/status.py +47 -0
- shikiru/ui/status_strip.py +149 -0
- shikiru/ui/stream.py +152 -0
- shikiru/ui/strip.py +332 -0
- shikiru/ui/theme.py +83 -0
- shikiru/ui/tokens.py +51 -0
- shikiru/view_log.py +126 -0
- shikiru/workspace/__init__.py +3 -0
- shikiru/workspace/base_refs.py +160 -0
- shikiru/workspace/caches.py +136 -0
- shikiru/workspace/facts.py +118 -0
- shikiru/workspace/git.py +601 -0
- shikiru/workspace/github.py +744 -0
- shikiru/workspace/messages.py +107 -0
- shikiru/workspace/reports.py +145 -0
- shikiru/workspace/roots.py +180 -0
- shikiru_cli-0.2.0.dist-info/METADATA +428 -0
- shikiru_cli-0.2.0.dist-info/RECORD +319 -0
- shikiru_cli-0.2.0.dist-info/WHEEL +4 -0
- shikiru_cli-0.2.0.dist-info/entry_points.txt +18 -0
- shikiru_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
"""Claude Code headless turn plugin."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from collections.abc import Mapping
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from tempfile import TemporaryDirectory
|
|
10
|
+
from typing import TYPE_CHECKING, cast
|
|
11
|
+
|
|
12
|
+
from pydantic import ValidationError
|
|
13
|
+
|
|
14
|
+
from shikiru.agents.contracts import (
|
|
15
|
+
BILLING_SOURCE_OPERATOR_CONFIG,
|
|
16
|
+
DOCTOR_TIMEOUT_SECONDS,
|
|
17
|
+
UNKNOWN_BILLING_DECLARATION,
|
|
18
|
+
BillingDeclaration,
|
|
19
|
+
CheckResult,
|
|
20
|
+
ModelCatalog,
|
|
21
|
+
)
|
|
22
|
+
from shikiru.agents.sdk.reset_time import ResetTimeParser
|
|
23
|
+
from shikiru.agents.sdk.turn import StreamingTurn
|
|
24
|
+
from shikiru.errors import CommandFailed, ShikiruError
|
|
25
|
+
from shikiru.plugins.agents.claude.headless import ClaudeHeadless
|
|
26
|
+
from shikiru.plugins.agents.claude.model_catalog import parse_model_catalog
|
|
27
|
+
from shikiru.plugins.agents.claude.options import (
|
|
28
|
+
ADD_DIR_FLAG,
|
|
29
|
+
CLAUDE_COMMAND,
|
|
30
|
+
CLAUDE_SETTINGS,
|
|
31
|
+
EFFORT_FLAG,
|
|
32
|
+
EMPTY_MCP_CONFIG,
|
|
33
|
+
EMPTY_TOOLS,
|
|
34
|
+
INPUT_FORMAT_FLAG,
|
|
35
|
+
MCP_CONFIG_FLAG,
|
|
36
|
+
MODEL_CATALOG_REQUEST_ID,
|
|
37
|
+
MODEL_FLAG,
|
|
38
|
+
NO_SESSION_PERSISTENCE_FLAG,
|
|
39
|
+
OUTPUT_FORMAT_FLAG,
|
|
40
|
+
PRINT_FLAG,
|
|
41
|
+
RESUME_FLAG,
|
|
42
|
+
SAFE_MODE_FLAG,
|
|
43
|
+
STREAM_JSON_FORMAT,
|
|
44
|
+
STRICT_MCP_CONFIG_FLAG,
|
|
45
|
+
TOOLS_FLAG,
|
|
46
|
+
VERBOSE_FLAG,
|
|
47
|
+
ClaudeOptions,
|
|
48
|
+
)
|
|
49
|
+
from shikiru.plugins.agents.claude.turn import ClaudeTurnSequence
|
|
50
|
+
from shikiru.plugins.agents.shared_settings import options_error
|
|
51
|
+
|
|
52
|
+
if TYPE_CHECKING:
|
|
53
|
+
from collections.abc import Sequence
|
|
54
|
+
|
|
55
|
+
from shikiru.agents.contracts import AgentContext, HeadlessAgent, Rung, Turn, TurnSpec
|
|
56
|
+
from shikiru.agents.settings import AgentSetting
|
|
57
|
+
from shikiru.clock import Clock
|
|
58
|
+
from shikiru.process import SubprocessRunner
|
|
59
|
+
|
|
60
|
+
CLAUDE_VERSION_ARGV = (CLAUDE_COMMAND, "--version")
|
|
61
|
+
AUTH_STATUS_ARGV = (CLAUDE_COMMAND, "auth", "status", "--json")
|
|
62
|
+
_AUTH_STATUS_LOGGED_IN_FIELD = "loggedIn"
|
|
63
|
+
_INVALID_AUTH_STATUS_MESSAGE = "Claude auth status did not return valid JSON"
|
|
64
|
+
_MODEL_CATALOG_TIMEOUT_SECONDS = 20.0
|
|
65
|
+
_CONTROL_REQUEST_TYPE = "control_request"
|
|
66
|
+
_INITIALIZE_SUBTYPE = "initialize"
|
|
67
|
+
_MODEL_CATALOG_REQUEST = json.dumps(
|
|
68
|
+
{
|
|
69
|
+
"type": _CONTROL_REQUEST_TYPE,
|
|
70
|
+
"request_id": MODEL_CATALOG_REQUEST_ID,
|
|
71
|
+
"request": {
|
|
72
|
+
"subtype": _INITIALIZE_SUBTYPE,
|
|
73
|
+
"hooks": {},
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
separators=(",", ":"),
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class ClaudeAgent:
|
|
81
|
+
"""Build Claude Code turn processes and health checks."""
|
|
82
|
+
|
|
83
|
+
name = "claude"
|
|
84
|
+
|
|
85
|
+
def __init__(self, runner: SubprocessRunner, clock: Clock, options: ClaudeOptions) -> None:
|
|
86
|
+
self._runner = runner
|
|
87
|
+
self._clock = clock
|
|
88
|
+
self._options = options
|
|
89
|
+
self._reset_time = ResetTimeParser(clock)
|
|
90
|
+
self._billing_declaration = (
|
|
91
|
+
BillingDeclaration(options.billing_mode, BILLING_SOURCE_OPERATOR_CONFIG)
|
|
92
|
+
if options.billing_mode is not None
|
|
93
|
+
else UNKNOWN_BILLING_DECLARATION
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def billing_declaration(self) -> BillingDeclaration:
|
|
98
|
+
"""Return the explicit plugin option or the unknown declaration."""
|
|
99
|
+
return self._billing_declaration
|
|
100
|
+
|
|
101
|
+
async def model_catalog(self) -> ModelCatalog:
|
|
102
|
+
"""Read model and per-model effort choices from Claude Code's initialize response."""
|
|
103
|
+
argv = (
|
|
104
|
+
CLAUDE_COMMAND,
|
|
105
|
+
PRINT_FLAG,
|
|
106
|
+
INPUT_FORMAT_FLAG,
|
|
107
|
+
STREAM_JSON_FORMAT,
|
|
108
|
+
OUTPUT_FORMAT_FLAG,
|
|
109
|
+
STREAM_JSON_FORMAT,
|
|
110
|
+
VERBOSE_FLAG,
|
|
111
|
+
NO_SESSION_PERSISTENCE_FLAG,
|
|
112
|
+
SAFE_MODE_FLAG,
|
|
113
|
+
STRICT_MCP_CONFIG_FLAG,
|
|
114
|
+
MCP_CONFIG_FLAG,
|
|
115
|
+
EMPTY_MCP_CONFIG,
|
|
116
|
+
TOOLS_FLAG,
|
|
117
|
+
EMPTY_TOOLS,
|
|
118
|
+
)
|
|
119
|
+
try:
|
|
120
|
+
with TemporaryDirectory() as isolated_directory:
|
|
121
|
+
result = await self._runner.run(
|
|
122
|
+
argv,
|
|
123
|
+
timeout=_MODEL_CATALOG_TIMEOUT_SECONDS,
|
|
124
|
+
cwd=Path(isolated_directory),
|
|
125
|
+
env=self._subprocess_environment(),
|
|
126
|
+
input_text=f"{_MODEL_CATALOG_REQUEST}\n",
|
|
127
|
+
check=False,
|
|
128
|
+
)
|
|
129
|
+
except (FileNotFoundError, OSError, ShikiruError, TimeoutError) as error:
|
|
130
|
+
detail = str(error) or type(error).__name__
|
|
131
|
+
return ModelCatalog(error=f"Claude Code model discovery command failed: {detail}")
|
|
132
|
+
if result.returncode != 0:
|
|
133
|
+
detail = result.stderr.strip() or "no diagnostic output"
|
|
134
|
+
return ModelCatalog(
|
|
135
|
+
error=f"Claude Code model discovery exited with code {result.returncode}: {detail}"
|
|
136
|
+
)
|
|
137
|
+
try:
|
|
138
|
+
return parse_model_catalog(result.stdout, MODEL_CATALOG_REQUEST_ID)
|
|
139
|
+
except TypeError as error:
|
|
140
|
+
return ModelCatalog(error=f"could not parse Claude Code model catalog: {error}")
|
|
141
|
+
|
|
142
|
+
async def start_turn(self, spec: TurnSpec) -> Turn:
|
|
143
|
+
"""Start one process-per-turn Claude Code exchange."""
|
|
144
|
+
return await StreamingTurn.start(spec, self._runner, self.process_sequence(spec))
|
|
145
|
+
|
|
146
|
+
def process_sequence(self, spec: TurnSpec) -> ClaudeTurnSequence:
|
|
147
|
+
"""Build the compact, prompt and retry process sequence for `spec`."""
|
|
148
|
+
return ClaudeTurnSequence(
|
|
149
|
+
spec,
|
|
150
|
+
clock=self._clock,
|
|
151
|
+
reset_time=self._reset_time,
|
|
152
|
+
compact_at_pct=self._options.compact_at_pct,
|
|
153
|
+
args=self._options.args,
|
|
154
|
+
environment=self._turn_environment,
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
def interactive_command(self, session_id: str, run_dir: Path, rung: Rung) -> Sequence[str]:
|
|
158
|
+
"""Return Claude's interactive resume command for a headless session and rung."""
|
|
159
|
+
argv = [CLAUDE_COMMAND, RESUME_FLAG, session_id, MODEL_FLAG, rung.model]
|
|
160
|
+
if rung.effort is not None:
|
|
161
|
+
argv.extend((EFFORT_FLAG, rung.effort))
|
|
162
|
+
argv.extend((ADD_DIR_FLAG, str(run_dir), *self._options.args))
|
|
163
|
+
return tuple(argv)
|
|
164
|
+
|
|
165
|
+
async def doctor(self, models: Sequence[str]) -> list[CheckResult]:
|
|
166
|
+
"""Check Claude's binary, version and authentication."""
|
|
167
|
+
del models
|
|
168
|
+
binary = await self._check_binary()
|
|
169
|
+
if not binary.ok:
|
|
170
|
+
unavailable = "binary unavailable"
|
|
171
|
+
return [
|
|
172
|
+
binary,
|
|
173
|
+
CheckResult("version", False, unavailable),
|
|
174
|
+
CheckResult("login", False, unavailable),
|
|
175
|
+
]
|
|
176
|
+
return [binary, await self._check_version(), await self._check_login()]
|
|
177
|
+
|
|
178
|
+
def settings(self) -> tuple[AgentSetting, ...]:
|
|
179
|
+
"""Return the options worth writing to the project config."""
|
|
180
|
+
return CLAUDE_SETTINGS
|
|
181
|
+
|
|
182
|
+
def headless(self) -> HeadlessAgent | None:
|
|
183
|
+
"""Return Claude's schema-constrained headless client."""
|
|
184
|
+
return ClaudeHeadless(self._runner, self._subprocess_environment())
|
|
185
|
+
|
|
186
|
+
def _subprocess_environment(self) -> dict[str, str]:
|
|
187
|
+
return dict(os.environ)
|
|
188
|
+
|
|
189
|
+
def _turn_environment(self, spec: TurnSpec) -> dict[str, str]:
|
|
190
|
+
environment = dict(os.environ)
|
|
191
|
+
environment.update(spec.env)
|
|
192
|
+
return environment
|
|
193
|
+
|
|
194
|
+
async def _check_binary(self) -> CheckResult:
|
|
195
|
+
try:
|
|
196
|
+
result = await self._runner.run(
|
|
197
|
+
CLAUDE_VERSION_ARGV,
|
|
198
|
+
timeout=DOCTOR_TIMEOUT_SECONDS,
|
|
199
|
+
env=self._subprocess_environment(),
|
|
200
|
+
check=False,
|
|
201
|
+
)
|
|
202
|
+
except (FileNotFoundError, CommandFailed, ShikiruError) as error:
|
|
203
|
+
return CheckResult("binary", False, str(error))
|
|
204
|
+
if result.returncode != 0:
|
|
205
|
+
return CheckResult("binary", False, result.stderr.strip())
|
|
206
|
+
return CheckResult("binary", True, CLAUDE_COMMAND)
|
|
207
|
+
|
|
208
|
+
async def _check_version(self) -> CheckResult:
|
|
209
|
+
try:
|
|
210
|
+
result = await self._runner.run(
|
|
211
|
+
CLAUDE_VERSION_ARGV,
|
|
212
|
+
timeout=DOCTOR_TIMEOUT_SECONDS,
|
|
213
|
+
env=self._subprocess_environment(),
|
|
214
|
+
check=False,
|
|
215
|
+
)
|
|
216
|
+
except (FileNotFoundError, ShikiruError) as error:
|
|
217
|
+
return CheckResult("version", False, str(error))
|
|
218
|
+
detail = result.stdout.strip() or result.stderr.strip()
|
|
219
|
+
return CheckResult("version", result.returncode == 0, detail)
|
|
220
|
+
|
|
221
|
+
async def _check_login(self) -> CheckResult:
|
|
222
|
+
try:
|
|
223
|
+
result = await self._runner.run(
|
|
224
|
+
AUTH_STATUS_ARGV,
|
|
225
|
+
timeout=DOCTOR_TIMEOUT_SECONDS,
|
|
226
|
+
env=self._subprocess_environment(),
|
|
227
|
+
check=False,
|
|
228
|
+
)
|
|
229
|
+
except (FileNotFoundError, ShikiruError) as error:
|
|
230
|
+
return CheckResult("login", False, str(error))
|
|
231
|
+
try:
|
|
232
|
+
payload = json.loads(result.stdout)
|
|
233
|
+
except json.JSONDecodeError:
|
|
234
|
+
return CheckResult("login", False, _INVALID_AUTH_STATUS_MESSAGE)
|
|
235
|
+
if not isinstance(payload, Mapping):
|
|
236
|
+
return CheckResult("login", False, _INVALID_AUTH_STATUS_MESSAGE)
|
|
237
|
+
typed_payload = cast("Mapping[str, object]", payload)
|
|
238
|
+
authenticated = (
|
|
239
|
+
result.returncode == 0 and typed_payload.get(_AUTH_STATUS_LOGGED_IN_FIELD) is True
|
|
240
|
+
)
|
|
241
|
+
detail = "authenticated" if authenticated else "not authenticated"
|
|
242
|
+
return CheckResult("login", authenticated, detail)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def create(context: AgentContext) -> ClaudeAgent:
|
|
246
|
+
"""Build the Claude Code plugin from validated `AgentContext.options`."""
|
|
247
|
+
try:
|
|
248
|
+
options = ClaudeOptions.model_validate(context.options)
|
|
249
|
+
except ValidationError as error:
|
|
250
|
+
raise options_error(CLAUDE_COMMAND, error) from error
|
|
251
|
+
return ClaudeAgent(context.runner, context.clock, options)
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Claude's context window and next-request token estimate used for compaction decisions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from collections.abc import Mapping
|
|
7
|
+
from typing import TYPE_CHECKING, Any, cast
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from collections.abc import Iterable
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
CONTEXT_WINDOW_TOKENS = 200_000
|
|
14
|
+
EXTENDED_CONTEXT_WINDOW_TOKENS = 1_000_000
|
|
15
|
+
EXTENDED_CONTEXT_MODEL_SUFFIX = "[1m]"
|
|
16
|
+
CHARS_PER_TOKEN = 3
|
|
17
|
+
_PERCENT = 100
|
|
18
|
+
_TYPE_FIELD = "type"
|
|
19
|
+
_ASSISTANT_TYPE = "assistant"
|
|
20
|
+
_USER_TYPE = "user"
|
|
21
|
+
_TOOL_RESULT_TYPE = "tool_result"
|
|
22
|
+
_MESSAGE_FIELD = "message"
|
|
23
|
+
_ID_FIELD = "id"
|
|
24
|
+
_USAGE_FIELD = "usage"
|
|
25
|
+
_CONTENT_FIELD = "content"
|
|
26
|
+
_CONTEXT_USAGE_FIELDS = (
|
|
27
|
+
"input_tokens",
|
|
28
|
+
"cache_read_input_tokens",
|
|
29
|
+
"cache_creation_input_tokens",
|
|
30
|
+
"output_tokens",
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def compact_limit_tokens(model: str, compact_at_pct: float) -> int:
|
|
35
|
+
"""Return the tokens at which a request on `model` must be preceded by compaction."""
|
|
36
|
+
window = (
|
|
37
|
+
EXTENDED_CONTEXT_WINDOW_TOKENS
|
|
38
|
+
if model.endswith(EXTENDED_CONTEXT_MODEL_SUFFIX)
|
|
39
|
+
else CONTEXT_WINDOW_TOKENS
|
|
40
|
+
)
|
|
41
|
+
return int(window * compact_at_pct / _PERCENT)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def pending_tokens(value: object) -> int:
|
|
45
|
+
"""Estimate the tokens a tool result adds to the next request."""
|
|
46
|
+
return len(json.dumps(value)) // CHARS_PER_TOKEN
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def read_transcript(path: Path) -> list[Mapping[str, Any]]:
|
|
50
|
+
"""Return the JSON object entries of a Claude session transcript."""
|
|
51
|
+
entries: list[Mapping[str, Any]] = []
|
|
52
|
+
for line in path.read_text(encoding="utf-8").splitlines():
|
|
53
|
+
raw = json.loads(line)
|
|
54
|
+
if isinstance(raw, Mapping):
|
|
55
|
+
entries.append(cast("Mapping[str, Any]", raw))
|
|
56
|
+
return entries
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def last_assistant_context(entries: Iterable[Mapping[str, Any]]) -> tuple[str | None, int]:
|
|
60
|
+
"""Return the id of the last assistant message and the context its request used."""
|
|
61
|
+
message_id: str | None = None
|
|
62
|
+
tokens = 0
|
|
63
|
+
for entry in entries:
|
|
64
|
+
message = _assistant_message(entry)
|
|
65
|
+
if message is not None:
|
|
66
|
+
raw_id = message.get(_ID_FIELD)
|
|
67
|
+
message_id = raw_id if isinstance(raw_id, str) else None
|
|
68
|
+
tokens = _usage_tokens(message.get(_USAGE_FIELD))
|
|
69
|
+
return message_id, tokens
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def transcript_context_tokens(entries: Iterable[Mapping[str, Any]]) -> int:
|
|
73
|
+
"""Estimate the next request: the last assistant context plus the tool results since."""
|
|
74
|
+
tokens = 0
|
|
75
|
+
pending = 0
|
|
76
|
+
for entry in entries:
|
|
77
|
+
message = _assistant_message(entry)
|
|
78
|
+
if message is not None:
|
|
79
|
+
tokens = _usage_tokens(message.get(_USAGE_FIELD))
|
|
80
|
+
pending = 0
|
|
81
|
+
else:
|
|
82
|
+
pending += sum(pending_tokens(result) for result in _tool_results(entry))
|
|
83
|
+
return tokens + pending
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _assistant_message(entry: Mapping[str, Any]) -> Mapping[str, Any] | None:
|
|
87
|
+
if entry.get(_TYPE_FIELD) != _ASSISTANT_TYPE:
|
|
88
|
+
return None
|
|
89
|
+
return _mapping(entry.get(_MESSAGE_FIELD))
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _tool_results(entry: Mapping[str, Any]) -> list[object]:
|
|
93
|
+
message = _mapping(entry.get(_MESSAGE_FIELD)) if entry.get(_TYPE_FIELD) == _USER_TYPE else None
|
|
94
|
+
blocks = message.get(_CONTENT_FIELD) if message is not None else None
|
|
95
|
+
if not isinstance(blocks, list):
|
|
96
|
+
return []
|
|
97
|
+
return [
|
|
98
|
+
block.get(_CONTENT_FIELD)
|
|
99
|
+
for item in cast("list[object]", blocks)
|
|
100
|
+
if (block := _mapping(item)) is not None and block.get(_TYPE_FIELD) == _TOOL_RESULT_TYPE
|
|
101
|
+
]
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _usage_tokens(value: object) -> int:
|
|
105
|
+
usage = _mapping(value)
|
|
106
|
+
if usage is None:
|
|
107
|
+
return 0
|
|
108
|
+
return sum(
|
|
109
|
+
int(amount)
|
|
110
|
+
for field in _CONTEXT_USAGE_FIELDS
|
|
111
|
+
if isinstance(amount := usage.get(field), int | float)
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _mapping(value: object) -> Mapping[str, Any] | None:
|
|
116
|
+
return cast("Mapping[str, Any]", value) if isinstance(value, Mapping) else None
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Claude Code schema-constrained one-shot calls."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from collections.abc import Mapping
|
|
7
|
+
from typing import TYPE_CHECKING, Any, cast
|
|
8
|
+
|
|
9
|
+
from shikiru.errors import HeadlessOutputError
|
|
10
|
+
from shikiru.plugins.agents.claude.options import (
|
|
11
|
+
CLAUDE_COMMAND,
|
|
12
|
+
EFFORT_FLAG,
|
|
13
|
+
EMPTY_TOOLS,
|
|
14
|
+
MODEL_FLAG,
|
|
15
|
+
NO_SESSION_PERSISTENCE_FLAG,
|
|
16
|
+
OUTPUT_FORMAT_FLAG,
|
|
17
|
+
PRINT_FLAG,
|
|
18
|
+
SAFE_MODE_FLAG,
|
|
19
|
+
TOOLS_FLAG,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from datetime import timedelta
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
from shikiru.process import SubprocessRunner
|
|
27
|
+
|
|
28
|
+
_OUTPUT_FORMAT_JSON = "json"
|
|
29
|
+
_JSON_SCHEMA_FLAG = "--json-schema"
|
|
30
|
+
_STRUCTURED_OUTPUT_FIELD = "structured_output"
|
|
31
|
+
_INVALID_JSON_MESSAGE = "Claude headless output was not valid JSON"
|
|
32
|
+
_INVALID_OBJECT_MESSAGE = "Claude headless output was not an object"
|
|
33
|
+
_MISSING_STRUCTURED_OUTPUT_MESSAGE = "Claude headless output had no structured_output"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class ClaudeHeadless:
|
|
37
|
+
"""Runs Claude Code's JSON structured-output mode."""
|
|
38
|
+
|
|
39
|
+
def __init__(self, runner: SubprocessRunner, environment: Mapping[str, str]) -> None:
|
|
40
|
+
"""Use `runner` and the operator's inherited Claude environment."""
|
|
41
|
+
self._runner = runner
|
|
42
|
+
self._environment = dict(environment)
|
|
43
|
+
|
|
44
|
+
async def ask(
|
|
45
|
+
self,
|
|
46
|
+
prompt: str,
|
|
47
|
+
schema: Mapping[str, Any],
|
|
48
|
+
model: str,
|
|
49
|
+
effort: str | None,
|
|
50
|
+
cwd: Path,
|
|
51
|
+
timeout: timedelta,
|
|
52
|
+
) -> Mapping[str, Any]:
|
|
53
|
+
"""Return Claude's schema-constrained `structured_output` value."""
|
|
54
|
+
argv = [
|
|
55
|
+
CLAUDE_COMMAND,
|
|
56
|
+
PRINT_FLAG,
|
|
57
|
+
OUTPUT_FORMAT_FLAG,
|
|
58
|
+
_OUTPUT_FORMAT_JSON,
|
|
59
|
+
_JSON_SCHEMA_FLAG,
|
|
60
|
+
json.dumps(schema, separators=(",", ":")),
|
|
61
|
+
MODEL_FLAG,
|
|
62
|
+
model,
|
|
63
|
+
]
|
|
64
|
+
if effort is not None:
|
|
65
|
+
argv.extend([EFFORT_FLAG, effort])
|
|
66
|
+
argv.extend(
|
|
67
|
+
[
|
|
68
|
+
SAFE_MODE_FLAG,
|
|
69
|
+
TOOLS_FLAG,
|
|
70
|
+
EMPTY_TOOLS,
|
|
71
|
+
NO_SESSION_PERSISTENCE_FLAG,
|
|
72
|
+
]
|
|
73
|
+
)
|
|
74
|
+
result = await self._runner.run(
|
|
75
|
+
argv,
|
|
76
|
+
timeout=timeout.total_seconds(),
|
|
77
|
+
cwd=cwd,
|
|
78
|
+
env=self._environment,
|
|
79
|
+
input_text=prompt,
|
|
80
|
+
check=True,
|
|
81
|
+
)
|
|
82
|
+
try:
|
|
83
|
+
payload = json.loads(result.stdout)
|
|
84
|
+
except json.JSONDecodeError as error:
|
|
85
|
+
raise HeadlessOutputError(_INVALID_JSON_MESSAGE) from error
|
|
86
|
+
if not isinstance(payload, Mapping):
|
|
87
|
+
raise HeadlessOutputError(_INVALID_OBJECT_MESSAGE)
|
|
88
|
+
typed_payload = cast("Mapping[str, Any]", payload)
|
|
89
|
+
structured = typed_payload.get(_STRUCTURED_OUTPUT_FIELD)
|
|
90
|
+
if not isinstance(structured, Mapping):
|
|
91
|
+
raise HeadlessOutputError(_MISSING_STRUCTURED_OUTPUT_MESSAGE)
|
|
92
|
+
return cast("Mapping[str, Any]", structured)
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Claude `PostToolUse` hook: stop the turn before a request would cross the limit.
|
|
2
|
+
|
|
3
|
+
Run as `<python> -P -m shikiru.plugins.agents.claude.hook <state> <marker> <limit_tokens>`.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import sys
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from shikiru.plugins.agents.claude.context import (
|
|
13
|
+
last_assistant_context,
|
|
14
|
+
pending_tokens,
|
|
15
|
+
read_transcript,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
TRANSCRIPT_PATH_FIELD = "transcript_path"
|
|
19
|
+
TOOL_RESPONSE_FIELD = "tool_response"
|
|
20
|
+
STOP_REASON = "Shikiru: context threshold reached; compacting before the next request"
|
|
21
|
+
_CONTINUE_FIELD = "continue"
|
|
22
|
+
_STOP_REASON_FIELD = "stopReason"
|
|
23
|
+
_STATE_ID_FIELD = "id"
|
|
24
|
+
_STATE_TOKENS_FIELD = "tokens"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def main() -> None:
|
|
28
|
+
"""Record this tool result and stop the turn once the next request reaches the limit."""
|
|
29
|
+
_, state_argument, marker_argument, limit_argument = sys.argv
|
|
30
|
+
state_path, marker_path, limit = (
|
|
31
|
+
Path(state_argument),
|
|
32
|
+
Path(marker_argument),
|
|
33
|
+
int(limit_argument),
|
|
34
|
+
)
|
|
35
|
+
event = json.load(sys.stdin)
|
|
36
|
+
message_id, context = last_assistant_context(
|
|
37
|
+
read_transcript(Path(event[TRANSCRIPT_PATH_FIELD]))
|
|
38
|
+
)
|
|
39
|
+
pending = _record_pending(
|
|
40
|
+
state_path, message_id, pending_tokens(event.get(TOOL_RESPONSE_FIELD))
|
|
41
|
+
)
|
|
42
|
+
if context + pending >= limit:
|
|
43
|
+
marker_path.touch()
|
|
44
|
+
json.dump({_CONTINUE_FIELD: False, _STOP_REASON_FIELD: STOP_REASON}, sys.stdout)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _record_pending(state_path: Path, message_id: str | None, tokens: int) -> int:
|
|
48
|
+
with state_path.open("a", encoding="utf-8") as handle:
|
|
49
|
+
handle.write(json.dumps({_STATE_ID_FIELD: message_id, _STATE_TOKENS_FIELD: tokens}) + "\n")
|
|
50
|
+
records = [json.loads(line) for line in state_path.read_text(encoding="utf-8").splitlines()]
|
|
51
|
+
return sum(
|
|
52
|
+
record[_STATE_TOKENS_FIELD] for record in records if record[_STATE_ID_FIELD] == message_id
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
if __name__ == "__main__":
|
|
57
|
+
main()
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Claude Code initialize response model catalog parsing."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from collections.abc import Mapping
|
|
7
|
+
from typing import Any, cast
|
|
8
|
+
|
|
9
|
+
from shikiru.agents.contracts import ModelCatalog, ModelChoice
|
|
10
|
+
|
|
11
|
+
_CONTROL_RESPONSE_TYPE = "control_response"
|
|
12
|
+
_SUCCESS_SUBTYPE = "success"
|
|
13
|
+
_INVALID_RESPONSE = "Claude Code initialize response has an invalid model catalog"
|
|
14
|
+
_INVALID_JSON = "Claude Code initialize output is not valid stream JSON"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def parse_model_catalog(output: str, request_id: str) -> ModelCatalog:
|
|
18
|
+
"""Parse only model choices from the matching Claude Code initialize response."""
|
|
19
|
+
response = _matching_initialize_response(output, request_id)
|
|
20
|
+
raw_models = response.get("models")
|
|
21
|
+
if not isinstance(raw_models, list) or not raw_models:
|
|
22
|
+
raise TypeError(_INVALID_RESPONSE)
|
|
23
|
+
return ModelCatalog(models=_catalog_choices(cast("list[object]", raw_models)))
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _matching_initialize_response(output: str, request_id: str) -> Mapping[str, Any]:
|
|
27
|
+
response: Mapping[str, Any] | None = None
|
|
28
|
+
for line in output.splitlines():
|
|
29
|
+
if not line.strip():
|
|
30
|
+
continue
|
|
31
|
+
try:
|
|
32
|
+
raw_message = json.loads(line)
|
|
33
|
+
except json.JSONDecodeError as error:
|
|
34
|
+
raise TypeError(_INVALID_JSON) from error
|
|
35
|
+
if not isinstance(raw_message, Mapping):
|
|
36
|
+
raise TypeError(_INVALID_RESPONSE)
|
|
37
|
+
message = cast("Mapping[str, Any]", raw_message)
|
|
38
|
+
control_response = message.get("response")
|
|
39
|
+
if message.get("type") != _CONTROL_RESPONSE_TYPE or not isinstance(
|
|
40
|
+
control_response, Mapping
|
|
41
|
+
):
|
|
42
|
+
continue
|
|
43
|
+
typed_response = cast("Mapping[str, Any]", control_response)
|
|
44
|
+
if typed_response.get("request_id") != request_id:
|
|
45
|
+
continue
|
|
46
|
+
if typed_response.get("subtype") != _SUCCESS_SUBTYPE:
|
|
47
|
+
raise TypeError(_INVALID_RESPONSE)
|
|
48
|
+
initialize_response = typed_response.get("response")
|
|
49
|
+
if not isinstance(initialize_response, Mapping):
|
|
50
|
+
raise TypeError(_INVALID_RESPONSE)
|
|
51
|
+
response = cast("Mapping[str, Any]", initialize_response)
|
|
52
|
+
break
|
|
53
|
+
if response is None:
|
|
54
|
+
raise TypeError(_INVALID_RESPONSE)
|
|
55
|
+
return response
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _catalog_choices(raw_models: list[object]) -> tuple[ModelChoice, ...]:
|
|
59
|
+
choices: dict[str, ModelChoice] = {}
|
|
60
|
+
for raw_model in raw_models:
|
|
61
|
+
for choice in _model_choices(raw_model):
|
|
62
|
+
existing = choices.get(choice.model)
|
|
63
|
+
if existing is not None and existing.efforts != choice.efforts:
|
|
64
|
+
raise TypeError(_INVALID_RESPONSE)
|
|
65
|
+
choices[choice.model] = choice
|
|
66
|
+
return tuple(choices.values())
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _model_choices(raw_model: object) -> tuple[ModelChoice, ...]:
|
|
70
|
+
if not isinstance(raw_model, Mapping):
|
|
71
|
+
raise TypeError(_INVALID_RESPONSE)
|
|
72
|
+
model = cast("Mapping[str, Any]", raw_model)
|
|
73
|
+
value = model.get("value")
|
|
74
|
+
if not isinstance(value, str) or not value:
|
|
75
|
+
raise TypeError(_INVALID_RESPONSE)
|
|
76
|
+
efforts = _efforts(model)
|
|
77
|
+
identifiers = [value]
|
|
78
|
+
resolved = model.get("resolvedModel")
|
|
79
|
+
if resolved is not None:
|
|
80
|
+
if not isinstance(resolved, str) or not resolved:
|
|
81
|
+
raise TypeError(_INVALID_RESPONSE)
|
|
82
|
+
identifiers.append(resolved)
|
|
83
|
+
unique_identifiers = tuple(dict.fromkeys(identifiers))
|
|
84
|
+
return tuple(
|
|
85
|
+
ModelChoice(model=identifier, efforts=efforts) for identifier in unique_identifiers
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _efforts(model: Mapping[str, Any]) -> tuple[str, ...] | None:
|
|
90
|
+
supported = model.get("supportsEffort")
|
|
91
|
+
if supported is not None and not isinstance(supported, bool):
|
|
92
|
+
raise TypeError(_INVALID_RESPONSE)
|
|
93
|
+
if supported is False:
|
|
94
|
+
return ()
|
|
95
|
+
raw_efforts = model.get("supportedEffortLevels")
|
|
96
|
+
if raw_efforts is None:
|
|
97
|
+
return None
|
|
98
|
+
if not isinstance(raw_efforts, list):
|
|
99
|
+
raise TypeError(_INVALID_RESPONSE)
|
|
100
|
+
efforts: list[str] = []
|
|
101
|
+
for effort in cast("list[object]", raw_efforts):
|
|
102
|
+
if not isinstance(effort, str) or not effort or effort in efforts:
|
|
103
|
+
raise TypeError(_INVALID_RESPONSE)
|
|
104
|
+
efforts.append(effort)
|
|
105
|
+
return tuple(efforts)
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Claude Code plugin options and protocol constants."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
8
|
+
|
|
9
|
+
from shikiru.agents.contracts import PERCENT, BillingMode
|
|
10
|
+
from shikiru.plugins.agents.shared_settings import (
|
|
11
|
+
billing_declaration_setting,
|
|
12
|
+
cli_args_setting,
|
|
13
|
+
compact_threshold_setting,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from shikiru.agents.settings import AgentSetting
|
|
18
|
+
|
|
19
|
+
CLAUDE_COMMAND = "claude"
|
|
20
|
+
PRINT_FLAG = "-p"
|
|
21
|
+
INPUT_FORMAT_FLAG = "--input-format"
|
|
22
|
+
OUTPUT_FORMAT_FLAG = "--output-format"
|
|
23
|
+
STREAM_JSON_FORMAT = "stream-json"
|
|
24
|
+
VERBOSE_FLAG = "--verbose"
|
|
25
|
+
NO_SESSION_PERSISTENCE_FLAG = "--no-session-persistence"
|
|
26
|
+
SAFE_MODE_FLAG = "--safe-mode"
|
|
27
|
+
STRICT_MCP_CONFIG_FLAG = "--strict-mcp-config"
|
|
28
|
+
MCP_CONFIG_FLAG = "--mcp-config"
|
|
29
|
+
EMPTY_MCP_CONFIG = '{"mcpServers":{}}'
|
|
30
|
+
TOOLS_FLAG = "--tools"
|
|
31
|
+
EMPTY_TOOLS = ""
|
|
32
|
+
MODEL_CATALOG_REQUEST_ID = "shikiru-model-catalog"
|
|
33
|
+
DEFAULT_COMPACT_AT_PCT = 80.0
|
|
34
|
+
STATUS_PAYMENT_REQUIRED = 402
|
|
35
|
+
STATUS_TOO_MANY_REQUESTS = 429
|
|
36
|
+
STATUS_UNAUTHORIZED = 401
|
|
37
|
+
STATUS_FORBIDDEN = 403
|
|
38
|
+
PERMISSION_MODE_FLAG = "--permission-mode"
|
|
39
|
+
DEFAULT_ARGS = (PERMISSION_MODE_FLAG, "bypassPermissions")
|
|
40
|
+
SESSION_ID_FLAG = "--session-id"
|
|
41
|
+
RESUME_FLAG = "--resume"
|
|
42
|
+
MODEL_FLAG = "--model"
|
|
43
|
+
EFFORT_FLAG = "--effort"
|
|
44
|
+
ADD_DIR_FLAG = "--add-dir"
|
|
45
|
+
SETTINGS_FLAG = "--settings"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class ClaudeOptions(BaseModel):
|
|
49
|
+
"""Options accepted by the Claude Code agent factory."""
|
|
50
|
+
|
|
51
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
52
|
+
|
|
53
|
+
args: list[str] = Field(default_factory=lambda: list(DEFAULT_ARGS))
|
|
54
|
+
compact_at_pct: float = Field(default=DEFAULT_COMPACT_AT_PCT, ge=0, le=PERCENT)
|
|
55
|
+
billing_mode: BillingMode | None = None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
CLAUDE_SETTINGS: tuple[AgentSetting, ...] = (
|
|
59
|
+
compact_threshold_setting(DEFAULT_COMPACT_AT_PCT),
|
|
60
|
+
billing_declaration_setting(),
|
|
61
|
+
cli_args_setting(DEFAULT_ARGS),
|
|
62
|
+
)
|