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 +13 -0
- hive_ide/__main__.py +3 -0
- hive_ide/activity.py +65 -0
- hive_ide/adapters.py +57 -0
- hive_ide/agentmodal.py +207 -0
- hive_ide/cli.py +692 -0
- hive_ide/config.py +296 -0
- hive_ide/drivers.py +162 -0
- hive_ide/environments.py +73 -0
- hive_ide/errormodal.py +43 -0
- hive_ide/errors.py +17 -0
- hive_ide/frame.py +817 -0
- hive_ide/git_status.py +117 -0
- hive_ide/hook.py +149 -0
- hive_ide/hooks.py +232 -0
- hive_ide/info.py +77 -0
- hive_ide/layout.py +132 -0
- hive_ide/nav.py +140 -0
- hive_ide/newmodal.py +393 -0
- hive_ide/paths.py +42 -0
- hive_ide/popup.py +46 -0
- hive_ide/relayout.py +306 -0
- hive_ide/seen.py +83 -0
- hive_ide/sidebar.py +1090 -0
- hive_ide/sidebar_grid.py +118 -0
- hive_ide/sidebar_plugins.py +315 -0
- hive_ide/skill_definition/SKILL.md +33 -0
- hive_ide/source.py +97 -0
- hive_ide/state_compat.py +116 -0
- hive_ide/store.py +231 -0
- hive_ide-1.0.0.dist-info/METADATA +138 -0
- hive_ide-1.0.0.dist-info/RECORD +36 -0
- hive_ide-1.0.0.dist-info/WHEEL +5 -0
- hive_ide-1.0.0.dist-info/entry_points.txt +2 -0
- hive_ide-1.0.0.dist-info/licenses/LICENSE +21 -0
- hive_ide-1.0.0.dist-info/top_level.txt +1 -0
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
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))
|