@turmind/halo 1.5.7 → 1.5.9

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. package/admin-out/404.html +1 -1
  2. package/admin-out/_next/static/chunks/220.7566bc89f3d98993.js +1 -0
  3. package/admin-out/_next/static/chunks/597-399e2e1de26aa747.js +1 -0
  4. package/admin-out/_next/static/chunks/989-c404f6f43c400911.js +1 -0
  5. package/admin-out/_next/static/chunks/app/page-59bfce35bbb30b05.js +1 -0
  6. package/admin-out/_next/static/chunks/{webpack-edf248e07333566d.js → webpack-efd27999305b080a.js} +1 -1
  7. package/admin-out/_next/static/css/27b44c85485eabb9.css +3 -0
  8. package/admin-out/index.html +1 -1
  9. package/admin-out/index.txt +5 -5
  10. package/bundled-docs/design/architecture.md +3 -2
  11. package/bundled-docs/design/canvas-extensions.md +51 -11
  12. package/bundled-docs/design/cron.md +6 -6
  13. package/bundled-docs/design/evolution.md +47 -15
  14. package/bundled-docs/design/express-self.md +96 -20
  15. package/bundled-docs/design/storage.md +10 -2
  16. package/bundled-docs/design/ws.md +1 -0
  17. package/bundled-docs/dev/add-model-provider.md +16 -7
  18. package/bundled-docs/dev/api.md +7 -6
  19. package/bundled-docs/dev/desktop-packaging.md +26 -0
  20. package/bundled-docs/guide/agents.md +3 -3
  21. package/bundled-docs/guide/chat.md +3 -0
  22. package/bundled-docs/guide/cli.md +10 -1
  23. package/bundled-docs/guide/workspace.md +2 -2
  24. package/bundled-docs/requirements/agent.md +1 -1
  25. package/bundled-docs/requirements/chat.md +17 -9
  26. package/bundled-docs/requirements/editor.md +3 -1
  27. package/bundled-docs/requirements/explorer.md +4 -2
  28. package/bundled-docs/requirements/settings.md +11 -3
  29. package/bundled-docs/requirements/workspace.md +2 -2
  30. package/dist/index.js +2514 -1669
  31. package/package.json +2 -1
  32. package/templates/agents/__evo_agent__/AGENT.md +7 -0
  33. package/templates/agents/__score__/AGENT.md +183 -71
  34. package/templates/canvas/self.html +617 -71
  35. package/templates/models/anthropic.yaml +1 -0
  36. package/templates/models/aws-bedrock-claude-invoke.yaml +1 -0
  37. package/templates/models/aws-bedrock-mantle.yaml +1 -0
  38. package/templates/models/aws-bedrock-openai.yaml +1 -0
  39. package/templates/models/deepseek.yaml +1 -0
  40. package/templates/models/doubao.yaml +1 -0
  41. package/templates/models/hunyuan.yaml +1 -0
  42. package/templates/models/kimi.yaml +1 -0
  43. package/templates/models/mimo-token-plan-china.yaml +1 -0
  44. package/templates/models/minimax.yaml +1 -0
  45. package/templates/models/openai.yaml +1 -0
  46. package/templates/models/qwen.yaml +1 -0
  47. package/templates/models/zhipu.yaml +1 -0
  48. package/templates/skills/cron/SKILL.md +59 -4
  49. package/templates/skills/cron/manage-cron.py +158 -24
  50. package/templates/skills/extension/SKILL.md +30 -2
  51. package/templates/skills/extension/config.yaml +2 -2
  52. package/templates/skills/extension/templates/ext.sh +69 -16
  53. package/templates/skills/self/SKILL.md +79 -17
  54. package/admin-out/_next/static/chunks/220.e250bbbf65d24094.js +0 -1
  55. package/admin-out/_next/static/chunks/422-cc74b1fdd3af5415.js +0 -1
  56. package/admin-out/_next/static/chunks/989-e0faeff1c4a0b0b9.js +0 -1
  57. package/admin-out/_next/static/chunks/app/page-44f707ea8dad57da.js +0 -1
  58. package/admin-out/_next/static/css/b8c87b919758c52b.css +0 -3
  59. /package/admin-out/_next/static/{rTD3zkMvzwtqUwzhz6QJH → RD5w3FmDZ6HtyQGGaUb8t}/_buildManifest.js +0 -0
  60. /package/admin-out/_next/static/{rTD3zkMvzwtqUwzhz6QJH → RD5w3FmDZ6HtyQGGaUb8t}/_ssgManifest.js +0 -0
@@ -1,5 +1,6 @@
1
1
  id: anthropic
2
2
  runtime: anthropic-messages
3
+ revision: 2026100501
3
4
  displayName: Anthropic (Generic)
4
5
  displayName_zh: Anthropic(通用)
5
6
  description: Generic Anthropic Messages API. Use for api.anthropic.com or any Anthropic-compatible third-party gateway.
@@ -1,5 +1,6 @@
1
1
  id: aws-bedrock-claude-invoke
2
2
  runtime: bedrock-invoke
3
+ revision: 2026100501
3
4
  displayName: AWS Bedrock Claude (Invoke API)
4
5
  displayName_zh: AWS Bedrock Claude(Invoke API)
5
6
  description: Invokes Bedrock via InvokeModel (non-streaming), sends raw Anthropic Messages API format
@@ -1,5 +1,6 @@
1
1
  id: aws-bedrock-mantle
2
2
  runtime: bedrock-mantle
3
+ revision: 2026100501
3
4
  displayName: AWS Bedrock Mantle (OpenAI GPT-6.1 / 6 / 5.6)
4
5
  displayName_zh: AWS Bedrock Mantle(OpenAI GPT-6.1 / 6 / 5.6)
5
6
  description: OpenAI GPT-6.1 Sol, GPT-6 (Astra / Sol / Luna) and GPT-5.6 (Sol / Terra / Luna) on Amazon Bedrock via the bedrock-mantle endpoint (OpenAI Responses API only — not Converse/InvokeModel). Region-split — GPT-6.1 Sol and GPT-6 Sol / Luna are on us-east-1 only, Astra on us-west-2 only.
@@ -1,5 +1,6 @@
1
1
  id: aws-bedrock-openai
2
2
  runtime: bedrock-mantle
3
+ revision: 2026100501
3
4
  displayName: AWS Bedrock OpenAI-compatible (GPT-6.1 / 6 / 5.6 · Grok 4.6 · Kimi K3)
4
5
  displayName_zh: AWS Bedrock OpenAI 兼容接口(GPT-6.1 / 6 / 5.6 · Grok 4.6 · Kimi K3)
5
6
  description: Frontier models on Amazon Bedrock via the bedrock-runtime endpoint's OpenAI Responses API (/openai/v1) — OpenAI GPT-6.1 Sol, GPT-6 (Astra / Sol / Luna) and GPT-5.6 (Sol / Terra / Luna), xAI Grok 4.6, Moonshot Kimi K3. Cross-Region inference profiles (global.*) only — every region serves every model, no region split. No server-side web_search (that is Mantle-only).
@@ -1,5 +1,6 @@
1
1
  id: deepseek
2
2
  runtime: deepseek
3
+ revision: 2026100501
3
4
  displayName: DeepSeek
4
5
  displayName_zh: DeepSeek
5
6
  description: DeepSeek V4 via OpenAI-compatible chat completions API
@@ -1,5 +1,6 @@
1
1
  id: doubao
2
2
  runtime: doubao
3
+ revision: 2026100501
3
4
  displayName: Doubao (Volcengine)
4
5
  displayName_zh: 豆包(火山方舟)
5
6
  description: ByteDance Doubao Seed-2.0 family on Volcengine Ark — pro / lite / mini / code, OpenAI-compatible
@@ -1,5 +1,6 @@
1
1
  id: hunyuan
2
2
  runtime: hunyuan
3
+ revision: 2026100501
3
4
  displayName: Hunyuan (Tencent)
4
5
  displayName_zh: 混元(腾讯)
5
6
  description: Tencent Hunyuan Hy3 preview — 295B/21B MoE, agent-oriented with no_think / think_low / think_high modes
@@ -1,5 +1,6 @@
1
1
  id: kimi
2
2
  runtime: kimi
3
+ revision: 2026100501
3
4
  displayName: Kimi (Moonshot AI)
4
5
  displayName_zh: Kimi(月之暗面)
5
6
  description: Kimi K3 / K2.6 via OpenAI-compatible chat completions API
@@ -1,5 +1,6 @@
1
1
  id: mimo-token-plan-china
2
2
  runtime: anthropic-messages
3
+ revision: 2026100501
3
4
  displayName: Xiaomi MiMo
4
5
  displayName_zh: 小米 MiMo
5
6
  description: Xiaomi MiMo via its Anthropic-compatible gateway — Token Plan (China / Singapore / Europe clusters) or pay-as-you-go. Supports 1M context window.
@@ -1,5 +1,6 @@
1
1
  id: minimax
2
2
  runtime: minimax
3
+ revision: 2026100501
3
4
  displayName: MiniMax
4
5
  displayName_zh: MiniMax
