induscode 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 (167) hide show
  1. induscode/__init__.py +56 -0
  2. induscode/addons/__init__.py +176 -0
  3. induscode/addons/contract.py +923 -0
  4. induscode/addons/dispatch/__init__.py +43 -0
  5. induscode/addons/dispatch/event_dispatcher.py +348 -0
  6. induscode/addons/dispatch/tool_interceptor.py +349 -0
  7. induscode/addons/host.py +469 -0
  8. induscode/addons/loader.py +314 -0
  9. induscode/addons/manifest.py +232 -0
  10. induscode/addons/surface.py +199 -0
  11. induscode/boot/__init__.py +108 -0
  12. induscode/boot/auth_vault.py +323 -0
  13. induscode/boot/boot.py +210 -0
  14. induscode/boot/contract.py +223 -0
  15. induscode/boot/invocation.py +117 -0
  16. induscode/boot/runners/__init__.py +42 -0
  17. induscode/boot/runners/link_runner.py +82 -0
  18. induscode/boot/runners/oneshot_runner.py +85 -0
  19. induscode/boot/runners/registry.py +46 -0
  20. induscode/boot/runners/repl_runner.py +340 -0
  21. induscode/boot/runners/session.py +549 -0
  22. induscode/boot/stages.py +198 -0
  23. induscode/boot/upgrade/__init__.py +36 -0
  24. induscode/boot/upgrade/apply.py +125 -0
  25. induscode/boot/upgrade/upgrades.py +136 -0
  26. induscode/briefing/__init__.py +115 -0
  27. induscode/briefing/compose.py +414 -0
  28. induscode/briefing/contract.py +528 -0
  29. induscode/briefing/macros.py +721 -0
  30. induscode/briefing/skills.py +417 -0
  31. induscode/capability_deck/__init__.py +233 -0
  32. induscode/capability_deck/bridge_ledger/__init__.py +66 -0
  33. induscode/capability_deck/bridge_ledger/key.py +181 -0
  34. induscode/capability_deck/bridge_ledger/ledger.py +276 -0
  35. induscode/capability_deck/bridge_ledger/network.py +336 -0
  36. induscode/capability_deck/builtin_bridge.py +358 -0
  37. induscode/capability_deck/cards/__init__.py +116 -0
  38. induscode/capability_deck/cards/bg_process.py +482 -0
  39. induscode/capability_deck/cards/memory.py +226 -0
  40. induscode/capability_deck/cards/saas.py +280 -0
  41. induscode/capability_deck/cards/task.py +256 -0
  42. induscode/capability_deck/cards/todo.py +312 -0
  43. induscode/capability_deck/contract.py +450 -0
  44. induscode/capability_deck/manifest.py +126 -0
  45. induscode/capability_deck/provision.py +217 -0
  46. induscode/channels/__init__.py +146 -0
  47. induscode/channels/contract.py +585 -0
  48. induscode/channels/framer.py +132 -0
  49. induscode/channels/link/__init__.py +50 -0
  50. induscode/channels/link/dialog.py +246 -0
  51. induscode/channels/link/driver.py +308 -0
  52. induscode/channels/link/server.py +217 -0
  53. induscode/channels/oneshot.py +178 -0
  54. induscode/channels/ops.py +140 -0
  55. induscode/channels/session_ops.py +172 -0
  56. induscode/conductor/__init__.py +240 -0
  57. induscode/conductor/catalog.py +309 -0
  58. induscode/conductor/conductor.py +1084 -0
  59. induscode/conductor/contract.py +1035 -0
  60. induscode/conductor/matcher.py +291 -0
  61. induscode/conductor/serialize.py +575 -0
  62. induscode/conductor/signal_hub.py +382 -0
  63. induscode/conductor/skill_parse.py +294 -0
  64. induscode/conductor/transcript_store.py +449 -0
  65. induscode/console/__init__.py +236 -0
  66. induscode/console/app.py +1677 -0
  67. induscode/console/components/__init__.py +62 -0
  68. induscode/console/components/banner.py +499 -0
  69. induscode/console/components/banner_sweep.py +188 -0
  70. induscode/console/components/emblem.py +181 -0
  71. induscode/console/components/status_bar.py +102 -0
  72. induscode/console/contract.py +836 -0
  73. induscode/console/input/__init__.py +107 -0
  74. induscode/console/input/chord.py +197 -0
  75. induscode/console/input/dir_reader.py +113 -0
  76. induscode/console/input/intents.py +258 -0
  77. induscode/console/input/providers.py +469 -0
  78. induscode/console/mount.py +137 -0
  79. induscode/console/overlays/__init__.py +94 -0
  80. induscode/console/overlays/auth.py +503 -0
  81. induscode/console/overlays/pickers.py +526 -0
  82. induscode/console/overlays/router.py +129 -0
  83. induscode/console/overlays/sessions.py +232 -0
  84. induscode/console/reducer.py +145 -0
  85. induscode/console/resume_picker.py +156 -0
  86. induscode/console/slash_commands/__init__.py +78 -0
  87. induscode/console/slash_commands/builtins.py +254 -0
  88. induscode/console/slash_commands/dynamic.py +217 -0
  89. induscode/console/slash_commands/integrations.py +949 -0
  90. induscode/console/slash_commands/transcript.py +404 -0
  91. induscode/console/slash_commands/workbench.py +430 -0
  92. induscode/console/startup.py +434 -0
  93. induscode/console/theme/__init__.py +44 -0
  94. induscode/console/theme/adapter.py +168 -0
  95. induscode/console/theme/palette.py +128 -0
  96. induscode/console/theme/resolve.py +123 -0
  97. induscode/console/theme/tokens.py +185 -0
  98. induscode/console_slash/__init__.py +111 -0
  99. induscode/console_slash/contract.py +185 -0
  100. induscode/console_slash/registry.py +140 -0
  101. induscode/console_slash/resolve.py +194 -0
  102. induscode/console_slash/shared.py +172 -0
  103. induscode/entry.py +108 -0
  104. induscode/insight/__init__.py +153 -0
  105. induscode/insight/collector.py +73 -0
  106. induscode/insight/replay.py +305 -0
  107. induscode/insight/wrapper.py +1115 -0
  108. induscode/kit/__init__.py +82 -0
  109. induscode/kit/clipboard_image.py +215 -0
  110. induscode/kit/external_editor.py +120 -0
  111. induscode/kit/image.py +188 -0
  112. induscode/kit/shell.py +89 -0
  113. induscode/kit/tool_fetch.py +288 -0
  114. induscode/launch/__init__.py +224 -0
  115. induscode/launch/catalog.py +310 -0
  116. induscode/launch/contract.py +569 -0
  117. induscode/launch/credentials.py +852 -0
  118. induscode/launch/invocation/__init__.py +39 -0
  119. induscode/launch/invocation/attachments.py +281 -0
  120. induscode/launch/invocation/flags.py +210 -0
  121. induscode/launch/invocation/read.py +369 -0
  122. induscode/launch/invocation/usage.py +110 -0
  123. induscode/launch/oauth.py +808 -0
  124. induscode/launch/packages.py +299 -0
  125. induscode/launch/pickers.py +291 -0
  126. induscode/py.typed +0 -0
  127. induscode/runtime_bridge/__init__.py +166 -0
  128. induscode/runtime_bridge/bridges/__init__.py +66 -0
  129. induscode/runtime_bridge/bridges/_drive.py +268 -0
  130. induscode/runtime_bridge/bridges/builtins.py +177 -0
  131. induscode/runtime_bridge/bridges/claude_cli.py +198 -0
  132. induscode/runtime_bridge/bridges/codex_cli.py +203 -0
  133. induscode/runtime_bridge/bridges/indusagi_cli.py +217 -0
  134. induscode/runtime_bridge/broker.py +397 -0
  135. induscode/runtime_bridge/contract.py +734 -0
  136. induscode/runtime_bridge/sink.py +351 -0
  137. induscode/sessions/__init__.py +25 -0
  138. induscode/sessions/contract.py +119 -0
  139. induscode/sessions/library.py +350 -0
  140. induscode/settings/__init__.py +47 -0
  141. induscode/settings/contract.py +313 -0
  142. induscode/settings/manager.py +268 -0
  143. induscode/transcript_export/__init__.py +109 -0
  144. induscode/transcript_export/contract.py +522 -0
  145. induscode/transcript_export/publish.py +455 -0
  146. induscode/transcript_export/sgr.py +566 -0
  147. induscode/transcript_export/template.py +319 -0
  148. induscode/transcript_export/theme_bridge.py +325 -0
  149. induscode/window_budget/__init__.py +76 -0
  150. induscode/window_budget/budget/__init__.py +26 -0
  151. induscode/window_budget/budget/estimate.py +273 -0
  152. induscode/window_budget/budget/gate.py +60 -0
  153. induscode/window_budget/budget/slice.py +145 -0
  154. induscode/window_budget/condenser.py +170 -0
  155. induscode/window_budget/contract.py +329 -0
  156. induscode/window_budget/summarize/__init__.py +33 -0
  157. induscode/window_budget/summarize/condense.py +212 -0
  158. induscode/window_budget/summarize/prompt.py +241 -0
  159. induscode/workspace/__init__.py +30 -0
  160. induscode/workspace/brand.py +96 -0
  161. induscode/workspace/locator.py +269 -0
  162. induscode-0.1.0.dist-info/METADATA +97 -0
  163. induscode-0.1.0.dist-info/RECORD +167 -0
  164. induscode-0.1.0.dist-info/WHEEL +4 -0
  165. induscode-0.1.0.dist-info/entry_points.txt +3 -0
  166. induscode-0.1.0.dist-info/licenses/CREDITS.md +22 -0
  167. induscode-0.1.0.dist-info/licenses/NOTICE +7 -0
