discoclaw 0.1.1 → 0.1.4

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/README.md CHANGED
@@ -136,6 +136,10 @@ Full step-by-step guide: [docs/discord-bot-setup.md](docs/discord-bot-setup.md)
136
136
  ```bash
137
137
  discoclaw install-daemon
138
138
  ```
139
+ Optional: pass `--service-name <name>` to use a custom service name (useful on macOS when running multiple instances, or to match your own naming convention):
140
+ ```bash
141
+ discoclaw install-daemon --service-name personal
142
+ ```
139
143
 
140
144
  #### From source (contributors)
141
145
 
@@ -157,6 +161,8 @@ pnpm dev
157
161
  ```bash
158
162
  npm update -g discoclaw
159
163
  discoclaw install-daemon # re-register the service after updating
164
+ # If you used a custom service name, pass it again:
165
+ # discoclaw install-daemon --service-name personal
160
166
  ```
161
167
 
162
168
  **From source:**
@@ -21,6 +21,19 @@ export function resolvePackageRoot() {
21
21
  const selfDir = path.dirname(fileURLToPath(import.meta.url));
22
22
  return path.resolve(selfDir, '..', '..');
23
23
  }
24
+ /**
25
+ * Parses --service-name <name> from argv.
26
+ * Defaults to 'discoclaw' if not provided.
27
+ */
28
+ export function parseServiceName(argv = process.argv) {
29
+ const idx = argv.indexOf('--service-name');
30
+ if (idx !== -1 && idx + 1 < argv.length) {
31
+ const name = argv[idx + 1];
32
+ if (name && !name.startsWith('-'))
33
+ return name;
34
+ }
35
+ return 'discoclaw';
36
+ }
24
37
  // ── Rendering helpers ──────────────────────────────────────────────────────
25
38
  /**
26
39
  * Parses a .env file into key-value pairs.
@@ -71,8 +84,9 @@ export function renderSystemdUnit(packageRoot, cwd) {
71
84
  * Since launchd has no EnvironmentFile equivalent, env vars are baked in
72
85
  * from the parsed .env at install time.
73
86
  */
