windcode 0.1.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.
Files changed (143) hide show
  1. windcode/__init__.py +6 -0
  2. windcode/__main__.py +4 -0
  3. windcode/auth/__init__.py +11 -0
  4. windcode/auth/store.py +90 -0
  5. windcode/cli.py +181 -0
  6. windcode/config/__init__.py +41 -0
  7. windcode/config/loader.py +117 -0
  8. windcode/config/models.py +203 -0
  9. windcode/config/writer.py +74 -0
  10. windcode/context/__init__.py +18 -0
  11. windcode/context/compactor.py +72 -0
  12. windcode/context/estimator.py +89 -0
  13. windcode/context/truncation.py +87 -0
  14. windcode/domain/__init__.py +1 -0
  15. windcode/domain/errors.py +33 -0
  16. windcode/domain/events.py +597 -0
  17. windcode/domain/messages.py +226 -0
  18. windcode/domain/models.py +73 -0
  19. windcode/domain/subagents.py +263 -0
  20. windcode/domain/tools.py +55 -0
  21. windcode/extensions/__init__.py +27 -0
  22. windcode/extensions/commands.py +25 -0
  23. windcode/extensions/discovery.py +139 -0
  24. windcode/extensions/events.py +58 -0
  25. windcode/extensions/hooks/__init__.py +4 -0
  26. windcode/extensions/hooks/dispatcher.py +107 -0
  27. windcode/extensions/hooks/executor.py +49 -0
  28. windcode/extensions/hooks/loader.py +101 -0
  29. windcode/extensions/hooks/models.py +109 -0
  30. windcode/extensions/mcp/__init__.py +10 -0
  31. windcode/extensions/mcp/adapter.py +101 -0
  32. windcode/extensions/mcp/catalog.py +153 -0
  33. windcode/extensions/mcp/client.py +273 -0
  34. windcode/extensions/mcp/runtime.py +144 -0
  35. windcode/extensions/mcp/tools.py +570 -0
  36. windcode/extensions/models.py +168 -0
  37. windcode/extensions/paths.py +71 -0
  38. windcode/extensions/plugins/__init__.py +3 -0
  39. windcode/extensions/plugins/installer.py +93 -0
  40. windcode/extensions/plugins/manifest.py +166 -0
  41. windcode/extensions/runtime.py +366 -0
  42. windcode/extensions/service.py +437 -0
  43. windcode/extensions/skills/__init__.py +3 -0
  44. windcode/extensions/skills/loader.py +67 -0
  45. windcode/extensions/skills/parser.py +57 -0
  46. windcode/extensions/skills/tools.py +213 -0
  47. windcode/extensions/snapshot.py +57 -0
  48. windcode/extensions/state.py +158 -0
  49. windcode/instructions/__init__.py +8 -0
  50. windcode/instructions/loader.py +50 -0
  51. windcode/memory/__init__.py +57 -0
  52. windcode/memory/extraction.py +83 -0
  53. windcode/memory/models.py +218 -0
  54. windcode/memory/refiner.py +189 -0
  55. windcode/memory/security.py +23 -0
  56. windcode/memory/service.py +179 -0
  57. windcode/memory/store.py +362 -0
  58. windcode/observability/__init__.py +4 -0
  59. windcode/observability/redaction.py +95 -0
  60. windcode/observability/trace.py +115 -0
  61. windcode/policy/__init__.py +22 -0
  62. windcode/policy/engine.py +137 -0
  63. windcode/policy/models.py +69 -0
  64. windcode/providers/__init__.py +29 -0
  65. windcode/providers/_utils.py +19 -0
  66. windcode/providers/anthropic.py +215 -0
  67. windcode/providers/base.py +46 -0
  68. windcode/providers/catalog.py +119 -0
  69. windcode/providers/errors.py +96 -0
  70. windcode/providers/openai_compat.py +231 -0
  71. windcode/providers/openai_responses.py +189 -0
  72. windcode/providers/registry.py +105 -0
  73. windcode/runtime/__init__.py +15 -0
  74. windcode/runtime/control.py +89 -0
  75. windcode/runtime/event_bus.py +67 -0
  76. windcode/runtime/loop.py +510 -0
  77. windcode/runtime/prompts.py +132 -0
  78. windcode/runtime/report.py +64 -0
  79. windcode/runtime/retry.py +73 -0
  80. windcode/runtime/scheduler.py +194 -0
  81. windcode/runtime/subagents/__init__.py +28 -0
  82. windcode/runtime/subagents/approvals.py +88 -0
  83. windcode/runtime/subagents/budgets.py +79 -0
  84. windcode/runtime/subagents/coordinator.py +714 -0
  85. windcode/runtime/subagents/factory.py +311 -0
  86. windcode/runtime/subagents/roles.py +74 -0
  87. windcode/runtime/subagents/verification.py +53 -0
  88. windcode/sandbox/__init__.py +3 -0
  89. windcode/sandbox/bwrap.py +79 -0
  90. windcode/sdk.py +1259 -0
  91. windcode/sessions/__init__.py +23 -0
  92. windcode/sessions/artifacts.py +69 -0
  93. windcode/sessions/models.py +104 -0
  94. windcode/sessions/store.py +167 -0
  95. windcode/sessions/tree.py +35 -0
  96. windcode/tools/__init__.py +10 -0
  97. windcode/tools/apply_patch.py +191 -0
  98. windcode/tools/ask_user.py +58 -0
  99. windcode/tools/builtins.py +44 -0
  100. windcode/tools/edit_file.py +54 -0
  101. windcode/tools/filesystem.py +77 -0
  102. windcode/tools/glob.py +35 -0
  103. windcode/tools/grep.py +66 -0
  104. windcode/tools/memory.py +400 -0
  105. windcode/tools/read_file.py +54 -0
  106. windcode/tools/registry.py +120 -0
  107. windcode/tools/shell.py +159 -0
  108. windcode/tools/subagents/__init__.py +31 -0
  109. windcode/tools/subagents/cancel.py +35 -0
  110. windcode/tools/subagents/integrate.py +54 -0
  111. windcode/tools/subagents/list.py +46 -0
  112. windcode/tools/subagents/spawn.py +86 -0
  113. windcode/tools/subagents/wait.py +74 -0
  114. windcode/tools/write_file.py +61 -0
  115. windcode/tui/__init__.py +3 -0
  116. windcode/tui/app.py +1015 -0
  117. windcode/tui/commands.py +90 -0
  118. windcode/tui/permission_display.py +24 -0
  119. windcode/tui/styles.tcss +591 -0
  120. windcode/tui/widgets/__init__.py +31 -0
  121. windcode/tui/widgets/approval.py +98 -0
  122. windcode/tui/widgets/command_menu.py +90 -0
  123. windcode/tui/widgets/extensions.py +48 -0
  124. windcode/tui/widgets/input.py +110 -0
  125. windcode/tui/widgets/memory.py +143 -0
  126. windcode/tui/widgets/messages.py +299 -0
  127. windcode/tui/widgets/models.py +565 -0
  128. windcode/tui/widgets/question.py +46 -0
  129. windcode/tui/widgets/sessions.py +68 -0
  130. windcode/tui/widgets/status.py +46 -0
  131. windcode/tui/widgets/subagents.py +195 -0
  132. windcode/tui/widgets/tools.py +66 -0
  133. windcode/tui/widgets/welcome.py +149 -0
  134. windcode/types.py +80 -0
  135. windcode/worktrees/__init__.py +24 -0
  136. windcode/worktrees/git.py +92 -0
  137. windcode/worktrees/manager.py +265 -0
  138. windcode/worktrees/models.py +62 -0
  139. windcode-0.1.0.dist-info/METADATA +207 -0
  140. windcode-0.1.0.dist-info/RECORD +143 -0
  141. windcode-0.1.0.dist-info/WHEEL +4 -0
  142. windcode-0.1.0.dist-info/entry_points.txt +2 -0
  143. windcode-0.1.0.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,139 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ from windcode.extensions.models import (
8
+ ActivationState,
9
+ CapabilityKind,
10
+ CapabilityRecord,
11
+ Diagnostic,
12
+ DiagnosticSeverity,
13
+ DiagnosticStage,
14
+ ExtensionScope,
15
+ ExtensionSource,
16
+ PermissionRequirement,
17
+ capability_id,
18
+ )
19
+ from windcode.extensions.skills.parser import parse_skill_metadata
20
+
21
+
22
+ @dataclass(frozen=True, slots=True)
23
+ class DiscoveryRoot:
24
+ path: Path
25
+ scope: ExtensionScope
26
+ trusted: bool = True
27
+
28
+
29
+ @dataclass(frozen=True, slots=True)
30
+ class DiscoveryResult:
31
+ records: tuple[CapabilityRecord, ...]
32
+ definitions: dict[str, Any]
33
+ diagnostics: tuple[Diagnostic, ...]
34
+
35
+
36
+ def discover_skills(
37
+ roots: tuple[DiscoveryRoot, ...], *, max_metadata_bytes: int = 65_536
38
+ ) -> DiscoveryResult:
39
+ candidates: list[tuple[CapabilityRecord, Any]] = []
40
+ diagnostics: list[Diagnostic] = []
41
+ for root in sorted(roots, key=lambda item: (item.scope.value, str(item.path))):
42
+ if not root.path.exists():
43
+ continue
44
+ for directory in sorted(
45
+ (path for path in root.path.iterdir() if path.is_dir()), key=lambda path: path.name
46
+ ):
47
+ source = ExtensionSource(root.scope, directory)
48
+ try:
49
+ metadata = parse_skill_metadata(directory, max_bytes=max_metadata_bytes)
50
+ stable_id = capability_id(CapabilityKind.SKILL, metadata.name)
51
+ record = CapabilityRecord(
52
+ stable_id,
53
+ metadata.name,
54
+ CapabilityKind.SKILL,
55
+ source,
56
+ trusted=root.trusted,
57
+ activation=ActivationState.AVAILABLE
58
+ if root.trusted
59
+ else ActivationState.INACTIVE,
60
+ permissions=PermissionRequirement(filesystem_read=True),
61
+ )
62
+ candidates.append((record, metadata))
63
+ except (OSError, ValueError) as exc:
64
+ diagnostics.append(
65
+ Diagnostic(
66
+ DiagnosticStage.PARSE,
67
+ DiagnosticSeverity.ERROR,
68
+ "invalid_skill",
69
+ str(exc),
70
+ source.source_id,
71
+ "Fix the Skill metadata or remove this source.",
72
+ )
73
+ )
74
+
75
+ grouped: dict[tuple[CapabilityKind, str], list[tuple[CapabilityRecord, Any]]] = {}
76
+ for candidate in candidates:
77
+ record = candidate[0]
78
+ grouped.setdefault((record.kind, record.public_name), []).append(candidate)
79
+ records: list[CapabilityRecord] = []
80
+ definitions: dict[str, Any] = {}
81
+ for key in sorted(grouped, key=lambda item: (item[0].value, item[1])):
82
+ items = sorted(grouped[key], key=lambda item: item[0].sort_key)
83
+ by_scope: dict[ExtensionScope, list[tuple[CapabilityRecord, Any]]] = {}
84
+ for item in items:
85
+ by_scope.setdefault(item[0].source.scope, []).append(item)
86
+ conflicted = {scope for scope, values in by_scope.items() if len(values) > 1}
87
+ eligible = [item for item in items if item[0].source.scope not in conflicted]
88
+ winner = eligible[-1] if eligible else None
89
+ for item in items:
90
+ record, definition = item
91
+ if record.source.scope in conflicted:
92
+ diagnostic = Diagnostic(
93
+ DiagnosticStage.MERGE,
94
+ DiagnosticSeverity.ERROR,
95
+ "same_scope_conflict",
96
+ f"duplicate {record.kind.value} named {record.public_name}",
97
+ record.source.source_id,
98
+ "Remove or rename one same-scope definition.",
99
+ )
100
+ diagnostics.append(diagnostic)
101
+ records.append(
102
+ CapabilityRecord(
103
+ record.capability_id,
104
+ record.public_name,
105
+ record.kind,
106
+ record.source,
107
+ record.enabled,
108
+ record.trusted,
109
+ record.required,
110
+ ActivationState.FAILED,
111
+ record.permissions,
112
+ None,
113
+ (diagnostic,),
114
+ )
115
+ )
116
+ elif winner is not None and record is not winner[0]:
117
+ records.append(
118
+ CapabilityRecord(
119
+ record.capability_id,
120
+ record.public_name,
121
+ record.kind,
122
+ record.source,
123
+ record.enabled,
124
+ record.trusted,
125
+ record.required,
126
+ ActivationState.INACTIVE,
127
+ record.permissions,
128
+ winner[0].source.source_id,
129
+ record.diagnostics,
130
+ )
131
+ )
132
+ else:
133
+ records.append(record)
134
+ definitions[record.capability_id] = definition
135
+ return DiscoveryResult(
136
+ tuple(sorted(records, key=lambda item: item.sort_key)),
137
+ definitions,
138
+ tuple(sorted(diagnostics, key=lambda item: (item.source_id, item.category))),
139
+ )
@@ -0,0 +1,58 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from windcode.domain.events import ExtensionEvent
6
+
7
+ EXTENSION_ACTIONS = frozenset(
8
+ {
9
+ "discovery_completed",
10
+ "diagnostic",
11
+ "snapshot_reloaded",
12
+ "capability_activated",
13
+ "skill_loaded",
14
+ "mcp_connecting",
15
+ "mcp_connected",
16
+ "mcp_called",
17
+ "mcp_closed",
18
+ "hook_started",
19
+ "hook_finished",
20
+ "hook_rejected",
21
+ "plugin_state_changed",
22
+ }
23
+ )
24
+
25
+
26
+ def extension_event(
27
+ *,
28
+ event_id: str,
29
+ session_id: str,
30
+ run_id: str,
31
+ turn: int,
32
+ action: str,
33
+ snapshot_generation: int,
34
+ extension_id: str,
35
+ source_id: str,
36
+ status: str = "",
37
+ server_id: str | None = None,
38
+ hook_id: str | None = None,
39
+ call_id: str | None = None,
40
+ details: dict[str, Any] | None = None,
41
+ ) -> ExtensionEvent:
42
+ if action not in EXTENSION_ACTIONS:
43
+ raise ValueError(f"unknown extension event action: {action}")
44
+ return ExtensionEvent(
45
+ event_id=event_id,
46
+ session_id=session_id,
47
+ run_id=run_id,
48
+ turn=turn,
49
+ action=action,
50
+ snapshot_generation=snapshot_generation,
51
+ extension_id=extension_id,
52
+ source_id=source_id,
53
+ status=status,
54
+ server_id=server_id,
55
+ hook_id=hook_id,
56
+ call_id=call_id,
57
+ details={} if details is None else dict(details),
58
+ )
@@ -0,0 +1,4 @@
1
+ from windcode.extensions.hooks.dispatcher import HookDispatcher
2
+ from windcode.extensions.hooks.models import HookContext, HookDefinition, HookEvent
3
+
4
+ __all__ = ["HookContext", "HookDefinition", "HookDispatcher", "HookEvent"]
@@ -0,0 +1,107 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from collections.abc import Awaitable, Callable
5
+
6
+ from windcode.domain.tools import ToolEffect
7
+ from windcode.extensions.hooks.executor import HookExecutor
8
+ from windcode.extensions.hooks.models import HookContext, HookDefinition, HookOutcome
9
+
10
+ HookObserver = Callable[[str, HookDefinition, HookContext, HookOutcome | None], Awaitable[None]]
11
+
12
+
13
+ class HookDispatcher:
14
+ def __init__(
15
+ self,
16
+ hooks: tuple[HookDefinition, ...],
17
+ executor: HookExecutor,
18
+ observer: HookObserver | None = None,
19
+ ) -> None:
20
+ self.hooks = tuple(sorted(hooks, key=lambda item: item.sort_key))
21
+ self.executor = executor
22
+ self._active_sources: set[str] = set()
23
+ self._background: set[asyncio.Task[HookOutcome]] = set()
24
+ self._closed = False
25
+ self.observer = observer
26
+
27
+ async def dispatch(self, context: HookContext, *, background: bool = False) -> HookOutcome:
28
+ if self._closed:
29
+ raise RuntimeError("Hook dispatcher is closed")
30
+ matched = tuple(
31
+ hook
32
+ for hook in self.hooks
33
+ if hook.matcher.matches(context)
34
+ and context.source_id != f"hook:{hook.source_id}/{hook.hook_id}"
35
+ )
36
+ if background:
37
+ required = tuple(hook for hook in matched if hook.required)
38
+ for hook in required:
39
+ try:
40
+ await self._run_one(hook, context)
41
+ except (TimeoutError, RuntimeError, ValueError, OSError) as exc:
42
+ raise RuntimeError(
43
+ f"required Hook failed: {hook.source_id}/{hook.hook_id}: {exc}"
44
+ ) from exc
45
+ for hook in matched:
46
+ if hook.required:
47
+ continue
48
+ task = asyncio.create_task(self._run_one(hook, context))
49
+ self._background.add(task)
50
+ return HookOutcome()
51
+ rejected: str | None = None
52
+ effects: set[ToolEffect] = set()
53
+ notifications: list[str] = []
54
+ prompts: list[tuple[str, str]] = []
55
+ diagnostics: list[str] = []
56
+ for hook in matched:
57
+ try:
58
+ outcome = await self._run_one(hook, context)
59
+ except (TimeoutError, RuntimeError, ValueError, OSError) as exc:
60
+ diagnostics.append(f"{hook.source_id}/{hook.hook_id}: {exc}")
61
+ if hook.required:
62
+ raise RuntimeError(
63
+ f"required Hook failed: {hook.source_id}/{hook.hook_id}: {exc}"
64
+ ) from exc
65
+ if hook.decision_making:
66
+ rejected = rejected or "security Hook failed closed"
67
+ continue
68
+ rejected = rejected or outcome.rejected
69
+ effects.update(outcome.additional_effects)
70
+ notifications.extend(outcome.notifications)
71
+ prompts.extend(outcome.sourced_prompts)
72
+ diagnostics.extend(outcome.diagnostics)
73
+ return HookOutcome(
74
+ rejected,
75
+ frozenset(effects),
76
+ tuple(notifications),
77
+ tuple(prompts),
78
+ tuple(diagnostics),
79
+ )
80
+
81
+ async def _run_one(self, hook: HookDefinition, context: HookContext) -> HookOutcome:
82
+ recursion_key = f"{hook.source_id}/{hook.hook_id}"
83
+ if recursion_key in self._active_sources:
84
+ raise RuntimeError("recursive Hook invocation blocked")
85
+ self._active_sources.add(recursion_key)
86
+ try:
87
+ if self.observer is not None:
88
+ await self.observer("started", hook, context, None)
89
+ async with asyncio.timeout(hook.timeout_seconds):
90
+ outcome = await self.executor.execute(hook, context)
91
+ if self.observer is not None:
92
+ await self.observer(
93
+ "rejected" if outcome.rejected is not None else "finished",
94
+ hook,
95
+ context,
96
+ outcome,
97
+ )
98
+ return outcome
99
+ finally:
100
+ self._active_sources.discard(recursion_key)
101
+
102
+ async def aclose(self) -> None:
103
+ self._closed = True
104
+ tasks = tuple(self._background)
105
+ if tasks:
106
+ await asyncio.gather(*tasks, return_exceptions=True)
107
+ self._background.clear()
@@ -0,0 +1,49 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Awaitable, Callable
4
+
5
+ from windcode.domain.messages import SourcedContextMessage
6
+ from windcode.extensions.hooks.models import (
7
+ HookContext,
8
+ HookDefinition,
9
+ HookOutcome,
10
+ NotifyAction,
11
+ PromptAction,
12
+ RejectAction,
13
+ TightenAction,
14
+ )
15
+
16
+ CommandRunner = Callable[[str, str, HookContext], Awaitable[str]]
17
+
18
+
19
+ class HookExecutor:
20
+ def __init__(self, command_runner: CommandRunner | None = None) -> None:
21
+ self.command_runner = command_runner
22
+ self.notifications: list[tuple[str, str]] = []
23
+ self.context_messages: list[SourcedContextMessage] = []
24
+
25
+ def drain_context(self) -> tuple[SourcedContextMessage, ...]:
26
+ messages = tuple(self.context_messages)
27
+ self.context_messages.clear()
28
+ return messages
29
+
30
+ async def execute(self, hook: HookDefinition, context: HookContext) -> HookOutcome:
31
+ action = hook.action
32
+ if isinstance(action, NotifyAction):
33
+ message = action.message[: hook.output_limit]
34
+ self.notifications.append((hook.source_id, message))
35
+ return HookOutcome(notifications=(message,))
36
+ if isinstance(action, PromptAction):
37
+ content = action.content[: hook.output_limit]
38
+ self.context_messages.append(SourcedContextMessage(hook.source_id, content))
39
+ return HookOutcome(sourced_prompts=((hook.source_id, content),))
40
+ if isinstance(action, RejectAction):
41
+ return HookOutcome(rejected=action.reason[: hook.output_limit])
42
+ if isinstance(action, TightenAction):
43
+ return HookOutcome(additional_effects=action.effects)
44
+ if self.command_runner is None:
45
+ raise RuntimeError("Hook command runner is not configured")
46
+ output = await self.command_runner(
47
+ action.command, f"hook:{hook.source_id}/{hook.hook_id}", context
48
+ )
49
+ return HookOutcome(notifications=(output[: hook.output_limit],))
@@ -0,0 +1,101 @@
1
+ from __future__ import annotations
2
+
3
+ import tomllib
4
+ from pathlib import Path
5
+ from typing import cast
6
+
7
+ from windcode.domain.tools import ToolEffect
8
+ from windcode.extensions.hooks.models import (
9
+ DECISION_EVENTS,
10
+ CommandAction,
11
+ HookDefinition,
12
+ HookEvent,
13
+ HookMatcher,
14
+ NotifyAction,
15
+ PromptAction,
16
+ RejectAction,
17
+ TightenAction,
18
+ )
19
+ from windcode.extensions.models import normalize_id
20
+ from windcode.extensions.paths import read_bounded
21
+
22
+
23
+ def load_hook_definition(
24
+ root: Path, relative_path: str, *, source_id: str, max_bytes: int = 65_536
25
+ ) -> HookDefinition:
26
+ try:
27
+ raw = cast(
28
+ dict[str, object],
29
+ tomllib.loads(read_bounded(root, relative_path, max_bytes=max_bytes).decode("utf-8")),
30
+ )
31
+ except (UnicodeError, tomllib.TOMLDecodeError) as exc:
32
+ raise ValueError(f"invalid Hook definition: {exc}") from exc
33
+ allowed = {
34
+ "id",
35
+ "event",
36
+ "tool_id",
37
+ "status",
38
+ "priority",
39
+ "timeout_seconds",
40
+ "output_limit",
41
+ "required",
42
+ "action",
43
+ }
44
+ unknown = set(raw) - allowed
45
+ if unknown:
46
+ raise ValueError(f"unknown Hook fields: {', '.join(sorted(unknown))}")
47
+ hook_id = normalize_id(str(raw.get("id", "")))
48
+ event = HookEvent(str(raw.get("event", "")))
49
+ action_raw = raw.get("action")
50
+ if not isinstance(action_raw, dict):
51
+ raise ValueError("Hook action must be a table")
52
+ action_data = cast(dict[str, object], action_raw)
53
+ action_type = str(action_data.get("type", ""))
54
+ if action_type == "notify" and set(action_data) == {"type", "message"}:
55
+ action = NotifyAction(str(action_data["message"]))
56
+ elif action_type == "prompt" and set(action_data) == {"type", "content"}:
57
+ action = PromptAction(str(action_data["content"]))
58
+ elif action_type == "command" and set(action_data) == {"type", "command"}:
59
+ action = CommandAction(str(action_data["command"]))
60
+ elif action_type == "reject" and set(action_data) == {"type", "reason"}:
61
+ action = RejectAction(str(action_data["reason"]))
62
+ elif action_type == "tighten" and set(action_data) == {"type", "effects"}:
63
+ effects = action_data["effects"]
64
+ if not isinstance(effects, list):
65
+ raise ValueError("tighten effects must be an array")
66
+ effect_values = cast(list[object], effects)
67
+ action = TightenAction(frozenset(ToolEffect(str(value)) for value in effect_values))
68
+ else:
69
+ raise ValueError(f"invalid Hook action: {action_type}")
70
+ if isinstance(action, (RejectAction, TightenAction)) and event not in DECISION_EVENTS:
71
+ raise ValueError("reject and tighten actions are only valid before tool policy")
72
+ return HookDefinition(
73
+ hook_id,
74
+ source_id,
75
+ HookMatcher(
76
+ event,
77
+ None if raw.get("tool_id") is None else str(raw["tool_id"]),
78
+ None if raw.get("status") is None else str(raw["status"]),
79
+ ),
80
+ action,
81
+ _int_field(raw.get("priority"), 100, "priority"),
82
+ _float_field(raw.get("timeout_seconds"), 10.0, "timeout_seconds"),
83
+ _int_field(raw.get("output_limit"), 4096, "output_limit"),
84
+ bool(raw.get("required", False)),
85
+ )
86
+
87
+
88
+ def _int_field(value: object, default: int, name: str) -> int:
89
+ if value is None:
90
+ return default
91
+ if not isinstance(value, int) or isinstance(value, bool):
92
+ raise ValueError(f"Hook {name} must be an integer")
93
+ return value
94
+
95
+
96
+ def _float_field(value: object, default: float, name: str) -> float:
97
+ if value is None:
98
+ return default
99
+ if not isinstance(value, (int, float)) or isinstance(value, bool):
100
+ raise ValueError(f"Hook {name} must be a number")
101
+ return float(value)
@@ -0,0 +1,109 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from enum import StrEnum
5
+
6
+ from windcode.domain.tools import ToolEffect
7
+
8
+
9
+ class HookEvent(StrEnum):
10
+ SESSION_START = "session_start"
11
+ SESSION_END = "session_end"
12
+ USER_SUBMIT = "user_submit"
13
+ RUN_START = "run_start"
14
+ RUN_END = "run_end"
15
+ RUN_ERROR = "run_error"
16
+ TOOL_BEFORE_POLICY = "tool_before_policy"
17
+ TOOL_AFTER = "tool_after"
18
+ PERMISSION_REQUEST = "permission_request"
19
+ COMPACT_BEFORE = "compact_before"
20
+ COMPACT_AFTER = "compact_after"
21
+ SUBAGENT_START = "subagent_start"
22
+ SUBAGENT_END = "subagent_end"
23
+
24
+
25
+ DECISION_EVENTS = frozenset({HookEvent.TOOL_BEFORE_POLICY})
26
+
27
+
28
+ @dataclass(frozen=True, slots=True)
29
+ class HookContext:
30
+ version: int
31
+ event: HookEvent
32
+ session_id: str
33
+ run_id: str
34
+ correlation_id: str
35
+ source_id: str = "windcode"
36
+ tool_id: str | None = None
37
+ status: str | None = None
38
+ fields: tuple[tuple[str, str | int | float | bool | None], ...] = ()
39
+
40
+
41
+ @dataclass(frozen=True, slots=True)
42
+ class HookMatcher:
43
+ event: HookEvent
44
+ tool_id: str | None = None
45
+ status: str | None = None
46
+
47
+ def matches(self, context: HookContext) -> bool:
48
+ return (
49
+ context.event is self.event
50
+ and (self.tool_id is None or self.tool_id == context.tool_id)
51
+ and (self.status is None or self.status == context.status)
52
+ )
53
+
54
+
55
+ @dataclass(frozen=True, slots=True)
56
+ class NotifyAction:
57
+ message: str
58
+
59
+
60
+ @dataclass(frozen=True, slots=True)
61
+ class PromptAction:
62
+ content: str
63
+
64
+
65
+ @dataclass(frozen=True, slots=True)
66
+ class CommandAction:
67
+ command: str
68
+
69
+
70
+ @dataclass(frozen=True, slots=True)
71
+ class RejectAction:
72
+ reason: str
73
+
74
+
75
+ @dataclass(frozen=True, slots=True)
76
+ class TightenAction:
77
+ effects: frozenset[ToolEffect]
78
+
79
+
80
+ HookAction = NotifyAction | PromptAction | CommandAction | RejectAction | TightenAction
81
+
82
+
83
+ @dataclass(frozen=True, slots=True)
84
+ class HookDefinition:
85
+ hook_id: str
86
+ source_id: str
87
+ matcher: HookMatcher
88
+ action: HookAction
89
+ priority: int = 100
90
+ timeout_seconds: float = 10.0
91
+ output_limit: int = 4096
92
+ required: bool = False
93
+
94
+ @property
95
+ def decision_making(self) -> bool:
96
+ return isinstance(self.action, (RejectAction, TightenAction))
97
+
98
+ @property
99
+ def sort_key(self) -> tuple[int, str, str]:
100
+ return (self.priority, self.source_id, self.hook_id)
101
+
102
+
103
+ @dataclass(frozen=True, slots=True)
104
+ class HookOutcome:
105
+ rejected: str | None = None
106
+ additional_effects: frozenset[ToolEffect] = frozenset()
107
+ notifications: tuple[str, ...] = ()
108
+ sourced_prompts: tuple[tuple[str, str], ...] = ()
109
+ diagnostics: tuple[str, ...] = ()
@@ -0,0 +1,10 @@
1
+ from windcode.extensions.mcp.client import McpClient, ResolvedHttpServer, ResolvedStdioServer
2
+ from windcode.extensions.mcp.runtime import McpRuntime, McpServerState
3
+
4
+ __all__ = [
5
+ "McpClient",
6
+ "McpRuntime",
7
+ "McpServerState",
8
+ "ResolvedHttpServer",
9
+ "ResolvedStdioServer",
10
+ ]
@@ -0,0 +1,101 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from collections.abc import Mapping
5
+ from typing import Any, cast
6
+
7
+ import jsonschema
8
+ from mcp.types import AudioContent, ImageContent, TextContent
9
+ from pydantic import BaseModel, RootModel
10
+
11
+ from windcode.domain.tools import ToolContext, ToolEffect, ToolResult
12
+ from windcode.extensions.mcp.catalog import McpToolDefinition, mcp_tool_wire_name
13
+ from windcode.extensions.mcp.runtime import McpRuntime
14
+ from windcode.sessions.artifacts import ArtifactStore
15
+
16
+
17
+ class McpArguments(RootModel[dict[str, Any]]):
18
+ pass
19
+
20
+
21
+ class McpToolAdapter:
22
+ def __init__(
23
+ self,
24
+ definition: McpToolDefinition,
25
+ runtime: McpRuntime,
26
+ *,
27
+ artifact_store: ArtifactStore | None = None,
28
+ output_limit: int = 20_000,
29
+ ) -> None:
30
+ self.definition = definition
31
+ self.runtime = runtime
32
+ self.artifact_store = artifact_store
33
+ self.output_limit = output_limit
34
+
35
+ @property
36
+ def name(self) -> str:
37
+ return mcp_tool_wire_name(self.definition.server_id, self.definition.name)
38
+
39
+ @property
40
+ def description(self) -> str:
41
+ return self.definition.description or f"MCP tool from {self.definition.server_id}"
42
+
43
+ @property
44
+ def input_model(self) -> type[BaseModel]:
45
+ return McpArguments
46
+
47
+ @property
48
+ def input_schema(self) -> Mapping[str, Any]:
49
+ return self.definition.input_schema
50
+
51
+ @property
52
+ def effects(self) -> frozenset[ToolEffect]:
53
+ return frozenset({ToolEffect.PROCESS, ToolEffect.NETWORK})
54
+
55
+ def validate_arguments(self, arguments: Mapping[str, Any]) -> McpArguments:
56
+ jsonschema.Draft202012Validator(self.definition.input_schema).validate(arguments) # pyright: ignore[reportUnknownMemberType]
57
+ return McpArguments(dict(arguments))
58
+
59
+ async def execute(self, context: ToolContext, arguments: BaseModel) -> ToolResult:
60
+ if context.cancelled():
61
+ return ToolResult("MCP call cancelled", True, data={"error": "cancelled"})
62
+ parsed = cast(McpArguments, arguments)
63
+ result = await self.runtime.call(
64
+ self.definition.server_id,
65
+ lambda client: client.call_tool(
66
+ self.definition.name, cast(dict[str, object], parsed.root)
67
+ ),
68
+ )
69
+ from mcp.types import CallToolResult
70
+
71
+ if not isinstance(result, CallToolResult):
72
+ raise ValueError("invalid MCP call result")
73
+ parts: list[str] = []
74
+ for item in result.content:
75
+ if isinstance(item, TextContent):
76
+ parts.append(item.text)
77
+ elif isinstance(item, (ImageContent, AudioContent)):
78
+ parts.append(f"[{item.type}: {item.mimeType}; base64={item.data}]")
79
+ else:
80
+ parts.append(json.dumps(item.model_dump(mode="json"), sort_keys=True))
81
+ # MCP servers commonly mirror structuredContent into text content for
82
+ # backwards-compatible clients. Prefer the canonical content when present.
83
+ if not parts and result.structuredContent is not None:
84
+ parts.append(json.dumps(result.structuredContent, sort_keys=True))
85
+ output = "\n".join(parts)
86
+ artifact_ref: str | None = None
87
+ if self.artifact_store is not None:
88
+ output, reference = self.artifact_store.externalize(output, threshold=self.output_limit)
89
+ artifact_ref = None if reference is None else reference.relative_path
90
+ elif len(output) > self.output_limit:
91
+ output = output[: self.output_limit] + "..."
92
+ return ToolResult(
93
+ output,
94
+ is_error=result.isError,
95
+ artifact_ref=artifact_ref,
96
+ data={
97
+ "source": self.definition.server_id,
98
+ "tool": self.definition.stable_id,
99
+ "error": "remote_error" if result.isError else None,
100
+ },
101
+ )