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,93 @@
|
|
|
1
|
+
"""The Home `Tasks` label with its filter and search key hints."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from rich.cells import cell_len, set_cell_size
|
|
6
|
+
from rich.text import Text
|
|
7
|
+
|
|
8
|
+
from shikiru.ui.section import BOLD_STEEL, grey, key_label
|
|
9
|
+
from shikiru.ui.tokens import PRIMARY_TEXT_COLOR
|
|
10
|
+
|
|
11
|
+
TASKS_LABEL = "Tasks"
|
|
12
|
+
FILTER_KEY = "f"
|
|
13
|
+
SEARCH_KEY = "/"
|
|
14
|
+
MORE_KEY = "m"
|
|
15
|
+
MORE_LABEL = "load more"
|
|
16
|
+
HINT_GAP = " "
|
|
17
|
+
ELLIPSIS = "\N{HORIZONTAL ELLIPSIS}"
|
|
18
|
+
MIN_SEARCH_CELLS = 3
|
|
19
|
+
LEVEL_FULL = 0
|
|
20
|
+
LEVEL_NAME = 1
|
|
21
|
+
LEVEL_GLYPH = 2
|
|
22
|
+
LEVEL_KEYS = 3
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def task_list_header(
|
|
26
|
+
*,
|
|
27
|
+
name: str,
|
|
28
|
+
marker: tuple[str, str] | None,
|
|
29
|
+
search: str,
|
|
30
|
+
width: int,
|
|
31
|
+
keys_only: bool,
|
|
32
|
+
) -> Text:
|
|
33
|
+
"""Return `Tasks` and the key hints, shortened until they fit `width` cells.
|
|
34
|
+
|
|
35
|
+
A `width` of 0 means the width is not known yet and shows the full hint.
|
|
36
|
+
"""
|
|
37
|
+
for level in range(LEVEL_KEYS if keys_only else LEVEL_FULL, LEVEL_KEYS):
|
|
38
|
+
header = _header(level, name, marker, search)
|
|
39
|
+
if width <= 0 or cell_len(header.plain) <= width:
|
|
40
|
+
return header
|
|
41
|
+
clipped = _clip(search, cell_len(header.plain) - width)
|
|
42
|
+
if clipped is not None:
|
|
43
|
+
return _header(level, name, marker, clipped)
|
|
44
|
+
return _header(LEVEL_KEYS, name, marker, search)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def load_more_hint(*, keys_only: bool) -> Text:
|
|
48
|
+
"""Return the `m load more` hint, or only the key at tiny heights."""
|
|
49
|
+
return Text(MORE_KEY, style=BOLD_STEEL) if keys_only else key_label(MORE_KEY, MORE_LABEL)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _clip(search: str, excess: int) -> str | None:
|
|
53
|
+
keep = cell_len(search) - excess
|
|
54
|
+
if not search or keep < MIN_SEARCH_CELLS:
|
|
55
|
+
return None
|
|
56
|
+
return set_cell_size(search, keep - 1) + ELLIPSIS
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _header(level: int, name: str, marker: tuple[str, str] | None, search: str) -> Text:
|
|
60
|
+
header = Text(TASKS_LABEL)
|
|
61
|
+
header.append(HINT_GAP)
|
|
62
|
+
header.append_text(_filter_part(level, name, marker))
|
|
63
|
+
header.append(HINT_GAP)
|
|
64
|
+
header.append_text(_search_part(level, search))
|
|
65
|
+
return header
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _filter_part(level: int, name: str, marker: tuple[str, str] | None) -> Text:
|
|
69
|
+
part = Text(FILTER_KEY, style=BOLD_STEEL)
|
|
70
|
+
if level == LEVEL_KEYS:
|
|
71
|
+
return part
|
|
72
|
+
if level == LEVEL_FULL:
|
|
73
|
+
part.append_text(grey(" filter:"))
|
|
74
|
+
part.append(" ")
|
|
75
|
+
if marker is None:
|
|
76
|
+
part.append(name, style=PRIMARY_TEXT_COLOR)
|
|
77
|
+
elif level == LEVEL_GLYPH:
|
|
78
|
+
part.append(marker[0], style=marker[1])
|
|
79
|
+
else:
|
|
80
|
+
part.append(marker[0], style=marker[1])
|
|
81
|
+
part.append(f" {name}", style=PRIMARY_TEXT_COLOR)
|
|
82
|
+
return part
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _search_part(level: int, search: str) -> Text:
|
|
86
|
+
part = Text(SEARCH_KEY, style=BOLD_STEEL)
|
|
87
|
+
if level == LEVEL_FULL:
|
|
88
|
+
part.append_text(grey(" search"))
|
|
89
|
+
if search and level <= LEVEL_NAME:
|
|
90
|
+
if level == LEVEL_FULL:
|
|
91
|
+
part.append_text(grey(":"))
|
|
92
|
+
part.append(f" {search}", style=PRIMARY_TEXT_COLOR)
|
|
93
|
+
return part
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""The `resources` widget: host and Shikiru process-tree usage."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
|
|
9
|
+
from shikiru.dashboard.contract import FOCUS_PROJECT_DIAGNOSTICS_VIEW, WidgetPlacement
|
|
10
|
+
from shikiru.ui.compact import render_resource_metric
|
|
11
|
+
from shikiru.ui.gated_static import GatedStatic
|
|
12
|
+
from shikiru.ui.live_interval import LiveInterval
|
|
13
|
+
from shikiru.ui.screen import widget_is_attached
|
|
14
|
+
|
|
15
|
+
RESOURCE_ENTER_KEY = "enter"
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from rich.text import Text
|
|
19
|
+
from textual import events
|
|
20
|
+
from textual.widget import Widget
|
|
21
|
+
|
|
22
|
+
from shikiru.dashboard.contract import ResourceView, WidgetContext
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ResourcesPanel(GatedStatic):
|
|
26
|
+
"""Show host use and the engine plus every pane process tree."""
|
|
27
|
+
|
|
28
|
+
def __init__(self, context: WidgetContext) -> None:
|
|
29
|
+
"""Hold `context`; resource sampling starts on mount."""
|
|
30
|
+
super().__init__(id="resources")
|
|
31
|
+
self.can_focus = context.placement is WidgetPlacement.RESOURCES
|
|
32
|
+
self._widget_context = context
|
|
33
|
+
self._sample: ResourceView | None = None
|
|
34
|
+
self._applied_min_height: int | None = None
|
|
35
|
+
self._interval = LiveInterval(
|
|
36
|
+
self,
|
|
37
|
+
lambda: context.settings.current().orchestration.resources.sample_seconds,
|
|
38
|
+
self._refresh,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
async def on_mount(self) -> None:
|
|
42
|
+
"""Render and resample resources at the configured cadence."""
|
|
43
|
+
await self._refresh()
|
|
44
|
+
if self._widget_context.placement is WidgetPlacement.RESOURCES:
|
|
45
|
+
self._interval.apply()
|
|
46
|
+
self.run_worker(self._widget_context.on_change(self._interval.reapply), exclusive=True)
|
|
47
|
+
|
|
48
|
+
async def on_click(self, event: events.Click) -> None:
|
|
49
|
+
if self._widget_context.placement is not WidgetPlacement.RESOURCES:
|
|
50
|
+
return
|
|
51
|
+
open_view = self._widget_context.open_contextual_view
|
|
52
|
+
if open_view is not None:
|
|
53
|
+
await open_view(FOCUS_PROJECT_DIAGNOSTICS_VIEW)
|
|
54
|
+
event.stop()
|
|
55
|
+
|
|
56
|
+
async def on_key(self, event: events.Key) -> None:
|
|
57
|
+
if (
|
|
58
|
+
event.key == RESOURCE_ENTER_KEY
|
|
59
|
+
and self._widget_context.placement is WidgetPlacement.RESOURCES
|
|
60
|
+
and self._widget_context.open_contextual_view is not None
|
|
61
|
+
):
|
|
62
|
+
await self._widget_context.open_contextual_view(FOCUS_PROJECT_DIAGNOSTICS_VIEW)
|
|
63
|
+
event.stop()
|
|
64
|
+
|
|
65
|
+
def on_resize(self, event: events.Resize) -> None:
|
|
66
|
+
del event
|
|
67
|
+
if self._sample is not None:
|
|
68
|
+
self._render_sample_at_current_width()
|
|
69
|
+
|
|
70
|
+
async def _refresh(self) -> None:
|
|
71
|
+
if not widget_is_attached(self):
|
|
72
|
+
return
|
|
73
|
+
sample = await self._widget_context.views.resources()
|
|
74
|
+
if not widget_is_attached(self):
|
|
75
|
+
return
|
|
76
|
+
self._sample = sample
|
|
77
|
+
self._render_sample_at_current_width()
|
|
78
|
+
|
|
79
|
+
def _render_sample_at_current_width(self) -> None:
|
|
80
|
+
sample = self._sample
|
|
81
|
+
if sample is None:
|
|
82
|
+
return
|
|
83
|
+
self._update_content(render_resource_metric(sample, width=self.size.width))
|
|
84
|
+
|
|
85
|
+
def _update_content(self, content: Text) -> None:
|
|
86
|
+
self.update_if_changed(content)
|
|
87
|
+
if self.size.width:
|
|
88
|
+
rendered_lines = content.wrap(Console(), self.size.width)
|
|
89
|
+
min_height = len(rendered_lines)
|
|
90
|
+
if min_height != self._applied_min_height:
|
|
91
|
+
self._applied_min_height = min_height
|
|
92
|
+
self.styles.min_height = min_height
|
|
93
|
+
if self.parent is not None:
|
|
94
|
+
self.parent.refresh(layout=True)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class ResourcesWidget:
|
|
98
|
+
"""The `resources` dashboard widget plugin."""
|
|
99
|
+
|
|
100
|
+
name = "resources"
|
|
101
|
+
placement = WidgetPlacement.RESOURCES
|
|
102
|
+
|
|
103
|
+
def create(self, context: WidgetContext) -> Widget:
|
|
104
|
+
"""Build the resource panel for `context`."""
|
|
105
|
+
return ResourcesPanel(context)
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""The `strips` widget: active tasks as comic-strip panels."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, cast
|
|
6
|
+
|
|
7
|
+
from rich.text import Text
|
|
8
|
+
|
|
9
|
+
from shikiru.dashboard.contract import WidgetPlacement
|
|
10
|
+
from shikiru.ui.gated_static import GatedStatic
|
|
11
|
+
from shikiru.ui.screen import widget_is_attached, widget_is_current
|
|
12
|
+
from shikiru.ui.strip import STRIP_WIDE_MIN_COLUMNS, TaskStrip
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from collections.abc import Callable
|
|
16
|
+
|
|
17
|
+
from textual import events
|
|
18
|
+
from textual.app import App
|
|
19
|
+
from textual.widget import Widget
|
|
20
|
+
|
|
21
|
+
from shikiru.dashboard.contract import WidgetContext
|
|
22
|
+
from shikiru.ui.strip import TaskStripData
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class StripsPanel(GatedStatic):
|
|
26
|
+
"""Renders every active task's strip, in dispatch order, focused task first."""
|
|
27
|
+
|
|
28
|
+
def __init__(self, context: WidgetContext) -> None:
|
|
29
|
+
"""Hold `context`; nothing here does I/O until `on_mount`."""
|
|
30
|
+
super().__init__()
|
|
31
|
+
self._widget_context = context
|
|
32
|
+
self._rendered: tuple[list[TaskStripData], int] | None = None
|
|
33
|
+
self._remove_motion_callback: Callable[[], None] | None = None
|
|
34
|
+
|
|
35
|
+
async def on_mount(self) -> None:
|
|
36
|
+
"""Render the current strips, then refresh on every state change."""
|
|
37
|
+
await self._refresh()
|
|
38
|
+
self.run_worker(self._widget_context.on_change(self._refresh_if_attached), exclusive=True)
|
|
39
|
+
self._remove_motion_callback = self._widget_context.on_motion(self._refresh_while_active)
|
|
40
|
+
|
|
41
|
+
def on_unmount(self) -> None:
|
|
42
|
+
if self._remove_motion_callback is not None:
|
|
43
|
+
self._remove_motion_callback()
|
|
44
|
+
self._remove_motion_callback = None
|
|
45
|
+
|
|
46
|
+
async def _refresh_while_active(self) -> None:
|
|
47
|
+
if widget_is_current(self) and cast("App[None]", self.app).is_running:
|
|
48
|
+
await self._refresh(motion=True)
|
|
49
|
+
|
|
50
|
+
async def _refresh_if_attached(self) -> None:
|
|
51
|
+
if widget_is_attached(self):
|
|
52
|
+
await self._refresh()
|
|
53
|
+
|
|
54
|
+
async def on_resize(self, event: events.Resize) -> None:
|
|
55
|
+
"""Re-render at the new width: a narrower pane switches every strip to its one-line form."""
|
|
56
|
+
del event
|
|
57
|
+
if widget_is_current(self) and cast("App[None]", self.app).is_running:
|
|
58
|
+
await self._refresh()
|
|
59
|
+
|
|
60
|
+
async def _refresh(self, *, motion: bool = False) -> None:
|
|
61
|
+
width = self.size.width or STRIP_WIDE_MIN_COLUMNS
|
|
62
|
+
views = self._widget_context.views.strips()
|
|
63
|
+
rendering = (views, width)
|
|
64
|
+
if motion and rendering == self._rendered:
|
|
65
|
+
return
|
|
66
|
+
self._rendered = rendering
|
|
67
|
+
strips = [TaskStrip(data).render(width) for data in views]
|
|
68
|
+
if not strips:
|
|
69
|
+
self.update_if_changed("no active tasks")
|
|
70
|
+
return
|
|
71
|
+
self.update_if_changed(Text("\n\n").join(strips))
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class StripsWidget:
|
|
75
|
+
"""The `strips` dashboard widget plugin."""
|
|
76
|
+
|
|
77
|
+
name = "strips"
|
|
78
|
+
placement = WidgetPlacement.WORKFLOW
|
|
79
|
+
|
|
80
|
+
def create(self, context: WidgetContext) -> Widget:
|
|
81
|
+
"""Build the `StripsPanel` for `context`."""
|
|
82
|
+
return StripsPanel(context)
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""The `usage` widget: token totals, reported costs and quota windows."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, cast
|
|
6
|
+
|
|
7
|
+
from rich.console import Group, RenderableType
|
|
8
|
+
from rich.text import Text
|
|
9
|
+
|
|
10
|
+
from shikiru.dashboard.contract import WidgetPlacement
|
|
11
|
+
from shikiru.ui.compact import (
|
|
12
|
+
USAGE_BY_AGENT_LABEL,
|
|
13
|
+
USAGE_LIMITS_LABEL,
|
|
14
|
+
AgentQuotaLimits,
|
|
15
|
+
AgentUsageRows,
|
|
16
|
+
ProjectUsageMetric,
|
|
17
|
+
ProjectUsageTotals,
|
|
18
|
+
)
|
|
19
|
+
from shikiru.ui.gated_static import GatedStatic
|
|
20
|
+
from shikiru.ui.glyphs import GLYPH_QUOTA
|
|
21
|
+
from shikiru.ui.screen import widget_is_attached, widget_is_current
|
|
22
|
+
from shikiru.ui.section import Section
|
|
23
|
+
from shikiru.ui.tokens import AMBER, STYLE_BOLD
|
|
24
|
+
|
|
25
|
+
if TYPE_CHECKING:
|
|
26
|
+
from collections.abc import Callable
|
|
27
|
+
|
|
28
|
+
from textual import events
|
|
29
|
+
from textual.app import App
|
|
30
|
+
from textual.widget import Widget
|
|
31
|
+
|
|
32
|
+
from shikiru.dashboard.contract import (
|
|
33
|
+
AgentUsageView,
|
|
34
|
+
QuotaBlockView,
|
|
35
|
+
RecordedUsageView,
|
|
36
|
+
WidgetContext,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
ENTER_KEY = "enter"
|
|
40
|
+
NO_USAGE_RECORDED = "No runs or quota windows recorded."
|
|
41
|
+
TASK_USAGE_UNAVAILABLE = "Task usage coverage unavailable"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _banner_texts(blocks: list[QuotaBlockView]) -> list[Text]:
|
|
45
|
+
return [Text(f"{GLYPH_QUOTA} {block.banner}", style=AMBER) for block in blocks]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _usage_body(
|
|
49
|
+
totals: RecordedUsageView,
|
|
50
|
+
agents: list[AgentUsageView],
|
|
51
|
+
context: WidgetContext,
|
|
52
|
+
scope: str | None,
|
|
53
|
+
blocks: list[QuotaBlockView],
|
|
54
|
+
) -> Group:
|
|
55
|
+
parts: list[RenderableType] = [] if scope is None else [Text(scope, style=STYLE_BOLD)]
|
|
56
|
+
parts.extend(_banner_texts(blocks))
|
|
57
|
+
parts.append(ProjectUsageTotals(totals))
|
|
58
|
+
with_runs = [agent for agent in agents if agent.run_count]
|
|
59
|
+
with_quotas = [agent for agent in agents if agent.quotas]
|
|
60
|
+
if with_runs:
|
|
61
|
+
parts.append(Section(USAGE_BY_AGENT_LABEL, AgentUsageRows(with_runs)))
|
|
62
|
+
if with_quotas:
|
|
63
|
+
gauges = context.settings.current().dashboard.gauges
|
|
64
|
+
limits = AgentQuotaLimits(with_quotas, gauges.quota_warn_pct, gauges.quota_alert_pct)
|
|
65
|
+
parts.append(Section(USAGE_LIMITS_LABEL, limits))
|
|
66
|
+
if not agents:
|
|
67
|
+
parts.append(Text(NO_USAGE_RECORDED))
|
|
68
|
+
return Group(*parts)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class UsagePanel(GatedStatic):
|
|
72
|
+
"""Show token totals and quota gauges for every known agent."""
|
|
73
|
+
|
|
74
|
+
def __init__(self, context: WidgetContext) -> None:
|
|
75
|
+
"""Hold `context`; usage refreshes after state changes and for live countdowns."""
|
|
76
|
+
super().__init__(id="usage")
|
|
77
|
+
self.can_focus = context.placement is WidgetPlacement.PROJECT_USAGE
|
|
78
|
+
self._widget_context = context
|
|
79
|
+
self._rendered: (
|
|
80
|
+
tuple[RecordedUsageView, list[AgentUsageView], list[QuotaBlockView]] | None
|
|
81
|
+
) = None
|
|
82
|
+
self._home_usage: RecordedUsageView | None = None
|
|
83
|
+
self._home_blocks: list[QuotaBlockView] = []
|
|
84
|
+
self._remove_motion_callback: Callable[[], None] | None = None
|
|
85
|
+
|
|
86
|
+
async def on_mount(self) -> None:
|
|
87
|
+
"""Render current usage, subscribe to state and tick quota age/countdowns."""
|
|
88
|
+
await self._refresh()
|
|
89
|
+
self.run_worker(self._widget_context.on_change(self._refresh_if_attached), exclusive=True)
|
|
90
|
+
self._remove_motion_callback = self._widget_context.on_motion(self._refresh_on_motion)
|
|
91
|
+
|
|
92
|
+
def on_unmount(self) -> None:
|
|
93
|
+
if self._remove_motion_callback is not None:
|
|
94
|
+
self._remove_motion_callback()
|
|
95
|
+
self._remove_motion_callback = None
|
|
96
|
+
|
|
97
|
+
def on_resize(self, event: events.Resize) -> None:
|
|
98
|
+
del event
|
|
99
|
+
if self._home_usage is not None:
|
|
100
|
+
self._render_home_usage(self._home_usage, self._home_blocks)
|
|
101
|
+
|
|
102
|
+
async def on_click(self, event: events.Click) -> None:
|
|
103
|
+
if self._widget_context.placement is WidgetPlacement.PROJECT_USAGE:
|
|
104
|
+
open_view = self._widget_context.open_contextual_view
|
|
105
|
+
if open_view is not None:
|
|
106
|
+
await open_view("usage")
|
|
107
|
+
event.stop()
|
|
108
|
+
|
|
109
|
+
async def on_key(self, event: events.Key) -> None:
|
|
110
|
+
if (
|
|
111
|
+
event.key == ENTER_KEY
|
|
112
|
+
and self._widget_context.placement is WidgetPlacement.PROJECT_USAGE
|
|
113
|
+
and self._widget_context.open_contextual_view is not None
|
|
114
|
+
):
|
|
115
|
+
await self._widget_context.open_contextual_view("usage")
|
|
116
|
+
event.stop()
|
|
117
|
+
|
|
118
|
+
async def _refresh(self, *, motion: bool = False) -> None:
|
|
119
|
+
if self._widget_context.placement is WidgetPlacement.PROJECT_USAGE:
|
|
120
|
+
self._home_usage = self._widget_context.views.project_usage()
|
|
121
|
+
self._home_blocks = self._widget_context.views.quota_blocks()
|
|
122
|
+
self._render_home_usage(self._home_usage, self._home_blocks)
|
|
123
|
+
return
|
|
124
|
+
usage = self._widget_context.views.usage(self._widget_context.focus_task)
|
|
125
|
+
if self._widget_context.focus_task is None:
|
|
126
|
+
project_usage = self._widget_context.views.project_usage()
|
|
127
|
+
blocks = self._widget_context.views.quota_blocks()
|
|
128
|
+
rendering = (project_usage, usage, blocks)
|
|
129
|
+
if motion and rendering == self._rendered:
|
|
130
|
+
return
|
|
131
|
+
self._rendered = rendering
|
|
132
|
+
body = _usage_body(project_usage, usage, self._widget_context, None, blocks)
|
|
133
|
+
else:
|
|
134
|
+
detail = await self._widget_context.views.task_detail(
|
|
135
|
+
self._widget_context.focus_task,
|
|
136
|
+
)
|
|
137
|
+
if not widget_is_attached(self):
|
|
138
|
+
return
|
|
139
|
+
if detail is None or detail.usage is None:
|
|
140
|
+
body = Group(Text(TASK_USAGE_UNAVAILABLE, style=STYLE_BOLD))
|
|
141
|
+
else:
|
|
142
|
+
scope = f"Task {self._widget_context.focus_task}"
|
|
143
|
+
body = _usage_body(detail.usage, usage, self._widget_context, scope, [])
|
|
144
|
+
self.update_if_changed(body)
|
|
145
|
+
|
|
146
|
+
def _render_home_usage(self, usage: RecordedUsageView, blocks: list[QuotaBlockView]) -> None:
|
|
147
|
+
metric = ProjectUsageMetric(usage).render_home(width=self.size.width or None)
|
|
148
|
+
self.update_if_changed(Group(*_banner_texts(blocks), metric))
|
|
149
|
+
|
|
150
|
+
async def _refresh_if_active(self, *, motion: bool = False) -> None:
|
|
151
|
+
if widget_is_current(self) and cast("App[None]", self.app).is_running:
|
|
152
|
+
await self._refresh(motion=motion)
|
|
153
|
+
|
|
154
|
+
async def _refresh_if_attached(self) -> None:
|
|
155
|
+
if widget_is_attached(self):
|
|
156
|
+
await self._refresh()
|
|
157
|
+
|
|
158
|
+
async def _refresh_on_motion(self) -> None:
|
|
159
|
+
if self._widget_context.focus_task is None and self._needs_motion_refresh():
|
|
160
|
+
await self._refresh_if_active(motion=True)
|
|
161
|
+
|
|
162
|
+
def _needs_motion_refresh(self) -> bool:
|
|
163
|
+
if self._widget_context.placement is WidgetPlacement.PROJECT_USAGE:
|
|
164
|
+
return bool(self._home_blocks)
|
|
165
|
+
return True
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
class UsageWidget:
|
|
169
|
+
"""The `usage` dashboard widget plugin."""
|
|
170
|
+
|
|
171
|
+
name = "usage"
|
|
172
|
+
placement = WidgetPlacement.PROJECT_USAGE
|
|
173
|
+
|
|
174
|
+
def create(self, context: WidgetContext) -> Widget:
|
|
175
|
+
"""Build the usage panel for `context`."""
|
|
176
|
+
return UsagePanel(context)
|
shikiru/private.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Owner-only creation and tightening of everything Shikiru keeps under `.shikiru/`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import stat
|
|
7
|
+
from logging.handlers import RotatingFileHandler
|
|
8
|
+
from typing import TYPE_CHECKING
|
|
9
|
+
|
|
10
|
+
from shikiru.paths import WORKTREES_DIR_NAME
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from io import TextIOWrapper
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
PRIVATE_DIR_MODE = 0o700
|
|
17
|
+
PRIVATE_FILE_MODE = 0o600
|
|
18
|
+
_OTHERS_MASK = 0o077
|
|
19
|
+
TEXT_ENCODING = "utf-8"
|
|
20
|
+
_CREATE_WRITE = os.O_WRONLY | os.O_CREAT
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def make_private_dir(path: Path) -> None:
|
|
24
|
+
"""Create `path` and its missing parents as owner-only directories, racing creators included."""
|
|
25
|
+
for directory in reversed([path, *path.parents]):
|
|
26
|
+
if not directory.exists():
|
|
27
|
+
directory.mkdir(mode=PRIVATE_DIR_MODE, exist_ok=True)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def create_private_file(path: Path) -> None:
|
|
31
|
+
"""Create the empty file `path` as owner-only unless it exists; its directory must exist."""
|
|
32
|
+
os.close(os.open(path, _CREATE_WRITE, PRIVATE_FILE_MODE))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def create_private_text(path: Path, text: str) -> None:
|
|
36
|
+
"""Write `text` to the new file `path` as owner-only; raise `FileExistsError` if it exists."""
|
|
37
|
+
descriptor = os.open(path, _CREATE_WRITE | os.O_EXCL, PRIVATE_FILE_MODE)
|
|
38
|
+
with os.fdopen(descriptor, "w", encoding=TEXT_ENCODING) as handle:
|
|
39
|
+
handle.write(text)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def write_private_text(path: Path, text: str) -> None:
|
|
43
|
+
"""Write `text` to `path`, creating it owner-only."""
|
|
44
|
+
descriptor = os.open(path, _CREATE_WRITE | os.O_TRUNC, PRIVATE_FILE_MODE)
|
|
45
|
+
with os.fdopen(descriptor, "w", encoding=TEXT_ENCODING) as handle:
|
|
46
|
+
handle.write(text)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def append_private_text(path: Path, text: str) -> None:
|
|
50
|
+
"""Append `text` to `path`, creating it owner-only."""
|
|
51
|
+
descriptor = os.open(path, _CREATE_WRITE | os.O_APPEND, PRIVATE_FILE_MODE)
|
|
52
|
+
with os.fdopen(descriptor, "a", encoding=TEXT_ENCODING) as handle:
|
|
53
|
+
handle.write(text)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def append_private_bytes(path: Path, content: bytes) -> None:
|
|
57
|
+
"""Append `content` to `path`, creating it owner-only."""
|
|
58
|
+
descriptor = os.open(path, _CREATE_WRITE | os.O_APPEND, PRIVATE_FILE_MODE)
|
|
59
|
+
with os.fdopen(descriptor, "ab") as handle:
|
|
60
|
+
handle.write(content)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class PrivateRotatingFileHandler(RotatingFileHandler):
|
|
64
|
+
"""A size-rotated log whose current file is always created owner-only."""
|
|
65
|
+
|
|
66
|
+
def _open(self) -> TextIOWrapper:
|
|
67
|
+
descriptor = os.open(self.baseFilename, _CREATE_WRITE | os.O_APPEND, PRIVATE_FILE_MODE)
|
|
68
|
+
return os.fdopen(descriptor, "a", encoding=self.encoding)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def tighten_state_dir(state_dir: Path) -> list[Path]:
|
|
72
|
+
"""Remove group and other access below `state_dir` and return the paths it changed.
|
|
73
|
+
|
|
74
|
+
Task worktrees hold the project's own files, so only the `worktrees/` directory itself is
|
|
75
|
+
tightened. Symlinks are never followed or changed.
|
|
76
|
+
"""
|
|
77
|
+
changed: list[Path] = []
|
|
78
|
+
for directory, subdirectories, names in state_dir.walk():
|
|
79
|
+
entries = [directory]
|
|
80
|
+
if directory.name == WORKTREES_DIR_NAME and directory.parent == state_dir:
|
|
81
|
+
subdirectories.clear()
|
|
82
|
+
else:
|
|
83
|
+
entries.extend(directory / name for name in names)
|
|
84
|
+
changed.extend(entry for entry in entries if not entry.is_symlink() and _restrict(entry))
|
|
85
|
+
return changed
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _restrict(path: Path) -> bool:
|
|
89
|
+
mode = stat.S_IMODE(path.stat().st_mode)
|
|
90
|
+
restricted = PRIVATE_DIR_MODE if path.is_dir() else mode & ~_OTHERS_MASK
|
|
91
|
+
if restricted == mode:
|
|
92
|
+
return False
|
|
93
|
+
path.chmod(restricted)
|
|
94
|
+
return True
|