skimpyclaw 0.2.0 → 0.3.2
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/dist/__tests__/cron.test.js +51 -1
- package/dist/__tests__/sandbox-runtime.test.js +37 -1
- package/dist/__tests__/setup.test.js +2 -2
- package/dist/agent.js +1 -0
- package/dist/cli.js +17 -5
- package/dist/cron.d.ts +6 -0
- package/dist/cron.js +43 -1
- package/dist/providers/anthropic.js +1 -1
- package/dist/providers/codex.js +1 -1
- package/dist/providers/openai.js +1 -1
- package/dist/sandbox/index.d.ts +1 -1
- package/dist/sandbox/index.js +1 -1
- package/dist/sandbox/runtime.d.ts +5 -0
- package/dist/sandbox/runtime.js +20 -0
- package/dist/service.js +20 -12
- package/dist/setup.d.ts +1 -0
- package/dist/setup.js +41 -6
- package/dist/tools/execute-context.d.ts +2 -0
- package/dist/voice.js +9 -2
- package/package.json +1 -1
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import { parseDualOutput } from '../cron.js';
|
|
2
|
+
import { parseDualOutput, validatePrReviewOutput } from '../cron.js';
|
|
3
3
|
describe('parseDualOutput', () => {
|
|
4
4
|
it('returns full response as text when no delimiters present', () => {
|
|
5
5
|
const response = 'Hello, this is a regular response with no delimiters.';
|
|
@@ -64,3 +64,53 @@ Voice content here
|
|
|
64
64
|
expect(result.text).toBe(fullResponse);
|
|
65
65
|
});
|
|
66
66
|
});
|
|
67
|
+
describe('validatePrReviewOutput', () => {
|
|
68
|
+
it('returns null for NO_CANDIDATES result', () => {
|
|
69
|
+
const output = 'No PRs found.\n[PR_REVIEW_RESULT: NO_CANDIDATES]';
|
|
70
|
+
expect(validatePrReviewOutput(output)).toBeNull();
|
|
71
|
+
});
|
|
72
|
+
it('returns null when candidates were reviewed with code_with_agent', () => {
|
|
73
|
+
const output = 'Reviewed 3 PRs.\n[PR_REVIEW_RESULT: CANDIDATES=3 CODE_AGENT_CALLS=3 BLOCKED=0]';
|
|
74
|
+
expect(validatePrReviewOutput(output)).toBeNull();
|
|
75
|
+
});
|
|
76
|
+
it('returns null when all candidates are blocked', () => {
|
|
77
|
+
const output = 'All blocked.\n[PR_REVIEW_RESULT: CANDIDATES=2 CODE_AGENT_CALLS=0 BLOCKED=2]';
|
|
78
|
+
expect(validatePrReviewOutput(output)).toBeNull();
|
|
79
|
+
});
|
|
80
|
+
it('returns alert when candidates exist but no code_with_agent calls', () => {
|
|
81
|
+
const output = 'Inline review.\n[PR_REVIEW_RESULT: CANDIDATES=3 CODE_AGENT_CALLS=0 BLOCKED=0]';
|
|
82
|
+
const result = validatePrReviewOutput(output);
|
|
83
|
+
expect(result).not.toBeNull();
|
|
84
|
+
expect(result).toContain('code_with_agent was never called');
|
|
85
|
+
expect(result).toContain('3 PR candidate');
|
|
86
|
+
});
|
|
87
|
+
it('returns alert when result line is missing entirely', () => {
|
|
88
|
+
const output = 'The agent just rambled about PRs without following the prompt.';
|
|
89
|
+
const result = validatePrReviewOutput(output);
|
|
90
|
+
expect(result).not.toBeNull();
|
|
91
|
+
expect(result).toContain('Missing [PR_REVIEW_RESULT]');
|
|
92
|
+
});
|
|
93
|
+
it('returns null when some candidates reviewed and some blocked', () => {
|
|
94
|
+
const output = '[PR_REVIEW_RESULT: CANDIDATES=4 CODE_AGENT_CALLS=2 BLOCKED=2]';
|
|
95
|
+
expect(validatePrReviewOutput(output)).toBeNull();
|
|
96
|
+
});
|
|
97
|
+
it('returns alert when partially blocked but zero calls', () => {
|
|
98
|
+
const output = '[PR_REVIEW_RESULT: CANDIDATES=3 CODE_AGENT_CALLS=0 BLOCKED=1]';
|
|
99
|
+
const result = validatePrReviewOutput(output);
|
|
100
|
+
expect(result).not.toBeNull();
|
|
101
|
+
expect(result).toContain('code_with_agent was never called');
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
describe('cron job tool injection', () => {
|
|
105
|
+
it('isCronJob field exists on ExecuteToolContext', () => {
|
|
106
|
+
// Verify the field is part of the type (compile-time check via assignment)
|
|
107
|
+
const ctx = {
|
|
108
|
+
isCronJob: true,
|
|
109
|
+
};
|
|
110
|
+
expect(ctx.isCronJob).toBe(true);
|
|
111
|
+
});
|
|
112
|
+
it('isCronJob defaults to undefined when not set', () => {
|
|
113
|
+
const ctx = {};
|
|
114
|
+
expect(ctx.isCronJob).toBeUndefined();
|
|
115
|
+
});
|
|
116
|
+
});
|
|
@@ -4,7 +4,7 @@ const { mockSpawn, mockSpawnSync } = vi.hoisted(() => ({
|
|
|
4
4
|
mockSpawnSync: vi.fn().mockReturnValue({ status: 0, stdout: '', stderr: '' }),
|
|
5
5
|
}));
|
|
6
6
|
vi.mock('child_process', () => ({ spawn: mockSpawn, spawnSync: mockSpawnSync }));
|
|
7
|
-
import { createContainer, execInContainer, removeContainer, isContainerRunning, cleanupOrphans, setRuntime, resetRuntime, } from '../sandbox/runtime.js';
|
|
7
|
+
import { createContainer, execInContainer, removeContainer, isContainerRunning, cleanupOrphans, setRuntime, resetRuntime, probeRuntime, } from '../sandbox/runtime.js';
|
|
8
8
|
function fakeChild(exitCode, stdout = '', stderr = '', opts) {
|
|
9
9
|
const stdoutCallbacks = [];
|
|
10
10
|
const stderrCallbacks = [];
|
|
@@ -117,6 +117,42 @@ describe('sandbox/runtime', () => {
|
|
|
117
117
|
expect(await isContainerRunning('ctr')).toBe(false);
|
|
118
118
|
});
|
|
119
119
|
});
|
|
120
|
+
describe('probeRuntime', () => {
|
|
121
|
+
it('returns preferred runtime when available', () => {
|
|
122
|
+
mockSpawnSync.mockReturnValue({ status: 0 });
|
|
123
|
+
expect(probeRuntime('docker')).toBe('docker');
|
|
124
|
+
expect(mockSpawnSync).toHaveBeenCalledWith('docker', ['--version'], { stdio: 'ignore' });
|
|
125
|
+
});
|
|
126
|
+
it('falls back to auto-detect when preferred is unavailable', () => {
|
|
127
|
+
mockSpawnSync.mockImplementation((cmd) => {
|
|
128
|
+
// preferred 'docker' fails, but 'container' succeeds
|
|
129
|
+
if (cmd === 'docker')
|
|
130
|
+
return { status: 1 };
|
|
131
|
+
if (cmd === 'container')
|
|
132
|
+
return { status: 0 };
|
|
133
|
+
return { status: 1 };
|
|
134
|
+
});
|
|
135
|
+
expect(probeRuntime('docker')).toBe('container');
|
|
136
|
+
});
|
|
137
|
+
it('returns null when no runtime is available', () => {
|
|
138
|
+
mockSpawnSync.mockReturnValue({ status: 1 });
|
|
139
|
+
expect(probeRuntime('docker')).toBeNull();
|
|
140
|
+
});
|
|
141
|
+
it('auto-detects without preferred runtime', () => {
|
|
142
|
+
mockSpawnSync.mockImplementation((cmd) => {
|
|
143
|
+
if (cmd === 'container')
|
|
144
|
+
return { status: 0 };
|
|
145
|
+
return { status: 1 };
|
|
146
|
+
});
|
|
147
|
+
expect(probeRuntime()).toBe('container');
|
|
148
|
+
});
|
|
149
|
+
it('prefers container over docker in auto-detect', () => {
|
|
150
|
+
mockSpawnSync.mockReturnValue({ status: 0 });
|
|
151
|
+
expect(probeRuntime()).toBe('container');
|
|
152
|
+
// First call should be to 'container'
|
|
153
|
+
expect(mockSpawnSync.mock.calls[0][0]).toBe('container');
|
|
154
|
+
});
|
|
155
|
+
});
|
|
120
156
|
describe('cleanupOrphans', () => {
|
|
121
157
|
it('lists containers, filters by prefix, removes matches', async () => {
|
|
122
158
|
let callCount = 0;
|
|
@@ -133,8 +133,8 @@ describe('setup config generation', () => {
|
|
|
133
133
|
},
|
|
134
134
|
});
|
|
135
135
|
expect(config.cron.jobs).toHaveLength(2);
|
|
136
|
-
expect(config.cron.jobs[0].id).toBe('
|
|
137
|
-
expect(config.cron.jobs[1].id).toBe('
|
|
136
|
+
expect(config.cron.jobs[0].id).toBe('tech-digest');
|
|
137
|
+
expect(config.cron.jobs[1].id).toBe('weather');
|
|
138
138
|
expect(config.cron.jobs[1].schedule.tz).toBe('America/New_York');
|
|
139
139
|
expect(config.cron.jobs[1].payload.message).toContain('Austin, TX');
|
|
140
140
|
expect(config.skills.enabled).toBe(true);
|
package/dist/agent.js
CHANGED
|
@@ -155,6 +155,7 @@ export async function runAgentTurn(agentId, userMessage, config, modelOverride,
|
|
|
155
155
|
approverUsername: context?.metadata?.username,
|
|
156
156
|
sandboxConfig: config.sandbox,
|
|
157
157
|
sessionId: context?.sessionId || String(chatIdNum ?? 'default'),
|
|
158
|
+
isCronJob: context?.metadata?.isCronJob === true,
|
|
158
159
|
};
|
|
159
160
|
const runTurn = async () => {
|
|
160
161
|
if (toolConfig?.enabled) {
|
package/dist/cli.js
CHANGED
|
@@ -6,7 +6,7 @@ import { spawn, spawnSync } from 'child_process';
|
|
|
6
6
|
import { fileURLToPath } from 'url';
|
|
7
7
|
import { loadConfig, loadRawConfig, getConfigPath, saveConfig } from './config.js';
|
|
8
8
|
import { startRuntime } from './service.js';
|
|
9
|
-
import { runSetup } from './setup.js';
|
|
9
|
+
import { runSetup, renderGatewayPlist } from './setup.js';
|
|
10
10
|
import { runDoctor as runDoctorCommand } from './doctor/index.js';
|
|
11
11
|
import { executeTool, getToolDefinitions, BUILTIN_TOOL_DEFINITIONS, BROWSER_TOOL_DEFINITION } from './tools.js';
|
|
12
12
|
import { formatModelSelectionError, getModelSelectionUsage, resolveModelSelection } from './model-selection.js';
|
|
@@ -129,10 +129,22 @@ function startDaemon() {
|
|
|
129
129
|
console.error('Daemon control is only supported on macOS with launchctl.');
|
|
130
130
|
return 1;
|
|
131
131
|
}
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
132
|
+
// Regenerate plist to point at the current binary (pnpm changes path on upgrade)
|
|
133
|
+
try {
|
|
134
|
+
const plistContent = renderGatewayPlist();
|
|
135
|
+
const plistDir = join(homedir(), 'Library', 'LaunchAgents');
|
|
136
|
+
if (!existsSync(plistDir))
|
|
137
|
+
mkdirSync(plistDir, { recursive: true });
|
|
138
|
+
writeFileSync(LAUNCHD_PLIST, plistContent);
|
|
139
|
+
}
|
|
140
|
+
catch (err) {
|
|
141
|
+
// If template is missing (e.g. corrupted install), fall back to existing plist
|
|
142
|
+
if (!existsSync(LAUNCHD_PLIST)) {
|
|
143
|
+
console.error(`Launchd plist not found: ${LAUNCHD_PLIST}`);
|
|
144
|
+
console.error('Run `skimpyclaw onboard` first.');
|
|
145
|
+
return 1;
|
|
146
|
+
}
|
|
147
|
+
console.warn(`Warning: could not regenerate plist (${err instanceof Error ? err.message : err}), using existing`);
|
|
136
148
|
}
|
|
137
149
|
const result = runLaunchctl(['load', LAUNCHD_PLIST]);
|
|
138
150
|
if (!result.ok && !result.output.includes('already loaded')) {
|
package/dist/cron.d.ts
CHANGED
|
@@ -23,6 +23,12 @@ export declare function parseDualOutput(response: string): {
|
|
|
23
23
|
voice: string | null;
|
|
24
24
|
text: string;
|
|
25
25
|
};
|
|
26
|
+
/**
|
|
27
|
+
* Post-run guard for the pr-review cron job.
|
|
28
|
+
* Validates that the agent actually used code_with_agent when PRs were found.
|
|
29
|
+
* Non-throwing — logs a warning and returns an alert message (or null if OK).
|
|
30
|
+
*/
|
|
31
|
+
export declare function validatePrReviewOutput(output: string): string | null;
|
|
26
32
|
export declare function getCronJobs(): {
|
|
27
33
|
id: string;
|
|
28
34
|
name: string;
|
package/dist/cron.js
CHANGED
|
@@ -139,7 +139,7 @@ async function executeJobPayload(jobDef, config) {
|
|
|
139
139
|
channel: getActiveChannelId() || 'telegram',
|
|
140
140
|
trigger: 'cron',
|
|
141
141
|
sessionId: jobDef.id,
|
|
142
|
-
metadata: { jobName: jobDef.name },
|
|
142
|
+
metadata: { jobName: jobDef.name, isCronJob: true },
|
|
143
143
|
});
|
|
144
144
|
appendCronLogLine(jobDef.id, `Agent turn completed (${response.length} chars)`);
|
|
145
145
|
// Parse dual output (voice + text) if delimiters present
|
|
@@ -149,6 +149,19 @@ async function executeJobPayload(jobDef, config) {
|
|
|
149
149
|
}
|
|
150
150
|
// Use text portion for log output and notifications
|
|
151
151
|
logEntry.output = textPortion.slice(0, 5000);
|
|
152
|
+
// Post-run guard for pr-review job
|
|
153
|
+
if (jobDef.id === 'pr-review') {
|
|
154
|
+
const guardAlert = validatePrReviewOutput(textPortion);
|
|
155
|
+
if (guardAlert) {
|
|
156
|
+
appendCronLogLine(jobDef.id, guardAlert);
|
|
157
|
+
try {
|
|
158
|
+
await sendActiveChannelProactiveMessage(config, guardAlert);
|
|
159
|
+
}
|
|
160
|
+
catch {
|
|
161
|
+
// Non-critical
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
152
165
|
// Parse and save digest from the text portion
|
|
153
166
|
try {
|
|
154
167
|
parseAndSaveDigest(jobDef.id, jobDef.name, textPortion);
|
|
@@ -356,6 +369,35 @@ export function parseDualOutput(response) {
|
|
|
356
369
|
text: text || response,
|
|
357
370
|
};
|
|
358
371
|
}
|
|
372
|
+
/**
|
|
373
|
+
* Post-run guard for the pr-review cron job.
|
|
374
|
+
* Validates that the agent actually used code_with_agent when PRs were found.
|
|
375
|
+
* Non-throwing — logs a warning and returns an alert message (or null if OK).
|
|
376
|
+
*/
|
|
377
|
+
export function validatePrReviewOutput(output) {
|
|
378
|
+
// Check for the machine-readable result line
|
|
379
|
+
const resultMatch = output.match(/\[PR_REVIEW_RESULT:\s*(.+?)\]/);
|
|
380
|
+
if (!resultMatch) {
|
|
381
|
+
return '⚠️ PR Pre-Review: Missing [PR_REVIEW_RESULT] line in output. The agent may not have followed the prompt correctly.';
|
|
382
|
+
}
|
|
383
|
+
const resultLine = resultMatch[1].trim();
|
|
384
|
+
// NO_CANDIDATES is fine — nothing to review
|
|
385
|
+
if (resultLine === 'NO_CANDIDATES') {
|
|
386
|
+
return null;
|
|
387
|
+
}
|
|
388
|
+
// Parse CANDIDATES=N CODE_AGENT_CALLS=M BLOCKED=B
|
|
389
|
+
const candidatesMatch = resultLine.match(/CANDIDATES=(\d+)/);
|
|
390
|
+
const callsMatch = resultLine.match(/CODE_AGENT_CALLS=(\d+)/);
|
|
391
|
+
const blockedMatch = resultLine.match(/BLOCKED=(\d+)/);
|
|
392
|
+
const candidates = candidatesMatch ? parseInt(candidatesMatch[1], 10) : 0;
|
|
393
|
+
const calls = callsMatch ? parseInt(callsMatch[1], 10) : 0;
|
|
394
|
+
const blocked = blockedMatch ? parseInt(blockedMatch[1], 10) : 0;
|
|
395
|
+
// If there were candidates but zero code_with_agent calls (and not all blocked), alert
|
|
396
|
+
if (candidates > 0 && calls === 0 && blocked < candidates) {
|
|
397
|
+
return `⚠️ PR Pre-Review: Found ${candidates} PR candidate(s) but code_with_agent was never called (blocked: ${blocked}). The agent likely wrote inline commentary instead of delegating.`;
|
|
398
|
+
}
|
|
399
|
+
return null;
|
|
400
|
+
}
|
|
359
401
|
function expandVariables(message) {
|
|
360
402
|
const now = new Date();
|
|
361
403
|
const date = now.toLocaleDateString('en-US', {
|
|
@@ -122,7 +122,7 @@ export async function chatWithToolsAnthropic(params) {
|
|
|
122
122
|
const modelId = stripProvider(options.model);
|
|
123
123
|
const maxIterations = toolConfig.maxIterations || 20;
|
|
124
124
|
// Resolve tools once at start of agent loop
|
|
125
|
-
const includeSpawn = !!(toolContext?.
|
|
125
|
+
const includeSpawn = !!(toolContext?.fullConfig && (toolContext?.chatId || toolContext?.isCronJob));
|
|
126
126
|
const toolDefs = await getToolDefinitions(toolConfig, { includeSpawnSubagent: includeSpawn, projects: toolContext?.fullConfig?.projects });
|
|
127
127
|
// Enable prompt caching for system + tools
|
|
128
128
|
const cacheEnabled = config.models?.promptCaching !== false;
|
package/dist/providers/codex.js
CHANGED
|
@@ -286,7 +286,7 @@ export async function chatWithToolsCodex(params) {
|
|
|
286
286
|
}
|
|
287
287
|
// Get tool definitions
|
|
288
288
|
const { getToolDefinitions } = await import('../tools.js');
|
|
289
|
-
const includeSpawn = !!(toolContext?.
|
|
289
|
+
const includeSpawn = !!(toolContext?.fullConfig && (toolContext?.chatId || toolContext?.isCronJob));
|
|
290
290
|
const toolDefs = await getToolDefinitions(toolConfig, {
|
|
291
291
|
includeSpawnSubagent: includeSpawn,
|
|
292
292
|
includeMcp: false,
|
package/dist/providers/openai.js
CHANGED
|
@@ -121,7 +121,7 @@ export async function chatWithToolsOpenAI(params, provider) {
|
|
|
121
121
|
const modelId = stripProvider(options.model, openaiClients);
|
|
122
122
|
const maxIterations = toolConfig.maxIterations || 20;
|
|
123
123
|
// Resolve tools once at start
|
|
124
|
-
const includeSpawn = !!(toolContext?.
|
|
124
|
+
const includeSpawn = !!(toolContext?.fullConfig && (toolContext?.chatId || toolContext?.isCronJob));
|
|
125
125
|
const toolDefs = await getToolDefinitions(toolConfig, {
|
|
126
126
|
includeSpawnSubagent: includeSpawn,
|
|
127
127
|
includeMcp: false,
|
package/dist/sandbox/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { createContainer, execInContainer, removeContainer, isContainerRunning, cleanupOrphans, setRuntime, getRuntime, resetRuntime } from './runtime.js';
|
|
1
|
+
export { createContainer, execInContainer, removeContainer, isContainerRunning, cleanupOrphans, setRuntime, getRuntime, resetRuntime, probeRuntime } from './runtime.js';
|
|
2
2
|
export type { ContainerOpts, ExecOpts, ExecResult } from './runtime.js';
|
|
3
3
|
export { ensureContainer, releaseContainer, pruneIdle, releaseAll, SANDBOX_DEFAULTS } from './manager.js';
|
|
4
4
|
export { sandboxBash, sandboxReadFile, sandboxWriteFile, sandboxListDir, sandboxGlob } from './bridge.js';
|
package/dist/sandbox/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { createContainer, execInContainer, removeContainer, isContainerRunning, cleanupOrphans, setRuntime, getRuntime, resetRuntime } from './runtime.js';
|
|
1
|
+
export { createContainer, execInContainer, removeContainer, isContainerRunning, cleanupOrphans, setRuntime, getRuntime, resetRuntime, probeRuntime } from './runtime.js';
|
|
2
2
|
export { ensureContainer, releaseContainer, pruneIdle, releaseAll, SANDBOX_DEFAULTS } from './manager.js';
|
|
3
3
|
export { sandboxBash, sandboxReadFile, sandboxWriteFile, sandboxListDir, sandboxGlob } from './bridge.js';
|
|
4
4
|
export { validateMountPaths, isBlockedPath, translatePath } from './mount-security.js';
|
|
@@ -24,6 +24,11 @@ export interface ExecResult {
|
|
|
24
24
|
export declare function setRuntime(runtime: 'container' | 'docker'): void;
|
|
25
25
|
/** Get the container runtime binary, auto-detecting if not explicitly set. */
|
|
26
26
|
export declare function getRuntime(): string;
|
|
27
|
+
/**
|
|
28
|
+
* Check if a usable container runtime is available.
|
|
29
|
+
* Returns the runtime name if found, null otherwise. Never throws.
|
|
30
|
+
*/
|
|
31
|
+
export declare function probeRuntime(preferred?: string): string | null;
|
|
27
32
|
/** Reset runtime detection (for testing). */
|
|
28
33
|
export declare function resetRuntime(): void;
|
|
29
34
|
export declare function createContainer(name: string, opts: ContainerOpts): Promise<void>;
|
package/dist/sandbox/runtime.js
CHANGED
|
@@ -23,6 +23,26 @@ export function getRuntime() {
|
|
|
23
23
|
}
|
|
24
24
|
return runtimeBinary;
|
|
25
25
|
}
|
|
26
|
+
/**
|
|
27
|
+
* Check if a usable container runtime is available.
|
|
28
|
+
* Returns the runtime name if found, null otherwise. Never throws.
|
|
29
|
+
*/
|
|
30
|
+
export function probeRuntime(preferred) {
|
|
31
|
+
// If a preferred runtime is specified, check that one first
|
|
32
|
+
if (preferred) {
|
|
33
|
+
const result = spawnSync(preferred, ['--version'], { stdio: 'ignore' });
|
|
34
|
+
if (result.status === 0)
|
|
35
|
+
return preferred;
|
|
36
|
+
}
|
|
37
|
+
// Auto-detect: prefer Apple Containers, fall back to Docker
|
|
38
|
+
if (spawnSync('container', ['--version'], { stdio: 'ignore' }).status === 0) {
|
|
39
|
+
return 'container';
|
|
40
|
+
}
|
|
41
|
+
if (spawnSync('docker', ['--version'], { stdio: 'ignore' }).status === 0) {
|
|
42
|
+
return 'docker';
|
|
43
|
+
}
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
26
46
|
/** Reset runtime detection (for testing). */
|
|
27
47
|
export function resetRuntime() {
|
|
28
48
|
runtimeBinary = null;
|
package/dist/service.js
CHANGED
|
@@ -5,26 +5,34 @@ import { initActiveChannel, startActiveChannel, stopActiveChannel } from './chan
|
|
|
5
5
|
import { initProviders } from './agent.js';
|
|
6
6
|
import { initLangfuse, shutdownLangfuse } from './langfuse.js';
|
|
7
7
|
import { restoreCodeAgentTasks, setCodeAgentConfig } from './tools.js';
|
|
8
|
-
import { releaseAll, cleanupOrphans, setRuntime } from './sandbox/index.js';
|
|
8
|
+
import { releaseAll, cleanupOrphans, setRuntime, probeRuntime } from './sandbox/index.js';
|
|
9
9
|
export async function startRuntime(config) {
|
|
10
10
|
const smokeTest = process.env.SKIMPYCLAW_SMOKE_TEST === '1';
|
|
11
11
|
initLangfuse(config);
|
|
12
12
|
initProviders(config);
|
|
13
13
|
restoreCodeAgentTasks();
|
|
14
14
|
setCodeAgentConfig(config);
|
|
15
|
-
// Initialize sandbox runtime if configured
|
|
16
|
-
if (config.sandbox?.runtime) {
|
|
17
|
-
setRuntime(config.sandbox.runtime);
|
|
18
|
-
}
|
|
19
|
-
// Clean up orphaned sandbox containers from previous runs
|
|
15
|
+
// Initialize sandbox runtime if configured — auto-disable if no runtime available
|
|
20
16
|
if (config.sandbox?.enabled) {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
17
|
+
const detected = probeRuntime(config.sandbox.runtime);
|
|
18
|
+
if (detected) {
|
|
19
|
+
setRuntime(detected);
|
|
20
|
+
if (detected !== config.sandbox.runtime) {
|
|
21
|
+
console.log(`[sandbox] Configured runtime "${config.sandbox.runtime}" not found, using "${detected}" instead`);
|
|
22
|
+
}
|
|
23
|
+
// Clean up orphaned sandbox containers from previous runs
|
|
24
|
+
try {
|
|
25
|
+
const count = await cleanupOrphans();
|
|
26
|
+
if (count > 0)
|
|
27
|
+
console.log(`[sandbox] Cleaned up ${count} orphaned container(s)`);
|
|
28
|
+
}
|
|
29
|
+
catch (err) {
|
|
30
|
+
console.warn('[sandbox] Failed to clean up orphaned containers:', err instanceof Error ? err.message : err);
|
|
31
|
+
}
|
|
25
32
|
}
|
|
26
|
-
|
|
27
|
-
console.warn('[sandbox]
|
|
33
|
+
else {
|
|
34
|
+
console.warn('[sandbox] No container runtime found (docker/container not installed). Sandbox disabled.');
|
|
35
|
+
config.sandbox.enabled = false;
|
|
28
36
|
}
|
|
29
37
|
}
|
|
30
38
|
const port = smokeTest ? (parseInt(process.env.SKIMPYCLAW_SMOKE_PORT || '19999', 10)) : config.gateway.port;
|
package/dist/setup.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
interface SetupOptions {
|
|
2
2
|
dryRun?: boolean;
|
|
3
3
|
}
|
|
4
|
+
export declare function renderGatewayPlist(): string;
|
|
4
5
|
type ProviderChoice = 'anthropic-api' | 'anthropic-oauth' | 'openai-api' | 'codex-oauth' | 'minimax-api' | 'kimi-api';
|
|
5
6
|
interface ProviderSecrets {
|
|
6
7
|
anthropicKey?: string;
|
package/dist/setup.js
CHANGED
|
@@ -128,7 +128,7 @@ function maskInput(input) {
|
|
|
128
128
|
return '****';
|
|
129
129
|
return input.slice(0, 4) + '****' + input.slice(-4);
|
|
130
130
|
}
|
|
131
|
-
function renderGatewayPlist() {
|
|
131
|
+
export function renderGatewayPlist() {
|
|
132
132
|
if (!existsSync(GATEWAY_PLIST_TEMPLATE)) {
|
|
133
133
|
throw new Error(`Gateway launchd template not found: ${GATEWAY_PLIST_TEMPLATE}`);
|
|
134
134
|
}
|
|
@@ -226,8 +226,8 @@ function buildStarterCronJobs(starters) {
|
|
|
226
226
|
const jobs = [];
|
|
227
227
|
if (starters.cronTechNews) {
|
|
228
228
|
jobs.push({
|
|
229
|
-
id: '
|
|
230
|
-
name: 'Tech News
|
|
229
|
+
id: 'tech-digest',
|
|
230
|
+
name: 'Tech News',
|
|
231
231
|
schedule: {
|
|
232
232
|
kind: 'cron',
|
|
233
233
|
expr: '0 8 * * *',
|
|
@@ -235,14 +235,21 @@ function buildStarterCronJobs(starters) {
|
|
|
235
235
|
},
|
|
236
236
|
payload: {
|
|
237
237
|
kind: 'agentTurn',
|
|
238
|
-
message: '
|
|
238
|
+
message: 'Fetch today\'s top 10 Hacker News stories. Reply with title, URL, and 1-line summary for each item.',
|
|
239
|
+
tools: {
|
|
240
|
+
enabled: true,
|
|
241
|
+
allowedPaths: [`${homedir()}/.skimpyclaw`],
|
|
242
|
+
maxIterations: 30,
|
|
243
|
+
bashTimeout: 30000,
|
|
244
|
+
browser: { enabled: true, headless: true },
|
|
245
|
+
},
|
|
239
246
|
},
|
|
240
247
|
});
|
|
241
248
|
}
|
|
242
249
|
if (starters.cronWeather) {
|
|
243
250
|
jobs.push({
|
|
244
|
-
id: '
|
|
245
|
-
name: 'Weather
|
|
251
|
+
id: 'weather',
|
|
252
|
+
name: 'Weather',
|
|
246
253
|
schedule: {
|
|
247
254
|
kind: 'cron',
|
|
248
255
|
expr: '0 7 * * *',
|
|
@@ -251,6 +258,13 @@ function buildStarterCronJobs(starters) {
|
|
|
251
258
|
payload: {
|
|
252
259
|
kind: 'agentTurn',
|
|
253
260
|
message: `Check current weather and today forecast for ${starters.weatherLocation}. Keep it concise: current temp/conditions, highs/lows, precipitation chance, and 1 recommendation.`,
|
|
261
|
+
tools: {
|
|
262
|
+
enabled: true,
|
|
263
|
+
allowedPaths: [`${homedir()}/.skimpyclaw`],
|
|
264
|
+
maxIterations: 30,
|
|
265
|
+
bashTimeout: 30000,
|
|
266
|
+
browser: { enabled: true, headless: true },
|
|
267
|
+
},
|
|
254
268
|
},
|
|
255
269
|
});
|
|
256
270
|
}
|
|
@@ -488,6 +502,13 @@ export function buildSetupConfig(input) {
|
|
|
488
502
|
allowFrom: [parseInt(input.telegramId, 10) || input.telegramId],
|
|
489
503
|
dailyNotesDir: '${HOME}/.skimpyclaw/Daily Notes',
|
|
490
504
|
defaultAllowedPaths: allPaths,
|
|
505
|
+
tools: {
|
|
506
|
+
enabled: true,
|
|
507
|
+
allowedPaths: allPaths,
|
|
508
|
+
maxIterations: 100,
|
|
509
|
+
bashTimeout: 15000,
|
|
510
|
+
...(features.browser ? { browser: { type: 'chromium', enabled: true, headless: true } } : {}),
|
|
511
|
+
},
|
|
491
512
|
},
|
|
492
513
|
discord: {
|
|
493
514
|
enabled: useDiscord,
|
|
@@ -495,6 +516,13 @@ export function buildSetupConfig(input) {
|
|
|
495
516
|
allowFrom: useDiscord ? [input.discordUserId || ''] : [],
|
|
496
517
|
defaultAllowedPaths: allPaths,
|
|
497
518
|
...(input.discordDefaultChannelId ? { defaultChannelId: input.discordDefaultChannelId } : {}),
|
|
519
|
+
tools: {
|
|
520
|
+
enabled: true,
|
|
521
|
+
allowedPaths: allPaths,
|
|
522
|
+
maxIterations: 100,
|
|
523
|
+
bashTimeout: 15000,
|
|
524
|
+
...(features.browser ? { browser: { type: 'chromium', enabled: true, headless: false } } : {}),
|
|
525
|
+
},
|
|
498
526
|
},
|
|
499
527
|
},
|
|
500
528
|
cron: {
|
|
@@ -517,6 +545,13 @@ export function buildSetupConfig(input) {
|
|
|
517
545
|
defaultProvider: 'macos',
|
|
518
546
|
providers: {
|
|
519
547
|
macos: { tts: { voice: 'Samantha' } },
|
|
548
|
+
...(input.selectedProviders.has('openai-api') ? {
|
|
549
|
+
openai: {
|
|
550
|
+
apiKey: '${OPENAI_API_KEY}',
|
|
551
|
+
baseURL: 'https://api.openai.com/v1',
|
|
552
|
+
stt: { model: 'whisper-1' },
|
|
553
|
+
},
|
|
554
|
+
} : {}),
|
|
520
555
|
},
|
|
521
556
|
channels: {
|
|
522
557
|
telegram: { enabled: true, acceptVoice: true, sendVoice: true },
|
|
@@ -23,6 +23,8 @@ export interface ExecuteToolContext {
|
|
|
23
23
|
trigger?: string;
|
|
24
24
|
/** Agent ID for usage tracking */
|
|
25
25
|
agentId?: string;
|
|
26
|
+
/** True when this context is from a cron job — enables spawn tools even without a chatId */
|
|
27
|
+
isCronJob?: boolean;
|
|
26
28
|
/** Sandbox configuration for containerized tool execution */
|
|
27
29
|
sandboxConfig?: import('../types.js').SandboxConfig;
|
|
28
30
|
/** Session ID for sandbox container mapping */
|
package/dist/voice.js
CHANGED
|
@@ -190,11 +190,18 @@ function getSTTProvider(config) {
|
|
|
190
190
|
const isApiBackedSttProvider = (name, provider) => {
|
|
191
191
|
if (!provider)
|
|
192
192
|
return false;
|
|
193
|
-
// macOS voice provider is TTS-only and must never be used for transcription.
|
|
194
193
|
const normalizedName = name.trim().toLowerCase();
|
|
194
|
+
// macOS is TTS-only
|
|
195
195
|
if (normalizedName === 'macos')
|
|
196
196
|
return false;
|
|
197
|
-
|
|
197
|
+
// Explicit stt config — always good
|
|
198
|
+
if (provider.stt)
|
|
199
|
+
return true;
|
|
200
|
+
// Only OpenAI has a Whisper-compatible transcription endpoint by default.
|
|
201
|
+
// Other providers (elevenlabs, etc.) need explicit stt config to be used for STT.
|
|
202
|
+
if (normalizedName === 'openai' && provider.apiKey)
|
|
203
|
+
return true;
|
|
204
|
+
return false;
|
|
198
205
|
};
|
|
199
206
|
if (config.defaultProvider) {
|
|
200
207
|
const preferredName = config.defaultProvider;
|