knowop 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.
- knowop/__init__.py +1 -0
- knowop/__main__.py +17 -0
- knowop/agent/__init__.py +14 -0
- knowop/agent/__main__.py +19 -0
- knowop/agent/box_hook/__init__.py +4 -0
- knowop/agent/box_hook/__main__.py +21 -0
- knowop/agent/box_hook/entry.py +14 -0
- knowop/agent/box_hook/hook.py +414 -0
- knowop/agent/claude_stream.py +756 -0
- knowop/agent/cli.py +184 -0
- knowop/agent/codex.py +19 -0
- knowop/agent/codex_app.py +837 -0
- knowop/agent/commands.py +194 -0
- knowop/agent/custom_types.py +57 -0
- knowop/agent/detach.py +747 -0
- knowop/agent/errors.py +39 -0
- knowop/agent/headless.py +708 -0
- knowop/agent/inbound.py +282 -0
- knowop/agent/materialize.py +689 -0
- knowop/agent/placement.py +107 -0
- knowop/agent/program.py +12 -0
- knowop/agent/prompt_bridge.py +601 -0
- knowop/agent/resume.py +347 -0
- knowop/agent/session.py +3851 -0
- knowop/agent/sink.py +1440 -0
- knowop/agent/slash.py +202 -0
- knowop/agent/truncate.py +273 -0
- knowop/agent/trust.py +411 -0
- knowop/agent/worktree_diff.py +461 -0
- knowop/cli.py +524 -0
- knowop/jobs/__init__.py +1 -0
- knowop/jobs/__main__.py +17 -0
- knowop/jobs/cli.py +87 -0
- knowop/jobs/job.py +620 -0
- knowop/jobs/job_fetch.py +246 -0
- knowop/jobs/job_records.py +114 -0
- knowop/jobs/job_source.py +356 -0
- knowop/jobs/job_ssh.py +423 -0
- knowop/lib/__init__.py +1 -0
- knowop/machines.py +364 -0
- knowop/shell/__init__.py +1 -0
- knowop/shell/exec_command.py +359 -0
- knowop/shell/exec_host.py +296 -0
- knowop/shell/exec_stream.py +101 -0
- knowop/shell/machine_host.py +371 -0
- knowop/shell/serve.py +278 -0
- knowop/shell/terminal_direct.py +392 -0
- knowop/shell/terminal_direct_aiortc.py +115 -0
- knowop/shell/terminal_host.py +558 -0
- knowop/shell/trax_context.py +35 -0
- knowop/skills/README.md +12 -0
- knowop/skills/agent/SKILL.md +103 -0
- knowop/skills/jobs/SKILL.md +69 -0
- knowop/skills/shell/SKILL.md +97 -0
- knowop-0.1.0.dist-info/METADATA +153 -0
- knowop-0.1.0.dist-info/RECORD +59 -0
- knowop-0.1.0.dist-info/WHEEL +4 -0
- knowop-0.1.0.dist-info/entry_points.txt +2 -0
- knowop-0.1.0.dist-info/licenses/LICENSE +201 -0
knowop/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""The ``knowop`` command: log in, connect this computer, and run recorded agents."""
|
knowop/__main__.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# ruff: noqa: EXE003, D300, D205 -- Polyglot shell/Python script.
|
|
3
|
+
# fmt: off
|
|
4
|
+
'''' 2>/dev/null #
|
|
5
|
+
exec uv --quiet --project "$(dirname "$0")" run --frozen --no-sync python3 "$0" "$@"
|
|
6
|
+
Executable entry point. Dispatches to the knowop command.
|
|
7
|
+
'''
|
|
8
|
+
# fmt: on
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from knowop.cli import main
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
if __name__ == "__main__":
|
|
16
|
+
raise SystemExit(main())
|
|
17
|
+
# vim: ft=python
|
knowop/agent/__init__.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""The agent runner of ``knowop run``: run an agent CLI, tail its log, emit events.
|
|
2
|
+
|
|
3
|
+
The wrapped CLI inherits the real terminal and runs as if launched
|
|
4
|
+
directly. Alongside it, a per-CLI ``Adapter`` watches the CLI's session-log
|
|
5
|
+
directory and turns new JSONL lines into ``Event`` records, which flow to a
|
|
6
|
+
sink.
|
|
7
|
+
|
|
8
|
+
By default events sync to the Trackinizer server resolved from the active
|
|
9
|
+
trax profile (URL plus auth), reaching the same server as every other
|
|
10
|
+
``trax`` verb. ``--no-sync`` (or ``--out PATH``) captures to a local JSONL
|
|
11
|
+
file with no network instead.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
knowop/agent/__main__.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# ruff: noqa: EXE003, D300, D205 -- Polyglot shell/Python script.
|
|
3
|
+
# fmt: off
|
|
4
|
+
'''' 2>/dev/null #
|
|
5
|
+
exec uv --quiet --project "$(dirname "$0")" run --frozen --no-sync python3 "$0" "$@"
|
|
6
|
+
Run Claude Code on this computer, recorded and reachable from Chat.
|
|
7
|
+
|
|
8
|
+
Executable entry point: dispatches to the sibling ``cli`` module's ``main``.
|
|
9
|
+
'''
|
|
10
|
+
# fmt: on
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from knowop.agent.cli import main
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
if __name__ == "__main__":
|
|
18
|
+
raise SystemExit(main())
|
|
19
|
+
# vim: ft=python
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# ruff: noqa: EXE003, D300, D205 -- Polyglot shell/Python script.
|
|
3
|
+
# fmt: off
|
|
4
|
+
'''' 2>/dev/null #
|
|
5
|
+
exec uv --quiet --project "$(dirname "$0")" run --frozen --no-sync python3 "$0" "$@"
|
|
6
|
+
Mark a claude that ``knowop run`` runs ready, bridge its prompts to Chat, and tell it of boxes.
|
|
7
|
+
|
|
8
|
+
Executable entry point: dispatches to the sibling ``entry`` module's ``main``.
|
|
9
|
+
Claude Code runs it once its session starts, around each tool call and before
|
|
10
|
+
each prompt; run by hand with ``python -m`` on this package.
|
|
11
|
+
'''
|
|
12
|
+
# fmt: on
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from knowop.agent.box_hook.entry import main
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
if __name__ == "__main__":
|
|
20
|
+
raise SystemExit(main())
|
|
21
|
+
# vim: ft=python
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""The hook as Claude runs it: its flags from the process, the client as ``trax`` has it."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
from trackinizer.trax.connection import connect
|
|
8
|
+
|
|
9
|
+
from knowop.agent.box_hook import hook
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def main() -> int:
|
|
13
|
+
"""Run the hook on ``sys.argv``; return the process exit code."""
|
|
14
|
+
return hook.main(sys.argv[1:], connect=connect)
|
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
"""The hooks ``knowop run`` gives every claude it runs.
|
|
2
|
+
|
|
3
|
+
Three kinds of hook, one entry point:
|
|
4
|
+
|
|
5
|
+
- ``SessionStart`` writes a marker once claude is past its startup dialogs, so the
|
|
6
|
+
run knows when a line typed in lands in the composer rather than in a dialog. A
|
|
7
|
+
line typed into the trust dialog picks its highlighted "No, exit" and claude exits.
|
|
8
|
+
- ``UserPromptSubmit``, for a run placed on a station, tells the agent of the boxes
|
|
9
|
+
the person drew there. Claude Code runs it before each prompt the person types and
|
|
10
|
+
adds what it prints to that prompt's context. The person types into the CLI, never
|
|
11
|
+
through the server, so this is the only place the agent can hear of a box: the
|
|
12
|
+
same words, and the same rule (once, the first time after it is drawn), that a
|
|
13
|
+
Chat partner follows.
|
|
14
|
+
- ``PreToolUse``, ``PermissionRequest``, ``PermissionDenied``, ``PostToolUse``,
|
|
15
|
+
``UserPromptSubmit``, ``Stop`` and ``SessionEnd``, for a run that syncs, hand each
|
|
16
|
+
event to the run's prompt bridge (:mod:`~knowop.agent.prompt_bridge`),
|
|
17
|
+
so a prompt claude asks reaches Chat, an answer from Chat reaches claude, and an
|
|
18
|
+
answer given in the terminal closes the card.
|
|
19
|
+
|
|
20
|
+
The hook never gets in the way of a prompt: any failure, or a server slower than
|
|
21
|
+
:data:`_BUDGET_SEC`, prints nothing and exits 0.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
from argparse import ArgumentParser, Namespace
|
|
27
|
+
from dataclasses import dataclass
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from typing import TYPE_CHECKING, Final, Literal, Protocol, cast
|
|
30
|
+
|
|
31
|
+
import contextlib
|
|
32
|
+
import json
|
|
33
|
+
import os
|
|
34
|
+
import shlex
|
|
35
|
+
import sys
|
|
36
|
+
import threading
|
|
37
|
+
import uuid
|
|
38
|
+
|
|
39
|
+
from trackinizer.lib.userdirs import state_dir
|
|
40
|
+
from trackinizer.trax.context import workspace_from_env
|
|
41
|
+
from trackinizer.trax.daemon.protocol import socket_address
|
|
42
|
+
from treekle import PlainTree, from_plain, json_loads
|
|
43
|
+
|
|
44
|
+
from knowop.agent import prompt_bridge
|
|
45
|
+
from knowop.agent.inbound import Box, render_box_lines
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
if TYPE_CHECKING:
|
|
49
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
50
|
+
|
|
51
|
+
from trackinizer.client.client import Client
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
_BUDGET_SEC: Final = 2.0
|
|
55
|
+
"""How long the hook waits for the server before it says nothing."""
|
|
56
|
+
|
|
57
|
+
_BOXES_PER_PROMPT: Final = 20
|
|
58
|
+
"""The newest this many are listed, as with a Chat line."""
|
|
59
|
+
|
|
60
|
+
_HOOK_TIMEOUT_SEC: Final = 4
|
|
61
|
+
"""Claude's own cap on the command, above :data:`_BUDGET_SEC` for starting python."""
|
|
62
|
+
|
|
63
|
+
_PROMPT_TIMEOUT_SEC: Final = 600
|
|
64
|
+
"""How long claude lets a ``PermissionRequest`` hook wait for Chat. Past it claude
|
|
65
|
+
ends the hook and asks in its TUI alone, as it would with no hook."""
|
|
66
|
+
|
|
67
|
+
_BRIDGED: Final = (
|
|
68
|
+
"PreToolUse",
|
|
69
|
+
"PermissionRequest",
|
|
70
|
+
"PermissionDenied",
|
|
71
|
+
"PostToolUse",
|
|
72
|
+
"UserPromptSubmit",
|
|
73
|
+
"Stop",
|
|
74
|
+
"SessionEnd",
|
|
75
|
+
)
|
|
76
|
+
"""The events handed to the prompt bridge, in the order a tool call raises them."""
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
80
|
+
class HookFiles:
|
|
81
|
+
"""The files one run's hooks live in, in a directory of the run's own."""
|
|
82
|
+
|
|
83
|
+
settings: Path
|
|
84
|
+
"""The file to hand claude's ``--settings``."""
|
|
85
|
+
|
|
86
|
+
ready: Path
|
|
87
|
+
"""The marker the ``SessionStart`` hook writes; it exists once claude is ready."""
|
|
88
|
+
|
|
89
|
+
socket: Path | None = None
|
|
90
|
+
"""Where the run's prompt bridge listens; ``None`` when the run has none."""
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def prepare(
|
|
94
|
+
*,
|
|
95
|
+
profile: str | None,
|
|
96
|
+
boxes: bool = True,
|
|
97
|
+
user: Mapping[str, PlainTree] | None = None,
|
|
98
|
+
bridge: bool = False,
|
|
99
|
+
) -> HookFiles:
|
|
100
|
+
"""Write the settings file for one claude run.
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
profile: The ``--profile`` the run was started with, if any.
|
|
104
|
+
boxes: Whether the run is placed on a station, so its prompts are told of the
|
|
105
|
+
boxes drawn there.
|
|
106
|
+
user: The settings the person passed to ``--settings``, merged in, since
|
|
107
|
+
claude takes one; ``None`` when they passed none.
|
|
108
|
+
bridge: Whether the run bridges claude's prompts to Chat, so its hooks hand
|
|
109
|
+
their events to a socket in the run's directory.
|
|
110
|
+
|
|
111
|
+
Returns:
|
|
112
|
+
files: The settings file, in a fresh directory of this run's own, which also
|
|
113
|
+
holds the last box told, the readiness marker and the bridge's socket.
|
|
114
|
+
|
|
115
|
+
"""
|
|
116
|
+
root = state_dir() / "rekursiv-ai" / "trax" / "run" / "box-hooks"
|
|
117
|
+
root.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
118
|
+
run_dir = root / uuid.uuid4().hex
|
|
119
|
+
run_dir.mkdir(mode=0o700)
|
|
120
|
+
seen = run_dir / "seen"
|
|
121
|
+
settings = run_dir / "settings.json"
|
|
122
|
+
# Inside the 0700 directory: whoever can connect can answer a prompt. A state dir
|
|
123
|
+
# too deep for a socket address is reached through a short alias of the directory.
|
|
124
|
+
socket = socket_address(run_dir / "bridge.sock") if bridge else None
|
|
125
|
+
_ = settings.write_text(
|
|
126
|
+
settings_json(
|
|
127
|
+
seen=seen,
|
|
128
|
+
profile=profile,
|
|
129
|
+
boxes=boxes,
|
|
130
|
+
user=user,
|
|
131
|
+
socket=socket,
|
|
132
|
+
),
|
|
133
|
+
)
|
|
134
|
+
return HookFiles(settings=settings, ready=ready_marker(seen), socket=socket)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def ready_marker(seen: Path) -> Path:
|
|
138
|
+
"""Return the marker the ``SessionStart`` hook writes beside ``seen``."""
|
|
139
|
+
return seen.with_name("ready")
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def read_settings(value: str) -> dict[str, PlainTree] | None:
|
|
143
|
+
"""Read what a person passed to claude's ``--settings``: a file, or JSON text.
|
|
144
|
+
|
|
145
|
+
Args:
|
|
146
|
+
value: The flag's value.
|
|
147
|
+
|
|
148
|
+
Returns:
|
|
149
|
+
settings: The settings, or ``None`` when they cannot be read or their hooks
|
|
150
|
+
are not claude's shape, which leaves the person's flag as they gave it.
|
|
151
|
+
|
|
152
|
+
"""
|
|
153
|
+
# Read as a file first; JSON text is no file, and a long one is no file NAME
|
|
154
|
+
# either, which the filesystem refuses with an error of its own.
|
|
155
|
+
try:
|
|
156
|
+
text = Path(value).read_text()
|
|
157
|
+
except OSError:
|
|
158
|
+
text = value
|
|
159
|
+
try:
|
|
160
|
+
settings = json_loads(text, dict[str, PlainTree])
|
|
161
|
+
_ = from_plain(settings.get("hooks", {}), dict[str, list[PlainTree]])
|
|
162
|
+
except ValueError:
|
|
163
|
+
return None
|
|
164
|
+
return settings
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def settings_json(
|
|
168
|
+
*,
|
|
169
|
+
seen: Path,
|
|
170
|
+
profile: str | None,
|
|
171
|
+
boxes: bool = True,
|
|
172
|
+
user: Mapping[str, PlainTree] | None = None,
|
|
173
|
+
socket: Path | None = None,
|
|
174
|
+
) -> str:
|
|
175
|
+
"""Return claude's settings holding the hooks that run :func:`main`.
|
|
176
|
+
|
|
177
|
+
Args:
|
|
178
|
+
seen: Where the hook keeps the newest box it told this run; the readiness
|
|
179
|
+
marker goes beside it.
|
|
180
|
+
profile: The trax profile the hook reads the station with; ``None`` for the
|
|
181
|
+
environment's, as ``trax`` itself resolves it.
|
|
182
|
+
boxes: Whether to add the hook that tells each prompt of the station's boxes.
|
|
183
|
+
user: Settings of the person's own to keep: every key, and every hook of
|
|
184
|
+
theirs before ours on the same event.
|
|
185
|
+
socket: The prompt bridge's socket, which the bridged events are handed to;
|
|
186
|
+
``None`` adds none of them.
|
|
187
|
+
|
|
188
|
+
Returns:
|
|
189
|
+
settings: The JSON text.
|
|
190
|
+
|
|
191
|
+
"""
|
|
192
|
+
# This package's own name, so an export that renames the package still runs it.
|
|
193
|
+
argv = [sys.executable, "-m", __name__.rpartition(".")[0], "--seen", str(seen)]
|
|
194
|
+
ours: dict[str, list[PlainTree]] = {
|
|
195
|
+
"SessionStart": [_hook_entry([*argv, "--event", "session-start"])],
|
|
196
|
+
}
|
|
197
|
+
if boxes:
|
|
198
|
+
prompt = argv if profile is None else [*argv, "--profile", profile]
|
|
199
|
+
ours["UserPromptSubmit"] = [_hook_entry(prompt)]
|
|
200
|
+
if socket is not None:
|
|
201
|
+
bridged = [*argv, "--event", "bridge", "--socket", str(socket)]
|
|
202
|
+
for event in _BRIDGED:
|
|
203
|
+
# The one hook that waits on a person; the rest hold up a tool call.
|
|
204
|
+
timeout = (
|
|
205
|
+
_PROMPT_TIMEOUT_SEC
|
|
206
|
+
if event == "PermissionRequest"
|
|
207
|
+
else _HOOK_TIMEOUT_SEC
|
|
208
|
+
)
|
|
209
|
+
# ``UserPromptSubmit`` has the box hook already, when the run has boxes.
|
|
210
|
+
ours.setdefault(event, []).append(_hook_entry(bridged, timeout=timeout))
|
|
211
|
+
kept = dict(user or {})
|
|
212
|
+
theirs = from_plain(kept.get("hooks"), dict[str, list[PlainTree]], default={})
|
|
213
|
+
kept["hooks"] = {
|
|
214
|
+
**theirs,
|
|
215
|
+
**{
|
|
216
|
+
event: [*theirs.get(event, []), *entries] for event, entries in ours.items()
|
|
217
|
+
},
|
|
218
|
+
}
|
|
219
|
+
return json.dumps(kept)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def render_boxes(state: dict[str, PlainTree], *, after: int) -> tuple[str, int]:
|
|
223
|
+
"""Word the boxes of ``state`` drawn after the one numbered ``after``.
|
|
224
|
+
|
|
225
|
+
Args:
|
|
226
|
+
state: The station as the server returns it.
|
|
227
|
+
after: The newest box already told.
|
|
228
|
+
|
|
229
|
+
Returns:
|
|
230
|
+
told: The text to print, empty when nothing is new.
|
|
231
|
+
newest: The newest box number seen, ``after`` when there is none.
|
|
232
|
+
|
|
233
|
+
"""
|
|
234
|
+
labels = from_plain(state.get("record_labels"), dict[str, str], default={})
|
|
235
|
+
visuals = {
|
|
236
|
+
from_plain(v.get("id"), str, default=""): from_plain(
|
|
237
|
+
v.get("type"),
|
|
238
|
+
str,
|
|
239
|
+
default="",
|
|
240
|
+
)
|
|
241
|
+
for v in from_plain(
|
|
242
|
+
state.get("visuals"),
|
|
243
|
+
list[dict[str, PlainTree]],
|
|
244
|
+
default=[],
|
|
245
|
+
)
|
|
246
|
+
}
|
|
247
|
+
marks = [
|
|
248
|
+
mark
|
|
249
|
+
for mark in from_plain(
|
|
250
|
+
state.get("marks"),
|
|
251
|
+
list[dict[str, PlainTree]],
|
|
252
|
+
default=[],
|
|
253
|
+
)
|
|
254
|
+
if from_plain(mark.get("seq"), int, default=0) > after
|
|
255
|
+
]
|
|
256
|
+
newest = max([after, *(from_plain(m.get("seq"), int, default=0) for m in marks)])
|
|
257
|
+
boxes: list[Box] = []
|
|
258
|
+
for mark in marks:
|
|
259
|
+
tile = from_plain(mark.get("tile"), str, default="")
|
|
260
|
+
if tile and tile not in visuals:
|
|
261
|
+
continue
|
|
262
|
+
covers = from_plain(mark.get("covers"), dict[str, list[str]], default={})
|
|
263
|
+
# A label is a title people wrote, so it is quoted as data.
|
|
264
|
+
named = [
|
|
265
|
+
json.dumps(labels[r]) for r in covers.get("records", []) if r in labels
|
|
266
|
+
]
|
|
267
|
+
named += [
|
|
268
|
+
f"{visuals[each]} {each}"
|
|
269
|
+
for each in covers.get("instances", [])
|
|
270
|
+
if each in visuals
|
|
271
|
+
]
|
|
272
|
+
boxes.append(
|
|
273
|
+
Box(
|
|
274
|
+
id=from_plain(mark.get("id"), str, default=""),
|
|
275
|
+
author=from_plain(mark.get("author"), str, default=""),
|
|
276
|
+
page=from_plain(mark.get("page"), str, default="") or None,
|
|
277
|
+
covers=tuple(named),
|
|
278
|
+
),
|
|
279
|
+
)
|
|
280
|
+
if not boxes:
|
|
281
|
+
return "", newest
|
|
282
|
+
return render_box_lines(boxes[-_BOXES_PER_PROMPT:]).removeprefix("\n"), newest
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def main(
|
|
286
|
+
argv: Sequence[str],
|
|
287
|
+
*,
|
|
288
|
+
connect: Callable[[Namespace], Client],
|
|
289
|
+
) -> int:
|
|
290
|
+
"""Run the hook for one event; always exit 0.
|
|
291
|
+
|
|
292
|
+
On ``SessionStart`` it writes the readiness marker. Before a prompt it prints
|
|
293
|
+
the boxes drawn since this run last heard of one. A bridged event goes to the
|
|
294
|
+
run's prompt bridge, and what the bridge replies is printed for claude.
|
|
295
|
+
|
|
296
|
+
Args:
|
|
297
|
+
argv: ``--seen FILE``, and optionally ``--profile NAME``, ``--event`` and
|
|
298
|
+
``--socket PATH``.
|
|
299
|
+
connect: Builds the client from the flags and environment, as ``trax`` does.
|
|
300
|
+
|
|
301
|
+
Returns:
|
|
302
|
+
status: 0, whatever happened: a prompt is never held up by this.
|
|
303
|
+
|
|
304
|
+
"""
|
|
305
|
+
parser = ArgumentParser()
|
|
306
|
+
_add_arguments(parser)
|
|
307
|
+
flags = cast(_Flags, parser.parse_args(argv))
|
|
308
|
+
if flags.event == "bridge":
|
|
309
|
+
reply = (
|
|
310
|
+
""
|
|
311
|
+
if flags.socket is None
|
|
312
|
+
else prompt_bridge.ask(flags.socket, sys.stdin.read())
|
|
313
|
+
)
|
|
314
|
+
sys.stdout.write(reply)
|
|
315
|
+
sys.stdout.flush()
|
|
316
|
+
return 0
|
|
317
|
+
if flags.event == "session-start":
|
|
318
|
+
# A marker the run cannot write leaves it to its fallback wait, which is
|
|
319
|
+
# all a failure here may cost.
|
|
320
|
+
with contextlib.suppress(OSError):
|
|
321
|
+
ready_marker(flags.seen).touch()
|
|
322
|
+
return 0
|
|
323
|
+
station = workspace_from_env(os.environ)
|
|
324
|
+
if station is None:
|
|
325
|
+
return 0
|
|
326
|
+
found: list[dict[str, PlainTree]] = []
|
|
327
|
+
reader = threading.Thread(
|
|
328
|
+
target=_read_station,
|
|
329
|
+
name="box-hook-read",
|
|
330
|
+
kwargs={
|
|
331
|
+
"connect": connect,
|
|
332
|
+
"profile": flags.profile,
|
|
333
|
+
"station": station,
|
|
334
|
+
"found": found,
|
|
335
|
+
},
|
|
336
|
+
daemon=True,
|
|
337
|
+
)
|
|
338
|
+
reader.start()
|
|
339
|
+
reader.join(_BUDGET_SEC)
|
|
340
|
+
if not found:
|
|
341
|
+
return 0
|
|
342
|
+
try:
|
|
343
|
+
after = int(flags.seen.read_text())
|
|
344
|
+
except (OSError, ValueError):
|
|
345
|
+
after = 0
|
|
346
|
+
text, newest = render_boxes(found[0], after=after)
|
|
347
|
+
if text:
|
|
348
|
+
sys.stdout.write(f"{text}\n")
|
|
349
|
+
sys.stdout.flush()
|
|
350
|
+
_ = flags.seen.write_text(str(newest))
|
|
351
|
+
return 0
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _read_station(
|
|
355
|
+
*,
|
|
356
|
+
connect: Callable[[Namespace], Client],
|
|
357
|
+
profile: str | None,
|
|
358
|
+
station: uuid.UUID,
|
|
359
|
+
found: list[dict[str, PlainTree]],
|
|
360
|
+
) -> None:
|
|
361
|
+
"""Append the station's state to ``found``; any failure leaves it empty."""
|
|
362
|
+
# Nothing here may hold up the person's prompt, whatever fails.
|
|
363
|
+
with contextlib.suppress(Exception):
|
|
364
|
+
client = connect(Namespace(profile=profile, host=None, port=None))
|
|
365
|
+
found.append(client.read_workspace(station))
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def _hook_entry(argv: list[str], *, timeout: int = _HOOK_TIMEOUT_SEC) -> PlainTree:
|
|
369
|
+
"""Return one settings entry running ``argv`` as a command hook."""
|
|
370
|
+
return {
|
|
371
|
+
"hooks": [
|
|
372
|
+
{
|
|
373
|
+
"type": "command",
|
|
374
|
+
"command": shlex.join(argv),
|
|
375
|
+
"timeout": timeout,
|
|
376
|
+
},
|
|
377
|
+
],
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
class _Flags(Protocol):
|
|
382
|
+
"""Parsed command-line flags."""
|
|
383
|
+
|
|
384
|
+
seen: Path
|
|
385
|
+
profile: str | None
|
|
386
|
+
event: Literal["prompt", "session-start", "bridge"]
|
|
387
|
+
socket: Path | None
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def _add_arguments(parser: ArgumentParser) -> None:
|
|
391
|
+
"""Register flags on ``parser``."""
|
|
392
|
+
_ = parser.add_argument(
|
|
393
|
+
"--seen",
|
|
394
|
+
type=Path,
|
|
395
|
+
required=True,
|
|
396
|
+
help="File holding the newest box already told.",
|
|
397
|
+
)
|
|
398
|
+
_ = parser.add_argument(
|
|
399
|
+
"--profile",
|
|
400
|
+
default=None,
|
|
401
|
+
help="Trax profile to read with.",
|
|
402
|
+
)
|
|
403
|
+
_ = parser.add_argument(
|
|
404
|
+
"--event",
|
|
405
|
+
choices=("prompt", "session-start", "bridge"),
|
|
406
|
+
default="prompt",
|
|
407
|
+
help="The claude hook event this runs for; bridge hands it to --socket.",
|
|
408
|
+
)
|
|
409
|
+
_ = parser.add_argument(
|
|
410
|
+
"--socket",
|
|
411
|
+
type=Path,
|
|
412
|
+
default=None,
|
|
413
|
+
help="The run's prompt bridge, which a bridge event goes to.",
|
|
414
|
+
)
|