claude-threads 1.25.1 → 1.26.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.
@@ -51087,7 +51087,8 @@ class PromptExecutor extends BaseExecutor {
51087
51087
  return {
51088
51088
  pendingContextPrompt: null,
51089
51089
  pendingExistingWorktreePrompt: null,
51090
- pendingUpdatePrompt: null
51090
+ pendingUpdatePrompt: null,
51091
+ pendingRoutinePrompt: null
51091
51092
  };
51092
51093
  }
51093
51094
  getInitialState() {
@@ -51097,14 +51098,16 @@ class PromptExecutor extends BaseExecutor {
51097
51098
  return {
51098
51099
  pendingContextPrompt: this.state.pendingContextPrompt ? { ...this.state.pendingContextPrompt } : null,
51099
51100
  pendingExistingWorktreePrompt: this.state.pendingExistingWorktreePrompt ? { ...this.state.pendingExistingWorktreePrompt } : null,
51100
- pendingUpdatePrompt: this.state.pendingUpdatePrompt ? { ...this.state.pendingUpdatePrompt } : null
51101
+ pendingUpdatePrompt: this.state.pendingUpdatePrompt ? { ...this.state.pendingUpdatePrompt } : null,
51102
+ pendingRoutinePrompt: this.state.pendingRoutinePrompt ? { ...this.state.pendingRoutinePrompt } : null
51101
51103
  };
51102
51104
  }
51103
51105
  hydrateState(persisted) {
51104
51106
  this.state = {
51105
51107
  pendingContextPrompt: persisted.pendingContextPrompt ?? null,
51106
51108
  pendingExistingWorktreePrompt: persisted.pendingExistingWorktreePrompt ?? null,
51107
- pendingUpdatePrompt: persisted.pendingUpdatePrompt ?? null
51109
+ pendingUpdatePrompt: persisted.pendingUpdatePrompt ?? null,
51110
+ pendingRoutinePrompt: null
51108
51111
  };
51109
51112
  }
51110
51113
  setPendingContextPrompt(prompt) {
@@ -51234,6 +51237,30 @@ class PromptExecutor extends BaseExecutor {
51234
51237
  }
51235
51238
  return true;
51236
51239
  }
51240
+ setPendingRoutinePrompt(prompt) {
51241
+ this.state.pendingRoutinePrompt = prompt;
51242
+ }
51243
+ hasPendingRoutinePrompt() {
51244
+ return this.state.pendingRoutinePrompt !== null;
51245
+ }
51246
+ async handleRoutinePromptResponse(postId, approved, username, ctx) {
51247
+ if (!this.state.pendingRoutinePrompt)
51248
+ return false;
51249
+ if (this.state.pendingRoutinePrompt.postId !== postId)
51250
+ return false;
51251
+ const { parsed, requestedBy } = this.state.pendingRoutinePrompt;
51252
+ const statusMessage = approved ? `✅ ${ctx.formatter.formatBold(`Routine "${parsed.name}" confirmed`)} by ${ctx.formatter.formatUserMention(username)} — saving...` : `❌ ${ctx.formatter.formatBold(`Routine "${parsed.name}" discarded`)} by ${ctx.formatter.formatUserMention(username)}`;
51253
+ try {
51254
+ await ctx.platform.updatePost(postId, statusMessage);
51255
+ } catch (err) {
51256
+ ctx.logger.debug(`Failed to update routine prompt post: ${err}`);
51257
+ }
51258
+ this.state.pendingRoutinePrompt = null;
51259
+ if (this.events) {
51260
+ this.events.emit("routine-prompt:complete", { approved, parsed, requestedBy, postId });
51261
+ }
51262
+ return true;
51263
+ }
51237
51264
  async handleReaction(postId, emoji4, user, action, ctx) {
51238
51265
  ctx.logger.debug(`PromptExecutor.handleReaction: postId=${postId.substring(0, 8)}, emoji=${emoji4}, user=${user}, action=${action}`);
51239
51266
  if (action !== "added") {
@@ -51294,6 +51321,18 @@ class PromptExecutor extends BaseExecutor {
51294
51321
  ctx.logger.debug(`PromptExecutor: emoji ${emoji4} not valid for update prompt, ignoring`);
51295
51322
  return false;
51296
51323
  }
51324
+ if (this.state.pendingRoutinePrompt?.postId === postId) {
51325
+ if (isApprovalEmoji(emoji4)) {
51326
+ ctx.logger.debug(`Routine prompt reaction from @${user}: approve`);
51327
+ return this.handleRoutinePromptResponse(postId, true, user, ctx);
51328
+ }
51329
+ if (isDenialEmoji(emoji4)) {
51330
+ ctx.logger.debug(`Routine prompt reaction from @${user}: discard`);
51331
+ return this.handleRoutinePromptResponse(postId, false, user, ctx);
51332
+ }
51333
+ ctx.logger.debug(`PromptExecutor: emoji ${emoji4} not valid for routine prompt, ignoring`);
51334
+ return false;
51335
+ }
51297
51336
  ctx.logger.debug(`PromptExecutor: no pending prompt state matches postId=${postId.substring(0, 8)}`);
51298
51337
  return false;
51299
51338
  }
@@ -51910,6 +51949,9 @@ class MessageManager {
51910
51949
  clearPendingUpdatePrompt() {
51911
51950
  this.promptExecutor.clearPendingUpdatePrompt();
51912
51951
  }
51952
+ setPendingRoutinePrompt(prompt) {
51953
+ this.promptExecutor.setPendingRoutinePrompt(prompt);
51954
+ }
51913
51955
  setPendingBugReport(report) {
51914
51956
  this.bugReportExecutor.setPendingBugReport(report);
51915
51957
  }
@@ -52184,6 +52226,9 @@ import { resolve as resolve2, dirname as dirname2 } from "path";
52184
52226
  import { homedir } from "os";
52185
52227
 
52186
52228
  // node_modules/js-yaml/dist/js-yaml.mjs
52229
+ function getDefaultExportFromCjs(x) {
52230
+ return x && x.__esModule && Object.prototype.hasOwnProperty.call(x, "default") ? x["default"] : x;
52231
+ }
52187
52232
  var jsYaml = {};
52188
52233
  var loader = {};
52189
52234
  var common = {};
@@ -55287,6 +55332,7 @@ function requireJsYaml() {
55287
55332
  return jsYaml;
55288
55333
  }
55289
55334
  var jsYamlExports = requireJsYaml();
55335
+ var yaml = /* @__PURE__ */ getDefaultExportFromCjs(jsYamlExports);
55290
55336
 
55291
55337
  // src/config/index.ts
55292
55338
  var CONFIG_PATH = resolve2(homedir(), ".config", "claude-threads", "config.yaml");
@@ -56784,6 +56830,28 @@ var COMMAND_REGISTRY = [
56784
56830
  { name: "forget", description: "Remove one entry (by number or matching text), or all", args: "<n|text> | all" }
56785
56831
  ]
56786
56832
  },
56833
+ {
56834
+ command: "routine",
56835
+ description: "Create a scheduled routine from a natural-language request (confirmed with \uD83D\uDC4D before saving)",
56836
+ args: "<schedule, task>",
56837
+ category: "settings",
56838
+ audience: "user",
56839
+ claudeNotes: "User decisions, not yours"
56840
+ },
56841
+ {
56842
+ command: "routines",
56843
+ description: "List scheduled routines; pause/resume/delete/run manage them",
56844
+ args: "[pause|resume|delete|run <n>]",
56845
+ category: "settings",
56846
+ audience: "user",
56847
+ claudeNotes: "User decisions, not yours",
56848
+ subcommands: [
56849
+ { name: "pause", description: "Pause a routine", args: "<n>" },
56850
+ { name: "resume", description: "Resume a paused routine", args: "<n>" },
56851
+ { name: "delete", description: "Delete a routine", args: "<n>" },
56852
+ { name: "run", description: "Run a routine now, outside its schedule", args: "<n>" }
56853
+ ]
56854
+ },
56787
56855
  {
56788
56856
  command: "update",
56789
56857
  description: "Show auto-update status",
@@ -57058,6 +57126,30 @@ var handleMemory = async (ctx, args) => {
57058
57126
  await ctx.client.createPost(`⚠️ Usage: ${ctx.formatter.formatCode("!memory")} or ${ctx.formatter.formatCode("!memory forget <n|text>")} or ${ctx.formatter.formatCode("!memory forget all")}`, ctx.threadId);
57059
57127
  return { handled: true };
57060
57128
  };
57129
+ var handleRoutine = async (ctx, args) => {
57130
+ if (ctx.commandContext === "first-message") {
57131
+ return { handled: false };
57132
+ }
57133
+ if (!ctx.isAllowed) {
57134
+ return { handled: true };
57135
+ }
57136
+ if (!args?.trim()) {
57137
+ await ctx.client.createPost(`⚠️ Usage: ${ctx.formatter.formatCode("!routine every weekday at 9:00, <task>")}`, ctx.threadId);
57138
+ return { handled: true };
57139
+ }
57140
+ await ctx.sessionManager.createRoutine(ctx.threadId, args, ctx.username);
57141
+ return { handled: true };
57142
+ };
57143
+ var handleRoutines = async (ctx, args) => {
57144
+ if (ctx.commandContext === "first-message") {
57145
+ return { handled: false };
57146
+ }
57147
+ if (!ctx.isAllowed) {
57148
+ return { handled: true };
57149
+ }
57150
+ await ctx.sessionManager.manageRoutines(ctx.threadId, args, ctx.username);
57151
+ return { handled: true };
57152
+ };
57061
57153
  var handleCd = async (ctx, args) => {
57062
57154
  if (!args) {
57063
57155
  return { handled: false };
@@ -57249,6 +57341,8 @@ handlers.set("kick", handleKick);
57249
57341
  handlers.set("github-email", handleGitHubEmail);
57250
57342
  handlers.set("remember", handleRemember);
57251
57343
  handlers.set("memory", handleMemory);
57344
+ handlers.set("routine", handleRoutine);
57345
+ handlers.set("routines", handleRoutines);
57252
57346
  handlers.set("cd", handleCd);
57253
57347
  handlers.set("permissions", handlePermissions);
57254
57348
  handlers.set("mentions", handleMentions);
@@ -57652,23 +57746,200 @@ var log20 = createLogger("gh-emails");
57652
57746
  var DEFAULT_CONFIG_DIR = join7(homedir5(), ".config", "claude-threads");
57653
57747
  var DEFAULT_FILE = join7(DEFAULT_CONFIG_DIR, "github-emails.yaml");
57654
57748
 
57749
+ // src/persistence/routines-store.ts
57750
+ import { existsSync as existsSync6, mkdirSync as mkdirSync2, readFileSync as readFileSync5 } from "fs";
57751
+ import { homedir as homedir6 } from "os";
57752
+ import { join as join8 } from "path";
57753
+ import { randomUUID as randomUUID2 } from "crypto";
57754
+
57755
+ // src/persistence/atomic-file.ts
57756
+ import { chmodSync as chmodSync2, renameSync as renameSync2, writeFileSync as writeFileSync3 } from "fs";
57757
+
57758
+ class SerialQueue {
57759
+ tail = Promise.resolve();
57760
+ run(fn) {
57761
+ const next = this.tail.then(fn, fn);
57762
+ this.tail = next.catch(() => {
57763
+ return;
57764
+ });
57765
+ return next;
57766
+ }
57767
+ }
57768
+ function writeFileAtomic(file2, content) {
57769
+ const tempFile = `${file2}.tmp`;
57770
+ writeFileSync3(tempFile, content, { encoding: "utf-8", mode: 384 });
57771
+ renameSync2(tempFile, file2);
57772
+ chmodSync2(file2, 384);
57773
+ }
57774
+
57775
+ // src/persistence/routines-store.ts
57776
+ var log21 = createLogger("routines");
57777
+ var DEFAULT_CONFIG_DIR2 = join8(homedir6(), ".config", "claude-threads");
57778
+ var DEFAULT_FILE2 = join8(DEFAULT_CONFIG_DIR2, "routines.yaml");
57779
+ var STORE_VERSION = 1;
57780
+ var DEFAULT_MAX_ROUTINES = 10;
57781
+ var SCHEDULE_PRESETS = ["hourly", "daily", "weekdays", "weekly"];
57782
+ var TIME_RE = /^([01]\d|2[0-3]):[0-5]\d$/;
57783
+ function isValidTimezone(tz) {
57784
+ if (typeof tz !== "string" || !tz)
57785
+ return false;
57786
+ try {
57787
+ new Intl.DateTimeFormat("en-US", { timeZone: tz });
57788
+ return true;
57789
+ } catch {
57790
+ return false;
57791
+ }
57792
+ }
57793
+ function validateSchedule(schedule) {
57794
+ if (!SCHEDULE_PRESETS.includes(schedule.preset)) {
57795
+ return `unknown preset "${String(schedule.preset)}" (expected ${SCHEDULE_PRESETS.join("/")})`;
57796
+ }
57797
+ if (!isValidTimezone(schedule.timezone)) {
57798
+ return `invalid timezone "${String(schedule.timezone)}"`;
57799
+ }
57800
+ if (schedule.preset === "hourly") {
57801
+ return null;
57802
+ }
57803
+ if (!schedule.time || !TIME_RE.test(schedule.time)) {
57804
+ return `invalid time "${String(schedule.time)}" (expected HH:MM, 24h)`;
57805
+ }
57806
+ if (schedule.preset === "weekly") {
57807
+ const weekday = schedule.weekday;
57808
+ if (typeof weekday !== "number" || !Number.isInteger(weekday) || weekday < 1 || weekday > 7) {
57809
+ return `invalid weekday "${String(weekday)}" (expected 1=Mon … 7=Sun)`;
57810
+ }
57811
+ }
57812
+ return null;
57813
+ }
57814
+ class RoutinesStore {
57815
+ file;
57816
+ configDir;
57817
+ queue = new SerialQueue;
57818
+ constructor(filePath) {
57819
+ const effective = filePath ?? process.env.CLAUDE_THREADS_ROUTINES_PATH;
57820
+ if (effective) {
57821
+ this.file = effective;
57822
+ this.configDir = join8(effective, "..");
57823
+ } else {
57824
+ this.file = DEFAULT_FILE2;
57825
+ this.configDir = DEFAULT_CONFIG_DIR2;
57826
+ }
57827
+ if (!existsSync6(this.configDir)) {
57828
+ mkdirSync2(this.configDir, { recursive: true, mode: 448 });
57829
+ }
57830
+ }
57831
+ list(platformId) {
57832
+ return this.loadRaw().routines[platformId] ?? [];
57833
+ }
57834
+ get(platformId, id) {
57835
+ return this.list(platformId).find((r) => r.id === id);
57836
+ }
57837
+ add(platformId, routine, maxRoutines = DEFAULT_MAX_ROUTINES) {
57838
+ return this.runExclusive(() => {
57839
+ const scheduleError = validateSchedule(routine.schedule);
57840
+ if (scheduleError)
57841
+ return { ok: false, error: scheduleError };
57842
+ const name = routine.name.trim().slice(0, 80);
57843
+ const prompt = routine.prompt.trim().slice(0, 2000);
57844
+ if (!name || !prompt)
57845
+ return { ok: false, error: "name and prompt are required" };
57846
+ const data = this.loadRaw();
57847
+ const existing = data.routines[platformId] ?? [];
57848
+ if (existing.length >= maxRoutines) {
57849
+ return { ok: false, error: `routine limit reached (${maxRoutines}); delete one first` };
57850
+ }
57851
+ const full = {
57852
+ ...routine,
57853
+ name,
57854
+ prompt,
57855
+ id: randomUUID2().slice(0, 8),
57856
+ createdAt: new Date().toISOString(),
57857
+ enabled: true,
57858
+ consecutiveFailures: 0
57859
+ };
57860
+ data.routines[platformId] = [...existing, full];
57861
+ this.writeAtomic(data);
57862
+ log21.info(`Routine "${full.name}" created on ${platformId} by @${full.createdBy}`);
57863
+ return { ok: true, routine: full };
57864
+ });
57865
+ }
57866
+ update(platformId, id, patch) {
57867
+ return this.runExclusive(() => {
57868
+ const data = this.loadRaw();
57869
+ const routines = data.routines[platformId] ?? [];
57870
+ const idx = routines.findIndex((r) => r.id === id);
57871
+ if (idx < 0)
57872
+ return;
57873
+ routines[idx] = { ...routines[idx], ...patch };
57874
+ this.writeAtomic(data);
57875
+ return routines[idx];
57876
+ });
57877
+ }
57878
+ remove(platformId, id) {
57879
+ return this.runExclusive(() => {
57880
+ const data = this.loadRaw();
57881
+ const routines = data.routines[platformId] ?? [];
57882
+ const idx = routines.findIndex((r) => r.id === id);
57883
+ if (idx < 0)
57884
+ return;
57885
+ const [removed] = routines.splice(idx, 1);
57886
+ if (routines.length === 0)
57887
+ delete data.routines[platformId];
57888
+ this.writeAtomic(data);
57889
+ log21.info(`Routine "${removed.name}" removed from ${platformId}`);
57890
+ return removed;
57891
+ });
57892
+ }
57893
+ runExclusive(fn) {
57894
+ return this.queue.run(fn);
57895
+ }
57896
+ loadRaw() {
57897
+ if (!existsSync6(this.file)) {
57898
+ return { version: STORE_VERSION, routines: {} };
57899
+ }
57900
+ try {
57901
+ const parsed = yaml.load(readFileSync5(this.file, "utf-8"));
57902
+ if (!parsed || typeof parsed !== "object") {
57903
+ return { version: STORE_VERSION, routines: {} };
57904
+ }
57905
+ const routines = parsed.routines && typeof parsed.routines === "object" ? parsed.routines : {};
57906
+ for (const list of Object.values(routines)) {
57907
+ for (const r of list) {
57908
+ r.enabled = r.enabled ?? true;
57909
+ r.consecutiveFailures = r.consecutiveFailures ?? 0;
57910
+ }
57911
+ }
57912
+ return { version: parsed.version ?? STORE_VERSION, routines };
57913
+ } catch (err) {
57914
+ log21.warn(`Failed to read ${this.file}: ${err.message} — starting empty`);
57915
+ return { version: STORE_VERSION, routines: {} };
57916
+ }
57917
+ }
57918
+ writeAtomic(data) {
57919
+ writeFileAtomic(this.file, yaml.dump(data, { sortKeys: true, lineWidth: -1 }));
57920
+ }
57921
+ }
57922
+
57923
+ // src/routines/parser.ts
57924
+ var log22 = createLogger("routines");
57925
+
57655
57926
  // src/operations/commands/handler.ts
57656
- var log21 = createLogger("commands");
57657
- var sessionLog4 = createSessionLog(log21);
57927
+ var log23 = createLogger("commands");
57928
+ var sessionLog4 = createSessionLog(log23);
57658
57929
  // src/operations/suggestions/branch.ts
57659
57930
  import { exec as exec2 } from "child_process";
57660
57931
  import { promisify as promisify2 } from "util";
57661
57932
  var execAsync2 = promisify2(exec2);
57662
- var log22 = createLogger("branch");
57933
+ var log24 = createLogger("branch");
57663
57934
 
57664
57935
  // src/operations/worktree/handler.ts
57665
- var log23 = createLogger("worktree");
57666
- var sessionLog5 = createSessionLog(log23);
57936
+ var log25 = createLogger("worktree");
57937
+ var sessionLog5 = createSessionLog(log25);
57667
57938
  // src/operations/events/handler.ts
57668
- var log24 = createLogger("events");
57669
- var sessionLog6 = createSessionLog(log24);
57939
+ var log26 = createLogger("events");
57940
+ var sessionLog6 = createSessionLog(log26);
57670
57941
  // src/operations/monitor/handler.ts
57671
- var log25 = createLogger("monitor");
57942
+ var log27 = createLogger("monitor");
57672
57943
  var DEFAULT_INTERVAL_MS = 60 * 1000;
57673
57944
  // src/utils/websocket.ts
57674
57945
  var WS;
@@ -57750,7 +58021,7 @@ ${code}
57750
58021
 
57751
58022
  // src/platform/mattermost/upload.ts
57752
58023
  import { readFile } from "fs/promises";
57753
- var log26 = createLogger("mm-upload");
58024
+ var log28 = createLogger("mm-upload");
57754
58025
  async function uploadFileMattermost(args) {
57755
58026
  const { url: url2, token, channelId, threadId, filePath, filename, caption } = args;
57756
58027
  const buffer = await readFile(filePath);
@@ -57758,7 +58029,7 @@ async function uploadFileMattermost(args) {
57758
58029
  const arrayBuffer = buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength);
57759
58030
  const formData = new FormData;
57760
58031
  formData.append("files", new Blob([arrayBuffer]), filename);
57761
- log26.debug(`POST /files (${buffer.length} bytes, ${filename})`);
58032
+ log28.debug(`POST /files (${buffer.length} bytes, ${filename})`);
57762
58033
  const uploadResponse = await fetch(uploadUrl, {
57763
58034
  method: "POST",
57764
58035
  headers: {
@@ -57782,7 +58053,7 @@ async function uploadFileMattermost(args) {
57782
58053
  root_id: threadId,
57783
58054
  file_ids: [fileInfo.id]
57784
58055
  };
57785
- log26.debug(`POST /posts (file_ids=[${fileInfo.id}])`);
58056
+ log28.debug(`POST /posts (file_ids=[${fileInfo.id}])`);
57786
58057
  const postResponse = await fetch(postUrl, {
57787
58058
  method: "POST",
57788
58059
  headers: {
@@ -58276,7 +58547,7 @@ ${code}
58276
58547
 
58277
58548
  // src/platform/slack/upload.ts
58278
58549
  import { readFile as readFile2 } from "fs/promises";
58279
- var log27 = createLogger("slack-upload");
58550
+ var log29 = createLogger("slack-upload");
58280
58551
  var DEFAULT_API_URL = "https://slack.com/api";
58281
58552
  async function uploadFileSlack(args) {
58282
58553
  const { botToken, channelId, threadTs, filePath, filename, caption } = args;
@@ -58284,7 +58555,7 @@ async function uploadFileSlack(args) {
58284
58555
  const buffer = await readFile2(filePath);
58285
58556
  const params = new URLSearchParams({ filename, length: String(buffer.length) });
58286
58557
  const step1Url = `${apiUrl}/files.getUploadURLExternal?${params.toString()}`;
58287
- log27.debug(`GET files.getUploadURLExternal (${buffer.length} bytes, ${filename})`);
58558
+ log29.debug(`GET files.getUploadURLExternal (${buffer.length} bytes, ${filename})`);
58288
58559
  const step1Response = await fetch(step1Url, {
58289
58560
  method: "GET",
58290
58561
  headers: {
@@ -58302,7 +58573,7 @@ async function uploadFileSlack(args) {
58302
58573
  const uploadUrl = step1Data.upload_url;
58303
58574
  const fileId = step1Data.file_id;
58304
58575
  const arrayBuffer = buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength);
58305
- log27.debug(`POST <upload_url>`);
58576
+ log29.debug(`POST <upload_url>`);
58306
58577
  const step2Response = await fetch(uploadUrl, {
58307
58578
  method: "POST",
58308
58579
  headers: {
@@ -58322,7 +58593,7 @@ async function uploadFileSlack(args) {
58322
58593
  if (caption !== undefined) {
58323
58594
  step3Body.initial_comment = caption;
58324
58595
  }
58325
- log27.debug(`POST files.completeUploadExternal (file_id=${fileId}, thread_ts=${threadTs})`);
58596
+ log29.debug(`POST files.completeUploadExternal (file_id=${fileId}, thread_ts=${threadTs})`);
58326
58597
  const step3Response = await fetch(`${apiUrl}/files.completeUploadExternal`, {
58327
58598
  method: "POST",
58328
58599
  headers: {
@@ -58340,7 +58611,7 @@ async function uploadFileSlack(args) {
58340
58611
  throw new Error(`Slack completeUploadExternal error: ${step3Data.error || "unknown"}`);
58341
58612
  }
58342
58613
  if (!step3Data.ts) {
58343
- log27.warn(`Slack completeUploadExternal returned no ts; using fileId ${fileId} as postId. ` + `Do not use this id for updatePost/addReaction.`);
58614
+ log29.warn(`Slack completeUploadExternal returned no ts; using fileId ${fileId} as postId. ` + `Do not use this id for updatePost/addReaction.`);
58344
58615
  }
58345
58616
  return { fileId, postId: step3Data.ts ?? fileId };
58346
58617
  }
@@ -245,6 +245,65 @@ platforms:
245
245
  `CLAUDE_CODE_REMOTE` is set (unless `CLAUDE_CODE_REMOTE_MEMORY_DIR` is
246
246
  configured) — the repo layer will be inert in such environments.
247
247
 
248
+ ### Routines (`routines`, default: enabled)
249
+
250
+ Scheduled recurring work, Claude Tag-style: a routine fires on its schedule
251
+ as a **bot-initiated session thread** in the channel — a completely normal
252
+ session (platform permission mode, account-pool balancing, channel memory,
253
+ distillation) whose task is the routine's prompt.
254
+
255
+ ```yaml
256
+ platforms:
257
+ - id: mattermost-main
258
+ type: mattermost
259
+ # ... credentials ...
260
+ routines: true # default; `false` disables the scheduler + commands
261
+
262
+ limits:
263
+ maxRoutines: 10 # per-platform cap (default 10)
264
+ ```
265
+
266
+ **Creating** (natural language, confirmed before saving):
267
+
268
+ ```
269
+ !routine every weekday at 9am, summarize the open review threads
270
+ ```
271
+
272
+ A haiku pass parses the request into a structured schedule (presets: hourly /
273
+ daily / weekdays / weekly — hourly is the floor), the bot posts the parsed
274
+ result, and **nothing is saved until someone reacts 👍**. Timezones: name one
275
+ explicitly ("9am Pacific"); otherwise the bot host's timezone is used and the
276
+ confirmation says so.
277
+
278
+ **Managing:**
279
+
280
+ - `!routines` — numbered list with schedule, creator, and last-run status
281
+ - `!routines pause|resume|delete <n>` — owner-gated
282
+ - `!routines run <n>` — fire now, outside the schedule (platform-allowed
283
+ users only — not temporarily `!invite`d guests; does not consume the
284
+ period's scheduled fire)
285
+
286
+ **Semantics & guardrails:**
287
+
288
+ - Runs fire **as their creator** and are re-authorized on every fire — a
289
+ creator who loses platform authorization disables the routine (with a
290
+ channel notice), mirroring Claude Tag.
291
+ - At most one fire per period (hour/day/week), evaluated on the wall clock in
292
+ the routine's timezone (DST-safe). A window missed entirely (bot offline)
293
+ is skipped, not back-filled.
294
+ - 3 consecutive failed runs auto-disable the routine with a channel notice;
295
+ `!routines resume <n>` re-arms it.
296
+ - Runs count against `MAX_SESSIONS`; at the limit a fire is retried within
297
+ its window and otherwise skipped.
298
+ - **Each run starts a full Claude session on your subscription** — the
299
+ confirmation and `!routines` listing both say so.
300
+ - Routines are scoped per platform instance (same privacy boundary as
301
+ memory) and stored at `~/.config/claude-threads/routines.yaml` (0600;
302
+ override with `CLAUDE_THREADS_ROUTINES_PATH`).
303
+ - The natural-language parse uses one haiku `claude -p` call — the same
304
+ bot-process-credentials caveat as memory distillation applies in OAuth
305
+ account pools.
306
+
248
307
  ## Claude Accounts (optional, multi-account mode)
249
308
 
250
309
  By default every session spawns `claude` with the bot's own `process.env`, so they all share one subscription's token budget. Add a `claudeAccounts` block to spread load across multiple accounts. Omit the block entirely to stay in single-account mode (unchanged behavior).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-threads",
3
- "version": "1.25.1",
3
+ "version": "1.26.0",
4
4
  "description": "Run Claude Code from Slack or Mattermost. Sessions stream live into threads where your whole team can watch and steer.",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",