abstract-serve-core 0.1.1__tar.gz

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 (60) hide show
  1. abstract_serve_core-0.1.1/PKG-INFO +19 -0
  2. abstract_serve_core-0.1.1/README.md +8 -0
  3. abstract_serve_core-0.1.1/pyproject.toml +29 -0
  4. abstract_serve_core-0.1.1/setup.cfg +4 -0
  5. abstract_serve_core-0.1.1/src/abstract_serve/__init__.py +10 -0
  6. abstract_serve_core-0.1.1/src/abstract_serve/__main__.py +6 -0
  7. abstract_serve_core-0.1.1/src/abstract_serve/actions.py +594 -0
  8. abstract_serve_core-0.1.1/src/abstract_serve/attachments.py +128 -0
  9. abstract_serve_core-0.1.1/src/abstract_serve/cfgdir.py +272 -0
  10. abstract_serve_core-0.1.1/src/abstract_serve/cli.py +384 -0
  11. abstract_serve_core-0.1.1/src/abstract_serve/console_service.py +1109 -0
  12. abstract_serve_core-0.1.1/src/abstract_serve/hub.py +351 -0
  13. abstract_serve_core-0.1.1/src/abstract_serve/keepalive.py +225 -0
  14. abstract_serve_core-0.1.1/src/abstract_serve/launch.py +286 -0
  15. abstract_serve_core-0.1.1/src/abstract_serve/mcp.py +852 -0
  16. abstract_serve_core-0.1.1/src/abstract_serve/mct_archive.py +203 -0
  17. abstract_serve_core-0.1.1/src/abstract_serve/mct_repl.py +1128 -0
  18. abstract_serve_core-0.1.1/src/abstract_serve/models.py +298 -0
  19. abstract_serve_core-0.1.1/src/abstract_serve/paths.py +52 -0
  20. abstract_serve_core-0.1.1/src/abstract_serve/pricing.py +235 -0
  21. abstract_serve_core-0.1.1/src/abstract_serve/queue.py +144 -0
  22. abstract_serve_core-0.1.1/src/abstract_serve/reduce.py +316 -0
  23. abstract_serve_core-0.1.1/src/abstract_serve/relay.py +265 -0
  24. abstract_serve_core-0.1.1/src/abstract_serve/rollover.py +995 -0
  25. abstract_serve_core-0.1.1/src/abstract_serve/roster.py +729 -0
  26. abstract_serve_core-0.1.1/src/abstract_serve/seat_report.py +141 -0
  27. abstract_serve_core-0.1.1/src/abstract_serve/serve_cli.py +44 -0
  28. abstract_serve_core-0.1.1/src/abstract_serve/server.py +1659 -0
  29. abstract_serve_core-0.1.1/src/abstract_serve/templates/dot-claude.json +16 -0
  30. abstract_serve_core-0.1.1/src/abstract_serve/templates/settings.json +20 -0
  31. abstract_serve_core-0.1.1/src/abstract_serve/token_test.py +176 -0
  32. abstract_serve_core-0.1.1/src/abstract_serve/toolset.py +43 -0
  33. abstract_serve_core-0.1.1/src/abstract_serve/usage_db.py +1156 -0
  34. abstract_serve_core-0.1.1/src/abstract_serve/util.py +236 -0
  35. abstract_serve_core-0.1.1/src/abstract_serve/webui/assets/backend-sessions.js +180 -0
  36. abstract_serve_core-0.1.1/src/abstract_serve/webui/assets/index-DaUql2da.js +59 -0
  37. abstract_serve_core-0.1.1/src/abstract_serve/webui/assets/index-oJz42rZV.css +34 -0
  38. abstract_serve_core-0.1.1/src/abstract_serve/webui/assets/keeper-live.js +2293 -0
  39. abstract_serve_core-0.1.1/src/abstract_serve/webui/index.html +30 -0
  40. abstract_serve_core-0.1.1/src/abstract_serve_core.egg-info/PKG-INFO +19 -0
  41. abstract_serve_core-0.1.1/src/abstract_serve_core.egg-info/SOURCES.txt +58 -0
  42. abstract_serve_core-0.1.1/src/abstract_serve_core.egg-info/dependency_links.txt +1 -0
  43. abstract_serve_core-0.1.1/src/abstract_serve_core.egg-info/entry_points.txt +3 -0
  44. abstract_serve_core-0.1.1/src/abstract_serve_core.egg-info/requires.txt +4 -0
  45. abstract_serve_core-0.1.1/src/abstract_serve_core.egg-info/top_level.txt +1 -0
  46. abstract_serve_core-0.1.1/tests/test_attach.py +99 -0
  47. abstract_serve_core-0.1.1/tests/test_console_service.py +290 -0
  48. abstract_serve_core-0.1.1/tests/test_governor.py +77 -0
  49. abstract_serve_core-0.1.1/tests/test_harness_classify.py +83 -0
  50. abstract_serve_core-0.1.1/tests/test_interrupt.py +46 -0
  51. abstract_serve_core-0.1.1/tests/test_models.py +153 -0
  52. abstract_serve_core-0.1.1/tests/test_pricing.py +234 -0
  53. abstract_serve_core-0.1.1/tests/test_queue.py +45 -0
  54. abstract_serve_core-0.1.1/tests/test_reduce.py +147 -0
  55. abstract_serve_core-0.1.1/tests/test_relay.py +284 -0
  56. abstract_serve_core-0.1.1/tests/test_rollover.py +53 -0
  57. abstract_serve_core-0.1.1/tests/test_roster.py +298 -0
  58. abstract_serve_core-0.1.1/tests/test_roster_archive.py +139 -0
  59. abstract_serve_core-0.1.1/tests/test_session_events.py +130 -0
  60. abstract_serve_core-0.1.1/tests/test_turn_uuid.py +62 -0