@@ -0,0 +1,116 @@
1
+ """Capability cards — the app-novel capabilities the deck builds in-house.
2
+
3
+ Each card is a :class:`CapabilityCard`: metadata plus a ``build(ctx)`` factory
4
+ that mints a live :data:`Capability` (the framework ``AgentTool`` shape) for a
5
+ working context. These are written fresh against the framework contract — not
6
+ derived from any prior tooling layer — and stay framework-agnostic where the
7
+ behavior is original (todo and bg-process wrap only the stdlib + the deck
8
+ contract).
9
+
10
+ The connector and memory cards expose a thin, clearly-typed adapter seam over
11
+ a framework handle injected through :attr:`DeckContext.framework`; when no
12
+ handle is wired they degrade to a typed stub so every card builds and runs in
13
+ any environment, including tests.
14
+
15
+ :data:`APP_NOVEL_CARDS` is the contribution this package makes to the catalog;
16
+ the provisioner's profile table concatenates it onto the builtin-bridge cards
17
+ (the framework's file/shell/search/web tools) for the ``all`` profile.
18
+
19
+ Port note: the TS barrel also exported the ``Static``-derived
20
+ ``XxxParamsType`` aliases; those compile-time types have no Python analogue
21
+ (parameters are plain JSON-schema mappings — see the deck contract's port
22
+ note) and are dropped here. Everything else is exported 1:1, snake_cased.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ from ..contract import CapabilityCard
28
+ from .bg_process import (
29
+ DaemonDetails,
30
+ DaemonState,
31
+ DaemonTable,
32
+ build_daemon_capability,
33
+ daemon_card,
34
+ )
35
+ from .memory import (
36
+ InMemoryStore,
37
+ MEMORY_HANDLE_KEY,
38
+ MemoryDetails,
39
+ MemoryStore,
40
+ build_memory_capability,
41
+ memory_card,
42
+ )
43
+ from .saas import (
44
+ RemoteExecution,
45
+ RemoteToolSummary,
46
+ SAAS_GATEWAY_KEY,
47
+ SaasDetails,
48
+ SaasGatewayPort,
49
+ build_saas_capability,
50
+ saas_card,
51
+ )
52
+ from .task import (
53
+ DELEGATE_HANDLE_KEY,
54
+ DelegateRequest,
55
+ DelegateResult,
56
+ DelegateRunner,
57
+ TaskDetails,
58
+ build_task_capability,
59
+ task_card,
60
+ )
61
+ from .todo import (
62
+ TodoDetails,
63
+ TodoItem,
64
+ TodoLedger,
65
+ TodoState,
66
+ TodoWeight,
67
+ build_todo_capability,
68
+ todo_card,
69
+ )
70
+
71
+ __all__ = [
72
+ "APP_NOVEL_CARDS",
73
+ "DELEGATE_HANDLE_KEY",
74
+ "DaemonDetails",
75
+ "DaemonState",
76
+ "DaemonTable",
77
+ "DelegateRequest",
78
+ "DelegateResult",
79
+ "DelegateRunner",
80
+ "InMemoryStore",
81
+ "MEMORY_HANDLE_KEY",
82
+ "MemoryDetails",
83
+ "MemoryStore",
84
+ "RemoteExecution",
85
+ "RemoteToolSummary",
86
+ "SAAS_GATEWAY_KEY",
87
+ "SaasDetails",
88
+ "SaasGatewayPort",
89
+ "TaskDetails",
90
+ "TodoDetails",
91
+ "TodoItem",
92
+ "TodoLedger",
93
+ "TodoState",
94
+ "TodoWeight",
95
+ "build_daemon_capability",
96
+ "build_memory_capability",
97
+ "build_saas_capability",
98
+ "build_task_capability",
99
+ "build_todo_capability",
100
+ "daemon_card",
101
+ "memory_card",
102
+ "saas_card",
103
+ "task_card",
104
+ "todo_card",
105
+ ]
106
+
107
+ #: The app-novel cards, in catalog order. The provisioner's profile table
108
+ #: concatenates these onto the builtin-bridge selection for the ``all``
109
+ #: profile.
110
+ APP_NOVEL_CARDS: tuple[CapabilityCard, ...] = (
111
+ todo_card,
112
+ daemon_card,
113
+ task_card,
114
+ saas_card,
115
+ memory_card,
116
+ )
@@ -0,0 +1,482 @@
1
+ """Background-process capability — start, poll, and stop long-lived child
2
+ processes that outlive a single tool call.
3
+
4
+ App-novel and framework-agnostic: it wraps :func:`asyncio.create_subprocess_shell`
5
+ directly rather than delegating to any framework process controller, so the
6
+ lifecycle is owned entirely by this card. A long-running command (a dev
7
+ server, a watcher, a build in ``--watch`` mode) is launched detached from the
8
+ tool turn; the agent later polls its captured output or signals it to stop.
9
+
10
+ One tool folds the lifecycle into an ``action`` discriminant:
11
+
12
+ - ``start`` — spawn ``command`` in a shell, return the assigned handle id.
13
+ - ``poll`` — return the buffered stdout/stderr (and live/exited status) for a
14
+ handle, optionally only the lines appended since the last poll.
15
+ - ``stop`` — send SIGTERM (escalating to SIGKILL after a grace window) to a
16
+ handle's process.
17
+ - ``list`` — enumerate every handle this capability is tracking.
18
+
19
+ Output is captured into bounded ring buffers so a chatty process cannot grow
20
+ memory without limit; only the most recent lines are retained. The card
21
+ produces a :data:`Capability` (framework ``AgentTool``) the conductor consumes.
22
+
23
+ Port notes (TS ``cards/bg-process-card.ts`` → asyncio):
24
+
25
+ - Node's sync ``spawn`` becomes the awaited ``create_subprocess_shell``, so
26
+ :meth:`DaemonTable.start` is a coroutine — ``execute`` is async anyway.
27
+ - The stdout/stderr ``data`` listeners become two reader tasks per handle,
28
+ each pumping decoded chunks into its ring buffer; the ``exit`` listener
29
+ becomes a waiter task recording terminal state (a negative ``returncode``
30
+ maps to the TS ``signalled`` branch, with the signal *name* recovered).
31
+ - The TS ``graceMs = 3000`` SIGTERM→SIGKILL escalation is kept verbatim as
32
+ ``grace = 3.0`` seconds; like the TS original, ``stop`` resolves as soon as
33
+ the kill is sent rather than waiting again.
34
+ """
35
+
36
+ from __future__ import annotations
37
+
38
+ import asyncio
39
+ import re
40
+ import signal as _signal
41
+ import time
42
+ from collections.abc import Mapping
43
+ from dataclasses import dataclass, field
44
+ from typing import Literal, TypeAlias
45
+
46
+ from indusagi.agent import AgentToolResult
47
+ from indusagi.ai import TextContent
48
+
49
+ from ..contract import Capability, CapabilityCard, DeckContext, Schema, capability_id
50
+
51
+ __all__ = [
52
+ "DaemonDetails",
53
+ "DaemonState",
54
+ "DaemonTable",
55
+ "build_daemon_capability",
56
+ "daemon_card",
57
+ ]
58
+
59
+
60
+ # ---------------------------------------------------------------------------
61
+ # Process table (in-memory, card-owned)
62
+ # ---------------------------------------------------------------------------
63
+
64
+ #: Coarse lifecycle state of a tracked background process.
65
+ DaemonState: TypeAlias = Literal["running", "exited", "signalled"]
66
+
67
+ _MAX_BUFFERED_LINES = 2_000
68
+
69
+ _LINE_SPLIT = re.compile(r"\r?\n")
70
+
71
+
72
+ @dataclass(slots=True)
73
+ class _RingBuffer:
74
+ """A bounded buffer of recent output lines for one stream."""
75
+
76
+ lines: list[str] = field(default_factory=list)
77
+ # Index of the next unread line for incremental polling.
78
+ cursor: int = 0
79
+
80
+
81
+ def _push_lines(buf: _RingBuffer, chunk: str) -> None:
82
+ # Split on newlines; keeping partials joined to the last buffered line is
83
+ # overkill here (the TS card made the same call) — each chunk's lines are
84
+ # treated as discrete and the buffer is clipped to the cap.
85
+ for line in _LINE_SPLIT.split(chunk):
86
+ if line:
87
+ buf.lines.append(line)
88
+ if len(buf.lines) > _MAX_BUFFERED_LINES:
89
+ drop = len(buf.lines) - _MAX_BUFFERED_LINES
90
+ del buf.lines[:drop]
91
+ buf.cursor = max(0, buf.cursor - drop)
92
+
93
+
94
+ @dataclass(slots=True)
95
+ class DaemonHandle:
96
+ """One tracked background process and its captured state."""
97
+
98
+ id: str
99
+ command: str
100
+ proc: asyncio.subprocess.Process
101
+ state: DaemonState
102
+ exit_code: int | None
103
+ signal: str | None
104
+ started_at: int
105
+ stdout: _RingBuffer
106
+ stderr: _RingBuffer
107
+ # The reader tasks pumping the pipes, plus the exit waiter.
108
+ pumps: tuple[asyncio.Task[None], ...] = ()
109
+ waiter: asyncio.Task[None] | None = None
110
+
111
+
112
+ def _signal_name(signum: int) -> str:
113
+ try:
114
+ return _signal.Signals(signum).name
115
+ except ValueError:
116
+ return f"SIG{signum}"
117
+
118
+
119
+ async def _pump(stream: asyncio.StreamReader, buf: _RingBuffer) -> None:
120
+ """Read a pipe to EOF, feeding decoded chunks into the ring buffer."""
121
+ while True:
122
+ chunk = await stream.read(8192)
123
+ if not chunk:
124
+ return
125
+ _push_lines(buf, chunk.decode("utf-8", errors="replace"))
126
+
127
+
128
+ class DaemonTable:
129
+ """An in-process table of background children, owned by one built
130
+ capability.
131
+
132
+ Each :meth:`start` spawns a shell-wrapped child, wires its stdout/stderr
133
+ into bounded buffers via reader tasks, and records terminal state when the
134
+ process exits. :meth:`stop` escalates from SIGTERM to SIGKILL if the
135
+ process does not exit within a grace window. The table is per-capability,
136
+ so one session's daemons are isolated from another's.
137
+ """
138
+
139
+ def __init__(self, cwd: str) -> None:
140
+ self._cwd = cwd
141
+ self._handles: dict[str, DaemonHandle] = {}
142
+ self._next_seq = 1
143
+
144
+ async def start(self, command: str) -> DaemonHandle:
145
+ """Spawn ``command`` in a shell under the table's cwd and begin
146
+ capturing its output."""
147
+ handle_id = f"bg-{self._next_seq}"
148
+ self._next_seq += 1
149
+ proc = await asyncio.create_subprocess_shell(
150
+ command,
151
+ cwd=self._cwd,
152
+ stdin=asyncio.subprocess.DEVNULL,
153
+ stdout=asyncio.subprocess.PIPE,
154
+ stderr=asyncio.subprocess.PIPE,
155
+ )
156
+ handle = DaemonHandle(
157
+ id=handle_id,
158
+ command=command,
159
+ proc=proc,
160
+ state="running",
161
+ exit_code=None,
162
+ signal=None,
163
+ started_at=int(time.time() * 1000),
164
+ stdout=_RingBuffer(),
165
+ stderr=_RingBuffer(),
166
+ )
167
+ pumps: list[asyncio.Task[None]] = []
168
+ if proc.stdout is not None:
169
+ pumps.append(asyncio.create_task(_pump(proc.stdout, handle.stdout)))
170
+ if proc.stderr is not None:
171
+ pumps.append(asyncio.create_task(_pump(proc.stderr, handle.stderr)))
172
+ handle.pumps = tuple(pumps)
173
+ handle.waiter = asyncio.create_task(self._watch_exit(handle))
174
+ self._handles[handle_id] = handle
175
+ return handle
176
+
177
+ @staticmethod
178
+ async def _watch_exit(handle: DaemonHandle) -> None:
179
+ """Record terminal state once the child exits — the TS ``exit``
180
+ listener. A negative returncode means the child was signalled (Node
181
+ reported ``code=null, signal="SIGTERM"`` for the same case)."""
182
+ code = await handle.proc.wait()
183
+ if code < 0:
184
+ handle.exit_code = None
185
+ handle.signal = _signal_name(-code)
186
+ handle.state = "signalled"
187
+ else:
188
+ handle.exit_code = code
189
+ handle.signal = None
190
+ handle.state = "exited"
191
+
192
+ def get(self, id: str) -> DaemonHandle | None:
193
+ return self._handles.get(id)
194
+
195
+ def list(self) -> list[DaemonHandle]:
196
+ return list(self._handles.values())
197
+
198
+ async def stop(self, handle: DaemonHandle, grace: float = 3.0) -> None:
199
+ """Signal a handle's process to terminate. Sends SIGTERM immediately
200
+ and a SIGKILL after ``grace`` seconds if the child is still alive.
201
+ Resolves once the child has exited or the kill has been sent."""
202
+ if handle.state != "running":
203
+ return
204
+ try:
205
+ handle.proc.terminate()
206
+ except ProcessLookupError:
207
+ # Already gone; the waiter records the terminal state.
208
+ return
209
+ waiter = handle.waiter
210
+ if waiter is None: # pragma: no cover — start always wires a waiter
211
+ return
212
+ try:
213
+ # Shield: a grace timeout must not cancel the exit recorder.
214
+ await asyncio.wait_for(asyncio.shield(waiter), grace)
215
+ except TimeoutError:
216
+ if handle.state == "running":
217
+ try:
218
+ handle.proc.kill()
219
+ except ProcessLookupError:
220
+ pass
221
+ return
222
+ # The child exited within the grace window; drain the readers so no
223
+ # task outlives the call (they end promptly at pipe EOF).
224
+ if handle.pumps:
225
+ await asyncio.gather(*handle.pumps, return_exceptions=True)
226
+
227
+
228
+ # ---------------------------------------------------------------------------
229
+ # Parameters (dict-literal JSON Schema)
230
+ # ---------------------------------------------------------------------------
231
+
232
+ _DAEMON_PARAMS: Schema = {
233
+ "type": "object",
234
+ "properties": {
235
+ "action": {
236
+ "type": "string",
237
+ "enum": ["start", "poll", "stop", "list"],
238
+ "description": (
239
+ "`start` launches a long-running command; `poll` reads its captured "
240
+ "output; `stop` terminates it; `list` shows all tracked processes."
241
+ ),
242
+ },
243
+ "command": {
244
+ "type": "string",
245
+ "description": "Shell command to launch. Required for `start`.",
246
+ },
247
+ "id": {
248
+ "type": "string",
249
+ "description": "Handle returned by `start`. Required for `poll` and `stop`.",
250
+ },
251
+ "sinceLast": {
252
+ "type": "boolean",
253
+ "description": (
254
+ "On `poll`, return only output appended since the previous poll of "
255
+ "this handle. Defaults to false (return the full retained buffer)."
256
+ ),
257
+ },
258
+ },
259
+ "required": ["action"],
260
+ "additionalProperties": False,
261
+ }
262
+
263
+
264
+ @dataclass(frozen=True, slots=True, kw_only=True)
265
+ class _DaemonRow:
266
+ """One row of a ``list`` result's structured detail."""
267
+
268
+ id: str
269
+ command: str
270
+ state: DaemonState
271
+
272
+
273
+ @dataclass(frozen=True, slots=True, kw_only=True)
274
+ class DaemonDetails:
275
+ """Structured detail returned alongside the model-facing content."""
276
+
277
+ action: Literal["start", "poll", "stop", "list"]
278
+ ok: bool
279
+ id: str | None = None
280
+ state: DaemonState | None = None
281
+ exit_code: int | None = None
282
+ processes: tuple[_DaemonRow, ...] | None = None
283
+
284
+
285
+ _DAEMON_DESCRIPTION = (
286
+ "Run and manage long-lived background processes that persist across tool calls — dev "
287
+ "servers, file watchers, anything you start once and observe over time. Use "
288
+ '`action:"start"` with a `command` to launch one (you get back a handle `id`), '
289
+ '`action:"poll"` with that `id` to read its accumulated output, `action:"stop"` to '
290
+ 'terminate it, and `action:"list"` to see what is running. Do NOT use this for '
291
+ "ordinary one-shot commands that finish on their own — run those with the shell tool."
292
+ )
293
+
294
+
295
+ # ---------------------------------------------------------------------------
296
+ # Output rendering
297
+ # ---------------------------------------------------------------------------
298
+
299
+
300
+ def _drain_buffer(buf: _RingBuffer, since_last: bool) -> list[str]:
301
+ if since_last:
302
+ fresh = buf.lines[buf.cursor :]
303
+ buf.cursor = len(buf.lines)
304
+ return fresh
305
+ return list(buf.lines)
306
+
307
+
308
+ def _status_line(handle: DaemonHandle) -> str:
309
+ if handle.state == "running":
310
+ return "running"
311
+ if handle.state == "signalled":
312
+ return f"signalled ({handle.signal if handle.signal is not None else '?'})"
313
+ code = handle.exit_code if handle.exit_code is not None else "?"
314
+ return f"exited (code {code})"
315
+
316
+
317
+ def _field(params: object, key: str) -> object:
318
+ if isinstance(params, Mapping):
319
+ return params.get(key)
320
+ return getattr(params, key, None)
321
+
322
+
323
+ def _err_result(
324
+ action: Literal["start", "poll", "stop", "list"], message: str
325
+ ) -> AgentToolResult:
326
+ return AgentToolResult(
327
+ content=(TextContent(text=message),),
328
+ details=DaemonDetails(action=action, ok=False),
329
+ isError=True,
330
+ )
331
+
332
+
333
+ # ---------------------------------------------------------------------------
334
+ # Capability builder
335
+ # ---------------------------------------------------------------------------
336
+
337
+
338
+ class _DaemonCapability:
339
+ """The live background-process capability — structurally an ``AgentTool``."""
340
+
341
+ name = "bg-process"
342
+ label = "Background process"
343
+ description = _DAEMON_DESCRIPTION
344
+ parameters: Schema = _DAEMON_PARAMS
345
+
346
+ def __init__(self, cwd: str) -> None:
347
+ self._table = DaemonTable(cwd)
348
+
349
+ async def execute(
350
+ self,
351
+ tool_call_id: str,
352
+ params: object,
353
+ signal: object = None,
354
+ on_update: object = None,
355
+ ) -> AgentToolResult:
356
+ del tool_call_id, signal, on_update
357
+ action = _field(params, "action")
358
+ if action == "start":
359
+ command = _field(params, "command")
360
+ if not isinstance(command, str) or command == "":
361
+ return _err_result(
362
+ "start", "`command` is required to start a background process."
363
+ )
364
+ try:
365
+ handle = await self._table.start(command)
366
+ except Exception as bad: # defensive: a spawn failure names itself
367
+ return _err_result("start", f"spawn error: {bad}")
368
+ return AgentToolResult(
369
+ content=(
370
+ TextContent(
371
+ text=(
372
+ f"Started background process {handle.id}: {handle.command}\n"
373
+ f'Poll it with action:"poll", id:"{handle.id}".'
374
+ )
375
+ ),
376
+ ),
377
+ details=DaemonDetails(
378
+ action="start", ok=True, id=handle.id, state=handle.state
379
+ ),
380
+ )
381
+ if action == "poll":
382
+ raw_id = _field(params, "id")
383
+ handle = self._table.get(raw_id) if isinstance(raw_id, str) else None
384
+ if handle is None:
385
+ shown = raw_id if isinstance(raw_id, str) else ""
386
+ return _err_result("poll", f'No background process with id "{shown}".')
387
+ since_last = bool(_field(params, "sinceLast") or False)
388
+ out = _drain_buffer(handle.stdout, since_last)
389
+ err = _drain_buffer(handle.stderr, since_last)
390
+ parts = [f"Process {handle.id} — {_status_line(handle)}"]
391
+ if out:
392
+ parts.append("--- stdout ---\n" + "\n".join(out))
393
+ if err:
394
+ parts.append("--- stderr ---\n" + "\n".join(err))
395
+ if not out and not err:
396
+ parts.append("(no new output)")
397
+ return AgentToolResult(
398
+ content=(TextContent(text="\n".join(parts)),),
399
+ details=DaemonDetails(
400
+ action="poll",
401
+ ok=True,
402
+ id=handle.id,
403
+ state=handle.state,
404
+ exit_code=handle.exit_code,
405
+ ),
406
+ )
407
+ if action == "stop":
408
+ raw_id = _field(params, "id")
409
+ handle = self._table.get(raw_id) if isinstance(raw_id, str) else None
410
+ if handle is None:
411
+ shown = raw_id if isinstance(raw_id, str) else ""
412
+ return _err_result("stop", f'No background process with id "{shown}".')
413
+ await self._table.stop(handle)
414
+ return AgentToolResult(
415
+ content=(
416
+ TextContent(
417
+ text=(
418
+ f"Stopped background process {handle.id} "
419
+ f"({_status_line(handle)})."
420
+ )
421
+ ),
422
+ ),
423
+ details=DaemonDetails(
424
+ action="stop",
425
+ ok=True,
426
+ id=handle.id,
427
+ state=handle.state,
428
+ exit_code=handle.exit_code,
429
+ ),
430
+ )
431
+ if action == "list":
432
+ handles = self._table.list()
433
+ text = (
434
+ "No background processes are being tracked."
435
+ if not handles
436
+ else "\n".join(
437
+ f"{h.id} [{_status_line(h)}] {h.command}" for h in handles
438
+ )
439
+ )
440
+ return AgentToolResult(
441
+ content=(TextContent(text=text),),
442
+ details=DaemonDetails(
443
+ action="list",
444
+ ok=True,
445
+ processes=tuple(
446
+ _DaemonRow(id=h.id, command=h.command, state=h.state)
447
+ for h in handles
448
+ ),
449
+ ),
450
+ )
451
+ return AgentToolResult(
452
+ content=(
453
+ TextContent(
454
+ text='`action` must be "start", "poll", "stop", or "list".'
455
+ ),
456
+ ),
457
+ details=None,
458
+ isError=True,
459
+ )
460
+
461
+
462
+ def build_daemon_capability(ctx: DeckContext) -> _DaemonCapability:
463
+ """Build the background-process capability, binding it to a fresh
464
+ per-session :class:`DaemonTable` scoped to the context's working
465
+ directory.
466
+
467
+ :param ctx: the deck context — its ``cwd`` is where launched processes run
468
+ """
469
+ return _DaemonCapability(ctx.cwd)
470
+
471
+
472
+ def _build(ctx: DeckContext) -> Capability:
473
+ return build_daemon_capability(ctx)
474
+
475
+
476
+ #: Catalog row for the background-process capability.
477
+ daemon_card = CapabilityCard(
478
+ id=capability_id("bg-process"),
479
+ title="Background process",
480
+ summary="Start, poll, and stop long-lived child processes that span multiple turns.",
481
+ build=_build,
482
+ )