omega-code 0.4.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 (73) hide show
  1. omega/__init__.py +0 -0
  2. omega/__main__.py +589 -0
  3. omega/artifacts.py +151 -0
  4. omega/checkpoint.py +246 -0
  5. omega/compact.py +106 -0
  6. omega/config.py +285 -0
  7. omega/eval/__init__.py +3 -0
  8. omega/eval/cli.py +127 -0
  9. omega/eval/examples/plan-version-flag.yaml +11 -0
  10. omega/eval/examples/relative-age-negative-delta.yaml +14 -0
  11. omega/eval/examples/version-flag.yaml +10 -0
  12. omega/eval/manifest.py +129 -0
  13. omega/eval/prices.py +29 -0
  14. omega/eval/report.py +135 -0
  15. omega/eval/runner.py +199 -0
  16. omega/eval/tasks.py +97 -0
  17. omega/events.py +145 -0
  18. omega/export.py +80 -0
  19. omega/gitlog.py +229 -0
  20. omega/hooks.py +63 -0
  21. omega/instructions.py +103 -0
  22. omega/integrations.py +284 -0
  23. omega/keys.py +173 -0
  24. omega/llm.py +442 -0
  25. omega/loop.py +510 -0
  26. omega/mcp.py +490 -0
  27. omega/memory/__init__.py +5 -0
  28. omega/memory/consolidate.py +103 -0
  29. omega/memory/curate.py +69 -0
  30. omega/memory/store.py +321 -0
  31. omega/memory/tools.py +175 -0
  32. omega/migrate.py +40 -0
  33. omega/onboarding.py +242 -0
  34. omega/permissions.py +137 -0
  35. omega/secrets.py +173 -0
  36. omega/server/__init__.py +7 -0
  37. omega/server/__main__.py +18 -0
  38. omega/server/app.py +71 -0
  39. omega/server/auth.py +73 -0
  40. omega/server/manager.py +287 -0
  41. omega/server/models.py +123 -0
  42. omega/server/tasks_api.py +311 -0
  43. omega/server/terminals.py +245 -0
  44. omega/server/worker.py +186 -0
  45. omega/session.py +209 -0
  46. omega/setup.html +281 -0
  47. omega/setup_server.py +452 -0
  48. omega/skills.py +158 -0
  49. omega/subagent.py +98 -0
  50. omega/tasks.py +195 -0
  51. omega/tools.py +590 -0
  52. omega/trace.py +156 -0
  53. omega/trajectory.py +146 -0
  54. omega/ui/__init__.py +0 -0
  55. omega/ui/composer.py +140 -0
  56. omega/ui/format.py +708 -0
  57. omega/ui/plain.py +141 -0
  58. omega/ui/tui/__init__.py +9 -0
  59. omega/ui/tui/app.py +958 -0
  60. omega/ui/tui/history.py +50 -0
  61. omega/ui/tui/modals.py +292 -0
  62. omega/ui/tui/onboarding.py +367 -0
  63. omega/ui/tui/prefs.py +25 -0
  64. omega/ui/tui/sidebar.py +510 -0
  65. omega/ui/tui/status.py +115 -0
  66. omega/ui/tui/theme.py +91 -0
  67. omega/ui/tui/transcript.py +783 -0
  68. omega/verify.py +133 -0
  69. omega_code-0.4.0.dist-info/METADATA +479 -0
  70. omega_code-0.4.0.dist-info/RECORD +73 -0
  71. omega_code-0.4.0.dist-info/WHEEL +4 -0
  72. omega_code-0.4.0.dist-info/entry_points.txt +2 -0
  73. omega_code-0.4.0.dist-info/licenses/LICENSE +21 -0
