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.
- induscode/__init__.py +56 -0
- induscode/addons/__init__.py +176 -0
- induscode/addons/contract.py +923 -0
- induscode/addons/dispatch/__init__.py +43 -0
- induscode/addons/dispatch/event_dispatcher.py +348 -0
- induscode/addons/dispatch/tool_interceptor.py +349 -0
- induscode/addons/host.py +469 -0
- induscode/addons/loader.py +314 -0
- induscode/addons/manifest.py +232 -0
- induscode/addons/surface.py +199 -0
- induscode/boot/__init__.py +108 -0
- induscode/boot/auth_vault.py +323 -0
- induscode/boot/boot.py +210 -0
- induscode/boot/contract.py +223 -0
- induscode/boot/invocation.py +117 -0
- induscode/boot/runners/__init__.py +42 -0
- induscode/boot/runners/link_runner.py +82 -0
- induscode/boot/runners/oneshot_runner.py +85 -0
- induscode/boot/runners/registry.py +46 -0
- induscode/boot/runners/repl_runner.py +340 -0
- induscode/boot/runners/session.py +549 -0
- induscode/boot/stages.py +198 -0
- induscode/boot/upgrade/__init__.py +36 -0
- induscode/boot/upgrade/apply.py +125 -0
- induscode/boot/upgrade/upgrades.py +136 -0
- induscode/briefing/__init__.py +115 -0
- induscode/briefing/compose.py +414 -0
- induscode/briefing/contract.py +528 -0
- induscode/briefing/macros.py +721 -0
- induscode/briefing/skills.py +417 -0
- induscode/capability_deck/__init__.py +233 -0
- induscode/capability_deck/bridge_ledger/__init__.py +66 -0
- induscode/capability_deck/bridge_ledger/key.py +181 -0
- induscode/capability_deck/bridge_ledger/ledger.py +276 -0
- induscode/capability_deck/bridge_ledger/network.py +336 -0
- induscode/capability_deck/builtin_bridge.py +358 -0
- induscode/capability_deck/cards/__init__.py +116 -0
- induscode/capability_deck/cards/bg_process.py +482 -0
- induscode/capability_deck/cards/memory.py +226 -0
- induscode/capability_deck/cards/saas.py +280 -0
- induscode/capability_deck/cards/task.py +256 -0
- induscode/capability_deck/cards/todo.py +312 -0
- induscode/capability_deck/contract.py +450 -0
- induscode/capability_deck/manifest.py +126 -0
- induscode/capability_deck/provision.py +217 -0
- induscode/channels/__init__.py +146 -0
- induscode/channels/contract.py +585 -0
- induscode/channels/framer.py +132 -0
- induscode/channels/link/__init__.py +50 -0
- induscode/channels/link/dialog.py +246 -0
- induscode/channels/link/driver.py +308 -0
- induscode/channels/link/server.py +217 -0
- induscode/channels/oneshot.py +178 -0
- induscode/channels/ops.py +140 -0
- induscode/channels/session_ops.py +172 -0
- induscode/conductor/__init__.py +240 -0
- induscode/conductor/catalog.py +309 -0
- induscode/conductor/conductor.py +1084 -0
- induscode/conductor/contract.py +1035 -0
- induscode/conductor/matcher.py +291 -0
- induscode/conductor/serialize.py +575 -0
- induscode/conductor/signal_hub.py +382 -0
- induscode/conductor/skill_parse.py +294 -0
- induscode/conductor/transcript_store.py +449 -0
- induscode/console/__init__.py +236 -0
- induscode/console/app.py +1677 -0
- induscode/console/components/__init__.py +62 -0
- induscode/console/components/banner.py +499 -0
- induscode/console/components/banner_sweep.py +188 -0
- induscode/console/components/emblem.py +181 -0
- induscode/console/components/status_bar.py +102 -0
- induscode/console/contract.py +836 -0
- induscode/console/input/__init__.py +107 -0
- induscode/console/input/chord.py +197 -0
- induscode/console/input/dir_reader.py +113 -0
- induscode/console/input/intents.py +258 -0
- induscode/console/input/providers.py +469 -0
- induscode/console/mount.py +137 -0
- induscode/console/overlays/__init__.py +94 -0
- induscode/console/overlays/auth.py +503 -0
- induscode/console/overlays/pickers.py +526 -0
- induscode/console/overlays/router.py +129 -0
- induscode/console/overlays/sessions.py +232 -0
- induscode/console/reducer.py +145 -0
- induscode/console/resume_picker.py +156 -0
- induscode/console/slash_commands/__init__.py +78 -0
- induscode/console/slash_commands/builtins.py +254 -0
- induscode/console/slash_commands/dynamic.py +217 -0
- induscode/console/slash_commands/integrations.py +949 -0
- induscode/console/slash_commands/transcript.py +404 -0
- induscode/console/slash_commands/workbench.py +430 -0
- induscode/console/startup.py +434 -0
- induscode/console/theme/__init__.py +44 -0
- induscode/console/theme/adapter.py +168 -0
- induscode/console/theme/palette.py +128 -0
- induscode/console/theme/resolve.py +123 -0
- induscode/console/theme/tokens.py +185 -0
- induscode/console_slash/__init__.py +111 -0
- induscode/console_slash/contract.py +185 -0
- induscode/console_slash/registry.py +140 -0
- induscode/console_slash/resolve.py +194 -0
- induscode/console_slash/shared.py +172 -0
- induscode/entry.py +108 -0
- induscode/insight/__init__.py +153 -0
- induscode/insight/collector.py +73 -0
- induscode/insight/replay.py +305 -0
- induscode/insight/wrapper.py +1115 -0
- induscode/kit/__init__.py +82 -0
- induscode/kit/clipboard_image.py +215 -0
- induscode/kit/external_editor.py +120 -0
- induscode/kit/image.py +188 -0
- induscode/kit/shell.py +89 -0
- induscode/kit/tool_fetch.py +288 -0
- induscode/launch/__init__.py +224 -0
- induscode/launch/catalog.py +310 -0
- induscode/launch/contract.py +569 -0
- induscode/launch/credentials.py +852 -0
- induscode/launch/invocation/__init__.py +39 -0
- induscode/launch/invocation/attachments.py +281 -0
- induscode/launch/invocation/flags.py +210 -0
- induscode/launch/invocation/read.py +369 -0
- induscode/launch/invocation/usage.py +110 -0
- induscode/launch/oauth.py +808 -0
- induscode/launch/packages.py +299 -0
- induscode/launch/pickers.py +291 -0
- induscode/py.typed +0 -0
- induscode/runtime_bridge/__init__.py +166 -0
- induscode/runtime_bridge/bridges/__init__.py +66 -0
- induscode/runtime_bridge/bridges/_drive.py +268 -0
- induscode/runtime_bridge/bridges/builtins.py +177 -0
- induscode/runtime_bridge/bridges/claude_cli.py +198 -0
- induscode/runtime_bridge/bridges/codex_cli.py +203 -0
- induscode/runtime_bridge/bridges/indusagi_cli.py +217 -0
- induscode/runtime_bridge/broker.py +397 -0
- induscode/runtime_bridge/contract.py +734 -0
- induscode/runtime_bridge/sink.py +351 -0
- induscode/sessions/__init__.py +25 -0
- induscode/sessions/contract.py +119 -0
- induscode/sessions/library.py +350 -0
- induscode/settings/__init__.py +47 -0
- induscode/settings/contract.py +313 -0
- induscode/settings/manager.py +268 -0
- induscode/transcript_export/__init__.py +109 -0
- induscode/transcript_export/contract.py +522 -0
- induscode/transcript_export/publish.py +455 -0
- induscode/transcript_export/sgr.py +566 -0
- induscode/transcript_export/template.py +319 -0
- induscode/transcript_export/theme_bridge.py +325 -0
- induscode/window_budget/__init__.py +76 -0
- induscode/window_budget/budget/__init__.py +26 -0
- induscode/window_budget/budget/estimate.py +273 -0
- induscode/window_budget/budget/gate.py +60 -0
- induscode/window_budget/budget/slice.py +145 -0
- induscode/window_budget/condenser.py +170 -0
- induscode/window_budget/contract.py +329 -0
- induscode/window_budget/summarize/__init__.py +33 -0
- induscode/window_budget/summarize/condense.py +212 -0
- induscode/window_budget/summarize/prompt.py +241 -0
- induscode/workspace/__init__.py +30 -0
- induscode/workspace/brand.py +96 -0
- induscode/workspace/locator.py +269 -0
- induscode-0.1.0.dist-info/METADATA +97 -0
- induscode-0.1.0.dist-info/RECORD +167 -0
- induscode-0.1.0.dist-info/WHEEL +4 -0
- induscode-0.1.0.dist-info/entry_points.txt +3 -0
- induscode-0.1.0.dist-info/licenses/CREDITS.md +22 -0
- 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
|
+
)
|