@@ -0,0 +1,19 @@
1
+ Metadata-Version: 2.4
2
+ Name: abstract-serve-core
3
+ Version: 0.1.1
4
+ Summary: Provider-neutral shared Serve console for Claude, GPT, Hugpy, and local model backends.
5
+ License: Proprietary
6
+ Requires-Python: >=3.9
7
+ Description-Content-Type: text/markdown
8
+ Requires-Dist: prompt_toolkit>=3.0
9
+ Provides-Extra: gpt
10
+ Requires-Dist: abstract-gpt>=0.1.9; extra == "gpt"
11
+
12
+ # abstract-serve
13
+
14
+ The provider-neutral shared Serve console. It owns the durable conversation
15
+ store, queue/hold contract, session picker, and provider adapters. Claude, GPT,
16
+ Hugpy, and local-model packages may provide adapters without owning the Serve
17
+ runtime.
18
+
19
+ Run `abstract-serve --no-browser` to start the console.
@@ -0,0 +1,8 @@
1
+ # abstract-serve
2
+
3
+ The provider-neutral shared Serve console. It owns the durable conversation
4
+ store, queue/hold contract, session picker, and provider adapters. Claude, GPT,
5
+ Hugpy, and local-model packages may provide adapters without owning the Serve
6
+ runtime.
7
+
8
+ Run `abstract-serve --no-browser` to start the console.
@@ -0,0 +1,29 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "abstract-serve-core"
7
+ version = "0.1.1"
8
+ description = "Provider-neutral shared Serve console for Claude, GPT, Hugpy, and local model backends."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = { text = "Proprietary" }
12
+ dependencies = ["prompt_toolkit>=3.0"]
13
+
14
+ [project.optional-dependencies]
15
+ gpt = ["abstract-gpt>=0.1.9"]
16
+
17
+ [project.scripts]
18
+ abstract-serve = "abstract_serve.serve_cli:main"
19
+ abstract-serve-mcp = "abstract_serve.mcp:main"
20
+
21
+ [tool.setuptools]
22
+ package-dir = { "" = "src" }
23
+ include-package-data = true
24
+
25
+ [tool.setuptools.packages.find]
26
+ where = ["src"]
27
+
28
+ [tool.setuptools.package-data]
29
+ abstract_serve = ["templates/*.json", "webui/*", "webui/assets/*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,10 @@
1
+ """abstract-serve — provider-neutral shared Serve console."""
2
+ try: # the installed distribution's version (pyproject), never a hand-kept string
3
+ from importlib.metadata import version as _v
4
+ __version__ = _v("abstract-serve")
5
+ except Exception: # running from a source checkout without metadata
6
+ __version__ = "0.0.0+src"
7
+
8
+ from .toolset import get_toolset # noqa: E402
9
+
10
+ __all__ = ["get_toolset"]
@@ -0,0 +1,6 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ sys.exit(main())
@@ -0,0 +1,594 @@
1
+ """Core actions: template save/restore/reset, oauth, state, and root init."""
2
+ import os
3
+ import shutil
4
+
5
+ from . import paths
6
+ from .paths import (CONFIG_PATH, HOME, LIVE_CLAUDE_DIR, LIVE_DOTCLAUDE,
7
+ LIVE_SETTINGS, ROOT, SESSIONS_DIR, TEMPLATE_DIR, TPL_DOTCLAUDE,
8
+ TPL_SETTINGS)
9
+ from .util import (OAUTH_ENV, _operator_token, has_claude_auth, has_real_login, load_json,
10
+ load_oauth_token,
11
+ real_claude, run, save_json, store_oauth_token, which_claude_auth)
12
+
13
+ # Session-settings surfaced by the console (valid keys in current CC schema).
14
+ BOOL_KEYS = [
15
+ "enableArtifact", "workflowKeywordTriggerEnabled", "promptSuggestionEnabled",
16
+ "awaySummaryEnabled", "skipDangerousModePermissionPrompt", "autoCompactEnabled",
17
+ "switchModelsOnFlag", "autoScrollEnabled", "fileCheckpointingEnabled",
18
+ "terminalProgressBarEnabled", "remoteControlAtStartup", "inputNeededNotifEnabled",
19
+ "agentPushNotifEnabled",
20
+ ]
21
+ EFFORT_VALUES = ["low", "medium", "high", "xhigh"]
22
+ PERM_MODES = ["acceptEdits", "auto", "bypassPermissions", "default", "plan"]
23
+
24
+ # Minimal flags a fresh ~/.claude.json needs to skip onboarding/trust prompts.
25
+ FLAG_KEYS = ["hasCompletedOnboarding", "bypassPermissionsModeAccepted",
26
+ "hasSeenAutoModeEntryWarning", "hasSeenTasksHint"]
27
+
28
+
29
+ def _pkg_default(name):
30
+ """Read a shipped default template file from package data."""
31
+ try:
32
+ from importlib.resources import files
33
+ return (files("abstract_claude.templates") / name).read_text()
34
+ except Exception:
35
+ return None
36
+
37
+
38
+ def init_root():
39
+ """Materialize AC_ROOT with config.json + template/ from shipped defaults if missing."""
40
+ os.makedirs(TEMPLATE_DIR, exist_ok=True)
41
+ created = []
42
+ if not os.path.exists(CONFIG_PATH):
43
+ save_json(CONFIG_PATH, dict(paths.DEFAULTS))
44
+ created.append(CONFIG_PATH)
45
+ for dst, name in ((TPL_SETTINGS, "settings.json"), (TPL_DOTCLAUDE, "dot-claude.json")):
46
+ if not os.path.exists(dst):
47
+ data = _pkg_default(name)
48
+ if data is not None:
49
+ with open(dst, "w") as f:
50
+ f.write(data)
51
+ created.append(dst)
52
+ return created
53
+
54
+
55
+ def save_template():
56
+ """Capture current settings.json + derive minimal flags into the template."""
57
+ os.makedirs(TEMPLATE_DIR, exist_ok=True)
58
+ live = load_json(LIVE_SETTINGS, {})
59
+ save_json(TPL_SETTINGS, live)
60
+ dot = load_json(LIVE_DOTCLAUDE, {})
61
+ tpl_existing = load_json(TPL_DOTCLAUDE, {})
62
+ flags = {k: bool(dot.get(k, True)) for k in FLAG_KEYS}
63
+ # preserve MCP server registration (from live if present, else keep the template's)
64
+ mcp = dot.get("mcpServers") or tpl_existing.get("mcpServers")
65
+ if mcp:
66
+ flags["mcpServers"] = mcp
67
+ save_json(TPL_DOTCLAUDE, flags)
68
+ return {"ok": True, "out": f"saved settings + flags -> {TEMPLATE_DIR}"}
69
+
70
+
71
+ def restore():
72
+ """Create a fresh ~/.claude (settings + flags) if it does not exist."""
73
+ if os.path.isdir(LIVE_CLAUDE_DIR):
74
+ return {"ok": True, "out": "~/.claude exists (use reset to force a fresh session)"}
75
+ os.makedirs(LIVE_CLAUDE_DIR, mode=0o700, exist_ok=True)
76
+ tpl = load_json(TPL_SETTINGS, {})
77
+ save_json(LIVE_SETTINGS, tpl)
78
+ save_json(LIVE_DOTCLAUDE, load_json(TPL_DOTCLAUDE, {}))
79
+ return {"ok": True, "out": "fresh ~/.claude created; login via CLAUDE_CODE_OAUTH_TOKEN"}
80
+
81
+
82
+ def reset(hard=False):
83
+ """Wipe prior state and reseed the template -> a clean session.
84
+
85
+ By default the interactive login (~/.claude/.credentials.json) is PRESERVED,
86
+ since Claude Code's interactive startup requires it and the env token does not
87
+ suppress the login menu. Pass hard=True to also remove the login.
88
+ """
89
+ creds_path = os.path.join(LIVE_CLAUDE_DIR, ".credentials.json")
90
+ saved_creds = None
91
+ if not hard:
92
+ try:
93
+ with open(creds_path, "rb") as f:
94
+ saved_creds = f.read()
95
+ except OSError:
96
+ pass
97
+ shutil.rmtree(LIVE_CLAUDE_DIR, ignore_errors=True)
98
+ try:
99
+ os.remove(LIVE_DOTCLAUDE)
100
+ except FileNotFoundError:
101
+ pass
102
+ r = restore()
103
+ note = "wiped prior state"
104
+ if saved_creds is not None:
105
+ os.makedirs(LIVE_CLAUDE_DIR, mode=0o700, exist_ok=True)
106
+ with open(creds_path, "wb") as f:
107
+ f.write(saved_creds)
108
+ os.chmod(creds_path, 0o600)
109
+ note += " (kept login)"
110
+ else:
111
+ note += " (no stored login to keep — interactive will ask once)" if not hard \
112
+ else " (hard: login removed)"
113
+ r["out"] = note + "; " + r["out"]
114
+ return r
115
+
116
+
117
+ def ensure_provisioned():
118
+ """Idempotently ensure ~/.claude carries the template's settings + onboarding/
119
+ trust flags + MCP servers — so no theme/onboarding/trust prompts and tools are
120
+ registered. Non-destructive: never wipes transcripts or login, only ADDS missing
121
+ keys. Safe to call on every launch.
122
+ """
123
+ changed = []
124
+ tpl_s = load_json(TPL_SETTINGS, {})
125
+ live_s = load_json(LIVE_SETTINGS, {})
126
+ added = not os.path.exists(LIVE_SETTINGS)
127
+ for k, v in tpl_s.items():
128
+ if k not in live_s:
129
+ live_s[k] = v
130
+ added = True
131
+ if added:
132
+ os.makedirs(LIVE_CLAUDE_DIR, mode=0o700, exist_ok=True)
133
+ save_json(LIVE_SETTINGS, live_s)
134
+ changed.append("settings")
135
+
136
+ tpl_d = load_json(TPL_DOTCLAUDE, {})
137
+ live_d = load_json(LIVE_DOTCLAUDE, {})
138
+ added = not os.path.exists(LIVE_DOTCLAUDE)
139
+ for k, v in tpl_d.items():
140
+ if k == "mcpServers":
141
+ live_mcp = dict(live_d.get("mcpServers") or {})
142
+ for name, spec in v.items():
143
+ if name not in live_mcp:
144
+ live_mcp[name] = spec
145
+ added = True
146
+ live_d["mcpServers"] = live_mcp
147
+ elif k not in live_d:
148
+ live_d[k] = v
149
+ added = True
150
+ if added:
151
+ save_json(LIVE_DOTCLAUDE, live_d)
152
+ changed.append("flags")
153
+ return changed
154
+
155
+
156
+ def _pid_alive(pid):
157
+ try:
158
+ os.kill(int(pid), 0)
159
+ return True
160
+ except (ProcessLookupError, ValueError):
161
+ return False
162
+ except PermissionError:
163
+ return True
164
+
165
+
166
+ def list_sessions(sessions_dir=None):
167
+ """Per-launch session dirs, oldest first: [{path, name, pid, alive, started, cwd}]."""
168
+ base = sessions_dir or SESSIONS_DIR
169
+ out = []
170
+ try:
171
+ names = sorted(os.listdir(base))
172
+ except FileNotFoundError:
173
+ return out
174
+ for n in names:
175
+ d = os.path.join(base, n)
176
+ if not os.path.isdir(d):
177
+ continue
178
+ meta = load_json(os.path.join(d, "session.json"), {})
179
+ pid = meta.get("pid") or (n.rsplit("-", 1)[-1] if "-" in n else "")
180
+ out.append({"path": d, "name": n, "pid": pid, "alive": _pid_alive(pid) if pid else False,
181
+ "started": meta.get("started", ""), "cwd": meta.get("cwd", "")})
182
+ return out
183
+
184
+
185
+ def prune_sessions(keep=30, sessions_dir=None):
186
+ """Remove the oldest finished session dirs beyond `keep` (live pids are never
187
+ touched). keep=0 disables pruning. Returns the removed paths."""
188
+ removed = []
189
+ if not keep or keep < 0:
190
+ return removed
191
+ finished = [s for s in list_sessions(sessions_dir) if not s["alive"]]
192
+ for s in finished[:max(0, len(finished) - keep)]:
193
+ shutil.rmtree(s["path"], ignore_errors=True)
194
+ removed.append(s["path"])
195
+ return removed
196
+
197
+
198
+ def _creds_ok(path):
199
+ d = load_json(path, {})
200
+ return bool(((d.get("claudeAiOauth") or {}).get("accessToken") or "").strip())
201
+
202
+
203
+ def freshest_credentials(sessions_dir=None):
204
+ """Path of the newest NON-EMPTY .credentials.json among ~/.claude and every
205
+ per-launch session dir — Claude Code rotates the OAuth refresh token inside
206
+ whichever config dir it runs in, so the holder copy in ~/.claude goes stale
207
+ after a session refreshes. The freshest copy is synced back into ~/.claude
208
+ (the holder) so it stays current. Returns '' when there is no login at all."""
209
+ base = sessions_dir or SESSIONS_DIR
210
+ holder = os.path.join(LIVE_CLAUDE_DIR, ".credentials.json")
211
+ cands = [holder]
212
+ try:
213
+ cands += [os.path.join(base, n, ".credentials.json") for n in os.listdir(base)]
214
+ except FileNotFoundError:
215
+ pass
216
+ best, best_m = "", -1.0
217
+ for c in cands:
218
+ try:
219
+ m = os.stat(c).st_mtime
220
+ except OSError:
221
+ continue
222
+ if m > best_m and _creds_ok(c):
223
+ best, best_m = c, m
224
+ if best and best != holder:
225
+ try:
226
+ os.makedirs(LIVE_CLAUDE_DIR, mode=0o700, exist_ok=True)
227
+ shutil.copyfile(best, holder)
228
+ os.chmod(holder, 0o600)
229
+ except OSError:
230
+ pass
231
+ return best
232
+
233
+
234
+ def _template_doc(path, shipped_name):
235
+ """AC_ROOT template file, else the package's shipped default (a seat with no
236
+ AC_ROOT materialized still gets a sane profile), else {}."""
237
+ doc = load_json(path, None)
238
+ if isinstance(doc, dict):
239
+ return doc
240
+ import json
241
+ try:
242
+ return json.loads(_pkg_default(shipped_name) or "{}")
243
+ except Exception:
244
+ return {}
245
+
246
+
247
+ def _login_identity():
248
+ """oauthAccount/userID from the live ~/.claude.json (or ~/.claude/.claude.json)."""
249
+ holder = load_json(LIVE_DOTCLAUDE, None)
250
+ if not isinstance(holder, dict):
251
+ holder = load_json(os.path.join(LIVE_CLAUDE_DIR, ".claude.json"), {}) or {}
252
+ return {k: holder[k] for k in ("oauthAccount", "userID") if holder.get(k)}
253
+
254
+
255
+ def _session_settings(settings=None):
256
+ """The settings.json a fresh session starts with: the given doc, else
257
+ $AC_SETTINGS_JSON (an embedder such as hugpy-station), else the template."""
258
+ import json
259
+ if settings is None and os.environ.get("AC_SETTINGS_JSON"):
260
+ try:
261
+ settings = json.loads(os.environ["AC_SETTINGS_JSON"])
262
+ except Exception:
263
+ settings = None
264
+ if not isinstance(settings, dict):
265
+ settings = _template_doc(TPL_SETTINGS, "settings.json")
266
+ return settings
267
+
268
+
269
+ def fresh_home(cwd=None, settings=None):
270
+ """`abstract-claude reset` semantics on every launch (operator ruling
271
+ 2026-09-04: "delete the .claude then run claude — no memory"): the existing
272
+ reset() clears ~/.claude + ~/.claude.json and keeps only the login; then the
273
+ session settings (template or $AC_SETTINGS_JSON) and fresh flags carrying the
274
+ login identity are written so Claude Code starts signed in with no
275
+ transcripts, history, todos, project trust or caches from earlier sessions.
276
+ Returns ~/.claude."""
277
+ cwd = cwd or os.getcwd()
278
+ freshest_credentials() # newest rotated token into the holder first
279
+ ident = _login_identity() # captured BEFORE reset removes ~/.claude.json
280
+ settings = _session_settings(settings)
281
+ reset(hard=False)
282
+ save_json(LIVE_SETTINGS, settings)
283
+ save_json(LIVE_DOTCLAUDE, session_flags(cwd, identity=ident))
284
+ return LIVE_CLAUDE_DIR
285
+
286
+
287
+ def session_flags(cwd, tpl=None, identity=None):
288
+ """The .claude.json a fresh session starts with: template flags + MCP
289
+ servers, the login IDENTITY carried from the holder (~/.claude.json
290
+ oauthAccount/userID — without it interactive startup shows the login
291
+ picker even with valid credentials), onboarding/trust flags forced on,
292
+ folder trust for `cwd` (no trust dialog), and the toolserver MCP bridge
293
+ given the fleet operator token when one is resolvable. Never carries
294
+ project history, per-project allow-lists or any other session state."""
295
+ d = dict(tpl if tpl is not None else _template_doc(TPL_DOTCLAUDE, "dot-claude.json"))
296
+ holder = identity if isinstance(identity, dict) else load_json(LIVE_DOTCLAUDE, None)
297
+ if not isinstance(holder, dict):
298
+ holder = load_json(os.path.join(LIVE_CLAUDE_DIR, ".claude.json"), {}) or {}
299
+ for k in ("oauthAccount", "userID"):
300
+ if k not in d and holder.get(k):
301
+ d[k] = holder[k]
302
+ for k in FLAG_KEYS:
303
+ d.setdefault(k, True)
304
+ if cwd:
305
+ d.setdefault("projects", {}).setdefault(cwd, {})["hasTrustDialogAccepted"] = True
306
+ ms = d.setdefault("mcpServers", {})
307
+ cur = ms.get("toolserver") or {}
308
+ if not cur or cur.get("command") == "abstract-claude":
309
+ env = dict(cur.get("env") or {})
310
+ url = (os.environ.get("TOOLSERVER_URL") or "").strip().rstrip("/")
311
+ env["TOOLSERVER_URL"] = url or env.get("TOOLSERVER_URL") or "https://toolserver.hugpy.ai"
312
+ tok = _operator_token()
313
+ if tok:
314
+ env["TOOLSERVER_TOKEN"] = tok
315
+ ms["toolserver"] = {"type": "stdio", "command": "abstract-claude", "args": ["mcp"], "env": env}
316
+ return d
317
+
318
+
319
+ def new_session_dir(keep_login=True, sessions_dir=None, keep=30, pid=None, cwd=None,
320
+ settings=None, label=""):
321
+ """Materialize a BRAND-NEW per-session Claude config dir (for CLAUDE_CONFIG_DIR):
322
+ settings.json (the `settings` doc, else $AC_SETTINGS_JSON, else the template),
323
+ .claude.json from session_flags(), and a copy of the freshest login so the
324
+ login menu never shows. Nothing from earlier sessions (transcripts, history,
325
+ project trust) is carried over. Returns the new dir path."""
326
+ import json, re, time
327
+ base = sessions_dir or SESSIONS_DIR
328
+ pid = pid or os.getpid()
329
+ cwd = cwd or os.getcwd()
330
+ os.makedirs(base, mode=0o700, exist_ok=True)
331
+ label = re.sub(r"[^A-Za-z0-9_.-]+", "-", label or os.environ.get("AC_SESSION_LABEL", ""))[:32]
332
+ stamp = time.strftime("%Y%m%d-%H%M%S")
333
+ stem = f"{stamp}-{pid}" + (f"-{label}" if label else "")
334
+ d = os.path.join(base, stem)
335
+ n = 0
336
+ while os.path.exists(d):
337
+ n += 1
338
+ d = os.path.join(base, f"{stem}.{n}")
339
+ os.makedirs(d, mode=0o700)
340
+ if settings is None and os.environ.get("AC_SETTINGS_JSON"):
341
+ try:
342
+ settings = json.loads(os.environ["AC_SETTINGS_JSON"])
343
+ except Exception:
344
+ settings = None
345
+ if not isinstance(settings, dict):
346
+ settings = _template_doc(TPL_SETTINGS, "settings.json")
347
+ save_json(os.path.join(d, "settings.json"), settings)
348
+ save_json(os.path.join(d, ".claude.json"), session_flags(cwd))
349
+ kept_login = False
350
+ if keep_login:
351
+ src = freshest_credentials(base)
352
+ if src:
353
+ dst = os.path.join(d, ".credentials.json")
354
+ shutil.copyfile(src, dst)
355
+ os.chmod(dst, 0o600)
356
+ kept_login = True
357
+ save_json(os.path.join(d, "session.json"), {
358
+ "pid": pid, "started": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
359
+ "cwd": cwd, "label": label, "template": TEMPLATE_DIR, "login_copied": kept_login,
360
+ "settings_source": "env:AC_SETTINGS_JSON" if os.environ.get("AC_SETTINGS_JSON") else "template",
361
+ })
362
+ try:
363
+ prune_sessions(keep, base)
364
+ except Exception:
365
+ pass
366
+ return d
367
+
368
+
369
+ def oauth_status():
370
+ if has_claude_auth():
371
+ r = run([which_claude_auth(), "status"], timeout=20)
372
+ return (r["out"] + (("\n" + r["err"]) if r["err"] else "")).strip()
373
+ tok = load_oauth_token()
374
+ if tok:
375
+ return f"stored token: yes ({tok[:12]}…, {len(tok)} chars); source: abstract_claude store"
376
+ return ("stored token: none — no durable OAuth token for this toolserver. "
377
+ "Solution: POST /claude/oauth_solve to auto-import a token this host can "
378
+ "already see, else mint one on a browser/tty host "
379
+ "(`abstract-claude oauth-mint`) and register it with "
380
+ "`abstract-claude oauth-set <sk-ant-oat…>`. Full plan: /claude/oauth_solution")
381
+
382
+
383
+ def oauth_set(token):
384
+ token = (token or "").strip()
385
+ if not token.startswith("sk-ant-"):
386
+ return {"ok": False, "err": "token must start with sk-ant-"}
387
+ path = store_oauth_token(token) # self-contained store (0600)
388
+ if has_claude_auth(): # keep claude-auth's meta in sync too
389
+ run([which_claude_auth(), "set", token])
390
+ return {"ok": True, "out": f"stored durable token -> {path}"}
391
+
392
+
393
+ def oauth_mint():
394
+ """Run `claude setup-token` via a pty, capture the printed sk-ant-oat token, store it.
395
+
396
+ The one browser approval still happens interactively (URL is teed to your
397
+ terminal); the token is never shown or copy-pasted — it's parsed and stored 0600.
398
+ """
399
+ import pty
400
+ import re
401
+ import sys
402
+
403
+ if not sys.stdin.isatty():
404
+ return {"ok": False, "err": "oauth-mint needs a terminal for browser approval; "
405
+ "use `oauth-set <token>` in non-interactive contexts"}
406
+ buf = bytearray()
407
+
408
+ def _read(fd):
409
+ data = os.read(fd, 1024)
410
+ buf.extend(data)
411
+ return data # pty.spawn tees this to your terminal
412
+
413
+ try:
414
+ pty.spawn([real_claude(), "setup-token"], _read)
415
+ except Exception as e:
416
+ return {"ok": False, "err": f"setup-token failed: {e}"}
417
+ clean = re.sub(rb"\x1b\[[0-9;]*[A-Za-z]", b"", bytes(buf)) # strip ANSI
418
+ matches = re.findall(rb"sk-ant-oat[0-9A-Za-z_\-]+", clean)
419
+ if not matches:
420
+ return {"ok": False, "err": "no sk-ant-oat token found in setup-token output"}
421
+ token = max(matches, key=len).decode().strip() # the full token
422
+ if len(token) < 80:
423
+ return {"ok": False, "err": f"captured token looks truncated ({len(token)} chars)"}
424
+ path = store_oauth_token(token)
425
+ if has_claude_auth():
426
+ run([which_claude_auth(), "set", token])
427
+ return {"ok": True, "out": f"minted + stored durable token ({token[:12]}…) -> {path}"}
428
+
429
+
430
+ def _credentials_token():
431
+ """Best-effort durable/OAuth token from the live Claude CLI credential store
432
+ (~/.claude/.credentials.json) — left behind by any claude login in this HOME."""
433
+ data = load_json(os.path.join(HOME, ".claude", ".credentials.json"), {})
434
+ oa = (data or {}).get("claudeAiOauth") or {}
435
+ return oa.get("accessToken") or data.get("CLAUDE_CODE_OAUTH_TOKEN") or None
436
+
437
+
438
+ def discover_oauth_sources():
439
+ """Token candidates this headless host could adopt WITHOUT a browser, best-first.
440
+ Each: {source, path, token, kind}. `kind` is 'durable' for sk-ant-oat… tokens."""
441
+ def _kind(t):
442
+ return "durable" if t.startswith("sk-ant-oat") else "oauth"
443
+
444
+ found, seen = [], set()
445
+ for source, path, tok in (
446
+ ("env:CLAUDE_CODE_OAUTH_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN",
447
+ os.environ.get("CLAUDE_CODE_OAUTH_TOKEN")),
448
+ ("claude-cli-credentials", os.path.join(HOME, ".claude", ".credentials.json"),
449
+ _credentials_token()),
450
+ ):
451
+ if tok and tok not in seen:
452
+ seen.add(tok)
453
+ found.append({"source": source, "path": path, "token": tok, "kind": _kind(tok)})
454
+ return found
455
+
456
+
457
+ def oauth_solution():
458
+ """Offer a concrete way to ATTAIN a durable OAuth token when none is stored.
459
+
460
+ Read-only: reports current state, any token this host could auto-import, and the
461
+ exact ordered steps. `/claude/oauth_solve` performs the auto-import; minting a
462
+ fresh token needs a browser/tty and therefore happens off-server.
463
+ """
464
+ tok = load_oauth_token()
465
+ if tok:
466
+ return {"ok": True, "have_token": True, "stored_to": OAUTH_ENV,
467
+ "message": f"durable token already stored ({tok[:12]}…, {len(tok)} chars)"}
468
+ sources = discover_oauth_sources()
469
+ steps = []
470
+ if sources:
471
+ steps.append("Auto-import a token this host can already see: "
472
+ "POST /claude/oauth_solve (adopts the best source below).")
473
+ steps.append("Mint a fresh 1-year token on a machine WITH a browser/tty "
474
+ "(`abstract-claude oauth-mint`, or `claude setup-token`), then register "
475
+ "it here: `abstract-claude oauth-set <sk-ant-oat…>` "
476
+ "(POST /claude/oauth_set {\"token\": \"sk-ant-oat…\"}).")
477
+ return {
478
+ "ok": False, "have_token": False,
479
+ "reason": "no durable OAuth token stored for this toolserver",
480
+ "can_auto_import": bool(sources),
481
+ "importable_sources": [
482
+ {"source": s["source"], "path": s["path"], "kind": s["kind"],
483
+ "preview": s["token"][:12] + "…"} for s in sources
484
+ ],
485
+ "steps": steps,
486
+ "host_note": "this toolserver runs headless (no browser/tty), so a fresh mint "
487
+ "must happen elsewhere; oauth_solve/oauth_set register it here",
488
+ }
489
+
490
+
491
+ def oauth_solve(import_from=None):
492
+ """Attain a durable token automatically when possible: no-op if one is already
493
+ stored, else adopt the best discovered source (or the one named by `import_from`).
494
+ When nothing is importable, returns the oauth_solution() plan (with the manual
495
+ mint steps) rather than failing silently."""
496
+ tok = load_oauth_token()
497
+ if tok:
498
+ return {"ok": True, "attained": False, "already": True, "stored_to": OAUTH_ENV,
499
+ "message": f"durable token already stored ({tok[:12]}…)"}
500
+ sources = discover_oauth_sources()
501
+ if import_from:
502
+ sources = [s for s in sources if s["source"] == import_from] or sources
503
+ if not sources:
504
+ sol = oauth_solution()
505
+ sol.update({"ok": False, "attained": False,
506
+ "message": "nothing to auto-import; follow the mint steps"})
507
+ return sol
508
+ chosen = sources[0]
509
+ path = store_oauth_token(chosen["token"])
510
+ if has_claude_auth():
511
+ run([which_claude_auth(), "set", chosen["token"]])
512
+ return {"ok": True, "attained": True, "source": chosen["source"],
513
+ "kind": chosen["kind"], "stored_to": path,
514
+ "message": f"imported durable token from {chosen['source']} "
515
+ f"({chosen['token'][:12]}…)"}
516
+
517
+
518
+ def oauth_probe():
519
+ if has_claude_auth():
520
+ return run([which_claude_auth(), "probe"], timeout=40)
521
+ tok = load_oauth_token()
522
+ if tok:
523
+ return {"ok": True, "out": f"token present ({tok[:12]}…) — "
524
+ "no claude-auth binary to run a live probe"}
525
+ return {"ok": False, "out": "no token stored", "solution": oauth_solution()}
526
+
527
+
528
+ def set_model(default_model=None, quota_fallback_model=None, fallback_model=None):
529
+ live = load_json(LIVE_SETTINGS, {})
530
+ cfg = load_json(CONFIG_PATH, {})
531
+ if default_model:
532
+ live["model"] = default_model
533
+ cfg["default_model"] = default_model
534
+ if fallback_model is not None:
535
+ fm = [x for x in fallback_model if x]
536
+ if fm:
537
+ live["fallbackModel"] = fm
538
+ else:
539
+ live.pop("fallbackModel", None)
540
+ if quota_fallback_model:
541
+ cfg["quota_fallback_model"] = quota_fallback_model
542
+ save_json(LIVE_SETTINGS, live)
543
+ save_json(CONFIG_PATH, cfg)
544
+ return {"ok": True}
545
+
546
+
547
+ def mcp_tool_catalog(base=None, server="toolserver"):
548
+ """Group the toolserver's live tools into MCP permission strings, so the console can
549
+ render a checklist for permissions.allow instead of hand-typing `mcp__<server>__<tool>`.
550
+ Best-effort: returns an empty catalog (+ error) if the toolserver is unreachable."""
551
+ import json as _json
552
+ import urllib.request
553
+ base = (base or os.environ.get("TOOLSERVER_URL", "http://127.0.0.1:7004")).rstrip("/")
554
+ out = {"server": server, "perm_all": "mcp__%s" % server, "base": base, "categories": []}
555
+ try:
556
+ with urllib.request.urlopen(base + "/endpoints", timeout=8) as r:
557
+ eps = _json.loads(r.read().decode())
558
+ except Exception as exc: # noqa: BLE001 - surface unreachable toolserver to the UI
559
+ out["error"] = str(exc)
560
+ return out
561
+ seen, cats = set(), {}
562
+ for ep in (eps if isinstance(eps, list) else []):
563
+ url = (ep or {}).get("url", "")
564
+ if not url or url.endswith("/"):
565
+ continue
566
+ seg = url.strip("/").split("/")
567
+ if seg[0] in ("endpoints", "prefixes") or any("<" in s for s in seg):
568
+ continue
569
+ name = "_".join(seg) # tool id = mcp bridge name (cat_route)
570
+ if name in seen:
571
+ continue
572
+ seen.add(name)
573
+ cats.setdefault(seg[0], []).append({"tool": name, "perm": "mcp__%s__%s" % (server, name)})
574
+ out["categories"] = [{"name": c, "tools": sorted(cats[c], key=lambda t: t["tool"])}
575
+ for c in sorted(cats)]
576
+ return out
577
+
578
+
579
+ def build_state():
580
+ ver = run(["claude", "--version"], timeout=15)
581
+ return {
582
+ "config": paths.load_config(),
583
+ "live_settings": load_json(LIVE_SETTINGS, {}),
584
+ "template_settings": load_json(TPL_SETTINGS, {}),
585
+ "template_dotclaude": load_json(TPL_DOTCLAUDE, {}),
586
+ "oauth": oauth_status(),
587
+ "version": ver["out"] or ver["err"],
588
+ "bool_keys": BOOL_KEYS,
589
+ "effort_values": EFFORT_VALUES,
590
+ "perm_modes": PERM_MODES,
591
+ "mcp_tools": mcp_tool_catalog(),
592
+ "live_settings_path": LIVE_SETTINGS,
593
+ "root": ROOT,
594
+ }