hive-ide 1.0.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.
hive_ide/__init__.py ADDED
@@ -0,0 +1,13 @@
1
+ """Public package surface for hive-ide."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ PROTOCOL_VERSION = 1
6
+ SCHEMA_VERSION = 1
7
+
8
+ try:
9
+ __version__ = version("hive-ide")
10
+ except PackageNotFoundError:
11
+ __version__ = "0.1.0.dev0"
12
+
13
+ __all__ = ["PROTOCOL_VERSION", "SCHEMA_VERSION", "__version__"]
hive_ide/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
hive_ide/activity.py ADDED
@@ -0,0 +1,65 @@
1
+ """Fail-open activity markers for processes running inside a session."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+
7
+ from .store import StateStore, utc_now
8
+
9
+
10
+ class IdeActivity:
11
+ KIND_TASK = "task"
12
+ STATE_RUNNING = "running"
13
+ STATE_BLOCKED = "blocked"
14
+
15
+ @staticmethod
16
+ def _target() -> tuple[StateStore, str] | None:
17
+ state_home = os.environ.get("HIVE_IDE_STATE_HOME")
18
+ workspace = os.environ.get("HIVE_IDE_WORKSPACE_KEY")
19
+ session_id = os.environ.get("HIVE_IDE_SESSION_ID")
20
+ if not state_home or not workspace or not session_id:
21
+ return None
22
+ return StateStore(state_home, workspace), session_id
23
+
24
+ @staticmethod
25
+ def mark(kind: str, *, label: str = "", state: str = "running") -> bool:
26
+ try:
27
+ target = IdeActivity._target()
28
+ if target is None:
29
+ return False
30
+ store, session_id = target
31
+ with store.mutation_lock():
32
+ if store.find_session(session_id) is None:
33
+ return False
34
+ store.write(
35
+ "activity",
36
+ session_id,
37
+ {
38
+ "schema_version": 1,
39
+ "session_id": session_id,
40
+ "workspace_key": store.workspace_key,
41
+ "kind": kind,
42
+ "state": state,
43
+ "label": label,
44
+ "observed_at": utc_now(),
45
+ },
46
+ )
47
+ return True
48
+ except Exception:
49
+ return False
50
+
51
+ @staticmethod
52
+ def blocked(kind: str, *, label: str = "") -> bool:
53
+ return IdeActivity.mark(kind, label=label, state=IdeActivity.STATE_BLOCKED)
54
+
55
+ @staticmethod
56
+ def clear() -> bool:
57
+ try:
58
+ target = IdeActivity._target()
59
+ if target is None:
60
+ return False
61
+ store, session_id = target
62
+ with store.mutation_lock():
63
+ return store.delete("activity", session_id)
64
+ except Exception:
65
+ return False
hive_ide/adapters.py ADDED
@@ -0,0 +1,57 @@
1
+ """Optional host integration contracts.
2
+
3
+ The standalone core uses these neutral defaults. A host may provide richer behavior
4
+ to foreground commands; spawned panes receive only the resulting JSON state.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+ from typing import Protocol
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class Workspace:
16
+ key: str
17
+ working_dir: str
18
+ metadata: dict[str, object]
19
+
20
+
21
+ class WorkspaceAdapter(Protocol):
22
+ def resolve(self, directory: Path) -> Workspace: ...
23
+
24
+
25
+ class PlanAdapter(Protocol):
26
+ def resolve(self, reference: str | None, workspace: Workspace) -> dict[str, object]: ...
27
+
28
+ def active_task(self, plan: dict[str, object]) -> str | None: ...
29
+
30
+
31
+ class CommandSurface(Protocol):
32
+ def before_create(self, workspace: Workspace, name: str) -> None: ...
33
+
34
+ def after_archive(self, session: dict[str, object]) -> None: ...
35
+
36
+
37
+ class DefaultWorkspaceAdapter:
38
+ def resolve(self, directory: Path) -> Workspace:
39
+ resolved = str(directory.expanduser().resolve())
40
+ return Workspace(key=resolved, working_dir=resolved, metadata={})
41
+
42
+
43
+ class DefaultPlanAdapter:
44
+ def resolve(self, reference: str | None, workspace: Workspace) -> dict[str, object]:
45
+ return {"path": reference, "active_task": None}
46
+
47
+ def active_task(self, plan: dict[str, object]) -> str | None:
48
+ value = plan.get("active_task")
49
+ return value if isinstance(value, str) else None
50
+
51
+
52
+ class DefaultCommandSurface:
53
+ def before_create(self, workspace: Workspace, name: str) -> None:
54
+ return None
55
+
56
+ def after_archive(self, session: dict[str, object]) -> None:
57
+ return None
hive_ide/agentmodal.py ADDED
@@ -0,0 +1,207 @@
1
+ #!/usr/bin/env python3
2
+ """Guided 'change agent' modal — arrow-select, ESC-cancelable — in a tmux popup.
3
+
4
+ The visual twin of `ide_newmodal`'s driver step, but for an existing session: pick the
5
+ occupant the middle pane should run with ↑/↓ (or j/k, or 1-4), Enter to switch, and
6
+ ESC to cancel. The currently-running occupant is
7
+ marked and pre-selected, so Enter-on-it is a deliberate no-op rather than a surprise
8
+ switch.
9
+
10
+ It deliberately reuses `IdeNewModal`'s primitives — the SSH-timing-safe `_getkey`, the
11
+ colour constants, and the `TYPES` list — so the two modals look identical and the one
12
+ piece of subtle input logic (the arrow-vs-Escape peek) lives in exactly one place.
13
+
14
+ Stdlib only (like `ide_newmodal`); raw cbreak input, so it must not boot the foreground
15
+ runtime. The final action invokes the public package CLI.
16
+
17
+ usage: python3 -I ide_agentmodal.py <skill_dir> <repo> <window>
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import subprocess
22
+ import argparse
23
+ import sys
24
+ from pathlib import Path
25
+
26
+ from .newmodal import IdeNewModal
27
+ from .state_compat import StateIO
28
+
29
+ try: # POSIX only (tmux is Unix); keep importable elsewhere
30
+ import termios
31
+ import tty
32
+ except ImportError:
33
+ termios = None
34
+ tty = None
35
+
36
+
37
+ class IdeAgentModal:
38
+ """One popup: arrow-select the session's occupant → switch. ESC cancels."""
39
+
40
+ DEFAULT_AGENT = "claude" # mirrors _IdeCore.DEFAULT_AGENT; the modal must not import it
41
+
42
+ # ---- switch (testable, no tty) ----
43
+
44
+ @staticmethod
45
+ def _switch(skill_dir: Path, session_id: str, kind: str) -> bool:
46
+ """Shell the switch through the launcher — the same path `pick_agent` uses, so the
47
+ pane respawn/resume logic stays in one place (`switch_agent`)."""
48
+ ok, _ = IdeNewModal._cli(
49
+ skill_dir,
50
+ [
51
+ "switch-driver",
52
+ f"--session-id={session_id}",
53
+ f"--driver={kind}",
54
+ *(
55
+ [f"--tmux-socket={IdeNewModal._tmux_socket}"]
56
+ if IdeNewModal._tmux_socket
57
+ else []
58
+ ),
59
+ ],
60
+ )
61
+ return ok
62
+
63
+ @staticmethod
64
+ def _context(skill_dir: Path, argv: list[str]) -> tuple[str, str, str] | None:
65
+ """`(repo, session_id, display_name)` for this popup, else None.
66
+
67
+ Resolution order is deliberate — IDENTITY first, display name only as a fallback:
68
+
69
+ 1. Public session-id and workspace-key options stamped by the frame at build time.
70
+ Immutable: `ide rename`, tmux `automatic-rename`, and a hand rename all leave
71
+ them untouched, so this keeps working when the window is called something else.
72
+ 2. `#{session_name}` / `#{window_name}` — for a frame built before the tags existed
73
+ (a server that has not been reopened since). Correct today, but goes stale on the
74
+ first rename, which is exactly why it is second.
75
+ """
76
+ M = IdeNewModal
77
+ sid = M._ask_tmux("#{@hive_ide_session_id}")
78
+ repo = M._ask_tmux("#{@hive_ide_workspace_key}")
79
+ if sid and repo:
80
+ hit = StateIO.find_by_id(skill_dir, repo, sid)
81
+ if hit:
82
+ return hit[0], hit[2]["id"], hit[1]
83
+ # A stamped id that resolves to nothing is a REAL error (deleted record, wrong
84
+ # checkout's registry) — not a reason to quietly fall back to a name that might
85
+ # accidentally match a different session.
86
+ return None
87
+ repo = M._resolve(argv[2] if len(argv) > 2 else None, "#{session_name}")
88
+ window = M._resolve(argv[3] if len(argv) > 3 else None, "#{window_name}")
89
+ if not repo or not window:
90
+ return None
91
+ hit = StateIO.find_by_identity(skill_dir, repo, window)
92
+ return (hit[0], hit[2]["id"], hit[1]) if hit else None
93
+
94
+ @staticmethod
95
+ def _active(skill_dir: Path, repo: str, session_id: str) -> str | None:
96
+ """The occupant the session runs now — None if there is no such record (the modal
97
+ then has nothing to change and bails, rather than inventing a default)."""
98
+ found = StateIO.find_by_id(skill_dir, repo, session_id)
99
+ if found is None:
100
+ return None
101
+ rec = found[2]
102
+ return (
103
+ (rec.get("driver") or {}).get("id")
104
+ or (rec.get("agents") or {}).get("active")
105
+ or IdeAgentModal.DEFAULT_AGENT
106
+ )
107
+
108
+ # ---- interactive loop (tty) ----
109
+
110
+ @staticmethod
111
+ def _draw(window: str, repo: str, active: str, sel: int) -> None:
112
+ M = IdeNewModal
113
+ o = [M.CLR, f" {M.BOLD}Change agent{M.RST}\n"]
114
+ # Subtitle: which session, and what it runs now — the context a bare list lacks.
115
+ o.append(f" {M.DIM}{window} ({repo}) — running {active}{M.RST}{M.EL}\n\n")
116
+ for i, (kind, label, note) in enumerate(M.TYPES):
117
+ picked = i == sel
118
+ here = kind == active
119
+ arrow = "▸" if picked else " "
120
+ # The active occupant carries a trailing "· current" so it reads even when it is
121
+ # NOT the highlighted row — the highlight says "will switch here", the tag says
122
+ # "you are here", and the two must not be confused.
123
+ tag = f" {M.NAME}· current{M.RST}" if here else ""
124
+ if picked:
125
+ o.append(f" {M.SEL} {arrow} {label} {note} {M.RST}{tag}{M.EL}\n")
126
+ else:
127
+ o.append(f" {arrow} {label} {M.DIM}{note}{M.RST}{tag}{M.EL}\n")
128
+ o.append(f"\n {M.DIM}↑/↓ or j/k · 1-4 or Enter → switch · Esc → cancel{M.RST}{M.EL}")
129
+ sys.stdout.write("".join(o))
130
+ sys.stdout.flush()
131
+
132
+ @staticmethod
133
+ def main(argv: list[str]) -> int:
134
+ raw = argv[1:]
135
+ if "--state-home" in raw:
136
+ parser = argparse.ArgumentParser(prog="python -m hive_ide.agentmodal")
137
+ parser.add_argument("--state-home", required=True)
138
+ parser.add_argument("--workspace-key", required=True)
139
+ parser.add_argument("--session-id", required=True)
140
+ parser.add_argument("--tmux-socket")
141
+ parsed = parser.parse_args(raw)
142
+ skill_dir = Path(parsed.state_home)
143
+ IdeNewModal._tmux_socket = parsed.tmux_socket or ""
144
+ found = StateIO.find_by_id(skill_dir, parsed.workspace_key, parsed.session_id)
145
+ ctx = (found[0], found[2]["id"], found[1]) if found else None
146
+ else:
147
+ if len(argv) < 2:
148
+ sys.stderr.write("usage: ide_agentmodal.py <state_home> [workspace] [window]\n")
149
+ return 2
150
+ skill_dir = Path(argv[1])
151
+ ctx = IdeAgentModal._context(skill_dir, argv)
152
+ # `repo`/`window` are OPTIONAL and self-resolving. They used to be passed as tmux
153
+ # formats from the key binding — but `display-popup` does not expand formats in its
154
+ # command, so the modal received the literal `#{session_name}` / `#{window_name}`,
155
+ # found no record, and closed instantly. That was the "flashes and closes" bug.
156
+ if ctx is None:
157
+ return IdeNewModal._bail(
158
+ "Could not determine which ide session this window is.",
159
+ "no @hive_ide_session_id on the window and no resolvable name — "
160
+ "run `hive-ide open` to rebuild the frame, or `hive-ide rebuild`.")
161
+ repo, session_id, window = ctx
162
+ IdeNewModal._workspace_key = repo
163
+ if not (termios and tty and sys.stdin.isatty()):
164
+ return 0 # interactive-only; no tty → nothing to do
165
+ active = IdeAgentModal._active(skill_dir, repo, session_id)
166
+ if active is None:
167
+ return IdeNewModal._bail(
168
+ f"No ide session record for id '{session_id}'.",
169
+ f"looked in the protocol session registry for workspace {repo}")
170
+ types = IdeNewModal.TYPES
171
+ # Pre-select the active occupant, so opening the modal and pressing Enter keeps
172
+ # things as they are — the safe default for a picker over an existing session.
173
+ sel = next((i for i, (k, *_ ) in enumerate(types) if k == active), 0)
174
+ fd = sys.stdin.fileno()
175
+ saved = termios.tcgetattr(fd)
176
+ tty.setcbreak(fd)
177
+ sys.stdout.write("\x1b[?25l") # hide the real cursor
178
+ sys.stdout.flush()
179
+ result = None
180
+ try:
181
+ while True:
182
+ IdeAgentModal._draw(window, repo, active, sel)
183
+ k = IdeNewModal._getkey(fd)
184
+ if k == "esc":
185
+ return 0
186
+ if k in ("up", "k"):
187
+ sel = (sel - 1) % len(types)
188
+ elif k in ("down", "j"):
189
+ sel = (sel + 1) % len(types)
190
+ elif k.isdigit() and 1 <= int(k) <= len(types):
191
+ result = types[int(k) - 1][0]
192
+ break
193
+ elif k == "enter":
194
+ result = types[sel][0]
195
+ break
196
+ finally:
197
+ sys.stdout.write("\x1b[?25h") # restore the cursor
198
+ termios.tcsetattr(fd, termios.TCSADRAIN, saved)
199
+ sys.stdout.write(IdeNewModal.CLR)
200
+ sys.stdout.flush()
201
+ if result is None or result == active:
202
+ return 0 # cancelled, or picked what's already running
203
+ return 0 if IdeAgentModal._switch(skill_dir, session_id, result) else 1
204
+
205
+
206
+ if __name__ == "__main__":
207
+ sys.exit(IdeAgentModal.main(sys.argv))