handoff-cli 0.3.0__tar.gz → 0.3.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.
- {handoff_cli-0.3.0 → handoff_cli-0.3.1}/CLAUDE.md +17 -9
- {handoff_cli-0.3.0 → handoff_cli-0.3.1}/PKG-INFO +1 -1
- {handoff_cli-0.3.0 → handoff_cli-0.3.1}/README.md +17 -29
- {handoff_cli-0.3.0 → handoff_cli-0.3.1}/README.zh-CN.md +17 -16
- handoff_cli-0.3.1/assets/handoff-hero.jpg +0 -0
- handoff_cli-0.3.1/cli/__init__.py +8 -0
- handoff_cli-0.3.1/cli/commands/new.py +88 -0
- {handoff_cli-0.3.0 → handoff_cli-0.3.1}/cli/commands/run.py +81 -7
- {handoff_cli-0.3.0 → handoff_cli-0.3.1}/cli/core.py +110 -15
- {handoff_cli-0.3.0 → handoff_cli-0.3.1}/cli/jsonl_viewer.py +33 -5
- {handoff_cli-0.3.0 → handoff_cli-0.3.1}/cli/main.py +11 -6
- {handoff_cli-0.3.0 → handoff_cli-0.3.1}/cli/skills/handoff-codex/SKILL.md +16 -10
- {handoff_cli-0.3.0 → handoff_cli-0.3.1}/cli/skills/handoff-ds/SKILL.md +16 -10
- {handoff_cli-0.3.0 → handoff_cli-0.3.1}/cli/skills/handoff-ds.toml +7 -7
- {handoff_cli-0.3.0 → handoff_cli-0.3.1}/cli/skills/handoff-opus/SKILL.md +16 -10
- {handoff_cli-0.3.0 → handoff_cli-0.3.1}/cli/user_config_template.yaml +12 -6
- {handoff_cli-0.3.0 → handoff_cli-0.3.1/docs}/TODO.md +2 -2
- {handoff_cli-0.3.0 → handoff_cli-0.3.1}/docs/cli-reference.zh-CN.md +4 -20
- {handoff_cli-0.3.0 → handoff_cli-0.3.1}/docs/design.zh-CN.md +1 -1
- handoff_cli-0.3.1/image-plan.md +60 -0
- {handoff_cli-0.3.0 → handoff_cli-0.3.1}/pyproject.toml +1 -1
- handoff_cli-0.3.0/cli/__init__.py +0 -3
- {handoff_cli-0.3.0 → handoff_cli-0.3.1}/.github/workflows/publish.yml +0 -0
- {handoff_cli-0.3.0 → handoff_cli-0.3.1}/.gitignore +0 -0
- {handoff_cli-0.3.0 → handoff_cli-0.3.1}/assets/claude-code.jpg +0 -0
- {handoff_cli-0.3.0 → handoff_cli-0.3.1}/assets/codex.jpg +0 -0
- {handoff_cli-0.3.0 → handoff_cli-0.3.1}/assets/list-tui.jpg +0 -0
- {handoff_cli-0.3.0 → handoff_cli-0.3.1}/assets/parallel.jpg +0 -0
- {handoff_cli-0.3.0 → handoff_cli-0.3.1}/assets/shell.jpg +0 -0
- {handoff_cli-0.3.0 → handoff_cli-0.3.1}/assets/tail.jpg +0 -0
- {handoff_cli-0.3.0 → handoff_cli-0.3.1}/cli/backend.py +0 -0
- {handoff_cli-0.3.0 → handoff_cli-0.3.1}/cli/backend_types.yaml +0 -0
- {handoff_cli-0.3.0 → handoff_cli-0.3.1}/cli/commands/__init__.py +0 -0
- {handoff_cli-0.3.0 → handoff_cli-0.3.1}/cli/commands/env.py +0 -0
- {handoff_cli-0.3.0 → handoff_cli-0.3.1}/cli/commands/init.py +0 -0
- {handoff_cli-0.3.0 → handoff_cli-0.3.1}/cli/commands/list.py +0 -0
- {handoff_cli-0.3.0 → handoff_cli-0.3.1}/cli/commands/resume.py +0 -0
- {handoff_cli-0.3.0 → handoff_cli-0.3.1}/cli/commands/tail.py +0 -0
- {handoff_cli-0.3.0 → handoff_cli-0.3.1}/cli/config.py +0 -0
- {handoff_cli-0.3.0 → handoff_cli-0.3.1}/cli/jsonl_parser.py +0 -0
- {handoff_cli-0.3.0 → handoff_cli-0.3.1}/cli/stream.py +0 -0
- {handoff_cli-0.3.0 → handoff_cli-0.3.1}/cli/tui.py +0 -0
- {handoff_cli-0.3.0 → handoff_cli-0.3.1}/docs/configuration.zh-CN.md +0 -0
- {handoff_cli-0.3.0 → handoff_cli-0.3.1}/plans/archive/ds-cli-tail-go-cli-commands-go-py-magical-toucan.md +0 -0
- {handoff_cli-0.3.0 → handoff_cli-0.3.1}/plans/archive/handoff/00-overview.md +0 -0
- {handoff_cli-0.3.0 → handoff_cli-0.3.1}/plans/archive/handoff/01-rename-packaging.md +0 -0
- {handoff_cli-0.3.0 → handoff_cli-0.3.1}/plans/archive/handoff/02-multi-backend.md +0 -0
- {handoff_cli-0.3.0 → handoff_cli-0.3.1}/plans/archive/handoff/03-skills-docs-cleanup.md +0 -0
- {handoff_cli-0.3.0 → handoff_cli-0.3.1}/plans/archive/handoff/04-config-split-env.md +0 -0
- {handoff_cli-0.3.0 → handoff_cli-0.3.1}/plans/archive/readme-zh-cn-md-readme-md-screenshot-golden-church.md +0 -0
|
@@ -12,7 +12,14 @@ A CLI proxy that dispatches coding tasks to configurable AI backends — Claude
|
|
|
12
12
|
# Install: uv tool install -e . then run init
|
|
13
13
|
handoff --help
|
|
14
14
|
|
|
15
|
-
#
|
|
15
|
+
# Pre-allocate a run_id and get the canonical prompt file path
|
|
16
|
+
p=$(handoff new --backend deepseek --slug fix-auth)
|
|
17
|
+
cat > "$p" <<'EOF'
|
|
18
|
+
...prompt...
|
|
19
|
+
EOF
|
|
20
|
+
handoff run --backend deepseek "$p"
|
|
21
|
+
|
|
22
|
+
# Other dispatch modes (allocate seq automatically)
|
|
16
23
|
echo "Refactor X and add tests" | handoff run -
|
|
17
24
|
handoff run --text "smoke test"
|
|
18
25
|
handoff run --backend codex --pro - <<'EOF'
|
|
@@ -20,7 +27,7 @@ handoff run --backend codex --pro - <<'EOF'
|
|
|
20
27
|
EOF
|
|
21
28
|
|
|
22
29
|
# Browse/manage past runs
|
|
23
|
-
handoff list # interactive TUI (curses) when stdout is a terminal
|
|
30
|
+
handoff list # interactive TUI (curses) when stdout is a terminal; `handoff ls` is an alias
|
|
24
31
|
handoff resume <seq> # reopen a past conversation interactively in claude/codex
|
|
25
32
|
handoff resume <seq> - # dispatch a follow-up task to that conversation (heredoc)
|
|
26
33
|
handoff tail <run-id> # live-tail a run's output stream
|
|
@@ -40,7 +47,7 @@ There are no test suites or linting setup in this repo.
|
|
|
40
47
|
|
|
41
48
|
### Command dispatch (`cli/main.py`)
|
|
42
49
|
|
|
43
|
-
`main()` parses `sys.argv[1]` and dispatches to the matching `cli/commands/<subcmd>.py`. Known commands: `run`, `list`, `resume`, `tail`, `env`, `init`. Before dispatching, `_migrate_legacy_state()` checks for a legacy `~/.ds-cli/` directory and renames it to `~/.handoff/` if the new directory doesn't exist yet. `env` and `init` do NOT initialize `Config`; other subcommands do (validates user config, creates DB).
|
|
50
|
+
`main()` parses `sys.argv[1]` and dispatches to the matching `cli/commands/<subcmd>.py`. Known commands: `run`, `new`, `list`/`ls`, `resume`, `tail`, `env`, `init`. Before dispatching, `_migrate_legacy_state()` checks for a legacy `~/.ds-cli/` directory and renames it to `~/.handoff/` if the new directory doesn't exist yet. `env` and `init` do NOT initialize `Config`; other subcommands do (validates user config, creates DB).
|
|
44
51
|
|
|
45
52
|
### Config (`cli/config.py`)
|
|
46
53
|
|
|
@@ -50,8 +57,9 @@ Two-layer split: `cli/backend_types.yaml` (mechanism) defines HOW each type is l
|
|
|
50
57
|
|
|
51
58
|
All state lives under `~/.handoff/`:
|
|
52
59
|
- `runs/handoff.db` — SQLite (WAL mode) with `runs` table (seq, run_id, uuid, session_id, cwd, prompt, jsonl_path, status, backend) and `run_counters` (daily auto-increment per day). `session_id` is the underlying conversation: equals `uuid` for a fresh run, or the parent's `session_id` for a `resume` continuation. `get_db()` performs in-place `ALTER TABLE` migrations to add `session_id` (backfilled from `uuid`) on old databases.
|
|
53
|
-
- `tasks/` — per-run files: `{run_id}.prompt.
|
|
54
|
-
- Run IDs:
|
|
60
|
+
- `tasks/` — per-run files: `{run_id}.prompt.md`, `.out.txt` (progress), `.result.md` (final)
|
|
61
|
+
- Run IDs: `<mmdd>-<backend2>-<SEQ_CODE>-<slug>` (e.g. `0611-ds-03-fix-auth`). `<backend2>` is a 2-char abbreviation: deepseek→ds, codex→cx, opus→op, others→first 2 chars. SEQ_CODE is a 2-char encoding: `01`–`99` for 1–99, then `A0`–`ZZ` for 100–1035. `<slug>` is ≤3 dash-separated lowercase words supplied by caller; automatic slugs: stdin→`from-stdin`, --text→`from-text`, external file→`from-file`, no-slug→`task`.
|
|
62
|
+
- `handoff new --backend <name> [--slug <slug>]`: pre-allocates a seq/run_id and prints the canonical `.prompt.md` path (does NOT create the file or DB row). Caller writes prompt to this path, then `handoff run <path>` adopts it.
|
|
55
63
|
- Automatic migration: on startup, if `~/.ds-cli/` exists and `~/.handoff/` doesn't, the entire directory is renamed and `dscli.db` becomes `handoff.db`
|
|
56
64
|
|
|
57
65
|
### Backend resolution (`cli/backend.py`)
|
|
@@ -91,10 +99,10 @@ Textual-based interactive listing for `handoff list`. Renders a scrollable `Data
|
|
|
91
99
|
### Skill/subagent files
|
|
92
100
|
|
|
93
101
|
All files live in `cli/skills/` (distributed with the wheel):
|
|
94
|
-
- `handoff-ds/SKILL.md` — Claude Code skill for DeepSeek backend (`--backend deepseek`)
|
|
95
|
-
- `handoff-codex/SKILL.md` — Claude Code skill for Codex backend (`--backend codex`)
|
|
96
|
-
- `handoff-opus/SKILL.md` — Claude Code skill for Opus backend (`--backend opus`)
|
|
97
|
-
- `handoff-ds.toml` — Codex subagent
|
|
102
|
+
- `handoff-ds/SKILL.md` — Claude Code skill for DeepSeek backend (`--backend deepseek`). Protocol: `handoff new --backend deepseek --slug <slug>` → write prompt to returned path → `handoff run --backend deepseek <path>`
|
|
103
|
+
- `handoff-codex/SKILL.md` — Claude Code skill for Codex backend (`--backend codex`). Same protocol with `--backend codex`.
|
|
104
|
+
- `handoff-opus/SKILL.md` — Claude Code skill for Opus backend (`--backend opus`). Same protocol with `--backend opus`.
|
|
105
|
+
- `handoff-ds.toml` — Codex subagent. Caller runs `handoff new --backend deepseek --slug <slug>`, writes prompt to returned path, passes `PROMPT_FILE=<path>` to subagent; subagent runs `handoff run --backend deepseek <PROMPT_FILE> >/dev/null`
|
|
98
106
|
|
|
99
107
|
`handoff init` creates hard/soft links from `cli/skills/` into `~/.codex/agents/` and `~/.claude/skills/`.
|
|
100
108
|
|
|
@@ -1,23 +1,23 @@
|
|
|
1
1
|
<div align="center">
|
|
2
2
|
|
|
3
3
|
# handoff
|
|
4
|
+
**Your coding agents should be collaborating with each other.**
|
|
4
5
|
|
|
5
|
-
**Your coding agents should be delegating to each other.**
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
No tool-switching, no lost context.
|
|
7
|
+
<img src="assets/handoff-hero.jpg" width="100%" alt="hero">
|
|
8
|
+
|
|
10
9
|
|
|
11
|
-
|
|
12
|
-
|
|
10
|
+
| From | → Hand off to | Why |
|
|
11
|
+
| :-- | :-- | :-- |
|
|
12
|
+
| Claude Code / Codex | **DeepSeek** | Execution work is fast and cheap; save flagship quota for decisions |
|
|
13
|
+
| DeepSeek | **Codex / Opus** | Borrow a brain for hard problems, bring the answer back to your session |
|
|
14
|
+
|
|
15
|
+
No tool-switching, no lost context.
|
|
13
16
|
|
|
14
17
|
**English** · [简体中文](README.zh-CN.md)
|
|
15
18
|
|
|
16
19
|
</div>
|
|
17
20
|
|
|
18
|
-
<!-- assets/claude-code.jpg — ~720px wide — hero shot: one-sentence dispatch in Claude Code, main session only echoes RESULT=, agent reads .result.md and reports back -->
|
|
19
|
-
<img src="assets/claude-code.jpg" width="720" alt="Handing a task off to DeepSeek from Claude Code">
|
|
20
|
-
|
|
21
21
|
## Why handoff
|
|
22
22
|
|
|
23
23
|
If you juggle more than one coding agent, these will sound familiar:
|
|
@@ -63,15 +63,16 @@ handoff init
|
|
|
63
63
|
|
|
64
64
|
## Who you can hand work off to
|
|
65
65
|
|
|
66
|
-
|
|
|
66
|
+
| What you say | From | Hands off to | Best for |
|
|
67
67
|
| --- | --- | --- | --- |
|
|
68
|
-
| `/handoff-ds` |
|
|
69
|
-
|
|
|
70
|
-
| `/handoff-
|
|
68
|
+
| `/handoff-ds` | Claude Code | DeepSeek V4 | Execution work: writing code, running tests, refactors, bulk edits |
|
|
69
|
+
| `handoff-ds` (subagent) | Codex | DeepSeek V4 | Same as above — use this when you're inside Codex |
|
|
70
|
+
| `/handoff-codex` | Claude Code | Codex (GPT-5.5) | Heavy reasoning, second opinions, gnarly debugging |
|
|
71
|
+
| `/handoff-opus` | Claude Code | Claude Opus | Decisions that deserve the top model |
|
|
71
72
|
|
|
72
|
-
> Codex has no slash commands
|
|
73
|
+
> Codex has no slash commands, so that row is the subagent of the same name — say "have `handoff-ds` execute the task above."
|
|
73
74
|
|
|
74
|
-
All three targets work out of the box: opus / codex reuse your local logins with zero config; deepseek only needs a token.
|
|
75
|
+
All three targets work out of the box: opus / codex reuse your local logins with zero config; deepseek only needs a token. Under the hood they run `claude -p` (deepseek via its Anthropic-compatible endpoint, opus via your local login) and `codex exec`.
|
|
75
76
|
|
|
76
77
|
## After a task is dispatched
|
|
77
78
|
|
|
@@ -81,7 +82,7 @@ Dispatching and resuming are the AI's job (`handoff run` / `handoff resume` unde
|
|
|
81
82
|
<tr>
|
|
82
83
|
<td width="50%" valign="top">
|
|
83
84
|
|
|
84
|
-
**`handoff list`** — interactive TUI over your full task history. Read the full prompt, live status, and final result; press `G` on a row to reload that conversation and keep chatting.
|
|
85
|
+
**`handoff list` / `handoff ls`** — interactive TUI over your full task history. Read the full prompt, live status, and final result; press `G` on a row to reload that conversation and keep chatting.
|
|
85
86
|
|
|
86
87
|
</td>
|
|
87
88
|
<td width="50%" valign="top">
|
|
@@ -170,19 +171,6 @@ backends:
|
|
|
170
171
|
...
|
|
171
172
|
```
|
|
172
173
|
|
|
173
|
-
Want another Anthropic-compatible endpoint? Add a block under `backends`:
|
|
174
|
-
|
|
175
|
-
```yaml
|
|
176
|
-
backends:
|
|
177
|
-
kimi:
|
|
178
|
-
type: claude
|
|
179
|
-
model: kimi-k3
|
|
180
|
-
env:
|
|
181
|
-
ANTHROPIC_BASE_URL: https://api.moonshot.cn/anthropic
|
|
182
|
-
ANTHROPIC_AUTH_TOKEN: "${MOONSHOT_API_KEY}"
|
|
183
|
-
ANTHROPIC_MODEL: "{model}"
|
|
184
|
-
```
|
|
185
|
-
|
|
186
174
|
The env block is entirely yours — every key=value you set is exported before the CLI launches. `{model}` substitutes the resolved model name, `${ENV_VAR}` expands from your shell.
|
|
187
175
|
|
|
188
176
|
Run `handoff env` to see where everything lives. Full details: **[configuration docs →](docs/configuration.zh-CN.md)**.
|
|
@@ -1,23 +1,23 @@
|
|
|
1
1
|
<div align="center">
|
|
2
2
|
|
|
3
3
|
# handoff
|
|
4
|
+
**你的 coding agent 们,该互相协作了。**
|
|
4
5
|
|
|
5
|
-
**你的 coding agent 们,该互相派活了。**
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
不用切来切去,也不丢上下文。
|
|
7
|
+
<img src="assets/handoff-hero.jpg" width="100%" alt="hero">
|
|
8
|
+
|
|
10
9
|
|
|
11
|
-
|
|
12
|
-
|
|
10
|
+
| 从 | → 派给 | 为什么 |
|
|
11
|
+
| :-- | :-- | :-- |
|
|
12
|
+
| Claude Code / Codex | **DeepSeek** | 执行性工作又快又便宜,旗舰额度留给决策 |
|
|
13
|
+
| DeepSeek | **Codex / Opus** | 难题借个脑子,答案带回当前会话 |
|
|
14
|
+
|
|
15
|
+
不用切来切去,也不丢上下文。
|
|
13
16
|
|
|
14
17
|
[English](README.md) · **简体中文**
|
|
15
18
|
|
|
16
19
|
</div>
|
|
17
20
|
|
|
18
|
-
<!-- assets/claude-code.jpg — 建议 720 宽 — 主演示图:Claude Code 里一句话派发,主会话只回显 RESULT=,完成后读 .result.md 汇报 -->
|
|
19
|
-
<img src="assets/claude-code.jpg" width="720" alt="在 Claude Code 里把任务 handoff 给 DeepSeek">
|
|
20
|
-
|
|
21
21
|
## 为什么需要 handoff
|
|
22
22
|
|
|
23
23
|
如果你同时用着几家 coding agent,这些场景你一定眼熟:
|
|
@@ -63,15 +63,16 @@ handoff init
|
|
|
63
63
|
|
|
64
64
|
## 可以把活儿派给谁
|
|
65
65
|
|
|
66
|
-
|
|
|
66
|
+
| 你怎么说 | 从 | 派给 | 适合 |
|
|
67
67
|
| --- | --- | --- | --- |
|
|
68
|
-
| `/handoff-ds` |
|
|
69
|
-
|
|
|
70
|
-
| `/handoff-
|
|
68
|
+
| `/handoff-ds` | Claude Code | DeepSeek V4 | 写代码、跑测试、重构、批量修改等执行性工作 |
|
|
69
|
+
| `handoff-ds`(subagent) | Codex | DeepSeek V4 | 同上——你人在 Codex 里时走这条 |
|
|
70
|
+
| `/handoff-codex` | Claude Code | Codex (GPT-5.5) | 复杂推理、第二意见、疑难调试 |
|
|
71
|
+
| `/handoff-opus` | Claude Code | Claude Opus | 需要顶级模型出马的关键决策 |
|
|
71
72
|
|
|
72
|
-
> Codex 里没有 slash
|
|
73
|
+
> Codex 里没有 slash 命令,所以那行是同名 subagent:说「让 `handoff-ds` 执行上述任务」即可。
|
|
73
74
|
|
|
74
|
-
三个目标开箱即用:opus / codex 走你本机的登录态,零配置;deepseek 只需 token
|
|
75
|
+
三个目标开箱即用:opus / codex 走你本机的登录态,零配置;deepseek 只需 token。底层分别是 `claude -p`(deepseek 走其 Anthropic 端点,opus 走本机登录态)和 `codex exec`。
|
|
75
76
|
|
|
76
77
|
## 任务派出去之后
|
|
77
78
|
|
|
@@ -81,7 +82,7 @@ handoff init
|
|
|
81
82
|
<tr>
|
|
82
83
|
<td width="50%" valign="top">
|
|
83
84
|
|
|
84
|
-
**`handoff list`** — 交互式 TUI,浏览全部历史任务。看 prompt 全文、实时状态、最终结果;选中按 `G` 直接把那次会话重新加载进来接着聊。
|
|
85
|
+
**`handoff list` / `handoff ls`** — 交互式 TUI,浏览全部历史任务。看 prompt 全文、实时状态、最终结果;选中按 `G` 直接把那次会话重新加载进来接着聊。
|
|
85
86
|
|
|
86
87
|
</td>
|
|
87
88
|
<td width="50%" valign="top">
|
|
Binary file
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""handoff new command.
|
|
2
|
+
|
|
3
|
+
Pre-allocates a run_id and returns the canonical .prompt.md path so the caller
|
|
4
|
+
can write the prompt directly to its final archive location before dispatching.
|
|
5
|
+
|
|
6
|
+
Usage:
|
|
7
|
+
handoff new --backend <name> [--slug <slug>]
|
|
8
|
+
|
|
9
|
+
Stdout: one line — absolute path to the .prompt.md file (file is NOT created).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
import sys
|
|
16
|
+
import datetime
|
|
17
|
+
|
|
18
|
+
from ..core import (
|
|
19
|
+
get_db,
|
|
20
|
+
alloc_seq,
|
|
21
|
+
backend_abbrev,
|
|
22
|
+
slug_clean,
|
|
23
|
+
TASKS_DIR,
|
|
24
|
+
)
|
|
25
|
+
from ..config import Config
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def cmd_new(argv: list[str], config: Config):
|
|
29
|
+
"""handoff new --backend <name> [--slug <slug>]"""
|
|
30
|
+
backend_arg = ""
|
|
31
|
+
slug_arg = ""
|
|
32
|
+
|
|
33
|
+
i = 0
|
|
34
|
+
while i < len(argv):
|
|
35
|
+
a = argv[i]
|
|
36
|
+
if a == "--backend":
|
|
37
|
+
i += 1
|
|
38
|
+
if i >= len(argv):
|
|
39
|
+
print("handoff new: --backend requires a value", file=sys.stderr)
|
|
40
|
+
sys.exit(2)
|
|
41
|
+
backend_arg = argv[i]
|
|
42
|
+
elif a.startswith("--backend="):
|
|
43
|
+
backend_arg = a.split("=", 1)[1]
|
|
44
|
+
elif a == "--slug":
|
|
45
|
+
i += 1
|
|
46
|
+
if i >= len(argv):
|
|
47
|
+
print("handoff new: --slug requires a value", file=sys.stderr)
|
|
48
|
+
sys.exit(2)
|
|
49
|
+
slug_arg = argv[i]
|
|
50
|
+
elif a.startswith("--slug="):
|
|
51
|
+
slug_arg = a.split("=", 1)[1]
|
|
52
|
+
elif a in ("-h", "--help"):
|
|
53
|
+
from ..main import usage
|
|
54
|
+
usage()
|
|
55
|
+
sys.exit(0)
|
|
56
|
+
elif a.startswith("-"):
|
|
57
|
+
print(f"handoff new: unknown option {a}", file=sys.stderr)
|
|
58
|
+
sys.exit(2)
|
|
59
|
+
i += 1
|
|
60
|
+
|
|
61
|
+
backend_name = backend_arg or config.default_backend
|
|
62
|
+
if not backend_name:
|
|
63
|
+
print("handoff new: --backend is required (or set a default backend in config)", file=sys.stderr)
|
|
64
|
+
sys.exit(2)
|
|
65
|
+
|
|
66
|
+
# Validate backend exists in config
|
|
67
|
+
backend_cfg = config.get_backend(backend_name)
|
|
68
|
+
if not backend_cfg:
|
|
69
|
+
print(
|
|
70
|
+
f"handoff new: unknown backend '{backend_name}'. "
|
|
71
|
+
f"Available: {', '.join(sorted(config.backends.keys()))}",
|
|
72
|
+
file=sys.stderr,
|
|
73
|
+
)
|
|
74
|
+
sys.exit(2)
|
|
75
|
+
|
|
76
|
+
b2 = backend_abbrev(backend_name)
|
|
77
|
+
clean_slug = slug_clean(slug_arg) if slug_arg else "task"
|
|
78
|
+
|
|
79
|
+
mmdd = datetime.date.today().strftime("%m%d")
|
|
80
|
+
|
|
81
|
+
conn = get_db()
|
|
82
|
+
_n, seq_code = alloc_seq(conn)
|
|
83
|
+
conn.close()
|
|
84
|
+
|
|
85
|
+
run_id = f"{mmdd}-{b2}-{seq_code}-{clean_slug}"
|
|
86
|
+
prompt_path = os.path.join(TASKS_DIR, f"{run_id}.prompt.md")
|
|
87
|
+
|
|
88
|
+
print(prompt_path)
|
|
@@ -6,7 +6,10 @@ import os
|
|
|
6
6
|
import sys
|
|
7
7
|
import datetime
|
|
8
8
|
|
|
9
|
-
from ..core import
|
|
9
|
+
from ..core import (
|
|
10
|
+
get_db, create_run, task_paths, UUID_RE,
|
|
11
|
+
TASKS_DIR, parse_new_run_id, backend_abbrev,
|
|
12
|
+
)
|
|
10
13
|
from ..backend import (
|
|
11
14
|
set_backend_env,
|
|
12
15
|
build_args,
|
|
@@ -19,6 +22,21 @@ from ..stream import execute_run
|
|
|
19
22
|
from ..config import Config
|
|
20
23
|
|
|
21
24
|
|
|
25
|
+
def _is_adopted_path(input_src: str) -> bool:
|
|
26
|
+
"""Return True if input_src is a .prompt.md file inside TASKS_DIR with new run_id format."""
|
|
27
|
+
if not input_src or input_src == "-":
|
|
28
|
+
return False
|
|
29
|
+
abs_src = os.path.abspath(input_src)
|
|
30
|
+
tasks_dir = os.path.abspath(TASKS_DIR)
|
|
31
|
+
if not abs_src.startswith(tasks_dir + os.sep):
|
|
32
|
+
return False
|
|
33
|
+
basename = os.path.basename(abs_src)
|
|
34
|
+
if not basename.endswith(".prompt.md"):
|
|
35
|
+
return False
|
|
36
|
+
stem = basename[: -len(".prompt.md")]
|
|
37
|
+
return parse_new_run_id(stem) is not None
|
|
38
|
+
|
|
39
|
+
|
|
22
40
|
def cmd_run(argv: list[str], config: Config):
|
|
23
41
|
"""handoff run [--backend <name>] [--cwd <dir>] [--pro] (<input-file|-> | --text <prompt...>)."""
|
|
24
42
|
pro = False
|
|
@@ -95,6 +113,9 @@ def cmd_run(argv: list[str], config: Config):
|
|
|
95
113
|
print(f"handoff run: cwd not found: {cwd}", file=sys.stderr)
|
|
96
114
|
sys.exit(2)
|
|
97
115
|
|
|
116
|
+
# Determine prompt source and whether to adopt a pre-allocated run_id.
|
|
117
|
+
adopted_run_id: str | None = None
|
|
118
|
+
|
|
98
119
|
if text_mode:
|
|
99
120
|
if not text_parts:
|
|
100
121
|
print("handoff run: --text requires a value", file=sys.stderr)
|
|
@@ -103,21 +124,48 @@ def cmd_run(argv: list[str], config: Config):
|
|
|
103
124
|
if not prompt_text:
|
|
104
125
|
print("handoff run: --text requires a non-empty value", file=sys.stderr)
|
|
105
126
|
sys.exit(2)
|
|
127
|
+
slug = "from-text"
|
|
106
128
|
elif input_src == "-" or (not input_src and not sys.stdin.isatty()):
|
|
107
129
|
prompt_text = sys.stdin.read()
|
|
130
|
+
slug = "from-stdin"
|
|
108
131
|
elif input_src:
|
|
109
132
|
if not os.path.isfile(input_src):
|
|
110
133
|
print(f"handoff run: input file not found: {input_src}", file=sys.stderr)
|
|
111
134
|
sys.exit(2)
|
|
112
|
-
|
|
113
|
-
|
|
135
|
+
|
|
136
|
+
if _is_adopted_path(input_src):
|
|
137
|
+
# Adopt: file is already at the canonical tasks/ path with new format.
|
|
138
|
+
stem = os.path.basename(input_src)[: -len(".prompt.md")]
|
|
139
|
+
parsed = parse_new_run_id(stem) # guaranteed non-None by _is_adopted_path
|
|
140
|
+
_mmdd, file_b2, _seq_code, _slug = parsed # type: ignore[misc]
|
|
141
|
+
|
|
142
|
+
# Validate backend2 consistency.
|
|
143
|
+
backend_name_candidate = backend_arg or config.default_backend
|
|
144
|
+
expected_b2 = backend_abbrev(backend_name_candidate)
|
|
145
|
+
if file_b2 != expected_b2:
|
|
146
|
+
print(
|
|
147
|
+
f"handoff run: adopted file has backend '{file_b2}' but "
|
|
148
|
+
f"--backend resolves to '{backend_name_candidate}' (abbrev '{expected_b2}'). "
|
|
149
|
+
f"Use --backend matching the file's backend.",
|
|
150
|
+
file=sys.stderr,
|
|
151
|
+
)
|
|
152
|
+
sys.exit(2)
|
|
153
|
+
|
|
154
|
+
with open(input_src) as f:
|
|
155
|
+
prompt_text = f.read()
|
|
156
|
+
adopted_run_id = stem
|
|
157
|
+
slug = _slug # unused when adopting, but kept for clarity
|
|
158
|
+
else:
|
|
159
|
+
with open(input_src) as f:
|
|
160
|
+
prompt_text = f.read()
|
|
161
|
+
slug = "from-file"
|
|
114
162
|
else:
|
|
115
163
|
print("handoff run: input file required, or use --text <prompt...> / pipe via '-'", file=sys.stderr)
|
|
116
164
|
sys.exit(2)
|
|
117
165
|
|
|
118
166
|
backend_name = backend_arg or config.default_backend
|
|
119
167
|
|
|
120
|
-
_execute(cwd, prompt_text, backend_name, pro, config)
|
|
168
|
+
_execute(cwd, prompt_text, backend_name, pro, config, slug=slug, adopted_run_id=adopted_run_id)
|
|
121
169
|
|
|
122
170
|
|
|
123
171
|
def _execute(
|
|
@@ -127,6 +175,8 @@ def _execute(
|
|
|
127
175
|
pro: bool,
|
|
128
176
|
config: Config,
|
|
129
177
|
resume_session_id: str | None = None,
|
|
178
|
+
slug: str = "task",
|
|
179
|
+
adopted_run_id: str | None = None,
|
|
130
180
|
):
|
|
131
181
|
"""Shared execution path for file, stdin, and --text run modes.
|
|
132
182
|
|
|
@@ -134,6 +184,10 @@ def _execute(
|
|
|
134
184
|
claude conversation (`claude -p ... --resume <id>`) rather than starting a
|
|
135
185
|
fresh session; the new row still gets its own run_id/seq/files but shares the
|
|
136
186
|
session_id. Used by `handoff resume <seq> <prompt>`.
|
|
187
|
+
|
|
188
|
+
When `adopted_run_id` is given, the pre-allocated run_id from `handoff new`
|
|
189
|
+
is adopted: the seq counter is NOT re-incremented, and the prompt file is
|
|
190
|
+
already at the canonical tasks/ path (not written again).
|
|
137
191
|
"""
|
|
138
192
|
backend_cfg = config.get_backend(backend_name)
|
|
139
193
|
if not backend_cfg:
|
|
@@ -147,16 +201,36 @@ def _execute(
|
|
|
147
201
|
ensure_backend_token_ready(backend_name, backend_cfg, config.user_config_path)
|
|
148
202
|
|
|
149
203
|
conn = get_db()
|
|
204
|
+
|
|
205
|
+
# Check for duplicate dispatch when adopting a pre-allocated run_id.
|
|
206
|
+
if adopted_run_id:
|
|
207
|
+
existing = conn.execute(
|
|
208
|
+
"SELECT run_id FROM runs WHERE run_id = ?", (adopted_run_id,)
|
|
209
|
+
).fetchone()
|
|
210
|
+
if existing:
|
|
211
|
+
conn.close()
|
|
212
|
+
print(
|
|
213
|
+
f"handoff run: run_id '{adopted_run_id}' already exists in DB — "
|
|
214
|
+
f"duplicate dispatch rejected.",
|
|
215
|
+
file=sys.stderr,
|
|
216
|
+
)
|
|
217
|
+
sys.exit(2)
|
|
218
|
+
|
|
150
219
|
run_id, uid, jsonl_path = create_run(
|
|
151
|
-
conn, cwd, prompt_text, backend_name,
|
|
220
|
+
conn, cwd, prompt_text, backend_name,
|
|
221
|
+
session_id=resume_session_id,
|
|
222
|
+
slug=slug,
|
|
223
|
+
run_id_override=adopted_run_id,
|
|
152
224
|
)
|
|
153
225
|
conn.commit()
|
|
154
226
|
|
|
155
227
|
# tasks dir files
|
|
156
228
|
prompt_path, out_path, result_path = task_paths(run_id)
|
|
157
229
|
|
|
158
|
-
|
|
159
|
-
|
|
230
|
+
# Write prompt file only when not adopting (adopted file is already in place).
|
|
231
|
+
if not adopted_run_id:
|
|
232
|
+
with open(prompt_path, "w") as pf:
|
|
233
|
+
pf.write(prompt_text)
|
|
160
234
|
|
|
161
235
|
# Resolve model
|
|
162
236
|
model = resolve_backend_model(backend_cfg, pro)
|
|
@@ -26,6 +26,50 @@ UUID_RE = re.compile(
|
|
|
26
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
27
|
)
|
|
28
28
|
|
|
29
|
+
# New run_id format: <mmdd>-<backend2>-<SEQ_CODE>-<slug>
|
|
30
|
+
# e.g. 0611-ds-03-fix-auth
|
|
31
|
+
_NEW_RUN_ID_RE = re.compile(
|
|
32
|
+
r"^(\d{4})-([a-z]{2})-([0-9A-Z]{2})-(.+)$"
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
# Explicit backend abbreviation mapping; others fall back to first 2 chars of name
|
|
36
|
+
_BACKEND_ABBREV: dict[str, str] = {
|
|
37
|
+
"deepseek": "ds",
|
|
38
|
+
"codex": "cx",
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def backend_abbrev(backend_name: str) -> str:
|
|
43
|
+
"""Return 2-char abbreviation for a backend name."""
|
|
44
|
+
name = backend_name.lower()
|
|
45
|
+
return _BACKEND_ABBREV.get(name, name[:2])
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def slug_clean(raw: str) -> str:
|
|
49
|
+
"""Sanitise a user-supplied slug: lowercase, only [a-z0-9-], max 3 dash-separated words.
|
|
50
|
+
|
|
51
|
+
Returns 'task' if the input is empty after cleaning.
|
|
52
|
+
"""
|
|
53
|
+
s = raw.lower()
|
|
54
|
+
s = re.sub(r"[^a-z0-9-]", "-", s)
|
|
55
|
+
s = re.sub(r"-+", "-", s).strip("-")
|
|
56
|
+
# Keep at most 3 words (segments separated by '-')
|
|
57
|
+
parts = s.split("-")
|
|
58
|
+
parts = [p for p in parts if p]
|
|
59
|
+
s = "-".join(parts[:3])
|
|
60
|
+
return s or "task"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def parse_new_run_id(stem: str) -> Optional[tuple[str, str, str, str]]:
|
|
64
|
+
"""Parse a new-format run_id stem.
|
|
65
|
+
|
|
66
|
+
Returns (mmdd, backend2, seq_code, slug) or None if it doesn't match.
|
|
67
|
+
"""
|
|
68
|
+
m = _NEW_RUN_ID_RE.match(stem)
|
|
69
|
+
if not m:
|
|
70
|
+
return None
|
|
71
|
+
return m.group(1), m.group(2), m.group(3), m.group(4)
|
|
72
|
+
|
|
29
73
|
|
|
30
74
|
# ── migration ──────────────────────────────────────────────────────────────────
|
|
31
75
|
|
|
@@ -175,6 +219,8 @@ def create_run(
|
|
|
175
219
|
prompt_text: str,
|
|
176
220
|
backend_name: str = "",
|
|
177
221
|
session_id: Optional[str] = None,
|
|
222
|
+
slug: str = "task",
|
|
223
|
+
run_id_override: Optional[str] = None,
|
|
178
224
|
):
|
|
179
225
|
"""Allocate a new run inside a BEGIN IMMEDIATE transaction.
|
|
180
226
|
|
|
@@ -186,6 +232,12 @@ def create_run(
|
|
|
186
232
|
`resume` continuation it is the parent conversation's session_id, so the new
|
|
187
233
|
row (new run_id/seq/files) shares one claude session across turns.
|
|
188
234
|
|
|
235
|
+
`slug` is appended to the run_id (≤3 dash-separated words, cleaned by slug_clean).
|
|
236
|
+
|
|
237
|
+
`run_id_override` is used when adopting a pre-allocated run_id from `handoff new`.
|
|
238
|
+
When set, the seq counter is NOT incremented — the counter was already bumped by
|
|
239
|
+
`new`. The seq and seq_code are extracted from the override run_id.
|
|
240
|
+
|
|
189
241
|
Returns (run_id, uuid, jsonl_path). Caller must commit/rollback.
|
|
190
242
|
"""
|
|
191
243
|
conn.execute("BEGIN IMMEDIATE")
|
|
@@ -193,23 +245,42 @@ def create_run(
|
|
|
193
245
|
today_iso = today.isoformat()
|
|
194
246
|
mmdd = today.strftime("%m%d")
|
|
195
247
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
248
|
+
if run_id_override:
|
|
249
|
+
# Adopt a pre-allocated run_id. Parse seq_code from it to fill seq.
|
|
250
|
+
parsed = parse_new_run_id(run_id_override)
|
|
251
|
+
if parsed is None:
|
|
252
|
+
conn.execute("ROLLBACK")
|
|
253
|
+
print(f"handoff: cannot parse adopted run_id '{run_id_override}'", file=sys.stderr)
|
|
254
|
+
sys.exit(2)
|
|
255
|
+
_mmdd, _b2, seq_code, _slug = parsed
|
|
256
|
+
try:
|
|
257
|
+
n = seq_code_to_counter(seq_code)
|
|
258
|
+
except ValueError:
|
|
259
|
+
conn.execute("ROLLBACK")
|
|
260
|
+
print(f"handoff: invalid seq_code in run_id '{run_id_override}'", file=sys.stderr)
|
|
261
|
+
sys.exit(2)
|
|
262
|
+
run_id = run_id_override
|
|
263
|
+
else:
|
|
264
|
+
row = conn.execute(
|
|
265
|
+
"SELECT last_n FROM run_counters WHERE day = ?", (today_iso,)
|
|
266
|
+
).fetchone()
|
|
267
|
+
n = (row[0] + 1) if row else 1
|
|
200
268
|
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
269
|
+
if n > _MAX_DAILY:
|
|
270
|
+
conn.execute("ROLLBACK")
|
|
271
|
+
print("handoff: exceeded maximum daily run count (ZZ = 1035)", file=sys.stderr)
|
|
272
|
+
sys.exit(2)
|
|
205
273
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
274
|
+
conn.execute(
|
|
275
|
+
"INSERT OR REPLACE INTO run_counters (day, last_n) VALUES (?, ?)",
|
|
276
|
+
(today_iso, n),
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
seq_code = counter_to_seq_code(n)
|
|
280
|
+
b2 = backend_abbrev(backend_name) if backend_name else "xx"
|
|
281
|
+
clean = slug_clean(slug)
|
|
282
|
+
run_id = f"{mmdd}-{b2}-{seq_code}-{clean}"
|
|
210
283
|
|
|
211
|
-
seq_code = counter_to_seq_code(n)
|
|
212
|
-
run_id = f"hd-{mmdd}-{seq_code}"
|
|
213
284
|
uid = str(_uuid.uuid4()).lower()
|
|
214
285
|
sess = session_id or uid
|
|
215
286
|
jsonl_path = os.path.join(DB_DIR, f"{run_id}-{uid}.jsonl")
|
|
@@ -296,7 +367,31 @@ def task_paths(run_id: str):
|
|
|
296
367
|
"""Return (prompt, out, result) paths under TASKS_DIR using run_id as basename."""
|
|
297
368
|
os.makedirs(TASKS_DIR, exist_ok=True)
|
|
298
369
|
return (
|
|
299
|
-
os.path.join(TASKS_DIR, f"{run_id}.prompt.
|
|
370
|
+
os.path.join(TASKS_DIR, f"{run_id}.prompt.md"),
|
|
300
371
|
os.path.join(TASKS_DIR, f"{run_id}.out.txt"),
|
|
301
372
|
os.path.join(TASKS_DIR, f"{run_id}.result.md"),
|
|
302
373
|
)
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def alloc_seq(conn: sqlite3.Connection) -> tuple[int, str]:
|
|
377
|
+
"""Atomically increment today's run counter and return (n, seq_code).
|
|
378
|
+
|
|
379
|
+
Used by `handoff new` to pre-allocate a seq without creating a run row.
|
|
380
|
+
Caller must hold (or acquire) a transaction.
|
|
381
|
+
"""
|
|
382
|
+
today = datetime.date.today().isoformat()
|
|
383
|
+
conn.execute("BEGIN IMMEDIATE")
|
|
384
|
+
row = conn.execute(
|
|
385
|
+
"SELECT last_n FROM run_counters WHERE day = ?", (today,)
|
|
386
|
+
).fetchone()
|
|
387
|
+
n = (row[0] + 1) if row else 1
|
|
388
|
+
if n > _MAX_DAILY:
|
|
389
|
+
conn.execute("ROLLBACK")
|
|
390
|
+
print("handoff: exceeded maximum daily run count (ZZ = 1035)", file=sys.stderr)
|
|
391
|
+
sys.exit(2)
|
|
392
|
+
conn.execute(
|
|
393
|
+
"INSERT OR REPLACE INTO run_counters (day, last_n) VALUES (?, ?)",
|
|
394
|
+
(today, n),
|
|
395
|
+
)
|
|
396
|
+
conn.commit()
|
|
397
|
+
return n, counter_to_seq_code(n)
|