discoclaw 0.2.1 → 0.2.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/.env.example.full CHANGED
@@ -318,6 +318,9 @@ DISCOCLAW_DISCORD_ACTIONS_DEFER=1
318
318
  # managers, or running under a different user). If unset, the command is
319
319
  # auto-selected: systemctl --user on Linux, launchctl on macOS.
320
320
  #DC_RESTART_CMD=
321
+ # Sets the systemd unit / launchd label name used by !restart and !update apply.
322
+ # Default: discoclaw. Ignored when DC_RESTART_CMD is set (that overrides the entire command).
323
+ #DISCOCLAW_SERVICE_NAME=discoclaw
321
324
  # Global cap on parallel runtime invocations across all Discord sessions (0 = unlimited).
322
325
  #DISCOCLAW_MAX_CONCURRENT_INVOCATIONS=3
323
326
  # Max depth for chained action follow-ups (e.g. defer → action → response). 0 = disabled.
@@ -68,6 +68,7 @@ export function renderSystemdUnit(packageRoot, cwd) {
68
68
  '',
69
69
  '[Service]',
70
70
  'Type=simple',
71
+ 'Environment=PATH=%h/.local/bin:%h/.npm-global/bin:/usr/local/bin:/usr/bin:/bin',
71
72
  `ExecStart=/usr/bin/node ${entryPoint}`,
72
73
  `WorkingDirectory=${cwd}`,
73
74
  `EnvironmentFile=${path.join(cwd, '.env')}`,
@@ -119,8 +120,38 @@ export function renderLaunchdPlist(packageRoot, cwd, envVars, serviceName = 'dis
119
120
  lines.push('');
120
121
  return lines.join('\n');
121
122
  }
123
+ // ── Env helpers ────────────────────────────────────────────────────────────
124
+ /**
125
+ * Ensures DISCOCLAW_SERVICE_NAME in the .env file reflects serviceName.
126
+ * - Default ('discoclaw'): removes any existing DISCOCLAW_SERVICE_NAME line.
127
+ * - Non-default: replaces existing line in-place, or appends a new one.
128
+ * Only writes if content actually changed.
129
+ */
130
+ export function ensureServiceNameInEnv(envPath, serviceName) {
131
+ const content = fs.readFileSync(envPath, 'utf8');
132
+ const KEY = 'DISCOCLAW_SERVICE_NAME';
133
+ const lineRegex = /^DISCOCLAW_SERVICE_NAME=.*$/m;
134
+ let newContent;
135
+ if (serviceName === 'discoclaw') {
136
+ if (!lineRegex.test(content))
137
+ return;
138
+ newContent = content.replace(/^DISCOCLAW_SERVICE_NAME=.*\n?/m, '');
139
+ }
140
+ else {
141
+ if (lineRegex.test(content)) {
142
+ newContent = content.replace(lineRegex, `${KEY}=${serviceName}`);
143
+ }
144
+ else {
145
+ const sep = content.endsWith('\n') ? '' : '\n';
146
+ newContent = `${content}${sep}${KEY}=${serviceName}\n`;
147
+ }
148
+ }
149
+ if (newContent !== content) {
150
+ fs.writeFileSync(envPath, newContent, 'utf8');
151
+ }
152
+ }
122
153
  // ── Platform installers ────────────────────────────────────────────────────
123
- async function installSystemd(packageRoot, cwd, serviceName, ask) {
154
+ async function installSystemd(packageRoot, cwd, envPath, serviceName, ask) {
124
155
  const serviceDir = path.join(os.homedir(), '.config', 'systemd', 'user');
125
156
  const servicePath = path.join(serviceDir, `${serviceName}.service`);
126
157
  if (fs.existsSync(servicePath)) {
@@ -130,6 +161,13 @@ async function installSystemd(packageRoot, cwd, serviceName, ask) {
130
161
  return;
131
162
  }
132
163
  }
164
+ try {
165
+ ensureServiceNameInEnv(envPath, serviceName);
166
+ }
167
+ catch (err) {
168
+ console.error(`Failed to update .env: ${err.message}\n`);
169
+ process.exit(1);
170
+ }
133
171
  const unit = renderSystemdUnit(packageRoot, cwd);
134
172
  fs.mkdirSync(serviceDir, { recursive: true });
135
173
  fs.writeFileSync(servicePath, unit, 'utf8');
@@ -168,6 +206,13 @@ async function installLaunchd(packageRoot, cwd, envPath, serviceName, ask) {
168
206
  return;
169
207
  }
170
208
  }
209
+ try {
210
+ ensureServiceNameInEnv(envPath, serviceName);
211
+ }
212
+ catch (err) {
213
+ console.error(`Failed to update .env: ${err.message}\n`);
214
+ process.exit(1);
215
+ }
171
216
  const envContent = fs.readFileSync(envPath, 'utf8');
172
217
  const envVars = parseEnvFile(envContent);
173
218
  const plist = renderLaunchdPlist(packageRoot, cwd, envVars, serviceName);
@@ -229,7 +274,7 @@ export async function runDaemonInstaller() {
229
274
  const ask = (prompt) => rl.question(prompt);
230
275
  try {
231
276
  if (platform === 'linux') {
232
- await installSystemd(packageRoot, cwd, serviceName, ask);
277
+ await installSystemd(packageRoot, cwd, envPath, serviceName, ask);
233
278
  }
234
279
  else {
235
280
  await installLaunchd(packageRoot, cwd, envPath, serviceName, ask);
@@ -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, parseServiceName, renderSystemdUnit, renderLaunchdPlist, runDaemonInstaller, } from './daemon-installer.js';
19
+ import { ensureServiceNameInEnv, parseEnvFile, parseServiceName, renderSystemdUnit, renderLaunchdPlist, runDaemonInstaller, } from './daemon-installer.js';
20
20
  // ── Test helpers ───────────────────────────────────────────────────────────
21
21
  function makeReadline(answers = []) {
22
22
  return {
@@ -76,6 +76,12 @@ describe('renderSystemdUnit', () => {
76
76
  const unit = renderSystemdUnit(PACKAGE_ROOT, CWD);
77
77
  expect(unit).toContain(`EnvironmentFile=${CWD}/.env`);
78
78
  });
79
+ it('includes Environment=PATH with user-local bin directories', () => {
80
+ const unit = renderSystemdUnit(PACKAGE_ROOT, CWD);
81
+ expect(unit).toContain('Environment=PATH=');
82
+ expect(unit).toContain('%h/.local/bin');
83
+ expect(unit).toContain('%h/.npm-global/bin');
84
+ });
79
85
  it('includes standard unit and install sections', () => {
80
86
  const unit = renderSystemdUnit(PACKAGE_ROOT, CWD);
81
87
  expect(unit).toContain('[Unit]');
@@ -121,6 +127,43 @@ describe('renderLaunchdPlist', () => {
121
127
  expect(plist).toContain('<true/>');
122
128
  });
123
129
  });
130
+ // ── ensureServiceNameInEnv ─────────────────────────────────────────────────
131
+ describe('ensureServiceNameInEnv', () => {
132
+ const ENV_PATH = '/home/user/bot/.env';
133
+ beforeEach(() => {
134
+ vi.clearAllMocks();
135
+ vi.mocked(fs.writeFileSync).mockReturnValue(undefined);
136
+ });
137
+ it('default name, no existing line — content is unchanged', () => {
138
+ vi.mocked(fs.readFileSync).mockReturnValue('DISCORD_TOKEN=abc\n');
139
+ ensureServiceNameInEnv(ENV_PATH, 'discoclaw');
140
+ expect(fs.writeFileSync).not.toHaveBeenCalled();
141
+ });
142
+ it('custom name, no existing line — appends DISCOCLAW_SERVICE_NAME=custom-name', () => {
143
+ vi.mocked(fs.readFileSync).mockReturnValue('DISCORD_TOKEN=abc\n');
144
+ ensureServiceNameInEnv(ENV_PATH, 'custom-name');
145
+ expect(fs.writeFileSync).toHaveBeenCalledWith(ENV_PATH, 'DISCORD_TOKEN=abc\nDISCOCLAW_SERVICE_NAME=custom-name\n', 'utf8');
146
+ });
147
+ it('custom name, existing stale line — replaces old value in-place, no duplicate', () => {
148
+ vi.mocked(fs.readFileSync).mockReturnValue('DISCORD_TOKEN=abc\nDISCOCLAW_SERVICE_NAME=old-name\nFOO=bar\n');
149
+ ensureServiceNameInEnv(ENV_PATH, 'new-name');
150
+ const written = vi.mocked(fs.writeFileSync).mock.calls[0][1];
151
+ expect(written).toContain('DISCOCLAW_SERVICE_NAME=new-name');
152
+ expect(written).not.toContain('DISCOCLAW_SERVICE_NAME=old-name');
153
+ expect((written.match(/DISCOCLAW_SERVICE_NAME=/g) ?? []).length).toBe(1);
154
+ });
155
+ it('default name, existing stale line — removes DISCOCLAW_SERVICE_NAME line', () => {
156
+ vi.mocked(fs.readFileSync).mockReturnValue('DISCORD_TOKEN=abc\nDISCOCLAW_SERVICE_NAME=old-name\n');
157
+ ensureServiceNameInEnv(ENV_PATH, 'discoclaw');
158
+ const written = vi.mocked(fs.writeFileSync).mock.calls[0][1];
159
+ expect(written).not.toContain('DISCOCLAW_SERVICE_NAME');
160
+ });
161
+ it('custom name matches existing line — no file write (no-op)', () => {
162
+ vi.mocked(fs.readFileSync).mockReturnValue('DISCORD_TOKEN=abc\nDISCOCLAW_SERVICE_NAME=custom-name\n');
163
+ ensureServiceNameInEnv(ENV_PATH, 'custom-name');
164
+ expect(fs.writeFileSync).not.toHaveBeenCalled();
165
+ });
166
+ });
124
167
  // ── runDaemonInstaller ─────────────────────────────────────────────────────
125
168
  const originalIsTTY = process.stdin.isTTY;
126
169
  const originalPlatform = process.platform;
@@ -306,4 +349,49 @@ describe('runDaemonInstaller', () => {
306
349
  expect(fs.writeFileSync).not.toHaveBeenCalled();
307
350
  expect(execFileSync).not.toHaveBeenCalled();
308
351
  });
352
+ it('linux: does not mutate .env when overwrite is declined with custom --service-name', async () => {
353
+ vi.spyOn(console, 'log').mockImplementation(() => { });
354
+ Object.defineProperty(process, 'platform', { value: 'linux', configurable: true });
355
+ const originalArgv = process.argv;
356
+ process.argv = ['node', 'discoclaw', 'install-daemon', '--service-name', 'custom'];
357
+ vi.mocked(fs.existsSync).mockImplementation((p) => {
358
+ const s = String(p);
359
+ if (s.endsWith('.env'))
360
+ return true;
361
+ if (s.endsWith('dist/index.js'))
362
+ return true;
363
+ if (s.endsWith('custom.service'))
364
+ return true;
365
+ return false;
366
+ });
367
+ const rl = makeReadline(['n']); // decline overwrite
368
+ vi.mocked(createInterface).mockReturnValue(rl);
369
+ try {
370
+ await runDaemonInstaller();
371
+ }
372
+ finally {
373
+ process.argv = originalArgv;
374
+ }
375
+ expect(fs.writeFileSync).not.toHaveBeenCalled();
376
+ });
377
+ it('linux: writes DISCOCLAW_SERVICE_NAME=custom to .env before service file when --service-name custom is passed', async () => {
378
+ vi.spyOn(console, 'log').mockImplementation(() => { });
379
+ Object.defineProperty(process, 'platform', { value: 'linux', configurable: true });
380
+ const originalArgv = process.argv;
381
+ process.argv = ['node', 'discoclaw', 'install-daemon', '--service-name', 'custom'];
382
+ // .env has no DISCOCLAW_SERVICE_NAME yet
383
+ vi.mocked(fs.readFileSync).mockReturnValue(SAMPLE_ENV);
384
+ try {
385
+ await runDaemonInstaller();
386
+ }
387
+ finally {
388
+ process.argv = originalArgv;
389
+ }
390
+ const calls = vi.mocked(fs.writeFileSync).mock.calls;
391
+ const envWriteIdx = calls.findIndex(([p]) => String(p).endsWith('.env'));
392
+ const serviceWriteIdx = calls.findIndex(([p]) => String(p).endsWith('custom.service'));
393
+ expect(envWriteIdx).toBeGreaterThanOrEqual(0);
394
+ expect(calls[envWriteIdx][1]).toContain('DISCOCLAW_SERVICE_NAME=custom');
395
+ expect(serviceWriteIdx).toBeGreaterThan(envWriteIdx);
396
+ });
309
397
  });
package/dist/config.js CHANGED
@@ -406,6 +406,7 @@ export function parseConfig(env) {
406
406
  botActivity: parseTrimmedString(env, 'DISCOCLAW_BOT_ACTIVITY'),
407
407
  botActivityType: parseEnum(env, 'DISCOCLAW_BOT_ACTIVITY_TYPE', ['Playing', 'Listening', 'Watching', 'Competing', 'Custom'], 'Playing'),
408
408
  botAvatar: parseAvatarPath(env, 'DISCOCLAW_BOT_AVATAR'),
409
+ serviceName: parseTrimmedString(env, 'DISCOCLAW_SERVICE_NAME') ?? 'discoclaw',
409
410
  },
410
411
  warnings,
411
412
  infos,
@@ -27,6 +27,7 @@ describe('parseConfig', () => {
27
27
  expect(config.tasksSyncFailureRetryDelayMs).toBe(30_000);
28
28
  expect(config.tasksSyncDeferredRetryDelayMs).toBe(30_000);
29
29
  expect(config.outputFormat).toBe('text');
30
+ expect(config.serviceName).toBe('discoclaw');
30
31
  expect(warnings.some((w) => w.includes('category flags are ignored'))).toBe(false);
31
32
  expect(infos.some((i) => i.includes('category flags are ignored'))).toBe(false);
32
33
  });
@@ -693,4 +694,17 @@ describe('parseConfig', () => {
693
694
  const { config } = parseConfig(env({ DISCOCLAW_WEBHOOK_CONFIG: '/etc/discoclaw/webhooks.json' }));
694
695
  expect(config.webhookConfigPath).toBe('/etc/discoclaw/webhooks.json');
695
696
  });
697
+ // --- serviceName ---
698
+ it('defaults serviceName to "discoclaw"', () => {
699
+ const { config } = parseConfig(env());
700
+ expect(config.serviceName).toBe('discoclaw');
701
+ });
702
+ it('parses DISCOCLAW_SERVICE_NAME when set', () => {
703
+ const { config } = parseConfig(env({ DISCOCLAW_SERVICE_NAME: 'discoclaw-dev' }));
704
+ expect(config.serviceName).toBe('discoclaw-dev');
705
+ });
706
+ it('returns default serviceName when DISCOCLAW_SERVICE_NAME is whitespace-only', () => {
707
+ const { config } = parseConfig(env({ DISCOCLAW_SERVICE_NAME: ' ' }));
708
+ expect(config.serviceName).toBe('discoclaw');
709
+ });
696
710
  });
@@ -214,7 +214,8 @@ export async function executeCronAction(action, ctx, cronCtx) {
214
214
  }
215
215
  else {
216
216
  // Can't edit user's message — post update note.
217
- const note = `**Cron Updated**\n**Schedule:** \`${newSchedule}\` (${newTimezone})\n**Channel:** #${newChannel}\n\nPlease update the starter message to reflect these changes.`;
217
+ const promptPreview = newPrompt.length > 200 ? `${newPrompt.slice(0, 200)}... (truncated)` : newPrompt;
218
+ const note = `**Cron Updated**\n**Schedule:** \`${newSchedule}\` (${newTimezone})\n**Channel:** #${newChannel}\n**Prompt:** ${promptPreview}\n\nPlease update the starter message to reflect these changes.`;
218
219
  await thread.send({ content: note, allowedMentions: { parse: [] } });
219
220
  }
220
221
  }
@@ -320,6 +321,11 @@ export async function executeCronAction(action, ctx, cronCtx) {
320
321
  lines.push(`Tags: ${record.purposeTags.join(', ')}`);
321
322
  if (record.lastErrorMessage)
322
323
  lines.push(`Last error: ${record.lastErrorMessage}`);
324
+ if (job) {
325
+ const promptText = job.def.prompt;
326
+ const truncated = promptText.length > 500 ? `${promptText.slice(0, 500)}... (truncated)` : promptText;
327
+ lines.push(`Prompt: ${truncated}`);
328
+ }
323
329
  return { ok: true, summary: lines.join('\n') };
324
330
  }
325
331
  case 'cronPause': {
@@ -484,6 +484,48 @@ describe('executeCronAction', () => {
484
484
  expect(result.summary).toContain('no sync coordinator configured');
485
485
  }
486
486
  });
487
+ it('cronShow includes Prompt line with job prompt text', async () => {
488
+ const cronCtx = makeCronCtx();
489
+ const result = await executeCronAction({ type: 'cronShow', cronId: 'cron-test0001' }, makeActionCtx(), cronCtx);
490
+ expect(result.ok).toBe(true);
491
+ if (result.ok) {
492
+ expect(result.summary).toContain('Prompt: Test');
493
+ }
494
+ });
495
+ it('cronShow truncates prompt longer than 500 chars', async () => {
496
+ const cronCtx = makeCronCtx();
497
+ const job = cronCtx.scheduler.getJob('thread-1');
498
+ job.def.prompt = 'x'.repeat(600);
499
+ const result = await executeCronAction({ type: 'cronShow', cronId: 'cron-test0001' }, makeActionCtx(), cronCtx);
500
+ expect(result.ok).toBe(true);
501
+ if (result.ok) {
502
+ expect(result.summary).toContain('... (truncated)');
503
+ expect(result.summary).not.toContain('x'.repeat(600));
504
+ }
505
+ });
506
+ it('cronShow omits Prompt line when scheduler job is missing', async () => {
507
+ const cronCtx = makeCronCtx({ scheduler: makeScheduler([]) });
508
+ const result = await executeCronAction({ type: 'cronShow', cronId: 'cron-test0001' }, makeActionCtx(), cronCtx);
509
+ expect(result.ok).toBe(true);
510
+ if (result.ok) {
511
+ expect(result.summary).not.toContain('Prompt:');
512
+ }
513
+ });
514
+ it('cronUpdate fallback note includes prompt when starter is not bot-owned', async () => {
515
+ const cronCtx = makeCronCtx();
516
+ const threadSend = vi.fn(async () => ({}));
517
+ const mockThread = {
518
+ id: 'thread-1',
519
+ isThread: () => true,
520
+ send: threadSend,
521
+ fetchStarterMessage: vi.fn(async () => ({ author: { id: 'other-user' }, edit: vi.fn() })),
522
+ setArchived: vi.fn(),
523
+ };
524
+ cronCtx.client.channels.cache.get.mockImplementation((id) => id === 'thread-1' ? mockThread : undefined);
525
+ const result = await executeCronAction({ type: 'cronUpdate', cronId: 'cron-test0001', prompt: 'New prompt text' }, makeActionCtx(), cronCtx);
526
+ expect(result.ok).toBe(true);
527
+ expect(threadSend).toHaveBeenCalledWith(expect.objectContaining({ content: expect.stringContaining('New prompt text') }));
528
+ });
487
529
  it('cronTagMapReload failure returns error', async () => {
488
530
  const { reloadCronTagMapInPlace } = await import('../cron/tag-map.js');
489
531
  vi.mocked(reloadCronTagMapInPlace).mockRejectedValue(new Error('bad json'));
@@ -494,6 +494,7 @@ export function createMessageCreateHandler(params, queue, statusRef) {
494
494
  dataDir: params.dataDir,
495
495
  userId: msg.author.id,
496
496
  activeForge: getActiveOrchestrator()?.activePlanId,
497
+ serviceName: params.serviceName,
497
498
  });
498
499
  await msg.reply({ content: result.reply, allowedMentions: NO_MENTIONS });
499
500
  // Deferred action (e.g., restart) runs after the reply is sent.
@@ -509,6 +510,7 @@ export function createMessageCreateHandler(params, queue, statusRef) {
509
510
  projectCwd: params.projectCwd,
510
511
  dataDir: params.dataDir,
511
512
  restartCmd: params.updateRestartCmd,
513
+ serviceName: params.serviceName,
512
514
  });
513
515
  await msg.reply({ content: result.reply, allowedMentions: NO_MENTIONS });
514
516
  // Deferred action (e.g., restart after apply) runs after the reply is sent.
@@ -30,21 +30,21 @@ function mapExitCode(err) {
30
30
  return 0;
31
31
  return typeof err.code === 'number' ? err.code : null;
32
32
  }
33
- function getPlatformCommands() {
33
+ function getPlatformCommands(serviceName = 'discoclaw') {
34
34
  if (process.platform === 'linux') {
35
35
  return {
36
- statusCmd: ['systemctl', ['--user', 'status', 'discoclaw']],
37
- logsCmd: ['journalctl', ['--user', '-u', 'discoclaw', '--no-pager', '-n', '30']],
38
- checkActiveCmd: ['systemctl', ['--user', 'status', 'discoclaw']],
36
+ statusCmd: ['systemctl', ['--user', 'status', serviceName]],
37
+ logsCmd: ['journalctl', ['--user', '-u', serviceName, '--no-pager', '-n', '30']],
38
+ checkActiveCmd: ['systemctl', ['--user', 'status', serviceName]],
39
39
  isActive: (result) => result.stdout.includes('active (running)'),
40
- restartCmd: () => ['systemctl', ['--user', 'restart', 'discoclaw']],
40
+ restartCmd: () => ['systemctl', ['--user', 'restart', serviceName]],
41
41
  };
42
42
  }
43
43
  if (process.platform === 'darwin') {
44
44
  const uid = process.getuid?.() ?? 501;
45
- const plistPath = `${os.homedir()}/Library/LaunchAgents/com.discoclaw.agent.plist`;
45
+ const label = `com.discoclaw.${serviceName}`;
46
+ const plistPath = `${os.homedir()}/Library/LaunchAgents/${label}.plist`;
46
47
  const domain = `gui/${uid}`;
47
- const label = 'com.discoclaw.agent';
48
48
  return {
49
49
  statusCmd: ['launchctl', ['list', label]],
50
50
  logsCmd: ['log', ['show', '--predicate', 'process == "node"', '--last', '5m', '--style', 'compact']],
@@ -62,18 +62,18 @@ function getPlatformCommands() {
62
62
  * platform, assuming the service is already running. Falls back to systemctl
63
63
  * on unsupported platforms.
64
64
  */
65
- export function getRestartCmdArgs() {
66
- const pc = getPlatformCommands();
65
+ export function getRestartCmdArgs(serviceName) {
66
+ const pc = getPlatformCommands(serviceName);
67
67
  if (pc)
68
68
  return pc.restartCmd(true);
69
- return ['systemctl', ['--user', 'restart', 'discoclaw']];
69
+ return ['systemctl', ['--user', 'restart', serviceName ?? 'discoclaw']];
70
70
  }
71
71
  export async function handleRestartCommand(cmd, opts) {
72
72
  // Support both legacy (log) and new (opts bag) signatures.
73
73
  const resolved = opts && typeof opts === 'object' && 'info' in opts
74
74
  ? { log: opts }
75
75
  : opts ?? {};
76
- const { log, dataDir, userId, activeForge } = resolved;
76
+ const { log, dataDir, userId, activeForge, serviceName = 'discoclaw' } = resolved;
77
77
  try {
78
78
  if (cmd.action === 'help') {
79
79
  return {
@@ -86,7 +86,7 @@ export async function handleRestartCommand(cmd, opts) {
86
86
  ].join('\n'),
87
87
  };
88
88
  }
89
- const pc = getPlatformCommands();
89
+ const pc = getPlatformCommands(serviceName);
90
90
  if (!pc) {
91
91
  return {
92
92
  reply: `!restart is not supported on this platform (${process.platform}). Only Linux (systemd) and macOS (launchd) are supported.`,
@@ -114,8 +114,8 @@ export async function handleRestartCommand(cmd, opts) {
114
114
  // invokes *after* sending the reply to Discord.
115
115
  return {
116
116
  reply: wasActive
117
- ? 'Restarting discoclaw... back in a moment.'
118
- : 'Starting discoclaw...',
117
+ ? `Restarting ${serviceName}... back in a moment.`
118
+ : `Starting ${serviceName}...`,
119
119
  deferred: () => {
120
120
  // Write shutdown context right before triggering restart so it
121
121
  // doesn't linger if the deferred never fires or restart fails.
@@ -126,7 +126,7 @@ describe('handleRestartCommand - macOS', () => {
126
126
  const statusCall = calls.find((c) => c[0] === 'launchctl');
127
127
  expect(statusCall).toBeDefined();
128
128
  expect(statusCall[1]).toContain('list');
129
- expect(statusCall[1]).toContain('com.discoclaw.agent');
129
+ expect(statusCall[1]).toContain('com.discoclaw.discoclaw');
130
130
  // Must NOT call systemctl
131
131
  expect(calls.every((c) => c[0] !== 'systemctl')).toBe(true);
132
132
  });
@@ -154,7 +154,7 @@ describe('handleRestartCommand - macOS', () => {
154
154
  const kickstartCall = calls.find((c) => c[0] === 'launchctl' && c[1].includes('kickstart'));
155
155
  expect(kickstartCall).toBeDefined();
156
156
  expect(kickstartCall[1]).toContain('-k');
157
- expect(kickstartCall[1]).toContain('gui/501/com.discoclaw.agent');
157
+ expect(kickstartCall[1]).toContain('gui/501/com.discoclaw.discoclaw');
158
158
  });
159
159
  it('restart uses launchctl bootstrap in deferred when service is inactive', async () => {
160
160
  const { execFile } = await import('node:child_process');
@@ -171,7 +171,96 @@ describe('handleRestartCommand - macOS', () => {
171
171
  const bootstrapCall = calls.find((c) => c[0] === 'launchctl' && c[1].includes('bootstrap'));
172
172
  expect(bootstrapCall).toBeDefined();
173
173
  expect(bootstrapCall[1]).toContain('gui/501');
174
- expect(bootstrapCall[1]).toContain('/Users/testuser/Library/LaunchAgents/com.discoclaw.agent.plist');
174
+ expect(bootstrapCall[1]).toContain('/Users/testuser/Library/LaunchAgents/com.discoclaw.discoclaw.plist');
175
+ });
176
+ });
177
+ describe('handleRestartCommand - custom serviceName (Linux)', () => {
178
+ beforeEach(() => {
179
+ vi.clearAllMocks();
180
+ Object.defineProperty(process, 'platform', { value: 'linux', configurable: true });
181
+ });
182
+ afterEach(() => {
183
+ Object.defineProperty(process, 'platform', { value: savedPlatform, configurable: true });
184
+ });
185
+ it('default serviceName still targets discoclaw in systemctl args', async () => {
186
+ const { execFile } = await import('node:child_process');
187
+ await handleRestartCommand({ action: 'status' });
188
+ const calls = execFile.mock.calls;
189
+ const statusCall = calls.find((c) => c[0] === 'systemctl');
190
+ expect(statusCall[1]).toContain('discoclaw');
191
+ });
192
+ it('default restart reply still says "Restarting discoclaw..."', async () => {
193
+ const result = await handleRestartCommand({ action: 'restart' });
194
+ expect(result.reply).toBe('Restarting discoclaw... back in a moment.');
195
+ });
196
+ it('custom serviceName threads into systemctl status args', async () => {
197
+ const { execFile } = await import('node:child_process');
198
+ await handleRestartCommand({ action: 'status' }, { serviceName: 'discoclaw-beta' });
199
+ const calls = execFile.mock.calls;
200
+ const statusCall = calls.find((c) => c[0] === 'systemctl' && c[1].includes('status'));
201
+ expect(statusCall[1]).toContain('discoclaw-beta');
202
+ expect(statusCall[1]).not.toContain('discoclaw-beta'.replace('discoclaw-beta', 'discoclaw'));
203
+ });
204
+ it('custom serviceName threads into journalctl logs args', async () => {
205
+ const { execFile } = await import('node:child_process');
206
+ await handleRestartCommand({ action: 'logs' }, { serviceName: 'discoclaw-beta' });
207
+ const calls = execFile.mock.calls;
208
+ const logsCall = calls.find((c) => c[0] === 'journalctl');
209
+ expect(logsCall).toBeDefined();
210
+ expect(logsCall[1]).toContain('discoclaw-beta');
211
+ });
212
+ it('custom serviceName threads into systemctl restart args and reply string', async () => {
213
+ const { execFile } = await import('node:child_process');
214
+ const result = await handleRestartCommand({ action: 'restart' }, { serviceName: 'discoclaw-beta' });
215
+ expect(result.reply).toBe('Restarting discoclaw-beta... back in a moment.');
216
+ result.deferred();
217
+ const calls = execFile.mock.calls;
218
+ const restartCall = calls.find((c) => c[0] === 'systemctl' && c[1].includes('restart'));
219
+ expect(restartCall).toBeDefined();
220
+ expect(restartCall[1]).toContain('discoclaw-beta');
221
+ });
222
+ });
223
+ describe('handleRestartCommand - custom serviceName (macOS)', () => {
224
+ beforeEach(() => {
225
+ vi.clearAllMocks();
226
+ Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true });
227
+ vi.spyOn(process, 'getuid').mockReturnValue(501);
228
+ });
229
+ afterEach(() => {
230
+ Object.defineProperty(process, 'platform', { value: savedPlatform, configurable: true });
231
+ vi.restoreAllMocks();
232
+ });
233
+ it('custom serviceName threads into launchctl label and plist path', async () => {
234
+ const { execFile } = await import('node:child_process');
235
+ const result = await handleRestartCommand({ action: 'restart' }, { serviceName: 'discoclaw-beta' });
236
+ expect(result.reply).toBe('Restarting discoclaw-beta... back in a moment.');
237
+ result.deferred();
238
+ const calls = execFile.mock.calls;
239
+ const kickstartCall = calls.find((c) => c[0] === 'launchctl' && c[1].includes('kickstart'));
240
+ expect(kickstartCall).toBeDefined();
241
+ expect(kickstartCall[1]).toContain('gui/501/com.discoclaw.discoclaw-beta');
242
+ });
243
+ it('custom serviceName uses correct plist path on bootstrap', async () => {
244
+ const { execFile } = await import('node:child_process');
245
+ execFile.mockImplementationOnce((cmd, args, opts, cb) => {
246
+ const err = Object.assign(new Error('not loaded'), { code: 1 });
247
+ cb(err, '', '');
248
+ });
249
+ const result = await handleRestartCommand({ action: 'restart' }, { serviceName: 'discoclaw-beta' });
250
+ expect(result.reply).toBe('Starting discoclaw-beta...');
251
+ result.deferred();
252
+ const calls = execFile.mock.calls;
253
+ const bootstrapCall = calls.find((c) => c[0] === 'launchctl' && c[1].includes('bootstrap'));
254
+ expect(bootstrapCall).toBeDefined();
255
+ expect(bootstrapCall[1]).toContain('/Users/testuser/Library/LaunchAgents/com.discoclaw.discoclaw-beta.plist');
256
+ });
257
+ it('custom serviceName threads into launchctl list (status)', async () => {
258
+ const { execFile } = await import('node:child_process');
259
+ await handleRestartCommand({ action: 'status' }, { serviceName: 'discoclaw-beta' });
260
+ const calls = execFile.mock.calls;
261
+ const listCall = calls.find((c) => c[0] === 'launchctl' && c[1].includes('list'));
262
+ expect(listCall).toBeDefined();
263
+ expect(listCall[1]).toContain('com.discoclaw.discoclaw-beta');
175
264
  });
176
265
  });
177
266
  describe('handleRestartCommand - unsupported platform', () => {
@@ -35,7 +35,7 @@ function mapExitCode(err) {
35
35
  }
36
36
  const GIT_ENV = { ...process.env, GIT_TERMINAL_PROMPT: '0' };
37
37
  export async function handleUpdateCommand(cmd, opts = {}) {
38
- const { log, dataDir, userId, restartCmd, projectCwd, onProgress } = opts;
38
+ const { log, dataDir, userId, restartCmd, projectCwd, onProgress, serviceName } = opts;
39
39
  const progress = (msg) => {
40
40
  onProgress?.(msg);
41
41
  log?.info({}, `update-command: ${msg}`);
@@ -123,7 +123,7 @@ export async function handleUpdateCommand(cmd, opts = {}) {
123
123
  });
124
124
  }
125
125
  else {
126
- const [restartBin, restartArgList] = getRestartCmdArgs();
126
+ const [restartBin, restartArgList] = getRestartCmdArgs(serviceName);
127
127
  execFile(restartBin, restartArgList, (err) => {
128
128
  if (err)
129
129
  log?.error({ err }, 'update-command: restart failed');
@@ -180,7 +180,7 @@ export async function handleUpdateCommand(cmd, opts = {}) {
180
180
  });
181
181
  }
182
182
  else {
183
- const [restartBin, restartArgList] = getRestartCmdArgs();
183
+ const [restartBin, restartArgList] = getRestartCmdArgs(serviceName);
184
184
  execFile(restartBin, restartArgList, (err) => {
185
185
  if (err)
186
186
  log?.error({ err }, 'update-command: restart failed');
@@ -249,7 +249,7 @@ describe('handleUpdateCommand: apply', () => {
249
249
  const restartCall = calls.find(([cmd, args]) => cmd === 'launchctl' && args.includes('kickstart'));
250
250
  expect(restartCall).toBeDefined();
251
251
  expect(restartCall[1]).toContain('-k');
252
- expect(restartCall[1].some((a) => a.includes('com.discoclaw.agent'))).toBe(true);
252
+ expect(restartCall[1].some((a) => a.includes('com.discoclaw.discoclaw'))).toBe(true);
253
253
  }
254
254
  finally {
255
255
  Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true });
@@ -266,6 +266,17 @@ describe('handleUpdateCommand: apply', () => {
266
266
  expect(shCall).toBeDefined();
267
267
  expect(shCall[1]).toEqual(['-c', 'sudo systemctl restart discoclaw']);
268
268
  });
269
+ it('deferred passes custom serviceName to getRestartCmdArgs (git-managed)', async () => {
270
+ const { execFile } = await import('node:child_process');
271
+ const mock = mockAllSuccess();
272
+ execFile.mockImplementation(mock);
273
+ const result = await handleUpdateCommand({ action: 'apply' }, { serviceName: 'discoclaw-beta' });
274
+ result.deferred();
275
+ const calls = execFile.mock.calls;
276
+ const restartCall = calls.find(([cmd, args]) => cmd === 'systemctl' && args.includes('restart'));
277
+ expect(restartCall).toBeDefined();
278
+ expect(restartCall[1]).toEqual(['--user', 'restart', 'discoclaw-beta']);
279
+ });
269
280
  it('calls onProgress callback for each step', async () => {
270
281
  const { execFile } = await import('node:child_process');
271
282
  execFile.mockImplementation(mockAllSuccess());
@@ -340,4 +351,18 @@ describe('handleUpdateCommand: npm-managed mode', () => {
340
351
  expect(result.reply).toContain('failed');
341
352
  expect(result.deferred).toBeUndefined();
342
353
  });
354
+ it('deferred passes custom serviceName to getRestartCmdArgs (npm-managed)', async () => {
355
+ const { execFile } = await import('node:child_process');
356
+ execFile.mockImplementation((cmd, args, optsOrCb, maybeCb) => {
357
+ const cb = typeof optsOrCb === 'function' ? optsOrCb : maybeCb;
358
+ if (cb)
359
+ cb(null, '', '');
360
+ });
361
+ const result = await handleUpdateCommand({ action: 'apply' }, { serviceName: 'discoclaw-beta' });
362
+ result.deferred();
363
+ const calls = execFile.mock.calls;
364
+ const restartCall = calls.find(([cmd, args]) => cmd === 'systemctl' && args.includes('restart'));
365
+ expect(restartCall).toBeDefined();
366
+ expect(restartCall[1]).toEqual(['--user', 'restart', 'discoclaw-beta']);
367
+ });
343
368
  });
@@ -43,6 +43,9 @@ export function mapRuntimeErrorToUserMessage(raw) {
43
43
  return ('This channel is missing required context. Create/index the channel context file under content/discord/channels ' +
44
44
  'or disable DISCORD_REQUIRE_CHANNEL_CONTEXT.');
45
45
  }
46
+ if (lc.includes('prompt is too long') || lc.includes('context length exceeded') || lc.includes('context_length_exceeded')) {
47
+ return 'The conversation context exceeded the model\'s limit. Try a shorter message or start a new conversation.';
48
+ }
46
49
  if (!msg) {
47
50
  return 'An unexpected runtime error occurred with no additional detail.';
48
51
  }
@@ -36,4 +36,12 @@ describe('mapRuntimeErrorToUserMessage', () => {
36
36
  expect(msg).toContain('DISCOCLAW_STREAM_STALL_TIMEOUT_MS');
37
37
  expect(msg).not.toContain('ms /');
38
38
  });
39
+ it('maps "Prompt is too long" to context overflow user message', () => {
40
+ const msg = mapRuntimeErrorToUserMessage('Prompt is too long');
41
+ expect(msg).toBe('The conversation context exceeded the model\'s limit. Try a shorter message or start a new conversation.');
42
+ });
43
+ it('maps "context_length_exceeded" to context overflow user message', () => {
44
+ const msg = mapRuntimeErrorToUserMessage('context_length_exceeded');
45
+ expect(msg).toBe('The conversation context exceeded the model\'s limit. Try a shorter message or start a new conversation.');
46
+ });
39
47
  });
package/dist/index.js CHANGED
@@ -586,6 +586,7 @@ const botParams = {
586
586
  workspaceCwd,
587
587
  projectCwd: projectRoot,
588
588
  updateRestartCmd: process.env.DC_RESTART_CMD,
589
+ serviceName: cfg.serviceName,
589
590
  groupsDir,
590
591
  useGroupDirCwd,
591
592
  runtimeModel,
@@ -10,6 +10,16 @@ import { STDIN_THRESHOLD, tryParseJsonLine, createEventQueue, SubprocessTracker,
10
10
  import { extractTextFromUnknownEvent, extractResultText, extractImageFromUnknownEvent, extractResultContentBlocks, imageDedupeKey, stripToolUseBlocks, } from './cli-output-parsers.js';
11
11
  // Global subprocess tracker shared across all CLI adapters.
12
12
  const globalTracker = new SubprocessTracker();
13
+ const CONTEXT_OVERFLOW_PHRASES = [
14
+ 'prompt is too long',
15
+ 'context length exceeded',
16
+ 'context_length_exceeded',
17
+ 'context overflow',
18
+ ];
19
+ function isContextOverflowMessage(text) {
20
+ const lower = text.toLowerCase();
21
+ return CONTEXT_OVERFLOW_PHRASES.some((phrase) => lower.includes(phrase));
22
+ }
13
23
  function asCliLogLike(log) {
14
24
  if (!log || typeof log !== 'object')
15
25
  return undefined;
@@ -95,10 +105,19 @@ export function createCliRuntime(strategy, opts) {
95
105
  const onPoolAbort = () => { proc.kill?.(); };
96
106
  params.signal?.addEventListener('abort', onPoolAbort, { once: true });
97
107
  let fallback = false;
108
+ let contextOverflow = false;
98
109
  try {
99
110
  for await (const evt of proc.sendTurn(params.prompt, params.images)) {
100
111
  if (evt.type === 'error' && (evt.message.startsWith('long-running:') || evt.message.includes('hang detected'))) {
101
- pool.remove(params.sessionKey);
112
+ if (evt.message.includes('context overflow'))
113
+ contextOverflow = true;
114
+ pool.remove(params.sessionKey, contextOverflow ? 'context-overflow' : undefined);
115
+ fallback = true;
116
+ break;
117
+ }
118
+ if ((evt.type === 'text_delta' || evt.type === 'text_final') && isContextOverflowMessage(evt.text)) {
119
+ pool.remove(params.sessionKey, 'context-overflow');
120
+ contextOverflow = true;
102
121
  fallback = true;
103
122
  break;
104
123
  }
@@ -112,6 +131,10 @@ export function createCliRuntime(strategy, opts) {
112
131
  globalTracker.delete(sub);
113
132
  if (!fallback)
114
133
  return;
134
+ if (contextOverflow) {
135
+ cliLog?.info?.({ sessionKey: params.sessionKey }, 'multi-turn: context overflow, resetting session and retrying');
136
+ yield { type: 'text_delta', text: '*(Session reset — conversation context limit reached. Starting fresh.)*\n\n' };
137
+ }
115
138
  cliLog?.info?.('multi-turn: process failed, falling back to one-shot');
116
139
  }
117
140
  }
@@ -288,11 +288,23 @@ export class LongRunningProcess {
288
288
  }
289
289
  }
290
290
  }
291
+ isContextOverflow(text) {
292
+ const lower = text.toLowerCase();
293
+ return (lower.includes('prompt is too long') ||
294
+ lower.includes('context length exceeded') ||
295
+ lower.includes('context_length_exceeded'));
296
+ }
291
297
  finalizeTurn() {
292
298
  const raw = this.turnResultText.trim() || (this.turnMerged.trim() ? this.turnMerged.trimEnd() : '');
293
299
  const final = stripToolUseBlocks(raw);
294
- if (final)
295
- this.pushEvent({ type: 'text_final', text: final });
300
+ if (final) {
301
+ if (this.isContextOverflow(final)) {
302
+ this.pushEvent({ type: 'error', message: 'long-running: context overflow' });
303
+ }
304
+ else {
305
+ this.pushEvent({ type: 'text_final', text: final });
306
+ }
307
+ }
296
308
  this.pushDoneOnce();
297
309
  }
298
310
  handleExit() {
@@ -299,6 +299,23 @@ describe('LongRunningProcess', () => {
299
299
  expect(callArgs).not.toContain('--max-budget-usd');
300
300
  expect(callArgs).not.toContain('--append-system-prompt');
301
301
  });
302
+ it('context overflow result emits error event instead of text_final', async () => {
303
+ const mock = createMockSubprocess();
304
+ execa.mockReturnValue(mock.proc);
305
+ const proc = new LongRunningProcess(baseOpts);
306
+ proc.spawn();
307
+ queueMicrotask(() => {
308
+ mock.stdout.emit('data', JSON.stringify({ type: 'result', result: 'Prompt is too long' }) + '\n');
309
+ });
310
+ const events = [];
311
+ for await (const evt of proc.sendTurn('tell me everything')) {
312
+ events.push(evt);
313
+ }
314
+ expect(events.find((e) => e.type === 'error')?.message).toBe('long-running: context overflow');
315
+ expect(events.find((e) => e.type === 'done')).toBeTruthy();
316
+ expect(events.find((e) => e.type === 'text_final')).toBeUndefined();
317
+ expect(proc.state).toBe('idle');
318
+ });
302
319
  it('sendTurn without images writes plain string content (no regression)', async () => {
303
320
  const mock = createMockSubprocess();
304
321
  execa.mockReturnValue(mock.proc);
@@ -59,12 +59,12 @@ export class ProcessPool {
59
59
  return proc;
60
60
  }
61
61
  /** Kill and remove a specific session's process. */
62
- remove(sessionKey) {
62
+ remove(sessionKey, reason) {
63
63
  const proc = this.pool.get(sessionKey);
64
64
  if (proc) {
65
65
  this.pool.delete(sessionKey);
66
66
  proc.kill();
67
- this.log?.info({ sessionKey }, 'process-pool: removed process');
67
+ this.log?.info({ sessionKey, reason }, 'process-pool: removed process');
68
68
  }
69
69
  }
70
70
  /** Kill all processes (shutdown cleanup). */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "discoclaw",
3
- "version": "0.2.1",
3
+ "version": "0.2.4",
4
4
  "description": "Minimal Discord bridge routing messages to AI runtimes",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -5,6 +5,7 @@ Wants=network-online.target
5
5
 
6
6
  [Service]
7
7
  Type=simple
8
+ Environment=PATH=%h/.local/bin:%h/.npm-global/bin:/usr/local/bin:/usr/bin:/bin
8
9
  WorkingDirectory=%h/code/discoclaw
9
10
  # Keep secrets local; this file should exist on the host (not committed).
10
11
  EnvironmentFile=%h/code/discoclaw/.env