@argszero/cordis-plugin-schedule-cron 0.1.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 argszero
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,99 @@
1
+ # @argszero/cordis-plugin-schedule-cron
2
+
3
+ Cron-driven **autonomous task runs** for the deepseek-harness (`dsh`) agent.
4
+
5
+ Unlike the official [`@deepseek-ai/dsh-schedule`](https://www.npmjs.com/package/@deepseek-ai/dsh-schedule) — which only **messages the live session** (a follow-up delivered into whatever agent is already running and idle) — this plugin **spawns a fresh agent run** when a calendar fire occurs, so scheduled work actually happens even with no browser session open. It is the "EMRG-style" scheduled-task runner: a cron calendar rule awakens a brand-new agent turn.
6
+
7
+ ## Why this exists
8
+
9
+ `dsh-schedule` explicitly documents the gap:
10
+
11
+ > "当交付必须到达会话之外时请避开它...或者当你需要**「每个工作日 9 点」这类日历规则**时:重复提醒**只按固定间隔运行**。"
12
+
13
+ That is, it has:
14
+ - **No calendar rules** — only `after_seconds` / `at` / `every_seconds` fixed intervals.
15
+ - **No autonomous drive** — it delivers into a live session; closed/cold sessions just leave the reminder overdue.
16
+
17
+ `dsh-cron` fills both: standard 5-field cron calendar rules and a fresh agent run per fire.
18
+
19
+ ## Install
20
+
21
+ ```sh
22
+ npm install @argszero/cordis-plugin-schedule-cron
23
+ ```
24
+
25
+ Then mount it into your `dsh` profile (a Cordis overlay). For example:
26
+
27
+ ```yaml
28
+ # cordis.yml fragment
29
+ plugins:
30
+ schedule-cron:
31
+ cron:
32
+ - id: daily-triage
33
+ expression: "0 9 * * 1-5" # 09:00 Mon–Fri
34
+ task: "Review the open issues in this repo and summarize anything urgent."
35
+ - id: weekly-report
36
+ expression: "0 8 * * 1" # 08:00 Monday
37
+ task: "Write this week's status report."
38
+ provider: deepseek
39
+ model: deepseek-chat
40
+ cwd: /path/to/workspace
41
+ tickMs: 60000
42
+ ```
43
+
44
+ ## How it works
45
+
46
+ On a tick (`tickMs`, default `60_000`), the plugin checks each rule. When a rule is due:
47
+
48
+ 1. `ctx.agents.create({ sessionId, meta, agentOptions })` composes a **fresh agent** (a brand new session) — the same primitive the ACP bridge uses for non-interactive sessions.
49
+ 2. `agent.followup(createUserMessage({ content: [{ type: 'text', text: task }], source: { kind: 'plugin', plugin: 'schedule-cron' } }))` drives its first turn **autonomously**.
50
+
51
+ The fire uses a **monotonic per-rule cursor** (`croner`), so:
52
+ - Each occurrence fires exactly once.
53
+ - On first mount, every cursor anchors to its **next future** occurrence (no backfill burst for past times).
54
+ - A long-downed schedule is capped at `MAX_CATCH_UP_PER_TICK = 4` fires per tick (no burst).
55
+ - Disabled rules (`enabled: false`) are parsed but never fired.
56
+
57
+ A rule's state is recoverable across a restart via `initialState` (`lastFiredAt` per rule id), so a process restart mid-window resumes forward instead of re-firing.
58
+
59
+ ## API
60
+
61
+ ### Config
62
+
63
+ | Field | Type | Default | Description |
64
+ |-------|------|---------|-------------|
65
+ | `cron` | `CronRule[]` | `[]` | The rules to fire. |
66
+ | `tickMs` | number | `60000` | How often (ms) to check for due rules. |
67
+ | `initialState` | `CronRuleState[]` | `[]` | Restore `lastFiredAt` baselines across a restart. |
68
+
69
+ ### `CronRule`
70
+
71
+ | Field | Type | Description |
72
+ |-------|------|-------------|
73
+ | `id` | string | Stable persistence key (defaults to `expr:<expr>`). |
74
+ | `expression` | `string \| string[]` | 5-field cron; an array acts as alternatives. |
75
+ | `task` | string | Prompt delivered as the spawned agent's first turn. |
76
+ | `provider` | string | Provider route (defaults to harness default). |
77
+ | `model` | string | Model id (defaults to harness default). |
78
+ | `cwd` | string | Working dir for the spawned agent (defaults to harness default). |
79
+ | `enabled` | boolean | `true` if the rule should fire (defaults true). |
80
+
81
+ ### Engine (pure, no harness needed)
82
+
83
+ ```ts
84
+ import { collectDue, initialCursor, resumeCursor, ruleIdOf, normalizeExpression } from '@argszero/cordis-plugin-schedule-cron'
85
+ ```
86
+
87
+ The `collectDue(cursors, now)` function is dependency-free and unit-tested, so it can be driven by any timer (not just Cordis).
88
+
89
+ ## Development
90
+
91
+ ```sh
92
+ npm install
93
+ npm run build # tsc emits lib/
94
+ npm test # node test/logic.test.mjs (needs build first)
95
+ ```
96
+
97
+ ## License
98
+
99
+ MIT
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Pure fire-engine logic for the cron plugin.
3
+ *
4
+ * No Cordis dependency: the computation (which rules are due, how to advance a
5
+ * per-rule cursor) is kept pure so it can be unit-tested without a harness.
6
+ * The Cordis plugin (`index.ts`) owns the tick timer and the
7
+ * `ctx.agents.create()` call; this module only answers "what should fire now".
8
+ *
9
+ * @module @argszero/cordis-plugin-schedule-cron/engine
10
+ */
11
+ import { Cron } from 'croner';
12
+ import type { CronRule, CronRuleState } from './types.js';
13
+ /**
14
+ * The deadline offset (ms) a fire must sit before the clock for a rule already
15
+ * behind on multiple occurrences. We fire at most one occurrence per tick so a
16
+ * long-downed schedule doesn't backfill a burst of runs.
17
+ */
18
+ export declare const MAX_CATCH_UP_PER_TICK = 4;
19
+ /**
20
+ * A rule that is due to fire right now, along with the occurrence timestamp.
21
+ */
22
+ export interface DueFire {
23
+ readonly rule: CronRule;
24
+ /** The occurrence this fire represents (the schedule time it satisfies). */
25
+ readonly occurrenceAt: Date;
26
+ }
27
+ /**
28
+ * A normalized, mutable view of one rule's schedule state. The plugin holds a
29
+ * `Map<id, CronCursor>`; each cursor is advanced one occurrence per fire.
30
+ */
31
+ export interface CronCursor {
32
+ /** The rule this cursor serves. */
33
+ readonly rule: CronRule;
34
+ /** The parsed Cron engine (one per rule, reused across ticks). */
35
+ readonly cron: Cron;
36
+ /** The next un-fired occurrence. `null` when the rule is exhausted/absent. */
37
+ next: Date | null;
38
+ }
39
+ /**
40
+ * Resolve a stable id for a rule. Falls back to the joined expression so a
41
+ * rule configured without an explicit `id` still has a durable persistence key.
42
+ */
43
+ export declare function ruleIdOf(rule: CronRule): string;
44
+ /**
45
+ * Normalize a cron expression (or array of them) to a single croner-parseable
46
+ * string. `croner` accepts a single string; an array is joined with `,` so
47
+ * multiple expressions act as alternatives on the same rule.
48
+ */
49
+ export declare function normalizeExpression(expression: CronRule['expression']): string;
50
+ /**
51
+ * Compute the next strict occurrence of a cron expression at or after `from`.
52
+ * Returns `null` when the expression yields no future occurrence.
53
+ */
54
+ export declare function nextOccurrence(cron: Cron, from: Date): Date | null;
55
+ /**
56
+ * Build the initial cursor for a rule. On first boot we anchor the cursor to
57
+ * the **next** future occurrence (never a past one), so a freshly-mounted
58
+ * schedule does not immediately backfill a burst of runs for every past
59
+ * occurrence.
60
+ *
61
+ * @param rule - the cron rule.
62
+ * @param now - the current time (the boot anchor).
63
+ */
64
+ export declare function initialCursor(rule: CronRule, now: Date): CronCursor;
65
+ /**
66
+ * Restore a cursor from persisted `lastFiredAt`. The cursor is anchored one
67
+ * occurrence **after** the last fire, so a restart in the middle of an
68
+ * overdue window resumes forward without re-firing a completed occurrence.
69
+ *
70
+ * @param rule - the cron rule.
71
+ * @param lastFiredAt - the ISO timestamp of the last fire recorded for this rule.
72
+ * @param fallbackNow - the boot anchor when the recorded timestamp is invalid.
73
+ */
74
+ export declare function resumeCursor(rule: CronRule, lastFiredAt: string, fallbackNow: Date): CronCursor;
75
+ /**
76
+ * Advance a cursor to the strictly-next occurrence after it fired.
77
+ * @param cursor - the cursor that just fired.
78
+ * @returns the advanced cursor (same object).
79
+ */
80
+ export declare function advance(cursor: CronCursor): CronCursor;
81
+ /**
82
+ * Decide which rules are due at `now`, producing the fires + the per-rule
83
+ * state to persist afterwards. At most {@link MAX_CATCH_UP_PER_TICK} fires
84
+ * total are produced per call so a long-downed schedule doesn't burst.
85
+ *
86
+ * @param cursors - the current cursor map (keyed by rule id).
87
+ * @param now - the current time this tick observed.
88
+ * @returns the due fires and a snapshot of the post-fire cursors (for the
89
+ * optional state projection).
90
+ */
91
+ export declare function collectDue(cursors: ReadonlyMap<string, CronCursor>, now: Date): {
92
+ fires: DueFire[];
93
+ state: CronRuleState[];
94
+ };
95
+ //# sourceMappingURL=engine.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"engine.d.ts","sourceRoot":"","sources":["../src/engine.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,OAAO,EAAE,IAAI,EAAE,MAAM,QAAQ,CAAA;AAC7B,OAAO,KAAK,EAAE,QAAQ,EAAE,aAAa,EAAE,MAAM,YAAY,CAAA;AAEzD;;;;GAIG;AACH,eAAO,MAAM,qBAAqB,IAAI,CAAA;AAEtC;;GAEG;AACH,MAAM,WAAW,OAAO;IACtB,QAAQ,CAAC,IAAI,EAAE,QAAQ,CAAA;IACvB,4EAA4E;IAC5E,QAAQ,CAAC,YAAY,EAAE,IAAI,CAAA;CAC5B;AAED;;;GAGG;AACH,MAAM,WAAW,UAAU;IACzB,mCAAmC;IACnC,QAAQ,CAAC,IAAI,EAAE,QAAQ,CAAA;IACvB,kEAAkE;IAClE,QAAQ,CAAC,IAAI,EAAE,IAAI,CAAA;IACnB,8EAA8E;IAC9E,IAAI,EAAE,IAAI,GAAG,IAAI,CAAA;CAClB;AAED;;;GAGG;AACH,wBAAgB,QAAQ,CAAC,IAAI,EAAE,QAAQ,GAAG,MAAM,CAI/C;AAED;;;;GAIG;AACH,wBAAgB,mBAAmB,CAAC,UAAU,EAAE,QAAQ,CAAC,YAAY,CAAC,GAAG,MAAM,CAE9E;AAED;;;GAGG;AACH,wBAAgB,cAAc,CAAC,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,GAAG,IAAI,GAAG,IAAI,CAGlE;AAED;;;;;;;;GAQG;AACH,wBAAgB,aAAa,CAAC,IAAI,EAAE,QAAQ,EAAE,GAAG,EAAE,IAAI,GAAG,UAAU,CAInE;AAED;;;;;;;;GAQG;AACH,wBAAgB,YAAY,CAAC,IAAI,EAAE,QAAQ,EAAE,WAAW,EAAE,MAAM,EAAE,WAAW,EAAE,IAAI,GAAG,UAAU,CAM/F;AAED;;;;GAIG;AACH,wBAAgB,OAAO,CAAC,MAAM,EAAE,UAAU,GAAG,UAAU,CAGtD;AAED;;;;;;;;;GASG;AACH,wBAAgB,UAAU,CACxB,OAAO,EAAE,WAAW,CAAC,MAAM,EAAE,UAAU,CAAC,EACxC,GAAG,EAAE,IAAI,GACR;IAAE,KAAK,EAAE,OAAO,EAAE,CAAC;IAAC,KAAK,EAAE,aAAa,EAAE,CAAA;CAAE,CAqB9C"}
package/lib/engine.js ADDED
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Pure fire-engine logic for the cron plugin.
3
+ *
4
+ * No Cordis dependency: the computation (which rules are due, how to advance a
5
+ * per-rule cursor) is kept pure so it can be unit-tested without a harness.
6
+ * The Cordis plugin (`index.ts`) owns the tick timer and the
7
+ * `ctx.agents.create()` call; this module only answers "what should fire now".
8
+ *
9
+ * @module @argszero/cordis-plugin-schedule-cron/engine
10
+ */
11
+ import { Cron } from 'croner';
12
+ /**
13
+ * The deadline offset (ms) a fire must sit before the clock for a rule already
14
+ * behind on multiple occurrences. We fire at most one occurrence per tick so a
15
+ * long-downed schedule doesn't backfill a burst of runs.
16
+ */
17
+ export const MAX_CATCH_UP_PER_TICK = 4;
18
+ /**
19
+ * Resolve a stable id for a rule. Falls back to the joined expression so a
20
+ * rule configured without an explicit `id` still has a durable persistence key.
21
+ */
22
+ export function ruleIdOf(rule) {
23
+ if (rule.id !== undefined && rule.id !== '')
24
+ return rule.id;
25
+ const expr = Array.isArray(rule.expression) ? rule.expression.join(',') : rule.expression;
26
+ return `expr:${expr}`;
27
+ }
28
+ /**
29
+ * Normalize a cron expression (or array of them) to a single croner-parseable
30
+ * string. `croner` accepts a single string; an array is joined with `,` so
31
+ * multiple expressions act as alternatives on the same rule.
32
+ */
33
+ export function normalizeExpression(expression) {
34
+ return Array.isArray(expression) ? expression.join(',') : expression;
35
+ }
36
+ /**
37
+ * Compute the next strict occurrence of a cron expression at or after `from`.
38
+ * Returns `null` when the expression yields no future occurrence.
39
+ */
40
+ export function nextOccurrence(cron, from) {
41
+ const next = cron.nextRun(from);
42
+ return next ?? null;
43
+ }
44
+ /**
45
+ * Build the initial cursor for a rule. On first boot we anchor the cursor to
46
+ * the **next** future occurrence (never a past one), so a freshly-mounted
47
+ * schedule does not immediately backfill a burst of runs for every past
48
+ * occurrence.
49
+ *
50
+ * @param rule - the cron rule.
51
+ * @param now - the current time (the boot anchor).
52
+ */
53
+ export function initialCursor(rule, now) {
54
+ const cron = new Cron(normalizeExpression(rule.expression));
55
+ const next = nextOccurrence(cron, now);
56
+ return { rule, cron, next };
57
+ }
58
+ /**
59
+ * Restore a cursor from persisted `lastFiredAt`. The cursor is anchored one
60
+ * occurrence **after** the last fire, so a restart in the middle of an
61
+ * overdue window resumes forward without re-firing a completed occurrence.
62
+ *
63
+ * @param rule - the cron rule.
64
+ * @param lastFiredAt - the ISO timestamp of the last fire recorded for this rule.
65
+ * @param fallbackNow - the boot anchor when the recorded timestamp is invalid.
66
+ */
67
+ export function resumeCursor(rule, lastFiredAt, fallbackNow) {
68
+ const cron = new Cron(normalizeExpression(rule.expression));
69
+ const from = new Date(lastFiredAt);
70
+ const parsed = Number.isNaN(from.getTime()) ? fallbackNow : from;
71
+ const next = nextOccurrence(cron, parsed);
72
+ return { rule, cron, next };
73
+ }
74
+ /**
75
+ * Advance a cursor to the strictly-next occurrence after it fired.
76
+ * @param cursor - the cursor that just fired.
77
+ * @returns the advanced cursor (same object).
78
+ */
79
+ export function advance(cursor) {
80
+ cursor.next = cursor.next === null ? null : nextOccurrence(cursor.cron, cursor.next);
81
+ return cursor;
82
+ }
83
+ /**
84
+ * Decide which rules are due at `now`, producing the fires + the per-rule
85
+ * state to persist afterwards. At most {@link MAX_CATCH_UP_PER_TICK} fires
86
+ * total are produced per call so a long-downed schedule doesn't burst.
87
+ *
88
+ * @param cursors - the current cursor map (keyed by rule id).
89
+ * @param now - the current time this tick observed.
90
+ * @returns the due fires and a snapshot of the post-fire cursors (for the
91
+ * optional state projection).
92
+ */
93
+ export function collectDue(cursors, now) {
94
+ const fires = [];
95
+ const state = [];
96
+ let budget = MAX_CATCH_UP_PER_TICK;
97
+ for (const cursor of cursors.values()) {
98
+ if (cursor.rule.enabled === false)
99
+ continue;
100
+ if (cursor.next === null)
101
+ continue;
102
+ if (cursor.next > now)
103
+ continue;
104
+ // One fire per tick per rule, up to the catch-up budget.
105
+ if (budget <= 0)
106
+ break;
107
+ const occurrenceAt = cursor.next;
108
+ fires.push({ rule: cursor.rule, occurrenceAt });
109
+ advance(cursor);
110
+ // Persist the occurrence the cursor just consumed (before advance mutated `next`).
111
+ state.push({ id: ruleIdOf(cursor.rule), lastFiredAt: occurrenceAt.toISOString() });
112
+ budget -= 1;
113
+ }
114
+ return { fires, state };
115
+ }
116
+ //# sourceMappingURL=engine.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"engine.js","sourceRoot":"","sources":["../src/engine.ts"],"names":[],"mappings":"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"}
package/lib/index.d.ts ADDED
@@ -0,0 +1,27 @@
1
+ /**
2
+ * @argszero/cordis-plugin-schedule-cron
3
+ *
4
+ * Cron-driven autonomous task runs for the dsh harness. On a calendar cron
5
+ * fire this plugin **spawns a fresh agent run** via `ctx.agents.create()` and
6
+ * `agent.followup()`, so scheduled work happens even with no browser session
7
+ * open. This is the EMRG-style "autonomous scheduled task" model, distinct
8
+ * from the official @deepseek-ai/dsh-schedule which only messages the live
9
+ * session.
10
+ *
11
+ * @module @argszero/cordis-plugin-schedule-cron
12
+ */
13
+ import type { Context } from '@deepseek-ai/cordis';
14
+ import type { ScheduleCronConfig } from './types.js';
15
+ export type * from './types.js';
16
+ export { collectDue, initialCursor, resumeCursor, ruleIdOf, normalizeExpression } from './engine.js';
17
+ export type { CronCursor, DueFire } from './engine.js';
18
+ /** Cordis function-plugin name. */
19
+ export declare const name = "schedule-cron";
20
+ /** Services required before any scheduled run can be spawned. */
21
+ export declare const inject: string[];
22
+ /**
23
+ * Mount the cron scheduler. Config holds the rules; each fire spawns a fresh
24
+ * agent run.
25
+ */
26
+ export declare function apply(ctx: Context, config: ScheduleCronConfig): void;
27
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,qBAAqB,CAAA;AAYlD,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,YAAY,CAAA;AAEpD,mBAAmB,YAAY,CAAA;AAC/B,OAAO,EAAE,UAAU,EAAE,aAAa,EAAE,YAAY,EAAE,QAAQ,EAAE,mBAAmB,EAAE,MAAM,aAAa,CAAA;AACpG,YAAY,EAAE,UAAU,EAAE,OAAO,EAAE,MAAM,aAAa,CAAA;AAEtD,mCAAmC;AACnC,eAAO,MAAM,IAAI,kBAAkB,CAAA;AACnC,iEAAiE;AACjE,eAAO,MAAM,MAAM,UAAgC,CAAA;AAInD;;;GAGG;AACH,wBAAgB,KAAK,CAAC,GAAG,EAAE,OAAO,EAAE,MAAM,EAAE,kBAAkB,GAAG,IAAI,CA6DpE"}
package/lib/index.js ADDED
@@ -0,0 +1,87 @@
1
+ /**
2
+ * @argszero/cordis-plugin-schedule-cron
3
+ *
4
+ * Cron-driven autonomous task runs for the dsh harness. On a calendar cron
5
+ * fire this plugin **spawns a fresh agent run** via `ctx.agents.create()` and
6
+ * `agent.followup()`, so scheduled work happens even with no browser session
7
+ * open. This is the EMRG-style "autonomous scheduled task" model, distinct
8
+ * from the official @deepseek-ai/dsh-schedule which only messages the live
9
+ * session.
10
+ *
11
+ * @module @argszero/cordis-plugin-schedule-cron
12
+ */
13
+ import { SessionId as toSessionId } from '@deepseek-ai/dsh-session';
14
+ import { createUserMessage } from '@deepseek-ai/dsh-llm';
15
+ import { randomUUID } from 'node:crypto';
16
+ import { collectDue, initialCursor, resumeCursor, ruleIdOf, } from './engine.js';
17
+ export { collectDue, initialCursor, resumeCursor, ruleIdOf, normalizeExpression } from './engine.js';
18
+ /** Cordis function-plugin name. */
19
+ export const name = 'schedule-cron';
20
+ /** Services required before any scheduled run can be spawned. */
21
+ export const inject = ['agents', 'sessions', 'llm'];
22
+ const DEFAULT_TICK_MS = 60_000;
23
+ /**
24
+ * Mount the cron scheduler. Config holds the rules; each fire spawns a fresh
25
+ * agent run.
26
+ */
27
+ export function apply(ctx, config) {
28
+ const tickMs = config.tickMs ?? DEFAULT_TICK_MS;
29
+ const rules = config.cron ?? [];
30
+ // Build the cursor map. Restore from initialState when present, else anchor
31
+ // each cursor to its next future occurrence (no backfill burst on mount).
32
+ const cursors = new Map();
33
+ const bootNow = new Date();
34
+ const restored = new Map();
35
+ for (const state of config.initialState ?? [])
36
+ restored.set(state.id, state.lastFiredAt);
37
+ for (const rule of rules) {
38
+ const id = ruleIdOf(rule);
39
+ if (cursors.has(id))
40
+ continue;
41
+ const lastFiredAt = restored.get(id);
42
+ cursors.set(id, lastFiredAt === undefined
43
+ ? initialCursor(rule, bootNow)
44
+ : resumeCursor(rule, lastFiredAt, bootNow));
45
+ }
46
+ let stopped = false;
47
+ /** Spawn one fresh agent run for a fired rule. */
48
+ async function fire(rule) {
49
+ const sessionId = toSessionId(`session-${randomUUID()}`);
50
+ const handle = await ctx.agents.create({
51
+ sessionId,
52
+ meta: rule.cwd === undefined ? {} : { cwd: rule.cwd },
53
+ agentOptions: rule.provider === undefined
54
+ ? (rule.model === undefined ? {} : { model: rule.model })
55
+ : { provider: rule.provider, ...(rule.model === undefined ? {} : { model: rule.model }) },
56
+ });
57
+ const message = createUserMessage({
58
+ content: [{ type: 'text', text: rule.task }],
59
+ source: { kind: 'plugin', plugin: 'schedule-cron' },
60
+ });
61
+ // Commit the fire to the durable log even if the turn errors.
62
+ handle.agent.followup(message);
63
+ // The agent lives on; the handle's disposer stays with the plugin.
64
+ }
65
+ ctx.effect(() => {
66
+ const onTick = async () => {
67
+ if (stopped)
68
+ return;
69
+ const now = new Date();
70
+ const { fires } = collectDue(cursors, now);
71
+ for (const due of fires) {
72
+ try {
73
+ await fire(due.rule);
74
+ }
75
+ catch (error) {
76
+ ctx.logger.warn('[schedule-cron] fire failed', { rule: due.rule.id, error });
77
+ }
78
+ }
79
+ };
80
+ const timerId = setInterval(() => { void onTick(); }, tickMs);
81
+ return () => {
82
+ stopped = true;
83
+ clearInterval(timerId);
84
+ };
85
+ }, 'schedule-cron.tick()');
86
+ }
87
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
package/lib/types.d.ts ADDED
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Types for @argszero/cordis-plugin-schedule-cron.
3
+ *
4
+ * A cron rule describes a calendar-based recurrence and the task the harness
5
+ * should run when it fires. Unlike the official @deepseek-ai/dsh-schedule
6
+ * (which messages the *live* session), each fire here spawns a **fresh
7
+ * autonomous agent run** via `ctx.agents.create()` + `agent.followup()`,
8
+ * so work happens even when no browser session is open.
9
+ *
10
+ * @module @argszero/cordis-plugin-schedule-cron
11
+ */
12
+ /** A calendar cron expression in the standard 5-field form. */
13
+ export interface CronRule {
14
+ /**
15
+ * The rule id. Persistence keys on this to dedupe fires. Auto-assigned when omitted.
16
+ */
17
+ readonly id?: string;
18
+ /**
19
+ * Standard 5-field cron expression: `minute hour day-of-month month day-of-week`.
20
+ * Example: `"0 9 * * 1-5"` for 09:00 on weekdays. May be an array of expressions.
21
+ */
22
+ readonly expression: string | readonly string[];
23
+ /**
24
+ * The prompt/task to run when the rule fires. This is delivered as a
25
+ * `UserMessage` into the spawned agent's first turn.
26
+ */
27
+ readonly task: string;
28
+ /**
29
+ * Optional provider route for the spawned agent. Defaults to the harness's
30
+ * current default selection when omitted.
31
+ */
32
+ readonly provider?: string;
33
+ /**
34
+ * Optional model id interpreted by the selected provider. Defaults to the
35
+ * harness's current default selection when omitted.
36
+ */
37
+ readonly model?: string;
38
+ /**
39
+ * Optional working directory for the spawned agent. Defaults to the
40
+ * harness default `cwd` when omitted.
41
+ */
42
+ readonly cwd?: string;
43
+ /**
44
+ * Whether the rule is enabled. Disabled rules are parsed but never fired.
45
+ * Useful for a persisted registry that the host toggles without deleting.
46
+ */
47
+ readonly enabled?: boolean;
48
+ }
49
+ /**
50
+ * Per-rule fire state. Duration-preserving key so the plugin can survive a
51
+ * process restart without re-firing already-completed shedules.
52
+ */
53
+ export interface CronRuleState {
54
+ /** The rule id. */
55
+ readonly id: string;
56
+ /** The last scheduled occurrence the plugin actually fired. */
57
+ readonly lastFiredAt: string;
58
+ }
59
+ /** Plugin configuration shape. */
60
+ export interface ScheduleCronConfig {
61
+ /**
62
+ * The cron rules this plugin owns. When a rule fires, the plugin spawns a
63
+ * fresh agent run with the rule's `task` as its first turn.
64
+ */
65
+ readonly cron: readonly CronRule[];
66
+ /**
67
+ * How often (milliseconds) the plugin wakes to check for due rules.
68
+ * Defaults to 60_000 (one minute). For fine-grained schedules, lower this
69
+ * (e.g. 15_000) — but sub-minute cron expressions are not honored by the
70
+ * standard 5-field form.
71
+ */
72
+ readonly tickMs?: number;
73
+ /**
74
+ * Optional initial state to restore dedupe baselines across a restart.
75
+ * Without it the plugin refuses to fire a rule it has no baseline for on
76
+ * the first boot (guarding against a "backfill burst" of fires for every
77
+ * past occurrence).
78
+ */
79
+ readonly initialState?: readonly CronRuleState[];
80
+ }
81
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,+DAA+D;AAC/D,MAAM,WAAW,QAAQ;IACvB;;OAEG;IACH,QAAQ,CAAC,EAAE,CAAC,EAAE,MAAM,CAAA;IACpB;;;OAGG;IACH,QAAQ,CAAC,UAAU,EAAE,MAAM,GAAG,SAAS,MAAM,EAAE,CAAA;IAC/C;;;OAGG;IACH,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB;;;OAGG;IACH,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAA;IAC1B;;;OAGG;IACH,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAA;IACvB;;;OAGG;IACH,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,CAAA;IACrB;;;OAGG;IACH,QAAQ,CAAC,OAAO,CAAC,EAAE,OAAO,CAAA;CAC3B;AAED;;;GAGG;AACH,MAAM,WAAW,aAAa;IAC5B,mBAAmB;IACnB,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAA;IACnB,+DAA+D;IAC/D,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAA;CAC7B;AAED,kCAAkC;AAClC,MAAM,WAAW,kBAAkB;IACjC;;;OAGG;IACH,QAAQ,CAAC,IAAI,EAAE,SAAS,QAAQ,EAAE,CAAA;IAClC;;;;;OAKG;IACH,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAA;IACxB;;;;;OAKG;IACH,QAAQ,CAAC,YAAY,CAAC,EAAE,SAAS,aAAa,EAAE,CAAA;CACjD"}
package/lib/types.js ADDED
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Types for @argszero/cordis-plugin-schedule-cron.
3
+ *
4
+ * A cron rule describes a calendar-based recurrence and the task the harness
5
+ * should run when it fires. Unlike the official @deepseek-ai/dsh-schedule
6
+ * (which messages the *live* session), each fire here spawns a **fresh
7
+ * autonomous agent run** via `ctx.agents.create()` + `agent.followup()`,
8
+ * so work happens even when no browser session is open.
9
+ *
10
+ * @module @argszero/cordis-plugin-schedule-cron
11
+ */
12
+ export {};
13
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG"}
package/package.json ADDED
@@ -0,0 +1,67 @@
1
+ {
2
+ "name": "@argszero/cordis-plugin-schedule-cron",
3
+ "description": "Cron-driven autonomous task runs for the dsh harness: fire a calendar cron rule and spawn a fresh agent run via ctx.agents.create(), unlike the official @deepseek-ai/dsh-schedule which only messages the live session.",
4
+ "version": "0.1.0",
5
+ "type": "module",
6
+ "main": "lib/index.js",
7
+ "types": "lib/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./lib/index.d.ts",
11
+ "default": "./lib/index.js"
12
+ }
13
+ },
14
+ "files": [
15
+ "lib",
16
+ "README.md",
17
+ "LICENSE"
18
+ ],
19
+ "peerDependencies": {
20
+ "@deepseek-ai/cordis": ">=4.0.0 <5",
21
+ "@deepseek-ai/dsh-agent": ">=0.1.0",
22
+ "@deepseek-ai/dsh-brand": ">=0.1.0",
23
+ "@deepseek-ai/dsh-llm": ">=0.1.0",
24
+ "@deepseek-ai/dsh-session": ">=0.1.0",
25
+ "@deepseek-ai/dsh-tools": ">=0.1.0"
26
+ },
27
+ "scripts": {
28
+ "build": "tsc -p .",
29
+ "prepack": "tsc -p .",
30
+ "test": "node test/logic.test.mjs"
31
+ },
32
+ "engines": {
33
+ "node": "^22.19 || >=24"
34
+ },
35
+ "keywords": [
36
+ "deepseek",
37
+ "dsh",
38
+ "cordis",
39
+ "plugin",
40
+ "schedule",
41
+ "cron",
42
+ "task-runner",
43
+ "autonomous",
44
+ "scheduler"
45
+ ],
46
+ "license": "MIT",
47
+ "publishConfig": {
48
+ "access": "public"
49
+ },
50
+ "repository": {
51
+ "type": "git",
52
+ "url": "git+https://github.com/argszero/cordis-plugin-schedule-cron.git"
53
+ },
54
+ "dependencies": {
55
+ "croner": "^10.0.1"
56
+ },
57
+ "devDependencies": {
58
+ "@deepseek-ai/cordis": ">=4.0.1-rc.4",
59
+ "@deepseek-ai/dsh-agent": "0.1.2-rc.1",
60
+ "@deepseek-ai/dsh-brand": "0.1.2-rc.1",
61
+ "@deepseek-ai/dsh-llm": "0.1.2-rc.1",
62
+ "@deepseek-ai/dsh-session": "0.1.2-rc.1",
63
+ "@deepseek-ai/dsh-tools": "0.1.2-rc.1",
64
+ "@types/node": "^26.4.1",
65
+ "typescript": "^7.0.2"
66
+ }
67
+ }