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,36 @@
|
|
|
1
|
+
"""The owned operator service bundle built for one project."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
|
|
10
|
+
from shikiru.operator.commands import OperatorCommands
|
|
11
|
+
from shikiru.operator.events import EventFeed
|
|
12
|
+
from shikiru.operator.queries import OperatorQueries
|
|
13
|
+
from shikiru.store.reader import StateReader
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class OperatorServices:
|
|
17
|
+
"""Own the query, command and event services and release their state connections."""
|
|
18
|
+
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
reader: StateReader,
|
|
22
|
+
queries: OperatorQueries,
|
|
23
|
+
commands: OperatorCommands,
|
|
24
|
+
events: EventFeed,
|
|
25
|
+
close: Callable[[], None],
|
|
26
|
+
) -> None:
|
|
27
|
+
"""Store the services and their state-connection cleanup callback."""
|
|
28
|
+
self.reader = reader
|
|
29
|
+
self.queries = queries
|
|
30
|
+
self.commands = commands
|
|
31
|
+
self.events = events
|
|
32
|
+
self._close = close
|
|
33
|
+
|
|
34
|
+
def close(self) -> None:
|
|
35
|
+
"""Close the bundle's writer and read-only state connections."""
|
|
36
|
+
self._close()
|
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
"""Read Shikiru tmux sessions and prepare exact attach targets."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import shlex
|
|
6
|
+
from collections.abc import Callable, Sequence
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import TYPE_CHECKING, Literal
|
|
10
|
+
|
|
11
|
+
from pydantic import BaseModel, ConfigDict, TypeAdapter, ValidationError
|
|
12
|
+
|
|
13
|
+
from shikiru.domain.tasks import Status
|
|
14
|
+
from shikiru.errors import (
|
|
15
|
+
CommandFailed,
|
|
16
|
+
CommandTimeoutError,
|
|
17
|
+
ConfigError,
|
|
18
|
+
DatabaseError,
|
|
19
|
+
UsageError,
|
|
20
|
+
)
|
|
21
|
+
from shikiru.operator.attachment import admit_attachment
|
|
22
|
+
from shikiru.paths import project_config_path, state_db_path
|
|
23
|
+
from shikiru.store.reader import StateReader
|
|
24
|
+
from shikiru.tmux.client import exact_target
|
|
25
|
+
from shikiru.tmux.session import PROJECT_DIR_ENV_VAR, SESSION_PREFIX
|
|
26
|
+
|
|
27
|
+
if TYPE_CHECKING:
|
|
28
|
+
from shikiru.process import SubprocessRunner
|
|
29
|
+
from shikiru.tmux.client import Tmux
|
|
30
|
+
|
|
31
|
+
SSH_COMMAND = "ssh"
|
|
32
|
+
SSH_OPTION_TERMINATOR = "--"
|
|
33
|
+
SSH_TTY_OPTION = "-t"
|
|
34
|
+
SHIKIRU_COMMAND = "shikiru"
|
|
35
|
+
LS_COMMAND = "ls"
|
|
36
|
+
JSON_OPTION = "--json"
|
|
37
|
+
ATTACH_COMMAND = "attach"
|
|
38
|
+
ATTACH_OPTION_TERMINATOR = "--"
|
|
39
|
+
TMUX_ENV_ASSIGNMENT = "TMUX="
|
|
40
|
+
TMUX_PANE_ENV_ASSIGNMENT = "TMUX_PANE="
|
|
41
|
+
ENV_COMMAND = "env"
|
|
42
|
+
ENGINE_UP = "up"
|
|
43
|
+
ENGINE_DOWN = "down"
|
|
44
|
+
REMOTE_LIST_COMMAND = (SHIKIRU_COMMAND, LS_COMMAND, JSON_OPTION)
|
|
45
|
+
NO_SESSIONS_ERROR = "no Shikiru sessions found"
|
|
46
|
+
REMOTE_PARTIAL_EXIT_CODE = 1
|
|
47
|
+
REMOTE_ORIGIN_HOST_ENV_VAR = "SHIKIRU_REMOTE_ORIGIN_HOST"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class SessionRow(BaseModel):
|
|
51
|
+
"""Progress counts for one live Shikiru tmux session."""
|
|
52
|
+
|
|
53
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
54
|
+
|
|
55
|
+
session: str
|
|
56
|
+
project_dir: str
|
|
57
|
+
engine_state: Literal["up", "down"]
|
|
58
|
+
running: int
|
|
59
|
+
queued: int
|
|
60
|
+
escalated: int
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
_ROWS_ADAPTER: TypeAdapter[list[SessionRow]] = TypeAdapter(list[SessionRow])
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass(frozen=True, slots=True)
|
|
67
|
+
class SessionRepair:
|
|
68
|
+
label: str
|
|
69
|
+
instruction: str
|
|
70
|
+
command: str
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass(frozen=True, slots=True)
|
|
74
|
+
class SessionDiagnostic:
|
|
75
|
+
session: str
|
|
76
|
+
session_id: str | None
|
|
77
|
+
project_dir: str | None
|
|
78
|
+
reason: str
|
|
79
|
+
repairs: tuple[SessionRepair, ...]
|
|
80
|
+
note: str
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def repair_commands(self) -> tuple[str, ...]:
|
|
84
|
+
return tuple(repair.command for repair in self.repairs)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass(frozen=True, slots=True)
|
|
88
|
+
class SessionListing:
|
|
89
|
+
rows: tuple[SessionRow, ...]
|
|
90
|
+
diagnostics: tuple[SessionDiagnostic, ...] = ()
|
|
91
|
+
remote_diagnostics: str = ""
|
|
92
|
+
remote_exit_code: int = 0
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
EngineState = Callable[[Path], bool]
|
|
96
|
+
SessionPrompt = Callable[[Sequence[SessionRow], str | None], SessionRow]
|
|
97
|
+
ReaderFactory = Callable[[Path], StateReader]
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class SessionLister:
|
|
101
|
+
"""Read local or remote Shikiru sessions without opening a writer connection."""
|
|
102
|
+
|
|
103
|
+
def __init__(
|
|
104
|
+
self,
|
|
105
|
+
*,
|
|
106
|
+
tmux: Tmux,
|
|
107
|
+
runner: SubprocessRunner,
|
|
108
|
+
engine_up: EngineState,
|
|
109
|
+
reader_factory: ReaderFactory,
|
|
110
|
+
remote_timeout_seconds: float,
|
|
111
|
+
) -> None:
|
|
112
|
+
"""Inject tmux, process, liveness, database-reader, and timeout boundaries."""
|
|
113
|
+
self._tmux = tmux
|
|
114
|
+
self._runner = runner
|
|
115
|
+
self._engine_up = engine_up
|
|
116
|
+
self._reader_factory = reader_factory
|
|
117
|
+
self._remote_timeout_seconds = remote_timeout_seconds
|
|
118
|
+
|
|
119
|
+
async def list_sessions(
|
|
120
|
+
self,
|
|
121
|
+
host: str | None = None,
|
|
122
|
+
*,
|
|
123
|
+
project_dir: Path | None = None,
|
|
124
|
+
session_name: str | None = None,
|
|
125
|
+
) -> SessionListing:
|
|
126
|
+
"""Return visible sessions and any per-project discovery failures."""
|
|
127
|
+
if host is not None:
|
|
128
|
+
return await self._list_remote(host)
|
|
129
|
+
return await self._list_local(project_dir=project_dir, session_name=session_name)
|
|
130
|
+
|
|
131
|
+
async def _list_local(
|
|
132
|
+
self, *, project_dir: Path | None, session_name: str | None
|
|
133
|
+
) -> SessionListing:
|
|
134
|
+
try:
|
|
135
|
+
names = await self._tmux.list_sessions()
|
|
136
|
+
rows: list[SessionRow] = []
|
|
137
|
+
diagnostics: list[SessionDiagnostic] = []
|
|
138
|
+
for name in names:
|
|
139
|
+
if not name.startswith(SESSION_PREFIX):
|
|
140
|
+
continue
|
|
141
|
+
if session_name is not None and not _session_matches_name(name, session_name):
|
|
142
|
+
continue
|
|
143
|
+
session_project_dir: str | None = None
|
|
144
|
+
try:
|
|
145
|
+
session_project_dir = await self._tmux.show_session_environment(
|
|
146
|
+
exact_target(name), PROJECT_DIR_ENV_VAR
|
|
147
|
+
)
|
|
148
|
+
except ConfigError as error:
|
|
149
|
+
diagnostics.append(
|
|
150
|
+
await _session_diagnostic(self._tmux, name, session_project_dir, error)
|
|
151
|
+
)
|
|
152
|
+
continue
|
|
153
|
+
if session_project_dir is None:
|
|
154
|
+
message = f"tmux session {name!r} has no {PROJECT_DIR_ENV_VAR}"
|
|
155
|
+
diagnostics.append(
|
|
156
|
+
await _session_diagnostic(self._tmux, name, None, ConfigError(message))
|
|
157
|
+
)
|
|
158
|
+
continue
|
|
159
|
+
if not Path(session_project_dir).is_absolute():
|
|
160
|
+
message = (
|
|
161
|
+
"tmux session "
|
|
162
|
+
f"{name!r} has an invalid project directory: {session_project_dir!r}"
|
|
163
|
+
)
|
|
164
|
+
diagnostics.append(
|
|
165
|
+
await _session_diagnostic(
|
|
166
|
+
self._tmux, name, session_project_dir, ConfigError(message)
|
|
167
|
+
)
|
|
168
|
+
)
|
|
169
|
+
continue
|
|
170
|
+
try:
|
|
171
|
+
if project_dir is not None and not _same_project(
|
|
172
|
+
Path(session_project_dir), project_dir
|
|
173
|
+
):
|
|
174
|
+
continue
|
|
175
|
+
rows.append(self._project_row(name, session_project_dir))
|
|
176
|
+
except (ConfigError, DatabaseError, OSError, ValidationError, ValueError) as error:
|
|
177
|
+
diagnostics.append(
|
|
178
|
+
await _session_diagnostic(self._tmux, name, session_project_dir, error)
|
|
179
|
+
)
|
|
180
|
+
except OSError as error:
|
|
181
|
+
message = f"could not inspect local tmux sessions: {error}"
|
|
182
|
+
raise ConfigError(message) from error
|
|
183
|
+
return SessionListing(tuple(rows), tuple(diagnostics))
|
|
184
|
+
|
|
185
|
+
async def _list_remote(self, host: str) -> SessionListing:
|
|
186
|
+
_validate_host(host)
|
|
187
|
+
try:
|
|
188
|
+
result = await self._runner.run(
|
|
189
|
+
(SSH_COMMAND, SSH_OPTION_TERMINATOR, host, *REMOTE_LIST_COMMAND),
|
|
190
|
+
timeout=self._remote_timeout_seconds,
|
|
191
|
+
check=False,
|
|
192
|
+
)
|
|
193
|
+
except OSError as error:
|
|
194
|
+
message = f"could not run ssh for host {host!r}: {error}"
|
|
195
|
+
raise ConfigError(message) from error
|
|
196
|
+
if result.returncode not in {0, REMOTE_PARTIAL_EXIT_CODE}:
|
|
197
|
+
raise CommandFailed(result.argv, result.returncode, result.stderr)
|
|
198
|
+
try:
|
|
199
|
+
rows = _ROWS_ADAPTER.validate_json(result.stdout)
|
|
200
|
+
except ValidationError as error:
|
|
201
|
+
message = f"host {host!r} returned invalid Shikiru session data: {error}"
|
|
202
|
+
raise ConfigError(message) from error
|
|
203
|
+
remote_diagnostics = result.stderr
|
|
204
|
+
if result.returncode == REMOTE_PARTIAL_EXIT_CODE and not remote_diagnostics:
|
|
205
|
+
remote_diagnostics = f"remote ls exited with status {result.returncode}"
|
|
206
|
+
return SessionListing(
|
|
207
|
+
tuple(rows),
|
|
208
|
+
remote_diagnostics=remote_diagnostics,
|
|
209
|
+
remote_exit_code=result.returncode,
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
def _project_row(self, session: str, project_dir: str) -> SessionRow:
|
|
213
|
+
root = Path(project_dir)
|
|
214
|
+
reader = self._reader_factory(root)
|
|
215
|
+
try:
|
|
216
|
+
tasks = reader.list_tasks()
|
|
217
|
+
engine_state = ENGINE_UP if self._engine_up(root) else ENGINE_DOWN
|
|
218
|
+
return SessionRow(
|
|
219
|
+
session=session,
|
|
220
|
+
project_dir=project_dir,
|
|
221
|
+
engine_state=engine_state,
|
|
222
|
+
running=sum(task.status in {Status.RUNNING, Status.INTEGRATING} for task in tasks),
|
|
223
|
+
queued=sum(task.status is Status.QUEUED for task in tasks),
|
|
224
|
+
escalated=sum(task.status is Status.ESCALATED for task in tasks),
|
|
225
|
+
)
|
|
226
|
+
except (ValidationError, ValueError) as error:
|
|
227
|
+
message = f"could not read Shikiru progress from {state_db_path(root)}: {error}"
|
|
228
|
+
raise DatabaseError(message) from error
|
|
229
|
+
except OSError as error:
|
|
230
|
+
message = f"could not inspect Shikiru project {root}: {error}"
|
|
231
|
+
raise ConfigError(message) from error
|
|
232
|
+
finally:
|
|
233
|
+
reader.close()
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
class SessionAttacher:
|
|
237
|
+
"""Select a listed row and replace the CLI with its local or remote tmux client."""
|
|
238
|
+
|
|
239
|
+
def __init__(self, runner: SubprocessRunner, tmux: Tmux, prompt: SessionPrompt) -> None:
|
|
240
|
+
"""Use `runner` for process replacement and `prompt` for interactive selection."""
|
|
241
|
+
self._runner = runner
|
|
242
|
+
self._tmux = tmux
|
|
243
|
+
self._prompt = prompt
|
|
244
|
+
|
|
245
|
+
async def attach(
|
|
246
|
+
self,
|
|
247
|
+
rows: Sequence[SessionRow],
|
|
248
|
+
*,
|
|
249
|
+
name: str | None,
|
|
250
|
+
host: str | None,
|
|
251
|
+
inside_tmux: bool,
|
|
252
|
+
current_pane: str | None = None,
|
|
253
|
+
) -> None:
|
|
254
|
+
"""Replace this process with tmux or ssh for one listed session."""
|
|
255
|
+
if host is not None and name is not None:
|
|
256
|
+
self._runner.replace(_remote_attach_argv(host, name))
|
|
257
|
+
return
|
|
258
|
+
row = self._select(rows, name, host)
|
|
259
|
+
if host is None:
|
|
260
|
+
same_client = await admit_attachment(self._tmux, row.session, current_pane)
|
|
261
|
+
if same_client:
|
|
262
|
+
return
|
|
263
|
+
command = self._tmux.guarded_client_argv(row.session, switch=inside_tmux)
|
|
264
|
+
else:
|
|
265
|
+
command = _remote_attach_argv(host, row.session)
|
|
266
|
+
self._runner.replace(command)
|
|
267
|
+
|
|
268
|
+
def _select(self, rows: Sequence[SessionRow], name: str | None, host: str | None) -> SessionRow:
|
|
269
|
+
if name is not None:
|
|
270
|
+
matches = [row for row in rows if _session_matches_name(row.session, name)]
|
|
271
|
+
if len(matches) == 1:
|
|
272
|
+
return matches[0]
|
|
273
|
+
message = f"no unique Shikiru session named {name!r}"
|
|
274
|
+
raise UsageError(message)
|
|
275
|
+
if len(rows) == 1:
|
|
276
|
+
return rows[0]
|
|
277
|
+
if not rows:
|
|
278
|
+
raise UsageError(NO_SESSIONS_ERROR)
|
|
279
|
+
return self._prompt(rows, host)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _session_matches_name(session: str, name: str) -> bool:
|
|
283
|
+
return session == name or session.removeprefix(SESSION_PREFIX) == name
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _same_project(session_project: Path, requested_project: Path) -> bool:
|
|
287
|
+
return session_project.resolve() == requested_project.resolve()
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _validate_host(host: str) -> None:
|
|
291
|
+
if not host or host.startswith("-") or any(character.isspace() for character in host):
|
|
292
|
+
message = f"invalid SSH host: {host!r}"
|
|
293
|
+
raise UsageError(message)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _remote_attach_argv(host: str, name: str) -> tuple[str, ...]:
|
|
297
|
+
_validate_host(host)
|
|
298
|
+
if not name:
|
|
299
|
+
message = "session name cannot be empty"
|
|
300
|
+
raise UsageError(message)
|
|
301
|
+
remote_command = shlex.join(
|
|
302
|
+
(
|
|
303
|
+
ENV_COMMAND,
|
|
304
|
+
f"{REMOTE_ORIGIN_HOST_ENV_VAR}={host}",
|
|
305
|
+
TMUX_ENV_ASSIGNMENT,
|
|
306
|
+
TMUX_PANE_ENV_ASSIGNMENT,
|
|
307
|
+
SHIKIRU_COMMAND,
|
|
308
|
+
ATTACH_COMMAND,
|
|
309
|
+
ATTACH_OPTION_TERMINATOR,
|
|
310
|
+
name,
|
|
311
|
+
)
|
|
312
|
+
)
|
|
313
|
+
return SSH_COMMAND, SSH_TTY_OPTION, SSH_OPTION_TERMINATOR, host, remote_command
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
async def _session_diagnostic(
|
|
317
|
+
tmux: Tmux,
|
|
318
|
+
session: str,
|
|
319
|
+
project_dir: str | None,
|
|
320
|
+
error: Exception,
|
|
321
|
+
) -> SessionDiagnostic:
|
|
322
|
+
try:
|
|
323
|
+
session_id = await tmux.session_id(session)
|
|
324
|
+
except (CommandFailed, CommandTimeoutError, OSError, ValueError) as lookup_error:
|
|
325
|
+
session_id = None
|
|
326
|
+
lookup_failure = (
|
|
327
|
+
f"Tmux session ID lookup failed ({type(lookup_error).__name__}): {lookup_error}"
|
|
328
|
+
)
|
|
329
|
+
else:
|
|
330
|
+
lookup_failure = None
|
|
331
|
+
repairs, note = _session_repairs(session_id, project_dir)
|
|
332
|
+
if lookup_failure is not None:
|
|
333
|
+
note = f"{note} {lookup_failure}"
|
|
334
|
+
return SessionDiagnostic(
|
|
335
|
+
session=session,
|
|
336
|
+
session_id=session_id,
|
|
337
|
+
project_dir=project_dir,
|
|
338
|
+
reason=str(error),
|
|
339
|
+
repairs=repairs,
|
|
340
|
+
note=note,
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _session_repairs(
|
|
345
|
+
session_id: str | None,
|
|
346
|
+
project_dir: str | None,
|
|
347
|
+
) -> tuple[tuple[SessionRepair, ...], str]:
|
|
348
|
+
repairs: list[SessionRepair] = []
|
|
349
|
+
if session_id is not None:
|
|
350
|
+
repairs.append(
|
|
351
|
+
SessionRepair(
|
|
352
|
+
"Session",
|
|
353
|
+
"Remove the retained session if it is no longer needed.",
|
|
354
|
+
shlex.join(("tmux", "kill-session", "-t", session_id)),
|
|
355
|
+
)
|
|
356
|
+
)
|
|
357
|
+
if project_dir is None or not Path(project_dir).is_absolute():
|
|
358
|
+
note = "The project path is missing or invalid."
|
|
359
|
+
else:
|
|
360
|
+
root = Path(project_dir)
|
|
361
|
+
if root.is_dir() and not project_config_path(root).exists():
|
|
362
|
+
repairs.append(
|
|
363
|
+
SessionRepair(
|
|
364
|
+
"Project",
|
|
365
|
+
"Initialize fresh state if intended; this cannot recover a deleted database.",
|
|
366
|
+
shlex.join((SHIKIRU_COMMAND, "--project", project_dir, "init")),
|
|
367
|
+
)
|
|
368
|
+
)
|
|
369
|
+
note = "Initialization creates fresh project state."
|
|
370
|
+
elif root.is_dir():
|
|
371
|
+
note = (
|
|
372
|
+
"Removing the retained tmux session does not repair the unavailable project state."
|
|
373
|
+
)
|
|
374
|
+
else:
|
|
375
|
+
note = "The project directory is absent."
|
|
376
|
+
if session_id is None:
|
|
377
|
+
note = f"{note} A stable tmux session ID is unavailable, so no kill command is offered."
|
|
378
|
+
return tuple(repairs), note
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""Build task-strip view data from domain state for operator interfaces."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from shikiru.domain.ladder import pick_rung
|
|
8
|
+
from shikiru.domain.steps import ACTING_ROLE, Step
|
|
9
|
+
from shikiru.domain.tasks import Hold, Status, Task
|
|
10
|
+
from shikiru.ui.glyphs import (
|
|
11
|
+
GLYPH_DONE,
|
|
12
|
+
GLYPH_ENGINE,
|
|
13
|
+
GLYPH_ESCALATED,
|
|
14
|
+
GLYPH_PENDING,
|
|
15
|
+
GLYPH_STOPPED,
|
|
16
|
+
ROLE_GLYPHS,
|
|
17
|
+
SPINNER_FRAMES,
|
|
18
|
+
STAGE_TRIAGE,
|
|
19
|
+
)
|
|
20
|
+
from shikiru.ui.strip import (
|
|
21
|
+
PanelState,
|
|
22
|
+
PanelView,
|
|
23
|
+
StampKind,
|
|
24
|
+
StripStamp,
|
|
25
|
+
TaskStripData,
|
|
26
|
+
panel_specs_for_mode,
|
|
27
|
+
)
|
|
28
|
+
from shikiru.ui.tokens import STOPPED_DURING
|
|
29
|
+
|
|
30
|
+
if TYPE_CHECKING:
|
|
31
|
+
from shikiru.config.settings import Settings
|
|
32
|
+
|
|
33
|
+
_PANEL_INDEX: dict[Step, int] = {
|
|
34
|
+
Step.TRIAGE: 0,
|
|
35
|
+
Step.PLAN: 1,
|
|
36
|
+
Step.IMPLEMENT: 1,
|
|
37
|
+
Step.CONSULT: 1,
|
|
38
|
+
Step.VERIFY: 2,
|
|
39
|
+
Step.INTEGRATE: 3,
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def task_strip_data(task: Task, settings: Settings) -> TaskStripData:
|
|
44
|
+
"""Build the render data for one task using the configured role ladder."""
|
|
45
|
+
if task.status is Status.CANCELLED:
|
|
46
|
+
return _unstarted_strip_data(task)
|
|
47
|
+
if task.step is None:
|
|
48
|
+
if task.status is Status.RUNNING:
|
|
49
|
+
return _starting_strip_data(task)
|
|
50
|
+
if task.status not in (Status.QUEUED, Status.DONE, Status.ESCALATED):
|
|
51
|
+
message = f"task {task.id} is active but has no step recorded"
|
|
52
|
+
raise ValueError(message)
|
|
53
|
+
return _unstarted_strip_data(task)
|
|
54
|
+
if task.mode is None:
|
|
55
|
+
if task.step is not Step.TRIAGE:
|
|
56
|
+
message = f"task {task.id} has no mode yet but is at step {task.step!r}, not triage"
|
|
57
|
+
raise ValueError(message)
|
|
58
|
+
specs = (("Triage", STAGE_TRIAGE),)
|
|
59
|
+
else:
|
|
60
|
+
specs = panel_specs_for_mode(task.mode)
|
|
61
|
+
active_state = _active_panel_state(task)
|
|
62
|
+
current_index = _PANEL_INDEX[task.step]
|
|
63
|
+
panels = tuple(
|
|
64
|
+
_panel_view(
|
|
65
|
+
label,
|
|
66
|
+
glyph,
|
|
67
|
+
PanelState.DONE
|
|
68
|
+
if index < current_index
|
|
69
|
+
else (active_state if index == current_index else PanelState.PENDING),
|
|
70
|
+
step=task.step,
|
|
71
|
+
task=task,
|
|
72
|
+
settings=settings,
|
|
73
|
+
)
|
|
74
|
+
for index, (label, glyph) in enumerate(specs)
|
|
75
|
+
)
|
|
76
|
+
return TaskStripData(
|
|
77
|
+
task_id=task.id,
|
|
78
|
+
title=task.title,
|
|
79
|
+
panels=panels,
|
|
80
|
+
stamp=_stamp_for(task),
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _unstarted_strip_data(task: Task) -> TaskStripData:
|
|
85
|
+
if task.status is Status.QUEUED:
|
|
86
|
+
state, glyph = PanelState.PENDING, GLYPH_PENDING
|
|
87
|
+
elif task.status is Status.DONE:
|
|
88
|
+
state, glyph = PanelState.DONE, GLYPH_DONE
|
|
89
|
+
elif task.status is Status.CANCELLED:
|
|
90
|
+
state, glyph = PanelState.PENDING, GLYPH_STOPPED
|
|
91
|
+
elif task.status is Status.ESCALATED:
|
|
92
|
+
state, glyph = PanelState.ESCALATED, GLYPH_ESCALATED
|
|
93
|
+
else:
|
|
94
|
+
message = f"task {task.id} has no step for status {task.status.value!r}"
|
|
95
|
+
raise ValueError(message)
|
|
96
|
+
stopped = (
|
|
97
|
+
f"{STOPPED_DURING} {task.step.value}"
|
|
98
|
+
if task.status is Status.CANCELLED and task.step is not None
|
|
99
|
+
else ""
|
|
100
|
+
)
|
|
101
|
+
panel = PanelView(
|
|
102
|
+
label=task.status.value.title(), stage_glyph=glyph, state=state, detail=stopped
|
|
103
|
+
)
|
|
104
|
+
return TaskStripData(
|
|
105
|
+
task_id=task.id,
|
|
106
|
+
title=task.title,
|
|
107
|
+
panels=(panel,),
|
|
108
|
+
stamp=_stamp_for(task),
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _starting_strip_data(task: Task) -> TaskStripData:
|
|
113
|
+
panel = PanelView(
|
|
114
|
+
label="Starting",
|
|
115
|
+
stage_glyph=GLYPH_ENGINE,
|
|
116
|
+
state=PanelState.ACTIVE,
|
|
117
|
+
detail="setting up",
|
|
118
|
+
spinner_frame=SPINNER_FRAMES[0],
|
|
119
|
+
)
|
|
120
|
+
return TaskStripData(
|
|
121
|
+
task_id=task.id,
|
|
122
|
+
title=task.title,
|
|
123
|
+
panels=(panel,),
|
|
124
|
+
stamp=_stamp_for(task),
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _active_panel_state(task: Task) -> PanelState:
|
|
129
|
+
if task.status is Status.ESCALATED:
|
|
130
|
+
return PanelState.ESCALATED
|
|
131
|
+
if task.status is Status.DONE:
|
|
132
|
+
return PanelState.DONE
|
|
133
|
+
return PanelState.ACTIVE
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _panel_view(
|
|
137
|
+
label: str, glyph: str, state: PanelState, *, step: Step, task: Task, settings: Settings
|
|
138
|
+
) -> PanelView:
|
|
139
|
+
if state not in (PanelState.ACTIVE, PanelState.ESCALATED):
|
|
140
|
+
return PanelView(label=label, stage_glyph=glyph, state=state)
|
|
141
|
+
role = ACTING_ROLE[step]
|
|
142
|
+
rung = pick_rung(task, role, settings)
|
|
143
|
+
detail = f"iter {task.iteration}" if task.iteration else ""
|
|
144
|
+
role_line = f"{ROLE_GLYPHS[role]} {rung.agent}/{rung.model}"
|
|
145
|
+
spinner_frame = SPINNER_FRAMES[0] if state is PanelState.ACTIVE else ""
|
|
146
|
+
return PanelView(
|
|
147
|
+
label=label,
|
|
148
|
+
stage_glyph=glyph,
|
|
149
|
+
state=state,
|
|
150
|
+
detail=detail,
|
|
151
|
+
role_line=role_line,
|
|
152
|
+
spinner_frame=spinner_frame,
|
|
153
|
+
role=role if state is PanelState.ACTIVE else None,
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _stamp_for(task: Task) -> StripStamp | None:
|
|
158
|
+
if Hold.PAUSED in task.holds:
|
|
159
|
+
return StripStamp(StampKind.PAUSED)
|
|
160
|
+
if Hold.INTERVENTION in task.holds:
|
|
161
|
+
return StripStamp(StampKind.OPERATOR)
|
|
162
|
+
return None
|