openclaw-scheduler 0.2.1 → 0.2.3

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/AGENTS.md CHANGED
@@ -27,6 +27,27 @@ For manifest authoring, validation, and identity/authorization profiles, use
27
27
  - Shell jobs (`session_target: "shell"`) run without the gateway. Agent jobs
28
28
  (`session_target: "isolated"` or `"main"`) require a running gateway.
29
29
 
30
+ ## Checking Job Status — Always Poll, Never Infer
31
+
32
+ When reporting on whether a dispatched job is running, done, or stuck, **always call the status command directly** — never infer from check-in messages or notifications that appeared in your conversation.
33
+
34
+ Check-in messages are delivered asynchronously. By the time they appear, the job may already be finished, failed, or on a later step. Conversation messages are stale by definition.
35
+
36
+ ```bash
37
+ # For chilisaus-dispatched jobs:
38
+ node ~/.openclaw/chilisaus/index.mjs status --label <label>
39
+
40
+ # For scheduler jobs:
41
+ openclaw-scheduler runs list <job-id> --json
42
+ openclaw-scheduler runs running --json
43
+ ```
44
+
45
+ The `status` output gives authoritative `status` (`accepted` / `running` / `done` / `error`), `updatedAt` timestamp, and final `summary`. Use that.
46
+
47
+ **Rule: if you haven't polled status, you don't know the status.**
48
+
49
+ ---
50
+
30
51
  ## Error Handling
31
52
 
32
53
  CLI errors exit non-zero. In plain-text mode, the message goes to stderr. With
package/BEST-PRACTICES.md CHANGED
@@ -134,7 +134,7 @@ Use `isolated` when:
134
134
 
135
135
  | Rule | Bad | Good |
136
136
  |------|-----|------|
137
- | Be imperative and specific | "check kubernetes" | "Check k8s pods in requesthub-prod and requesthub-dev. List any non-Running pods." |
137
+ | Be imperative and specific | "check kubernetes" | "Check k8s pods in myapp-prod and myapp-staging. List any non-Running pods." |
138
138
  | Include a success signal | *(nothing)* | "If all pods Running, reply with exactly: HEARTBEAT_OK" |
139
139
  | Specify output format | *(nothing)* | "Format issues as: ⚠️ \<namespace\>/\<pod\>: \<status\>" |
140
140
  | State available resources | *(implicit)* | "Your memory files are in ~/.openclaw/workspace/memory/" |
@@ -147,7 +147,7 @@ Check everything and let me know if anything is wrong
147
147
 
148
148
  **Good prompt:**
149
149
  ```
150
- Check k8s pod health across requesthub-prod and requesthub-dev namespaces.
150
+ Check k8s pod health across myapp-prod and myapp-staging namespaces.
151
151
  List any non-Running pods. If all pods are Running, reply with exactly: HEARTBEAT_OK
152
152
  Format any issues as: ⚠️ <namespace>/<pod>: <status>
153
153
  ```
@@ -282,7 +282,7 @@ When the dispatcher fires an isolated job, the agent receives a message structur
282
282
  From: scheduler | result | Previous backup: 3 files committed, pushed to origin
283
283
  ---
284
284
 
285
- Check k8s pod health across requesthub-prod and requesthub-dev.
285
+ Check k8s pod health across myapp-prod and myapp-staging.
286
286
  If all pods are Running, reply with exactly: HEARTBEAT_OK
287
287
  Format any issues as: ⚠️ <namespace>/<pod>: <status>
288
288
  ```
@@ -360,6 +360,23 @@ The dispatcher picks this up on its next tick (within 15s) and creates and immed
360
360
 
361
361
  ---
362
362
 
363
+ ### Checking Job Status — Always Poll, Never Infer
364
+
365
+ When reporting on whether a dispatched job is running, done, or stuck, **always call `status` directly** — never infer from check-in messages that appeared in the conversation.
366
+
367
+ Check-in messages (from `agent-checkin.mjs` or similar) are delivered asynchronously via the scheduler inbox. By the time they appear in your conversation, the job may have already finished, failed, or moved on to a later step. Treating them as real-time signals produces stale or incorrect status reports.
368
+
369
+ ```bash
370
+ # Always do this before reporting job status:
371
+ node ~/.openclaw/chilisaus/index.mjs status --label <label>
372
+ ```
373
+
374
+ The `status` output gives you the authoritative `status` field (`accepted` / `running` / `done` / `error`), the last `updatedAt` timestamp, and the final `summary`. Use that — not the most recent check-in message.
375
+
376
+ **Rule: if you haven't polled `status`, you don't know the status.**
377
+
378
+ ---
379
+
363
380
  ### Communicating Between Jobs
364
381
 
365
382
  Jobs can pass data to each other using the inter-agent message queue. Messages injected into a job's context appear in the `--- Pending Messages ---` block at the top of the prompt.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,36 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [0.2.3] -- 2026-04-16
6
+
7
+ ### Fixed
8
+ - fix(cli): harden runtime DB path resolution so installed package layouts prefer `~/.openclaw/scheduler/scheduler.db` instead of a repo-local checkout DB
9
+ - fix(cli): refuse validation-only commands (`jobs validate`, `jobs add --dry-run`) when a source checkout detects an existing runtime DB mismatch
10
+ - test(cli): add installed-package and repo/runtime mismatch coverage for DB path hardening
11
+
12
+ ### Changed
13
+ - docs: bump minimum supported Node.js version from 20 to 22 to match the package engine requirement
14
+
15
+ ## [0.2.2] -- 2026-04-15
16
+
17
+ ### Fixed
18
+ - fix(dispatch): make completion delivery deterministic
19
+ - fix(dispatch): suppress junk completion fallbacks
20
+ - fix(package): include provider registry in npm tarball
21
+ - fix(scheduler): canonicalize isolated session auth overrides
22
+ - fix(dispatch): delete watchdog jobs on disarm instead of disable to prevent accumulation
23
+
24
+ ### Added
25
+ - feat(watcher): add stop_reason-based early delivery (Path 2a)
26
+ - feat(dispatch): auto-inject ORIGIN_CHAT_ID from deliverTo into prompt
27
+ - fix(dispatch): prefer group sessions over DM in auto-detected origin
28
+
29
+ ### Changed
30
+ - ci: upgrade checkout and setup-node actions to v5
31
+ - docs: align packaged runtime path with host layout
32
+ - docs: document local npm pack install and upgrade flow
33
+ - test: remove dead locals in watcher coverage
34
+
5
35
  ## [0.2.1] -- 2026-04-01
6
36
 
7
37
  ### Fixed
@@ -12,7 +12,7 @@ This guide is for setting up the scheduler on a **second or additional OpenClaw
12
12
  | Requirement | Notes |
13
13
  |-------------|-------|
14
14
  | macOS or Linux | Tested on macOS arm64 |
15
- | Node.js >= 20 | `node --version` (use full path if needed: `/opt/homebrew/bin/node --version`) |
15
+ | Node.js >= 22 | `node --version` (use full path if needed: `/opt/homebrew/bin/node --version`) |
16
16
  | OpenClaw gateway running | With auth token |
17
17
  | Git or SCP access | To clone/copy the repo |
18
18
 
package/INSTALL-LINUX.md CHANGED
@@ -12,7 +12,7 @@ Step-by-step guide to deploy the scheduler on a Linux host running OpenClaw.
12
12
 
13
13
  | Requirement | Notes |
14
14
  |-------------|-------|
15
- | Node.js >= 20 | Install via [nvm](https://github.com/nvm-sh/nvm) or [NodeSource](https://github.com/nodesource/distributions) |
15
+ | Node.js >= 22 | Install via [nvm](https://github.com/nvm-sh/nvm) or [NodeSource](https://github.com/nodesource/distributions) |
16
16
  | build-essential | `sudo apt install build-essential python3` — required for `better-sqlite3` native compile |
17
17
  | OpenClaw gateway running | With auth token |
18
18
  | Git | `sudo apt install git` |
@@ -38,7 +38,7 @@ Use this path only if you can't use WSL2 — for example, if OpenClaw itself is
38
38
 
39
39
  | Requirement | Install |
40
40
  |-------------|---------|
41
- | Node.js >= 20 | [nodejs.org](https://nodejs.org) -- use the LTS installer |
41
+ | Node.js >= 22 | [nodejs.org](https://nodejs.org) -- use the LTS installer |
42
42
  | pm2 | `npm install -g pm2` |
43
43
  | OpenClaw gateway | Must be running with a valid auth token |
44
44
  | Git for Windows | [git-scm.com](https://git-scm.com) or use GitHub Desktop |
package/INSTALL.md CHANGED
@@ -11,7 +11,7 @@ If you just want the fastest path to a working local install, start with the npm
11
11
  | Requirement | Notes |
12
12
  |-------------|-------|
13
13
  | macOS or Linux | Tested on macOS arm64 |
14
- | Node.js >= 20 | `node --version` |
14
+ | Node.js >= 22 | `node --version` |
15
15
  | OpenClaw gateway running | With auth token |
16
16
  | Git or SCP access | To clone/copy the repo |
17
17
 
@@ -39,6 +39,21 @@ npm exec --prefix ~/.openclaw/scheduler openclaw-scheduler -- help
39
39
 
40
40
  Runtime state for npm installs defaults to `~/.openclaw/scheduler/`, not the package directory under `node_modules/`.
41
41
 
42
+ To test the published package layout from local source before publishing, build a tarball and install it into a separate runtime prefix:
43
+
44
+ ```bash
45
+ git clone https://github.com/amittell/openclaw-scheduler /tmp/openclaw-scheduler
46
+ cd /tmp/openclaw-scheduler
47
+ npm ci
48
+ npm run verify:local
49
+ npm pack
50
+
51
+ mkdir -p ~/.openclaw/packages/openclaw-scheduler
52
+ npm install --prefix ~/.openclaw/packages/openclaw-scheduler --omit=dev --no-package-lock ./openclaw-scheduler-*.tgz
53
+ ```
54
+
55
+ In that setup, run the service from `~/.openclaw/packages/openclaw-scheduler/node_modules/openclaw-scheduler/dispatcher.js` and keep mutable state in `~/.openclaw/scheduler` via `SCHEDULER_HOME` and `SCHEDULER_DB`.
56
+
42
57
  ---
43
58
 
44
59
  ## Step 2: Install Dependencies
@@ -223,6 +238,14 @@ npm exec --prefix ~/.openclaw/scheduler openclaw-scheduler -- setup --service-mo
223
238
  npm exec --prefix ~/.openclaw/scheduler openclaw-scheduler -- setup --service-mode daemon
224
239
  ```