5
6
  description: MiniMax M3 / M2.7 family via the Anthropic-compatible Messages API
@@ -1,5 +1,6 @@
1
1
  id: openai
2
2
  runtime: openai-chat
3
+ revision: 2026100501
3
4
  displayName: OpenAI (Generic)
4
5
  displayName_zh: OpenAI(通用)
5
6
  description: Generic OpenAI-compatible chat completions. Use for native OpenAI or any third-party OpenAI-compatible gateway. Capabilities are exposed openly — the user is expected to know what their endpoint supports.
@@ -1,5 +1,6 @@
1
1
  id: qwen
2
2
  runtime: qwen
3
+ revision: 2026100501
3
4
  displayName: Qwen (Aliyun)
4
5
  displayName_zh: 通义千问(阿里云)
5
6
  description: Qwen3 family on Aliyun Bailian via the Anthropic-compatible Messages API
@@ -1,5 +1,6 @@
1
1
  id: zhipu
2
2
  runtime: zhipu
3
+ revision: 2026100501
3
4
  displayName: Zhipu AI (GLM)
4
5
  displayName_zh: 智谱 AI(GLM)
5
6
  description: Zhipu GLM-5.x family on open.bigmodel.cn, OpenAI-compatible chat completions
@@ -38,6 +38,12 @@ loop). No restart needed after edits.
38
38
  the result back to the current chat with `{{channel.chat_id}}` — the user
39
39
  almost always means "send it to me here"
40
40
  - **edit / pause / resume** → `update`, `enable`, `disable`
41
+ - **skip / pause until a date / resume** ("no A-share report over the holiday",
42
+ "skip this week", "send until month end", "start again") → `update <id>
43
+ --active-from …` / `--active-until …` (see *Active window* in step 2).
44
+ A pause with a known end date goes through `--active-from`, **not**
45
+ `disable` — a disabled job only comes back if someone remembers to
46
+ re-enable it
41
47
  - **delete / remove** → `delete`. From inside a channel the user won't
42
48
  know the cron `id`; first run `list --chat-id {{channel.chat_id}}` to find
43
49
  the matching job, confirm which one, then delete by id
@@ -54,7 +60,7 @@ Sensible defaults if user didn't say:
54
60
  | targets | none (log only — the run shows in admin Cron tab) |
55
61
  | label | summarize from prompt + schedule |
56
62
  | timeout | leave unset (3600s) — see `--timeout-sec` below |
