discoclaw 0.5.3 → 0.5.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.env.example.full CHANGED
@@ -192,14 +192,17 @@ DISCORD_GUILD_ID=
192
192
  #DISCOCLAW_DISCORD_ACTIONS_BOT_PROFILE=1
193
193
 
194
194
  # Allow the AI to defer another invocation (e.g., "check on the forge run in 10 minutes").
195
- # The scheduler enforces DISCOCLAW_DISCORD_ACTIONS=1, respects the requesting message's channel
196
- # permissions, and prevents chaining by disabling defer during the follow-up run.
195
+ # The scheduler enforces DISCOCLAW_DISCORD_ACTIONS=1 and respects the requesting message's
196
+ # channel permissions. Nested defers are allowed up to DEFER_MAX_DEPTH (default: 4).
197
197
  DISCOCLAW_DISCORD_ACTIONS_DEFER=1
198
198
  # Optional guard rails keep deferred invocations within safe bounds.
199
199
  # Maximum delay (seconds) the scheduler will accept for defer commands (default: 1800 / 30 min).
200
200
  #DISCOCLAW_DISCORD_ACTIONS_DEFER_MAX_DELAY_SECONDS=1800
201
201
  # Maximum number of pending deferred invocations allowed at once (default: 5).
202
202
  #DISCOCLAW_DISCORD_ACTIONS_DEFER_MAX_CONCURRENT=5
203
+ # Maximum nesting depth for deferred runs. Depth 0 = original message, depth 1 = first defer, etc.
204
+ # When depth reaches this limit, the defer action is disabled for that run (default: 4).
205
+ #DISCOCLAW_DISCORD_ACTIONS_DEFER_MAX_DEPTH=4
203
206
 
204
207
  # Allow the AI to spawn parallel sub-agents in target channels.
205
208
  # Each spawned agent is a fire-and-forget invocation that posts output to the specified channel.
package/dist/config.js CHANGED
@@ -2,6 +2,7 @@ import { parseAllowBotIds, parseAllowChannelIds, parseAllowUserIds } from './dis
2
2
  export const KNOWN_TOOLS = new Set(['Bash', 'Read', 'Write', 'Edit', 'Glob', 'Grep', 'WebSearch', 'WebFetch']);
3
3
  export const DEFAULT_DISCORD_ACTIONS_DEFER_MAX_DELAY_SECONDS = 1800;
4
4
  export const DEFAULT_DISCORD_ACTIONS_DEFER_MAX_CONCURRENT = 5;
