@turmind/halo 2.2.0 → 2.2.1

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.
package/dist/index.js CHANGED
@@ -1929,7 +1929,7 @@ var init_init = __esm({
1929
1929
  path8.resolve(__dirname, "..", "bundled-docs"),
1930
1930
  path8.resolve(__dirname, "..", "..", "..", ".halo", "docs")
1931
1931
  ];
1932
- TEMPLATE_VERSION = 118;
1932
+ TEMPLATE_VERSION = 119;
1933
1933
  VERSION_FILE = ".template-version";
1934
1934
  SKIP_NAMES = /* @__PURE__ */ new Set([".DS_Store", "schema.sql", "__pycache__", ".pytest_cache"]);
1935
1935
  BUILTIN_AGENT_IDS = /* @__PURE__ */ new Set([
@@ -16976,7 +16976,7 @@ async function initObservability(haloProcess = "server") {
16976
16976
  return true;
16977
16977
  }
16978
16978
  const { registerSdk: registerSdk2 } = await Promise.resolve().then(() => (init_otel_sdk(), otel_sdk_exports));
16979
- providers = await registerSdk2(process.env.OTEL_SERVICE_NAME, "2.2.0", haloProcess);
16979
+ providers = await registerSdk2(process.env.OTEL_SERVICE_NAME, "2.2.1", haloProcess);
16980
16980
  console.log(`[Observability] OTLP export \u2192 ${process.env.OTEL_EXPORTER_OTLP_ENDPOINT} (service.name=${process.env.OTEL_SERVICE_NAME}, halo.process=${haloProcess}, capture_content=${config.observability.captureContent})`);
16981
16981
  return true;
16982
16982
  }
@@ -28938,6 +28938,23 @@ var init_handler = __esm({
28938
28938
  });
28939
28939
 
28940
28940
  // ../core/dist/protocol/session-message.js
28941
+ function inferMessageType(msg) {
28942
+ if (msg.type)
28943
+ return msg.type;
28944
+ if (msg.role === "user")
28945
+ return "user";
28946
+ if (msg.role === "assistant")
28947
+ return "assistant";
28948
+ if (msg.toolName)
28949
+ return "tool_call";
28950
+ if (msg.toolOutput !== void 0 && !msg.toolName)
28951
+ return "tool_result";
28952
+ if (msg.usage)
28953
+ return "usage";
28954
+ if (msg.systemPrompt)
28955
+ return "context";
28956
+ return "notification";
28957
+ }
28941
28958
  var init_session_message = __esm({
28942
28959
  "../core/dist/protocol/session-message.js"() {
28943
28960
  "use strict";
@@ -31566,6 +31583,12 @@ var init_terminal_manager = __esm({
31566
31583
 
31567
31584
  // ../server/dist/ws/handler.js
31568
31585
  import path78 from "node:path";
31586
+ function adminSnapshotRows(rows) {
31587
+ return rows.filter((m) => {
31588
+ const t2 = inferMessageType(m);
31589
+ return t2 !== "tool_call" && t2 !== "tool_result";
31590
+ });
31591
+ }
31569
31592
  function setupWebSocketHandler(deps) {
31570
31593
  const { wss: wss2, registry: registry2 } = deps;
31571
31594
  const clients = /* @__PURE__ */ new Set();
@@ -31689,8 +31712,10 @@ function setupWebSocketHandler(deps) {
31689
31712
  }
31690
31713
  if (client.subscriptions.has(targetSessionId)) {
31691
31714
  const state = getState(client, targetSessionId);
31692
- const messages2 = state ? [...createSaveSnapshot(state)] : [];
31693
- sendJson(ws, { type: "state:snapshot", snapshot: { recentMessages: messages2, sessionId: targetSessionId } });
31715
+ const log2 = state?.messageLog ?? [];
31716
+ const deletedIds = log2.filter((m) => m.deleted).map((m) => m.id);
31717
+ const userRows = log2.filter((m) => m.role === "user" && !m.taskId).length;
31718
+ sendJson(ws, { type: "exchange:deleted", sessionId: targetSessionId, deletedIds, userOrdinal: msg.userOrdinal, userRows });
31694
31719
  }
31695
31720
  }
31696
31721
  function createEventListener(client, sessionId) {
@@ -32074,7 +32099,7 @@ ${msg.message}` : markers.join("\n");
32074
32099
  const state2 = getState(client2, sid);
32075
32100
  const ctxConfig = await sm.getContextConfig(sid);
32076
32101
  const running = sm.isSessionRunning(sid);
32077
- const messages3 = state2 ? running ? [...state2.messageLog] : [...createSaveSnapshot(state2)] : [];
32102
+ const messages3 = state2 ? adminSnapshotRows(running ? state2.messageLog : createSaveSnapshot(state2)) : [];
32078
32103
  const detachedSession = sm.getSessionById(sid);
32079
32104
  sendJson(ws, { type: "state:snapshot", snapshot: { recentMessages: messages3, sessionId: sid, maxContextTokens: ctxConfig.maxTokens, agentId: detachedSession?.agentId, archiveCount: archiveCountFor(client2, sid, detachedSession?.agentId), accessLevel: detachedSession?.accessLevel ?? null } });
32080
32105
  if (state2 && state2.contextTokens > 0) {
@@ -32131,7 +32156,7 @@ ${msg.message}` : markers.join("\n");
32131
32156
  }
32132
32157
  }
32133
32158
  const state = sid ? getState(client2, sid) : null;
32134
- const messages2 = state ? [...createSaveSnapshot(state)] : [];
32159
+ const messages2 = state ? adminSnapshotRows(createSaveSnapshot(state)) : [];
32135
32160
  sendJson(ws, { type: "state:snapshot", snapshot: { recentMessages: messages2, sessionId: sid, maxContextTokens, agentId, archiveCount: sid ? archiveCountFor(client2, sid, agentId) : 0, accessLevel } });
32136
32161
  if (state && state.contextTokens > 0) {
32137
32162
  sendJson(ws, { type: "chat:usage", contextTokens: state.contextTokens, outputTokens: state.outputTokens, sessionId: sid });
@@ -32161,6 +32186,7 @@ var CLIENT_SILENCE_LIMIT_MS, detachedSessions;
32161
32186
  var init_handler2 = __esm({
32162
32187
  "../server/dist/ws/handler.js"() {
32163
32188
  "use strict";
32189
+ init_session_types();
32164
32190
  init_sandbox();
32165
32191
  init_ui_log_builder();
32166
32192
  init_config();
@@ -32291,7 +32317,7 @@ function buildCard(workspace, interfaceUrl, caps) {
32291
32317
  return {
32292
32318
  name: raw.name,
32293
32319
  description: raw.description,
32294
- version: typeof raw.version === "string" ? raw.version : "2.2.0",
32320
+ version: typeof raw.version === "string" ? raw.version : "2.2.1",
32295
32321
  supportedInterfaces: [{ url: interfaceUrl, protocolBinding: "JSONRPC", protocolVersion: A2A_VERSION }],
32296
32322
  capabilities: { streaming: caps.streaming, pushNotifications: true, extendedAgentCard: false },
32297
32323
  ...caps.tokenAuth === false ? {} : {
@@ -34046,6 +34072,8 @@ async function runJob(jobId, triggerKind) {
34046
34072
  kickPendingEvents(jobId);
34047
34073
  return runId;
34048
34074
  };
34075
+ if (triggerKind === "manual" && !job.userPrompt)
34076
+ return skip("nothing to run: the job has no prompt, and run-now carries no webhook events");
34049
34077
  if (_inflight.has(jobId))
34050
34078
  return skip("previous run still in progress");
34051
34079
  if (_inflightSessions.has(sessionKeyFor(job.workspacePath, sessionId)))
@@ -34131,9 +34159,9 @@ async function runJob(jobId, triggerKind) {
34131
34159
  const events = webhook ? claimPendingEvents(jobId, runId, sessionId) : [];
34132
34160
  try {
34133
34161
  const texts = events.map((ev) => eventText(ev, job.workspacePath, false));
34134
- const stamp = host.appendUserMessage(sessionId, [job.userPrompt, ...texts.map((t2) => t2.ui)].join("\n\n"));
34162
+ const stamp = host.appendUserMessage(sessionId, [job.userPrompt, ...texts.map((t2) => t2.ui)].filter(Boolean).join("\n\n"));
34135
34163
  stampEvents(events.map((ev) => ev.id), stamp);
34136
- await host.sendUserMessage(sessionId, [UNATTENDED_PREAMBLE2, job.userPrompt, ...texts.map((t2) => t2.model)].join("\n\n"), void 0, void 0, stamp);
34164
+ await host.sendUserMessage(sessionId, [UNATTENDED_PREAMBLE2, job.userPrompt, ...texts.map((t2) => t2.model)].filter(Boolean).join("\n\n"), void 0, void 0, stamp);
34137
34165
  } catch (err) {
34138
34166
  untrackRun(runId);
34139
34167
  tracked = false;
@@ -34531,7 +34559,7 @@ function createCronRoutes() {
34531
34559
  const body = await c.req.json().catch(() => null);
34532
34560
  if (!body)
34533
34561
  return c.json({ error: "invalid body" }, 400);
34534
- for (const k of ["workspacePath", "agentId", "userPrompt"]) {
34562
+ for (const k of ["workspacePath", "agentId"]) {
34535
34563
  if (!body[k] || typeof body[k] !== "string")
34536
34564
  return c.json({ error: `${k} required` }, 400);
34537
34565
  }
@@ -34540,6 +34568,11 @@ function createCronRoutes() {
34540
34568
  const hasSchedule = typeof body.schedule === "string" && body.schedule.trim().length > 0;
34541
34569
  const hasRunAt = typeof body.runAt === "number" && Number.isFinite(body.runAt);
34542
34570
  const webhook = body.webhook === true;
34571
+ const userPrompt = body.userPrompt ?? "";
34572
+ if (typeof userPrompt !== "string")
34573
+ return c.json({ error: "userPrompt must be a string" }, 400);
34574
+ if (!userPrompt && !webhook)
34575
+ return c.json({ error: "userPrompt required" }, 400);
34543
34576
  if (!hasSchedule && !hasRunAt && !webhook)
34544
34577
  return c.json({ error: "schedule, runAt or webhook required" }, 400);
34545
34578
  if (hasSchedule && hasRunAt)
@@ -34570,8 +34603,6 @@ function createCronRoutes() {
34570
34603
  const stateless = body.stateless === true;
34571
34604
  if (stateless && session.sessionId)
34572
34605
  return c.json({ error: STATELESS_SESSION_ERR }, 400);
34573
- if (webhook && session.sessionId)
34574
- return c.json({ error: WEBHOOK_SESSION_ERR }, 400);
34575
34606
  const folder = await parseWorkingDir(body.workspacePath, body.workingDir);
34576
34607
  if ("error" in folder)
34577
34608
  return c.json({ error: folder.error }, 400);
@@ -34598,7 +34629,7 @@ function createCronRoutes() {
34598
34629
  label: body.label && body.label.trim().length > 0 ? body.label.trim() : null,
34599
34630
  workspacePath: body.workspacePath,
34600
34631
  agentId: body.agentId,
34601
- userPrompt: body.userPrompt,
34632
+ userPrompt,
34602
34633
  schedule: hasSchedule ? body.schedule : "",
34603
34634
  runAt: hasRunAt ? body.runAt : null,
34604
34635
  webhookSecret: webhook ? newWebhookSecret() : null,
@@ -34694,8 +34725,12 @@ function createCronRoutes() {
34694
34725
  if (!nextWebhookSecret && nextSchedule.trim().length === 0 && typeof nextRunAt !== "number") {
34695
34726
  return c.json({ error: "schedule, runAt or webhook required" }, 400);
34696
34727
  }
34697
- if (nextWebhookSecret && merged.sessionId)
34698
- return c.json({ error: WEBHOOK_SESSION_ERR }, 400);
34728
+ if (body.userPrompt !== void 0 && typeof body.userPrompt !== "string")
34729
+ return c.json({ error: "userPrompt must be a string" }, 400);
34730
+ const nextPrompt = body.userPrompt ?? existing.userPrompt;
34731
+ if (!nextWebhookSecret && !nextPrompt && (body.userPrompt !== void 0 || existing.webhookSecret !== null)) {
34732
+ return c.json({ error: "userPrompt required (only a webhook job may leave it empty)" }, 400);
34733
+ }
34699
34734
  for (const k of ["activeFrom", "activeUntil"]) {
34700
34735
  const err = validateActiveBound(k, body[k]);
34701
34736
  if (err)
@@ -34720,7 +34755,7 @@ function createCronRoutes() {
34720
34755
  if (body.agentId !== void 0)
34721
34756
  patch.agentId = body.agentId;
34722
34757
  if (body.userPrompt !== void 0)
34723
- patch.userPrompt = body.userPrompt;
34758
+ patch.userPrompt = nextPrompt;
34724
34759
  if (body.schedule !== void 0 || clearSchedule)
34725
34760
  patch.schedule = nextSchedule;
34726
34761
  if (body.runAt !== void 0 || clearRunAt)
@@ -34871,7 +34906,7 @@ function computeNextRun(j) {
34871
34906
  return null;
34872
34907
  }
34873
34908
  }
34874
- var WEBHOOK_SESSION_ERR, TIMEOUT_SEC_MIN, TIMEOUT_SEC_MAX, STATELESS_SESSION_ERR;
34909
+ var TIMEOUT_SEC_MIN, TIMEOUT_SEC_MAX, STATELESS_SESSION_ERR;
34875
34910
  var init_cron = __esm({
34876
34911
  "../server/dist/routes/cron.js"() {
34877
34912
  "use strict";
@@ -34881,7 +34916,6 @@ var init_cron = __esm({
34881
34916
  init_dispatcher();
34882
34917
  init_broadcast();
34883
34918
  init_session_id();
34884
- WEBHOOK_SESSION_ERR = "a webhook job runs in its own session (or a new one every run), not a picked sessionId";
34885
34919
  TIMEOUT_SEC_MIN = 60;
34886
34920
  TIMEOUT_SEC_MAX = 21600;
34887
34921
  STATELESS_SESSION_ERR = "stateless (new session every run) and sessionId are mutually exclusive";
@@ -35381,7 +35415,7 @@ function createCoreChecker(opts) {
35381
35415
  }
35382
35416
  function getCoreStatus(force) {
35383
35417
  checker ??= createCoreChecker({
35384
- haloVersion: "2.2.0",
35418
+ haloVersion: "2.2.1",
35385
35419
  fetchLatest: fetchNpmLatest,
35386
35420
  onChange: (status) => broadcast({ type: "updates:changed", scope: "core", status })
35387
35421
  });
@@ -35549,7 +35583,7 @@ function hubBase() {
35549
35583
  }
35550
35584
  function serverChecker() {
35551
35585
  checker2 ??= createExtensionsChecker({
35552
- haloVersion: "2.2.0",
35586
+ haloVersion: "2.2.1",
35553
35587
  installed: () => {
35554
35588
  const s = getSnapshot();
35555
35589
  return [...s.extensions, ...s.disabled];
@@ -41920,7 +41954,7 @@ var init_dist2 = __esm({
41920
41954
  init_git_ssh();
41921
41955
  __dirname3 = path93.dirname(fileURLToPath3(import.meta.url));
41922
41956
  PORT = config.server.port;
41923
- HALO_VERSION = "2.2.0";
41957
+ HALO_VERSION = "2.2.1";
41924
41958
  PROJECT_ROOT = findProjectRoot();
41925
41959
  HALO_HOME4 = path93.join(homedir25(), ".halo");
41926
41960
  GIT_SHA = readGitSha();
@@ -42073,8 +42107,21 @@ var init_dist2 = __esm({
42073
42107
  }
42074
42108
  FRONTEND_DIR = resolveFrontendDir();
42075
42109
  if (!AGENTCORE) {
42076
- app.use("/*", serveStatic({ root: path93.relative(process.cwd(), FRONTEND_DIR) }));
42110
+ app.use("/*", serveStatic({
42111
+ root: path93.relative(process.cwd(), FRONTEND_DIR),
42112
+ // Next's `/_next/static/` files are content-hashed (under a buildId dir),
42113
+ // so they can be cached forever. Only on a found file — the SPA fallback
42114
+ // below must stay uncached, or a missing chunk caches index.html as js.
42115
+ // `c.res.headers`, not `c.header`: node-server calls onFound after the
42116
+ // body is built, so only c.res (merged into the result by compose) lands.
42117
+ onFound: (_path, c) => {
42118
+ if (c.req.path.startsWith("/_next/static/"))
42119
+ c.res.headers.set("Cache-Control", "public, max-age=31536000, immutable");
42120
+ }
42121
+ }));
42077
42122
  app.get("/*", (c) => {
42123
+ if (c.req.path.startsWith("/_next/static/"))
42124
+ return c.notFound();
42078
42125
  const indexPath = path93.join(FRONTEND_DIR, "index.html");
42079
42126
  try {
42080
42127
  const html = fs88.readFileSync(indexPath, "utf-8");
@@ -42102,6 +42149,7 @@ var init_dist2 = __esm({
42102
42149
  });
42103
42150
  wss = new WebSocketServer5({
42104
42151
  noServer: true,
42152
+ perMessageDeflate: { zlibDeflateOptions: { level: 1 }, threshold: 1024 },
42105
42153
  verifyClient: (info, callback) => {
42106
42154
  const token = getTokenFromCookieHeader(info.req.headers.cookie);
42107
42155
  if (isAuthenticated(token)) {
@@ -43981,7 +44029,7 @@ function runNode(opts) {
43981
44029
  shellTimeoutMs: config.timeout.shellExec,
43982
44030
  os: process.platform,
43983
44031
  hostname: os13.hostname(),
43984
- version: "2.2.0"
44032
+ version: "2.2.1"
43985
44033
  };
43986
44034
  const exec2 = createNodeExecutor({ root, base, allowShell }, log2);
43987
44035
  const instance = crypto17.randomUUID();
@@ -44497,7 +44545,7 @@ function installUserUnit(opts, run = runCommand) {
44497
44545
  }
44498
44546
 
44499
44547
  // src/index.ts
44500
- var VERSION = "2.2.0";
44548
+ var VERSION = "2.2.1";
44501
44549
  var HELP_TOP = `Usage: halo <command> [options]
44502
44550
 
44503
44551
  Commands:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@turmind/halo",
3
- "version": "2.2.0",
3
+ "version": "2.2.1",
4
4
  "description": "Halo — multi-agent collaborative workspace. CLI + TUI + HTTP/WS server in one package.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  name: cron
3
- description: Manage scheduled cron jobs that run a halo agent on a schedule (or on each incoming webhook) and optionally push the result to a chat channel (telegram / wechat / slack / feishu / wecom). Activate when the user asks to add / list / edit / pause / delete a recurring or webhook-triggered agent task.
3
+ description: Manage jobs that run a halo agent on a recurring schedule, once (`--run-at`), or per webhook POST from an external system or process (a turn in the job's session or an existing root session); results can go to a chat. Activate to add / list / edit / pause / delete a scheduled, one-shot or webhook-triggered task.
4
4
  command: /cron
5
5
  requiresAccess: full
6
6
  verbs:
7
- - { name: create, desc: Create a scheduled job }
8
- - { name: list, desc: List scheduled jobs }
7
+ - { name: create, desc: Create a job }
8
+ - { name: list, desc: List jobs }
9
9
  - { name: update, desc: Edit a job (schedule / prompt / targets) }
10
10
  - { name: enable, desc: Resume a paused job }
11
11
  - { name: disable, desc: Pause a job }
@@ -18,8 +18,8 @@ The requested action is **`$1`** (full args: `$ARGUMENTS`); with
18
18
  natural-language activation, infer it from the request. All verbs run through
19
19
  this body — map them onto the helper script's subcommands below.
20
20
 
21
- Halo runs a global cron daemon that executes user-defined agent prompts on a schedule and
22
- optionally pushes the resulting text to a chat (telegram / wechat / slack / feishu / wecom). Use the
21
+ Halo runs a global cron daemon that executes user-defined agent prompts on a schedule or on
22
+ webhook events and optionally pushes the resulting text to a chat (telegram / wechat / slack / feishu / wecom). Use the
23
23
  `manage-cron.py` helper script (sibling of this file) for every operation — it handles id
24
24
  generation, JSON encoding for `targets`, timestamps, and so on, so you don't end up
25
25
  handcrafting SQL.
@@ -91,20 +91,42 @@ of the two switches the job's mode and clears the other; never pass both.
91
91
  Ambiguous time ("morning")? Ask once for the exact hour.
92
92
 
93
93
  **Webhook trigger (`--webhook`)** — the third mode, instead of `--schedule` /
94
- `--run-at`: the job fires when something POSTs to `/cron-hooks/<id>` on the
95
- server (GitHub / CI / any HTTP sender). Use it for "when X happens, run
96
- this". The helper generates the secret and prints `webhook: {path, secret}`
97
- — give the user the full URL (the server's public origin, i.e. the admin
98
- address, + `path`) and the secret, and say once that the secret is shown
99
- again in the admin Cron tab and `get`. A GitHub webhook uses it as its
100
- secret (`X-Hub-Signature-256`); other senders send
101
- `Authorization: Bearer <secret>`. Each request body becomes a message
102
- in the job's session, marked as data; events that arrive while a run is
103
- in flight join that run. Tell the sender to set `X-GitHub-Delivery` (GitHub
104
- does) or `Idempotency-Key`: without one, the same body is processed only
105
- once per 10 minutes. A repeat of an event whose run failed runs again. Excludes `--session` and an active window. On
106
- `update`: `--webhook on` switches to it (an existing secret is kept),
107
- `--schedule …` / `--run-at …` switches away (the secret is dropped).
94
+ `--run-at`: the job runs on each HTTP POST to `/cron-hooks/<id>` on the
95
+ server — a notification from an external system or process (GitHub, CI,
96
+ any HTTP sender). Use it for "when X happens, run this".
97
+ - **What you get** — the helper generates the secret and prints
98
+ `webhook: {path, secret}`. Give the user the full URL (the server's
99
+ public origin, i.e. the admin address, + `path`) and the secret once, and
100
+ say the secret is shown again in the admin Triggers tab and `get`. Don't
101
+ echo the secret again in later replies or write it into files.
102
+ - **Session** — the same choices as a scheduled job: the job's own session
103
+ (default), a new one per run (`--stateless`), or an existing root session
104
+ (`--session`, below).
105
+ - **How an event enters the session** — each request body becomes a
106
+ message in the session, marked as data. `--prompt` is optional on a
107
+ webhook job: left out, a run delivers the events alone (and run-now has
108
+ nothing to run). Session idle → a new turn starts.
109
+ A user's turn running in it → the events join that turn at its next step,
110
+ and its reply is what the run pushes.
111
+ Session held by another owner (a relay or A2A task, another job's run) →
112
+ the events stay pending and run once it releases the session. Events that
113
+ arrive while the job's own run is in flight join that run.
114
+ - **Delivery** — the sender authenticates with
115
+ `Authorization: Bearer <secret>`, or as a GitHub webhook with the secret
116
+ as its secret (`X-Hub-Signature-256`). Dedupe key: `X-GitHub-Delivery`
117
+ (GitHub sets it), `Idempotency-Key` or `X-Request-Id`; without one, the
118
+ same body is processed only once per 10 minutes. A repeat of an event
119
+ whose run failed runs again. Responses: `202` accepted · `200` duplicate,
120
+ or GitHub `ping` (no run) · `404` wrong secret or unknown job · `409` job
121
+ disabled · `413` body over 1 MB · `429` 100 events already pending.
122
+ - **Limits** — root sessions only; no active window. The run timeout
123
+ (`--timeout-sec`, below; 60–21600 s) stops the session — on a picked
124
+ session, that is the user's conversation. The body is always data to the agent,
125
+ never instructions: put what to do in `--prompt`. A session that has
126
+ taken webhook events never gets an evolution patch auto-approved (it goes
127
+ to review).
128
+ - On `update`: `--webhook on` switches to it (an existing secret is kept),
129
+ `--schedule …` / `--run-at …` switches away (the secret is dropped).
108
130
 
109
131
  **Max run time (`--timeout-sec`, optional)** — each fire runs inside the
110
132
  server as a turn in the job's session; the server stops it after
@@ -122,7 +144,10 @@ only when the user wants the runs to continue a specific conversation (the
122
144
  agent then sees that session's history). Rules the user should hear once:
123
145
  - the session keeps **its own agent and access level** — `--agent` must
124
146
  match an existing session's agent (the helper refuses otherwise);
125
- - a fire is **skipped** while a turn is already running in that session;
147
+ - a scheduled / run-now fire is **skipped** while that session is busy (a
148
+ turn running in it, or held by another owner); a webhook job's accepted
149
+ events are never skipped — they join a user's turn in progress, and wait out another owner
150
+ (see *How an event enters the session* above);
126
151
  - if the user chats in that session while a run is in flight, the message
127
152
  joins the run, and the run pushes the reply of its last turn.
128
153
 
@@ -345,7 +370,7 @@ picks it up within ~10s. Example:
345
370
  For an active-window change, say when it resumes / stops — read
346
371
  `active_from_iso` / `active_until_iso` back with `get <id>` and name the
347
372
  first fire on or after it from the schedule (`get` has no computed next-run
348
- field; the admin Cron tab shows it):
373
+ field; the admin Triggers tab shows it):
349
374
 
350
375
  > Paused `A-share daily brief` over the holiday: it resumes on Oct 8 —
351
376
  > next run Oct 8 09:30 (Asia/Shanghai). Nothing is sent before that.
@@ -224,10 +224,6 @@ def require_v7_columns(conn: sqlite3.Connection):
224
224
  'once so it migrates cron.db, then retry')
225
225
 
226
226
 
227
- WEBHOOK_SESSION_ERR = ('a --webhook job runs in its own session (or a new one every run, '
228
- '--stateless), not a picked --session')
229
-
230
-
231
227
  def webhook_info(job_id: str, secret: str) -> dict:
232
228
  """What a webhook sender needs. The path goes after the server's public
233
229
  origin (the admin URL); the script doesn't know it."""
@@ -417,10 +413,11 @@ def cmd_get(args):
417
413
  def cmd_create(args):
418
414
  if not args.workspace:
419
415
  die('--workspace required')
420
- if not args.prompt:
421
- die('--prompt required')
422
416
  has_schedule = bool(args.schedule and args.schedule.strip())
423
417
  run_at_ms = parse_run_at(args.run_at)
418
+ # A webhook job's prompt is optional (its events are the message).
419
+ if not args.prompt and not args.webhook:
420
+ die('--prompt required (only a --webhook job may leave it out)')
424
421
  if not has_schedule and run_at_ms is None and not args.webhook:
425
422
  die('--schedule, --run-at or --webhook required')
426
423
  if has_schedule and run_at_ms is not None:
@@ -431,8 +428,6 @@ def cmd_create(args):
431
428
  die('--run-at must be in the future')
432
429
  timeout_sec = parse_timeout_sec(args.timeout_sec)
433
430
  session_id = parse_session(args.session)
434
- if args.webhook and session_id:
435
- die(WEBHOOK_SESSION_ERR)
436
431
  working_dir = parse_dir(args.workspace, args.dir)
437
432
  active_from = parse_active_bound('--active-from', args.active_from or '', args.timezone)
438
433
  active_until = parse_active_bound('--active-until', args.active_until or '', args.timezone)
@@ -460,7 +455,7 @@ def cmd_create(args):
460
455
  'label': args.label,
461
456
  'workspace_path': args.workspace,
462
457
  'agent_id': args.agent or 'default',
463
- 'user_prompt': args.prompt,
458
+ 'user_prompt': args.prompt or '',
464
459
  'schedule': args.schedule or '',
465
460
  'run_at': run_at_ms,
466
461
  'timezone': args.timezone,
@@ -579,8 +574,10 @@ def cmd_update(args):
579
574
  merged = {**cur, **dict(sets)}
580
575
  if not (merged.get('schedule') or '').strip() and merged.get('run_at') is None and not next_secret:
581
576
  die('--schedule, --run-at or --webhook required (this update would leave the job with none)')
582
- if next_secret and merged.get('session_id'):
583
- die(WEBHOOK_SESSION_ERR)
577
+ # Same rule as the REST PUT: only a webhook job may have an empty prompt —
578
+ # checked when this update touches the prompt or leaves webhook mode.
579
+ if not next_secret and not merged.get('user_prompt') and (args.prompt is not None or cur_secret):
580
+ die('--prompt required (only a webhook job may leave it empty)')
584
581
  validate_active_window(
585
582
  merged.get('run_at'), merged.get('active_from'), merged.get('active_until'),
586
583
  new_until=bool(args.active_until and args.active_until.strip()), webhook=bool(next_secret),
@@ -732,13 +729,13 @@ def main():
732
729
  c.add_argument('--label')
733
730
  c.add_argument('--workspace', required=True, help='absolute workspace path')
734
731
  c.add_argument('--agent', help="agent id (default: 'default')")
735
- c.add_argument('--prompt', required=True)
732
+ c.add_argument('--prompt', help='the task each run is given; optional with --webhook (then the events alone)')
736
733
  c.add_argument('--schedule', help='5-field cron expression (recurring); mutually exclusive with --run-at')
737
734
  c.add_argument('--run-at', dest='run_at',
738
735
  help='one-shot fire time, ISO-8601 (e.g. 2026-05-24T09:00) or unix ms — auto-disables after fire')
739
736
  c.add_argument('--webhook', action='store_true',
740
737
  help='fire on POST /cron-hooks/<id> instead of a schedule; prints the path + generated secret. '
741
- 'Excludes --schedule / --run-at / --session / an active window')
738
+ 'Excludes --schedule / --run-at / an active window')
742
739
  c.add_argument('--timezone', help='IANA tz, e.g. Asia/Shanghai')
743
740
  c.add_argument('--timeout-sec', dest='timeout_sec',
744
741
  help='max run time in seconds, 60–21600 (default: unset = 3600)')