225
240
 
241
+ If you installed from a locally packed tarball:
242
+
243
+ ```bash
244
+ npm exec --prefix ~/.openclaw/packages/openclaw-scheduler openclaw-scheduler -- setup --service-mode agent
245
+ # or:
246
+ npm exec --prefix ~/.openclaw/packages/openclaw-scheduler openclaw-scheduler -- setup --service-mode daemon
247
+ ```
248
+
226
249
  What each mode does:
227
250
 
228
251
  - `agent` writes `~/Library/LaunchAgents/ai.openclaw.scheduler.plist` and bootstraps `gui/$UID/ai.openclaw.scheduler`
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  [![CI](https://github.com/amittell/openclaw-scheduler/actions/workflows/ci.yml/badge.svg)](https://github.com/amittell/openclaw-scheduler/actions/workflows/ci.yml)
4
4
  [![License](https://img.shields.io/badge/license-MIT-blue)]()
5
- [![Node](https://img.shields.io/badge/node-%E2%89%A520-green)](https://nodejs.org)
5
+ [![Node](https://img.shields.io/badge/node-%E2%89%A522-green)](https://nodejs.org)
6
6
 
7
7
  A durable orchestration runtime for [OpenClaw](https://openclaw.ai) agents and shell workflows. Use it when built-in cron and heartbeat stop being enough: jobs fail and disappear into logs, shell scripts depend on gateway uptime, multi-step workflows need retries and approvals, and you want a real audit trail for what ran, what failed, and what triggered what.
8
8
 
@@ -11,7 +11,7 @@ It replaces OpenClaw's built-in cron/heartbeat with a SQLite-backed scheduler th
11
11
  **Repo:** `github.com/amittell/openclaw-scheduler`
12
12
  **Default location:** `~/.openclaw/scheduler/`
13
13
  **Service:** `ai.openclaw.scheduler` (macOS launchd: LaunchAgent or LaunchDaemon)
14
- **Runtime:** Node.js 20+ (ESM), SQLite via `better-sqlite3`, cron parsing via `croner`
14
+ **Runtime:** Node.js 22+ (ESM), SQLite via `better-sqlite3`, cron parsing via `croner`
15
15
  **Tests:** run with `npm test` (full suite, in-memory SQLite)
16
16
  **Platform:** macOS · Linux · Windows (WSL2)
17
17
 
@@ -204,7 +204,24 @@ npm run verify:local # full local maintainer gate
204
204
  npm run verify:smoke # lightweight smoke gate used by GitHub Actions
205
205
  ```
206
206
 
207
- GitHub Actions runs the smoke gate plus the in-memory test suite on Linux, macOS, and Windows with Node 20. The full release gate still runs locally via `npm run verify:local` and is enforced again by `prepublishOnly`.
207
+ GitHub Actions runs the smoke gate plus the in-memory test suite on Linux and macOS with Node 22. Publishing uses Node 24 (npm 22+) for OIDC trusted publisher support. The full release gate still runs locally via `npm run verify:local` and is enforced again by `prepublishOnly`.
208
+
209
+ ### Option C: local npm pack (simulate the published package from source)
210
+
211
+ Use this when you want to test the exact package layout end users get from npm without publishing to npmjs.org yet.
212
+
213
+ ```bash
214
+ git clone https://github.com/amittell/openclaw-scheduler /tmp/openclaw-scheduler
215
+ cd /tmp/openclaw-scheduler
216
+ npm ci
217
+ npm run verify:local
218
+ npm pack
219
+
220
+ mkdir -p ~/.openclaw/packages/openclaw-scheduler
221
+ npm install --prefix ~/.openclaw/packages/openclaw-scheduler --omit=dev --no-package-lock ./openclaw-scheduler-*.tgz
222
+ ```
223
+
224
+ Point your service at `~/.openclaw/packages/openclaw-scheduler/node_modules/openclaw-scheduler/dispatcher.js`, and keep mutable state in `~/.openclaw/scheduler` via `SCHEDULER_HOME` and `SCHEDULER_DB`.
208
225
 
209
226
  The package also exports a small safe programmatic API surface for tooling:
210
227
 
@@ -1140,18 +1157,13 @@ Use this when you want scripts to enqueue only actionable signals, then a single
1140
1157
  # 1) Enqueue a signal
1141
1158
  openclaw-scheduler msg send monitor-agent main "Found 3 critical errors in prod logs"
1142
1159
 
1143
- # 2) Add a consumer shell job (every 5 minutes)
1144
- openclaw-scheduler jobs add '{
1145
- "name": "Inbox Consumer",
1146
- "schedule_cron": "*/5 * * * *",
1147
- "session_target": "shell",
1148
- "payload_message": "npm exec --prefix ~/.openclaw/scheduler openclaw-inbox-consumer -- --to YOUR_CHAT_ID",
1149
- "delivery_mode": "announce",
1150
- "delivery_channel": "telegram",
1151
- "delivery_to": "YOUR_CHAT_ID",
1152
- "run_timeout_ms": 60000,
1153
- "origin": "system"
1154
- }'
1160
+ # 2) Run the inbox consumer daemon (event-driven, watches SQLite WAL)
1161
+ # Delivers within ~250ms of a message landing in the DB.
1162
+ # No polling cron needed — the daemon watches for WAL changes.
1163
+ node scripts/inbox-consumer.mjs --watch --to YOUR_CHAT_ID --channel telegram
1164
+
1165
+ # Or as a one-shot drain (useful for testing):
1166
+ node scripts/inbox-consumer.mjs --to YOUR_CHAT_ID --channel telegram
1155
1167
  ```
1156
1168
 
1157
1169
  ---
package/UPGRADING.md CHANGED
@@ -105,6 +105,24 @@ npm install --prefix ~/.openclaw/scheduler openclaw-scheduler@latest
105
105
  npm install --prefix $env:USERPROFILE\.openclaw\scheduler openclaw-scheduler@latest
106
106
  ```
107
107
 
108
+ ### Local source tarball upgrade (`npm pack`)
109
+
110
+ Use this when you want to upgrade a host to a locally built package before publishing to npmjs.org.
111
+
112
+ ```bash
113
+ git clone https://github.com/amittell/openclaw-scheduler /tmp/openclaw-scheduler
114
+ cd /tmp/openclaw-scheduler
115
+ git pull
116
+ npm ci
117
+ npm run verify:local
118
+ npm pack
119
+
120
+ mkdir -p ~/.openclaw/packages/openclaw-scheduler
121
+ npm install --prefix ~/.openclaw/packages/openclaw-scheduler --omit=dev --no-package-lock ./openclaw-scheduler-*.tgz
122
+ ```
123
+
124
+ Keep `SCHEDULER_HOME=~/.openclaw/scheduler` and `SCHEDULER_DB=~/.openclaw/scheduler/scheduler.db`, and point the service at `~/.openclaw/packages/openclaw-scheduler/node_modules/openclaw-scheduler/dispatcher.js`.
125
+
108
126
  ---
109
127
 
110
128
  ## Step 2: Install Dependencies
@@ -277,6 +295,19 @@ sleep 3
277
295
  ssh $HOST "tail -5 /tmp/openclaw-scheduler.log && cd ~/.openclaw/scheduler && node cli.js status"
278
296
  ```
279
297
 
298
+ #### macOS host over SSH using a local tarball
299
+
300
+ ```bash
301
+ HOST=youruser@your-mac-host.lan
302
+ TARBALL=./openclaw-scheduler-*.tgz
303
+
304
+ scp $TARBALL $HOST:~/.openclaw/
305
+ ssh $HOST "mkdir -p ~/.openclaw/packages/openclaw-scheduler && npm install --prefix ~/.openclaw/packages/openclaw-scheduler --omit=dev --no-package-lock ~/.openclaw/$(basename $TARBALL)"
306
+ ssh $HOST "launchctl kickstart -k gui/\$(id -u)/ai.openclaw.scheduler"
307
+ sleep 3
308
+ ssh $HOST "tail -5 /tmp/openclaw-scheduler.log && launchctl print gui/\$(id -u)/ai.openclaw.scheduler | sed -n '1,20p'"
309
+ ```
310
+
280
311
  #### Linux / Windows WSL2 host over SSH
281
312
 
282
313
  ```bash
package/cli.js CHANGED
@@ -1,7 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
  // Scheduler CLI -- manage jobs, runs, messages, agents
3
- import { readFileSync } from 'fs';
4
- import { initDb, getDb } from './db.js';
3
+ import { existsSync, readFileSync } from 'fs';
4
+ import { dirname, join } from 'path';
5
+ import { fileURLToPath } from 'url';
6
+ import { initDb, getDb, getResolvedDbPath } from './db.js';
5
7
  import { createJob, getJob, listJobs, updateJob, deleteJob, cancelJob, runJobNow, validateJobSpec, parseInDuration, AT_JOB_CRON_SENTINEL } from './jobs.js';
6
8
  import { getRun, getRunsForJob, getRunningRuns, getStaleRuns, finishRun } from './runs.js';
7
9
  import {
@@ -9,14 +11,53 @@ import {
9
11
  ackMessage, listMessageReceipts, getTeamMessages,
10
12
  } from './messages.js';
11
13
  import { upsertAgent, getAgent, listAgents } from './agents.js';
14
+ import { resolveSchedulerHome } from './paths.js';
12
15
  import { SCHEDULER_SCHEMAS } from './scheduler-schema.js';
13
16
 
17
+ const __dirname = dirname(fileURLToPath(import.meta.url));
14
18
  const cliArgs = process.argv.slice(2);
15
19
  const jsonFlagIndex = cliArgs.indexOf('--json');
16
20
  const jsonMode = jsonFlagIndex >= 0;
17
21
  if (jsonFlagIndex >= 0) cliArgs.splice(jsonFlagIndex, 1);
18
22
  const [command, sub, ...args] = cliArgs;
19
23
 
24
+ function firstNonEmpty(value) {
25
+ if (typeof value !== 'string') return '';
26
+ const trimmed = value.trim();
27
+ return trimmed.length > 0 ? trimmed : '';
28
+ }
29
+
30
+ function isNodeModulesInstall(moduleDir) {
31
+ return /[\\/]node_modules[\\/](?:@[^\\/]+[\\/])?openclaw-scheduler(?:[\\/]|$)/.test(moduleDir);
32
+ }
33
+
34
+ function isValidationOnlyCommand(cmd, subcommand, rest) {
35
+ return cmd === 'jobs' && (
36
+ subcommand === 'validate'
37
+ || (subcommand === 'add' && rest.includes('--dry-run'))
38
+ );
39
+ }
40
+
41
+ function getDbPathMismatchNotice(env = process.env) {
42
+ if (firstNonEmpty(env.SCHEDULER_DB)) return null;
43
+ if (isNodeModulesInstall(__dirname)) return null;
44
+
45
+ const repoDbPath = join(__dirname, 'scheduler.db');
46
+ const resolvedDbPath = getResolvedDbPath();
47
+ if (resolvedDbPath !== repoDbPath) return null;
48
+
49
+ const runtimeDbPath = join(resolveSchedulerHome(env), 'scheduler.db');
50
+ if (runtimeDbPath === resolvedDbPath) return null;
51
+ if (!existsSync(runtimeDbPath)) return null;
52
+
53
+ return { resolvedDbPath, runtimeDbPath };
54
+ }
55
+
56
+ function formatDbPathMismatchNotice({ resolvedDbPath, runtimeDbPath }, { validation = false } = {}) {
57
+ const prefix = validation ? 'Refusing to run validation.' : 'Warning: source checkout CLI is using a repo-local DB.';
58
+ return `${prefix} repo-local=${resolvedDbPath} runtime=${runtimeDbPath}. Re-run via the installed package CLI or set SCHEDULER_DB explicitly.`;
59
+ }
60
+
20
61
  function usage() {
21
62
  console.log(`
22
63
  Usage: openclaw-scheduler <command> [subcommand] [options]
@@ -119,6 +160,14 @@ Capabilities:
119
160
  `);
120
161
  }
121
162
 
163
+ const dbPathMismatchNotice = getDbPathMismatchNotice(process.env);
164
+ if (dbPathMismatchNotice) {
165
+ if (isValidationOnlyCommand(command, sub, args)) {
166
+ fail(formatDbPathMismatchNotice(dbPathMismatchNotice, { validation: true }));
167
+ }
168
+ process.stderr.write(`${formatDbPathMismatchNotice(dbPathMismatchNotice)}\n`);
169
+ }
170
+
122
171
  await initDb();
123
172
 
124
173
  function fmt(obj) { return JSON.stringify(obj, null, 2); }
@@ -953,6 +1002,7 @@ switch (command) {
953
1002
  // -- Status ----------------------------------------------
954
1003
  case 'status': {
955
1004
  const db = getDb();
1005
+ const dbPath = getResolvedDbPath();
956
1006
  const jobs = listJobs();
957
1007
  const runningRuns = getRunningRuns();
958
1008
  const stale = getStaleRuns();
@@ -982,6 +1032,7 @@ switch (command) {
982
1032
  .filter(j => j.enabled && j.next_run_at)
983
1033
  .sort((a, b) => a.next_run_at.localeCompare(b.next_run_at))[0] || null;
984
1034
  const payload = {
1035
+ db_path: dbPath,
985
1036
  jobs_total: jobs.length,
986
1037
  jobs_enabled: jobs.filter(j => j.enabled).length,
987
1038
  running_runs: runningRuns.length,
@@ -997,6 +1048,7 @@ switch (command) {
997
1048
  };
998
1049
  emit(payload, () => {
999
1050
  console.log('=== OpenClaw Scheduler Status ===');
1051
+ console.log(`DB: ${dbPath}`);
1000
1052
  console.log(`Jobs: ${jobs.length} total, ${jobs.filter(j => j.enabled).length} enabled`);
1001
1053
  console.log(`Running: ${runningRuns.length}`);
1002
1054
  console.log(`Stale: ${stale.length}`);
@@ -32,6 +32,7 @@ import { randomUUID } from 'crypto';
32
32
  import { execFileSync } from 'child_process';
33
33
  import { homedir } from 'os';
34
34
  import Database from 'better-sqlite3';
35
+ import { buildTerminalCompletionPayload } from './completion.mjs';
35
36
  import { onStarted, onFinished, onStuck } from './hooks.mjs';
36
37
 
37
38
  const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -334,14 +335,28 @@ function readSessionsStore(agent = 'main') {
334
335
  *
335
336
  * @returns {string|null} - e.g. "telegram:-100200000000", or null if not found
336
337
  */
338
+ /**
339
+ * Infer the chat type ("group" | "direct" | "") from a session object and its key.
340
+ * Checks session.chatType first, then falls back to key pattern matching.
341
+ * Key patterns: agent:main:<channel>:group:<id> → group
342
+ * agent:main:<channel>:direct:<id> → direct
343
+ */
344
+ function inferChatType(key, session) {
345
+ if (session.chatType) return session.chatType;
346
+ if (key.includes(":group:")) return "group";
347
+ if (key.includes(":direct:")) return "direct";
348
+ return "";
349
+ }
350
+
337
351
  function getActiveOriginFromSessions() {
338
352
  const store = readSessionsStore("main");
339
353
  if (!store) return null;
340
354
 
341
- let best = null;
342
- let bestTime = 0;
343
355
  const TEN_MIN_MS = 10 * 60 * 1000;
344
356
 
357
+ /** @type {Array<{origin: string, updatedAt: number, chatType: string}>} */
358
+ const candidates = [];
359
+
345
360
  for (const [key, session] of Object.entries(store)) {
346
361
  // Only consider main sessions, not subagents
347
362
  // Pattern: agent:main:<channel>:<type>:<id> but NOT agent:main:subagent:*
@@ -357,19 +372,37 @@ function getActiveOriginFromSessions() {
357
372
  // Must be recently active
358
373
  if (Date.now() - updatedAt > TEN_MIN_MS) continue;
359
374
 
360
- if (updatedAt > bestTime) {
361
- // Prefer deliveryContext.to if available
362
- const deliveryTo = session.deliveryContext?.to || null;
363
- if (deliveryTo) {
364
- bestTime = updatedAt;
365
- // deliveryContext.to format: "telegram:-100200000000"
366
- // Convert to origin format: "telegram:-100200000000"
367
- best = deliveryTo;
368
- }
369
- }
375
+ // Prefer deliveryContext.to if available
376
+ const deliveryTo = session.deliveryContext?.to || null;
377
+ if (!deliveryTo) continue;
378
+
379
+ candidates.push({
380
+ origin: deliveryTo,
381
+ updatedAt,
382
+ chatType: inferChatType(key, session),
383
+ });
370
384
  }
371
385
 
372
- return best;
386
+ if (candidates.length === 0) return null;
387
+
388
+ // Tiebreaker: prefer group sessions over direct/DM sessions.
389
+ // When both a DM and a group session are recently active, the DM session
390
+ // often has a more recent updatedAt (agent just replied there), but the
391
+ // triggering context was the group chat. Within the same chat type, prefer
392
+ // the most recently updated session.
393
+ const typeScore = (chatType) => {
394
+ if (chatType === "group") return 2;
395
+ if (chatType === "direct") return 0;
396
+ return 1; // unknown / other
397
+ };
398
+
399
+ candidates.sort((a, b) => {
400
+ const scoreDiff = typeScore(b.chatType) - typeScore(a.chatType);
401
+ if (scoreDiff !== 0) return scoreDiff;
402
+ return b.updatedAt - a.updatedAt;
403
+ });
404
+
405
+ return candidates[0].origin;
373
406
  }
374
407
 
375
408
  /**
@@ -507,12 +540,12 @@ function disarmWatchdog(label) {
507
540
  if (!entry?.watchdogJobId) return;
508
541
  try {
509
542
  const schedulerCli = join(__dirname, '..', 'cli.js');
510
- execFileSync(process.execPath, [schedulerCli, 'jobs', 'disable', entry.watchdogJobId], {
543
+ execFileSync(process.execPath, [schedulerCli, 'jobs', 'delete', entry.watchdogJobId], {
511
544
  encoding: 'utf-8',
512
545
  timeout: 5000,
513
546
  stdio: ['pipe', 'pipe', 'pipe'],
514
547
  });
515
- process.stderr.write(`[${BRAND}] watchdog disarmed for ${label}\n`);
548
+ process.stderr.write(`[${BRAND}] watchdog deleted for ${label}\n`);
516
549
  } catch (err) {
517
550
  process.stderr.write(`[${BRAND}] watchdog disarm failed for ${label}: ${err.message}\n`);
518
551
  }
@@ -599,6 +632,14 @@ async function cmdEnqueue(flags) {
599
632
  const deliverMode = flags['delivery-mode'] || 'announce';
600
633
  const mode = flags.mode || 'fresh';
601
634
 
635
+ // -- Auto-inject ORIGIN_CHAT_ID into prompt message ---------
636
+ // Ensures the spawned agent always knows where to send message tool calls,
637
+ // matching the delivery target. Skip if already present (caller is explicit)
638
+ // or if there's no delivery target.
639
+ if (deliverTo && !message.includes('ORIGIN_CHAT_ID:')) {
640
+ message = `ORIGIN_CHAT_ID: ${deliverTo}\n\n${message}`;
641
+ }
642
+
602
643
  // -- Verify command flag -----------------------------------
603
644
  const verifyCmd = flags['verify-cmd'] || null;
604
645
 
@@ -849,7 +890,8 @@ async function cmdEnqueue(flags) {
849
890
  const watcherPath = join(__dirname, 'watcher.mjs');
850
891
  // Watcher timeout = session timeout + 120s buffer for startup/polling
851
892
  const watcherTimeoutS = timeoutS + 120;
852
- const watcherCmd = `DISPATCH_LABELS_PATH='${sq(LABELS_PATH)}' '${sq(process.execPath)}' '${sq(watcherPath)}' --label '${sq(label)}' --timeout ${watcherTimeoutS} --poll-interval 20`;
893
+ const idleThresholdS = flags['idle-threshold'] || '300';
894
+ const watcherCmd = `DISPATCH_LABELS_PATH='${sq(LABELS_PATH)}' '${sq(process.execPath)}' '${sq(watcherPath)}' --label '${sq(label)}' --timeout ${watcherTimeoutS} --poll-interval 20 --idle-threshold ${idleThresholdS}`;
853
895
 
854
896
  const nowUtc = new Date().toISOString().replace('T', ' ').slice(0, 19);
855
897
  const jobSpec = JSON.stringify({
@@ -1116,6 +1158,7 @@ function cmdStatus(flags) {
1116
1158
  spawnedAt: current.spawnedAt,
1117
1159
  updatedAt: current.updatedAt,
1118
1160
  summary: current.summary || null,
1161
+ completion: current.completion || null,
1119
1162
  error: current.error || null,
1120
1163
  liveness,
1121
1164
  ...(syncAction ? { syncAction } : {}),
@@ -1405,6 +1448,7 @@ function cmdResult(flags) {
1405
1448
  status: entry.status,
1406
1449
  spawnedAt: entry.spawnedAt,
1407
1450
  summary: entry.summary || (lastReply ? lastReply.slice(0, 500) : null),
1451
+ completion: entry.completion || null,
1408
1452
  lastReply: lastReply || null,
1409
1453
  error: entry.error || null,
1410
1454
  });
@@ -1474,15 +1518,15 @@ async function cmdDone(flags) {
1474
1518
  }
1475
1519
  }
1476
1520
 
1477
- // Bug 1 fix: truncate summary to 300 chars (delivery path silently truncates at 500)
1478
- const MAX_SUMMARY = 300;
1479
- let summary = rawSummary;
1480
- if (rawSummary.length > MAX_SUMMARY) {
1481
- process.stderr.write(
1482
- `[${BRAND}] warn: --summary truncated from ${rawSummary.length} chars to ${MAX_SUMMARY} chars\n`,
1483
- );
1484
- summary = rawSummary.slice(0, MAX_SUMMARY);
1485
- }
1521
+ // Summary passes through as-is for raw diagnostics, but we also persist a
1522
+ // first-class completion payload with deterministic delivery text so the
1523
+ // watcher/post-office path never depends solely on transcript recovery.
1524
+ const completion = buildTerminalCompletionPayload({
1525
+ summary: rawSummary,
1526
+ checklist,
1527
+ sha,
1528
+ });
1529
+ const summary = completion.summary || rawSummary;
1486
1530
 
1487
1531
  const existing = getLabel(label);
1488
1532
 
@@ -1598,7 +1642,7 @@ async function cmdDone(flags) {
1598
1642
  // Label was never registered (e.g. direct subagent spawn, not via enqueue).
1599
1643
  // This is not an error -- the work completed, the label just wasn't tracked.
1600
1644
  process.stderr.write(`[${BRAND}] warn: no session found for label "${label}" -- registering as done\n`);
1601
- setLabel(label, { status: 'done', summary, ...(sha ? { sha } : {}) });
1645
+ setLabel(label, { status: 'done', summary, completion, ...(sha ? { sha } : {}) });
1602
1646
 
1603
1647
  // No watcher is polling for this label, so actively notify via the gateway
1604
1648
  // post office using delivery config from config.json as fallback target.
@@ -1622,13 +1666,14 @@ async function cmdDone(flags) {
1622
1666
  process.stderr.write(`[${BRAND}] warn: no deliverTo in config -- completion not delivered for "${label}"\n`);
1623
1667
  }
1624
1668
 
1625
- out({ ok: true, label, status: 'done', summary, message: 'Label not previously registered; marked done.' });
1669
+ out({ ok: true, label, status: 'done', summary, completion, message: 'Label not previously registered; marked done.' });
1626
1670
  return;
1627
1671
  }
1628
1672
 
1629
1673
  setLabel(label, {
1630
1674
  status: 'done',
1631
1675
  summary,
1676
+ completion,
1632
1677
  ...(sha ? { sha } : {}),
1633
1678
  });
1634
1679
 
@@ -1647,7 +1692,7 @@ async function cmdDone(flags) {
1647
1692
  session_key: existing.sessionKey || null,
1648
1693
  }).catch(() => {});
1649
1694
 
1650
- out({ ok: true, label, status: 'done', summary, message: 'Label marked done via agent signal.' });
1695
+ out({ ok: true, label, status: 'done', summary, completion, message: 'Label marked done via agent signal.' });
1651
1696
  }
1652
1697
 
1653
1698
  /**
@@ -31,6 +31,7 @@ import { readFileSync, writeFileSync, renameSync, statSync } from 'fs';
31
31
  import { dirname, join } from 'path';
32
32
  import { homedir } from 'os';
33
33
  import { fileURLToPath } from 'url';
34
+ import { resolveCompletionDelivery } from './completion.mjs';
34
35
  import { sendMessage } from '../messages.js';
35
36
 
36
37
  const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -53,6 +54,7 @@ const ACTIVITY_POLL_MS = 30_000;
53
54
  * so PING_INTERVAL_MS must stay well below PING_STALE_MS (3 * 60_000). */
54
55
  const PING_INTERVAL_MS = 60_000; // 60 seconds
55
56
 
57
+
56
58
  function getGatewayToken() {
57
59
  if (process.env.OPENCLAW_GATEWAY_TOKEN) return process.env.OPENCLAW_GATEWAY_TOKEN;
58
60
  try {
@@ -656,6 +658,49 @@ function getJsonlMidTurnReason(sessionId, agentDir = 'main') {
656
658
  return null; // Last assistant entry appears to be a complete text reply -- safe to proceed
657
659
  }
658
660
 
661
+ /**
662
+ * Read the last assistant entry's stop_reason from the session JSONL.
663
+ * Returns the stop_reason string (e.g. 'end_turn', 'tool_use') or null if unavailable.
664
+ *
665
+ * Uses readJsonlLastLines with n=10 to scan enough history to find the last
666
+ * assistant message even if several tool_result entries follow it.
667
+ *
668
+ * @param {string} sessionId - Internal session UUID
669
+ * @param {string} agentDir - Agent directory (default: 'main')
670
+ * @returns {string|null} stop_reason string or null
671
+ */
672
+ function getSessionStopReason(sessionId, agentDir = 'main') {
673
+ const lastLines = readJsonlLastLines(sessionId, agentDir, 10);
674
+ if (!lastLines) return null;
675
+ // Walk backwards to find last role=assistant entry
676
+ for (let i = lastLines.length - 1; i >= 0; i--) {
677
+ const entry = lastLines[i];
678
+ if (entry?.role === 'assistant') {
679
+ return entry?.stop_reason ?? null;
680
+ }
681
+ }
682
+ return null;
683
+ }
684
+
685
+ /**
686
+ * Returns true if the session has cleanly finished with stop_reason=end_turn.
687
+ * Requires:
688
+ * - stop_reason === 'end_turn' on the last assistant entry
689
+ * - getJsonlMidTurnReason() returns null (no in-flight tool calls or pending results)
690
+ *
691
+ * Used for Path 2a early delivery: skip FLAT_WINDOW_MS wait when session is
692
+ * verifiably done via JSONL stop_reason signal.
693
+ *
694
+ * @param {string} sessionId - Internal session UUID
695
+ * @param {string} agentDir - Agent directory (default: 'main')
696
+ * @returns {boolean}
697
+ */
698
+ function isSessionCleanlyFinished(sessionId, agentDir = 'main') {
699
+ if (getJsonlMidTurnReason(sessionId, agentDir) !== null) return false;
700
+ const stopReason = getSessionStopReason(sessionId, agentDir);
701
+ return stopReason === 'end_turn';
702
+ }
703
+
659
704
  /**
660
705
  * Update labels.json to mark the watched label as done (best-effort, atomic write).
661
706
  * Called before exit to ensure labels.json is reconciled even if sync fails.
@@ -698,7 +743,7 @@ function markLabelError(label, errorSummary) {
698
743
  * If the verify command exits non-zero, the job is marked as error and
699
744
  * an alert is written to stdout (delivery target receives the failure notice).
700
745
  */
701
- function deliverResult(label, lastReply, fallbackSummary) {
746
+ function deliverResult(label, lastReply, fallbackSummary, completionPayload = null) {
702
747
  // -- verify-cmd check -----------------------------------------------------
703
748
  // Run the stored verify-cmd (if any) before declaring the job done.
704
749
  // A non-zero exit flips the job to error state and sends an alert instead.
@@ -732,20 +777,21 @@ function deliverResult(label, lastReply, fallbackSummary) {
732
777
  }
733
778
 
734
779
  // Update labels.json before exiting -- prevents stuck detector false positives
735
- const summary = fallbackSummary || (lastReply ? lastReply.slice(0, 500) : null);
736
- markLabelDone(label, summary);
780
+ const completion = resolveCompletionDelivery({
781
+ lastReply,
782
+ completion: completionPayload,
783
+ fallbackSummary,
784
+ });
785
+ markLabelDone(label, completion.summary);
737
786
 
738
- if (lastReply) {
787
+ if (completion.deliveryText) {
739
788
  const maxLen = 3500;
740
- const reply = lastReply.length > maxLen
741
- ? lastReply.slice(0, maxLen) + '\n\n..[truncated]'
742
- : lastReply;
789
+ const reply = completion.deliveryText.length > maxLen
790
+ ? completion.deliveryText.slice(0, maxLen) + '\n\n..[truncated]'
791
+ : completion.deliveryText;
743
792
  process.stdout.write(`🌶️ *dispatch* [${label}] completed:\n\n${reply}\n`);
744
793
  } else {
745
- process.stdout.write(
746
- `🌶️ *dispatch* [${label}] completed (no reply captured)\n` +
747
- `Summary: ${fallbackSummary || 'none'}\n`
748
- );
794
+ process.stderr.write(`[watcher] [${label}] completion delivery suppressed (no meaningful reply or summary)\n`);
749
795
  }
750
796
  process.exit(0);
751
797
  }
@@ -821,6 +867,7 @@ let recoverySessionKey = null; // captured during polling for steer/kill
821
867
 
822
868
  // Module-level state accessible by SIGTERM handler
823
869
  let lastKnownReply = null;
870
+ let lastKnownCompletion = null;
824
871
 
825
872
  // -- SIGTERM handler (scheduler kills watcher with SIGTERM before SIGKILL) --
826
873
  // Ensures labels.json is updated and a delivery attempt is made even when killed.
@@ -830,9 +877,10 @@ process.on('SIGTERM', () => {
830
877
  try {
831
878
  const result = dispatch('result', ['--label', label]);
832
879
  if (result?.lastReply) lastKnownReply = result.lastReply;
880
+ if (result?.completion) lastKnownCompletion = result.completion;
833
881
  } catch {}
834
882
  // deliverResult calls process.exit(0) internally
835
- deliverResult(label, lastKnownReply, 'interrupted by watcher timeout');
883
+ deliverResult(label, lastKnownReply, 'interrupted by watcher timeout', lastKnownCompletion);
836
884
  });
837
885
 
838
886
  // -- Rolling deadline vars ------------------------------------
@@ -1007,7 +1055,7 @@ while (Date.now() < deadline) {
1007
1055
  // If the session DID produce a lastReply before being killed, deliver it normally.
1008
1056
  if (sessionEverFound && isGatewayRestartKill(status.summary)) {
1009
1057
  const gwCheckResult = dispatch('result', ['--label', label]);
1010
- if (!gwCheckResult?.lastReply) {
1058
+ if (!gwCheckResult?.lastReply && !gwCheckResult?.completion?.deliveryText) {
1011
1059
  // No result captured -- session was killed before completing
1012
1060
  const retryCount = getGwRestartRetryCount(label);
1013
1061
  if (retryCount >= MAX_GW_RESTART_RETRIES) {
@@ -1046,7 +1094,7 @@ while (Date.now() < deadline) {
1046
1094
  process.exit(1);
1047
1095
  }
1048
1096
  }
1049
- // lastReply present -- session completed before/during kill; fall through to normal delivery
1097
+ // lastReply or completion payload present -- session completed before/during kill; fall through to normal delivery
1050
1098
  }
1051
1099
 
1052
1100
  // Reset gw-restart retry count on successful completion
@@ -1082,7 +1130,23 @@ while (Date.now() < deadline) {
1082
1130
  }
1083
1131
  }
1084
1132
  const result = dispatch('result', ['--label', label]);
1085
- deliverResult(label, result?.lastReply, status.summary);
1133
+ deliverResult(label, result?.lastReply, status.summary, result?.completion || status?.completion || null);
1134
+ }
1135
+
1136
+ // -- Path 2a: stop_reason early delivery (clean end_turn) --
1137
+ // If the last assistant message has stop_reason=end_turn and no tool calls
1138
+ // are in flight, deliver immediately without waiting for FLAT_WINDOW_MS.
1139
+ // This is the fast path for sessions that write stop_reason to JSONL.
1140
+ if (status.sessionKey) {
1141
+ const _e2a = getSessionStoreEntry(status.sessionKey);
1142
+ const _sid2a = _e2a?.sessionId || null;
1143
+ const _adir2a = (status.sessionKey.split(':')[1]) || 'main';
1144
+ if (_sid2a && isSessionCleanlyFinished(_sid2a, _adir2a)) {
1145
+ process.stderr.write(`[watcher] stop_reason=end_turn detected -- delivering early\n`);
1146
+ const result = dispatch('result', ['--label', label]);
1147
+ deliverResult(label, result?.lastReply, 'completed (stop_reason=end_turn)', result?.completion || null);
1148
+ // deliverResult exits
1149
+ }
1086
1150
  }
1087
1151
 
1088
1152
  // -- Path 2: status says 'running' but session may be idle -
@@ -1094,8 +1158,8 @@ while (Date.now() < deadline) {
1094
1158
  const ageMs = status.liveness?.ageMs;
1095
1159
  if (ageMs != null && ageMs >= IDLE_RESULT_CHECK_MS) {
1096
1160
  const result = dispatch('result', ['--label', label]);
1097
- if (result?.lastReply) {
1098
- deliverResult(label, result.lastReply, null);
1161
+ if (result?.lastReply || result?.completion?.deliveryText) {
1162
+ deliverResult(label, result?.lastReply || null, null, result?.completion || null);
1099
1163
  }
1100
1164
  }
1101
1165
 
@@ -1106,16 +1170,15 @@ while (Date.now() < deadline) {
1106
1170
  // Timed out -- try one last result check
1107
1171
  const finalResult = dispatch('result', ['--label', label]);
1108
1172
  const finalStatus = dispatch('status', ['--label', label]);
1109
- if (finalResult?.lastReply) {
1173
+ if (finalStatus?.status === 'done') {
1110
1174
  const rc = getRetryCount(label);
1111
1175
  if (rc > 0) setRetryCount(label, 0);
1112
- deliverResult(label, finalResult.lastReply, finalStatus?.summary || null);
1113
- }
1114
- // If status is explicitly done, exit cleanly even without lastReply
1115
- if (finalStatus?.status === 'done') {
1116
- markDoneSync(finalStatus?.summary || 'completed');
1117
- process.stdout.write(`✅ dispatch [${label}] completed (status=done, no lastReply captured)\n`);
1118
- process.exit(0);
1176
+ deliverResult(
1177
+ label,
1178
+ finalResult?.lastReply || null,
1179
+ finalStatus?.summary || null,
1180
+ finalResult?.completion || finalStatus?.completion || null,
1181
+ );
1119
1182
  }
1120
1183
  // If status is interrupted (auto-resolved as incomplete), exit non-zero
1121
1184
  if (finalStatus?.status === 'interrupted') {
@@ -1174,9 +1237,9 @@ if (sessionInternalId) {
1174
1237
  // If the session already completed (gateway pruned it -> null tokens), exit cleanly.
1175
1238
  if (statusAtDeadline?.status === 'done' || baselineTokens === null) {
1176
1239
  const r = dispatch('result', ['--label', label]);
1177
- if (r?.lastReply) {
1240
+ if (r?.lastReply || r?.completion?.deliveryText) {
1178
1241
  // deliverResult calls process.exit(0) internally
1179
- deliverResult(label, r.lastReply, statusAtDeadline?.summary || null);
1242
+ deliverResult(label, r?.lastReply || null, statusAtDeadline?.summary || null, r?.completion || null);
1180
1243
  }
1181
1244
  // Status is explicitly done -- exit cleanly, no timeout noise
1182
1245
  if (statusAtDeadline?.status === 'done') {
@@ -1211,12 +1274,12 @@ while (Date.now() - flatSince < FLAT_WINDOW_MS) {
1211
1274
  if (st?.status === 'done') {
1212
1275
  const r = dispatch('result', ['--label', label]);
1213
1276
  // deliverResult calls process.exit(0) internally
1214
- deliverResult(label, r?.lastReply, st.summary);
1277
+ deliverResult(label, r?.lastReply || null, st.summary, r?.completion || st?.completion || null);
1215
1278
  }
1216
1279
  const r2 = dispatch('result', ['--label', label]);
1217
- if (r2?.lastReply) {
1280
+ if (r2?.lastReply || r2?.completion?.deliveryText) {
1218
1281
  // deliverResult calls process.exit(0) internally
1219
- deliverResult(label, r2.lastReply, null);
1282
+ deliverResult(label, r2?.lastReply || null, null, r2?.completion || null);
1220
1283
  }
1221
1284
 
1222
1285
  // Token growth?
@@ -1305,12 +1368,12 @@ if (sessionInternalId) {
1305
1368
  if (stExt?.status === 'done') {
1306
1369
  const rExt = dispatch('result', ['--label', label]);
1307
1370
  // deliverResult calls process.exit(0) internally
1308
- deliverResult(label, rExt?.lastReply, stExt.summary);
1371
+ deliverResult(label, rExt?.lastReply || null, stExt.summary, rExt?.completion || stExt?.completion || null);
1309
1372
  }
1310
1373
  const rExt2 = dispatch('result', ['--label', label]);
1311
- if (rExt2?.lastReply) {
1374
+ if (rExt2?.lastReply || rExt2?.completion?.deliveryText) {
1312
1375
  // deliverResult calls process.exit(0) internally
1313
- deliverResult(label, rExt2.lastReply, null);
1376
+ deliverResult(label, rExt2?.lastReply || null, null, rExt2?.completion || null);
1314
1377
  }
1315
1378
 
1316
1379
  // JSONL mtime check during extended wait
@@ -1362,12 +1425,12 @@ for (const round of steerRounds) {
1362
1425
  if (st2?.status === 'done') {
1363
1426
  const r3 = dispatch('result', ['--label', label]);
1364
1427
  // deliverResult calls process.exit(0) internally
1365
- deliverResult(label, r3?.lastReply, st2.summary);
1428
+ deliverResult(label, r3?.lastReply || null, st2.summary, r3?.completion || st2?.completion || null);
1366
1429
  }
1367
1430
  const r3 = dispatch('result', ['--label', label]);
1368
- if (r3?.lastReply) {
1431
+ if (r3?.lastReply || r3?.completion?.deliveryText) {
1369
1432
  // deliverResult calls process.exit(0) internally
1370
- deliverResult(label, r3.lastReply, null);
1433
+ deliverResult(label, r3?.lastReply || null, null, r3?.completion || null);
1371
1434
  }
1372
1435
 
1373
1436
  if (!round.msg && steerSessionKey) {
@@ -1380,8 +1443,8 @@ for (const round of steerRounds) {
1380
1443
  if (st3?.status === 'done') {
1381
1444
  // Check if a result was captured before marking as error
1382
1445
  const r4 = dispatch('result', ['--label', label]);
1383
- if (r4?.lastReply) {
1384
- deliverResult(label, r4.lastReply, st3.summary); // deliverResult calls process.exit(0)
1446
+ if (r4?.lastReply || r4?.completion?.deliveryText) {
1447
+ deliverResult(label, r4?.lastReply || null, st3.summary, r4?.completion || st3?.completion || null); // deliverResult calls process.exit(0)
1385
1448
  }
1386
1449
  markLabelError(label, 'timed out -- killed after steer attempts (no result captured)');
1387
1450
  process.stdout.write(`⏱ dispatch [${label}] killed after steer attempts -- no result captured\n`);
@@ -1083,7 +1083,10 @@ export async function executeAgent(job, ctx, deps) {
1083
1083
  // the warm session. This avoids full agent bootstrap on every dispatch --
1084
1084
  // memory search, plugin init, and context loading only happen on the first
1085
1085
  // run. Later runs get a pre-warmed session with context already loaded.
1086
- const sessionKey = job.preferred_session_key || `scheduler:${job.id}`;
1086
+ const requestedSessionKey = job.preferred_session_key || `scheduler:${job.id}`;
1087
+ const sessionKey = requestedSessionKey.startsWith('agent:')
1088
+ ? requestedSessionKey
1089
+ : `agent:${job.agent_id || 'main'}:${requestedSessionKey}`;
1087
1090
  updateRunSession(ctx.run.id, sessionKey, null);
1088
1091
 
1089
1092
  // Mark agent as busy
@@ -1119,6 +1122,37 @@ export async function executeAgent(job, ctx, deps) {
1119
1122
  }
1120
1123
  }
1121
1124
 
1125
+ // Always sync the live auth store to the agent's auth-profiles.json BEFORE
1126
+ // every agent turn. This ensures sessions that reuse a stable key (scheduler:<jobId>)
1127
+ // always have fresh credentials -- token refreshes, order changes, and new
1128
+ // profiles are picked up automatically without requiring an explicit auth_profile
1129
+ // on every job.
1130
+ const { syncAuthStoreToSession: syncAuth } = deps;
1131
+ if (typeof syncAuth === 'function') {
1132
+ const syncResult = syncAuth(job.agent_id || 'main');
1133
+ if (syncResult.ok) {
1134
+ log('debug', `Synced live auth store to agent '${job.agent_id || 'main'}'`, { jobId: job.id });
1135
+ } else {
1136
+ log('warn', `Failed to sync auth store: ${syncResult.error}`, { jobId: job.id });
1137
+ }
1138
+ }
1139
+
1140
+ // Apply auth profile to session store BEFORE the agent turn.
1141
+ // The x-openclaw-auth-profile HTTP header is not read by the gateway (dead header).
1142
+ // Writing authProfileOverride directly to sessions.json is the effective mechanism
1143
+ // for auth profile propagation to isolated/embedded sessions.
1144
+ if (resolvedAuthProfile && resolvedAuthProfile !== 'inherit') {
1145
+ const { applyAuthProfileToSessionStore: applyAuthProfile } = deps;
1146
+ if (typeof applyAuthProfile === 'function') {
1147
+ const applyResult = applyAuthProfile(sessionKey, resolvedAuthProfile, job.agent_id || 'main');
1148
+ if (applyResult.ok) {
1149
+ log('debug', `Applied auth profile '${resolvedAuthProfile}' to session store for ${sessionKey}`, { jobId: job.id });
1150
+ } else {
1151
+ log('warn', `Failed to apply auth profile to session store: ${applyResult.error}`, { jobId: job.id, sessionKey });
1152
+ }
1153
+ }
1154
+ }
1155
+
1122
1156
  const turnResult = await runAgentTurnWithActivityTimeout({
1123
1157
  message: prompt,
1124
1158
  agentId: job.agent_id || 'main',
package/dispatcher.js CHANGED
@@ -53,6 +53,8 @@ import { upsertAgent, setAgentStatus } from './agents.js';
53
53
  import {
54
54
  runAgentTurnWithActivityTimeout, sendSystemEvent, getAllSubAgentSessions, listSessions,
55
55
  deliverMessage, checkGatewayHealth, waitForGateway, resolveDeliveryAlias,
56
+ applyAuthProfileToSessionStore,
57
+ syncAuthStoreToSession,
56
58
  } from './gateway.js';
57
59
  import { normalizeShellResult } from './shell-result.js';
58
60
  import {
@@ -307,6 +309,8 @@ function buildDispatchDeps() {
307
309
  updateContextSummary, releaseIdempotencyKey,
308
310
  matchesSentinel, detectTransientError,
309
311
  listSessions,
312
+ applyAuthProfileToSessionStore,
313
+ syncAuthStoreToSession,
310
314
  // Finalize
311
315
  updateIdempotencyResultHash,
312
316
  shouldRetry, scheduleRetry,
@@ -378,11 +382,22 @@ function buildJobPrompt(job, run) {
378
382
  );
379
383
  }
380
384
 
381
- // Include any pending messages for this agent
385
+ // Include any pending messages for this agent.
386
+ // getInbox() without includeDelivered already filters to status='pending' only,
387
+ // but we add an explicit guard here to log and skip any message that slipped
388
+ // through with status='delivered' or 'read' -- re-displaying such messages
389
+ // would cause duplicate notifications when the inbox-consumer later picks them up.
382
390
  const inbox = getInbox(job.agent_id || 'main', { limit: 5 });
383
- if (inbox.length > 0) {
391
+ const injectableMessages = inbox.filter(msg => {
392
+ if (msg.status && msg.status !== 'pending') {
393
+ log('warn', `buildJobPrompt: skipping non-pending message ${msg.id} (status=${msg.status}) for agent ${job.agent_id || 'main'}`);
394
+ return false;
395
+ }
396
+ return true;
397
+ });
398
+ if (injectableMessages.length > 0) {
384
399
  parts.push('\n--- Pending Messages ---');
385
- for (const msg of inbox) {
400
+ for (const msg of injectableMessages) {
386
401
  const kindLabel = msg.kind && !['text', 'result', 'status', 'system', 'spawn'].includes(msg.kind)
387
402
  ? `[${msg.kind}]${msg.owner ? ` (owner: ${msg.owner})` : ''} `
388
403
  : '';
@@ -402,7 +417,7 @@ function buildJobPrompt(job, run) {
402
417
 
403
418
  // Collect context metadata
404
419
  const contextMeta = {
405
- messages_injected: inbox.length,
420
+ messages_injected: injectableMessages.length,
406
421
  scope: job.payload_scope || 'own',
407
422
  job_class: job.job_class || 'standard',
408
423
  delivery_guarantee: job.delivery_guarantee || 'at-most-once',
package/gateway.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // Gateway API client -- independent dispatch via chat completions + system events
2
2
  import { execFileSync } from 'child_process';
3
- import { readFileSync } from 'fs';
3
+ import { readFileSync, writeFileSync, existsSync, copyFileSync, mkdirSync } from 'fs';
4
4
  import { homedir } from 'os';
5
5
  import { join } from 'path';
6
6
  import { getDb } from './db.js';
@@ -471,3 +471,119 @@ export async function waitForGateway(timeoutMs = 30000, intervalMs = 2000) {
471
471
  }
472
472
  return false;
473
473
  }
474
+
475
+ /**
476
+ * Write authProfileOverride directly to the gateway's sessions.json store.
477
+ *
478
+ * The gateway reads sessions.json on each agent turn (with mtime-based cache
479
+ * invalidation), so writing here before dispatch ensures the embedded runner
480
+ * picks up the correct auth profile.
481
+ *
482
+ * The x-openclaw-auth-profile HTTP header sent by runAgentTurnWithActivityTimeout
483
+ * is NOT read by the gateway (dead header). This direct store write is the
484
+ * effective mechanism for auth profile propagation to isolated sessions.
485
+ *
486
+ * @param {string} sessionKey - Session key as used in the HTTP request (e.g. 'scheduler:<jobId>')
487
+ * @param {string} authProfile - Auth profile ID (e.g. 'anthropic:gmail')
488
+ * @param {string} [agentId='main'] - Agent ID for store path resolution
489
+ * @returns {{ ok: boolean, error?: string }}
490
+ */
491
+ export function applyAuthProfileToSessionStore(sessionKey, authProfile, agentId = 'main') {
492
+ if (!sessionKey || !authProfile) {
493
+ return { ok: false, error: 'sessionKey and authProfile are required' };
494
+ }
495
+
496
+ // The gateway may persist session state under either the canonical agent-scoped
497
+ // key or the flat transport key, depending on which path created the session.
498
+ // Keep both aliases in sync so isolated scheduler jobs cannot miss the override.
499
+ const canonicalMatch = sessionKey.match(/^agent:[^:]+:(.+)$/);
500
+ const canonicalKey = sessionKey.startsWith('agent:')
501
+ ? sessionKey
502
+ : `agent:${agentId}:${sessionKey}`;
503
+ const flatSessionKey = canonicalMatch?.[1] || sessionKey;
504
+ const keyAliases = Array.from(new Set([canonicalKey, flatSessionKey]));
505
+ const sessionsPath = join(HOME_DIR, '.openclaw', 'agents', agentId, 'sessions', 'sessions.json');
506
+
507
+ try {
508
+ if (!existsSync(sessionsPath)) {
509
+ return { ok: false, error: `sessions.json not found at ${sessionsPath}` };
510
+ }
511
+
512
+ const raw = readFileSync(sessionsPath, 'utf-8');
513
+ const store = JSON.parse(raw);
514
+
515
+ const now = Date.now();
516
+ let changed = false;
517
+
518
+ for (const key of keyAliases) {
519
+ const entry = store[key];
520
+ if (!entry) {
521
+ // Session doesn't exist yet -- create a minimal entry.
522
+ // The gateway will populate the rest on the first agent turn.
523
+ store[key] = {
524
+ updatedAt: now,
525
+ authProfileOverride: authProfile,
526
+ authProfileOverrideSource: 'user',
527
+ };
528
+ changed = true;
529
+ continue;
530
+ }
531
+
532
+ if (entry.authProfileOverride !== authProfile || entry.authProfileOverrideSource !== 'user') {
533
+ // Update existing entry
534
+ entry.authProfileOverride = authProfile;
535
+ entry.authProfileOverrideSource = 'user';
536
+ entry.updatedAt = now;
537
+ // Clear compaction count so the override sticks across compactions
538
+ delete entry.authProfileOverrideCompactionCount;
539
+ changed = true;
540
+ }
541
+ }
542
+
543
+ if (!changed) {
544
+ return { ok: true };
545
+ }
546
+
547
+ writeFileSync(sessionsPath, JSON.stringify(store), 'utf-8');
548
+ return { ok: true };
549
+ } catch (err) {
550
+ return { ok: false, error: `Failed to update sessions.json: ${err.message}` };
551
+ }
552
+ }
553
+
554
+ /**
555
+ * Sync the live auth-profiles.json from ~/.openclaw/credentials/ to the agent's
556
+ * auth store at ~/.openclaw/agents/<agentId>/agent/auth-profiles.json.
557
+ *
558
+ * This ensures scheduler sessions always use fresh credentials (tokens, order,
559
+ * default profile) even when no explicit auth_profile is set on the job.
560
+ * Without this, sessions created from a stable session key inherit a stale
561
+ * copy of the auth store that was snapshotted when the session was first created.
562
+ *
563
+ * This is a fast file-copy operation (~1ms) and is safe to call before every
564
+ * agent turn.
565
+ *
566
+ * @param {string} [agentId='main'] - Agent ID for store path resolution
567
+ * @returns {{ ok: boolean, error?: string }}
568
+ */
569
+ export function syncAuthStoreToSession(agentId = 'main') {
570
+ const livePath = join(HOME_DIR, '.openclaw', 'credentials', 'auth-profiles.json');
571
+ const agentStorePath = join(HOME_DIR, '.openclaw', 'agents', agentId, 'agent', 'auth-profiles.json');
572
+
573
+ try {
574
+ if (!existsSync(livePath)) {
575
+ return { ok: false, error: `Live auth store not found at ${livePath}` };
576
+ }
577
+
578
+ // Ensure the agent directory exists
579
+ const agentDir = join(HOME_DIR, '.openclaw', 'agents', agentId, 'agent');
580
+ if (!existsSync(agentDir)) {
581
+ mkdirSync(agentDir, { recursive: true });
582
+ }
583
+
584
+ copyFileSync(livePath, agentStorePath);
585
+ return { ok: true };
586
+ } catch (err) {
587
+ return { ok: false, error: `Failed to sync auth store: ${err.message}` };
588
+ }
589
+ }
package/jobs.js CHANGED
@@ -324,7 +324,7 @@ export function validateJobSpec(opts, currentJob = null, mode = 'create') {
324
324
  if (!_isExempt && (!merged.delivery_to || String(merged.delivery_to).trim() === '')) {
325
325
  throw new Error(
326
326
  'delivery_to is required on job insert. Set it to the origin chat_id ' +
327
- '(e.g. -5240776892 for AI Assisted Degeneracy, or 484946046 for Alex DM).'
327
+ '(e.g. -1001234567890 for a group chat, or 987654321 for a personal DM).'
328
328
  );
329
329
  }
330
330
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openclaw-scheduler",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "description": "SQLite-backed job scheduler and workflow engine for OpenClaw agents",
5
5
  "type": "module",
6
6
  "main": "./index.js",
@@ -17,7 +17,7 @@
17
17
  "./package.json": "./package.json"
18
18
  },
19
19
  "engines": {
20
- "node": ">=20"
20
+ "node": ">=22"
21
21
  },
22
22
  "scripts": {
23
23
  "start": "node dispatcher.js",
@@ -70,6 +70,7 @@
70
70
  "migrate.js",
71
71
  "migrate-consolidate.js",
72
72
  "paths.js",
73
+ "provider-registry.js",
73
74
  "prompt-context.js",
74
75
  "retrieval.js",
75
76
  "runs.js",
@@ -0,0 +1,94 @@
1
+ import { readdir, stat as fsStat } from 'node:fs/promises';
2
+ import { join, resolve } from 'node:path';
3
+ import { pathToFileURL } from 'node:url';
4
+
5
+ const identityProviders = new Map();
6
+ const authorizationProviders = new Map();
7
+ const proofVerifiers = new Map();
8
+
9
+ /**
10
+ * Load provider plugins from a directory. Every *.js file is imported and
11
+ * its default export registered by type (identity / authorization / proof-verifier).
12
+ *
13
+ * TRUST BOUNDARY: This directory is dynamically imported at startup. Only
14
+ * point SCHEDULER_PROVIDER_PATH at operator-controlled directories. The loader
15
+ * refuses world-writable directories as a minimal safety net, but the
16
+ * primary defense is correct deployment configuration.
17
+ */
18
+ export async function loadProviders(dirPath) {
19
+ if (!dirPath) return;
20
+ const absPath = resolve(dirPath);
21
+
22
+ // Trust boundary: provider plugins run arbitrary code in the scheduler process.
23
+ // Refuse to load from world-writable directories to prevent code injection.
24
+ try {
25
+ const dirStat = await fsStat(absPath);
26
+ if ((dirStat.mode & 0o002) !== 0) {
27
+ console.error(`[provider-registry] REFUSING to load providers: ${absPath} is world-writable (mode 0${(dirStat.mode & 0o777).toString(8)}). Fix permissions or use a trusted directory.`);
28
+ return;
29
+ }
30
+ } catch (err) {
31
+ console.error(`[provider-registry] Cannot stat provider directory ${absPath}: ${err.message}`);
32
+ return;
33
+ }
34
+
35
+ const files = await readdir(absPath);
36
+ const jsFiles = files.filter(f => f.endsWith('.js'));
37
+
38
+ for (const file of jsFiles) {
39
+ const filePath = join(absPath, file);
40
+ try {
41
+ const mod = await import(pathToFileURL(filePath).href);
42
+ const provider = mod.default;
43
+ if (!provider || !provider.name || !provider.type) {
44
+ console.warn(`[provider-registry] Skipping ${file}: missing name or type`);
45
+ continue;
46
+ }
47
+ if (provider.type === 'identity') {
48
+ identityProviders.set(provider.name, provider);
49
+ } else if (provider.type === 'authorization') {
50
+ authorizationProviders.set(provider.name, provider);
51
+ } else if (provider.type === 'proof-verifier') {
52
+ proofVerifiers.set(provider.name, provider);
53
+ } else {
54
+ console.warn(`[provider-registry] Skipping ${file}: unknown type "${provider.type}"`);
55
+ }
56
+ } catch (err) {
57
+ console.error(`[provider-registry] Failed to load ${file}: ${err.message}`);
58
+ }
59
+ }
60
+
61
+ const total = identityProviders.size + authorizationProviders.size + proofVerifiers.size;
62
+ console.log(`[provider-registry] Loaded ${total} provider(s) from ${absPath}`);
63
+ }
64
+
65
+ export function getIdentityProvider(name) {
66
+ return identityProviders.get(name) || null;
67
+ }
68
+
69
+ export function getAuthorizationProvider(name) {
70
+ return authorizationProviders.get(name) || null;
71
+ }
72
+
73
+ export function getProofVerifier(name) {
74
+ return proofVerifiers.get(name) || null;
75
+ }
76
+
77
+ export function hasProvider(name) {
78
+ return identityProviders.has(name) || authorizationProviders.has(name) || proofVerifiers.has(name);
79
+ }
80
+
81
+ export function listProviders() {
82
+ const result = [];
83
+ for (const [name, p] of identityProviders) result.push({ name, type: p.type });
84
+ for (const [name, p] of authorizationProviders) result.push({ name, type: p.type });
85
+ for (const [name, p] of proofVerifiers) result.push({ name, type: p.type });
86
+ return result;
87
+ }
88
+
89
+ // For testing: reset all registries
90
+ export function _resetForTesting() {
91
+ identityProviders.clear();
92
+ authorizationProviders.clear();
93
+ proofVerifiers.clear();
94
+ }
@@ -137,12 +137,17 @@ function _formatMessagesDebug(msgs, agentId) {
137
137
  }
138
138
 
139
139
  function selectPendingMessages(db, agentId, limit) {
140
+ // Only fetch 'pending' messages for user-facing delivery.
141
+ // Messages with status='delivered' have already been injected into an AI
142
+ // agent's context prompt (by buildJobPrompt/markDelivered in dispatcher.js)
143
+ // and must NOT be re-delivered to the user via Telegram — doing so causes
144
+ // duplicate notifications on every inbox-consumer run.
140
145
  return db.prepare(`
141
146
  SELECT id, from_agent, to_agent, subject, body, kind, created_at, priority,
142
147
  delivery_to, channel
143
148
  FROM messages
144
149
  WHERE (to_agent = ? OR to_agent = 'broadcast')
145
- AND status IN ('pending', 'delivered')
150
+ AND status = 'pending'
146
151
  ORDER BY
147
152
  CASE kind
148
153
  WHEN 'constraint' THEN 0
@@ -273,8 +278,30 @@ try {
273
278
  }, 250);
274
279
  });
275
280
 
281
+ // Periodic poll fallback — catches messages that slip through WAL checkpoints.
282
+ // When SQLite checkpoints the WAL (merges it back into the main DB), the WAL
283
+ // file is reset and the watcher may miss a subsequent write. This belt-and-
284
+ // suspenders poll ensures delivery within at most INBOX_POLL_INTERVAL_MS.
285
+ const pollIntervalMs = parsePositiveInt(process.env.INBOX_POLL_INTERVAL_MS, 60000);
286
+ const pollInterval = setInterval(async () => {
287
+ if (draining) return;
288
+ draining = true;
289
+ try {
290
+ const n = await drainOnce(db, { to: deliveryTo, channel, agentId, limit, brand });
291
+ if (n > 0) {
292
+ process.stdout.write(`[inbox-consumer] poll fallback delivered ${n} pending message(s)\n`);
293
+ }
294
+ } catch (err) {
295
+ process.stderr.write(`[inbox-consumer] poll fallback error: ${err.message}\n`);
296
+ } finally {
297
+ draining = false;
298
+ }
299
+ }, pollIntervalMs);
300
+ process.stdout.write(`[inbox-consumer] poll fallback enabled (interval=${pollIntervalMs}ms)\n`);
301
+
276
302
  const shutdown = (signal) => {
277
303
  if (timer) clearTimeout(timer);
304
+ clearInterval(pollInterval);
278
305
  watcher.close();
279
306
  process.stdout.write(`[inbox-consumer] ${signal}; exiting\n`);
280
307
  process.exit(0);