@inetafrica/open-claudia 2.0.3 → 2.0.5
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/bin/agent.js +60 -0
- package/bin/cli.js +36 -0
- package/bin/loopback-client.js +99 -0
- package/bin/schedule.js +118 -0
- package/bin/task.js +154 -0
- package/bot.js +3 -2
- package/core/actions.js +18 -7
- package/core/adapter-registry.js +1 -1
- package/core/config.js +6 -4
- package/core/handlers.js +22 -13
- package/core/jobs.js +99 -0
- package/core/loopback.js +173 -27
- package/core/runner.js +12 -0
- package/core/scheduler.js +249 -0
- package/core/subagent.js +109 -0
- package/core/system-prompt.js +43 -2
- package/core/tasks.js +168 -0
- package/package.json +1 -1
- package/core/cron.js +0 -77
package/core/jobs.js
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// Unified persistent store for scheduled work — both one-shot wakeups
|
|
2
|
+
// the agent set for itself ("check back in 30 min") and recurring crons
|
|
3
|
+
// (user-defined or agent-defined). Survives bot restarts.
|
|
4
|
+
//
|
|
5
|
+
// Entry shape:
|
|
6
|
+
// {
|
|
7
|
+
// id: "job_<ts>_<rand>",
|
|
8
|
+
// kind: "wakeup" | "cron",
|
|
9
|
+
// adapter: "<adapter-id>", // e.g. "telegram-1" / "kazee-1"
|
|
10
|
+
// adapterType: "telegram"|"kazee",
|
|
11
|
+
// channelId: "<chat or channel id>",
|
|
12
|
+
// canonicalUserId: "telegram:<id>" or "<email>",
|
|
13
|
+
// project: "<project-name>" or null,
|
|
14
|
+
// prompt: "<text to send as if user>",
|
|
15
|
+
// label: "<short>",
|
|
16
|
+
// source: "agent" | "user",
|
|
17
|
+
// // wakeup-only
|
|
18
|
+
// fireAt: <ms epoch>,
|
|
19
|
+
// // cron-only
|
|
20
|
+
// schedule: "<5-field cron>",
|
|
21
|
+
// // session pinning (per choice a/i):
|
|
22
|
+
// sessionKey: "lastSessionId" | "cursorSessionId" | "codexSessionId" | null,
|
|
23
|
+
// sessionId: "<claude-session-id>" | null,
|
|
24
|
+
// createdAt: <ms>,
|
|
25
|
+
// lastFireAt: <ms> | null,
|
|
26
|
+
// lastFireOk: true|false|null,
|
|
27
|
+
// }
|
|
28
|
+
|
|
29
|
+
const fs = require("fs");
|
|
30
|
+
const path = require("path");
|
|
31
|
+
const { JOBS_FILE } = require("./config");
|
|
32
|
+
|
|
33
|
+
function load() {
|
|
34
|
+
try {
|
|
35
|
+
const raw = JSON.parse(fs.readFileSync(JOBS_FILE, "utf-8"));
|
|
36
|
+
return Array.isArray(raw) ? raw : [];
|
|
37
|
+
} catch (e) { return []; }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function save(list) {
|
|
41
|
+
fs.mkdirSync(path.dirname(JOBS_FILE), { recursive: true });
|
|
42
|
+
const tmp = `${JOBS_FILE}.tmp`;
|
|
43
|
+
fs.writeFileSync(tmp, JSON.stringify(list, null, 2));
|
|
44
|
+
fs.renameSync(tmp, JOBS_FILE);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function nextId(kind) {
|
|
48
|
+
return `${kind}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function add(job) {
|
|
52
|
+
const list = load();
|
|
53
|
+
const entry = { createdAt: Date.now(), lastFireAt: null, lastFireOk: null, ...job };
|
|
54
|
+
if (!entry.id) entry.id = nextId(entry.kind || "job");
|
|
55
|
+
list.push(entry);
|
|
56
|
+
save(list);
|
|
57
|
+
return entry;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function remove(id) {
|
|
61
|
+
const list = load();
|
|
62
|
+
const idx = list.findIndex((j) => j.id === id);
|
|
63
|
+
if (idx < 0) return null;
|
|
64
|
+
const [removed] = list.splice(idx, 1);
|
|
65
|
+
save(list);
|
|
66
|
+
return removed;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function update(id, patch) {
|
|
70
|
+
const list = load();
|
|
71
|
+
const idx = list.findIndex((j) => j.id === id);
|
|
72
|
+
if (idx < 0) return null;
|
|
73
|
+
list[idx] = { ...list[idx], ...patch };
|
|
74
|
+
save(list);
|
|
75
|
+
return list[idx];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function get(id) {
|
|
79
|
+
return load().find((j) => j.id === id) || null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function listForChannel(adapter, channelId) {
|
|
83
|
+
return load().filter((j) =>
|
|
84
|
+
String(j.channelId) === String(channelId) &&
|
|
85
|
+
(j.adapter === adapter || j.adapterType === adapter),
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function listAll() { return load(); }
|
|
90
|
+
|
|
91
|
+
function listByCanonicalUser(canonicalUserId) {
|
|
92
|
+
return load().filter((j) => j.canonicalUserId === canonicalUserId);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
module.exports = {
|
|
96
|
+
load, save, add, remove, update, get,
|
|
97
|
+
listAll, listForChannel, listByCanonicalUser,
|
|
98
|
+
nextId,
|
|
99
|
+
};
|
package/core/loopback.js
CHANGED
|
@@ -15,6 +15,9 @@ const http = require("http");
|
|
|
15
15
|
const os = require("os");
|
|
16
16
|
const crypto = require("crypto");
|
|
17
17
|
const { CONFIG_DIR } = require("./config");
|
|
18
|
+
const jobsStore = require("./jobs");
|
|
19
|
+
const tasksStore = require("./tasks");
|
|
20
|
+
const scheduler = require("./scheduler");
|
|
18
21
|
|
|
19
22
|
const INFO_DIR = path.join(CONFIG_DIR, "loopback");
|
|
20
23
|
const INFO_FILE = path.join(INFO_DIR, `${process.pid}.json`);
|
|
@@ -58,7 +61,173 @@ function readBodyToFile(req, dest) {
|
|
|
58
61
|
});
|
|
59
62
|
}
|
|
60
63
|
|
|
61
|
-
const
|
|
64
|
+
const SEND_KINDS = new Set(["send-file", "send-voice", "send-photo"]);
|
|
65
|
+
const JSON_KINDS = new Set([
|
|
66
|
+
"schedule-wakeup", "cron-add", "cron-remove", "job-list",
|
|
67
|
+
"task-add", "task-plan", "task-update", "task-remove", "task-list", "task-tree", "task-clear-completed",
|
|
68
|
+
]);
|
|
69
|
+
|
|
70
|
+
function readBodyAsString(req, max = 64 * 1024) {
|
|
71
|
+
return new Promise((resolve, reject) => {
|
|
72
|
+
let buf = "";
|
|
73
|
+
req.on("data", (d) => {
|
|
74
|
+
buf += d.toString();
|
|
75
|
+
if (buf.length > max) {
|
|
76
|
+
req.destroy();
|
|
77
|
+
reject(new Error("body too large"));
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
req.on("end", () => resolve(buf));
|
|
81
|
+
req.on("error", reject);
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function handleSend(req, res, url, kind) {
|
|
86
|
+
const channelId = url.searchParams.get("channelId");
|
|
87
|
+
const adapterId = url.searchParams.get("adapter");
|
|
88
|
+
const caption = url.searchParams.get("caption") || "";
|
|
89
|
+
const fileName = url.searchParams.get("fileName") || `file-${Date.now()}.bin`;
|
|
90
|
+
if (!channelId || !adapterId) return reply(res, 400, { error: "missing channelId/adapter" });
|
|
91
|
+
const adapter = registry.findAdapter(adapterId);
|
|
92
|
+
if (!adapter) return reply(res, 400, { error: `unknown adapter ${adapterId}` });
|
|
93
|
+
|
|
94
|
+
const safeName = path.basename(fileName).replace(/[^A-Za-z0-9._-]/g, "_") || `file-${Date.now()}`;
|
|
95
|
+
const tmp = path.join(os.tmpdir(), `oc-loopback-${process.pid}-${Date.now()}-${safeName}`);
|
|
96
|
+
await readBodyToFile(req, tmp);
|
|
97
|
+
|
|
98
|
+
let ok = false;
|
|
99
|
+
try {
|
|
100
|
+
if (kind === "send-voice") {
|
|
101
|
+
ok = await adapter.sendVoice(channelId, tmp);
|
|
102
|
+
} else if (kind === "send-photo" && typeof adapter.sendPhoto === "function") {
|
|
103
|
+
ok = await adapter.sendPhoto(channelId, tmp, caption);
|
|
104
|
+
} else {
|
|
105
|
+
ok = await adapter.sendFile(channelId, tmp, caption);
|
|
106
|
+
}
|
|
107
|
+
} finally {
|
|
108
|
+
if (kind !== "send-voice") { try { fs.unlinkSync(tmp); } catch (e) {} }
|
|
109
|
+
}
|
|
110
|
+
return reply(res, ok ? 200 : 500, { ok });
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function handleJson(req, res, url, kind) {
|
|
114
|
+
const body = await readBodyAsString(req);
|
|
115
|
+
let payload = {};
|
|
116
|
+
if (body.trim()) {
|
|
117
|
+
try { payload = JSON.parse(body); }
|
|
118
|
+
catch (e) { return reply(res, 400, { error: "invalid JSON body" }); }
|
|
119
|
+
}
|
|
120
|
+
const channelId = String(payload.channelId || url.searchParams.get("channelId") || "");
|
|
121
|
+
const adapterId = String(payload.adapter || url.searchParams.get("adapter") || "");
|
|
122
|
+
if (!channelId || !adapterId) return reply(res, 400, { error: "missing channelId/adapter" });
|
|
123
|
+
const adapter = registry.findAdapter(adapterId);
|
|
124
|
+
if (!adapter) return reply(res, 400, { error: `unknown adapter ${adapterId}` });
|
|
125
|
+
|
|
126
|
+
if (kind === "schedule-wakeup") {
|
|
127
|
+
const fireAt = Number(payload.fireAt);
|
|
128
|
+
if (!Number.isFinite(fireAt) || fireAt <= 0) return reply(res, 400, { error: "missing/invalid fireAt" });
|
|
129
|
+
if (!payload.prompt) return reply(res, 400, { error: "missing prompt" });
|
|
130
|
+
const job = scheduler.addJob({
|
|
131
|
+
kind: "wakeup",
|
|
132
|
+
adapter: adapterId,
|
|
133
|
+
adapterType: adapter.type,
|
|
134
|
+
channelId,
|
|
135
|
+
canonicalUserId: payload.canonicalUserId || null,
|
|
136
|
+
project: payload.project || null,
|
|
137
|
+
prompt: String(payload.prompt),
|
|
138
|
+
label: String(payload.label || payload.prompt).slice(0, 60),
|
|
139
|
+
source: payload.source || "agent",
|
|
140
|
+
fireAt,
|
|
141
|
+
sessionKey: payload.sessionKey || null,
|
|
142
|
+
sessionId: payload.sessionId || null,
|
|
143
|
+
});
|
|
144
|
+
return reply(res, 200, { ok: true, job });
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (kind === "cron-add") {
|
|
148
|
+
if (!payload.schedule || !payload.prompt) return reply(res, 400, { error: "missing schedule/prompt" });
|
|
149
|
+
const job = scheduler.addJob({
|
|
150
|
+
kind: "cron",
|
|
151
|
+
adapter: adapterId,
|
|
152
|
+
adapterType: adapter.type,
|
|
153
|
+
channelId,
|
|
154
|
+
canonicalUserId: payload.canonicalUserId || null,
|
|
155
|
+
project: payload.project || null,
|
|
156
|
+
prompt: String(payload.prompt),
|
|
157
|
+
label: String(payload.label || payload.prompt).slice(0, 60),
|
|
158
|
+
source: payload.source || "agent",
|
|
159
|
+
schedule: String(payload.schedule),
|
|
160
|
+
sessionKey: payload.sessionKey || null,
|
|
161
|
+
sessionId: payload.sessionId || null,
|
|
162
|
+
});
|
|
163
|
+
return reply(res, 200, { ok: true, job });
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (kind === "cron-remove") {
|
|
167
|
+
const id = payload.id || url.searchParams.get("id");
|
|
168
|
+
if (!id) return reply(res, 400, { error: "missing id" });
|
|
169
|
+
const removed = scheduler.removeJob(id);
|
|
170
|
+
return reply(res, removed ? 200 : 404, { ok: !!removed, removed });
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (kind === "job-list") {
|
|
174
|
+
const list = jobsStore.listForChannel(adapterId, channelId);
|
|
175
|
+
return reply(res, 200, { ok: true, jobs: list });
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (kind === "task-add") {
|
|
179
|
+
if (!payload.content) return reply(res, 400, { error: "missing content" });
|
|
180
|
+
try {
|
|
181
|
+
const t = tasksStore.add(adapterId, channelId, payload.content, {
|
|
182
|
+
parentId: payload.parentId || null,
|
|
183
|
+
description: payload.description || null,
|
|
184
|
+
});
|
|
185
|
+
return reply(res, 200, { ok: true, task: t });
|
|
186
|
+
} catch (e) { return reply(res, 400, { error: e.message }); }
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (kind === "task-plan") {
|
|
190
|
+
if (!payload.title) return reply(res, 400, { error: "missing title" });
|
|
191
|
+
const subs = Array.isArray(payload.subtasks) ? payload.subtasks : [];
|
|
192
|
+
if (subs.length === 0) return reply(res, 400, { error: "subtasks must be a non-empty array" });
|
|
193
|
+
const result = tasksStore.plan(adapterId, channelId, payload.title, subs, {
|
|
194
|
+
description: payload.description || null,
|
|
195
|
+
});
|
|
196
|
+
return reply(res, 200, { ok: true, plan: result });
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (kind === "task-tree") {
|
|
200
|
+
const t = tasksStore.tree(adapterId, channelId);
|
|
201
|
+
return reply(res, 200, { ok: true, tree: t });
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (kind === "task-update") {
|
|
205
|
+
if (!payload.id) return reply(res, 400, { error: "missing id" });
|
|
206
|
+
const patch = {};
|
|
207
|
+
if (payload.status) patch.status = payload.status;
|
|
208
|
+
if (typeof payload.content === "string") patch.content = payload.content;
|
|
209
|
+
const t = tasksStore.update(adapterId, channelId, payload.id, patch);
|
|
210
|
+
return reply(res, t ? 200 : 404, { ok: !!t, task: t });
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (kind === "task-remove") {
|
|
214
|
+
if (!payload.id) return reply(res, 400, { error: "missing id" });
|
|
215
|
+
const removed = tasksStore.remove(adapterId, channelId, payload.id);
|
|
216
|
+
return reply(res, removed ? 200 : 404, { ok: !!removed, removed });
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (kind === "task-list") {
|
|
220
|
+
const list = tasksStore.list(adapterId, channelId, payload.status ? { status: payload.status } : {});
|
|
221
|
+
return reply(res, 200, { ok: true, tasks: list });
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (kind === "task-clear-completed") {
|
|
225
|
+
const remaining = tasksStore.clearCompleted(adapterId, channelId);
|
|
226
|
+
return reply(res, 200, { ok: true, tasks: remaining });
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return reply(res, 404, { error: "not found" });
|
|
230
|
+
}
|
|
62
231
|
|
|
63
232
|
async function handle(req, res) {
|
|
64
233
|
try {
|
|
@@ -66,32 +235,9 @@ async function handle(req, res) {
|
|
|
66
235
|
if (!authOk(req)) return reply(res, 401, { error: "unauthorized" });
|
|
67
236
|
const url = new URL(req.url, "http://127.0.0.1");
|
|
68
237
|
const kind = url.pathname.slice(1);
|
|
69
|
-
if (
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
const caption = url.searchParams.get("caption") || "";
|
|
73
|
-
const fileName = url.searchParams.get("fileName") || `file-${Date.now()}.bin`;
|
|
74
|
-
if (!channelId || !adapterId) return reply(res, 400, { error: "missing channelId/adapter" });
|
|
75
|
-
const adapter = registry.findAdapter(adapterId);
|
|
76
|
-
if (!adapter) return reply(res, 400, { error: `unknown adapter ${adapterId}` });
|
|
77
|
-
|
|
78
|
-
const safeName = path.basename(fileName).replace(/[^A-Za-z0-9._-]/g, "_") || `file-${Date.now()}`;
|
|
79
|
-
const tmp = path.join(os.tmpdir(), `oc-loopback-${process.pid}-${Date.now()}-${safeName}`);
|
|
80
|
-
await readBodyToFile(req, tmp);
|
|
81
|
-
|
|
82
|
-
let ok = false;
|
|
83
|
-
try {
|
|
84
|
-
if (kind === "send-voice") {
|
|
85
|
-
ok = await adapter.sendVoice(channelId, tmp); // adapter unlinks on success/failure
|
|
86
|
-
} else if (kind === "send-photo" && typeof adapter.sendPhoto === "function") {
|
|
87
|
-
ok = await adapter.sendPhoto(channelId, tmp, caption);
|
|
88
|
-
} else {
|
|
89
|
-
ok = await adapter.sendFile(channelId, tmp, caption);
|
|
90
|
-
}
|
|
91
|
-
} finally {
|
|
92
|
-
if (kind !== "send-voice") { try { fs.unlinkSync(tmp); } catch (e) {} }
|
|
93
|
-
}
|
|
94
|
-
return reply(res, ok ? 200 : 500, { ok });
|
|
238
|
+
if (SEND_KINDS.has(kind)) return handleSend(req, res, url, kind);
|
|
239
|
+
if (JSON_KINDS.has(kind)) return handleJson(req, res, url, kind);
|
|
240
|
+
return reply(res, 404, { error: "not found" });
|
|
95
241
|
} catch (e) {
|
|
96
242
|
console.error("loopback handle error:", e.message);
|
|
97
243
|
return reply(res, 500, { error: e.message });
|
package/core/runner.js
CHANGED
|
@@ -35,6 +35,18 @@ function chatEnvOverlay() {
|
|
|
35
35
|
}
|
|
36
36
|
const tinfo = transcriptProjectInfo();
|
|
37
37
|
if (tinfo && tinfo.transcriptPath) overlay.OC_TRANSCRIPT_PATH = tinfo.transcriptPath;
|
|
38
|
+
try {
|
|
39
|
+
const state = currentState();
|
|
40
|
+
if (state) {
|
|
41
|
+
if (state.userId) overlay.OC_CANONICAL_USER_ID = String(state.userId);
|
|
42
|
+
const key = getActiveSessionKey(state);
|
|
43
|
+
const sid = state[key];
|
|
44
|
+
if (sid) {
|
|
45
|
+
overlay.OC_LAST_SESSION_ID = String(sid);
|
|
46
|
+
overlay.OC_LAST_SESSION_KEY = key;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
} catch (e) { /* no chat context (e.g. startup) — skip */ }
|
|
38
50
|
return overlay;
|
|
39
51
|
}
|
|
40
52
|
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
// Unified scheduler — owns every timer the bot has running. Replaces
|
|
2
|
+
// the old core/cron.js. Loads jobs.json on boot, schedules wakeups
|
|
3
|
+
// (setTimeout) and crons (node-cron), and fires each one in the right
|
|
4
|
+
// chat context with the right session pinned.
|
|
5
|
+
//
|
|
6
|
+
// When a wakeup fires the agent wakes up inside the SAME Claude session
|
|
7
|
+
// it was in when it scheduled the wakeup (per design choice a/i). If
|
|
8
|
+
// the channel is busy with a live turn the fire is deferred 30s and
|
|
9
|
+
// retried. One-shot wakeups self-delete; crons persist.
|
|
10
|
+
|
|
11
|
+
const path = require("path");
|
|
12
|
+
const fs = require("fs");
|
|
13
|
+
const cron = require("node-cron");
|
|
14
|
+
|
|
15
|
+
const { CRONS_FILE, WORKSPACE, CHAT_ID } = require("./config");
|
|
16
|
+
const jobs = require("./jobs");
|
|
17
|
+
const { runInChat } = require("./context");
|
|
18
|
+
const { getUserState, userStates } = require("./state");
|
|
19
|
+
const { canonicalForTelegram, canonicalForChannel } = require("./identity");
|
|
20
|
+
const { send } = require("./io");
|
|
21
|
+
|
|
22
|
+
const MISSED_WAKEUP_GRACE_MS = 60 * 60 * 1000;
|
|
23
|
+
const DEFER_BUSY_MS = 30 * 1000;
|
|
24
|
+
|
|
25
|
+
const timers = new Map();
|
|
26
|
+
let adapters = [];
|
|
27
|
+
|
|
28
|
+
function setAdapters(list) {
|
|
29
|
+
adapters = Array.isArray(list) ? list.slice() : [];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function resolveAdapter(job) {
|
|
33
|
+
if (job.adapter) {
|
|
34
|
+
const byId = adapters.find((a) => a.id === job.adapter);
|
|
35
|
+
if (byId) return byId;
|
|
36
|
+
}
|
|
37
|
+
if (job.adapterType) {
|
|
38
|
+
const byType = adapters.find((a) => a.type === job.adapterType);
|
|
39
|
+
if (byType) return byType;
|
|
40
|
+
}
|
|
41
|
+
return adapters[0] || null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function projectDir(job) {
|
|
45
|
+
if (!job.project) return null;
|
|
46
|
+
if (path.isAbsolute(job.project)) return job.project;
|
|
47
|
+
if (!WORKSPACE) return null;
|
|
48
|
+
return path.join(WORKSPACE, job.project);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function deriveCanonicalUserId(adapter, job) {
|
|
52
|
+
if (job.canonicalUserId) return job.canonicalUserId;
|
|
53
|
+
if (!adapter) return null;
|
|
54
|
+
return canonicalForChannel(adapter.type, job.channelId);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function deriveUserId(adapter, job) {
|
|
58
|
+
if (job.userId) return String(job.userId);
|
|
59
|
+
if (adapter && adapter.type === "telegram") return String(job.channelId);
|
|
60
|
+
return String(job.canonicalUserId || job.channelId || "");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function fireJob(job, retry = 0) {
|
|
64
|
+
const adapter = resolveAdapter(job);
|
|
65
|
+
if (!adapter) {
|
|
66
|
+
console.error(`scheduler: no adapter for job ${job.id} (${job.label || job.kind})`);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
const canonicalUserId = deriveCanonicalUserId(adapter, job);
|
|
70
|
+
const userId = deriveUserId(adapter, job);
|
|
71
|
+
|
|
72
|
+
const ctx = {
|
|
73
|
+
adapter,
|
|
74
|
+
channelId: String(job.channelId),
|
|
75
|
+
canonicalUserId,
|
|
76
|
+
userId,
|
|
77
|
+
transport: adapter.type,
|
|
78
|
+
raw: null,
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
await runInChat(ctx, async () => {
|
|
82
|
+
const state = getUserState(canonicalUserId);
|
|
83
|
+
if (state.runningProcess) {
|
|
84
|
+
if (retry < 4) {
|
|
85
|
+
console.log(`scheduler: ${job.id} busy, deferring ${DEFER_BUSY_MS / 1000}s (retry ${retry + 1})`);
|
|
86
|
+
setTimeout(() => fireJob(job, retry + 1), DEFER_BUSY_MS);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
console.warn(`scheduler: ${job.id} still busy after retries, skipping`);
|
|
90
|
+
jobs.update(job.id, { lastFireAt: Date.now(), lastFireOk: false });
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const cwd = projectDir(job) || (state.currentSession && state.currentSession.dir) || WORKSPACE || process.cwd();
|
|
95
|
+
const label = job.label || (job.kind === "cron" ? "Scheduled cron" : "Scheduled wakeup");
|
|
96
|
+
|
|
97
|
+
try {
|
|
98
|
+
await send(`Wakeup: ${label}`);
|
|
99
|
+
} catch (e) { /* non-fatal */ }
|
|
100
|
+
|
|
101
|
+
const wrappedPrompt = job.kind === "cron"
|
|
102
|
+
? `[Scheduled cron \"${label}\" fired at ${new Date().toISOString()}]\n${job.prompt}`
|
|
103
|
+
: `[Scheduled wakeup \"${label}\" fired at ${new Date().toISOString()}]\n${job.prompt}`;
|
|
104
|
+
|
|
105
|
+
const { runClaude } = require("./runner");
|
|
106
|
+
const runOpts = {};
|
|
107
|
+
if (job.sessionId) runOpts.resumeSessionId = job.sessionId;
|
|
108
|
+
try {
|
|
109
|
+
await runClaude(wrappedPrompt, cwd, null, runOpts);
|
|
110
|
+
jobs.update(job.id, { lastFireAt: Date.now(), lastFireOk: true });
|
|
111
|
+
} catch (e) {
|
|
112
|
+
console.error(`scheduler: ${job.id} run error:`, e.message);
|
|
113
|
+
jobs.update(job.id, { lastFireAt: Date.now(), lastFireOk: false });
|
|
114
|
+
try { await send(`Wakeup "${label}" failed: ${e.message}`); } catch (_) {}
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
if (job.kind === "wakeup") {
|
|
119
|
+
jobs.remove(job.id);
|
|
120
|
+
timers.delete(job.id);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function scheduleJob(job) {
|
|
125
|
+
if (timers.has(job.id)) {
|
|
126
|
+
const prev = timers.get(job.id);
|
|
127
|
+
if (prev.stop) prev.stop();
|
|
128
|
+
if (prev.timeout) clearTimeout(prev.timeout);
|
|
129
|
+
timers.delete(job.id);
|
|
130
|
+
}
|
|
131
|
+
if (job.kind === "wakeup") {
|
|
132
|
+
const delay = Math.max(0, (job.fireAt || 0) - Date.now());
|
|
133
|
+
const timeout = setTimeout(() => fireJob(job), delay);
|
|
134
|
+
timers.set(job.id, { timeout });
|
|
135
|
+
} else if (job.kind === "cron") {
|
|
136
|
+
if (!cron.validate(job.schedule)) {
|
|
137
|
+
console.error(`scheduler: invalid cron schedule for ${job.id}: ${job.schedule}`);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
const task = cron.schedule(job.schedule, () => fireJob(job));
|
|
141
|
+
timers.set(job.id, { stop: () => task.stop() });
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function unscheduleJob(id) {
|
|
146
|
+
const t = timers.get(id);
|
|
147
|
+
if (!t) return;
|
|
148
|
+
if (t.stop) t.stop();
|
|
149
|
+
if (t.timeout) clearTimeout(t.timeout);
|
|
150
|
+
timers.delete(id);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function migrateLegacyCrons() {
|
|
154
|
+
try {
|
|
155
|
+
if (!CRONS_FILE || !fs.existsSync(CRONS_FILE)) return 0;
|
|
156
|
+
const raw = JSON.parse(fs.readFileSync(CRONS_FILE, "utf-8"));
|
|
157
|
+
if (!Array.isArray(raw) || raw.length === 0) return 0;
|
|
158
|
+
const existing = new Set(jobs.listAll().map((j) => j.id));
|
|
159
|
+
const tg = adapters.find((a) => a.type === "telegram");
|
|
160
|
+
const adapterId = tg ? tg.id : (adapters[0] && adapters[0].id);
|
|
161
|
+
const adapterType = tg ? "telegram" : (adapters[0] && adapters[0].type);
|
|
162
|
+
const channelId = tg ? (CHAT_ID || "") : "";
|
|
163
|
+
const canonicalUserId = adapterType === "telegram"
|
|
164
|
+
? canonicalForTelegram(CHAT_ID || "")
|
|
165
|
+
: canonicalForChannel(adapterType || "", channelId);
|
|
166
|
+
let migrated = 0;
|
|
167
|
+
for (const c of raw) {
|
|
168
|
+
if (!c || !c.schedule || !c.prompt) continue;
|
|
169
|
+
if (c.id && existing.has(c.id)) continue;
|
|
170
|
+
jobs.add({
|
|
171
|
+
id: c.id || jobs.nextId("cron"),
|
|
172
|
+
kind: "cron",
|
|
173
|
+
adapter: adapterId,
|
|
174
|
+
adapterType,
|
|
175
|
+
channelId: String(channelId),
|
|
176
|
+
canonicalUserId,
|
|
177
|
+
project: c.project || null,
|
|
178
|
+
prompt: c.prompt,
|
|
179
|
+
label: c.label || (c.prompt || "").slice(0, 50),
|
|
180
|
+
source: "user",
|
|
181
|
+
schedule: c.schedule,
|
|
182
|
+
sessionKey: null,
|
|
183
|
+
sessionId: null,
|
|
184
|
+
});
|
|
185
|
+
migrated++;
|
|
186
|
+
}
|
|
187
|
+
if (migrated > 0) {
|
|
188
|
+
const backup = `${CRONS_FILE}.migrated`;
|
|
189
|
+
try { fs.renameSync(CRONS_FILE, backup); } catch (e) {}
|
|
190
|
+
console.log(`scheduler: migrated ${migrated} legacy cron(s) to jobs.json (backup: ${backup})`);
|
|
191
|
+
}
|
|
192
|
+
return migrated;
|
|
193
|
+
} catch (e) {
|
|
194
|
+
console.error("scheduler: legacy migration failed:", e.message);
|
|
195
|
+
return 0;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function fireMissedWakeupsOnce() {
|
|
200
|
+
const now = Date.now();
|
|
201
|
+
for (const job of jobs.listAll()) {
|
|
202
|
+
if (job.kind !== "wakeup") continue;
|
|
203
|
+
if (!job.fireAt) continue;
|
|
204
|
+
if (job.fireAt >= now) continue;
|
|
205
|
+
if (now - job.fireAt > MISSED_WAKEUP_GRACE_MS) {
|
|
206
|
+
jobs.remove(job.id);
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
setTimeout(() => fireJob({ ...job, label: `${job.label || "wakeup"} (missed)` }), 1000);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function initScheduler(adapterList) {
|
|
214
|
+
setAdapters(adapterList);
|
|
215
|
+
migrateLegacyCrons();
|
|
216
|
+
for (const job of jobs.listAll()) {
|
|
217
|
+
try { scheduleJob(job); }
|
|
218
|
+
catch (e) { console.error(`scheduler: failed to schedule ${job.id}:`, e.message); }
|
|
219
|
+
}
|
|
220
|
+
fireMissedWakeupsOnce();
|
|
221
|
+
console.log(`scheduler: loaded ${jobs.listAll().length} job(s)`);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function addJob(spec) {
|
|
225
|
+
const job = jobs.add(spec);
|
|
226
|
+
try { scheduleJob(job); }
|
|
227
|
+
catch (e) { console.error(`scheduler: failed to schedule new ${job.id}:`, e.message); }
|
|
228
|
+
return job;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function removeJob(id) {
|
|
232
|
+
unscheduleJob(id);
|
|
233
|
+
return jobs.remove(id);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function stopAll() {
|
|
237
|
+
for (const [id] of timers) unscheduleJob(id);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
module.exports = {
|
|
241
|
+
setAdapters,
|
|
242
|
+
initScheduler,
|
|
243
|
+
scheduleJob,
|
|
244
|
+
unscheduleJob,
|
|
245
|
+
addJob,
|
|
246
|
+
removeJob,
|
|
247
|
+
stopAll,
|
|
248
|
+
fireJob,
|
|
249
|
+
};
|
package/core/subagent.js
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// Sub-agents: spawn a fresh, throwaway Claude process for focused
|
|
2
|
+
// research or sub-tasks. No session resume, no shared state with the
|
|
3
|
+
// caller's turn — the output is captured as text and returned. The
|
|
4
|
+
// caller (a parent Claude turn invoking the CLI via Bash) decides what
|
|
5
|
+
// to do with the result.
|
|
6
|
+
|
|
7
|
+
const { spawn } = require("child_process");
|
|
8
|
+
const { CLAUDE_PATH, MAX_PROCESS_TIMEOUT, botSubprocessEnv } = require("./config");
|
|
9
|
+
const { redactSensitive } = require("./redact");
|
|
10
|
+
const { claudeSubprocessEnv } = require("./auth-flow");
|
|
11
|
+
|
|
12
|
+
const DEFAULT_TIMEOUT_MS = 10 * 60 * 1000;
|
|
13
|
+
const MAX_OUTPUT_BYTES = 64 * 1024;
|
|
14
|
+
|
|
15
|
+
const concurrentByChannel = new Map();
|
|
16
|
+
|
|
17
|
+
function buildSubagentSystemPrompt(role) {
|
|
18
|
+
return [
|
|
19
|
+
"You are a sub-agent spawned by another Claude assistant via Open Claudia.",
|
|
20
|
+
"Your reply is captured as raw text and returned to the parent agent — there is no user reading it directly.",
|
|
21
|
+
"Stay focused on the prompt. Be concise. Return findings; do not chat.",
|
|
22
|
+
"You do not have access to the parent's chat session, files in /tmp from the parent, or the loopback send tools.",
|
|
23
|
+
"You can use Read, Glob, Grep, Bash for read-only research. Do not start long-running processes.",
|
|
24
|
+
role ? `Role: ${role}` : "",
|
|
25
|
+
].filter(Boolean).join("\n");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function trackStart(channelId) {
|
|
29
|
+
if (!channelId) return 0;
|
|
30
|
+
const cur = concurrentByChannel.get(channelId) || 0;
|
|
31
|
+
concurrentByChannel.set(channelId, cur + 1);
|
|
32
|
+
return cur + 1;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function trackEnd(channelId) {
|
|
36
|
+
if (!channelId) return;
|
|
37
|
+
const cur = concurrentByChannel.get(channelId) || 0;
|
|
38
|
+
if (cur <= 1) concurrentByChannel.delete(channelId);
|
|
39
|
+
else concurrentByChannel.set(channelId, cur - 1);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function concurrentCount(channelId) {
|
|
43
|
+
return concurrentByChannel.get(channelId) || 0;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function spawnSubagent(prompt, opts = {}) {
|
|
47
|
+
const cwd = opts.cwd || process.cwd();
|
|
48
|
+
const role = opts.role || null;
|
|
49
|
+
const channelId = opts.channelId || null;
|
|
50
|
+
const timeoutMs = Math.min(opts.timeoutMs || DEFAULT_TIMEOUT_MS, MAX_PROCESS_TIMEOUT || DEFAULT_TIMEOUT_MS);
|
|
51
|
+
|
|
52
|
+
const count = trackStart(channelId);
|
|
53
|
+
if (count > 3) {
|
|
54
|
+
console.warn(`subagent: ${count} concurrent sub-agents on channel ${channelId}`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return new Promise((resolve, reject) => {
|
|
58
|
+
const args = [
|
|
59
|
+
"-p",
|
|
60
|
+
"--output-format", opts.json ? "json" : "text",
|
|
61
|
+
"--verbose",
|
|
62
|
+
"--append-system-prompt", buildSubagentSystemPrompt(role),
|
|
63
|
+
"--dangerously-skip-permissions",
|
|
64
|
+
prompt,
|
|
65
|
+
];
|
|
66
|
+
const env = { ...botSubprocessEnv(), ...claudeSubprocessEnv() };
|
|
67
|
+
const proc = spawn(CLAUDE_PATH, args, { cwd, env, stdio: ["ignore", "pipe", "pipe"] });
|
|
68
|
+
|
|
69
|
+
let out = "";
|
|
70
|
+
let err = "";
|
|
71
|
+
let truncated = false;
|
|
72
|
+
|
|
73
|
+
proc.stdout.on("data", (d) => {
|
|
74
|
+
if (out.length + d.length > MAX_OUTPUT_BYTES) {
|
|
75
|
+
out += d.toString().slice(0, Math.max(0, MAX_OUTPUT_BYTES - out.length));
|
|
76
|
+
truncated = true;
|
|
77
|
+
} else {
|
|
78
|
+
out += d.toString();
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
proc.stderr.on("data", (d) => {
|
|
82
|
+
if (err.length < MAX_OUTPUT_BYTES) err += d.toString();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
const t = setTimeout(() => {
|
|
86
|
+
try { proc.kill("SIGTERM"); } catch (e) {}
|
|
87
|
+
reject(new Error(`subagent timed out after ${Math.round(timeoutMs / 1000)}s`));
|
|
88
|
+
}, timeoutMs);
|
|
89
|
+
|
|
90
|
+
proc.on("error", (e) => {
|
|
91
|
+
clearTimeout(t);
|
|
92
|
+
trackEnd(channelId);
|
|
93
|
+
reject(e);
|
|
94
|
+
});
|
|
95
|
+
proc.on("close", (code) => {
|
|
96
|
+
clearTimeout(t);
|
|
97
|
+
trackEnd(channelId);
|
|
98
|
+
const text = redactSensitive(out.trim());
|
|
99
|
+
const stderrText = redactSensitive(err.trim());
|
|
100
|
+
if (code !== 0 && !text) {
|
|
101
|
+
const msg = stderrText || `subagent exited with code ${code}`;
|
|
102
|
+
return reject(new Error(msg));
|
|
103
|
+
}
|
|
104
|
+
resolve({ text, truncated, stderr: stderrText, code });
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
module.exports = { spawnSubagent, concurrentCount };
|