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,226 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Mapping
4
+ from dataclasses import dataclass, field
5
+ from datetime import UTC, datetime
6
+ from enum import StrEnum
7
+ from typing import Any, cast
8
+
9
+
10
+ class Role(StrEnum):
11
+ SYSTEM = "system"
12
+ USER = "user"
13
+ ASSISTANT = "assistant"
14
+ TOOL = "tool"
15
+
16
+
17
+ @dataclass(frozen=True, slots=True)
18
+ class TextBlock:
19
+ text: str
20
+
21
+
22
+ @dataclass(frozen=True, slots=True)
23
+ class ReasoningBlock:
24
+ summary: str
25
+ opaque: dict[str, Any] = field(default_factory=dict[str, Any], repr=False)
26
+
27
+
28
+ @dataclass(frozen=True, slots=True)
29
+ class ToolCallBlock:
30
+ call_id: str
31
+ name: str
32
+ arguments: dict[str, Any]
33
+
34
+
35
+ @dataclass(frozen=True, slots=True)
36
+ class ToolResultBlock:
37
+ call_id: str
38
+ name: str
39
+ content: str
40
+ is_error: bool = False
41
+ artifact_ref: str | None = None
42
+
43
+
44
+ @dataclass(frozen=True, slots=True)
45
+ class AttachmentBlock:
46
+ media_type: str
47
+ reference: str
48
+ description: str | None = None
49
+
50
+
51
+ ContentBlock = TextBlock | ReasoningBlock | ToolCallBlock | ToolResultBlock | AttachmentBlock
52
+
53
+
54
+ @dataclass(frozen=True, slots=True)
55
+ class Message:
56
+ role: Role
57
+ content: tuple[ContentBlock, ...]
58
+ created_at: datetime = field(default_factory=lambda: datetime.now(UTC))
59
+ provider_metadata: dict[str, Any] = field(default_factory=dict[str, Any], repr=False)
60
+
61
+
62
+ @dataclass(frozen=True, slots=True)
63
+ class SourcedContextMessage:
64
+ source_id: str
65
+ content: str
66
+ lifecycle: str = "next_model_step"
67
+
68
+
69
+ def message_to_dict(message: Message) -> dict[str, Any]:
70
+ content: list[dict[str, Any]] = []
71
+ for block in message.content:
72
+ if isinstance(block, TextBlock):
73
+ content.append({"type": "text", "text": block.text})
74
+ elif isinstance(block, ReasoningBlock):
75
+ content.append({"type": "reasoning", "summary": block.summary, "opaque": block.opaque})
76
+ elif isinstance(block, ToolCallBlock):
77
+ content.append(
78
+ {
79
+ "type": "tool_call",
80
+ "call_id": block.call_id,
81
+ "name": block.name,
82
+ "arguments": block.arguments,
83
+ }
84
+ )
85
+ elif isinstance(block, ToolResultBlock):
86
+ content.append(
87
+ {
88
+ "type": "tool_result",
89
+ "call_id": block.call_id,
90
+ "name": block.name,
91
+ "content": block.content,
92
+ "is_error": block.is_error,
93
+ "artifact_ref": block.artifact_ref,
94
+ }
95
+ )
96
+ else:
97
+ content.append(
98
+ {
99
+ "type": "attachment",
100
+ "media_type": block.media_type,
101
+ "reference": block.reference,
102
+ "description": block.description,
103
+ }
104
+ )
105
+ return {
106
+ "role": message.role.value,
107
+ "content": content,
108
+ "created_at": message.created_at.isoformat(),
109
+ "provider_metadata": message.provider_metadata,
110
+ }
111
+
112
+
113
+ def heal_dangling_tool_calls(messages: tuple[Message, ...]) -> tuple[Message, ...]:
114
+ """Ensure every assistant tool_call is answered by a tool result.
115
+
116
+ A run that is cancelled or fails after the assistant tool_calls message is
117
+ persisted but before tool results are recorded leaves a dangling tool call.
118
+ OpenAI-compatible providers reject such transcripts, so synthesize a
119
+ cancelled tool result for any call id that has no matching result.
120
+ """
121
+
122
+ healed: list[Message] = []
123
+ index = 0
124
+ total = len(messages)
125
+ while index < total:
126
+ message = messages[index]
127
+ healed.append(message)
128
+ call_blocks = [block for block in message.content if isinstance(block, ToolCallBlock)]
129
+ if message.role is not Role.ASSISTANT or not call_blocks:
130
+ index += 1
131
+ continue
132
+ required = {block.call_id: block.name for block in call_blocks}
133
+ answered: set[str] = set()
134
+ cursor = index + 1
135
+ while cursor < total and messages[cursor].role is Role.TOOL:
136
+ healed.append(messages[cursor])
137
+ answered.update(
138
+ block.call_id
139
+ for block in messages[cursor].content
140
+ if isinstance(block, ToolResultBlock)
141
+ )
142
+ cursor += 1
143
+ missing = [call_id for call_id in required if call_id not in answered]
144
+ if missing:
145
+ healed.append(
146
+ Message(
147
+ Role.TOOL,
148
+ tuple(
149
+ ToolResultBlock(
150
+ call_id,
151
+ required[call_id],
152
+ "Tool call was interrupted before it produced a result.",
153
+ is_error=True,
154
+ )
155
+ for call_id in missing
156
+ ),
157
+ )
158
+ )
159
+ index = cursor
160
+ return tuple(healed)
161
+
162
+
163
+ def _object_mapping(value: object) -> dict[str, object]:
164
+ if not isinstance(value, Mapping):
165
+ raise ValueError("message value must be an object")
166
+ raw = cast(Mapping[object, object], value)
167
+ return {str(key): item for key, item in raw.items()}
168
+
169
+
170
+ def message_from_dict(value: Mapping[str, object]) -> Message:
171
+ raw_content = value.get("content")
172
+ if not isinstance(raw_content, list):
173
+ raise ValueError("message content must be a list")
174
+ blocks: list[ContentBlock] = []
175
+ for raw_block in cast(list[object], raw_content):
176
+ block = _object_mapping(raw_block)
177
+ block_type = str(block.get("type", ""))
178
+ if block_type == "text":
179
+ blocks.append(TextBlock(str(block.get("text", ""))))
180
+ elif block_type == "reasoning":
181
+ blocks.append(
182
+ ReasoningBlock(
183
+ str(block.get("summary", "")),
184
+ cast(dict[str, Any], _object_mapping(block.get("opaque", {}))),
185
+ )
186
+ )
187
+ elif block_type == "tool_call":
188
+ blocks.append(
189
+ ToolCallBlock(
190
+ str(block.get("call_id", "")),
191
+ str(block.get("name", "")),
192
+ cast(dict[str, Any], _object_mapping(block.get("arguments", {}))),
193
+ )
194
+ )
195
+ elif block_type == "tool_result":
196
+ artifact = block.get("artifact_ref")
197
+ blocks.append(
198
+ ToolResultBlock(
199
+ str(block.get("call_id", "")),
200
+ str(block.get("name", "")),
201
+ str(block.get("content", "")),
202
+ bool(block.get("is_error", False)),
203
+ None if artifact is None else str(artifact),
204
+ )
205
+ )
206
+ elif block_type == "attachment":
207
+ description = block.get("description")
208
+ blocks.append(
209
+ AttachmentBlock(
210
+ str(block.get("media_type", "application/octet-stream")),
211
+ str(block.get("reference", "")),
212
+ None if description is None else str(description),
213
+ )
214
+ )
215
+ else:
216
+ raise ValueError(f"unknown content block type: {block_type}")
217
+ metadata = _object_mapping(value.get("provider_metadata", {}))
218
+ created = value.get("created_at")
219
+ return Message(
220
+ role=Role(str(value["role"])),
221
+ content=tuple(blocks),
222
+ created_at=(
223
+ datetime.fromisoformat(created) if isinstance(created, str) else datetime.now(UTC)
224
+ ),
225
+ provider_metadata=cast(dict[str, Any], metadata),
226
+ )
@@ -0,0 +1,73 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from enum import StrEnum
5
+ from typing import Any
6
+
7
+ from windcode.domain.messages import Message
8
+
9
+
10
+ class StopReason(StrEnum):
11
+ STOP = "stop"
12
+ TOOL_USE = "tool_use"
13
+ MAX_TOKENS = "max_tokens"
14
+ CONTENT_FILTER = "content_filter"
15
+ CANCELLED = "cancelled"
16
+ ERROR = "error"
17
+
18
+
19
+ @dataclass(frozen=True, slots=True)
20
+ class Usage:
21
+ input_tokens: int = 0
22
+ output_tokens: int = 0
23
+ cache_read_tokens: int = 0
24
+ cache_write_tokens: int = 0
25
+
26
+
27
+ @dataclass(frozen=True, slots=True)
28
+ class ToolSchema:
29
+ name: str
30
+ description: str
31
+ parameters: dict[str, Any]
32
+
33
+
34
+ @dataclass(frozen=True, slots=True)
35
+ class ModelRequest:
36
+ model: str
37
+ messages: tuple[Message, ...]
38
+ system_prompt: str
39
+ tools: tuple[ToolSchema, ...] = ()
40
+ max_output_tokens: int | None = None
41
+ metadata: dict[str, Any] = field(default_factory=dict[str, Any])
42
+
43
+
44
+ @dataclass(frozen=True, slots=True)
45
+ class TextDelta:
46
+ text: str
47
+
48
+
49
+ @dataclass(frozen=True, slots=True)
50
+ class ReasoningDelta:
51
+ summary: str
52
+
53
+
54
+ @dataclass(frozen=True, slots=True)
55
+ class ToolCallDelta:
56
+ call_id: str
57
+ name: str
58
+ arguments_delta: str
59
+
60
+
61
+ @dataclass(frozen=True, slots=True)
62
+ class ModelUsage:
63
+ usage: Usage
64
+
65
+
66
+ @dataclass(frozen=True, slots=True)
67
+ class ModelCompleted:
68
+ reason: StopReason
69
+ usage: Usage = field(default_factory=Usage)
70
+ opaque: dict[str, Any] = field(default_factory=dict[str, Any], repr=False)
71
+
72
+
73
+ ModelEvent = TextDelta | ReasoningDelta | ToolCallDelta | ModelUsage | ModelCompleted
@@ -0,0 +1,263 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from collections.abc import Mapping, Sequence
5
+ from dataclasses import dataclass, field, replace
6
+ from datetime import UTC, datetime
7
+ from enum import StrEnum
8
+ from pathlib import Path
9
+ from typing import Any, cast
10
+
11
+ from windcode.domain.models import Usage
12
+
13
+ _TASK_NAME = re.compile(r"^[a-z0-9]+(?:_[a-z0-9]+)*$")
14
+
15
+
16
+ class SubagentRole(StrEnum):
17
+ RESEARCHER = "researcher"
18
+ WORKER = "worker"
19
+ VERIFIER = "verifier"
20
+
21
+
22
+ class SubagentTaskKind(StrEnum):
23
+ READ = "read"
24
+ WRITE = "write"
25
+
26
+
27
+ class SubagentStatus(StrEnum):
28
+ QUEUED = "queued"
29
+ RUNNING = "running"
30
+ BLOCKED = "blocked"
31
+ COMPLETED = "completed"
32
+ FAILED = "failed"
33
+ CANCELLED = "cancelled"
34
+ CONFLICT = "conflict"
35
+ INTEGRATION_FAILED = "integration_failed"
36
+ INTEGRATED = "integrated"
37
+
38
+
39
+ TERMINAL_SUBAGENT_STATUSES = frozenset(
40
+ {
41
+ SubagentStatus.BLOCKED,
42
+ SubagentStatus.FAILED,
43
+ SubagentStatus.CANCELLED,
44
+ SubagentStatus.CONFLICT,
45
+ SubagentStatus.INTEGRATION_FAILED,
46
+ SubagentStatus.INTEGRATED,
47
+ }
48
+ )
49
+
50
+ _ALLOWED_TRANSITIONS: dict[SubagentStatus, frozenset[SubagentStatus]] = {
51
+ SubagentStatus.QUEUED: frozenset({SubagentStatus.RUNNING, SubagentStatus.CANCELLED}),
52
+ SubagentStatus.RUNNING: frozenset(
53
+ {
54
+ SubagentStatus.BLOCKED,
55
+ SubagentStatus.COMPLETED,
56
+ SubagentStatus.FAILED,
57
+ SubagentStatus.CANCELLED,
58
+ }
59
+ ),
60
+ SubagentStatus.COMPLETED: frozenset(
61
+ {
62
+ SubagentStatus.INTEGRATED,
63
+ SubagentStatus.CONFLICT,
64
+ SubagentStatus.INTEGRATION_FAILED,
65
+ }
66
+ ),
67
+ }
68
+
69
+
70
+ @dataclass(frozen=True, slots=True)
71
+ class SubagentTaskSpec:
72
+ task_name: str
73
+ role: SubagentRole
74
+ kind: SubagentTaskKind
75
+ goal: str
76
+ context: str
77
+ expected_output: str
78
+ verification: tuple[str, ...]
79
+ allowed_tools: frozenset[str] | None = None
80
+ model: str | None = None
81
+ requires_network: bool = False
82
+
83
+ def __post_init__(self) -> None:
84
+ if not _TASK_NAME.fullmatch(self.task_name):
85
+ raise ValueError("task_name must contain lowercase letters, numbers, and underscores")
86
+ for name, value in (
87
+ ("goal", self.goal),
88
+ ("context", self.context),
89
+ ("expected_output", self.expected_output),
90
+ ):
91
+ if not value.strip():
92
+ raise ValueError(f"{name} must not be empty")
93
+ if not self.verification or any(not command.strip() for command in self.verification):
94
+ raise ValueError("verification must contain at least one non-empty requirement")
95
+ if self.kind is SubagentTaskKind.WRITE and self.role is not SubagentRole.WORKER:
96
+ raise ValueError(f"role {self.role.value} does not allow write tasks")
97
+ if self.allowed_tools is not None and any(not name for name in self.allowed_tools):
98
+ raise ValueError("allowed_tools cannot contain empty names")
99
+
100
+
101
+ @dataclass(frozen=True, slots=True)
102
+ class SubagentRecord:
103
+ subagent_id: str
104
+ parent_session_id: str
105
+ parent_run_id: str
106
+ task_index: int
107
+ spec: SubagentTaskSpec
108
+ status: SubagentStatus = SubagentStatus.QUEUED
109
+ child_session_id: str | None = None
110
+ base_commit: str | None = None
111
+ branch: str | None = None
112
+ worktree_path: Path | None = None
113
+ commit: str | None = None
114
+ created_at: datetime = field(default_factory=lambda: datetime.now(UTC))
115
+ started_at: datetime | None = None
116
+ finished_at: datetime | None = None
117
+ error_category: str | None = None
118
+ error_message: str | None = None
119
+
120
+
121
+ def transition_subagent(
122
+ record: SubagentRecord,
123
+ status: SubagentStatus,
124
+ *,
125
+ now: datetime | None = None,
126
+ error_category: str | None = None,
127
+ error_message: str | None = None,
128
+ ) -> SubagentRecord:
129
+ if status not in _ALLOWED_TRANSITIONS.get(record.status, frozenset()):
130
+ raise ValueError(
131
+ f"invalid subagent status transition: {record.status.value} -> {status.value}"
132
+ )
133
+ changed_at = now or datetime.now(UTC)
134
+ return replace(
135
+ record,
136
+ status=status,
137
+ started_at=changed_at if status is SubagentStatus.RUNNING else record.started_at,
138
+ finished_at=changed_at
139
+ if status not in {SubagentStatus.QUEUED, SubagentStatus.RUNNING}
140
+ else None,
141
+ error_category=error_category,
142
+ error_message=error_message,
143
+ )
144
+
145
+
146
+ @dataclass(frozen=True, slots=True)
147
+ class VerificationResult:
148
+ command: str
149
+ exit_code: int | None
150
+ output_summary: str
151
+ passed: bool
152
+
153
+
154
+ @dataclass(frozen=True, slots=True)
155
+ class SubagentResult:
156
+ subagent_id: str
157
+ task_name: str
158
+ status: SubagentStatus
159
+ summary: str
160
+ changed_files: tuple[str, ...] = ()
161
+ commit: str | None = None
162
+ verification: tuple[VerificationResult, ...] = ()
163
+ usage: Usage = field(default_factory=Usage)
164
+ error_category: str | None = None
165
+ error_message: str | None = None
166
+
167
+
168
+ def sort_subagent_records(records: tuple[SubagentRecord, ...]) -> tuple[SubagentRecord, ...]:
169
+ return tuple(sorted(records, key=lambda record: record.task_index))
170
+
171
+
172
+ def subagent_record_to_dict(record: SubagentRecord) -> dict[str, Any]:
173
+ spec = record.spec
174
+ return {
175
+ "subagent_id": record.subagent_id,
176
+ "parent_session_id": record.parent_session_id,
177
+ "parent_run_id": record.parent_run_id,
178
+ "child_session_id": record.child_session_id,
179
+ "task_index": record.task_index,
180
+ "spec": {
181
+ "task_name": spec.task_name,
182
+ "role": spec.role.value,
183
+ "kind": spec.kind.value,
184
+ "goal": spec.goal,
185
+ "context": spec.context,
186
+ "expected_output": spec.expected_output,
187
+ "verification": list(spec.verification),
188
+ "allowed_tools": None if spec.allowed_tools is None else sorted(spec.allowed_tools),
189
+ "model": spec.model,
190
+ "requires_network": spec.requires_network,
191
+ },
192
+ "status": record.status.value,
193
+ "base_commit": record.base_commit,
194
+ "branch": record.branch,
195
+ "worktree_path": None if record.worktree_path is None else str(record.worktree_path),
196
+ "commit": record.commit,
197
+ "created_at": record.created_at.isoformat(),
198
+ "started_at": None if record.started_at is None else record.started_at.isoformat(),
199
+ "finished_at": None if record.finished_at is None else record.finished_at.isoformat(),
200
+ "error_category": record.error_category,
201
+ "error_message": record.error_message,
202
+ }
203
+
204
+
205
+ def subagent_record_from_dict(value: Mapping[str, object]) -> SubagentRecord:
206
+ raw_spec = value.get("spec")
207
+ if not isinstance(raw_spec, Mapping):
208
+ raise ValueError("subagent record spec must be an object")
209
+ spec_values = cast(Mapping[str, object], raw_spec)
210
+ verification_value = spec_values.get("verification", ())
211
+ allowed_tools = spec_values.get("allowed_tools")
212
+ if not isinstance(verification_value, (list, tuple)):
213
+ raise ValueError("subagent verification must be a sequence")
214
+ if allowed_tools is not None and not isinstance(allowed_tools, (list, tuple, set, frozenset)):
215
+ raise ValueError("subagent allowed_tools must be a sequence")
216
+ verification = cast(Sequence[object], verification_value)
217
+ allowed_tool_values = None if allowed_tools is None else cast(Sequence[object], allowed_tools)
218
+ spec = SubagentTaskSpec(
219
+ task_name=str(spec_values["task_name"]),
220
+ role=SubagentRole(str(spec_values["role"])),
221
+ kind=SubagentTaskKind(str(spec_values["kind"])),
222
+ goal=str(spec_values["goal"]),
223
+ context=str(spec_values["context"]),
224
+ expected_output=str(spec_values["expected_output"]),
225
+ verification=tuple(str(item) for item in verification),
226
+ allowed_tools=(
227
+ None
228
+ if allowed_tool_values is None
229
+ else frozenset(str(item) for item in allowed_tool_values)
230
+ ),
231
+ model=None if spec_values.get("model") is None else str(spec_values.get("model")),
232
+ requires_network=bool(spec_values.get("requires_network", False)),
233
+ )
234
+
235
+ def optional_time(name: str) -> datetime | None:
236
+ item = value.get(name)
237
+ return None if item is None else datetime.fromisoformat(str(item))
238
+
239
+ path = value.get("worktree_path")
240
+ return SubagentRecord(
241
+ subagent_id=str(value["subagent_id"]),
242
+ parent_session_id=str(value["parent_session_id"]),
243
+ parent_run_id=str(value["parent_run_id"]),
244
+ child_session_id=(
245
+ None if value.get("child_session_id") is None else str(value.get("child_session_id"))
246
+ ),
247
+ task_index=int(str(value["task_index"])),
248
+ spec=spec,
249
+ status=SubagentStatus(str(value["status"])),
250
+ base_commit=None if value.get("base_commit") is None else str(value.get("base_commit")),
251
+ branch=None if value.get("branch") is None else str(value.get("branch")),
252
+ worktree_path=None if path is None else Path(str(path)),
253
+ commit=None if value.get("commit") is None else str(value.get("commit")),
254
+ created_at=datetime.fromisoformat(str(value["created_at"])),
255
+ started_at=optional_time("started_at"),
256
+ finished_at=optional_time("finished_at"),
257
+ error_category=(
258
+ None if value.get("error_category") is None else str(value.get("error_category"))
259
+ ),
260
+ error_message=(
261
+ None if value.get("error_message") is None else str(value.get("error_message"))
262
+ ),
263
+ )
@@ -0,0 +1,55 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Awaitable, Callable, Mapping
4
+ from dataclasses import dataclass, field
5
+ from enum import StrEnum
6
+ from pathlib import Path
7
+ from typing import Any, Protocol
8
+
9
+ from pydantic import BaseModel
10
+
11
+
12
+ class ToolEffect(StrEnum):
13
+ READ = "read"
14
+ WORKSPACE_WRITE = "workspace_write"
15
+ PROCESS = "process"
16
+ NETWORK = "network"
17
+ OUTSIDE_WORKSPACE = "outside_workspace"
18
+ USER_INTERACTION = "user_interaction"
19
+
20
+
21
+ @dataclass(frozen=True, slots=True)
22
+ class ToolContext:
23
+ workspace: Path
24
+ run_id: str
25
+ cancelled: Callable[[], bool]
26
+ progress: Callable[[str], Awaitable[None]] | None = None
27
+ request_user: Callable[[object], Awaitable[object]] | None = None
28
+
29
+
30
+ @dataclass(frozen=True, slots=True)
31
+ class ToolResult:
32
+ output: str
33
+ is_error: bool = False
34
+ artifact_ref: str | None = None
35
+ elapsed_seconds: float = 0.0
36
+ data: dict[str, Any] = field(default_factory=dict[str, Any])
37
+
38
+
39
+ class Tool(Protocol):
40
+ @property
41
+ def name(self) -> str: ...
42
+
43
+ @property
44
+ def description(self) -> str: ...
45
+
46
+ @property
47
+ def input_model(self) -> type[BaseModel]: ...
48
+
49
+ @property
50
+ def effects(self) -> frozenset[ToolEffect]: ...
51
+
52
+ def execute(self, context: ToolContext, arguments: BaseModel) -> Awaitable[ToolResult]: ...
53
+
54
+
55
+ ValidatedArguments = BaseModel | Mapping[str, Any]
@@ -0,0 +1,27 @@
1
+ from windcode.extensions.models import (
2
+ ActivationState,
3
+ CapabilityKind,
4
+ CapabilityRecord,
5
+ Diagnostic,
6
+ DiagnosticSeverity,
7
+ DiagnosticStage,
8
+ ExtensionScope,
9
+ ExtensionSnapshot,
10
+ ExtensionSource,
11
+ ManagementResult,
12
+ PermissionRequirement,
13
+ )
14
+
15
+ __all__ = [
16
+ "ActivationState",
17
+ "CapabilityKind",
18
+ "CapabilityRecord",
19
+ "Diagnostic",
20
+ "DiagnosticSeverity",
21
+ "DiagnosticStage",
22
+ "ExtensionScope",
23
+ "ExtensionSnapshot",
24
+ "ExtensionSource",
25
+ "ManagementResult",
26
+ "PermissionRequirement",
27
+ ]
@@ -0,0 +1,25 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+ from windcode.extensions.plugins.manifest import PluginCommand
6
+
7
+
8
+ @dataclass(frozen=True, slots=True)
9
+ class CommandRoute:
10
+ name: str
11
+ target: str
12
+ source_id: str
13
+
14
+
15
+ def build_command_catalog(
16
+ commands: tuple[tuple[str, PluginCommand], ...], *, reserved: frozenset[str] = frozenset()
17
+ ) -> tuple[CommandRoute, ...]:
18
+ catalog: dict[str, CommandRoute] = {}
19
+ for source_id, command in sorted(commands, key=lambda item: (item[1].name, item[0])):
20
+ if command.name in reserved:
21
+ raise ValueError(f"plugin command conflicts with built-in command: {command.name}")
22
+ if command.name in catalog:
23
+ raise ValueError(f"duplicate plugin command: {command.name}")
24
+ catalog[command.name] = CommandRoute(command.name, command.target, source_id)
25
+ return tuple(catalog.values())