@mininglamp-oss/cc-channel-octo 1.0.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/CHANGELOG.md +349 -0
- package/LICENSE +191 -0
- package/README.md +577 -0
- package/config.bot.example.json +15 -0
- package/config.example.json +33 -0
- package/dist/agent-bridge.d.ts +79 -0
- package/dist/agent-bridge.js +392 -0
- package/dist/agent-bridge.js.map +1 -0
- package/dist/commands.d.ts +57 -0
- package/dist/commands.js +121 -0
- package/dist/commands.js.map +1 -0
- package/dist/config.d.ts +278 -0
- package/dist/config.js +330 -0
- package/dist/config.js.map +1 -0
- package/dist/cron-evaluator.d.ts +53 -0
- package/dist/cron-evaluator.js +191 -0
- package/dist/cron-evaluator.js.map +1 -0
- package/dist/cron-fire-marker.d.ts +24 -0
- package/dist/cron-fire-marker.js +25 -0
- package/dist/cron-fire-marker.js.map +1 -0
- package/dist/cron-scheduler.d.ts +46 -0
- package/dist/cron-scheduler.js +114 -0
- package/dist/cron-scheduler.js.map +1 -0
- package/dist/cron-store.d.ts +62 -0
- package/dist/cron-store.js +63 -0
- package/dist/cron-store.js.map +1 -0
- package/dist/cron-tool.d.ts +44 -0
- package/dist/cron-tool.js +151 -0
- package/dist/cron-tool.js.map +1 -0
- package/dist/cwd-resolver.d.ts +72 -0
- package/dist/cwd-resolver.js +166 -0
- package/dist/cwd-resolver.js.map +1 -0
- package/dist/db-adapter.d.ts +21 -0
- package/dist/db-adapter.js +64 -0
- package/dist/db-adapter.js.map +1 -0
- package/dist/file-inline-wrap.d.ts +94 -0
- package/dist/file-inline-wrap.js +243 -0
- package/dist/file-inline-wrap.js.map +1 -0
- package/dist/gateway.d.ts +100 -0
- package/dist/gateway.js +420 -0
- package/dist/gateway.js.map +1 -0
- package/dist/group-config.d.ts +41 -0
- package/dist/group-config.js +104 -0
- package/dist/group-config.js.map +1 -0
- package/dist/group-context.d.ts +64 -0
- package/dist/group-context.js +396 -0
- package/dist/group-context.js.map +1 -0
- package/dist/inbound.d.ts +136 -0
- package/dist/inbound.js +667 -0
- package/dist/inbound.js.map +1 -0
- package/dist/index.d.ts +33 -0
- package/dist/index.js +922 -0
- package/dist/index.js.map +1 -0
- package/dist/media-inbound.d.ts +38 -0
- package/dist/media-inbound.js +131 -0
- package/dist/media-inbound.js.map +1 -0
- package/dist/mention-utils.d.ts +99 -0
- package/dist/mention-utils.js +185 -0
- package/dist/mention-utils.js.map +1 -0
- package/dist/octo/api.d.ts +148 -0
- package/dist/octo/api.js +320 -0
- package/dist/octo/api.js.map +1 -0
- package/dist/octo/socket.d.ts +102 -0
- package/dist/octo/socket.js +793 -0
- package/dist/octo/socket.js.map +1 -0
- package/dist/octo/types.d.ts +126 -0
- package/dist/octo/types.js +35 -0
- package/dist/octo/types.js.map +1 -0
- package/dist/prompt-safety.d.ts +78 -0
- package/dist/prompt-safety.js +148 -0
- package/dist/prompt-safety.js.map +1 -0
- package/dist/session-router.d.ts +127 -0
- package/dist/session-router.js +432 -0
- package/dist/session-router.js.map +1 -0
- package/dist/session-store.d.ts +89 -0
- package/dist/session-store.js +297 -0
- package/dist/session-store.js.map +1 -0
- package/dist/skill-linker.d.ts +31 -0
- package/dist/skill-linker.js +160 -0
- package/dist/skill-linker.js.map +1 -0
- package/dist/stream-relay.d.ts +42 -0
- package/dist/stream-relay.js +243 -0
- package/dist/stream-relay.js.map +1 -0
- package/dist/url-policy.d.ts +103 -0
- package/dist/url-policy.js +290 -0
- package/dist/url-policy.js.map +1 -0
- package/package.json +79 -0
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #115: Cron scheduler — a resident per-bot loop that fires due tasks.
|
|
3
|
+
*
|
|
4
|
+
* Every tick it loads the bot's cron.json, and for each enabled task whose
|
|
5
|
+
* `nextRun` is past, synthesizes a BotMessage (the task's prompt as a Text
|
|
6
|
+
* message, bound to the task's session coords, marked `_cronFire`) and hands it
|
|
7
|
+
* to `onFire` — which the gateway wires to the same `onInbound` real messages
|
|
8
|
+
* use, so a fired task runs through the entire normal pipeline.
|
|
9
|
+
*
|
|
10
|
+
* Best-effort throughout: a failing task is logged and skipped, never crashing
|
|
11
|
+
* the loop. Missed tasks (process was down across their window) fire ONCE on
|
|
12
|
+
* catch-up, then advance to the next future occurrence — no thundering herd.
|
|
13
|
+
*/
|
|
14
|
+
import type { BotMessage } from './octo/types.js';
|
|
15
|
+
import { CronStore, type CronTask } from './cron-store.js';
|
|
16
|
+
/** How often the scheduler scans cron.json (ms). 30s → ≤30s firing latency. */
|
|
17
|
+
export declare const CRON_TICK_MS = 30000;
|
|
18
|
+
export interface CronSchedulerOptions {
|
|
19
|
+
cronStore: CronStore;
|
|
20
|
+
/**
|
|
21
|
+
* Invoked with a synthetic BotMessage when a task is due (= onInbound). Fire
|
|
22
|
+
* is non-blocking; the scheduler does not await it and nextRun advances
|
|
23
|
+
* regardless (a failed fire is logged at the handler's own catch site —
|
|
24
|
+
* attributed to the task via the `cron:<id>:<ts>` message_id — not retried, to
|
|
25
|
+
* avoid an error loop hammering the channel).
|
|
26
|
+
*/
|
|
27
|
+
onFire: (msg: BotMessage) => void;
|
|
28
|
+
/** Log prefix, e.g. "[bot-id] " in multi-bot mode. */
|
|
29
|
+
label?: string;
|
|
30
|
+
}
|
|
31
|
+
/** Build the synthetic inbound message for a fired task. */
|
|
32
|
+
export declare function synthesizeCronMessage(task: CronTask): BotMessage;
|
|
33
|
+
export declare class CronScheduler {
|
|
34
|
+
private readonly opts;
|
|
35
|
+
private timer;
|
|
36
|
+
constructor(opts: CronSchedulerOptions);
|
|
37
|
+
/** Arm the periodic scan. Idempotent. */
|
|
38
|
+
start(): void;
|
|
39
|
+
/** Stop scanning. */
|
|
40
|
+
stop(): void;
|
|
41
|
+
/**
|
|
42
|
+
* One scan: fire due tasks, advance/drop them, persist. Exposed for tests.
|
|
43
|
+
* Never throws.
|
|
44
|
+
*/
|
|
45
|
+
tick(): void;
|
|
46
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #115: Cron scheduler — a resident per-bot loop that fires due tasks.
|
|
3
|
+
*
|
|
4
|
+
* Every tick it loads the bot's cron.json, and for each enabled task whose
|
|
5
|
+
* `nextRun` is past, synthesizes a BotMessage (the task's prompt as a Text
|
|
6
|
+
* message, bound to the task's session coords, marked `_cronFire`) and hands it
|
|
7
|
+
* to `onFire` — which the gateway wires to the same `onInbound` real messages
|
|
8
|
+
* use, so a fired task runs through the entire normal pipeline.
|
|
9
|
+
*
|
|
10
|
+
* Best-effort throughout: a failing task is logged and skipped, never crashing
|
|
11
|
+
* the loop. Missed tasks (process was down across their window) fire ONCE on
|
|
12
|
+
* catch-up, then advance to the next future occurrence — no thundering herd.
|
|
13
|
+
*/
|
|
14
|
+
import { MessageType } from './octo/types.js';
|
|
15
|
+
import { computeNextRun } from './cron-evaluator.js';
|
|
16
|
+
import { CRON_FIRE_NONCE, CRON_FIRE_NONCE_KEY } from './cron-fire-marker.js';
|
|
17
|
+
/** How often the scheduler scans cron.json (ms). 30s → ≤30s firing latency. */
|
|
18
|
+
export const CRON_TICK_MS = 30_000;
|
|
19
|
+
/** Build the synthetic inbound message for a fired task. */
|
|
20
|
+
export function synthesizeCronMessage(task) {
|
|
21
|
+
return {
|
|
22
|
+
message_id: `cron:${task.id}:${Date.now()}`,
|
|
23
|
+
message_seq: 0,
|
|
24
|
+
from_uid: task.fromUid,
|
|
25
|
+
from_name: task.fromName,
|
|
26
|
+
channel_id: task.channelId,
|
|
27
|
+
channel_type: task.channelType,
|
|
28
|
+
timestamp: Math.floor(Date.now() / 1000),
|
|
29
|
+
payload: {
|
|
30
|
+
type: MessageType.Text,
|
|
31
|
+
content: task.prompt,
|
|
32
|
+
// Synthetic marker + per-process nonce: lets the router bypass the group
|
|
33
|
+
// @mention gate for genuine in-process cron fires only (see
|
|
34
|
+
// session-router isCronFire / cron-fire-marker). A forged inbound payload
|
|
35
|
+
// can set `_cronFire` but cannot know the secret nonce. Allowed by the
|
|
36
|
+
// MessagePayload index signature; never set on real inbound messages.
|
|
37
|
+
_cronFire: true,
|
|
38
|
+
[CRON_FIRE_NONCE_KEY]: CRON_FIRE_NONCE,
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
export class CronScheduler {
|
|
43
|
+
opts;
|
|
44
|
+
timer = null;
|
|
45
|
+
constructor(opts) {
|
|
46
|
+
this.opts = opts;
|
|
47
|
+
}
|
|
48
|
+
/** Arm the periodic scan. Idempotent. */
|
|
49
|
+
start() {
|
|
50
|
+
if (this.timer)
|
|
51
|
+
return;
|
|
52
|
+
this.timer = setInterval(() => this.tick(), CRON_TICK_MS);
|
|
53
|
+
this.timer.unref(); // never keep the process alive on the cron loop alone
|
|
54
|
+
}
|
|
55
|
+
/** Stop scanning. */
|
|
56
|
+
stop() {
|
|
57
|
+
if (this.timer) {
|
|
58
|
+
clearInterval(this.timer);
|
|
59
|
+
this.timer = null;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* One scan: fire due tasks, advance/drop them, persist. Exposed for tests.
|
|
64
|
+
* Never throws.
|
|
65
|
+
*/
|
|
66
|
+
tick() {
|
|
67
|
+
const now = Date.now();
|
|
68
|
+
// Single atomic read-modify-write: fire due tasks and persist the survivor
|
|
69
|
+
// set in one synchronous pass, so a concurrent tool create/delete (which
|
|
70
|
+
// also goes through cronStore.update) can't lose updates against us.
|
|
71
|
+
try {
|
|
72
|
+
this.opts.cronStore.update((tasks) => {
|
|
73
|
+
const survivors = [];
|
|
74
|
+
let changed = false;
|
|
75
|
+
for (const task of tasks) {
|
|
76
|
+
if (!task.enabled || task.nextRun === null || task.nextRun > now) {
|
|
77
|
+
survivors.push(task);
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
changed = true;
|
|
81
|
+
// Due. Fire (best-effort; onFire is fire-and-forget).
|
|
82
|
+
const lateMin = Math.round((now - task.nextRun) / 60_000);
|
|
83
|
+
if (lateMin >= 1) {
|
|
84
|
+
console.warn(`[cc-channel-octo] ${this.opts.label ?? ''}cron: task ${task.id} (${task.schedule}) ` +
|
|
85
|
+
`fired ${lateMin} min late (catch-up)`);
|
|
86
|
+
}
|
|
87
|
+
try {
|
|
88
|
+
// Fire-and-forget: onFire (= onInbound) drives the full pipeline,
|
|
89
|
+
// which swallows its own errors and posts a user-facing reply. A
|
|
90
|
+
// FAILED fire is attributed to this task at handleMessage's catch
|
|
91
|
+
// site (via the `cron:<id>:<ts>` message_id) — not here, because the
|
|
92
|
+
// returned value is void and never rejects.
|
|
93
|
+
this.opts.onFire(synthesizeCronMessage(task));
|
|
94
|
+
}
|
|
95
|
+
catch (err) {
|
|
96
|
+
console.error(`[cc-channel-octo] ${this.opts.label ?? ''}cron: onFire threw for ${task.id}: ${String(err)}`);
|
|
97
|
+
}
|
|
98
|
+
task.lastRun = now;
|
|
99
|
+
if (task.recurring) {
|
|
100
|
+
task.nextRun = computeNextRun(task.schedule, true, now);
|
|
101
|
+
survivors.push(task); // keep; next future occurrence (or null → inert)
|
|
102
|
+
}
|
|
103
|
+
// one-shot: drop (not pushed to survivors)
|
|
104
|
+
}
|
|
105
|
+
// Return the SAME reference when nothing fired so update() skips the write.
|
|
106
|
+
return changed ? survivors : tasks;
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
catch (err) {
|
|
110
|
+
console.error(`[cc-channel-octo] ${this.opts.label ?? ''}cron: tick failed: ${String(err)}`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
//# sourceMappingURL=cron-scheduler.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cron-scheduler.js","sourceRoot":"","sources":["../src/cron-scheduler.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #115: Per-bot cron task persistence — `<baseDir>/<botId>/cron.json`.
|
|
3
|
+
*
|
|
4
|
+
* Holds the scheduled tasks a bot's agent has registered via the cron tool. Read
|
|
5
|
+
* by both the cron tool (agent turn, under the session lock) and the scheduler
|
|
6
|
+
* tick (every ~30s). Node is single-threaded and all I/O here is synchronous, so
|
|
7
|
+
* a tool write and a scheduler read can never interleave mid-operation; the
|
|
8
|
+
* atomic temp+rename below additionally guarantees a reader never sees a partial
|
|
9
|
+
* file even across a crash.
|
|
10
|
+
*/
|
|
11
|
+
import type { ChannelType } from './octo/types.js';
|
|
12
|
+
/** One scheduled task. Persisted as a plain JSON object. */
|
|
13
|
+
export interface CronTask {
|
|
14
|
+
/** Stable handle (uuid) — used by cron_delete; not user-chosen. */
|
|
15
|
+
id: string;
|
|
16
|
+
/** 5-field cron expression OR a one-shot ISO datetime. */
|
|
17
|
+
schedule: string;
|
|
18
|
+
/** true = re-schedule after each fire; false = delete after firing once. */
|
|
19
|
+
recurring: boolean;
|
|
20
|
+
/** Prompt injected as the synthetic message's text (≤ MAX_PROMPT_BYTES). */
|
|
21
|
+
prompt: string;
|
|
22
|
+
/** Bound session coords — where the fired task runs and replies. */
|
|
23
|
+
channelId: string;
|
|
24
|
+
channelType: ChannelType;
|
|
25
|
+
fromUid: string;
|
|
26
|
+
fromName?: string;
|
|
27
|
+
/** uid that registered the task (owner-gate source of truth). */
|
|
28
|
+
createdBy: string;
|
|
29
|
+
/** Scheduler skips disabled tasks (kept for a future cron_disable). */
|
|
30
|
+
enabled: boolean;
|
|
31
|
+
/** Unix ms of creation. */
|
|
32
|
+
createdAt: number;
|
|
33
|
+
/** Unix ms of the last fire, or null if never fired. */
|
|
34
|
+
lastRun: number | null;
|
|
35
|
+
/** Unix ms of the next fire (the scheduler's due check), or null if none. */
|
|
36
|
+
nextRun: number | null;
|
|
37
|
+
}
|
|
38
|
+
/** Max prompt size (bytes) accepted into a task. */
|
|
39
|
+
export declare const MAX_PROMPT_BYTES = 2048;
|
|
40
|
+
/** Max number of tasks per bot. */
|
|
41
|
+
export declare const MAX_TASKS_PER_BOT = 50;
|
|
42
|
+
/** Load/save a single bot's cron.json. */
|
|
43
|
+
export declare class CronStore {
|
|
44
|
+
private readonly cronJsonPath;
|
|
45
|
+
constructor(cronJsonPath: string);
|
|
46
|
+
/** Parse cron.json. Throws on malformed JSON (loud, not silent). */
|
|
47
|
+
load(): CronTask[];
|
|
48
|
+
/** Like load(), but returns [] when the file does not exist. */
|
|
49
|
+
loadOrEmpty(): CronTask[];
|
|
50
|
+
/** Atomically write the task array (temp file + rename). */
|
|
51
|
+
save(tasks: CronTask[]): void;
|
|
52
|
+
/**
|
|
53
|
+
* Atomic read-modify-write: load the current tasks, apply `mutator`, persist
|
|
54
|
+
* the result, and return it. The whole sequence runs **synchronously** (no
|
|
55
|
+
* await), so under Node's single-threaded model no other turn or scheduler
|
|
56
|
+
* tick can interleave between the load and the save — eliminating the
|
|
57
|
+
* lost-update race that separate load()+save() calls would risk if a caller
|
|
58
|
+
* ever introduced an await between them. All cron mutations (create, delete,
|
|
59
|
+
* scheduler advance) go through this one method.
|
|
60
|
+
*/
|
|
61
|
+
update(mutator: (tasks: CronTask[]) => CronTask[]): CronTask[];
|
|
62
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #115: Per-bot cron task persistence — `<baseDir>/<botId>/cron.json`.
|
|
3
|
+
*
|
|
4
|
+
* Holds the scheduled tasks a bot's agent has registered via the cron tool. Read
|
|
5
|
+
* by both the cron tool (agent turn, under the session lock) and the scheduler
|
|
6
|
+
* tick (every ~30s). Node is single-threaded and all I/O here is synchronous, so
|
|
7
|
+
* a tool write and a scheduler read can never interleave mid-operation; the
|
|
8
|
+
* atomic temp+rename below additionally guarantees a reader never sees a partial
|
|
9
|
+
* file even across a crash.
|
|
10
|
+
*/
|
|
11
|
+
import { existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
12
|
+
/** Max prompt size (bytes) accepted into a task. */
|
|
13
|
+
export const MAX_PROMPT_BYTES = 2048;
|
|
14
|
+
/** Max number of tasks per bot. */
|
|
15
|
+
export const MAX_TASKS_PER_BOT = 50;
|
|
16
|
+
/** Load/save a single bot's cron.json. */
|
|
17
|
+
export class CronStore {
|
|
18
|
+
cronJsonPath;
|
|
19
|
+
constructor(cronJsonPath) {
|
|
20
|
+
this.cronJsonPath = cronJsonPath;
|
|
21
|
+
}
|
|
22
|
+
/** Parse cron.json. Throws on malformed JSON (loud, not silent). */
|
|
23
|
+
load() {
|
|
24
|
+
const raw = readFileSync(this.cronJsonPath, 'utf8');
|
|
25
|
+
const parsed = JSON.parse(raw);
|
|
26
|
+
if (!Array.isArray(parsed)) {
|
|
27
|
+
throw new Error(`cron.json is not an array: ${this.cronJsonPath}`);
|
|
28
|
+
}
|
|
29
|
+
return parsed;
|
|
30
|
+
}
|
|
31
|
+
/** Like load(), but returns [] when the file does not exist. */
|
|
32
|
+
loadOrEmpty() {
|
|
33
|
+
if (!existsSync(this.cronJsonPath))
|
|
34
|
+
return [];
|
|
35
|
+
return this.load();
|
|
36
|
+
}
|
|
37
|
+
/** Atomically write the task array (temp file + rename). */
|
|
38
|
+
save(tasks) {
|
|
39
|
+
const tmp = `${this.cronJsonPath}.tmp`;
|
|
40
|
+
writeFileSync(tmp, JSON.stringify(tasks, null, 2), { mode: 0o600 });
|
|
41
|
+
renameSync(tmp, this.cronJsonPath);
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Atomic read-modify-write: load the current tasks, apply `mutator`, persist
|
|
45
|
+
* the result, and return it. The whole sequence runs **synchronously** (no
|
|
46
|
+
* await), so under Node's single-threaded model no other turn or scheduler
|
|
47
|
+
* tick can interleave between the load and the save — eliminating the
|
|
48
|
+
* lost-update race that separate load()+save() calls would risk if a caller
|
|
49
|
+
* ever introduced an await between them. All cron mutations (create, delete,
|
|
50
|
+
* scheduler advance) go through this one method.
|
|
51
|
+
*/
|
|
52
|
+
update(mutator) {
|
|
53
|
+
const current = this.loadOrEmpty();
|
|
54
|
+
const next = mutator(current);
|
|
55
|
+
// Skip the write when the mutator returns the same array reference
|
|
56
|
+
// unchanged (e.g. an idle scheduler tick with nothing due) — avoids
|
|
57
|
+
// rewriting cron.json on every 30s tick.
|
|
58
|
+
if (next !== current)
|
|
59
|
+
this.save(next);
|
|
60
|
+
return next;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
//# sourceMappingURL=cron-store.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cron-store.js","sourceRoot":"","sources":["../src/cron-store.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,OAAO,EAAE,UAAU,EAAE,YAAY,EAAE,UAAU,EAAE,aAAa,EAAE,MAAM,SAAS,CAAC;AA8B9E,oDAAoD;AACpD,MAAM,CAAC,MAAM,gBAAgB,GAAG,IAAI,CAAC;AACrC,mCAAmC;AACnC,MAAM,CAAC,MAAM,iBAAiB,GAAG,EAAE,CAAC;AAEpC,0CAA0C;AAC1C,MAAM,OAAO,SAAS;IACS;IAA7B,YAA6B,YAAoB;QAApB,iBAAY,GAAZ,YAAY,CAAQ;IAAG,CAAC;IAErD,oEAAoE;IACpE,IAAI;QACF,MAAM,GAAG,GAAG,YAAY,CAAC,IAAI,CAAC,YAAY,EAAE,MAAM,CAAC,CAAC;QACpD,MAAM,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;QAC/B,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,MAAM,CAAC,EAAE,CAAC;YAC3B,MAAM,IAAI,KAAK,CAAC,8BAA8B,IAAI,CAAC,YAAY,EAAE,CAAC,CAAC;QACrE,CAAC;QACD,OAAO,MAAoB,CAAC;IAC9B,CAAC;IAED,gEAAgE;IAChE,WAAW;QACT,IAAI,CAAC,UAAU,CAAC,IAAI,CAAC,YAAY,CAAC;YAAE,OAAO,EAAE,CAAC;QAC9C,OAAO,IAAI,CAAC,IAAI,EAAE,CAAC;IACrB,CAAC;IAED,4DAA4D;IAC5D,IAAI,CAAC,KAAiB;QACpB,MAAM,GAAG,GAAG,GAAG,IAAI,CAAC,YAAY,MAAM,CAAC;QACvC,aAAa,CAAC,GAAG,EAAE,IAAI,CAAC,SAAS,CAAC,KAAK,EAAE,IAAI,EAAE,CAAC,CAAC,EAAE,EAAE,IAAI,EAAE,KAAK,EAAE,CAAC,CAAC;QACpE,UAAU,CAAC,GAAG,EAAE,IAAI,CAAC,YAAY,CAAC,CAAC;IACrC,CAAC;IAED;;;;;;;;OAQG;IACH,MAAM,CAAC,OAA0C;QAC/C,MAAM,OAAO,GAAG,IAAI,CAAC,WAAW,EAAE,CAAC;QACnC,MAAM,IAAI,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;QAC9B,mEAAmE;QACnE,oEAAoE;QACpE,yCAAyC;QACzC,IAAI,IAAI,KAAK,OAAO;YAAE,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;QACtC,OAAO,IAAI,CAAC;IACd,CAAC;CACF"}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #115: Cron tool — an in-process MCP server letting the agent register, list,
|
|
3
|
+
* and delete per-bot scheduled tasks. Tools surface to the model as
|
|
4
|
+
* `mcp__cron__cron_create` / `_list` / `_delete`.
|
|
5
|
+
*
|
|
6
|
+
* The server is built PER TURN (`createCronToolServer`) with the current
|
|
7
|
+
* message's raw channel coords + the bot owner uid, so:
|
|
8
|
+
* - a created task BINDS to the session that created it (fires + replies there);
|
|
9
|
+
* - creation/deletion is GATED to the bot owner (registerBot.owner_uid). The
|
|
10
|
+
* agent is driven by untrusted IM users, so this server-side check — not LLM
|
|
11
|
+
* judgment — is what stops a prompt-injected agent from registering a
|
|
12
|
+
* malicious recurring task. (See SECURITY_PROMPT_PREFIX for the advisory
|
|
13
|
+
* defense-in-depth layer.)
|
|
14
|
+
*/
|
|
15
|
+
import { z } from 'zod';
|
|
16
|
+
import type { ChannelType } from './octo/types.js';
|
|
17
|
+
import { CronStore } from './cron-store.js';
|
|
18
|
+
/** MCP server name; tools surface as `mcp__cron__<tool>`. */
|
|
19
|
+
export declare const CRON_TOOL_SERVER_NAME = "cron";
|
|
20
|
+
/** Raw coords of the session creating a task — what a fired task binds to. */
|
|
21
|
+
export interface CronSessionCoords {
|
|
22
|
+
channelId: string;
|
|
23
|
+
channelType: ChannelType;
|
|
24
|
+
fromUid: string;
|
|
25
|
+
fromName?: string;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Build the cron tool DEFINITIONS for one agent turn. Exported separately from
|
|
29
|
+
* the server so tests can invoke each handler directly (the MCP server keeps its
|
|
30
|
+
* tools in private internals). `coords` binds created tasks to this session;
|
|
31
|
+
* `ownerUid` gates create/delete to the bot owner.
|
|
32
|
+
*/
|
|
33
|
+
export declare function buildCronTools(cronStore: CronStore, coords: CronSessionCoords, ownerUid: string): (import("@anthropic-ai/claude-agent-sdk").SdkMcpToolDefinition<{
|
|
34
|
+
schedule: z.ZodString;
|
|
35
|
+
prompt: z.ZodString;
|
|
36
|
+
recurring: z.ZodOptional<z.ZodBoolean>;
|
|
37
|
+
}> | import("@anthropic-ai/claude-agent-sdk").SdkMcpToolDefinition<{}> | import("@anthropic-ai/claude-agent-sdk").SdkMcpToolDefinition<{
|
|
38
|
+
id: z.ZodString;
|
|
39
|
+
}>)[];
|
|
40
|
+
/**
|
|
41
|
+
* Build the cron MCP server for one agent turn. `coords` binds created tasks to
|
|
42
|
+
* this session; `ownerUid` gates create/delete to the bot owner.
|
|
43
|
+
*/
|
|
44
|
+
export declare function createCronToolServer(cronStore: CronStore, coords: CronSessionCoords, ownerUid: string): import("@anthropic-ai/claude-agent-sdk").McpSdkServerConfigWithInstance;
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #115: Cron tool — an in-process MCP server letting the agent register, list,
|
|
3
|
+
* and delete per-bot scheduled tasks. Tools surface to the model as
|
|
4
|
+
* `mcp__cron__cron_create` / `_list` / `_delete`.
|
|
5
|
+
*
|
|
6
|
+
* The server is built PER TURN (`createCronToolServer`) with the current
|
|
7
|
+
* message's raw channel coords + the bot owner uid, so:
|
|
8
|
+
* - a created task BINDS to the session that created it (fires + replies there);
|
|
9
|
+
* - creation/deletion is GATED to the bot owner (registerBot.owner_uid). The
|
|
10
|
+
* agent is driven by untrusted IM users, so this server-side check — not LLM
|
|
11
|
+
* judgment — is what stops a prompt-injected agent from registering a
|
|
12
|
+
* malicious recurring task. (See SECURITY_PROMPT_PREFIX for the advisory
|
|
13
|
+
* defense-in-depth layer.)
|
|
14
|
+
*/
|
|
15
|
+
import { z } from 'zod';
|
|
16
|
+
import { createSdkMcpServer, tool } from '@anthropic-ai/claude-agent-sdk';
|
|
17
|
+
import { MAX_PROMPT_BYTES, MAX_TASKS_PER_BOT } from './cron-store.js';
|
|
18
|
+
import { computeNextRun, isOneShotSchedule, parseCronExpression } from './cron-evaluator.js';
|
|
19
|
+
/** MCP server name; tools surface as `mcp__cron__<tool>`. */
|
|
20
|
+
export const CRON_TOOL_SERVER_NAME = 'cron';
|
|
21
|
+
function jsonResult(value) {
|
|
22
|
+
return { content: [{ type: 'text', text: JSON.stringify(value, null, 2) }] };
|
|
23
|
+
}
|
|
24
|
+
function errResult(msg) {
|
|
25
|
+
return { content: [{ type: 'text', text: `Error: ${msg}` }], isError: true };
|
|
26
|
+
}
|
|
27
|
+
/** A task rendered for the model (nextRun as ISO, no internal-only churn). */
|
|
28
|
+
function summarize(t) {
|
|
29
|
+
return {
|
|
30
|
+
id: t.id,
|
|
31
|
+
schedule: t.schedule,
|
|
32
|
+
recurring: t.recurring,
|
|
33
|
+
prompt: t.prompt,
|
|
34
|
+
nextRun: t.nextRun ? new Date(t.nextRun).toISOString() : null,
|
|
35
|
+
enabled: t.enabled,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Build the cron tool DEFINITIONS for one agent turn. Exported separately from
|
|
40
|
+
* the server so tests can invoke each handler directly (the MCP server keeps its
|
|
41
|
+
* tools in private internals). `coords` binds created tasks to this session;
|
|
42
|
+
* `ownerUid` gates create/delete to the bot owner.
|
|
43
|
+
*/
|
|
44
|
+
export function buildCronTools(cronStore, coords, ownerUid) {
|
|
45
|
+
const isOwner = coords.fromUid === ownerUid && ownerUid !== '';
|
|
46
|
+
return [
|
|
47
|
+
tool('cron_create', 'Schedule a task: at the given time the bot re-runs `prompt` in THIS chat, ' +
|
|
48
|
+
'as if you received it as a message, and posts the result here. ' +
|
|
49
|
+
'`schedule` is a 5-field cron expression ("0 9 * * 1-5" = weekdays 9am) ' +
|
|
50
|
+
'or a one-shot ISO datetime ("2026-06-09T09:00:00Z"). Set recurring=false ' +
|
|
51
|
+
'for a one-time reminder. Only the bot owner may create tasks.', {
|
|
52
|
+
schedule: z.string().min(1).describe('5-field cron expression or one-shot ISO datetime.'),
|
|
53
|
+
prompt: z.string().min(1).describe('The instruction to run when the task fires.'),
|
|
54
|
+
recurring: z.boolean().optional().describe('Re-run on every match (default: cron→true, one-shot→false).'),
|
|
55
|
+
}, async (args) => {
|
|
56
|
+
try {
|
|
57
|
+
if (!isOwner) {
|
|
58
|
+
return errResult('Only the bot owner can create scheduled tasks.');
|
|
59
|
+
}
|
|
60
|
+
const oneShot = isOneShotSchedule(args.schedule);
|
|
61
|
+
// Validate the schedule.
|
|
62
|
+
if (!oneShot && !parseCronExpression(args.schedule)) {
|
|
63
|
+
return errResult(`Invalid cron expression: ${args.schedule}`);
|
|
64
|
+
}
|
|
65
|
+
if (Buffer.byteLength(args.prompt, 'utf8') > MAX_PROMPT_BYTES) {
|
|
66
|
+
return errResult(`prompt too long (max ${MAX_PROMPT_BYTES} bytes).`);
|
|
67
|
+
}
|
|
68
|
+
const recurring = args.recurring ?? !oneShot;
|
|
69
|
+
const now = Date.now();
|
|
70
|
+
const nextRun = computeNextRun(args.schedule, recurring, now);
|
|
71
|
+
if (nextRun === null) {
|
|
72
|
+
return errResult(oneShot
|
|
73
|
+
? 'one-shot time is in the past or invalid.'
|
|
74
|
+
: `schedule never matches (impossible cron): ${args.schedule}`);
|
|
75
|
+
}
|
|
76
|
+
const task = {
|
|
77
|
+
id: crypto.randomUUID(),
|
|
78
|
+
schedule: args.schedule,
|
|
79
|
+
recurring,
|
|
80
|
+
prompt: args.prompt,
|
|
81
|
+
channelId: coords.channelId,
|
|
82
|
+
channelType: coords.channelType,
|
|
83
|
+
fromUid: coords.fromUid,
|
|
84
|
+
fromName: coords.fromName,
|
|
85
|
+
createdBy: coords.fromUid,
|
|
86
|
+
enabled: true,
|
|
87
|
+
createdAt: now,
|
|
88
|
+
lastRun: null,
|
|
89
|
+
nextRun,
|
|
90
|
+
};
|
|
91
|
+
// Atomic read-modify-write; re-check the cap inside the mutator so a
|
|
92
|
+
// concurrent create can't push us over the limit.
|
|
93
|
+
let capped = false;
|
|
94
|
+
cronStore.update((tasks) => {
|
|
95
|
+
if (tasks.length >= MAX_TASKS_PER_BOT) {
|
|
96
|
+
capped = true;
|
|
97
|
+
return tasks;
|
|
98
|
+
}
|
|
99
|
+
return [...tasks, task];
|
|
100
|
+
});
|
|
101
|
+
if (capped) {
|
|
102
|
+
return errResult(`task limit reached (max ${MAX_TASKS_PER_BOT}). Delete one first.`);
|
|
103
|
+
}
|
|
104
|
+
return jsonResult({ created: summarize(task) });
|
|
105
|
+
}
|
|
106
|
+
catch (err) {
|
|
107
|
+
return errResult(err instanceof Error ? err.message : String(err));
|
|
108
|
+
}
|
|
109
|
+
}),
|
|
110
|
+
tool('cron_list', 'List the scheduled tasks bound to this bot.', {}, async () => {
|
|
111
|
+
try {
|
|
112
|
+
return jsonResult({ tasks: cronStore.loadOrEmpty().map(summarize) });
|
|
113
|
+
}
|
|
114
|
+
catch (err) {
|
|
115
|
+
return errResult(err instanceof Error ? err.message : String(err));
|
|
116
|
+
}
|
|
117
|
+
}),
|
|
118
|
+
tool('cron_delete', 'Delete a scheduled task by its id. Only the bot owner may delete tasks.', { id: z.string().min(1).describe('The task id (from cron_list).') }, async (args) => {
|
|
119
|
+
try {
|
|
120
|
+
if (!isOwner) {
|
|
121
|
+
return errResult('Only the bot owner can delete scheduled tasks.');
|
|
122
|
+
}
|
|
123
|
+
let found = false;
|
|
124
|
+
cronStore.update((tasks) => {
|
|
125
|
+
const next = tasks.filter((t) => t.id !== args.id);
|
|
126
|
+
found = next.length !== tasks.length;
|
|
127
|
+
return found ? next : tasks;
|
|
128
|
+
});
|
|
129
|
+
if (!found) {
|
|
130
|
+
return errResult(`no task with id ${args.id}`);
|
|
131
|
+
}
|
|
132
|
+
return jsonResult({ deleted: args.id });
|
|
133
|
+
}
|
|
134
|
+
catch (err) {
|
|
135
|
+
return errResult(err instanceof Error ? err.message : String(err));
|
|
136
|
+
}
|
|
137
|
+
}),
|
|
138
|
+
];
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Build the cron MCP server for one agent turn. `coords` binds created tasks to
|
|
142
|
+
* this session; `ownerUid` gates create/delete to the bot owner.
|
|
143
|
+
*/
|
|
144
|
+
export function createCronToolServer(cronStore, coords, ownerUid) {
|
|
145
|
+
return createSdkMcpServer({
|
|
146
|
+
name: CRON_TOOL_SERVER_NAME,
|
|
147
|
+
version: '1.0.0',
|
|
148
|
+
tools: buildCronTools(cronStore, coords, ownerUid),
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
//# sourceMappingURL=cron-tool.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cron-tool.js","sourceRoot":"","sources":["../src/cron-tool.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Q3: Per-session cwd isolation under a shared `cwdBase`.
|
|
3
|
+
*
|
|
4
|
+
* Each session maps to a deterministic 16-hex sha256 prefix directory inside
|
|
5
|
+
* `cwdBase`, so one user's working tree cannot be read or mutated from another
|
|
6
|
+
* user's session while operators still allocate a single disk root for the bot.
|
|
7
|
+
*
|
|
8
|
+
* The partition key is the *exact* `sessionKey` the SessionRouter already
|
|
9
|
+
* produced for history (`SessionRouter.sessionKey()`), prefixed by the channel
|
|
10
|
+
* kind. Reusing the router key verbatim — rather than re-deriving spaceId or
|
|
11
|
+
* channel_id from the raw message — guarantees the cwd partition can never
|
|
12
|
+
* drift from the history partition:
|
|
13
|
+
*
|
|
14
|
+
* - DM: sessionKey = `${spaceId}:${uid}` (or bare `uid`) → `dm:<key>`
|
|
15
|
+
* - Group: sessionKey = `${channel_id}` → `group:<key>`
|
|
16
|
+
*
|
|
17
|
+
* Group sessionKey is the channel id alone, so ALL members of a group share one
|
|
18
|
+
* sandbox (a group is a collective workspace). DM is per-user, so each peer gets
|
|
19
|
+
* a private sandbox. The `kind` prefix keeps a DM key and a group key that
|
|
20
|
+
* happen to be byte-identical from colliding. (Group sharing reverses the
|
|
21
|
+
* per-(channel×user) split from PR #64 — intentional; space isolation is
|
|
22
|
+
* provided by one-bot-per-space, each with its own cwdBase.)
|
|
23
|
+
*/
|
|
24
|
+
/**
|
|
25
|
+
* Per-session routing context for cwd isolation. `kind` is the channel class
|
|
26
|
+
* and `sessionKey` is the router-produced key the session's history is stored
|
|
27
|
+
* under — see `SessionRouter.sessionKey()`. Passing the router key directly
|
|
28
|
+
* (instead of re-deriving uid/spaceId/channel_id here) is what keeps the cwd
|
|
29
|
+
* and history partitions byte-for-byte consistent.
|
|
30
|
+
*/
|
|
31
|
+
export type SessionCtx = {
|
|
32
|
+
kind: 'dm' | 'group';
|
|
33
|
+
sessionKey: string;
|
|
34
|
+
};
|
|
35
|
+
/** 7 days — long enough for a vacation, short enough to bound disk growth. */
|
|
36
|
+
export declare const DEFAULT_CWD_TTL_MS: number;
|
|
37
|
+
/**
|
|
38
|
+
* Compute the SDK auto-memory directory for a session under `memoryBase`.
|
|
39
|
+
*
|
|
40
|
+
* Deliberately a PURE function — unlike resolveSessionCwd it does NOT mkdir,
|
|
41
|
+
* touch mtime, or write a registry marker. The SDK creates the directory on
|
|
42
|
+
* first use, and we want auto-memory to be PERMANENT (no TTL): `memoryBase`
|
|
43
|
+
* lives outside `cwdBase`, so the cwd TTL sweep (cleanupExpiredCwds) never sees
|
|
44
|
+
* it. Uses the same kind-prefixed sha256 hashing as the cwd sandbox so the
|
|
45
|
+
* memory partition tracks the session partition exactly (group=shared per
|
|
46
|
+
* channel, DM=private per peer).
|
|
47
|
+
*/
|
|
48
|
+
export declare function resolveMemoryDir(memoryBase: string, ctx: SessionCtx): string;
|
|
49
|
+
/**
|
|
50
|
+
* Resolve and ensure the per-session cwd exists. Idempotent — safe to call
|
|
51
|
+
* on every turn. Returns the absolute path under `cwdBase`.
|
|
52
|
+
*
|
|
53
|
+
* Note: the TTL tracks last *bot turn* (this function bumps the dir mtime on
|
|
54
|
+
* every call), not arbitrary filesystem activity inside the sandbox. A session
|
|
55
|
+
* with no inbound message for `ttlMs` is reclaimed even if a background process
|
|
56
|
+
* is still touching files inside it.
|
|
57
|
+
*/
|
|
58
|
+
export declare function resolveSessionCwd(cwdBase: string, ctx: SessionCtx): string;
|
|
59
|
+
/**
|
|
60
|
+
* Sweep `cwdBase` for hashed session dirs whose mtime is older than `ttlMs`
|
|
61
|
+
* and remove them. Best-effort: failures are logged, never thrown — the bot
|
|
62
|
+
* must continue running even if disk cleanup hits a permission error.
|
|
63
|
+
*
|
|
64
|
+
* Silent no-op when `cwdBase` does not exist (e.g. first-run before any
|
|
65
|
+
* session has been resolved).
|
|
66
|
+
*
|
|
67
|
+
* A dir is eligible for deletion only when ALL hold: the name matches the
|
|
68
|
+
* 16-hex pattern, it is a real directory (not a symlink), and it has a matching
|
|
69
|
+
* entry in the `.cc-octo-sessions` registry (P0-3). The registry entry is
|
|
70
|
+
* removed alongside the dir.
|
|
71
|
+
*/
|
|
72
|
+
export declare function cleanupExpiredCwds(cwdBase: string, ttlMs?: number): void;
|