@kevin5251984/guild 0.2.21 → 0.2.23

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/src/cron.ts ADDED
@@ -0,0 +1,403 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import type { CronJobRow } from "./db.ts";
3
+ import type { HandlerExtras } from "./handlers.ts";
4
+ import { StoreError, type GuildStore } from "./store.ts";
5
+ import type { ToolContext, ToolOutcome } from "./tools.ts";
6
+ import {
7
+ CRON_JOB_CAP,
8
+ CRON_MIN_EVERY_MS,
9
+ followingRun,
10
+ nextRunAt,
11
+ parseCronSchedule,
12
+ tokenizeCronSlash,
13
+ type CronSpec,
14
+ } from "./cron-schedule.ts";
15
+
16
+ const inflight = new Set<string>();
17
+
18
+ function specOf(job: CronJobRow): CronSpec {
19
+ return {
20
+ raw: job.schedule,
21
+ kind: job.kind,
22
+ atMs: job.atMs,
23
+ everyMs: job.everyMs,
24
+ cron: job.cronExpr,
25
+ };
26
+ }
27
+
28
+ export function publicCronJob(job: CronJobRow) {
29
+ return {
30
+ id: job.id,
31
+ name: job.name,
32
+ roomId: job.roomId,
33
+ botId: job.botId,
34
+ prompt: job.prompt,
35
+ schedule: job.schedule,
36
+ kind: job.kind,
37
+ nextRunAt: job.nextRunAt,
38
+ paused: job.paused,
39
+ createdAt: job.createdAt,
40
+ lastRunAt: job.lastRunAt,
41
+ lastStatus: job.lastStatus,
42
+ lastError: job.lastError,
43
+ running: inflight.has(job.id),
44
+ };
45
+ }
46
+
47
+ export function createCronJob(
48
+ store: GuildStore,
49
+ input: {
50
+ roomId: string;
51
+ botId: string;
52
+ prompt: string;
53
+ schedule: string;
54
+ name?: string;
55
+ },
56
+ ): CronJobRow {
57
+ const room = store.getRoom(input.roomId);
58
+ if (!room) throw new StoreError(404, "room not found");
59
+ const bot = store.getBot(input.botId);
60
+ if (!bot) throw new StoreError(400, "bot not found");
61
+ if (room.kind === "channel" && !room.memberIds.includes(bot.id)) {
62
+ throw new StoreError(400, "bot is not on this quest");
63
+ }
64
+ const prompt = input.prompt.trim();
65
+ if (!prompt) throw new StoreError(400, "prompt is required");
66
+ if (store.listCronJobs().length >= CRON_JOB_CAP) {
67
+ throw new StoreError(400, `at most ${CRON_JOB_CAP} cron jobs`);
68
+ }
69
+ let spec;
70
+ try {
71
+ spec = parseCronSchedule(input.schedule);
72
+ } catch (error) {
73
+ throw new StoreError(
74
+ 400,
75
+ error instanceof Error ? error.message : String(error),
76
+ );
77
+ }
78
+ const now = Date.now();
79
+ const name =
80
+ (input.name || "").trim() ||
81
+ prompt.replace(/\s+/g, " ").trim().slice(0, 28) ||
82
+ "cron";
83
+ const job: CronJobRow = {
84
+ id: randomUUID(),
85
+ name,
86
+ roomId: room.id,
87
+ botId: bot.id,
88
+ prompt,
89
+ schedule: spec.raw,
90
+ kind: spec.kind,
91
+ everyMs: spec.everyMs,
92
+ cronExpr: spec.cron,
93
+ atMs: spec.atMs,
94
+ nextRunAt: new Date(nextRunAt(spec, now)).toISOString(),
95
+ paused: false,
96
+ createdAt: new Date(now).toISOString(),
97
+ };
98
+ store.writeCronJob(job);
99
+ return job;
100
+ }
101
+
102
+ export function pauseCronJob(store: GuildStore, id: string): CronJobRow {
103
+ const job = store.getCronJob(id);
104
+ job.paused = true;
105
+ store.writeCronJob(job);
106
+ return job;
107
+ }
108
+
109
+ export function resumeCronJob(store: GuildStore, id: string): CronJobRow {
110
+ const job = store.getCronJob(id);
111
+ job.paused = false;
112
+ job.nextRunAt = new Date(nextRunAt(specOf(job), Date.now())).toISOString();
113
+ store.writeCronJob(job);
114
+ return job;
115
+ }
116
+
117
+ export function removeCronJob(store: GuildStore, id: string): { ok: true; id: string } {
118
+ if (!store.deleteCronJob(id)) throw new StoreError(404, "cron job not found");
119
+ return { ok: true, id };
120
+ }
121
+
122
+ function readCronJob(store: GuildStore, id: string): CronJobRow | null {
123
+ try {
124
+ return store.getCronJob(id);
125
+ } catch {
126
+ return null;
127
+ }
128
+ }
129
+
130
+ function commitFiredJob(
131
+ store: GuildStore,
132
+ id: string,
133
+ patch: Partial<CronJobRow> | "delete",
134
+ ): void {
135
+ const current = readCronJob(store, id);
136
+ if (!current) return;
137
+ if (patch === "delete") {
138
+ store.deleteCronJob(id);
139
+ return;
140
+ }
141
+ store.writeCronJob({
142
+ ...current,
143
+ ...patch,
144
+ id: current.id,
145
+ roomId: current.roomId,
146
+ botId: current.botId,
147
+ paused: current.paused || Boolean(patch.paused),
148
+ });
149
+ }
150
+
151
+ function seatBlocked(store: GuildStore, roomId: string, botId: string): string | null {
152
+ const live = store.listLiveRoomTurns(roomId);
153
+ if (live.some((turn) => turn.botId === botId)) return "seat busy";
154
+ if (live.some((turn) => !turn.paused)) return "room busy";
155
+ return null;
156
+ }
157
+
158
+ function findCronJob(store: GuildStore, ref: string): CronJobRow {
159
+ const id = ref.trim();
160
+ if (!id) throw new StoreError(400, "job id is required");
161
+ const direct = store.listCronJobs().find((job) => job.id === id);
162
+ if (direct) return direct;
163
+ const needle = id.toLowerCase();
164
+ const named = store.listCronJobs().filter((job) => job.name.toLowerCase() === needle);
165
+ if (named.length === 1) return named[0];
166
+ if (named.length > 1) {
167
+ throw new StoreError(
168
+ 400,
169
+ `name matches ${named.length} jobs: ${named.map((job) => job.id).join(", ")}`,
170
+ );
171
+ }
172
+ throw new StoreError(404, "cron job not found");
173
+ }
174
+
175
+ export async function fireCronJob(
176
+ store: GuildStore,
177
+ id: string,
178
+ env: NodeJS.ProcessEnv = process.env,
179
+ extras: HandlerExtras = {},
180
+ ): Promise<{ ok: boolean; skipped?: string; error?: string }> {
181
+ const job = store.getCronJob(id);
182
+ if (job.paused) return { ok: false, skipped: "paused" };
183
+ if (inflight.has(job.id)) return { ok: false, skipped: "already running" };
184
+ const blocked = seatBlocked(store, job.roomId, job.botId);
185
+ if (blocked) return { ok: false, skipped: blocked };
186
+ const room = store.getRoom(job.roomId);
187
+ if (!room) {
188
+ commitFiredJob(store, job.id, {
189
+ paused: true,
190
+ lastStatus: "failed",
191
+ lastError: "room not found",
192
+ });
193
+ return { ok: false, error: "room not found" };
194
+ }
195
+ if (room.kind === "channel" && !room.memberIds.includes(job.botId)) {
196
+ commitFiredJob(store, job.id, {
197
+ paused: true,
198
+ lastStatus: "failed",
199
+ lastError: "bot is not on this quest",
200
+ });
201
+ return { ok: false, error: "bot is not on this quest" };
202
+ }
203
+ const bot = store.getBot(job.botId);
204
+ if (!bot) {
205
+ commitFiredJob(store, job.id, {
206
+ paused: true,
207
+ lastStatus: "failed",
208
+ lastError: "bot not found",
209
+ });
210
+ return { ok: false, error: "bot not found" };
211
+ }
212
+ inflight.add(job.id);
213
+ const now = Date.now();
214
+ const failPatch = (message: string, pauseOnce: boolean): Partial<CronJobRow> => {
215
+ const next = followingRun(specOf(job), now);
216
+ return {
217
+ lastRunAt: new Date(now).toISOString(),
218
+ lastStatus: "failed",
219
+ lastError: message.slice(0, 400),
220
+ paused: pauseOnce && job.kind === "once",
221
+ nextRunAt: new Date(
222
+ next ?? now + CRON_MIN_EVERY_MS,
223
+ ).toISOString(),
224
+ };
225
+ };
226
+ try {
227
+ const { postUserMessage } = await import("./handlers.ts");
228
+ const body = `排程 · ${job.name}\n\n@${bot.handle} ${job.prompt}`;
229
+ const posted = await postUserMessage(
230
+ store,
231
+ job.roomId,
232
+ body,
233
+ env,
234
+ undefined,
235
+ undefined,
236
+ job.botId,
237
+ { ...extras, mentions: [job.botId], harvest: false, cronRun: true },
238
+ );
239
+ const spoke = (posted.replies || []).some((msg) => msg.author === job.botId);
240
+ if (!spoke) {
241
+ commitFiredJob(store, job.id, failPatch("no reply", true));
242
+ return { ok: false, skipped: "no reply" };
243
+ }
244
+ const next = followingRun(specOf(job), now);
245
+ if (next == null) {
246
+ commitFiredJob(store, job.id, "delete");
247
+ } else {
248
+ commitFiredJob(store, job.id, {
249
+ lastRunAt: new Date(now).toISOString(),
250
+ lastStatus: "ok",
251
+ lastError: undefined,
252
+ nextRunAt: new Date(next).toISOString(),
253
+ });
254
+ }
255
+ return { ok: true };
256
+ } catch (error) {
257
+ const message = error instanceof Error ? error.message : String(error);
258
+ commitFiredJob(store, job.id, failPatch(message, true));
259
+ return { ok: false, error: message };
260
+ } finally {
261
+ inflight.delete(job.id);
262
+ }
263
+ }
264
+
265
+ export async function tickCronJobs(
266
+ store: GuildStore,
267
+ env: NodeJS.ProcessEnv = process.env,
268
+ now = Date.now(),
269
+ ): Promise<void> {
270
+ const due = store
271
+ .listCronJobs()
272
+ .filter((job) => !job.paused && Date.parse(job.nextRunAt) <= now);
273
+ for (const job of due) {
274
+ if (inflight.has(job.id)) continue;
275
+ try {
276
+ await fireCronJob(store, job.id, env);
277
+ } catch {
278
+ /* one job must not block the tick */
279
+ }
280
+ }
281
+ }
282
+
283
+ export function executeCronjob(
284
+ store: GuildStore,
285
+ args: Record<string, unknown>,
286
+ ctx: ToolContext = {},
287
+ ): Promise<ToolOutcome> | ToolOutcome {
288
+ if (ctx.cronRun) {
289
+ return {
290
+ text: "cron jobs cannot manage cron (Hermes: no recursive scheduling)",
291
+ isError: true,
292
+ };
293
+ }
294
+ const action = String(args.action || args.op || "list").trim().toLowerCase();
295
+ try {
296
+ if (action === "list") {
297
+ const roomId =
298
+ typeof args.room_id === "string" ? args.room_id : ctx.roomId;
299
+ const jobs = store
300
+ .listCronJobs(roomId || undefined)
301
+ .map(publicCronJob);
302
+ return {
303
+ text: jobs.length ? JSON.stringify(jobs, null, 2) : "(no cron jobs)",
304
+ isError: false,
305
+ };
306
+ }
307
+ if (action === "create") {
308
+ const schedule = String(args.schedule || "").trim();
309
+ const prompt = String(args.prompt || args.task || "").trim();
310
+ const roomId =
311
+ (typeof args.room_id === "string" && args.room_id) || ctx.roomId || "";
312
+ const botId =
313
+ (typeof args.bot_id === "string" && args.bot_id) ||
314
+ (typeof args.botId === "string" && args.botId) ||
315
+ ctx.botId ||
316
+ "";
317
+ if (!roomId) throw new StoreError(400, "room_id is required");
318
+ if (!botId) throw new StoreError(400, "bot_id is required");
319
+ const job = createCronJob(store, {
320
+ roomId,
321
+ botId,
322
+ prompt,
323
+ schedule,
324
+ name: typeof args.name === "string" ? args.name : "",
325
+ });
326
+ return {
327
+ text: `created ${job.id} · ${job.name} · next ${job.nextRunAt} · ${job.schedule}`,
328
+ isError: false,
329
+ };
330
+ }
331
+ const ref = String(args.job_id || args.id || args.name || "").trim();
332
+ if (action === "pause") {
333
+ const job = pauseCronJob(store, findCronJob(store, ref).id);
334
+ return { text: `paused ${job.id} · ${job.name}`, isError: false };
335
+ }
336
+ if (action === "resume") {
337
+ const job = resumeCronJob(store, findCronJob(store, ref).id);
338
+ return {
339
+ text: `resumed ${job.id} · next ${job.nextRunAt}`,
340
+ isError: false,
341
+ };
342
+ }
343
+ if (action === "remove" || action === "delete") {
344
+ const job = findCronJob(store, ref);
345
+ removeCronJob(store, job.id);
346
+ return { text: `removed ${job.id} · ${job.name}`, isError: false };
347
+ }
348
+ if (action === "run") {
349
+ const job = findCronJob(store, ref);
350
+ return fireCronJob(store, job.id, ctx.env).then((result) => ({
351
+ text: result.ok
352
+ ? `ran ${job.id}`
353
+ : result.skipped
354
+ ? `skipped ${job.id}: ${result.skipped}`
355
+ : `failed ${job.id}: ${result.error || "error"}`,
356
+ isError: !result.ok && !result.skipped,
357
+ }));
358
+ }
359
+ return { text: `unknown cron action: ${action}`, isError: true };
360
+ } catch (error) {
361
+ const message = error instanceof Error ? error.message : String(error);
362
+ return { text: message, isError: true };
363
+ }
364
+ }
365
+
366
+ export function runCronSlash(
367
+ store: GuildStore,
368
+ roomId: string,
369
+ botId: string,
370
+ text: string,
371
+ ): { text: string; isError: boolean } {
372
+ const tokens = tokenizeCronSlash(text.replace(/^\/cron\b/i, "").trim());
373
+ const action = (tokens[0] || "list").toLowerCase();
374
+ if (action === "list" || action === "") {
375
+ return executeCronjob(store, { action: "list", room_id: roomId }, { roomId });
376
+ }
377
+ if (action === "add" || action === "create") {
378
+ const schedule = tokens[1] || "";
379
+ const prompt = tokens.slice(2).join(" ");
380
+ return executeCronjob(
381
+ store,
382
+ { action: "create", schedule, prompt, room_id: roomId, bot_id: botId },
383
+ { roomId },
384
+ );
385
+ }
386
+ if (
387
+ action === "pause" ||
388
+ action === "resume" ||
389
+ action === "run" ||
390
+ action === "remove" ||
391
+ action === "delete"
392
+ ) {
393
+ return executeCronjob(
394
+ store,
395
+ { action, job_id: tokens[1] || "" },
396
+ { roomId },
397
+ );
398
+ }
399
+ return {
400
+ text: 'usage: /cron list | add "<schedule>" "<prompt>" | pause|resume|run|remove <id>',
401
+ isError: true,
402
+ };
403
+ }
package/src/db.ts CHANGED
@@ -97,6 +97,27 @@ CREATE TABLE IF NOT EXISTS compact (
97
97
  updated_at TEXT NOT NULL,
98
98
  message_count INTEGER NOT NULL DEFAULT 0
99
99
  );
