handoff-cli 0.3.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.
- cli/__init__.py +3 -0
- cli/backend.py +224 -0
- cli/backend_types.yaml +91 -0
- cli/commands/__init__.py +0 -0
- cli/commands/env.py +30 -0
- cli/commands/init.py +129 -0
- cli/commands/list.py +81 -0
- cli/commands/resume.py +179 -0
- cli/commands/run.py +211 -0
- cli/commands/tail.py +48 -0
- cli/config.py +351 -0
- cli/core.py +302 -0
- cli/jsonl_parser.py +182 -0
- cli/jsonl_viewer.py +440 -0
- cli/main.py +98 -0
- cli/skills/handoff-codex/SKILL.md +77 -0
- cli/skills/handoff-ds/SKILL.md +77 -0
- cli/skills/handoff-ds.toml +52 -0
- cli/skills/handoff-opus/SKILL.md +77 -0
- cli/stream.py +286 -0
- cli/tui.py +317 -0
- cli/user_config_template.yaml +31 -0
- handoff_cli-0.3.0.dist-info/METADATA +7 -0
- handoff_cli-0.3.0.dist-info/RECORD +26 -0
- handoff_cli-0.3.0.dist-info/WHEEL +4 -0
- handoff_cli-0.3.0.dist-info/entry_points.txt +2 -0
cli/config.py
ADDED
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
"""YAML configuration loading and merging for handoff.
|
|
2
|
+
|
|
3
|
+
Configuration is split into two layers:
|
|
4
|
+
|
|
5
|
+
1. Mechanism (cli/backend_types.yaml) — HOW each backend type is launched
|
|
6
|
+
(command, PTY, flag templates). Bundled with the package; NOT overridable
|
|
7
|
+
by user config. This is the program's behavioural contract.
|
|
8
|
+
|
|
9
|
+
2. Data (~/.handoff/config.yaml) — user-owned backends, models, env vars.
|
|
10
|
+
Generated by `handoff init` from the bundled template; the sole source of
|
|
11
|
+
backend definitions. No hidden merge layers — what you see is what runs.
|
|
12
|
+
|
|
13
|
+
Backend resolution:
|
|
14
|
+
- Resolved backend = types[<type>] (mechanism) + the backend's own fields (data)
|
|
15
|
+
- Deep-merge for mappings; lists are replaced wholesale (not concatenated)
|
|
16
|
+
- The first backend in the user config is the default (dict insertion order).
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import os
|
|
22
|
+
import re
|
|
23
|
+
import sys
|
|
24
|
+
import copy
|
|
25
|
+
from typing import Optional
|
|
26
|
+
|
|
27
|
+
try:
|
|
28
|
+
import yaml
|
|
29
|
+
except ImportError:
|
|
30
|
+
print(
|
|
31
|
+
"handoff: PyYAML is required. Install it with: pip install pyyaml",
|
|
32
|
+
file=sys.stderr,
|
|
33
|
+
)
|
|
34
|
+
sys.exit(1)
|
|
35
|
+
|
|
36
|
+
_BACKEND_TYPES_PATH = os.path.join(os.path.dirname(__file__), "backend_types.yaml")
|
|
37
|
+
_USER_CONFIG_TEMPLATE_PATH = os.path.join(os.path.dirname(__file__), "user_config_template.yaml")
|
|
38
|
+
|
|
39
|
+
# Top-level config keys that belong to the mechanism layer or are otherwise
|
|
40
|
+
# removed. Warn once and ignore. system_prompt is deliberately absent from
|
|
41
|
+
# this list — users CAN override it.
|
|
42
|
+
_DEPRECATED_KEYS = (
|
|
43
|
+
"type_defaults", "backend_types", "backend_template",
|
|
44
|
+
"fast_backend", "default_model", "pro_model", "default_backend",
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
_TEMPLATE_URL = "https://github.com/dazuiba/handoff/blob/main/cli/user_config_template.yaml"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def user_config_dir() -> str:
|
|
51
|
+
return os.path.join(os.path.expanduser("~"), ".handoff")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def user_config_path() -> str:
|
|
55
|
+
return os.path.join(user_config_dir(), "config.yaml")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _load_yaml(path: str) -> dict:
|
|
59
|
+
"""Load a YAML file, returning an empty dict if not found."""
|
|
60
|
+
if not os.path.isfile(path):
|
|
61
|
+
return {}
|
|
62
|
+
try:
|
|
63
|
+
with open(path, "r") as f:
|
|
64
|
+
data = yaml.safe_load(f) or {}
|
|
65
|
+
if not isinstance(data, dict):
|
|
66
|
+
print(f"handoff: config {path} must be a mapping", file=sys.stderr)
|
|
67
|
+
sys.exit(1)
|
|
68
|
+
return data
|
|
69
|
+
except yaml.YAMLError as e:
|
|
70
|
+
print(f"handoff: error parsing {path}: {e}", file=sys.stderr)
|
|
71
|
+
sys.exit(1)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def write_default_user_config() -> bool:
|
|
75
|
+
"""Copy the bundled template to ~/.handoff/config.yaml if missing.
|
|
76
|
+
Return True when written.
|
|
77
|
+
"""
|
|
78
|
+
path = user_config_path()
|
|
79
|
+
if os.path.isfile(path):
|
|
80
|
+
return False
|
|
81
|
+
|
|
82
|
+
try:
|
|
83
|
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
84
|
+
with open(_USER_CONFIG_TEMPLATE_PATH, "r") as src:
|
|
85
|
+
content = src.read()
|
|
86
|
+
with open(path, "w") as dst:
|
|
87
|
+
dst.write(content)
|
|
88
|
+
return True
|
|
89
|
+
except OSError as e:
|
|
90
|
+
print(f"handoff: failed to create default user config at {path}: {e}", file=sys.stderr)
|
|
91
|
+
sys.exit(1)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _ensure_user_config_exists():
|
|
95
|
+
if os.path.isfile(user_config_path()):
|
|
96
|
+
return
|
|
97
|
+
|
|
98
|
+
from .commands.init import run_init
|
|
99
|
+
|
|
100
|
+
run_init()
|
|
101
|
+
if not os.path.isfile(user_config_path()):
|
|
102
|
+
print(f"handoff: initialization did not create {user_config_path()}", file=sys.stderr)
|
|
103
|
+
sys.exit(1)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _deep_merge(base: dict, override: dict) -> dict:
|
|
107
|
+
"""Recursively merge override into base, return new dict."""
|
|
108
|
+
result = copy.deepcopy(base)
|
|
109
|
+
for key, val in override.items():
|
|
110
|
+
if key in result and isinstance(result[key], dict) and isinstance(val, dict):
|
|
111
|
+
result[key] = _deep_merge(result[key], val)
|
|
112
|
+
else:
|
|
113
|
+
result[key] = copy.deepcopy(val)
|
|
114
|
+
return result
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
_ENV_VAR_RE = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}")
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _expand_env_vars(value):
|
|
121
|
+
"""Recursively expand ${ENV_VAR} references in config string values.
|
|
122
|
+
|
|
123
|
+
Unset variables expand to the empty string (later caught by
|
|
124
|
+
ensure_backend_token_ready when a real token is required).
|
|
125
|
+
"""
|
|
126
|
+
if isinstance(value, str):
|
|
127
|
+
return _ENV_VAR_RE.sub(lambda m: os.environ.get(m.group(1), ""), value)
|
|
128
|
+
if isinstance(value, dict):
|
|
129
|
+
return {k: _expand_env_vars(v) for k, v in value.items()}
|
|
130
|
+
if isinstance(value, list):
|
|
131
|
+
return [_expand_env_vars(v) for v in value]
|
|
132
|
+
return value
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _resolve_include_path(include_val: str, including_file_dir: str) -> str:
|
|
136
|
+
"""Resolve an include path.
|
|
137
|
+
|
|
138
|
+
Absolute paths are used as-is.
|
|
139
|
+
Relative paths: first try relative to the including file's directory,
|
|
140
|
+
then fall back to the package directory.
|
|
141
|
+
"""
|
|
142
|
+
if os.path.isabs(include_val):
|
|
143
|
+
return include_val
|
|
144
|
+
|
|
145
|
+
# Try relative to the including file's directory first
|
|
146
|
+
candidate = os.path.join(including_file_dir, include_val)
|
|
147
|
+
if os.path.isfile(candidate):
|
|
148
|
+
return candidate
|
|
149
|
+
|
|
150
|
+
# Fall back to the package directory
|
|
151
|
+
candidate = os.path.join(os.path.dirname(__file__), include_val)
|
|
152
|
+
if os.path.isfile(candidate):
|
|
153
|
+
return candidate
|
|
154
|
+
|
|
155
|
+
return candidate
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _load_with_includes(path: str, _seen: Optional[set] = None) -> dict:
|
|
159
|
+
"""Load a YAML file, recursively resolving `include:` directives.
|
|
160
|
+
|
|
161
|
+
`include` can be a string (single path) or list of paths.
|
|
162
|
+
Included files are deep-merged first (in order), then the current
|
|
163
|
+
file's own keys are deep-merged on top so they override includes.
|
|
164
|
+
|
|
165
|
+
Absolute include paths are used as-is. Relative paths are resolved
|
|
166
|
+
against the including file's directory first, then the package dir.
|
|
167
|
+
|
|
168
|
+
_seen tracks already-visited paths to guard against cycles.
|
|
169
|
+
"""
|
|
170
|
+
if _seen is None:
|
|
171
|
+
_seen = set()
|
|
172
|
+
|
|
173
|
+
real = os.path.realpath(path)
|
|
174
|
+
if real in _seen:
|
|
175
|
+
return {}
|
|
176
|
+
_seen.add(real)
|
|
177
|
+
|
|
178
|
+
data = _load_yaml(path)
|
|
179
|
+
includes = data.pop("include", None)
|
|
180
|
+
if isinstance(includes, str):
|
|
181
|
+
includes = [includes]
|
|
182
|
+
elif includes is None:
|
|
183
|
+
includes = []
|
|
184
|
+
|
|
185
|
+
including_dir = os.path.dirname(path)
|
|
186
|
+
|
|
187
|
+
# Deep-merge all includes first
|
|
188
|
+
merged = {}
|
|
189
|
+
for inc in includes:
|
|
190
|
+
inc_path = _resolve_include_path(inc, including_dir)
|
|
191
|
+
if os.path.isfile(inc_path):
|
|
192
|
+
inc_data = _load_with_includes(inc_path, _seen)
|
|
193
|
+
merged = _deep_merge(merged, inc_data)
|
|
194
|
+
|
|
195
|
+
# Then deep-merge current file's own keys on top
|
|
196
|
+
merged = _deep_merge(merged, data)
|
|
197
|
+
|
|
198
|
+
return merged
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
class Config:
|
|
202
|
+
"""Resolved handoff configuration.
|
|
203
|
+
|
|
204
|
+
Mechanism (backend_types.yaml) + Data (~/.handoff/config.yaml).
|
|
205
|
+
The two layers are loaded independently; user config CANNOT override the
|
|
206
|
+
mechanism layer (flags, PTY). Backend resolution deep-merges the
|
|
207
|
+
mechanism fields for the backend's type with the user's backend fields.
|
|
208
|
+
"""
|
|
209
|
+
|
|
210
|
+
def __init__(self):
|
|
211
|
+
_ensure_user_config_exists()
|
|
212
|
+
|
|
213
|
+
# Mechanism layer — how each type is launched (NOT overridable).
|
|
214
|
+
self._backend_types = _load_yaml(_BACKEND_TYPES_PATH)
|
|
215
|
+
types = self._backend_types.get("types")
|
|
216
|
+
if not isinstance(types, dict) or not types:
|
|
217
|
+
print(
|
|
218
|
+
f"handoff: missing or invalid types in {_BACKEND_TYPES_PATH}",
|
|
219
|
+
file=sys.stderr,
|
|
220
|
+
)
|
|
221
|
+
sys.exit(1)
|
|
222
|
+
|
|
223
|
+
# Data layer — user's backends (sole source of backend definitions).
|
|
224
|
+
self._user = _load_with_includes(user_config_path())
|
|
225
|
+
self._warn_deprecated()
|
|
226
|
+
self._validate()
|
|
227
|
+
|
|
228
|
+
# ── public read-only helpers ──────────────────────────────────────────────
|
|
229
|
+
|
|
230
|
+
@property
|
|
231
|
+
def user_config_path(self) -> str:
|
|
232
|
+
return user_config_path()
|
|
233
|
+
|
|
234
|
+
@property
|
|
235
|
+
def backend_types_path(self) -> str:
|
|
236
|
+
return _BACKEND_TYPES_PATH
|
|
237
|
+
|
|
238
|
+
@property
|
|
239
|
+
def default_backend(self) -> str:
|
|
240
|
+
"""The default backend: first entry in the user config (insertion order)."""
|
|
241
|
+
backends = self._user.get("backends", {})
|
|
242
|
+
if isinstance(backends, dict) and backends:
|
|
243
|
+
return next(iter(backends))
|
|
244
|
+
print("handoff: no backends configured", file=sys.stderr)
|
|
245
|
+
sys.exit(1)
|
|
246
|
+
|
|
247
|
+
@property
|
|
248
|
+
def system_prompt(self) -> str:
|
|
249
|
+
"""User's system_prompt if set, otherwise the built-in default."""
|
|
250
|
+
user_sp = self._user.get("system_prompt")
|
|
251
|
+
if user_sp is not None:
|
|
252
|
+
return user_sp
|
|
253
|
+
return self._backend_types.get("system_prompt", "")
|
|
254
|
+
|
|
255
|
+
@property
|
|
256
|
+
def type_defaults(self) -> dict:
|
|
257
|
+
"""Mechanism-layer type definitions (command, pty, flags). Read-only."""
|
|
258
|
+
return copy.deepcopy(self._backend_types.get("types", {}))
|
|
259
|
+
|
|
260
|
+
@property
|
|
261
|
+
def backends(self) -> dict:
|
|
262
|
+
"""Return the resolved backends dict.
|
|
263
|
+
|
|
264
|
+
Resolution per backend: types[<type>] mechanism fields -> backend's own
|
|
265
|
+
fields from user config (deep merge; lists replaced wholesale). String
|
|
266
|
+
values get ${ENV_VAR} interpolation.
|
|
267
|
+
|
|
268
|
+
Every resolved backend carries a `type` key (defaults to "claude" when
|
|
269
|
+
omitted).
|
|
270
|
+
"""
|
|
271
|
+
raw = self._user.get("backends", {})
|
|
272
|
+
if not isinstance(raw, dict):
|
|
273
|
+
print("handoff: config key 'backends' must be a mapping", file=sys.stderr)
|
|
274
|
+
sys.exit(1)
|
|
275
|
+
|
|
276
|
+
types = self._backend_types.get("types", {}) or {}
|
|
277
|
+
result = {}
|
|
278
|
+
for name, overrides in raw.items():
|
|
279
|
+
if not isinstance(overrides, dict):
|
|
280
|
+
print(f"handoff: backend '{name}' must be a mapping", file=sys.stderr)
|
|
281
|
+
sys.exit(1)
|
|
282
|
+
btype = overrides.get("type", "claude")
|
|
283
|
+
base = types.get(btype)
|
|
284
|
+
if not isinstance(base, dict):
|
|
285
|
+
base = {}
|
|
286
|
+
merged = _deep_merge(base, overrides)
|
|
287
|
+
merged["type"] = btype
|
|
288
|
+
result[name] = _expand_env_vars(merged)
|
|
289
|
+
return result
|
|
290
|
+
|
|
291
|
+
def get_backend(self, name: str) -> Optional[dict]:
|
|
292
|
+
"""Resolve a named backend (returns deep-copied merged dict or None)."""
|
|
293
|
+
backends = self.backends # already merged with type mechanism
|
|
294
|
+
return copy.deepcopy(backends.get(name))
|
|
295
|
+
|
|
296
|
+
def get_config_paths(self) -> list[str]:
|
|
297
|
+
"""Return paths of all config source files (for mtime checks)."""
|
|
298
|
+
paths = [_BACKEND_TYPES_PATH]
|
|
299
|
+
uc = user_config_path()
|
|
300
|
+
if os.path.isfile(uc):
|
|
301
|
+
paths.append(uc)
|
|
302
|
+
return paths
|
|
303
|
+
|
|
304
|
+
# ── validation ────────────────────────────────────────────────────────────
|
|
305
|
+
|
|
306
|
+
def _warn_deprecated(self):
|
|
307
|
+
"""Warn about (and drop) top-level keys that belong to the mechanism
|
|
308
|
+
layer or are otherwise removed. system_prompt is deliberately kept —
|
|
309
|
+
users can override it.
|
|
310
|
+
"""
|
|
311
|
+
for key in _DEPRECATED_KEYS:
|
|
312
|
+
if key in self._user:
|
|
313
|
+
print(
|
|
314
|
+
f"handoff: config key '{key}' is deprecated and was ignored "
|
|
315
|
+
f"(the mechanism layer is now in backend_types.yaml; "
|
|
316
|
+
f"backends carry their own model/pro_model fields)",
|
|
317
|
+
file=sys.stderr,
|
|
318
|
+
)
|
|
319
|
+
self._user.pop(key, None)
|
|
320
|
+
|
|
321
|
+
def _validate(self):
|
|
322
|
+
backends = self._user.get("backends", {})
|
|
323
|
+
if not isinstance(backends, dict) or not backends:
|
|
324
|
+
print(
|
|
325
|
+
"handoff: no backends configured. "
|
|
326
|
+
f"See {_TEMPLATE_URL} for the default template.",
|
|
327
|
+
file=sys.stderr,
|
|
328
|
+
)
|
|
329
|
+
sys.exit(1)
|
|
330
|
+
|
|
331
|
+
types = self._backend_types.get("types", {})
|
|
332
|
+
known_types = list(types.keys()) if isinstance(types, dict) else []
|
|
333
|
+
|
|
334
|
+
for name, overrides in backends.items():
|
|
335
|
+
if not isinstance(overrides, dict):
|
|
336
|
+
continue # reported when resolving
|
|
337
|
+
btype = overrides.get("type", "claude")
|
|
338
|
+
if btype not in types:
|
|
339
|
+
print(
|
|
340
|
+
f"handoff: backend '{name}' has unknown type '{btype}' "
|
|
341
|
+
f"(known: {', '.join(sorted(known_types))})",
|
|
342
|
+
file=sys.stderr,
|
|
343
|
+
)
|
|
344
|
+
sys.exit(1)
|
|
345
|
+
if btype == "claude" and not overrides.get("model"):
|
|
346
|
+
print(
|
|
347
|
+
f"handoff: backend '{name}' (type claude) must have a 'model' field. "
|
|
348
|
+
f"See {_TEMPLATE_URL} for the default template.",
|
|
349
|
+
file=sys.stderr,
|
|
350
|
+
)
|
|
351
|
+
sys.exit(1)
|
cli/core.py
ADDED
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
"""Core shared utilities for handoff.
|
|
2
|
+
|
|
3
|
+
Includes seq_code arithmetic, database operations, formatting helpers,
|
|
4
|
+
automatic migration from the legacy state directory, and shared constants.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import sys
|
|
11
|
+
import sqlite3
|
|
12
|
+
import uuid as _uuid
|
|
13
|
+
import datetime
|
|
14
|
+
import re
|
|
15
|
+
from typing import Optional
|
|
16
|
+
|
|
17
|
+
STATE_DIR = os.path.expanduser("~/.handoff")
|
|
18
|
+
_LEGACY_DIR = os.path.expanduser("~/.ds-cli") # pre-rename state dir, used only for migration
|
|
19
|
+
DB_DIR = os.path.join(STATE_DIR, "runs")
|
|
20
|
+
DB_PATH = os.path.join(DB_DIR, "handoff.db")
|
|
21
|
+
_LEGACY_DB = os.path.join(DB_DIR, "dscli.db") # pre-rename DB name, used only for migration
|
|
22
|
+
TASKS_DIR = os.path.join(STATE_DIR, "tasks")
|
|
23
|
+
_MAX_DAILY = 1035 # ZZ is max seq_code
|
|
24
|
+
|
|
25
|
+
UUID_RE = re.compile(
|
|
26
|
+
r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# ── migration ──────────────────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _migrate_legacy_state():
|
|
34
|
+
"""If the legacy state dir exists and ~/.handoff does not, rename the entire directory.
|
|
35
|
+
|
|
36
|
+
Also migrates the SQLite database file and any WAL/SHM sidecars from
|
|
37
|
+
the old name to handoff.db inside the renamed directory.
|
|
38
|
+
"""
|
|
39
|
+
if not os.path.isdir(_LEGACY_DIR):
|
|
40
|
+
return
|
|
41
|
+
if os.path.isdir(STATE_DIR):
|
|
42
|
+
return
|
|
43
|
+
|
|
44
|
+
print(f"handoff: detected legacy {_LEGACY_DIR}, migrating to {STATE_DIR} ...", file=sys.stderr)
|
|
45
|
+
try:
|
|
46
|
+
os.rename(_LEGACY_DIR, STATE_DIR)
|
|
47
|
+
except OSError as e:
|
|
48
|
+
print(f"handoff: migration rename failed: {e}", file=sys.stderr)
|
|
49
|
+
return
|
|
50
|
+
|
|
51
|
+
# Rename main DB file
|
|
52
|
+
if os.path.isfile(_LEGACY_DB) and not os.path.isfile(DB_PATH):
|
|
53
|
+
os.rename(_LEGACY_DB, DB_PATH)
|
|
54
|
+
# Also move WAL/SHM sidecars
|
|
55
|
+
for suffix in ("-wal", "-shm"):
|
|
56
|
+
old_sidecar = _LEGACY_DB + suffix
|
|
57
|
+
if os.path.isfile(old_sidecar):
|
|
58
|
+
try:
|
|
59
|
+
os.rename(old_sidecar, DB_PATH + suffix)
|
|
60
|
+
except OSError:
|
|
61
|
+
pass
|
|
62
|
+
|
|
63
|
+
print("handoff: migration complete.", file=sys.stderr)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# ── seq_code helpers ──────────────────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def counter_to_seq_code(n: int) -> str:
|
|
70
|
+
"""Convert 1-based daily counter to 2-char seq_code.
|
|
71
|
+
|
|
72
|
+
1..99 → "01".."99"
|
|
73
|
+
100+ → A0, A1..A9, AA..AZ, B0..ZZ
|
|
74
|
+
Raises ValueError if n exceeds ZZ (1035).
|
|
75
|
+
"""
|
|
76
|
+
if n < 1:
|
|
77
|
+
raise ValueError(f"counter must be >= 1, got {n}")
|
|
78
|
+
if n <= 99:
|
|
79
|
+
return f"{n:02d}"
|
|
80
|
+
val = n - 100 # 0-based offset from start of letter encoding
|
|
81
|
+
if val >= 26 * 36:
|
|
82
|
+
raise ValueError(f"counter too large, max {_MAX_DAILY} (ZZ), got {n}")
|
|
83
|
+
first = chr(ord("A") + val // 36)
|
|
84
|
+
r = val % 36
|
|
85
|
+
second = chr(ord("0") + r) if r < 10 else chr(ord("A") + r - 10)
|
|
86
|
+
return first + second
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def seq_code_to_counter(code: str) -> int:
|
|
90
|
+
"""Inverse of counter_to_seq_code: '01' → 1, 'A0' → 100, 'ZZ' → 1035."""
|
|
91
|
+
if len(code) != 2:
|
|
92
|
+
raise ValueError(f"invalid seq_code length: {code!r}")
|
|
93
|
+
if code.isdigit():
|
|
94
|
+
return int(code)
|
|
95
|
+
first, second = code[0], code[1]
|
|
96
|
+
if not ("A" <= first <= "Z"):
|
|
97
|
+
raise ValueError(f"invalid seq_code: {code!r}")
|
|
98
|
+
first_idx = ord(first) - ord("A")
|
|
99
|
+
if "0" <= second <= "9":
|
|
100
|
+
second_idx = ord(second) - ord("0")
|
|
101
|
+
elif "A" <= second <= "Z":
|
|
102
|
+
second_idx = ord(second) - ord("A") + 10
|
|
103
|
+
else:
|
|
104
|
+
raise ValueError(f"invalid seq_code char: {second!r}")
|
|
105
|
+
return 100 + first_idx * 36 + second_idx
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
# ── database ──────────────────────────────────────────────────────────────────
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def get_db():
|
|
112
|
+
"""Open (or create) the SQLite database, return a connection with row_factory set."""
|
|
113
|
+
_migrate_legacy_state()
|
|
114
|
+
os.makedirs(DB_DIR, exist_ok=True)
|
|
115
|
+
os.makedirs(TASKS_DIR, exist_ok=True)
|
|
116
|
+
conn = sqlite3.connect(DB_PATH)
|
|
117
|
+
conn.execute("PRAGMA journal_mode=WAL")
|
|
118
|
+
conn.execute("""
|
|
119
|
+
CREATE TABLE IF NOT EXISTS runs (
|
|
120
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
121
|
+
seq INTEGER NOT NULL,
|
|
122
|
+
seq_code TEXT NOT NULL,
|
|
123
|
+
run_id TEXT NOT NULL UNIQUE,
|
|
124
|
+
run_day TEXT NOT NULL,
|
|
125
|
+
uuid TEXT NOT NULL UNIQUE,
|
|
126
|
+
session_id TEXT,
|
|
127
|
+
cwd TEXT NOT NULL,
|
|
128
|
+
prompt TEXT NOT NULL,
|
|
129
|
+
jsonl_path TEXT NOT NULL,
|
|
130
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime')),
|
|
131
|
+
status TEXT DEFAULT 'running',
|
|
132
|
+
backend TEXT DEFAULT ''
|
|
133
|
+
)
|
|
134
|
+
""")
|
|
135
|
+
conn.execute("""
|
|
136
|
+
CREATE TABLE IF NOT EXISTS run_counters (
|
|
137
|
+
day TEXT PRIMARY KEY,
|
|
138
|
+
last_n INTEGER NOT NULL
|
|
139
|
+
)
|
|
140
|
+
""")
|
|
141
|
+
conn.execute("CREATE INDEX IF NOT EXISTS idx_runs_run_id ON runs(run_id)")
|
|
142
|
+
conn.execute("CREATE INDEX IF NOT EXISTS idx_runs_seq ON runs(seq)")
|
|
143
|
+
conn.execute("CREATE INDEX IF NOT EXISTS idx_runs_created ON runs(created_at)")
|
|
144
|
+
|
|
145
|
+
# Recreate schema if old table is missing new columns (e.g. reset not run)
|
|
146
|
+
try:
|
|
147
|
+
cursor = conn.execute("PRAGMA table_info(runs)")
|
|
148
|
+
cols = {row[1] for row in cursor.fetchall()}
|
|
149
|
+
# In-place migration: add session_id and backfill from uuid for old rows.
|
|
150
|
+
if "session_id" not in cols:
|
|
151
|
+
conn.execute("ALTER TABLE runs ADD COLUMN session_id TEXT")
|
|
152
|
+
conn.execute(
|
|
153
|
+
"UPDATE runs SET session_id = uuid WHERE session_id IS NULL OR session_id = ''"
|
|
154
|
+
)
|
|
155
|
+
cols.add("session_id")
|
|
156
|
+
missing = {"run_id", "seq_code", "run_day", "backend"} - cols
|
|
157
|
+
if missing:
|
|
158
|
+
print(
|
|
159
|
+
f"handoff: schema missing columns {missing}; "
|
|
160
|
+
f"delete ~/.handoff/runs/handoff.db and retry",
|
|
161
|
+
file=sys.stderr,
|
|
162
|
+
)
|
|
163
|
+
sys.exit(2)
|
|
164
|
+
except sqlite3.Error:
|
|
165
|
+
pass
|
|
166
|
+
|
|
167
|
+
conn.commit()
|
|
168
|
+
conn.row_factory = sqlite3.Row
|
|
169
|
+
return conn
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def create_run(
|
|
173
|
+
conn: sqlite3.Connection,
|
|
174
|
+
cwd: str,
|
|
175
|
+
prompt_text: str,
|
|
176
|
+
backend_name: str = "",
|
|
177
|
+
session_id: Optional[str] = None,
|
|
178
|
+
):
|
|
179
|
+
"""Allocate a new run inside a BEGIN IMMEDIATE transaction.
|
|
180
|
+
|
|
181
|
+
Assigns daily counter, seq_code, run_id, uuid, jsonl_path.
|
|
182
|
+
Records the backend name used.
|
|
183
|
+
|
|
184
|
+
`session_id` is the underlying claude session to associate this run with.
|
|
185
|
+
For a fresh run it is None and defaults to the row's own uuid. For a
|
|
186
|
+
`resume` continuation it is the parent conversation's session_id, so the new
|
|
187
|
+
row (new run_id/seq/files) shares one claude session across turns.
|
|
188
|
+
|
|
189
|
+
Returns (run_id, uuid, jsonl_path). Caller must commit/rollback.
|
|
190
|
+
"""
|
|
191
|
+
conn.execute("BEGIN IMMEDIATE")
|
|
192
|
+
today = datetime.date.today()
|
|
193
|
+
today_iso = today.isoformat()
|
|
194
|
+
mmdd = today.strftime("%m%d")
|
|
195
|
+
|
|
196
|
+
row = conn.execute(
|
|
197
|
+
"SELECT last_n FROM run_counters WHERE day = ?", (today_iso,)
|
|
198
|
+
).fetchone()
|
|
199
|
+
n = (row[0] + 1) if row else 1
|
|
200
|
+
|
|
201
|
+
if n > _MAX_DAILY:
|
|
202
|
+
conn.execute("ROLLBACK")
|
|
203
|
+
print("handoff: exceeded maximum daily run count (ZZ = 1035)", file=sys.stderr)
|
|
204
|
+
sys.exit(2)
|
|
205
|
+
|
|
206
|
+
conn.execute(
|
|
207
|
+
"INSERT OR REPLACE INTO run_counters (day, last_n) VALUES (?, ?)",
|
|
208
|
+
(today_iso, n),
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
seq_code = counter_to_seq_code(n)
|
|
212
|
+
run_id = f"hd-{mmdd}-{seq_code}"
|
|
213
|
+
uid = str(_uuid.uuid4()).lower()
|
|
214
|
+
sess = session_id or uid
|
|
215
|
+
jsonl_path = os.path.join(DB_DIR, f"{run_id}-{uid}.jsonl")
|
|
216
|
+
|
|
217
|
+
conn.execute(
|
|
218
|
+
"INSERT INTO runs (seq, seq_code, run_id, run_day, uuid, session_id, cwd, prompt, jsonl_path, backend) "
|
|
219
|
+
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
220
|
+
(n, seq_code, run_id, today_iso, uid, sess, cwd, prompt_text, jsonl_path, backend_name),
|
|
221
|
+
)
|
|
222
|
+
return run_id, uid, jsonl_path
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
# ── helpers ───────────────────────────────────────────────────────────────────
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def short_path(p):
|
|
229
|
+
"""Collapse $HOME to ~/."""
|
|
230
|
+
home = os.path.expanduser("~")
|
|
231
|
+
if p.startswith(home + "/"):
|
|
232
|
+
return "~/" + p[len(home) + 1:]
|
|
233
|
+
if p == home:
|
|
234
|
+
return "~"
|
|
235
|
+
return p
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def prompt_prefix(prompt: Optional[str], width: int = 30) -> str:
|
|
239
|
+
lines = [l for l in (prompt or "").splitlines() if l.strip()]
|
|
240
|
+
first = lines[0].strip() if lines else ""
|
|
241
|
+
return first[:width]
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def format_run_row(row, full_cwd: bool = False) -> dict[str, str]:
|
|
245
|
+
try:
|
|
246
|
+
dt = datetime.datetime.strptime(row["created_at"], "%Y-%m-%d %H:%M:%S")
|
|
247
|
+
if dt.date() == datetime.date.today():
|
|
248
|
+
date_str = dt.strftime("%H:%M")
|
|
249
|
+
else:
|
|
250
|
+
date_str = dt.strftime("%m-%d %H:%M")
|
|
251
|
+
except (ValueError, TypeError):
|
|
252
|
+
date_str = row["created_at"] or "?"
|
|
253
|
+
|
|
254
|
+
cwd = row["cwd"]
|
|
255
|
+
cwd_disp = short_path(cwd) if full_cwd else (os.path.basename(cwd) or cwd)
|
|
256
|
+
return {
|
|
257
|
+
"id": row["run_id"],
|
|
258
|
+
"date": date_str,
|
|
259
|
+
"prompt": prompt_prefix(row["prompt"], 40),
|
|
260
|
+
"cwd": cwd_disp,
|
|
261
|
+
"uuid": row["uuid"],
|
|
262
|
+
"status": row["status"],
|
|
263
|
+
"backend": row_value(row, "backend", "") or "",
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def row_value(row, key: str, default=None):
|
|
268
|
+
"""Read key from sqlite3.Row/dict-like row without assuming dict.get()."""
|
|
269
|
+
try:
|
|
270
|
+
return row[key]
|
|
271
|
+
except (KeyError, IndexError, TypeError):
|
|
272
|
+
return default
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def find_run(conn: sqlite3.Connection, selector: Optional[str]):
|
|
276
|
+
"""Find a run by run_id, numeric seq, or latest."""
|
|
277
|
+
if selector:
|
|
278
|
+
row = conn.execute(
|
|
279
|
+
"SELECT * FROM runs WHERE run_id = ?", (selector,)
|
|
280
|
+
).fetchone()
|
|
281
|
+
if row:
|
|
282
|
+
return row
|
|
283
|
+
try:
|
|
284
|
+
seq = int(selector)
|
|
285
|
+
except ValueError:
|
|
286
|
+
return None
|
|
287
|
+
return conn.execute(
|
|
288
|
+
"SELECT * FROM runs WHERE seq = ? ORDER BY created_at DESC LIMIT 1",
|
|
289
|
+
(seq,),
|
|
290
|
+
).fetchone()
|
|
291
|
+
|
|
292
|
+
return conn.execute("SELECT * FROM runs ORDER BY created_at DESC LIMIT 1").fetchone()
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def task_paths(run_id: str):
|
|
296
|
+
"""Return (prompt, out, result) paths under TASKS_DIR using run_id as basename."""
|
|
297
|
+
os.makedirs(TASKS_DIR, exist_ok=True)
|
|
298
|
+
return (
|
|
299
|
+
os.path.join(TASKS_DIR, f"{run_id}.prompt.txt"),
|
|
300
|
+
os.path.join(TASKS_DIR, f"{run_id}.out.txt"),
|
|
301
|
+
os.path.join(TASKS_DIR, f"{run_id}.result.md"),
|
|
302
|
+
)
|