57
- | session | leave unset (the job's own `cron-<jobId>`) — see `--session` below |
63
+ | session | leave unset (the job's own session) — see `--session` below |
58
64
  | prompt | pass the user's task as-is — the runner prepends an "unattended run, don't ask questions" notice to every fire, so don't add one yourself |
59
65
 
60
66
  ### 2. Translate the schedule
@@ -89,7 +95,9 @@ heavy analysis run that needs more than an hour (`--timeout-sec 7200`). On
89
95
  `update`, pass `--timeout-sec ""` to clear back to the default.
90
96
 
91
97
  **Run inside an existing session (`--session`, optional)** — by default every
92
- job accumulates its own `cron-<jobId>` session. Pass `--session <rootSessionId>`
98
+ job accumulates its own session (same id as the job; older jobs keep the
99
+ `cron-cron-…` session they already have, shown as their `session_id`).
100
+ Pass `--session <rootSessionId>`
93
101
  only when the user wants the runs to continue a specific conversation (the
94
102
  agent then sees that session's history). Rules the user should hear once:
95
103
  - the session keeps **its own agent and access level** — `--agent` only
@@ -100,7 +108,31 @@ agent then sees that session's history). Rules the user should hear once:
100
108
 
101
109
  Root session ids only (no `>` sub-session paths). You don't know session ids
102
110
  yourself — ask the user (admin → Sessions) rather than guessing. On `update`,
103
- pass `--session ""` to go back to the job's own session.
111
+ pass `--session ""` to go back to the job's own session (for an older job
112
+ pinned to `cron-cron-…` that starts a fresh session — leave it alone unless
113
+ the user asks).
114
+
115
+ **Active window (`--active-from` / `--active-until`, optional, recurring
116
+ jobs only)** — the schedule fires only inside `[from, until)`; fires outside
117
+ it are skipped silently (no run recorded). "Run now" ignores the window.
118
+ - Values: `YYYY-MM-DD`, ISO-8601 (`2026-10-08T09:00`) or unix ms. A time
119
+ without an offset is read in the **job's timezone** (on `update`: the new
120
+ `--timezone` if you pass one, else the job's stored one; host time if
121
+ the job has none).
122
+ - A bare date means **that day 00:00** for `--active-from`, and **the next
123
+ day 00:00** for `--active-until` — the date itself is included
124
+ (`--active-until 2026-10-31` still fires on the 31st).
125
+ - `--active-from` may be in the past (= active now); `--active-until` must
126
+ be in the future and after `--active-from`. `""` clears either bound.
127
+ - Past `--active-until` the job stays enabled but stops firing; the admin
128
+ shows it as "ended". Extending `--active-until` brings it back.
129
+ - Rejected together with `--run-at` (one-shot jobs have no window).
130
+ - There is **no "skip a middle stretch, keep firing before it"**: one
131
+ window per job. If the user asks for that (e.g. "keep sending this week,
132
+ skip next week, then resume"), say so plainly and offer a workaround —
133
+ usually: leave it running now and push `--active-from` past the gap just
134
+ before the gap starts (a one-shot cron can remind you), or create a
135
+ second job for the earlier stretch with an `--active-until`.
104
136
 
105
137
  ### 3. Channels (only when delivery is wanted)
106
138
 
@@ -188,12 +220,27 @@ shell_exec: python3 ~/.halo/global/skills/cron/manage-cron.py update <id> \
188
220
  --schedule "0 10 * * 1"
189
221
  ```
190
222
 
191
- **Pause / unpause:**
223
+ **Pause / unpause (no end date in mind):**
192
224
  ```bash
193
225
  shell_exec: python3 ~/.halo/global/skills/cron/manage-cron.py disable <id>
194
226
  shell_exec: python3 ~/.halo/global/skills/cron/manage-cron.py enable <id>
195
227
  ```
196
228
 
229
+ **Skip / pause until a date / resume (active window):** find the job first
230
+ (`list`, or `list --chat-id {{channel.chat_id}}` inside a chat), then:
231
+ ```bash
232
+ # "Don't send the English weekly this week" → active from next Monday
233
+ shell_exec: python3 ~/.halo/global/skills/cron/manage-cron.py update <id> --active-from 2026-10-12
234
+ # "No A-share report over the National Day holiday" → resume on Oct 8
235
+ shell_exec: python3 ~/.halo/global/skills/cron/manage-cron.py update <id> --active-from 2026-10-08
236
+ # "Send it until the end of the month" → Oct 31 still fires
237
+ shell_exec: python3 ~/.halo/global/skills/cron/manage-cron.py update <id> --active-until 2026-10-31
238
+ # "Start sending again" → clear the pause
239
+ shell_exec: python3 ~/.halo/global/skills/cron/manage-cron.py update <id> --active-from ""
240
+ ```
241
+ Prefer this over `disable` whenever the pause has a known end — it resumes
242
+ on its own.
243
+
197
244
  **Delete:**
198
245
  ```bash
199
246
  shell_exec: python3 ~/.halo/global/skills/cron/manage-cron.py delete <id>
@@ -229,6 +276,14 @@ picks it up within ~10s. Example:
229
276
  > Created cron job `cron-mphsy-abc123`: runs the `default` agent every day at 9am,
230
277
  > output will be pushed to this chat. The runner picks it up within ~10s.
231
278
 
279
+ For an active-window change, say when it resumes / stops — read
280
+ `active_from_iso` / `active_until_iso` back with `get <id>` and name the
281
+ first fire on or after it from the schedule (`get` has no computed next-run
282
+ field; the admin Cron tab shows it):
283
+
284
+ > Paused `A-share daily brief` over the holiday: it resumes on Oct 8 —
285
+ > next run Oct 8 09:30 (Asia/Shanghai). Nothing is sent before that.
286
+
232
287
  ## Patterns that go sideways
233
288
 
234
289
  - **telegram / slack / feishu / wecom target without a chatId** — every fire errors with
@@ -32,6 +32,12 @@ Subcommands:
32
32
 
33
33
  `--targets` arg: comma-separated `channelType:accountId` pairs.
34
34
  example: `--targets telegram:halo_agent_bot,wechat:alice`
35
+
36
+ `--active-from` / `--active-until` (recurring jobs only): the schedule fires
37
+ only inside [from, until); fires outside it are skipped silently. Accepts
38
+ YYYY-MM-DD, ISO-8601 or unix ms; a time without offset is read in the job's
39
+ timezone. A bare date means that day 00:00 for --active-from and the NEXT
40
+ day 00:00 for --active-until (so the date itself is included). `""` clears.
35
41
  """
36
42
  import argparse
37
43
  import json
@@ -126,8 +132,8 @@ SESSION_ID_RE = re.compile(r'^[A-Za-z0-9_:-]{1,200}$')
126
132
 
127
133
  def parse_session(raw: str | None) -> str | None:
128
134
  """Validate --session: a root session id (same rule as the admin REST
129
- routes — no `>` sub-session path). Empty = None = the job's own
130
- `cron-<jobId>` session."""
135
+ routes — no `>` sub-session path). Empty = None = the job's own session
136
+ (same id as the job)."""
131
137
  if raw is None or not raw.strip():
132
138
  return None
133
139
  v = raw.strip()
@@ -157,6 +163,90 @@ def parse_run_at(raw: str | None) -> int | None:
157
163
  return int(dt.timestamp() * 1000)
158
164
 
159
165
 
166
+ DATE_RE = re.compile(r'^\d{4}-\d{2}-\d{2}$')
167
+
168
+
169
+ def _tzinfo(tz_name: str | None):
170
+ """ZoneInfo for an IANA name; None for unset or unknown names."""
171
+ if not tz_name:
172
+ return None
173
+ from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
174
+ try:
175
+ return ZoneInfo(tz_name)
176
+ except (ZoneInfoNotFoundError, ValueError):
177
+ return None
178
+
179
+
180
+ def job_tz(tz_name: str | None):
181
+ """tzinfo for the job's timezone; None = host-local."""
182
+ tz = _tzinfo(tz_name)
183
+ if tz_name and tz is None:
184
+ die(f'unknown timezone "{tz_name}"; expected an IANA name like Asia/Shanghai')
185
+ return tz
186
+
187
+
188
+ def parse_active_bound(flag: str, raw: str, tz_name: str | None) -> int | None:
189
+ """Parse --active-from / --active-until into epoch ms; "" = None (clear).
190
+
191
+ YYYY-MM-DD → that day 00:00 (from) or the NEXT day 00:00 (until, so the
192
+ date is included). ISO without offset / bare dates are read in the job's
193
+ timezone (host-local when the job has none)."""
194
+ raw = raw.strip()
195
+ if not raw:
196
+ return None
197
+ if raw.isdigit():
198
+ return int(raw)
199
+ from datetime import datetime, timedelta
200
+ tz = job_tz(tz_name)
201
+ if DATE_RE.match(raw):
202
+ try:
203
+ dt = datetime.strptime(raw, '%Y-%m-%d')
204
+ except ValueError:
205
+ die(f'invalid {flag} "{raw}": not a calendar date')
206
+ if flag == '--active-until':
207
+ dt += timedelta(days=1)
208
+ else:
209
+ try:
210
+ dt = datetime.fromisoformat(raw)
211
+ except ValueError:
212
+ die(f'invalid {flag} "{raw}": expected YYYY-MM-DD, ISO-8601 (e.g. 2026-10-08T09:00) or unix ms')
213
+ if dt.tzinfo is None:
214
+ dt = dt.replace(tzinfo=tz) if tz else dt.astimezone()
215
+ return int(dt.timestamp() * 1000)
216
+
217
+
218
+ def validate_active_window(run_at, active_from, active_until, new_until: bool):
219
+ """Same rules as the admin REST routes, on the merged row: recurring jobs
220
+ only; a newly set --active-until must be in the future and after from."""
221
+ if active_from is None and active_until is None:
222
+ return
223
+ if run_at is not None:
224
+ die('--active-from/--active-until only apply to recurring jobs, not --run-at one-shots '
225
+ '(on update, clear them with --active-from "" --active-until "")')
226
+ if new_until and active_until is not None and active_until <= now_ms():
227
+ die('--active-until must be in the future')
228
+ if active_from is not None and active_until is not None and active_until <= active_from:
229
+ die('--active-until must be after --active-from')
230
+
231
+
232
+ def require_active_columns(conn: sqlite3.Connection):
233
+ """cron.db gains active_from/active_until via the server's boot migration;
234
+ a server that hasn't restarted onto this version yet won't have them."""
235
+ cols = {r[1] for r in conn.execute('PRAGMA table_info(cron_jobs)').fetchall()}
236
+ if 'active_from' not in cols or 'active_until' not in cols:
237
+ die('cron.db has no active_from/active_until columns yet — restart the halo server '
238
+ 'once so it migrates cron.db, then retry')
239
+
240
+
241
+ def ms_to_iso(ms, tz_name: str | None) -> str | None:
242
+ if ms is None:
243
+ return None
244
+ from datetime import datetime
245
+ tz = _tzinfo(tz_name) # display only — never die on a stored bad tz
246
+ dt = datetime.fromtimestamp(ms / 1000, tz=tz) if tz else datetime.fromtimestamp(ms / 1000).astimezone()
247
+ return dt.isoformat(timespec='minutes')
248
+
249
+
160
250
  def row_to_job(row: sqlite3.Row) -> dict:
161
251
  j = dict(row)
162
252
  try:
@@ -164,6 +254,11 @@ def row_to_job(row: sqlite3.Row) -> dict:
164
254
  except Exception:
165
255
  j['targets'] = []
166
256
  j['enabled'] = bool(j['enabled'])
257
+ # Active window: ms (as stored) + ISO in the job's timezone. Pre-migration
258
+ # dbs have no columns — report them as unset.
259
+ for k in ('active_from', 'active_until'):
260
+ j.setdefault(k, None)
261
+ j[f'{k}_iso'] = ms_to_iso(j[k], j.get('timezone'))
167
262
  return j
168
263
 
169
264
 
@@ -222,6 +317,9 @@ def cmd_create(args):
222
317
  die('--run-at must be in the future')
223
318
  timeout_sec = parse_timeout_sec(args.timeout_sec)
224
319
  session_id = parse_session(args.session)
320
+ active_from = parse_active_bound('--active-from', args.active_from or '', args.timezone)
321
+ active_until = parse_active_bound('--active-until', args.active_until or '', args.timezone)
322
+ validate_active_window(run_at_ms, active_from, active_until, new_until=True)
225
323
 
226
324
  job_id = args.id or gen_job_id()
227
325
  now = now_ms()
@@ -232,25 +330,31 @@ def cmd_create(args):
232
330
  if existing:
233
331
  die(f'job id {job_id} already exists; pick another with --id', 2)
234
332
 
333
+ values: dict[str, object] = {
334
+ 'id': job_id,
335
+ 'label': args.label,
336
+ 'workspace_path': args.workspace,
337
+ 'agent_id': args.agent or 'default',
338
+ 'user_prompt': args.prompt,
339
+ 'schedule': args.schedule or '',
340
+ 'run_at': run_at_ms,
341
+ 'timezone': args.timezone,
342
+ 'timeout_sec': timeout_sec,
343
+ 'session_id': session_id,
344
+ 'targets': json.dumps(targets, ensure_ascii=False),
345
+ 'enabled': 0 if args.disabled else 1,
346
+ 'created_at': now,
347
+ 'updated_at': now,
348
+ }
349
+ # Only name the window columns when used, so jobs without a window can
350
+ # still be created on a cron.db the server hasn't migrated yet.
351
+ if active_from is not None or active_until is not None:
352
+ require_active_columns(conn)
353
+ values['active_from'] = active_from
354
+ values['active_until'] = active_until
235
355
  conn.execute(
236
- 'INSERT INTO cron_jobs(id, label, workspace_path, agent_id, user_prompt, schedule, '
237
- 'run_at, timezone, timeout_sec, session_id, targets, enabled, created_at, updated_at) '
238
- 'VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
239
- (
240
- job_id,
241
- args.label,
242
- args.workspace,
243
- args.agent or 'default',
244
- args.prompt,
245
- args.schedule or '',
246
- run_at_ms,
247
- args.timezone,
248
- timeout_sec,
249
- session_id,
250
- json.dumps(targets, ensure_ascii=False),
251
- 0 if args.disabled else 1,
252
- now, now,
253
- ),
356
+ f'INSERT INTO cron_jobs({", ".join(values)}) VALUES ({", ".join("?" for _ in values)})',
357
+ list(values.values()),
254
358
  )
255
359
  conn.commit()
256
360
  print(json.dumps({'id': job_id, 'created': True}, ensure_ascii=False))
@@ -279,12 +383,29 @@ def cmd_update(args):
279
383
  # same convention as `--run-at ""`.
280
384
  sets.append(('timeout_sec', parse_timeout_sec(args.timeout_sec)))
281
385
  if args.session is not None:
282
- # `--session ""` clears back to the job's own cron-<jobId> session.
386
+ # `--session ""` clears back to the job's own session (id = job id).
283
387
  sets.append(('session_id', parse_session(args.session)))
284
388
  if args.targets is not None:
285
389
  sets.append(('targets', json.dumps(parse_targets(args.targets), ensure_ascii=False)))
390
+ cur = dict(row)
391
+ if args.active_from is not None or args.active_until is not None:
392
+ require_active_columns(conn)
393
+ # Bare dates / offset-less times read in the job's timezone after
394
+ # this update: the new --timezone if given, else the stored one.
395
+ tz = args.timezone if args.timezone is not None else cur.get('timezone')
396
+ if args.active_from is not None:
397
+ sets.append(('active_from', parse_active_bound('--active-from', args.active_from, tz)))
398
+ if args.active_until is not None:
399
+ sets.append(('active_until', parse_active_bound('--active-until', args.active_until, tz)))
400
+ # Window rules on the merged row (also catches --run-at on a job that
401
+ # still has a window).
402
+ merged = {**cur, **dict(sets)}
403
+ validate_active_window(
404
+ merged.get('run_at'), merged.get('active_from'), merged.get('active_until'),
405
+ new_until=bool(args.active_until and args.active_until.strip()),
406
+ )
286
407
  if not sets:
287
- die('nothing to update — pass at least one --label/--workspace/--agent/--prompt/--schedule/--run-at/--timezone/--timeout-sec/--session/--targets')
408
+ die('nothing to update — pass at least one --label/--workspace/--agent/--prompt/--schedule/--run-at/--timezone/--timeout-sec/--session/--targets/--active-from/--active-until')
288
409
  sets.append(('updated_at', now_ms()))
289
410
 
290
411
  placeholders = ', '.join(f'{k} = ?' for k, _ in sets)
@@ -432,10 +553,16 @@ def main():
432
553
  c.add_argument('--timeout-sec', dest='timeout_sec',
433
554
  help='max run time in seconds, 60–21600 (default: unset = 3600)')
434
555
  c.add_argument('--session',
435
- help="run in this existing root session id (default: the job's own cron-<jobId>)")
556
+ help="run in this existing root session id (default: the job's own session, same id as the job)")
436
557
  c.add_argument('--targets',
437
558
  help='comma-separated channelType:accountId[:chatId] list, or JSON array. '
438
559
  'chatId pins delivery to a specific chat (e.g. when scheduling from inside a chat).')
560
+ c.add_argument('--active-from', dest='active_from',
561
+ help='recurring only: start firing from this time — YYYY-MM-DD (= that day 00:00), '
562
+ 'ISO-8601 or unix ms; no offset = the job timezone. Use it to "pause until" a date.')
563
+ c.add_argument('--active-until', dest='active_until',
564
+ help='recurring only: stop firing at this time (exclusive) — YYYY-MM-DD (= the NEXT day 00:00, '
565
+ 'so the date itself still fires), ISO-8601 or unix ms; must be in the future')
439
566
  c.add_argument('--disabled', action='store_true', help='create paused (default: enabled)')
440
567
  c.set_defaults(func=cmd_create)
441
568
 
@@ -451,8 +578,15 @@ def main():
451
578
  u.add_argument('--timeout-sec', dest='timeout_sec',
452
579
  help='max run time in seconds, 60–21600; pass "" to clear back to default 3600')
453
580
  u.add_argument('--session',
454
- help='root session id to run in; pass "" to clear back to cron-<jobId>')
581
+ help='root session id to run in; pass "" to clear back to the job\'s own session '
582
+ '(same id as the job — an older job pinned to cron-cron-… then starts a fresh one)')
455
583
  u.add_argument('--targets')