omega/server/worker.py ADDED
@@ -0,0 +1,186 @@
1
+ """The child process spawned per running task (see manager.py). One process =
2
+ one task, so the process-wide globals `tools.CONFIRM`/`ASK_USER`/`SESSION_ID`
3
+ and `os.getcwd()` are safe to set here without racing any other task.
4
+
5
+ Wire protocol, newline-delimited JSON both ways:
6
+
7
+ Parent -> child (stdin):
8
+ {"cmd": "prompt", "text": str}
9
+ {"cmd": "answer", "request_id": str, "answer": str}
10
+ {"cmd": "confirm", "request_id": str, "allow": bool}
11
+ {"cmd": "cancel"}
12
+ {"cmd": "set_model", "model": str | None}
13
+ {"cmd": "set_mode", "mode": str}
14
+ {"cmd": "shutdown"}
15
+
16
+ Child -> parent (stdout), one JSON object per line:
17
+ {"type": "worker_ready"}
18
+ {"type": <events.Event dataclass name>, ...fields, "t": epoch, "turn": n}
19
+ {"type": "ask_user_request", "request_id", "question", "options", "multi_select"}
20
+ {"type": "confirm_request", "request_id", "tool", "args_preview", "why"}
21
+ {"type": "fatal", "message": str} -- unrecoverable startup failure
22
+ """
23
+ from __future__ import annotations
24
+
25
+ import asyncio
26
+ import dataclasses
27
+ import json
28
+ import os
29
+ import secrets
30
+ import sys
31
+ import time
32
+ from typing import Any
33
+
34
+ from .. import config, events, loop, session, tasks, tools
35
+ from ..session import Message
36
+ from ..ui import format
37
+
38
+
39
+ class Worker:
40
+ def __init__(self, task_id: str) -> None:
41
+ self.task_id = task_id
42
+ self.turn = 0
43
+ self.mode = "build"
44
+ self.model: str | None = None
45
+ self.history: list[Message] = []
46
+ self.cfg: config.Config | None = None
47
+ self.sess: session.Session | None = None
48
+ self._pending: dict[str, asyncio.Future[Any]] = {}
49
+ self._turn_task: asyncio.Task[None] | None = None
50
+
51
+ def _emit_line(self, obj: dict[str, Any]) -> None:
52
+ sys.stdout.write(json.dumps(obj) + "\n")
53
+ sys.stdout.flush()
54
+
55
+ def emit(self, ev: events.Event) -> None:
56
+ self._emit_line({"type": type(ev).__name__, **dataclasses.asdict(ev),
57
+ "t": time.time(), "turn": self.turn})
58
+
59
+ async def confirm(self, name: str, args: dict[str, Any], why: str) -> bool:
60
+ request_id = secrets.token_hex(8)
61
+ fut: asyncio.Future[bool] = asyncio.get_event_loop().create_future()
62
+ self._pending[request_id] = fut
63
+ try:
64
+ preview = format.describe_call(name, args)
65
+ except Exception:
66
+ preview = str(args)
67
+ self._emit_line({"type": "confirm_request", "request_id": request_id,
68
+ "tool": name, "args_preview": preview, "why": why})
69
+ return await fut
70
+
71
+ async def ask_user(self, question: str, options: list[events.Option],
72
+ multi_select: bool) -> str:
73
+ request_id = secrets.token_hex(8)
74
+ fut: asyncio.Future[str] = asyncio.get_event_loop().create_future()
75
+ self._pending[request_id] = fut
76
+ self._emit_line({"type": "ask_user_request", "request_id": request_id,
77
+ "question": question, "options": list(options),
78
+ "multi_select": multi_select})
79
+ return await fut
80
+
81
+ def _resolve(self, request_id: object, value: Any) -> None:
82
+ if not isinstance(request_id, str):
83
+ return
84
+ fut = self._pending.pop(request_id, None)
85
+ if fut is not None and not fut.done():
86
+ fut.set_result(value)
87
+
88
+ async def _run_turn(self, text: str) -> None:
89
+ assert self.cfg is not None and self.sess is not None
90
+ self.turn += 1
91
+ self.history.append({"role": "user", "content": text})
92
+ interrupted = False
93
+ try:
94
+ await loop.run_turn(self.cfg, self.history, mode=self.mode,
95
+ emit=self.emit, model=self.model)
96
+ except asyncio.CancelledError:
97
+ interrupted = True
98
+ except Exception as e:
99
+ self.emit(events.Error(message=f"{type(e).__name__}: {e}"))
100
+ finally:
101
+ try:
102
+ self.sess.close_turn(self.history, self.mode, interrupted)
103
+ except Exception as e:
104
+ self._emit_line({"type": "fatal", "message": f"could not save session: {e}"})
105
+ self._turn_task = None
106
+
107
+ async def _dispatch(self, cmd: dict[str, Any]) -> bool:
108
+ """Returns False when the worker should exit."""
109
+ kind = cmd.get("cmd")
110
+ if kind == "prompt":
111
+ if self._turn_task is not None and not self._turn_task.done():
112
+ self._emit_line({"type": "error", "message": "a turn is already running"})
113
+ else:
114
+ self._turn_task = asyncio.create_task(self._run_turn(str(cmd.get("text", ""))))
115
+ elif kind == "answer":
116
+ self._resolve(cmd.get("request_id"), str(cmd.get("answer", "")))
117
+ elif kind == "confirm":
118
+ self._resolve(cmd.get("request_id"), bool(cmd.get("allow", False)))
119
+ elif kind == "cancel":
120
+ if self._turn_task is not None:
121
+ self._turn_task.cancel()
122
+ elif kind == "set_mode":
123
+ self.mode = str(cmd.get("mode", self.mode))
124
+ elif kind == "set_model":
125
+ self.model = cmd.get("model") or None
126
+ elif kind == "jobs":
127
+ self._emit_line({"type": "jobs", "request_id": cmd.get("request_id"),
128
+ "jobs": tools.list_jobs()})
129
+ elif kind == "shutdown":
130
+ if self._turn_task is not None:
131
+ self._turn_task.cancel()
132
+ await asyncio.gather(self._turn_task, return_exceptions=True)
133
+ return False
134
+ return True
135
+
136
+ async def run(self) -> None:
137
+ task = tasks.get(self.task_id)
138
+ if task is None:
139
+ self._emit_line({"type": "fatal", "message": f"no such task {self.task_id!r}"})
140
+ return
141
+ os.chdir(task.cwd)
142
+ self.cfg = config.load()
143
+ self.mode = task.mode
144
+ self.model = task.model
145
+ self.sess = session.load(self.task_id)
146
+ self.history = self.sess.history
147
+ tools.SESSION_ID = self.sess.id
148
+ tools.CONFIRM = self.confirm
149
+ tools.ASK_USER = self.ask_user
150
+ self.turn = self.sess.turns
151
+
152
+ reader = await _stdin_reader()
153
+ self._emit_line({"type": "worker_ready"})
154
+ while True:
155
+ raw = await reader.readline()
156
+ if not raw:
157
+ break
158
+ try:
159
+ cmd = json.loads(raw)
160
+ except json.JSONDecodeError:
161
+ continue
162
+ if not await self._dispatch(cmd):
163
+ break
164
+
165
+
166
+ async def _stdin_reader() -> asyncio.StreamReader:
167
+ reader = asyncio.StreamReader()
168
+ protocol = asyncio.StreamReaderProtocol(reader)
169
+ loop_ = asyncio.get_event_loop()
170
+ await loop_.connect_read_pipe(lambda: protocol, sys.stdin)
171
+ return reader
172
+
173
+
174
+ async def _amain(task_id: str) -> None:
175
+ await Worker(task_id).run()
176
+
177
+
178
+ def main() -> None:
179
+ if len(sys.argv) < 2:
180
+ print("usage: python -m omega.server.worker <task_id>", file=sys.stderr)
181
+ raise SystemExit(2)
182
+ asyncio.run(_amain(sys.argv[1]))
183
+
184
+
185
+ if __name__ == "__main__":
186
+ main()
omega/session.py ADDED
@@ -0,0 +1,209 @@
1
+ import json
2
+ import os
3
+ import time
4
+ import uuid
5
+ from dataclasses import asdict, dataclass, field
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ Message = dict[str, Any]
10
+
11
+ DIR = Path.home() / ".omega" / "sessions"
12
+
13
+ # Shared with __main__'s resumed-session marker so UIs can identify and
14
+ # exclude it from turn/message counts shown to the user.
15
+ RESUME_PREFIX = "[session resumed"
16
+
17
+
18
+ @dataclass
19
+ class Session:
20
+ id: str = ""
21
+ cwd: str = ""
22
+ mode: str = "build"
23
+ created: float = 0.0
24
+ updated: float = 0.0
25
+ history: list[Message] = field(default_factory=list)
26
+ compactions: int = 0
27
+ model_override: str | None = None
28
+ # Set by load() when the turn log (.jsonl) held more messages than the
29
+ # last saved .json -- i.e. the process crashed mid-turn. Never persisted:
30
+ # it describes this one load, not the session itself.
31
+ recovered: int = 0
32
+
33
+ @classmethod
34
+ def new(cls, cwd: str | None = None, mode: str = "build") -> "Session":
35
+ now = time.time()
36
+ sid = time.strftime("%Y%m%d-%H%M%S") + "-" + uuid.uuid4().hex[:4]
37
+ return cls(id=sid, cwd=cwd or os.getcwd(), mode=mode, created=now, updated=now)
38
+
39
+ @property
40
+ def path(self) -> Path:
41
+ return DIR / f"{self.id}.json"
42
+
43
+ @property
44
+ def jsonl_path(self) -> Path:
45
+ return DIR / f"{self.id}.jsonl"
46
+
47
+ def append(self, msg: Message) -> None:
48
+ """Add one message to history AND durably log it immediately -- unlike
49
+ `save()` (a full rewrite that only happens at turn boundaries), this
50
+ makes a crash mid-turn resumable: see `load()`'s jsonl-vs-json check."""
51
+ self.history.append(msg)
52
+ log_message(self.id, msg)
53
+
54
+ @property
55
+ def turns(self) -> int:
56
+ return sum(1 for m in self.history if m.get("role") == "user")
57
+
58
+ def first_prompt(self, width: int = 70) -> str:
59
+ for m in self.history:
60
+ if m.get("role") == "user":
61
+ text = " ".join((m.get("content") or "").split())
62
+ return text[:width - 1] + "…" if len(text) > width else text
63
+ return "(empty)"
64
+
65
+ def save(self) -> None:
66
+ DIR.mkdir(parents=True, exist_ok=True)
67
+ self.updated = time.time()
68
+ data = asdict(self)
69
+ data.pop("recovered", None) # a fact about this load, not the session
70
+ tmp = self.path.with_suffix(".tmp")
71
+ tmp.write_text(json.dumps(data, indent=1))
72
+ tmp.replace(self.path)
73
+
74
+ def close_turn(self, history: list[dict[str, Any]], mode: str, interrupted: bool) -> None:
75
+ """Shared by every UI: whatever ran the turn, the session must land in
76
+ the same state afterwards."""
77
+ self.mode = mode
78
+ repair(history)
79
+ if interrupted:
80
+ history.append({"role": "user",
81
+ "content": "[previous turn interrupted by user]"})
82
+ self._sync_jsonl(history)
83
+ self.save()
84
+
85
+ def _sync_jsonl(self, history: list[Message]) -> None:
86
+ """Nothing in this codebase yet calls `append()` per-message during a
87
+ turn, so bring the turn log up to date at the turn boundary instead --
88
+ the log is then complete as of every close_turn(), even before a
89
+ caller is wired to append() live. A no-op once the log has caught up."""
90
+ path = self.jsonl_path
91
+ existing = len(_read_jsonl(path)) if path.exists() else 0
92
+ if existing >= len(history):
93
+ return
94
+ DIR.mkdir(parents=True, exist_ok=True)
95
+ tmp = path.with_suffix(".jsonl.tmp")
96
+ with tmp.open("w") as f:
97
+ for msg in history:
98
+ f.write(json.dumps(msg) + "\n")
99
+ tmp.replace(path)
100
+
101
+
102
+ def log_message(session_id: str, msg: Message) -> None:
103
+ """Append one message to `<session_id>.jsonl` without needing a `Session`
104
+ object loaded -- the primitive `Session.append` builds on."""
105
+ DIR.mkdir(parents=True, exist_ok=True)
106
+ with (DIR / f"{session_id}.jsonl").open("a") as f:
107
+ f.write(json.dumps(msg) + "\n")
108
+
109
+
110
+ def _read_jsonl(path: Path) -> list[Message]:
111
+ """Tolerates a truncated trailing line -- the shape a hard crash mid-write
112
+ leaves, since appends are not fsynced. Anything after the first bad line
113
+ is dropped rather than raising, since the log is append-only sequential."""
114
+ messages: list[Message] = []
115
+ for line in path.read_text().splitlines():
116
+ line = line.strip()
117
+ if not line:
118
+ continue
119
+ try:
120
+ messages.append(json.loads(line))
121
+ except json.JSONDecodeError:
122
+ break
123
+ return messages
124
+
125
+
126
+ def load(sid: str) -> Session:
127
+ p = DIR / f"{sid}.json"
128
+ if not p.exists():
129
+ matches = sorted(DIR.glob(f"{sid}*.json")) if DIR.exists() else []
130
+ if not matches:
131
+ raise SystemExit(f"omega: no session {sid!r}")
132
+ p = matches[-1]
133
+ raw = json.loads(p.read_text())
134
+ known = set(Session.__dataclass_fields__)
135
+ sess = Session(**{k: v for k, v in raw.items() if k in known})
136
+ sess.recovered = 0
137
+
138
+ jsonl = DIR / f"{sess.id}.jsonl"
139
+ if jsonl.exists():
140
+ logged = _read_jsonl(jsonl)
141
+ if len(logged) > len(sess.history):
142
+ sess.recovered = len(logged) - len(sess.history)
143
+ sess.history = logged
144
+
145
+ repair(sess.history)
146
+ return sess
147
+
148
+
149
+ def repair(history: list[Message]) -> list[Message]:
150
+ """Drop a trailing assistant message whose tool_calls were never answered.
151
+ Providers hard-400 on that shape, which would brick the session forever."""
152
+ while history:
153
+ last = history[-1]
154
+ if last.get("role") == "assistant" and last.get("tool_calls"):
155
+ answered = {m.get("tool_call_id") for m in history if m.get("role") == "tool"}
156
+ if any(c["id"] not in answered for c in last["tool_calls"]):
157
+ history.pop()
158
+ continue
159
+ break
160
+ return history
161
+
162
+
163
+ def all_sessions() -> list[Session]:
164
+ if not DIR.exists():
165
+ return []
166
+ out = []
167
+ for p in DIR.glob("*.json"):
168
+ try:
169
+ out.append(Session(**json.loads(p.read_text())))
170
+ except Exception:
171
+ continue
172
+ return sorted(out, key=lambda s: s.updated, reverse=True)
173
+
174
+
175
+ def latest(cwd: str | None = None) -> Session | None:
176
+ cwd = cwd or os.getcwd()
177
+ # Never fall back to another directory's session: bash and relative paths
178
+ # resolve against the real cwd, so the model would edit the wrong project.
179
+ here = [s for s in all_sessions() if s.cwd == cwd]
180
+ return here[0] if here else None
181
+
182
+
183
+ def render_list(limit: int = 20, cwd: str | None = None) -> str:
184
+ """One session per line: wrapped rows make the list unparseable. `cwd`
185
+ filters to that directory's sessions only (used by `omega resume` with
186
+ no id, and `sessions --resume`'s hint)."""
187
+ rows = all_sessions()
188
+ if cwd is not None:
189
+ rows = [s for s in rows if s.cwd == cwd]
190
+ rows = rows[:limit]
191
+ if not rows:
192
+ return "no sessions yet"
193
+ now = time.time()
194
+ import shutil
195
+ cols = shutil.get_terminal_size((80, 24)).columns
196
+ lines = [f"{'ID':<22}{'AGE':>6}{'TURNS':>6} {'CWD':<24}PROMPT"]
197
+ for s in rows:
198
+ age = now - s.updated
199
+ ago = (f"{age/86400:.0f}d" if age > 86400 else
200
+ f"{age/3600:.0f}h" if age > 3600 else f"{age/60:.0f}m")
201
+ cwd = s.cwd.replace(str(Path.home()), "~")
202
+ if len(cwd) > 23:
203
+ cwd = "…" + cwd[-22:]
204
+ lines.append(f"{s.id:<22}{ago:>6}{s.turns:>6} {cwd:<24}"
205
+ f"{s.first_prompt(200)}")
206
+ # hard-truncate: a wrapped row makes the list unreadable and unparseable
207
+ # Stay strictly under the terminal width: a line of exactly `cols` chars
208
+ # still wraps in some terminals.
209
+ return "\n".join(l[:cols - 2] + "…" if len(l) >= cols - 1 else l for l in lines)
omega/setup.html ADDED
@@ -0,0 +1,281 @@
1
+ <!doctype html>
2
+ <meta charset="utf-8"><title>omega setup</title>
3
+ <style>
4
+ :root{--bg:#fbfbfa;--fg:#1a1a19;--dim:#6b6b68;--line:#e4e4e1;--card:#fff;--soft:#f4f4f2;
5
+ --ok:#1a7f4b;--err:#b3261e;--accent:#2b6cb0}
6
+ @media(prefers-color-scheme:dark){:root{--bg:#191918;--fg:#e8e8e5;--dim:#96968f;--line:#33332f;
7
+ --card:#212120;--soft:#262625;--ok:#4ade80;--err:#f87171;--accent:#7cb3ea}}
8
+ *{box-sizing:border-box}
9
+ body{margin:0;padding:0;background:var(--bg);color:var(--fg);
10
+ font:14px/1.55 ui-sans-serif,-apple-system,system-ui,sans-serif}
11
+ main{max-width:680px;margin:0 auto;padding:48px 24px 80px}
12
+ h1{font-size:26px;margin:0 0 6px;letter-spacing:-.02em}
13
+ h2{font-size:19px;margin:0 0 6px;letter-spacing:-.01em}
14
+ .sub{color:var(--dim);margin:0 0 26px}
15
+ .steps{display:flex;gap:6px;margin-bottom:34px}
16
+ .steps div{height:3px;flex:1;background:var(--line);border-radius:2px;transition:background .25s}
17
+ .steps div.done{background:var(--ok)}.steps div.now{background:var(--accent)}
18
+ .card{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:26px}
19
+ label{display:block;font-size:12px;color:var(--dim);margin:14px 0 5px}
20
+ input,select{width:100%;padding:9px 11px;border:1px solid var(--line);border-radius:7px;
21
+ background:var(--bg);color:var(--fg);font:13px ui-monospace,SFMono-Regular,Menlo,monospace}
22
+ input:focus,select:focus{outline:2px solid var(--accent);outline-offset:-1px}
23
+ button{padding:9px 16px;border:1px solid var(--line);border-radius:7px;background:var(--bg);
24
+ color:var(--fg);font-size:13px;cursor:pointer}
25
+ button:hover:not(:disabled){border-color:var(--accent);color:var(--accent)}
26
+ button.primary{background:var(--accent);color:#fff;border-color:var(--accent)}
27
+ button.primary:hover:not(:disabled){opacity:.9;color:#fff}
28
+ button:disabled{opacity:.45;cursor:default}
29
+ .nav{display:flex;justify-content:space-between;margin-top:26px}
30
+ .ok{color:var(--ok)}.err{color:var(--err)}.dim{color:var(--dim)}
31
+ .mono{font:12px ui-monospace,SFMono-Regular,Menlo,monospace}
32
+ .msg{margin-top:12px;font-size:13px;min-height:20px}
33
+ table{width:100%;border-collapse:collapse;font-size:13px;margin-top:6px}
34
+ td,th{padding:7px 4px;border-bottom:1px solid var(--line);text-align:left}
35
+ th{font-size:11px;text-transform:uppercase;letter-spacing:.06em;color:var(--dim);font-weight:600}
36
+ tr.best td{background:var(--soft)}
37
+ .role{display:grid;grid-template-columns:120px 1fr;gap:10px;align-items:center;margin-bottom:9px}
38
+ .role .n{font-size:12px}.role .n b{display:block;font:12px ui-monospace,monospace}
39
+ pre{background:var(--soft);border:1px solid var(--line);border-radius:7px;padding:13px;
40
+ overflow-x:auto;font:12px ui-monospace,monospace;margin:12px 0 0}
41
+ .pill{display:inline-block;padding:2px 8px;border-radius:20px;font-size:11px;
42
+ background:var(--soft);border:1px solid var(--line);margin:2px 4px 2px 0}
43
+ h3.sec{margin:20px 0 6px;font-size:13px;text-transform:uppercase;letter-spacing:.05em;color:var(--dim)}
44
+ .cathead{font-size:11px;text-transform:uppercase;letter-spacing:.06em;color:var(--dim);
45
+ margin:16px 0 4px;font-weight:600}
46
+ .introw{display:flex;align-items:center;gap:10px;padding:8px 2px;border-bottom:1px solid var(--line)}
47
+ .introw .meta{flex:1;min-width:0}
48
+ .introw .meta .b{font-size:12px;color:var(--dim);margin-top:1px}
49
+ #catSearch{margin:8px 0}
50
+ button.small{padding:5px 10px;font-size:12px}
51
+ </style>
52
+ <main>
53
+ <h1>omega</h1><p class="sub" id="tagline">Let's get you running.</p>
54
+ <div class="steps" id="steps"></div>
55
+ <div class="card" id="card"></div>
56
+ <div class="nav"><button id="back" onclick="go(-1)">Back</button>
57
+ <button id="next" class="primary" onclick="go(1)">Continue</button></div>
58
+ </main>
59
+ <script>
60
+ const T = new URLSearchParams(location.search).get("t");
61
+ // /api/* is POST-only: GET dispatch was a CSRF-reachable RCE.
62
+ const api=(p,b)=>fetch(p+"?t="+T,{method:"POST",headers:{"Content-Type":"application/json",
63
+ "X-Omega-Token":T},body:JSON.stringify(b||{})}).then(r=>r.json());
64
+ const $=s=>document.querySelector(s);
65
+ let cfg=null, models=[], bench=[], step=0, keyOk=false, ranTest=false;
66
+
67
+ const ROLES={main:["Main","drives your session — pick the smartest"],
68
+ plan:["Planning","read-only research and plans"],
69
+ subagent_fast:["Fast subagent","bounded lookups — pick the quickest"],
70
+ subagent_mid:["Mid subagent","reasoning across files"],
71
+ compact:["Compaction","summarises old context"]};
72
+
73
+ const STEPS=[
74
+ {t:"Provider",r:()=>`<h2>Connect a provider</h2>
75
+ <p class="dim">omega talks to Anthropic natively, or any OpenAI-compatible endpoint.</p>
76
+ <label>Provider type</label>
77
+ <select id="providerType" onchange="onProviderTypeChange()">
78
+ <option value="openai"${Object.values(cfg.providers)[0].type!=='anthropic'?' selected':''}>OpenAI-compatible</option>
79
+ <option value="anthropic"${Object.values(cfg.providers)[0].type==='anthropic'?' selected':''}>Anthropic (native)</option>
80
+ </select>
81
+ <div id="baseUrlRow" style="${Object.values(cfg.providers)[0].type==='anthropic'?'display:none':''}">
82
+ <label>Base URL</label><input id="baseUrl" value="${Object.values(cfg.providers)[0].baseUrl||''}"></div>
83
+ <label>API key ${Object.values(cfg.providers)[0].hasKey?'<span class="ok">· already stored</span>':''}</label>
84
+ <input id="apiKey" type="password" placeholder="${Object.values(cfg.providers)[0].hasKey?'•••••••••••• (stored)':'sk-…'}">
85
+ <div class="msg" id="m"></div>`,
86
+ enter(){ if(Object.values(cfg.providers)[0].hasKey) verify(); },
87
+ next:()=>keyOk||verify()},
88
+
89
+ {t:"Models",r:()=>`<h2>Pick your models</h2>
90
+ <p class="dim">${models.length} available. Measure them, then assign roles.</p>
91
+ <button onclick="runBench()" id="bb">Measure latency</button>
92
+ <span class="mono dim" id="bs"></span>
93
+ <div id="bt"></div><div id="rs" style="margin-top:20px"></div>`,
94
+ enter(){ renderRoles(); }},
95
+
96
+ {t:"Tools",r:()=>`<h2>Connect tools</h2>
97
+ <p class="dim">Connect from the catalog, import what Claude Code already has, or manage
98
+ what omega is already using. Tools stay out of the prompt until the model searches for them.</p>
99
+ <div id="mc" class="dim">loading…</div>`,
100
+ enter(){ loadConnections(); }},
101
+
102
+ {t:"Test",r:()=>`<h2>Take it for a spin</h2>
103
+ <p class="dim">Runs one real turn against your config — proof it works end to end.</p>
104
+ <button onclick="drive()" id="db" class="primary">Run test</button>
105
+ <div id="dr"></div>`},
106
+
107
+ {t:"Done",r:()=>`<h2>You're set</h2>
108
+ <p class="dim">Config written to <span class="mono">~/.omega/config.json</span>.</p>
109
+ <pre>omega # interactive
110
+ omega "fix the failing test"
111
+ omega --plan "add caching" # read-only planning
112
+ omega --mcp "list my Linear issues"</pre>
113
+ <p class="dim" style="margin-top:16px">Re-run this anytime with
114
+ <span class="mono">omega setup</span>.</p>`}];
115
+
116
+ function render(){
117
+ $("#steps").innerHTML = STEPS.map((_,i)=>
118
+ `<div class="${i<step?'done':i===step?'now':''}"></div>`).join("");
119
+ $("#card").innerHTML = STEPS[step].r();
120
+ $("#back").style.visibility = step? "visible":"hidden";
121
+ $("#next").textContent = step===STEPS.length-1? "Finish":"Continue";
122
+ $("#tagline").textContent = STEPS[step].t;
123
+ STEPS[step].enter?.();
124
+ }
125
+ async function go(d){
126
+ if(d>0 && STEPS[step].next && !(await STEPS[step].next())) return;
127
+ if(d>0 && step===STEPS.length-1){ await save(); $("#next").textContent="Saved"; return; }
128
+ if(d>0) await save();
129
+ step=Math.max(0,Math.min(STEPS.length-1,step+d)); render();
130
+ }
131
+ function onProviderTypeChange(){
132
+ const p=Object.values(cfg.providers)[0], isAnthropic=$("#providerType").value==="anthropic";
133
+ p.type=$("#providerType").value;
134
+ $("#baseUrlRow").style.display = isAnthropic ? "none" : "";
135
+ }
136
+ async function verify(){
137
+ const m=$("#m"); m.textContent="checking…"; m.className="msg dim";
138
+ const p=Object.values(cfg.providers)[0];
139
+ p.type=$("#providerType").value;
140
+ const body={}; const k=$("#apiKey")?.value; if(k) body.apiKey=k;
141
+ if(p.type!=='anthropic' && $("#baseUrl")) p.baseUrl=$("#baseUrl").value;
142
+ if(k) p.apiKey=k;
143
+ else delete p.apiKey; // never write back a redacted key
144
+ const r=await api("/api/models",body);
145
+ if(r.error){ m.textContent="✗ "+r.error.slice(0,90); m.className="msg err"; return keyOk=false; }
146
+ models=r.models; keyOk=true;
147
+ m.innerHTML=`<span class="ok">✓ connected — ${models.length} models available</span>`;
148
+ return true;
149
+ }
150
+ async function runBench(){
151
+ const b=$("#bb"); b.disabled=true; $("#bs").textContent=" measuring one at a time…";
152
+ const r=await api("/api/benchmark",{});
153
+ bench=r.results||[]; b.disabled=false; $("#bs").textContent="";
154
+ $("#bt").innerHTML=`<table><tr><th>Model</th><th>Round trip</th></tr>`+
155
+ bench.map((x,i)=>`<tr class="${i===0&&x.ok?'best':''}"><td class="mono">${x.model}</td>
156
+ <td class="${x.ok?'ok':'err'} mono">${x.ok?x.ms+"ms":x.error}</td></tr>`).join("")+`</table>`;
157
+ renderRoles();
158
+ }
159
+ function renderRoles(){
160
+ const fast=bench.filter(b=>b.ok)[0]?.model;
161
+ $("#rs").innerHTML=Object.entries(ROLES).map(([k,[n,d]])=>`<div class="role">
162
+ <span class="n">${n}<b>${k}</b></span>
163
+ <select data-role="${k}">${(models.length?models:[cfg.roles[k].model])
164
+ .map(m=>`<option${m===cfg.roles[k].model?" selected":""}>${m}</option>`).join("")}</select>
165
+ </div><p class="dim" style="margin:-4px 0 12px;font-size:12px">${d}${
166
+ fast&&k.includes("fast")?` — fastest measured: <b>${fast}</b>`:""}</p>`).join("");
167
+ }
168
+ let connData=null, catFilter="";
169
+
170
+ async function loadConnections(){
171
+ connData=await api("/api/connections/list");
172
+ renderConnections();
173
+ }
174
+ function connStateBadge(r){
175
+ if(r.state==="connected") return `<span class="ok">connected</span> <span class="dim">(${r.tools})</span>`;
176
+ if(r.state==="needs_auth") return `<span class="err">needs auth</span>`;
177
+ if(r.state==="error") return `<span class="err">error</span>`;
178
+ if(r.state==="disabled") return `<span class="dim">disabled</span>`;
179
+ return `<span class="dim">configured</span>`;
180
+ }
181
+ function renderConnections(){
182
+ let html="";
183
+ if(connData.configured.length){
184
+ html+=`<h3 class="sec">Connected</h3><table><tr><th>Name</th><th>State</th><th></th></tr>`+
185
+ connData.configured.map(r=>`<tr><td>${r.name}</td><td>${connStateBadge(r)}</td>
186
+ <td style="width:260px">
187
+ <button class="small" onclick="connectServer('${r.name}')">connect</button>
188
+ <button class="small" onclick="testServer('${r.name}')">test</button>
189
+ <button class="small" onclick="toggleServer('${r.name}',${r.enabled?"false":"true"})">${r.enabled?"disable":"enable"}</button>
190
+ <button class="small" onclick="removeServer('${r.name}')">remove</button>
191
+ </td></tr>`).join("")+`</table>`;
192
+ }
193
+ if(connData.claudeCode.length){
194
+ html+=`<h3 class="sec">Found in Claude Code</h3><table><tr><th>Name</th><th></th><th></th></tr>`+
195
+ connData.claudeCode.map(c=>`<tr><td>${c.name}</td>
196
+ <td class="mono dim">${c.transport==="stdio"?c.command:c.url}</td>
197
+ <td style="width:100px"><button class="small" onclick="importClaude('${c.name}')">import</button></td>
198
+ </tr>`).join("")+`</table>`;
199
+ }
200
+ html+=`<h3 class="sec">Catalog</h3>
201
+ <input id="catSearch" placeholder="Search integrations…" value="${catFilter}"
202
+ oninput="catFilter=this.value;renderCatalog()">
203
+ <div id="catList"></div>
204
+ <div class="msg" id="connMsg"></div>`;
205
+ $("#mc").innerHTML=html;
206
+ renderCatalog();
207
+ }
208
+ function renderCatalog(){
209
+ const q=catFilter.trim().toLowerCase();
210
+ const groups={};
211
+ connData.catalog.forEach(i=>{
212
+ if(q && !(i.name.toLowerCase().includes(q) || i.blurb.toLowerCase().includes(q) || i.key.includes(q))) return;
213
+ (groups[i.category]=groups[i.category]||[]).push(i);
214
+ });
215
+ const cats=Object.keys(groups).sort();
216
+ $("#catList").innerHTML=cats.length ? cats.map(cat=>`
217
+ <div class="cathead">${cat}</div>
218
+ ${groups[cat].map(i=>`<div class="introw">
219
+ <div class="meta"><b>${i.name}</b>${i.verified?"":' <span class="dim">unverified</span>'}
220
+ <div class="b">${i.blurb}</div></div>
221
+ <span class="pill">${i.auth}</span>
222
+ <button class="small" onclick="addFromCatalog('${i.key}')">connect</button>
223
+ </div>`).join("")}`).join("")
224
+ : `<p class="dim">no matches</p>`;
225
+ }
226
+ function connMsg(text,ok){
227
+ const m=$("#connMsg"); if(!m) return;
228
+ m.textContent=text; m.className="msg "+(ok?"ok":"err");
229
+ }
230
+ async function addFromCatalog(key){
231
+ const entry=connData.catalog.find(i=>i.key===key);
232
+ const env={};
233
+ for(const k of (entry?.env||[])){
234
+ const v=window.prompt(`${entry.name} needs ${k}:`);
235
+ if(v===null) return; // cancelled
236
+ if(v) env[k]=v;
237
+ }
238
+ const r=await api("/api/connections/add",{name:key,key,env});
239
+ if(r.error){ connMsg(`${key}: ${r.error}`,false); return; }
240
+ await connectServer(key);
241
+ }
242
+ async function importClaude(name){
243
+ const r=await api("/api/connections/add",{name,source:"claude-code"});
244
+ if(r.error){ connMsg(`${name}: ${r.error}`,false); return; }
245
+ connData=await api("/api/connections/list"); renderConnections();
246
+ }
247
+ async function connectServer(name){
248
+ const st=await api("/api/connections/connect",{name});
249
+ if(st.state==="needs_auth") connMsg(`${name}: open ${st.error} to authorise, then click connect again`,false);
250
+ else if(st.state==="connected") connMsg(`${name}: connected (${st.tools} tools)`,true);
251
+ else connMsg(`${name}: ${st.state}${st.error?" — "+st.error:""}`,false);
252
+ connData=await api("/api/connections/list"); renderConnections();
253
+ }
254
+ async function testServer(name){
255
+ const st=await api("/api/connections/test",{name});
256
+ connMsg(`${name}: ${st.state==="connected"?`ok (${st.tools} tools)`:(st.error||st.state)}`,st.state==="connected");
257
+ connData=await api("/api/connections/list"); renderConnections();
258
+ }
259
+ async function toggleServer(name,val){
260
+ await api("/api/connections/toggle",{name,enabled:val});
261
+ connData=await api("/api/connections/list"); renderConnections();
262
+ }
263
+ async function removeServer(name){
264
+ await api("/api/connections/remove",{name});
265
+ connData=await api("/api/connections/list"); renderConnections();
266
+ }
267
+ async function drive(){
268
+ const b=$("#db"); b.disabled=true; b.textContent="running…";
269
+ const r=await api("/api/agent",{prompt:"Run 'pwd' and 'date' with bash, then say in one short sentence where you are and what day it is."});
270
+ b.disabled=false; b.textContent="Run again"; ranTest=r.ok;
271
+ $("#dr").innerHTML=r.ok
272
+ ?`<p style="margin-top:14px">${r.tools.map(t=>`<span class="pill">⏺ ${t}</span>`).join("")}</p>
273
+ <pre>${r.text.replace(/[<>]/g,"")}</pre><p class="ok" style="margin-top:10px">✓ working</p>`
274
+ :`<pre class="err">${(r.error||"failed").replace(/[<>]/g,"")}</pre>`;
275
+ }
276
+ async function save(){
277
+ document.querySelectorAll("select[data-role]").forEach(s=>cfg.roles[s.dataset.role].model=s.value);
278
+ await api("/api/save",cfg);
279
+ }
280
+ api("/api/config").then(c=>{cfg=c; render();});
281
+ </script>