100
+
101
+ CREATE TABLE IF NOT EXISTS cron_jobs (
102
+ id TEXT PRIMARY KEY,
103
+ name TEXT NOT NULL,
104
+ room_id TEXT NOT NULL REFERENCES rooms(id) ON DELETE CASCADE,
105
+ bot_id TEXT NOT NULL,
106
+ prompt TEXT NOT NULL,
107
+ schedule TEXT NOT NULL,
108
+ kind TEXT NOT NULL CHECK (kind IN ('once', 'every', 'cron')),
109
+ every_ms INTEGER,
110
+ cron_expr TEXT,
111
+ at_ms INTEGER,
112
+ next_run_at TEXT NOT NULL,
113
+ paused INTEGER NOT NULL DEFAULT 0,
114
+ created_at TEXT NOT NULL,
115
+ last_run_at TEXT,
116
+ last_status TEXT,
117
+ last_error TEXT
118
+ );
119
+
120
+ CREATE INDEX IF NOT EXISTS cron_jobs_next ON cron_jobs(paused, next_run_at);
100
121
  `;
101
122
 
102
123
  type CompactRow = {
@@ -106,6 +127,54 @@ type CompactRow = {
106
127
  messageCount: number;
107
128
  };
108
129
 
130
+ export type CronJobRow = {
131
+ id: string;
132
+ name: string;
133
+ roomId: string;
134
+ botId: string;
135
+ prompt: string;
136
+ schedule: string;
137
+ kind: "once" | "every" | "cron";
138
+ everyMs?: number;
139
+ cronExpr?: string;
140
+ atMs?: number;
141
+ nextRunAt: string;
142
+ paused: boolean;
143
+ createdAt: string;
144
+ lastRunAt?: string;
145
+ lastStatus?: string;
146
+ lastError?: string;
147
+ };
148
+
149
+ function cronJobFromRow(row: Record<string, unknown>): CronJobRow {
150
+ const job: CronJobRow = {
151
+ id: asString(row.id),
152
+ name: asString(row.name),
153
+ roomId: asString(row.room_id),
154
+ botId: asString(row.bot_id),
155
+ prompt: asString(row.prompt),
156
+ schedule: asString(row.schedule),
157
+ kind:
158
+ asString(row.kind) === "once" || asString(row.kind) === "cron"
159
+ ? asString(row.kind)
160
+ : "every",
161
+ nextRunAt: asString(row.next_run_at),
162
+ paused: asNumber(row.paused) === 1,
163
+ createdAt: asString(row.created_at),
164
+ };
165
+ if (row.every_ms != null) job.everyMs = asNumber(row.every_ms);
166
+ const cronExpr = asString(row.cron_expr);
167
+ if (cronExpr) job.cronExpr = cronExpr;
168
+ if (row.at_ms != null) job.atMs = asNumber(row.at_ms);
169
+ const lastRun = asString(row.last_run_at);
170
+ if (lastRun) job.lastRunAt = lastRun;
171
+ const lastStatus = asString(row.last_status);
172
+ if (lastStatus) job.lastStatus = lastStatus;
173
+ const lastError = asString(row.last_error);
174
+ if (lastError) job.lastError = lastError;
175
+ return job;
176
+ }
177
+
109
178
  function asString(value: unknown, fallback = ""): string {
110
179
  return typeof value === "string" ? value : fallback;
111
180
  }
@@ -771,6 +840,72 @@ export class GuildDb {
771
840
  }
772
841
  }
773
842
 
843
+ listCronJobs(roomId?: string): CronJobRow[] {
844
+ const rows = roomId
845
+ ? (this.sqlite
846
+ .prepare("SELECT * FROM cron_jobs WHERE room_id = ? ORDER BY created_at")
847
+ .all(roomId) as Record<string, unknown>[])
848
+ : (this.sqlite
849
+ .prepare("SELECT * FROM cron_jobs ORDER BY next_run_at")
850
+ .all() as Record<string, unknown>[]);
851
+ return rows.map(cronJobFromRow);
852
+ }
853
+
854
+ getCronJob(id: string): CronJobRow | null {
855
+ const row = this.sqlite
856
+ .prepare("SELECT * FROM cron_jobs WHERE id = ?")
857
+ .get(id) as Record<string, unknown> | undefined;
858
+ return row ? cronJobFromRow(row) : null;
859
+ }
860
+
861
+ upsertCronJob(job: CronJobRow): void {
862
+ this.sqlite
863
+ .prepare(
864
+ `INSERT INTO cron_jobs (
865
+ id, name, room_id, bot_id, prompt, schedule, kind, every_ms, cron_expr, at_ms,
866
+ next_run_at, paused, created_at, last_run_at, last_status, last_error
867
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
868
+ ON CONFLICT(id) DO UPDATE SET
869
+ name = excluded.name,
870
+ room_id = excluded.room_id,
871
+ bot_id = excluded.bot_id,
872
+ prompt = excluded.prompt,
873
+ schedule = excluded.schedule,
874
+ kind = excluded.kind,
875
+ every_ms = excluded.every_ms,
876
+ cron_expr = excluded.cron_expr,
877
+ at_ms = excluded.at_ms,
878
+ next_run_at = excluded.next_run_at,
879
+ paused = excluded.paused,
880
+ last_run_at = excluded.last_run_at,
881
+ last_status = excluded.last_status,
882
+ last_error = excluded.last_error`,
883
+ )
884
+ .run(
885
+ job.id,
886
+ job.name,
887
+ job.roomId,
888
+ job.botId,
889
+ job.prompt,
890
+ job.schedule,
891
+ job.kind,
892
+ job.everyMs ?? null,
893
+ job.cronExpr ?? null,
894
+ job.atMs ?? null,
895
+ job.nextRunAt,
896
+ job.paused ? 1 : 0,
897
+ job.createdAt,
898
+ job.lastRunAt ?? null,
899
+ job.lastStatus ?? null,
900
+ job.lastError ?? null,
901
+ );
902
+ }
903
+
904
+ deleteCronJob(id: string): boolean {
905
+ const result = this.sqlite.prepare("DELETE FROM cron_jobs WHERE id = ?").run(id);
906
+ return result.changes > 0;
907
+ }
908
+
774
909
  private messageCount(roomId: string): number {
775
910
  const row = this.sqlite
776
911
  .prepare("SELECT COUNT(*) AS n FROM messages WHERE room_id = ?")
package/src/generate.ts CHANGED
@@ -504,6 +504,9 @@ export async function chatReply(input: {
504
504
  mcpTools?: McpToolRef[];
505
505
  sandbox?: Sandbox;
506
506
  workspace?: string;
507
+ roomId?: string;
508
+ botId?: string;
509
+ cronRun?: boolean;
507
510
  dispatch?: ToolContext["dispatch"];
508
511
  }): Promise<ChatReply> {
509
512
  const env = input.env ?? process.env;
@@ -569,6 +572,9 @@ async function tryChatLlm(
569
572
  mcpTools?: McpToolRef[];
570
573
  sandbox?: Sandbox;
571
574
  workspace?: string;
575
+ roomId?: string;
576
+ botId?: string;
577
+ cronRun?: boolean;
572
578
  dispatch?: ToolContext["dispatch"];
573
579
  },
574
580
  env: NodeJS.ProcessEnv,
@@ -625,6 +631,9 @@ async function tryChatLlm(
625
631
  signal: input.signal,
626
632
  mcpTools: input.mcpTools ?? (await listMcpToolRefs(dataDir)),
627
633
  spawnHandles: new Map(),
634
+ ...(input.roomId ? { roomId: input.roomId } : {}),
635
+ ...(input.botId ? { botId: input.botId } : {}),
636
+ ...(input.cronRun ? { cronRun: true } : {}),
628
637
  ...policyFor(env, {
629
638
  sandbox: input.sandbox,
630
639
  workspace: input.workspace,
package/src/handlers.ts CHANGED
@@ -75,6 +75,8 @@ export type HandlerExtras = {
75
75
  turn?: (input: Parameters<typeof chatReply>[0]) => Promise<ChatReply>;
76
76
  /** Bot ids the client already resolved from @mentions. */
77
77
  mentions?: string[];
78
+ /** Hermes: cron child sessions cannot manage cron. */
79
+ cronRun?: boolean;
78
80
  };
79
81
 
80
82
  export function healthPayload(): HealthResponse {
@@ -914,6 +916,8 @@ export function chatTurnForBot(
914
916
  history,
915
917
  userMessage,
916
918
  dataDir: store.dataDir,
919
+ roomId,
920
+ botId,
917
921
  model: detail.model ?? null,
918
922
  skills: mergeSkillRefs(
919
923
  staffedSkills(store, botId),
@@ -1276,6 +1280,7 @@ async function generateReplies(
1276
1280
  env,
1277
1281
  signal: botSignal,
1278
1282
  mcpTools,
1283
+ ...(extras.cronRun ? { cronRun: true } : {}),
1279
1284
  onProgress: (update) => {
1280
1285
  const prev = store.getLiveBotTurn(roomId, botId);
1281
1286
  if (prev?.paused) return;
package/src/harness.ts CHANGED
@@ -133,6 +133,23 @@ export function pathInsideWorkspace(target: string, workspace: string): boolean
133
133
  return rel === "" || (rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel));
134
134
  }
135
135
 
136
+ /** Scratch dirs `workspace_write` may also read / write / run-cwd. Not `$HOME`. */
137
+ export function extraWriteRoots(dataDir?: string): string[] {
138
+ const roots = [resolve("/tmp")];
139
+ const home = dataDir?.trim();
140
+ if (home) roots.push(resolve(home, "cache"));
141
+ return roots;
142
+ }
143
+
144
+ export function allowedWorkspaceWritePath(
145
+ target: string,
146
+ workspace: string,
147
+ dataDir?: string,
148
+ ): boolean {
149
+ if (pathInsideWorkspace(target, workspace)) return true;
150
+ return extraWriteRoots(dataDir).some((root) => pathInsideWorkspace(target, root));
151
+ }
152
+
136
153
  export function mutatingTool(name: string): boolean {
137
154
  return (
138
155
  name === "run" ||
@@ -150,6 +167,7 @@ export function gateTool(
150
167
  input: {
151
168
  sandbox?: Sandbox;
152
169
  workspace?: string;
170
+ dataDir?: string;
153
171
  } = {},
154
172
  ): SandboxRefusal | null {
155
173
  const sandbox = parseSandbox(input.sandbox);
@@ -161,7 +179,8 @@ export function gateTool(
161
179
  name === "list" ||
162
180
  name === "skill" ||
163
181
  name === "spawn" ||
164
- name === "read_spawn"
182
+ name === "read_spawn" ||
183
+ name === "cronjob"
165
184
  ) {
166
185
  return null;
167
186
  }
@@ -187,7 +206,7 @@ export function gateTool(
187
206
  const raw = typeof args.path === "string" ? args.path : "";
188
207
  if (name === "list" && !raw.trim()) return null;
189
208
  const target = resolveToolPath(raw, workspace);
190
- if (!pathInsideWorkspace(target, workspace)) {
209
+ if (!allowedWorkspaceWritePath(target, workspace, input.dataDir)) {
191
210
  return {
192
211
  text: `sandbox=workspace_write refused ${name} outside workspace: ${target}`,
193
212
  isError: true,
@@ -199,7 +218,8 @@ export function gateTool(
199
218
  if (
200
219
  name === "skill" ||
201
220
  name === "spawn" ||
202
- name === "read_spawn"
221
+ name === "read_spawn" ||
222
+ name === "cronjob"
203
223
  ) {
204
224
  return null;
205
225
  }
@@ -208,7 +228,7 @@ export function gateTool(
208
228
  const raw = typeof args.path === "string" ? args.path : "";
209
229
  if (!raw.trim()) return null;
210
230
  const target = resolveToolPath(raw, workspace);
211
- if (!pathInsideWorkspace(target, workspace)) {
231
+ if (!allowedWorkspaceWritePath(target, workspace, input.dataDir)) {
212
232
  return {
213
233
  text: `sandbox=workspace_write refused write outside workspace: ${target}`,
214
234
  isError: true,
@@ -220,7 +240,7 @@ export function gateTool(
220
240
  if (name === "run") {
221
241
  const workdir = typeof args.workdir === "string" ? args.workdir.trim() : "";
222
242
  const cwd = workdir ? resolveToolPath(workdir, workspace) : workspace;
223
- if (!pathInsideWorkspace(cwd, workspace)) {
243
+ if (!allowedWorkspaceWritePath(cwd, workspace, input.dataDir)) {
224
244
  return {
225
245
  text: `sandbox=workspace_write refused run cwd outside workspace: ${cwd}`,
226
246
  isError: true,