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.
Files changed (59) hide show
  1. knowop/__init__.py +1 -0
  2. knowop/__main__.py +17 -0
  3. knowop/agent/__init__.py +14 -0
  4. knowop/agent/__main__.py +19 -0
  5. knowop/agent/box_hook/__init__.py +4 -0
  6. knowop/agent/box_hook/__main__.py +21 -0
  7. knowop/agent/box_hook/entry.py +14 -0
  8. knowop/agent/box_hook/hook.py +414 -0
  9. knowop/agent/claude_stream.py +756 -0
  10. knowop/agent/cli.py +184 -0
  11. knowop/agent/codex.py +19 -0
  12. knowop/agent/codex_app.py +837 -0
  13. knowop/agent/commands.py +194 -0
  14. knowop/agent/custom_types.py +57 -0
  15. knowop/agent/detach.py +747 -0
  16. knowop/agent/errors.py +39 -0
  17. knowop/agent/headless.py +708 -0
  18. knowop/agent/inbound.py +282 -0
  19. knowop/agent/materialize.py +689 -0
  20. knowop/agent/placement.py +107 -0
  21. knowop/agent/program.py +12 -0
  22. knowop/agent/prompt_bridge.py +601 -0
  23. knowop/agent/resume.py +347 -0
  24. knowop/agent/session.py +3851 -0
  25. knowop/agent/sink.py +1440 -0
  26. knowop/agent/slash.py +202 -0
  27. knowop/agent/truncate.py +273 -0
  28. knowop/agent/trust.py +411 -0
  29. knowop/agent/worktree_diff.py +461 -0
  30. knowop/cli.py +524 -0
  31. knowop/jobs/__init__.py +1 -0
  32. knowop/jobs/__main__.py +17 -0
  33. knowop/jobs/cli.py +87 -0
  34. knowop/jobs/job.py +620 -0
  35. knowop/jobs/job_fetch.py +246 -0
  36. knowop/jobs/job_records.py +114 -0
  37. knowop/jobs/job_source.py +356 -0
  38. knowop/jobs/job_ssh.py +423 -0
  39. knowop/lib/__init__.py +1 -0
  40. knowop/machines.py +364 -0
  41. knowop/shell/__init__.py +1 -0
  42. knowop/shell/exec_command.py +359 -0
  43. knowop/shell/exec_host.py +296 -0
  44. knowop/shell/exec_stream.py +101 -0
  45. knowop/shell/machine_host.py +371 -0
  46. knowop/shell/serve.py +278 -0
  47. knowop/shell/terminal_direct.py +392 -0
  48. knowop/shell/terminal_direct_aiortc.py +115 -0
  49. knowop/shell/terminal_host.py +558 -0
  50. knowop/shell/trax_context.py +35 -0
  51. knowop/skills/README.md +12 -0
  52. knowop/skills/agent/SKILL.md +103 -0
  53. knowop/skills/jobs/SKILL.md +69 -0
  54. knowop/skills/shell/SKILL.md +97 -0
  55. knowop-0.1.0.dist-info/METADATA +153 -0
  56. knowop-0.1.0.dist-info/RECORD +59 -0
  57. knowop-0.1.0.dist-info/WHEEL +4 -0
  58. knowop-0.1.0.dist-info/entry_points.txt +2 -0
  59. 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
@@ -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
@@ -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,4 @@
1
+ """The hook that tells a ``knowop run`` Claude about boxes drawn on its station.
2
+
3
+ Import from :mod:`.hook`; ``python -m`` on this package runs the hook itself.
4
+ """
@@ -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
+ )