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 +3 -0
- package/dist/cli/daemon-installer.js +47 -2
- package/dist/cli/daemon-installer.test.js +89 -1
- package/dist/config.js +1 -0
- package/dist/config.test.js +14 -0
- package/dist/discord/actions-crons.js +7 -1
- package/dist/discord/actions-crons.test.js +42 -0
- package/dist/discord/message-coordinator.js +2 -0
- package/dist/discord/restart-command.js +14 -14
- package/dist/discord/restart-command.test.js +92 -3
- package/dist/discord/update-command.js +3 -3
- package/dist/discord/update-command.test.js +26 -1
- package/dist/discord/user-errors.js +3 -0
- package/dist/discord/user-errors.test.js +8 -0
- package/dist/index.js +1 -0
- package/dist/runtime/cli-adapter.js +24 -1
- package/dist/runtime/long-running-process.js +14 -2
- package/dist/runtime/long-running-process.test.js +17 -0
- package/dist/runtime/process-pool.js +2 -2
- package/package.json +1 -1
- package/systemd/discoclaw.service +1 -0
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,
|
package/dist/config.test.js
CHANGED
|
@@ -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
|
|
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',
|
|
37
|
-
logsCmd: ['journalctl', ['--user', '-u',
|
|
38
|
-
checkActiveCmd: ['systemctl', ['--user', 'status',
|
|
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',
|
|
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
|
|
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
|
-
?
|
|
118
|
-
:
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
@@ -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
|
-
|
|
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.
|
|
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
|
@@ -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
|