5
+ export const DEFAULT_DISCORD_ACTIONS_DEFER_MAX_DEPTH = 4;
5
6
  function parseBoolean(env, name, defaultValue) {
6
7
  const raw = env[name];
7
8
  if (raw == null || raw.trim() === '')
@@ -162,6 +163,7 @@ export function parseConfig(env) {
162
163
  const spawnMaxConcurrent = parsePositiveInt(env, 'DISCOCLAW_DISCORD_ACTIONS_SPAWN_MAX_CONCURRENT', 4);
163
164
  const deferMaxDelaySeconds = parsePositiveNumber(env, 'DISCOCLAW_DISCORD_ACTIONS_DEFER_MAX_DELAY_SECONDS', DEFAULT_DISCORD_ACTIONS_DEFER_MAX_DELAY_SECONDS);
164
165
  const deferMaxConcurrent = parsePositiveInt(env, 'DISCOCLAW_DISCORD_ACTIONS_DEFER_MAX_CONCURRENT', DEFAULT_DISCORD_ACTIONS_DEFER_MAX_CONCURRENT);
166
+ const deferMaxDepth = parsePositiveInt(env, 'DISCOCLAW_DISCORD_ACTIONS_DEFER_MAX_DEPTH', DEFAULT_DISCORD_ACTIONS_DEFER_MAX_DEPTH);
165
167
  if (!discordActionsEnabled) {
166
168
  const enabledCategories = [
167
169
  { name: 'DISCOCLAW_DISCORD_ACTIONS_CHANNELS', enabled: discordActionsChannels },
@@ -366,6 +368,7 @@ export function parseConfig(env) {
366
368
  discordActionsVoice,
367
369
  discordActionsSpawn,
368
370
  deferMaxDelaySeconds,
371
+ deferMaxDepth,
369
372
  deferMaxConcurrent,
370
373
  spawnMaxConcurrent,
371
374
  messageHistoryBudget: parseNonNegativeInt(env, 'DISCOCLAW_MESSAGE_HISTORY_BUDGET', 3000),
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from 'vitest';
2
- import { parseConfig, DEFAULT_DISCORD_ACTIONS_DEFER_MAX_CONCURRENT, DEFAULT_DISCORD_ACTIONS_DEFER_MAX_DELAY_SECONDS, } from './config.js';
2
+ import { parseConfig, DEFAULT_DISCORD_ACTIONS_DEFER_MAX_CONCURRENT, DEFAULT_DISCORD_ACTIONS_DEFER_MAX_DELAY_SECONDS, DEFAULT_DISCORD_ACTIONS_DEFER_MAX_DEPTH, } from './config.js';
3
3
  function env(overrides = {}) {
4
4
  return {
5
5
  DISCORD_TOKEN: 'token',
@@ -110,16 +110,28 @@ describe('parseConfig', () => {
110
110
  expect(config.discordActionsDefer).toBe(true);
111
111
  expect(config.deferMaxDelaySeconds).toBe(DEFAULT_DISCORD_ACTIONS_DEFER_MAX_DELAY_SECONDS);
112
112
  expect(config.deferMaxConcurrent).toBe(DEFAULT_DISCORD_ACTIONS_DEFER_MAX_CONCURRENT);
113
+ expect(config.deferMaxDepth).toBe(DEFAULT_DISCORD_ACTIONS_DEFER_MAX_DEPTH);
114
+ expect(config.deferMaxDepth).toBe(4);
113
115
  });
114
116
  it('parses defer config overrides', () => {
115
117
  const { config } = parseConfig(env({
116
118
  DISCOCLAW_DISCORD_ACTIONS_DEFER: '1',
117
119
  DISCOCLAW_DISCORD_ACTIONS_DEFER_MAX_DELAY_SECONDS: '900',
118
120
  DISCOCLAW_DISCORD_ACTIONS_DEFER_MAX_CONCURRENT: '2',
121
+ DISCOCLAW_DISCORD_ACTIONS_DEFER_MAX_DEPTH: '8',
119
122
  }));
120
123
  expect(config.discordActionsDefer).toBe(true);
121
124
  expect(config.deferMaxDelaySeconds).toBe(900);
122
125
  expect(config.deferMaxConcurrent).toBe(2);
126
+ expect(config.deferMaxDepth).toBe(8);
127
+ });
128
+ it('throws on non-positive or non-integer deferMaxDepth', () => {
129
+ expect(() => parseConfig(env({ DISCOCLAW_DISCORD_ACTIONS_DEFER_MAX_DEPTH: '0' })))
130
+ .toThrow(/DISCOCLAW_DISCORD_ACTIONS_DEFER_MAX_DEPTH must be a positive number/);
131
+ expect(() => parseConfig(env({ DISCOCLAW_DISCORD_ACTIONS_DEFER_MAX_DEPTH: '-1' })))
132
+ .toThrow(/DISCOCLAW_DISCORD_ACTIONS_DEFER_MAX_DEPTH must be a positive number/);
133
+ expect(() => parseConfig(env({ DISCOCLAW_DISCORD_ACTIONS_DEFER_MAX_DEPTH: '2.5' })))
134
+ .toThrow(/DISCOCLAW_DISCORD_ACTIONS_DEFER_MAX_DEPTH must be an integer/);
123
135
  });
124
136
  it('reports ignored action category flags as info-level advisories', () => {
125
137
  const { warnings, infos } = parseConfig(env({
@@ -681,7 +681,7 @@ If an action fails with a "Missing Permissions" or "Missing Access" error, tell
681
681
  4. The bot may need to be re-invited with the "moderator" permission profile if the role wasn't granted at invite time.`);
682
682
  if (flags.defer) {
683
683
  sections.push(`### Deferred self-invocation
684
- Use a <discord-action>{"type":"defer","channel":"general","delaySeconds":600,"prompt":"Check on the forge run"}</discord-action> block to schedule a follow-up run inside the requested channel without another user prompt. You must specify the channel by name or ID; delaySeconds is how long to wait (capped by DISCOCLAW_DISCORD_ACTIONS_DEFER_MAX_DELAY_SECONDS) and prompt becomes the user message when the deferred invocation runs. The scheduler enforces DISCOCLAW_DISCORD_ACTIONS_DEFER_MAX_CONCURRENT pending jobs, respects the same channel permissions as this response, automatically posts the follow-up output, and forces \`defer\` off during that run so no chains can form. If a guard rail rejects the request (too long, too many active defers, missing permissions, or the channel becomes invalid) the action fails with an explanatory message.`);
684
+ Use a <discord-action>{"type":"defer","channel":"general","delaySeconds":600,"prompt":"Check on the forge run"}</discord-action> block to schedule a follow-up run inside the requested channel without another user prompt. You must specify the channel by name or ID; delaySeconds is how long to wait (capped by DISCOCLAW_DISCORD_ACTIONS_DEFER_MAX_DELAY_SECONDS) and prompt becomes the user message when the deferred invocation runs. The scheduler enforces DISCOCLAW_DISCORD_ACTIONS_DEFER_MAX_CONCURRENT pending jobs, respects the same channel permissions as this response, automatically posts the follow-up output, and allows nested defers up to the configured depth limit (DISCOCLAW_DISCORD_ACTIONS_DEFER_MAX_DEPTH, default 4); once the limit is reached, \`defer\` is disabled for that run. If a guard rail rejects the request (too long, too many active defers, missing permissions, or the channel becomes invalid) the action fails with an explanatory message.`);
685
685
  }
686
686
  return sections.join('\n\n');
687
687
  }
@@ -626,7 +626,7 @@ describe('discordActionsPromptSection', () => {
626
626
  expect(prompt).toContain('without another user prompt');
627
627
  expect(prompt).toContain('DISCOCLAW_DISCORD_ACTIONS_DEFER_MAX_DELAY_SECONDS');
628
628
  expect(prompt).toContain('DISCOCLAW_DISCORD_ACTIONS_DEFER_MAX_CONCURRENT');
629
- expect(prompt).toContain('forces `defer` off');
629
+ expect(prompt).toContain('DISCOCLAW_DISCORD_ACTIONS_DEFER_MAX_DEPTH');
630
630
  });
631
631
  });
632
632
  // ---------------------------------------------------------------------------
@@ -20,7 +20,7 @@ function getThreadParentId(candidate) {
20
20
  return null;
21
21
  return String(channel.parentId);
22
22
  }
23
- function buildDeferredActionFlags(state) {
23
+ function buildDeferredActionFlags(state, depth, maxDepth) {
24
24
  return {
25
25
  channels: state.discordActionsChannels,
26
26
  messaging: state.discordActionsMessaging,
@@ -35,7 +35,7 @@ function buildDeferredActionFlags(state) {
35
35
  // Deferred runs do not carry a user identity, so memory actions stay disabled.
36
36
  memory: false,
37
37
  config: Boolean(state.discordActionsConfig),
38
- defer: false,
38
+ defer: depth < maxDepth,
39
39
  imagegen: Boolean(state.discordActionsImagegen),
40
40
  voice: Boolean(state.discordActionsVoice),
41
41
  };
@@ -82,7 +82,8 @@ export function configureDeferredScheduler(opts) {
82
82
  opts.log?.warn({ flow: 'defer', channelId: channel.id, err }, 'defer:context inline failed');
83
83
  }
84
84
  }
85
- const deferredActionFlags = buildDeferredActionFlags(opts.state);
85
+ const deferDepth = (context.deferDepth ?? 0) + 1;
86
+ const deferredActionFlags = buildDeferredActionFlags(opts.state, deferDepth, opts.deferMaxDepth);
86
87
  const openTasksSection = buildOpenTasksSection(opts.state.taskCtx?.store);
87
88
  let prompt = buildPromptPreamble(inlinedContext) + '\n\n' +
88
89
  (openTasksSection
@@ -179,6 +180,8 @@ export function configureDeferredScheduler(opts) {
179
180
  channelId: channel.id,
180
181
  messageId: `defer-${Date.now()}`,
181
182
  threadParentId,
183
+ deferScheduler: context.deferScheduler,
184
+ deferDepth,
182
185
  transport: new DiscordTransportClient(guild, context.client),
183
186
  confirmation: {
184
187
  mode: 'automated',
@@ -76,6 +76,7 @@ function makeOpts(overrides = {}) {
76
76
  return {
77
77
  maxDelaySeconds: 3600,
78
78
  maxConcurrent: 5,
79
+ deferMaxDepth: 4,
79
80
  state: makeState(),
80
81
  runtime: makeRuntime([{ type: 'text_final', text: 'Hello' }, { type: 'done' }]),
81
82
  runtimeTools: [],
@@ -221,4 +222,81 @@ describe('deferred-runner observability', () => {
221
222
  scheduler.schedule({ action: makeAction(), context: makeContext() });
222
223
  await expect(vi.advanceTimersByTimeAsync(2000)).resolves.not.toThrow();
223
224
  });
225
+ it('flags have defer: true when depth is below maxDepth', async () => {
226
+ const { parseDiscordActions } = await import('./actions.js');
227
+ const mockParse = parseDiscordActions;
228
+ mockParse.mockClear();
229
+ mockParse.mockReturnValue({
230
+ actions: [],
231
+ cleanText: 'ok',
232
+ strippedUnrecognizedTypes: [],
233
+ parseFailures: 0,
234
+ });
235
+ const opts = makeOpts({ deferMaxDepth: 4 });
236
+ const scheduler = configureDeferredScheduler(opts);
237
+ // context with no deferDepth (defaults to 0) → depth becomes 1, which is < 4
238
+ scheduler.schedule({ action: makeAction(), context: makeContext() });
239
+ await vi.advanceTimersByTimeAsync(2000);
240
+ const flags = mockParse.mock.calls[0][1];
241
+ expect(flags.defer).toBe(true);
242
+ });
243
+ it('flags have defer: false when depth equals maxDepth', async () => {
244
+ const { parseDiscordActions } = await import('./actions.js');
245
+ const mockParse = parseDiscordActions;
246
+ mockParse.mockClear();
247
+ mockParse.mockReturnValue({
248
+ actions: [],
249
+ cleanText: 'ok',
250
+ strippedUnrecognizedTypes: [],
251
+ parseFailures: 0,
252
+ });
253
+ const opts = makeOpts({ deferMaxDepth: 4 });
254
+ const scheduler = configureDeferredScheduler(opts);
255
+ // context with deferDepth 3 → depth becomes 4, which equals maxDepth
256
+ const ctx = { ...makeContext(), deferDepth: 3 };
257
+ scheduler.schedule({ action: makeAction(), context: ctx });
258
+ await vi.advanceTimersByTimeAsync(2000);
259
+ const flags = mockParse.mock.calls[0][1];
260
+ expect(flags.defer).toBe(false);
261
+ });
262
+ it('actCtx carries deferDepth incremented by 1 from incoming context', async () => {
263
+ const { parseDiscordActions, executeDiscordActions } = await import('./actions.js');
264
+ const mockParse = parseDiscordActions;
265
+ const mockExecute = executeDiscordActions;
266
+ mockParse.mockClear();
267
+ mockExecute.mockClear();
268
+ mockParse.mockReturnValue({
269
+ actions: [{ type: 'sendMessage', content: 'hi' }],
270
+ cleanText: '',
271
+ strippedUnrecognizedTypes: [],
272
+ parseFailures: 0,
273
+ });
274
+ mockExecute.mockResolvedValue([{ ok: true, summary: 'sent' }]);
275
+ const opts = makeOpts({ deferMaxDepth: 4 });
276
+ const scheduler = configureDeferredScheduler(opts);
277
+ const ctx = { ...makeContext(), deferDepth: 2 };
278
+ scheduler.schedule({ action: makeAction(), context: ctx });
279
+ await vi.advanceTimersByTimeAsync(2000);
280
+ const actCtx = mockExecute.mock.calls[0][1];
281
+ expect(actCtx.deferDepth).toBe(3);
282
+ });
283
+ it('deferMaxDepth 1 allows first level but blocks second', async () => {
284
+ const { parseDiscordActions } = await import('./actions.js');
285
+ const mockParse = parseDiscordActions;
286
+ // First level: deferDepth undefined → depth = 1, maxDepth = 1 → defer: false
287
+ mockParse.mockClear();
288
+ mockParse.mockReturnValue({
289
+ actions: [],
290
+ cleanText: 'ok',
291
+ strippedUnrecognizedTypes: [],
292
+ parseFailures: 0,
293
+ });
294
+ const opts = makeOpts({ deferMaxDepth: 1 });
295
+ const scheduler = configureDeferredScheduler(opts);
296
+ scheduler.schedule({ action: makeAction(), context: makeContext() });
297
+ await vi.advanceTimersByTimeAsync(2000);
298
+ // depth = 0 + 1 = 1, maxDepth = 1 → 1 < 1 is false → defer: false
299
+ const flags = mockParse.mock.calls[0][1];
300
+ expect(flags.defer).toBe(false);
301
+ });
224
302
  });
@@ -97,7 +97,8 @@ export async function handleUpdateCommand(cmd, opts = {}) {
97
97
  }
98
98
  if (npmMode) {
99
99
  progress('Installing latest version from npm...');
100
- const install = await run('npm', ['install', '-g', 'discoclaw@latest', '--loglevel=error'], { timeout: 120_000 });
100
+ const npmEnv = { ...process.env, CFLAGS: '-Wno-incompatible-pointer-types' };
101
+ const install = await run('npm', ['install', '-g', 'discoclaw@latest', '--loglevel=error'], { timeout: 120_000, env: npmEnv });
101
102
  if (install.exitCode !== 0) {
102
103
  const detail = (install.stderr || install.stdout).trim().slice(0, 500);
103
104
  return { reply: `\`npm install -g discoclaw@latest\` failed:\n\`\`\`\n${detail}\n\`\`\`` };
package/dist/index.js CHANGED
@@ -866,6 +866,7 @@ if (discordActionsEnabled && cfg.discordActionsDefer) {
866
866
  deferOpts = {
867
867
  maxDelaySeconds: cfg.deferMaxDelaySeconds,
868
868
  maxConcurrent: cfg.deferMaxConcurrent,
869
+ deferMaxDepth: cfg.deferMaxDepth,
869
870
  state: botParams,
870
871
  runtime,
871
872
  runtimeTools,
@@ -50,6 +50,7 @@ export async function npmGlobalUpgrade() {
50
50
  try {
51
51
  const result = await execa('npm', ['install', '-g', 'discoclaw', '--loglevel=error'], {
52
52
  timeout: 120_000,
53
+ env: { ...process.env, CFLAGS: '-Wno-incompatible-pointer-types' },
53
54
  });
54
55
  return {
55
56
  exitCode: result.exitCode ?? 0,
@@ -83,6 +83,7 @@ describe('npmGlobalUpgrade', () => {
83
83
  expect(result.stderr).toBe('');
84
84
  expect(mockExeca).toHaveBeenCalledWith('npm', ['install', '-g', 'discoclaw', '--loglevel=error'], {
85
85
  timeout: 120_000,
86
+ env: expect.objectContaining({ CFLAGS: '-Wno-incompatible-pointer-types' }),
86
87
  });
87
88
  });
88
89
  it('returns a non-zero exitCode and stderr when npm install fails', async () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "discoclaw",
3
- "version": "0.5.3",
3
+ "version": "0.5.5",
4
4
  "description": "Minimal Discord bridge routing messages to AI runtimes",
5
5
  "license": "MIT",
6
6
  "repository": {