584
+ u.add_argument('--active-from', dest='active_from',
585
+ help='recurring only: YYYY-MM-DD (= that day 00:00), ISO-8601 or unix ms, in the job timezone; '
586
+ 'pass "" to clear (= active now)')
587
+ u.add_argument('--active-until', dest='active_until',
588
+ help='recurring only: YYYY-MM-DD (= the NEXT day 00:00, date included), ISO-8601 or unix ms, '
589
+ 'in the job timezone; pass "" to clear (= no end)')
456
590
  u.set_defaults(func=cmd_update)
457
591
 
458
592
  e = sub.add_parser('enable')
@@ -1,19 +1,20 @@
1
1
  ---
2
2
  name: extension
3
- description: Install / list / remove admin preview extensions (`~/.halo/global/extensions/<id>/`) — static viewers the admin editor loads for file types it has no built-in preview for (e.g. `.glb`). `/extension install <id|zip|url>` pulls the latest `<id>-v*` release from the configured hub (default turmind/halo-hub). Activate when the user wants to preview a file type the editor can't open, or asks to install / update / remove an extension.
3
+ description: Install / list / remove admin preview extensions (`~/.halo/global/extensions/<id>/`) — static viewers the admin editor loads for file types it has no built-in preview for (e.g. `.glb`) — and update the model provider list (new models, changed provider endpoints) from the hub. `/extension install <id|zip|url>` pulls the latest `<id>-v*` release from the configured hub (default turmind/halo-hub); `/extension models` pulls the latest `models-v*` provider configs. Activate when the user wants to preview a file type the editor can't open, asks to install / update / remove an extension, or asks to update the model list / get a newly released model ("更新模型列表").
4
4
  command: /extension
5
5
  requiresAccess: full
6
6
  verbs:
7
7
  - { name: install, desc: "Install or upgrade an extension from the hub (by id), a local zip path, or a zip URL" }
8
8
  - { name: list, desc: List installed extensions with version and status }
9
9
  - { name: remove, desc: Remove an installed extension by id }
10
+ - { name: models, desc: "Update the model provider list from the hub (newest models-v* release)" }
10
11
  ---
11
12
 
12
13
  # extension
13
14
 
14
15
  The requested action is **`$1`**; everything after it is the payload
15
16
  (`$ARGUMENTS` minus the first token). With natural language ("install the glb
16
- viewer"), infer both.
17
+ viewer", "更新模型列表"), infer both.
17
18
 
18
19
  Preview extensions are static bundles the admin editor loads in a sandboxed
19
20
  iframe to preview file types it has no built-in viewer for (e.g. `.glb`).
@@ -70,6 +71,33 @@ short `-` list; `(none)` when empty.
70
71
  `bash EXT remove <id>` → deletes `~/.halo/global/extensions/<id>/`. Confirm with
71
72
  the user first if they didn't name the id explicitly.
72
73
 
74
+ ## models
75
+
76
+ Updates the model provider list (the providers / models / endpoints the agent
77
+ editor offers) from the hub's newest `models-v*` release. Also run it for
78
+ natural language like "更新模型列表" / "update the model list" / "is there a
79
+ newer model". Run it right away:
80
+
81
+ `HALO_HUB_REPO='{{params.hub_repo}}' bash EXT models update`
82
+
83
+ The script prints one line per provider (`install` / `up-to-date` / `skip` /
84
+ `refuse` + reason), then a summary as its last line. By exit code:
85
+ - `0` — done. Relay the summary line; name any `refuse`d provider and its reason
86
+ (e.g. "needs a newer halo" → run `halo upgrade`). Takes effect immediately
87
+ — no restart, no page reload. Existing agents keep their own model / endpoint
88
+ (they live in each agent.yaml); only the choices offered change.
89
+ - `3` — nothing was written: a provider is new or its endpoints change. Show
90
+ the user the indented endpoint lines verbatim and ask whether to apply them.
91
+ Only after they agree, run the same command with `--yes` appended. Never add
92
+ `--yes` on your own.
93
+ - anything else — relay the error. Model configs come only from an https
94
+ GitHub / Gitea / Forgejo / GitLab hub with releases; a local / plain-git /
95
+ http hub is refused. "release API failed" / "no release for models" → same
96
+ hints as for `install`.
97
+
98
+ `bash EXT models list` shows each provider, which copy is in effect (`bundled`
99
+ = shipped with halo, `hub` = installed by this command) and its revision.
100
+
73
101
  ## Notes
74
102
 
75
103
  - Extensions are global (all workspaces on this server). Say so if the user
@@ -4,6 +4,6 @@ description_zh: 安装 / 列出 / 删除管理后台的预览扩展。
4
4
 
5
5
  params:
6
6
  - key: hub_repo
7
- description: "Hub that `install <id>` pulls from: a GitHub / Gitea / Forgejo / GitLab repo URL (newest `<id>-v*` release zip), `owner/repo` for GitHub, or any git URL / local repo path (newest `<id>-v<x.y.z>` tag). Empty = https://github.com/turmind/halo-hub."
8
- description_zh: "`install <id>` 的来源仓库:GitHub / Gitea / Forgejo / GitLab 仓库地址(取最新的 `<id>-v*` release zip),GitHub 可简写为 `owner/repo`;也可以是任意 git 地址或本地仓库路径(取最新的 `<id>-v<x.y.z>` 标签)。留空 = https://github.com/turmind/halo-hub。"
7
+ description: "Hub that `install <id>` and `models` pull from: a GitHub / Gitea / Forgejo / GitLab repo URL (newest `<id>-v*` release zip), `owner/repo` for GitHub, or any git URL / local repo path (newest `<id>-v<x.y.z>` tag; not for `models`, which needs an https release hub). Empty = https://github.com/turmind/halo-hub."
8
+ description_zh: "`install <id>` 和 `models` 的来源仓库:GitHub / Gitea / Forgejo / GitLab 仓库地址(取最新的 `<id>-v*` release zip),GitHub 可简写为 `owner/repo`;也可以是任意 git 地址或本地仓库路径(取最新的 `<id>-v<x.y.z>` 标签;`models` 不支持,它只认 https 的 release 仓库)。留空 = https://github.com/turmind/halo-hub。"
9
9
  default: https://github.com/turmind/halo-hub