74
- export function renderLaunchdPlist(packageRoot, cwd, envVars) {
87
+ export function renderLaunchdPlist(packageRoot, cwd, envVars, serviceName = 'discoclaw') {
75
88
  const entryPoint = path.join(packageRoot, 'dist', 'index.js');
89
+ const label = `com.discoclaw.${serviceName}`;
76
90
  const envEntries = Object.entries(envVars)
77
91
  .map(([k, v]) => `\t\t<key>${k}</key>\n\t\t<string>${v}</string>`)
78
92
  .join('\n');
@@ -82,7 +96,7 @@ export function renderLaunchdPlist(packageRoot, cwd, envVars) {
82
96
  '<plist version="1.0">',
83
97
  '<dict>',
84
98
  '\t<key>Label</key>',
85
- '\t<string>com.discoclaw.agent</string>',
99
+ `\t<string>${label}</string>`,
86
100
  '\t<key>ProgramArguments</key>',
87
101
  '\t<array>',
88
102
  '\t\t<string>/usr/bin/node</string>',
@@ -106,9 +120,9 @@ export function renderLaunchdPlist(packageRoot, cwd, envVars) {
106
120
  return lines.join('\n');
107
121
  }
108
122
  // ── Platform installers ────────────────────────────────────────────────────
109
- async function installSystemd(packageRoot, cwd, ask) {
123
+ async function installSystemd(packageRoot, cwd, serviceName, ask) {
110
124
  const serviceDir = path.join(os.homedir(), '.config', 'systemd', 'user');
111
- const servicePath = path.join(serviceDir, 'discoclaw.service');
125
+ const servicePath = path.join(serviceDir, `${serviceName}.service`);
112
126
  if (fs.existsSync(servicePath)) {
113
127
  const answer = await ask(`Service file already exists at ${servicePath}. Overwrite? [y/N] `);
114
128
  if (answer.trim().toLowerCase() !== 'y') {
@@ -128,9 +142,9 @@ async function installSystemd(packageRoot, cwd, ask) {
128
142
  console.error(`systemctl daemon-reload failed: ${err.message}\n`);
129
143
  process.exit(1);
130
144
  }
131
- console.log('Enabling and starting discoclaw service...');
145
+ console.log(`Enabling and starting ${serviceName} service...`);
132
146
  try {
133
- execFileSync('systemctl', ['--user', 'enable', '--now', 'discoclaw']);
147
+ execFileSync('systemctl', ['--user', 'enable', '--now', serviceName]);
134
148
  }
135
149
  catch (err) {
136
150
  console.error(`systemctl enable/start failed: ${err.message}\n`);
@@ -138,14 +152,15 @@ async function installSystemd(packageRoot, cwd, ask) {
138
152
  }
139
153
  console.log('DiscoClaw daemon installed and started.\n');
140
154
  console.log('Useful commands:');
141
- console.log(' journalctl --user -u discoclaw.service -f # tail logs');
142
- console.log(' systemctl --user status discoclaw # check status');
143
- console.log(' systemctl --user stop discoclaw # stop the service');
155
+ console.log(` journalctl --user -u ${serviceName}.service -f # tail logs`);
156
+ console.log(` systemctl --user status ${serviceName} # check status`);
157
+ console.log(` systemctl --user stop ${serviceName} # stop the service`);
144
158
  console.log('');
145
159
  }
146
- async function installLaunchd(packageRoot, cwd, envPath, ask) {
160
+ async function installLaunchd(packageRoot, cwd, envPath, serviceName, ask) {
147
161
  const agentsDir = path.join(os.homedir(), 'Library', 'LaunchAgents');
148
- const plistPath = path.join(agentsDir, 'com.discoclaw.agent.plist');
162
+ const label = `com.discoclaw.${serviceName}`;
163
+ const plistPath = path.join(agentsDir, `${label}.plist`);
149
164
  if (fs.existsSync(plistPath)) {
150
165
  const answer = await ask(`Plist already exists at ${plistPath}. Overwrite? [y/N] `);
151
166
  if (answer.trim().toLowerCase() !== 'y') {
@@ -155,14 +170,14 @@ async function installLaunchd(packageRoot, cwd, envPath, ask) {
155
170
  }
156
171
  const envContent = fs.readFileSync(envPath, 'utf8');
157
172
  const envVars = parseEnvFile(envContent);
158
- const plist = renderLaunchdPlist(packageRoot, cwd, envVars);
173
+ const plist = renderLaunchdPlist(packageRoot, cwd, envVars, serviceName);
159
174
  fs.mkdirSync(agentsDir, { recursive: true });
160
175
  fs.writeFileSync(plistPath, plist, 'utf8');
161
176
  console.log(`Wrote ${plistPath}\n`);
162
177
  console.log('Note: On macOS, .env changes require re-running `discoclaw install-daemon`.\n' +
163
178
  ' Environment variables are baked into the plist at install time.\n');
164
179
  const uid = process.getuid();
165
- const target = `gui/${uid}/com.discoclaw.agent`;
180
+ const target = `gui/${uid}/${label}`;
166
181
  // Idempotent: bootout first (ignore failure if agent is not currently loaded)
167
182
  try {
168
183
  execFileSync('launchctl', ['bootout', target]);
@@ -181,7 +196,7 @@ async function installLaunchd(packageRoot, cwd, envPath, ask) {
181
196
  console.log('DiscoClaw daemon installed and started.\n');
182
197
  console.log('Useful commands:');
183
198
  console.log(" log stream --predicate 'process == \"node\"' # tail logs");
184
- console.log(' launchctl list com.discoclaw.agent # check status');
199
+ console.log(` launchctl list ${label} # check status`);
185
200
  console.log(` launchctl bootout ${target} # stop and unload`);
186
201
  console.log('');
187
202
  }
@@ -209,14 +224,15 @@ export async function runDaemonInstaller() {
209
224
  'Make sure the package is properly installed (try running `npm install -g discoclaw` again).\n');
210
225
  process.exit(1);
211
226
  }
227
+ const serviceName = parseServiceName();
212
228
  const rl = readline.createInterface({ input, output });
213
229
  const ask = (prompt) => rl.question(prompt);
214
230
  try {
215
231
  if (platform === 'linux') {
216
- await installSystemd(packageRoot, cwd, ask);
232
+ await installSystemd(packageRoot, cwd, serviceName, ask);
217
233
  }
218
234
  else {
219
- await installLaunchd(packageRoot, cwd, envPath, ask);
235
+ await installLaunchd(packageRoot, cwd, envPath, serviceName, ask);
220
236
  }
221
237
  }
222
238
  finally {
@@ -16,7 +16,7 @@ vi.mock('node:readline/promises', () => ({
16
16
  import fs from 'node:fs';
17
17
  import { execFileSync } from 'node:child_process';
18
18
  import { createInterface } from 'node:readline/promises';
19
- import { parseEnvFile, renderSystemdUnit, renderLaunchdPlist, runDaemonInstaller, } from './daemon-installer.js';
19
+ import { parseEnvFile, parseServiceName, renderSystemdUnit, renderLaunchdPlist, runDaemonInstaller, } from './daemon-installer.js';
20
20
  // ── Test helpers ───────────────────────────────────────────────────────────
21
21
  function makeReadline(answers = []) {
22
22
  return {
@@ -47,6 +47,21 @@ describe('parseEnvFile', () => {
47
47
  expect(parseEnvFile('# just comments\n')).toEqual({});
48
48
  });
49
49
  });
50
+ // ── parseServiceName ───────────────────────────────────────────────────────
51
+ describe('parseServiceName', () => {
52
+ it('returns "discoclaw" when --service-name is absent', () => {
53
+ expect(parseServiceName(['node', 'discoclaw', 'install-daemon'])).toBe('discoclaw');
54
+ });
55
+ it('returns the provided name when --service-name is present', () => {
56
+ expect(parseServiceName(['node', 'discoclaw', 'install-daemon', '--service-name', 'personal'])).toBe('personal');
57
+ });
58
+ it('ignores --service-name if the next token starts with a dash', () => {
59
+ expect(parseServiceName(['node', 'discoclaw', 'install-daemon', '--service-name', '--other-flag'])).toBe('discoclaw');
60
+ });
61
+ it('ignores --service-name if it is the last token with no value', () => {
62
+ expect(parseServiceName(['node', 'discoclaw', 'install-daemon', '--service-name'])).toBe('discoclaw');
63
+ });
64
+ });
50
65
  // ── renderSystemdUnit ──────────────────────────────────────────────────────
51
66
  describe('renderSystemdUnit', () => {
52
67
  it('produces correct ExecStart with /usr/bin/node and dist/index.js', () => {
@@ -73,10 +88,15 @@ describe('renderSystemdUnit', () => {
73
88
  // ── renderLaunchdPlist ─────────────────────────────────────────────────────
74
89
  describe('renderLaunchdPlist', () => {
75
90
  const envVars = { DISCORD_TOKEN: 'tok', FOO: 'bar' };
76
- it('produces valid XML plist with correct label', () => {
91
+ it('produces valid XML plist with default label', () => {
77
92
  const plist = renderLaunchdPlist(PACKAGE_ROOT, CWD, envVars);
78
93
  expect(plist).toContain('<?xml version="1.0"');
79
- expect(plist).toContain('<string>com.discoclaw.agent</string>');
94
+ expect(plist).toContain('<string>com.discoclaw.discoclaw</string>');
95
+ });
96
+ it('uses custom service name in label when provided', () => {
97
+ const plist = renderLaunchdPlist(PACKAGE_ROOT, CWD, envVars, 'personal');
98
+ expect(plist).toContain('<string>com.discoclaw.personal</string>');
99
+ expect(plist).not.toContain('com.discoclaw.discoclaw');
80
100
  });
81
101
  it('sets ProgramArguments to /usr/bin/node and dist/index.js', () => {
82
102
  const plist = renderLaunchdPlist(PACKAGE_ROOT, CWD, envVars);
package/dist/cli/index.js CHANGED
@@ -34,8 +34,10 @@ function printHelp(ver) {
34
34
  console.log(`discoclaw v${ver} — Personal AI orchestrator\n` +
35
35
  `\nUsage: discoclaw <command>\n` +
36
36
  `\nCommands:\n` +
37
- ` init Interactive setup wizard — creates .env and workspace/\n` +
38
- ` install-daemon Register discoclaw as a persistent background service\n` +
37
+ ` init Interactive setup wizard — creates .env and workspace/\n` +
38
+ ` install-daemon [--service-name <name>] Register discoclaw as a persistent background service\n` +
39
+ ` Use --service-name to run multiple instances side-by-side.\n` +
40
+ ` Defaults to "discoclaw".\n` +
39
41
  `\nOptions:\n` +
40
42
  ` -v, --version Print version\n` +
41
43
  ` -h, --help Print this help\n`);
@@ -1,7 +1,7 @@
1
1
  import { acquireCronLock, releaseCronLock } from './job-lock.js';
2
2
  import { resolveChannel } from '../discord/action-utils.js';
3
3
  import * as discordActions from '../discord/actions.js';
4
- import { sendChunks, appendUnavailableActionTypesNotice } from '../discord/output-common.js';
4
+ import { sendChunks, appendUnavailableActionTypesNotice, appendParseFailureNotice } from '../discord/output-common.js';
5
5
  import { buildPromptPreamble, loadWorkspacePaFiles, inlineContextFiles, resolveEffectiveTools } from '../discord/prompt-common.js';
6
6
  import { ensureStatusMessage } from './discord-sync.js';
7
7
  import { globalMetrics } from '../observability/metrics.js';
@@ -199,11 +199,13 @@ export async function executeCronJob(job, ctx) {
199
199
  }
200
200
  let processedText = output;
201
201
  let strippedUnrecognizedTypes = [];
202
+ let parseFailuresCount = 0;
202
203
  // Handle Discord actions if enabled.
203
204
  if (ctx.discordActionsEnabled) {
204
205
  const parsed = discordActions.parseDiscordActions(processedText, ctx.actionFlags);
205
206
  const { cleanText, actions } = parsed;
206
207
  strippedUnrecognizedTypes = parsed.strippedUnrecognizedTypes;
208
+ parseFailuresCount = parsed.parseFailures;
207
209
  if (actions.length > 0) {
208
210
  const actCtx = {
209
211
  guild,
@@ -232,7 +234,7 @@ export async function executeCronJob(job, ctx) {
232
234
  ? cleanText.trimEnd() + '\n\n' + displayLines.join('\n')
233
235
  : cleanText.trimEnd();
234
236
  // When all display lines were suppressed and there's no prose, skip posting.
235
- if (!processedText.trim() && anyActionSucceeded && strippedUnrecognizedTypes.length === 0) {
237
+ if (!processedText.trim() && anyActionSucceeded && strippedUnrecognizedTypes.length === 0 && parseFailuresCount === 0) {
236
238
  ctx.log?.info({ jobId: job.id }, 'cron:reply suppressed (actions-only, no display text)');
237
239
  }
238
240
  if (ctx.status) {
@@ -248,6 +250,7 @@ export async function executeCronJob(job, ctx) {
248
250
  }
249
251
  }
250
252
  processedText = appendUnavailableActionTypesNotice(processedText, strippedUnrecognizedTypes);
253
+ processedText = appendParseFailureNotice(processedText, parseFailuresCount);
251
254
  await sendChunks(channelForSend, processedText, collectedImages);
252
255
  ctx.log?.info({ jobId: job.id, name: job.name, channel: job.def.channel }, 'cron:exec done');
253
256
  // Record successful run.
@@ -306,16 +306,17 @@ function collectParsedAction(parsed, flags, validTypes, actions, strippedUnrecog
306
306
  strippedUnrecognizedTypes.push(type);
307
307
  }
308
308
  }
309
- function parseActionJson(jsonStr, flags, validTypes, actions, strippedUnrecognizedTypes) {
309
+ function parseActionJson(jsonStr, flags, validTypes, actions, strippedUnrecognizedTypes, parseFailuresRef) {
310
310
  try {
311
311
  const parsed = JSON.parse(jsonStr);
312
312
  collectParsedAction(parsed, flags, validTypes, actions, strippedUnrecognizedTypes);
313
313
  }
314
314
  catch {
315
+ parseFailuresRef.count++;
315
316
  // Malformed JSON — skip silently.
316
317
  }
317
318
  }
318
- function stripActionsWithScanner(text, flags, validTypes, actions, strippedUnrecognizedTypes, codeRanges) {
319
+ function stripActionsWithScanner(text, flags, validTypes, actions, strippedUnrecognizedTypes, codeRanges, parseFailuresRef) {
319
320
  let result = '';
320
321
  let cursor = 0;
321
322
  while (cursor < text.length) {
@@ -347,7 +348,7 @@ function stripActionsWithScanner(text, flags, validTypes, actions, strippedUnrec
347
348
  cursor += trailing[0].length;
348
349
  continue;
349
350
  }
350
- parseActionJson(jsonStr, flags, validTypes, actions, strippedUnrecognizedTypes);
351
+ parseActionJson(jsonStr, flags, validTypes, actions, strippedUnrecognizedTypes, parseFailuresRef);
351
352
  // Advance past the JSON object and consume any trailing XML closing tags.
352
353
  cursor = afterMarker + jsonStr.length;
353
354
  const remaining = text.slice(cursor);
@@ -364,16 +365,18 @@ function stripActionsWithScanner(text, flags, validTypes, actions, strippedUnrec
364
365
  function parseWithRegexFallback(text, flags, validTypes, codeRanges) {
365
366
  const actions = [];
366
367
  const strippedUnrecognizedTypes = [];
368
+ const parseFailuresRef = { count: 0 };
367
369
  const cleaned = text.replace(ACTION_RE, (match, json, offset) => {
368
370
  if (isIndexInRanges(offset, codeRanges))
369
371
  return match;
370
- parseActionJson(json.trim(), flags, validTypes, actions, strippedUnrecognizedTypes);
372
+ parseActionJson(json.trim(), flags, validTypes, actions, strippedUnrecognizedTypes, parseFailuresRef);
371
373
  return '';
372
374
  });
373
375
  return {
374
376
  cleanText: cleaned.replace(/\n{3,}/g, '\n\n').trim(),
375
377
  actions,
376
378
  strippedUnrecognizedTypes,
379
+ parseFailures: parseFailuresRef.count,
377
380
  };
378
381
  }
379
382
  export function parseDiscordActions(text, flags) {
@@ -381,11 +384,13 @@ export function parseDiscordActions(text, flags) {
381
384
  const actions = [];
382
385
  const strippedUnrecognizedTypes = [];
383
386
  const codeRanges = computeMarkdownCodeRanges(text);
384
- const cleaned = stripActionsWithScanner(text, flags, validTypes, actions, strippedUnrecognizedTypes, codeRanges);
387
+ const parseFailuresRef = { count: 0 };
388
+ const cleaned = stripActionsWithScanner(text, flags, validTypes, actions, strippedUnrecognizedTypes, codeRanges, parseFailuresRef);
385
389
  const scanned = {
386
390
  cleanText: cleaned.replace(/\n{3,}/g, '\n\n').trim(),
387
391
  actions,
388
392
  strippedUnrecognizedTypes,
393
+ parseFailures: parseFailuresRef.count,
389
394
  };
390
395
  // Compatibility fallback: if scanner leaves markers behind or extracts nothing,
391
396
  // run the legacy regex parser and prefer it when it captures more actions.
@@ -501,7 +506,7 @@ export async function executeDiscordActions(actions, ctx, log, subs) {
501
506
  }
502
507
  catch (err) {
503
508
  const msg = err instanceof Error ? err.message : String(err);
504
- results.push({ ok: false, error: msg });
509
+ results.push({ ok: false, error: `Failed (${action.type}): ${msg}` });
505
510
  log?.error({ err, action }, 'discord:action failed');
506
511
  }
507
512
  }
@@ -211,6 +211,28 @@ describe('parseDiscordActions', () => {
211
211
  expect(actions).toHaveLength(0);
212
212
  expect(strippedUnrecognizedTypes).toEqual(['forgeStatus']);
213
213
  });
214
+ it('parses two same-type taskCreate actions and extracts both', () => {
215
+ const input = '<discord-action>{"type":"taskCreate","title":"First task"}</discord-action>' +
216
+ '<discord-action>{"type":"taskCreate","title":"Second task"}</discord-action>';
217
+ const { actions, parseFailures } = parseDiscordActions(input, { ...ALL_FLAGS, tasks: true });
218
+ expect(actions).toHaveLength(2);
219
+ expect(actions[0]).toEqual({ type: 'taskCreate', title: 'First task' });
220
+ expect(actions[1]).toEqual({ type: 'taskCreate', title: 'Second task' });
221
+ expect(parseFailures).toBe(0);
222
+ });
223
+ it('returns parseFailures: 1 and only the valid action when second block has malformed JSON', () => {
224
+ const input = '<discord-action>{"type":"taskCreate","title":"Valid task"}</discord-action>' +
225
+ '<discord-action>{bad json}</discord-action>';
226
+ const { actions, parseFailures } = parseDiscordActions(input, { ...ALL_FLAGS, tasks: true });
227
+ expect(actions).toHaveLength(1);
228
+ expect(actions[0]).toEqual({ type: 'taskCreate', title: 'Valid task' });
229
+ expect(parseFailures).toBe(1);
230
+ });
231
+ it('returns parseFailures: 0 for well-formed input', () => {
232
+ const input = '<discord-action>{"type":"channelList"}</discord-action>';
233
+ const { parseFailures } = parseDiscordActions(input, ALL_FLAGS);
234
+ expect(parseFailures).toBe(0);
235
+ });
214
236
  });
215
237
  // ---------------------------------------------------------------------------
216
238
  // executeDiscordActions — mocked guild
@@ -309,7 +331,7 @@ describe('executeDiscordActions', () => {
309
331
  });
310
332
  const results = await executeDiscordActions([{ type: 'channelCreate', name: 'test' }], makeCtx(guild));
311
333
  expect(results).toHaveLength(1);
312
- expect(results[0]).toEqual({ ok: false, error: 'Missing Permissions' });
334
+ expect(results[0]).toEqual({ ok: false, error: 'Failed (channelCreate): Missing Permissions' });
313
335
  });
314
336
  it('one failure does not block subsequent actions', async () => {
315
337
  const guild = makeMockGuild([
@@ -360,6 +382,16 @@ describe('executeDiscordActions', () => {
360
382
  expect(results[0].error).toContain('requires confirmation');
361
383
  expect(results[0].error).toContain('!confirm');
362
384
  });
385
+ it('executes two same-type channelCreate actions and returns two results', async () => {
386
+ const guild = makeMockGuild([]);
387
+ const results = await executeDiscordActions([
388
+ { type: 'channelCreate', name: 'alpha' },
389
+ { type: 'channelCreate', name: 'beta' },
390
+ ], makeCtx(guild));
391
+ expect(results).toHaveLength(2);
392
+ expect(results[0]).toEqual({ ok: true, summary: 'Created #alpha' });
393
+ expect(results[1]).toEqual({ ok: true, summary: 'Created #beta' });
394
+ });
363
395
  it('allows destructive actions with bypassDestructive confirmation context', async () => {
364
396
  const ban = vi.fn(async () => { });
365
397
  const guild = {
@@ -3,7 +3,7 @@ import { fmtTime, resolveChannel } from './action-utils.js';
3
3
  import { NO_MENTIONS } from './allowed-mentions.js';
4
4
  import { DeferScheduler as DeferSchedulerImpl } from './defer-scheduler.js';
5
5
  import { resolveDiscordChannelContext } from './channel-context.js';
6
- import { appendUnavailableActionTypesNotice } from './output-common.js';
6
+ import { appendUnavailableActionTypesNotice, appendParseFailureNotice } from './output-common.js';
7
7
  import { buildContextFiles, buildPromptPreamble, inlineContextFiles, loadWorkspacePaFiles, resolveEffectiveTools, } from './prompt-common.js';
8
8
  import { mapRuntimeErrorToUserMessage } from './user-errors.js';
9
9
  import { resolveModel } from '../runtime/model-tiers.js';
@@ -173,6 +173,7 @@ export function configureDeferredScheduler(opts) {
173
173
  outgoingText = outgoingText ? `${outgoingText}\n\n${displayLines.join('\n')}` : displayLines.join('\n');
174
174
  }
175
175
  outgoingText = appendUnavailableActionTypesNotice(outgoingText, parsed.strippedUnrecognizedTypes).trim();
176
+ outgoingText = appendParseFailureNotice(outgoingText, parsed.parseFailures).trim();
176
177
  if (!outgoingText && runtimeError) {
177
178
  outgoingText = runtimeError;
178
179
  }
@@ -30,7 +30,7 @@ import { taskThreadCache } from '../tasks/thread-cache.js';
30
30
  import { buildTaskContextSummary } from '../tasks/context-summary.js';
31
31
  import { TaskStore } from '../tasks/store.js';
32
32
  import { isChannelPublic, appendEntry, buildExcerptSummary } from './shortterm-memory.js';
33
- import { editThenSendChunks, shouldSuppressFollowUp, appendUnavailableActionTypesNotice } from './output-common.js';
33
+ import { editThenSendChunks, shouldSuppressFollowUp, appendUnavailableActionTypesNotice, appendParseFailureNotice } from './output-common.js';
34
34
  import { downloadMessageImages, resolveMediaType } from './image-download.js';
35
35
  import { resolveReplyReference } from './reply-reference.js';
36
36
  import { resolveThreadContext } from './thread-context.js';
@@ -2017,6 +2017,7 @@ export function createMessageCreateHandler(params, queue, statusRef) {
2017
2017
  let actions = [];
2018
2018
  let actionResults = [];
2019
2019
  let strippedUnrecognizedTypes = [];
2020
+ let parseFailuresCount = 0;
2020
2021
  // Gate action execution on successful stream completion — do not execute
2021
2022
  // actions against partial or error output, which could cause side effects
2022
2023
  // based on incomplete model responses. Relax the hadTextFinal requirement
@@ -2031,6 +2032,10 @@ export function createMessageCreateHandler(params, queue, statusRef) {
2031
2032
  && (hadTextFinal || processedText.includes('<discord-action>'));
2032
2033
  if (params.discordActionsEnabled && msg.guild && canParseActions) {
2033
2034
  const parsed = parseDiscordActions(processedText, actionFlags);
2035
+ parseFailuresCount = parsed.parseFailures;
2036
+ if (parsed.parseFailures > 0) {
2037
+ params.log?.warn(`parseDiscordActions: ${parsed.parseFailures} action block(s) failed to parse (sessionKey=${sessionKey})`);
2038
+ }
2034
2039
  if (parsed.actions.length > 0) {
2035
2040
  actions = parsed.actions;
2036
2041
  strippedUnrecognizedTypes = parsed.strippedUnrecognizedTypes;
@@ -2045,6 +2050,7 @@ export function createMessageCreateHandler(params, queue, statusRef) {
2045
2050
  mode: 'interactive',
2046
2051
  sessionKey,
2047
2052
  userId: msg.author.id,
2053
+ bypassDestructive: true,
2048
2054
  },
2049
2055
  };
2050
2056
  // Construct per-message memoryCtx with real user ID and Discord metadata.
@@ -2078,7 +2084,8 @@ export function createMessageCreateHandler(params, queue, statusRef) {
2078
2084
  if (!processedText.trim()
2079
2085
  && anyActionSucceeded
2080
2086
  && collectedImages.length === 0
2081
- && strippedUnrecognizedTypes.length === 0) {
2087
+ && strippedUnrecognizedTypes.length === 0
2088
+ && parseFailuresCount === 0) {
2082
2089
  try {
2083
2090
  await reply.delete();
2084
2091
  }
@@ -2103,6 +2110,7 @@ export function createMessageCreateHandler(params, queue, statusRef) {
2103
2110
  }
2104
2111
  }
2105
2112
  processedText = appendUnavailableActionTypesNotice(processedText, strippedUnrecognizedTypes);
2113
+ processedText = appendParseFailureNotice(processedText, parseFailuresCount);
2106
2114
  // Suppression: if a follow-up response is trivially short and has no further
2107
2115
  // actions, suppress it to avoid posting empty messages like "Got it."
2108
2116
  // Skip suppression when images are present, or when unrecognized action blocks
@@ -147,6 +147,21 @@ export function appendUnavailableActionTypesNotice(text, strippedTypes) {
147
147
  const base = String(text ?? '').trimEnd();
148
148
  return base ? `${base}\n\n${notice}` : notice;
149
149
  }
150
+ export function buildParseFailureNotice(count) {
151
+ if (count <= 0)
152
+ return '';
153
+ if (count === 1) {
154
+ return 'Warning: 1 action block failed to parse (malformed JSON) and was skipped.';
155
+ }
156
+ return `Warning: ${count} action blocks failed to parse (malformed JSON) and were skipped.`;
157
+ }
158
+ export function appendParseFailureNotice(text, count) {
159
+ const notice = buildParseFailureNotice(count);
160
+ if (!notice)
161
+ return text;
162
+ const base = String(text ?? '').trimEnd();
163
+ return base ? `${base}\n\n${notice}` : notice;
164
+ }
150
165
  export async function sendChunks(channel, text, images) {
151
166
  const attachments = images && images.length > 0 ? buildAttachments(images) : [];
152
167
  const chunks = prepareDiscordOutput(text);
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it, vi } from 'vitest';
2
- import { buildAttachments, imageMediaTypeToExtension, editThenSendChunks, replyThenSendChunks, sendChunks, shouldSuppressFollowUp, buildUnavailableActionTypesNotice, appendUnavailableActionTypesNotice, } from './output-common.js';
2
+ import { buildAttachments, imageMediaTypeToExtension, editThenSendChunks, replyThenSendChunks, sendChunks, shouldSuppressFollowUp, buildUnavailableActionTypesNotice, appendUnavailableActionTypesNotice, buildParseFailureNotice, appendParseFailureNotice, } from './output-common.js';
3
3
  describe('imageMediaTypeToExtension', () => {
4
4
  it('maps known types', () => {
5
5
  expect(imageMediaTypeToExtension('image/png')).toBe('png');
@@ -196,3 +196,39 @@ describe('sendChunks with images', () => {
196
196
  expect(channel.send.mock.calls[0][0].files).toHaveLength(1);
197
197
  });
198
198
  });
199
+ describe('buildParseFailureNotice', () => {
200
+ it('returns empty string for zero failures', () => {
201
+ expect(buildParseFailureNotice(0)).toBe('');
202
+ });
203
+ it('returns empty string for negative count', () => {
204
+ expect(buildParseFailureNotice(-1)).toBe('');
205
+ });
206
+ it('returns singular warning for one failure', () => {
207
+ const out = buildParseFailureNotice(1);
208
+ expect(out).toContain('1 action block');
209
+ expect(out).toContain('malformed JSON');
210
+ expect(out).toContain('skipped');
211
+ });
212
+ it('returns plural warning for multiple failures', () => {
213
+ const out = buildParseFailureNotice(3);
214
+ expect(out).toContain('3 action blocks');
215
+ expect(out).toContain('malformed JSON');
216
+ expect(out).toContain('skipped');
217
+ });
218
+ });
219
+ describe('appendParseFailureNotice', () => {
220
+ it('appends the notice under existing text', () => {
221
+ const out = appendParseFailureNotice('hello', 1);
222
+ expect(out).toContain('hello');
223
+ expect(out).toContain('malformed JSON');
224
+ expect(out.indexOf('hello')).toBeLessThan(out.indexOf('malformed JSON'));
225
+ });
226
+ it('returns notice alone when base text is empty', () => {
227
+ const out = appendParseFailureNotice('', 2);
228
+ expect(out).toContain('2 action blocks');
229
+ expect(out.startsWith('Warning:')).toBe(true);
230
+ });
231
+ it('returns original text when count is zero', () => {
232
+ expect(appendParseFailureNotice('hello', 0)).toBe('hello');
233
+ });
234
+ });
@@ -8,7 +8,7 @@ import { tryResolveReactionPrompt } from './reaction-prompts.js';
8
8
  import { tryAbortAll } from './abort-registry.js';
9
9
  import { getActiveOrchestrator } from './forge-plan-registry.js';
10
10
  import { buildContextFiles, inlineContextFiles, buildDurableMemorySection, buildTaskThreadSection, loadWorkspacePaFiles, resolveEffectiveTools, buildPromptPreamble } from './prompt-common.js';
11
- import { editThenSendChunks, appendUnavailableActionTypesNotice } from './output-common.js';
11
+ import { editThenSendChunks, appendUnavailableActionTypesNotice, appendParseFailureNotice } from './output-common.js';
12
12
  import { formatBoldLabel, thinkingLabel, selectStreamingOutput } from './output-utils.js';
13
13
  import { NO_MENTIONS } from './allowed-mentions.js';
14
14
  import { registerInFlightReply, isShuttingDown } from './inflight-replies.js';
@@ -515,11 +515,13 @@ function createReactionHandler(mode, params, queue, statusRef) {
515
515
  let parsedActions = [];
516
516
  let actionResults = [];
517
517
  let strippedUnrecognizedTypes = [];
518
+ let parseFailuresCount = 0;
518
519
  if (params.discordActionsEnabled && msg.guild && canParseActions && !invokeError) {
519
520
  const parsed = parseDiscordActions(processedText, actionFlags);
520
521
  parsedActionCount = parsed.actions.length;
521
522
  parsedActions = parsed.actions;
522
523
  strippedUnrecognizedTypes = parsed.strippedUnrecognizedTypes;
524
+ parseFailuresCount = parsed.parseFailures;
523
525
  if (parsed.actions.length > 0) {
524
526
  const actCtx = {
525
527
  guild: msg.guild,
@@ -564,7 +566,8 @@ function createReactionHandler(mode, params, queue, statusRef) {
564
566
  if (!processedText.trim()
565
567
  && anyActionSucceeded
566
568
  && collectedImages.length === 0
567
- && strippedUnrecognizedTypes.length === 0) {
569
+ && strippedUnrecognizedTypes.length === 0
570
+ && parseFailuresCount === 0) {
568
571
  try {
569
572
  await reply?.delete?.();
570
573
  }
@@ -587,6 +590,7 @@ function createReactionHandler(mode, params, queue, statusRef) {
587
590
  }
588
591
  }
589
592
  processedText = appendUnavailableActionTypesNotice(processedText, strippedUnrecognizedTypes);
593
+ processedText = appendParseFailureNotice(processedText, parseFailuresCount);
590
594
  // Suppress empty responses and the HEARTBEAT_OK sentinel — delete placeholder and bail.
591
595
  const strippedText = processedText.replace(/\s+/g, ' ').trim();
592
596
  const isSuppressible = strippedText.length === 0 || strippedText === 'HEARTBEAT_OK' || strippedText === '(no output)';
@@ -556,7 +556,7 @@ describe('destructive action confirmation flow', () => {
556
556
  afterEach(() => {
557
557
  resetDestructiveConfirm();
558
558
  });
559
- it('requires !confirm token before executing destructive actions', async () => {
559
+ it('executes destructive actions immediately in interactive mode without !confirm', async () => {
560
560
  const ban = vi.fn(async () => { });
561
561
  const guild = {
562
562
  members: {
@@ -572,17 +572,8 @@ describe('destructive action confirmation flow', () => {
572
572
  const handler = createMessageCreateHandler(baseParams(runtime, { discordActionsModeration: true }), makeQueue());
573
573
  const msg1 = makeMsg({ guild, content: 'ban that user' });
574
574
  await handler(msg1);
575
- const firstReply = await msg1.reply.mock.results[0]?.value;
576
- const firstContent = String(firstReply?.edit?.mock?.calls?.[firstReply.edit.mock.calls.length - 1]?.[0]?.content ?? '');
577
- const token = /!confirm\s+([a-z0-9_-]{6,64})/i.exec(firstContent)?.[1];
578
- expect(token).toBeTruthy();
579
- expect(ban).not.toHaveBeenCalled();
580
- const msg2 = makeMsg({ guild, content: `!confirm ${token}` });
581
- await handler(msg2);
582
575
  expect(runtime.invoke).toHaveBeenCalledTimes(1);
583
576
  expect(ban).toHaveBeenCalledOnce();
584
- const confirmReply = msg2.reply.mock.calls[0]?.[0]?.content ?? '';
585
- expect(confirmReply).toContain('Confirmed `ban`.');
586
577
  });
587
578
  });
588
579
  // ---------------------------------------------------------------------------
@@ -285,6 +285,27 @@ describe('closeTaskThread', () => {
285
285
  await closeTaskThread(client, 'missing', task);
286
286
  // No error thrown — function completes silently.
287
287
  });
288
+ it('calls thread.edit with correct this context (regression: unbound this)', async () => {
289
+ let capturedThis;
290
+ const mockClient = { rest: {} };
291
+ const thread = {
292
+ isThread: () => true,
293
+ archived: false,
294
+ client: mockClient,
295
+ fetchStarterMessage: vi.fn(async () => ({
296
+ author: { id: 'bot-123' },
297
+ content: buildTaskStarterContent(task),
298
+ edit: vi.fn(),
299
+ })),
300
+ send: vi.fn(),
301
+ setArchived: vi.fn(),
302
+ edit: function (_payload) { capturedThis = this; },
303
+ };
304
+ const client = makeClient(thread);
305
+ await closeTaskThread(client, 'thread-1', task);
306
+ expect(capturedThis).toBe(thread);
307
+ expect(capturedThis.client).toBe(mockClient);
308
+ });
288
309
  });
289
310
  // ---------------------------------------------------------------------------
290
311
  // getStatusTagIds
@@ -461,6 +482,20 @@ describe('updateTaskThreadTags', () => {
461
482
  });
462
483
  expect(thread.edit.mock.calls[0][0].appliedTags).not.toContain('s1');
463
484
  });
485
+ it('calls thread.edit with correct this context (regression: unbound this)', async () => {
486
+ let capturedThis;
487
+ const mockClient = { rest: {} };
488
+ const tagMap = { open: 's1', in_progress: 's2' };
489
+ const thread = {
490
+ isThread: () => true,
491
+ appliedTags: ['s1'],
492
+ client: mockClient,
493
+ edit: function (_payload) { capturedThis = this; },
494
+ };
495
+ await updateTaskThreadTags(makeClient(thread), '123', task, tagMap);
496
+ expect(capturedThis).toBe(thread);
497
+ expect(capturedThis.client).toBe(mockClient);
498
+ });
464
499
  });
465
500
  // ---------------------------------------------------------------------------
466
501
  // closeTaskThread with tagMap
@@ -17,7 +17,7 @@ function asEditableTaskThread(thread) {
17
17
  const appliedTags = getThreadAppliedTags(thread);
18
18
  return {
19
19
  appliedTags: appliedTags.length > 0 ? appliedTags : undefined,
20
- edit: candidate.edit,
20
+ edit: candidate.edit.bind(candidate),
21
21
  };
22
22
  }
23
23
  /** Post a close summary, rename with checkmark, and archive the thread. */
@@ -370,7 +370,7 @@ describe('template content — AGENTS.md', () => {
370
370
  });
371
371
  it('contains task creation guidance', async () => {
372
372
  agents ??= await fs.readFile(path.join(templatesDir, 'AGENTS.md'), 'utf-8');
373
- expect(agents).toContain('Task Creation');
373
+ expect(agents).toContain('Task Management');
374
374
  });
375
375
  it('contains git commit hash guidance', async () => {
376
376
  agents ??= await fs.readFile(path.join(templatesDir, 'AGENTS.md'), 'utf-8');
@@ -482,13 +482,13 @@ describe('scaffolded workspace contains operational content', () => {
482
482
  'Landing the Plane',
483
483
  'Plan-Audit-Implement Workflow',
484
484
  'Discord Formatting',
485
- 'Task Creation',
485
+ 'Task Management',
486
486
  ];
487
487
  for (const section of requiredSections) {
488
488
  expect(agents).toContain(section);
489
489
  }
490
490
  });
491
- it('fresh scaffold produces TOOLS.md with all 13 action types', async () => {
491
+ it('fresh scaffold produces TOOLS.md with all 30 action types', async () => {
492
492
  const workspace = await fs.mkdtemp(path.join(os.tmpdir(), 'ws-content-'));
493
493
  dirs.push(workspace);
494
494
  await ensureWorkspaceBootstrapFiles(workspace);
@@ -497,6 +497,8 @@ describe('scaffolded workspace contains operational content', () => {
497
497
  'forgeCreate', 'forgeResume', 'forgeStatus', 'forgeCancel',
498
498
  'planList', 'planShow', 'planApprove', 'planClose', 'planCreate', 'planRun',
499
499
  'memoryRemember', 'memoryForget', 'memoryShow',
500
+ 'taskCreate', 'taskUpdate', 'taskClose', 'taskShow', 'taskList', 'taskSync', 'tagMapReload',
501
+ 'cronCreate', 'cronUpdate', 'cronList', 'cronShow', 'cronPause', 'cronResume', 'cronDelete', 'cronTrigger', 'cronSync', 'cronTagMapReload',
500
502
  ];
501
503
  for (const action of allActionTypes) {
502
504
  expect(tools).toContain(action);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "discoclaw",
3
- "version": "0.1.1",
3
+ "version": "0.1.4",
4
4
  "description": "Minimal Discord bridge routing messages to AI runtimes",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -141,13 +141,13 @@ Plans are stored in `workspace/plans/plan-NNN-slug.md`. The user must explicitly
141
141
 
142
142
  **Canonical reference:** See `docs/plan-and-forge.md` for full command syntax, the forge orchestration loop, phase manager details, configuration options, and end-to-end workflows.
143
143
 
144
- ## Forge, Plan & Memory Action Types
144
+ ## Discord Action Types
145
145
 
146
- See TOOLS.md for the full reference of forge, plan, and memory `<discord-action>` types. Never send `!forge`/`!plan`/`!memory` as text messages — bot-sent messages don't trigger command handlers. Use the action blocks instead.
146
+ See TOOLS.md for the full reference of forge, plan, memory, task, and cron `<discord-action>` types. Never send `!forge`/`!plan`/`!memory` as text messages — bot-sent messages don't trigger command handlers. Use the action blocks instead.
147
147
 
148
- ## Task Creation
148
+ ## Task Management
149
149
 
150
- After creating a task, always post a link to its Discord thread so the user can jump straight to it.
150
+ Discoclaw has a built-in task tracker backed by Discord forum threads. Use `taskCreate` for tracking work items (TODOs, follow-ups, bugs, feature requests) — not GitHub issues, not manual thread creation. After creating a task, always post a link to its Discord thread so the user can jump straight to it. See TOOLS.md for action syntax.
151
151
 
152
152
  ## Discord Action Batching
153
153
 
@@ -196,9 +196,9 @@ This is a starting point. Add your own conventions, style, and rules as you figu
196
196
 
197
197
  **MANDATORY WORKFLOW:**
198
198
 
199
- 1. **File issues for remaining work** - Create issues for anything that needs follow-up
199
+ 1. **Track remaining work** - Use `taskCreate` for anything that needs follow-up
200
200
  2. **Run quality gates** (if code changed) - Tests, linters, builds
201
- 3. **Update issue status** - Close finished work, update in-progress items
201
+ 3. **Update task status** - Use `taskClose`/`taskUpdate` to close finished work and update in-progress items
202
202
  4. **PUSH TO REMOTE** - This is MANDATORY:
203
203
  ```bash
204
204
  git pull --rebase
@@ -177,7 +177,19 @@ Plans are stored in `workspace/plans/plan-NNN-slug.md`. Complex plans can be dec
177
177
 
178
178
  **Canonical reference:** See `docs/plan-and-forge.md` for full command syntax, the forge orchestration loop, phase manager details, configuration options, and end-to-end workflows.
179
179
 
180
- ## Discord Action Types for Forge, Plan & Memory
180
+ ## Task Management
181
+
182
+ Discoclaw has a built-in task tracker backed by Discord forum threads. Use `taskCreate` for tracking work items — not GitHub issues and not manual thread creation.
183
+
184
+ **When to create a task:**
185
+ - TODOs or action items that come up in conversation
186
+ - Follow-up work the user mentions but isn't ready to start
187
+ - Bug reports, feature requests, or things to revisit later
188
+ - Any work item the user wants tracked
189
+
190
+ After creating a task, always post a link to its Discord thread so the user can jump straight to it.
191
+
192
+ ## Discord Action Types
181
193
 
182
194
  Use these as `<discord-action>` blocks in responses — never send `!forge`/`!plan`/`!memory` as text messages (bot-sent messages don't trigger command handlers).
183
195
 
@@ -270,6 +282,122 @@ Use planList to check existing plans before creating duplicates. Use forgeCreate
270
282
 
271
283
  Use memoryRemember to proactively store important facts (preferences, projects, tools, constraints). Pick the most specific `kind` that fits. Memory items persist across sessions, channels, and restarts.
272
284
 
285
+ ### Task Actions
286
+
287
+ **taskCreate** — Create a new task:
288
+ ```
289
+ <discord-action>{"type":"taskCreate","title":"Task title","description":"Optional details","priority":2,"tags":"feature,work"}</discord-action>
290
+ ```
291
+ - `title` (required): Task title.
292
+ - `description` (optional): Detailed description.
293
+ - `priority` (optional): 0-4 (0=highest, default 2).
294
+ - `tags` (optional): Comma-separated labels/tags.
295
+
296
+ **taskUpdate** — Update a task's fields:
297
+ ```
298
+ <discord-action>{"type":"taskUpdate","taskId":"ws-001","status":"in_progress","priority":1}</discord-action>
299
+ ```
300
+ - `taskId` (required): Task ID.
301
+ - `title`, `description`, `priority`, `status` (optional): Fields to update.
302
+
303
+ **taskClose** — Close a task:
304
+ ```
305
+ <discord-action>{"type":"taskClose","taskId":"ws-001","reason":"Done"}</discord-action>
306
+ ```
307
+
308
+ **taskShow** — Show task details:
309
+ ```
310
+ <discord-action>{"type":"taskShow","taskId":"ws-001"}</discord-action>
311
+ ```
312
+
313
+ **taskList** — List tasks:
314
+ ```
315
+ <discord-action>{"type":"taskList","status":"open","limit":10}</discord-action>
316
+ ```
317
+ - `status` (optional): Filter by status (open, in_progress, blocked, closed, all).
318
+ - `label` (optional): Filter by label.
319
+ - `limit` (optional): Max results.
320
+
321
+ **taskSync** — Run full sync between local task store and Discord threads:
322
+ ```
323
+ <discord-action>{"type":"taskSync"}</discord-action>
324
+ ```
325
+
326
+ **tagMapReload** — Reload tag map from disk (hot-reload without restart):
327
+ ```
328
+ <discord-action>{"type":"tagMapReload"}</discord-action>
329
+ ```
330
+
331
+ #### Task Quality Guidelines
332
+ - **Title**: imperative mood, specific, <60 chars. Good: "Add retry logic to webhook handler", "Plan March Denver trip". Bad: "fix stuff".
333
+ - **Description** should answer what/why/scope. Use markdown for structure. Include what "done" looks like for larger tasks. Max 1900 characters — the system will reject longer descriptions with an error.
334
+ - **Priority**: P0=urgent, P1=important, P2=normal (default), P3=nice-to-have, P4=someday.
335
+ - If the user explicitly asks to create a task, always create it.
336
+ - Apply the same description quality standards when using taskUpdate to backfill details.
337
+
338
+ Use taskList to check existing tasks before creating duplicates. Use taskShow/taskUpdate/taskClose to interact with existing tasks by ID rather than channel-name messaging.
339
+
340
+ ### Cron Actions (Automations)
341
+
342
+ **Automations** is the user-facing name for crons. Each automation lives as a thread in a dedicated Discord forum channel (typically called "automations"). When a user says "create an automation," "set up a scheduled task," or "run X every morning/weekly/etc.," respond with `cronCreate`. Use `cronList` to check what's already running before creating a new one.
343
+
344
+ **cronCreate** — Create a new scheduled task:
345
+ ```
346
+ <discord-action>{"type":"cronCreate","name":"Morning Report","schedule":"0 7 * * 1-5","timezone":"America/Los_Angeles","channel":"general","prompt":"Generate a brief morning status update","model":"fast"}</discord-action>
347
+ ```
348
+ - `name` (required): Human-readable name.
349
+ - `schedule` (required): 5-field cron expression (e.g., "0 7 * * 1-5").
350
+ - `channel` (required): Target channel name or ID.
351
+ - `prompt` (required): The instruction text.
352
+ - `timezone` (optional, default: system timezone, or DEFAULT_TIMEZONE env if set): IANA timezone.
353
+ - `tags` (optional): Comma-separated purpose tags.
354
+ - `model` (optional): "fast" or "capable" (auto-classified if omitted).
355
+
356
+ **cronUpdate** — Update a cron's settings:
357
+ ```
358
+ <discord-action>{"type":"cronUpdate","cronId":"cron-a1b2c3d4","schedule":"0 9 * * *","model":"capable"}</discord-action>
359
+ ```
360
+ - `cronId` (required): The stable cron ID.
361
+ - `schedule`, `timezone`, `channel`, `prompt`, `model`, `tags` (optional).
362
+
363
+ **cronList** — List all cron jobs:
364
+ ```
365
+ <discord-action>{"type":"cronList"}</discord-action>
366
+ ```
367
+
368
+ **cronShow** — Show full details for a cron:
369
+ ```
370
+ <discord-action>{"type":"cronShow","cronId":"cron-a1b2c3d4"}</discord-action>
371
+ ```
372
+
373
+ **cronPause** / **cronResume** — Pause or resume a cron:
374
+ ```
375
+ <discord-action>{"type":"cronPause","cronId":"cron-a1b2c3d4"}</discord-action>
376
+ <discord-action>{"type":"cronResume","cronId":"cron-a1b2c3d4"}</discord-action>
377
+ ```
378
+
379
+ **cronDelete** — Remove a cron job and archive its thread:
380
+ ```
381
+ <discord-action>{"type":"cronDelete","cronId":"cron-a1b2c3d4"}</discord-action>
382
+ ```
383
+ Note: cronDelete **archives** the thread (reversible) — it does not permanently delete it. The thread history is preserved and can be unarchived later via the Discord UI, which will re-register the cron job automatically.
384
+
385
+ **cronTrigger** — Immediately execute a cron (manual fire):
386
+ ```
387
+ <discord-action>{"type":"cronTrigger","cronId":"cron-a1b2c3d4"}</discord-action>
388
+ ```
389
+ Note: `force` overrides are disabled in Discord actions.
390
+
391
+ **cronSync** — Run full bidirectional sync:
392
+ ```
393
+ <discord-action>{"type":"cronSync"}</discord-action>
394
+ ```
395
+
396
+ **cronTagMapReload** — Reload tag map from disk and optionally trigger sync:
397
+ ```
398
+ <discord-action>{"type":"cronTagMapReload"}</discord-action>
399
+ ```
400
+
273
401
  ### Model Configuration
274
402
 
275
403
  **modelShow** — Show current model assignments for all roles: