skimpyclaw 0.3.3 → 0.3.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -74,16 +74,10 @@ See [docs/architecture.md](docs/architecture.md) for runtime flow and startup se
74
74
 
75
75
  ## Quick Start
76
76
 
77
- **Install:**
78
-
79
- ```bash
80
- npm install -g skimpyclaw
81
- ```
82
-
83
- **Run onboarding:**
84
-
85
77
  ```bash
78
+ pnpm add -g skimpyclaw
86
79
  skimpyclaw onboard
80
+ skimpyclaw start --daemon
87
81
  ```
88
82
 
89
83
  Onboarding validates your Telegram token, provider auth, and creates:
@@ -91,14 +85,6 @@ Onboarding validates your Telegram token, provider auth, and creates:
91
85
  - `~/.skimpyclaw/config.json`
92
86
  - `~/.skimpyclaw/agents/main/*.md` (from templates)
93
87
 
94
- **Start:**
95
-
96
- ```bash
97
- skimpyclaw start
98
- # or run as launchd daemon on macOS:
99
- skimpyclaw start --daemon
100
- ```
101
-
102
88
  **Stop/Restart daemon (macOS):**
103
89
 
104
90
  ```bash
@@ -217,7 +203,7 @@ dist/ # Compiled output + built dashboard assets
217
203
  | [docs/chat-commands.md](docs/chat-commands.md) | Telegram/Discord bot commands |
218
204
  | [docs/skills.md](docs/skills.md) | Skills system, built-in skills, creating custom skills |
219
205
  | [docs/data-storage.md](docs/data-storage.md) | File layout, audit log format, security notes |
220
- | [docs/setup-guide.md](docs/setup-guide.md) | Step-by-step installation and setup guide |
206
+ | [Setup Guide](https://docs.skimpyclaw.xyz/guide/setup-guide.html) | Step-by-step installation and setup guide |
221
207
  | [docs/troubleshooting.md](docs/troubleshooting.md) | Common issues and solutions |
222
208
 
223
209
  ## Development
@@ -1225,3 +1225,22 @@ describe('Skills endpoints', () => {
1225
1225
  expect(res.json()).toHaveProperty('deleted', true);
1226
1226
  });
1227
1227
  });
1228
+ describe('Office endpoints', () => {
1229
+ it('GET /api/dashboard/office-status returns office status', async () => {
1230
+ const res = await inject({ method: 'GET', url: '/api/dashboard/office-status' });
1231
+ expect(res.statusCode).toBe(200);
1232
+ const json = res.json();
1233
+ expect(json).toHaveProperty('state');
1234
+ expect(json).toHaveProperty('currentTask');
1235
+ expect(json).toHaveProperty('updatedAt');
1236
+ expect(['working', 'thinking', 'waiting', 'offline']).toContain(json.state);
1237
+ });
1238
+ it('GET /api/dashboard/office-scene.png returns image or 404', async () => {
1239
+ const res = await inject({ method: 'GET', url: '/api/dashboard/office-scene.png' });
1240
+ // In test env the image file may not exist, so accept 200 or 404
1241
+ expect([200, 404]).toContain(res.statusCode);
1242
+ if (res.statusCode === 200) {
1243
+ expect(res.headers['content-type']).toBe('image/png');
1244
+ }
1245
+ });
1246
+ });
@@ -1,5 +1,5 @@
1
1
  import { beforeEach, describe, expect, it, vi } from 'vitest';
2
- const { mockLoadConfig, mockCheckNodeVersion, mockCheckPackageManagerAvailable, mockCheckTypeScriptCompile, mockCheckConfigExistsAndValidJson, mockCheckRequiredEnvVars, mockCheckEnvVarPatterns, mockCheckAllowedPathsWritable, mockCheckProviderAuth, mockCheckTelegramToken, mockCheckDiscordToken, mockCheckBrowserBinaryIfEnabled, mockCheckVoiceDependencies, mockCheckMcpConfig, mockCheckGatewayHostBindable, mockCheckSkimpyclawDirWritable, mockCheckPortAvailability, mockCheckSandboxAvailable, } = vi.hoisted(() => ({
2
+ const { mockLoadConfig, mockCheckNodeVersion, mockCheckPackageManagerAvailable, mockCheckTypeScriptCompile, mockCheckConfigExistsAndValidJson, mockCheckRequiredEnvVars, mockCheckEnvVarPatterns, mockCheckAllowedPathsWritable, mockCheckProviderAuth, mockCheckTelegramToken, mockCheckDiscordToken, mockCheckBrowserBinaryIfEnabled, mockCheckPlaywrightIfBrowserEnabled, mockCheckVoiceDependencies, mockCheckMcpConfig, mockCheckGatewayHostBindable, mockCheckSkimpyclawDirWritable, mockCheckPortAvailability, mockCheckSandboxAvailable, } = vi.hoisted(() => ({
3
3
  mockLoadConfig: vi.fn(),
4
4
  mockCheckNodeVersion: vi.fn(),
5
5
  mockCheckPackageManagerAvailable: vi.fn(),
@@ -12,6 +12,7 @@ const { mockLoadConfig, mockCheckNodeVersion, mockCheckPackageManagerAvailable,
12
12
  mockCheckTelegramToken: vi.fn(),
13
13
  mockCheckDiscordToken: vi.fn(),
14
14
  mockCheckBrowserBinaryIfEnabled: vi.fn(),
15
+ mockCheckPlaywrightIfBrowserEnabled: vi.fn(),
15
16
  mockCheckVoiceDependencies: vi.fn(),
16
17
  mockCheckMcpConfig: vi.fn(),
17
18
  mockCheckGatewayHostBindable: vi.fn(),
@@ -34,6 +35,7 @@ vi.mock('../doctor/checks.js', () => ({
34
35
  checkTelegramToken: mockCheckTelegramToken,
35
36
  checkDiscordToken: mockCheckDiscordToken,
36
37
  checkBrowserBinaryIfEnabled: mockCheckBrowserBinaryIfEnabled,
38
+ checkPlaywrightIfBrowserEnabled: mockCheckPlaywrightIfBrowserEnabled,
37
39
  checkVoiceDependencies: mockCheckVoiceDependencies,
38
40
  checkMcpConfig: mockCheckMcpConfig,
39
41
  checkGatewayHostBindable: mockCheckGatewayHostBindable,
@@ -75,6 +77,7 @@ describe('doctor runner', () => {
75
77
  mockCheckTelegramToken.mockReset();
76
78
  mockCheckDiscordToken.mockReset();
77
79
  mockCheckBrowserBinaryIfEnabled.mockReset();
80
+ mockCheckPlaywrightIfBrowserEnabled.mockReset();
78
81
  mockCheckVoiceDependencies.mockReset();
79
82
  mockCheckMcpConfig.mockReset();
80
83
  mockCheckGatewayHostBindable.mockReset();
@@ -92,6 +95,7 @@ describe('doctor runner', () => {
92
95
  mockCheckTelegramToken.mockResolvedValue(okCheck('telegram_token_valid', 'channels'));
93
96
  mockCheckDiscordToken.mockResolvedValue(okCheck('discord_token_valid', 'channels'));
94
97
  mockCheckBrowserBinaryIfEnabled.mockResolvedValue(okCheck('browser_binary_available', 'runtime'));
98
+ mockCheckPlaywrightIfBrowserEnabled.mockResolvedValue(okCheck('playwright_installed', 'runtime', 'Browser tools disabled'));
95
99
  mockCheckVoiceDependencies.mockResolvedValue(okCheck('voice_dependencies', 'runtime', 'Voice disabled'));
96
100
  mockCheckMcpConfig.mockResolvedValue(okCheck('mcp_config', 'runtime', 'MCP tools not configured'));
97
101
  mockCheckGatewayHostBindable.mockResolvedValue(okCheck('gateway_host_bindable', 'runtime', '127.0.0.1 (always available)'));
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,172 @@
1
+ import { describe, expect, it, beforeEach, afterEach } from 'vitest';
2
+ import { join } from 'path';
3
+ import { mkdtempSync, writeFileSync, rmSync } from 'fs';
4
+ import { tmpdir } from 'os';
5
+ // Import the real functions (no mocks needed — these are pure fs reads)
6
+ import { detectPackageManager, buildValidationCommand } from '../code-agents/executor.js';
7
+ describe('detectPackageManager', () => {
8
+ let tempDir;
9
+ beforeEach(() => {
10
+ tempDir = mkdtempSync(join(tmpdir(), 'pm-detect-'));
11
+ });
12
+ afterEach(() => {
13
+ rmSync(tempDir, { recursive: true, force: true });
14
+ });
15
+ it('returns pnpm as fallback when no indicators present', () => {
16
+ expect(detectPackageManager(tempDir)).toBe('pnpm');
17
+ });
18
+ it('detects yarn from packageManager field', () => {
19
+ writeFileSync(join(tempDir, 'package.json'), JSON.stringify({
20
+ name: 'test',
21
+ packageManager: 'yarn@4.1.0',
22
+ }));
23
+ expect(detectPackageManager(tempDir)).toBe('yarn');
24
+ });
25
+ it('detects pnpm from packageManager field', () => {
26
+ writeFileSync(join(tempDir, 'package.json'), JSON.stringify({
27
+ name: 'test',
28
+ packageManager: 'pnpm@9.0.0',
29
+ }));
30
+ expect(detectPackageManager(tempDir)).toBe('pnpm');
31
+ });
32
+ it('detects npm from packageManager field', () => {
33
+ writeFileSync(join(tempDir, 'package.json'), JSON.stringify({
34
+ name: 'test',
35
+ packageManager: 'npm@10.0.0',
36
+ }));
37
+ expect(detectPackageManager(tempDir)).toBe('npm');
38
+ });
39
+ it('detects bun from packageManager field', () => {
40
+ writeFileSync(join(tempDir, 'package.json'), JSON.stringify({
41
+ name: 'test',
42
+ packageManager: 'bun@1.0.0',
43
+ }));
44
+ expect(detectPackageManager(tempDir)).toBe('bun');
45
+ });
46
+ it('detects yarn from yarn.lock', () => {
47
+ writeFileSync(join(tempDir, 'package.json'), JSON.stringify({ name: 'test' }));
48
+ writeFileSync(join(tempDir, 'yarn.lock'), '');
49
+ expect(detectPackageManager(tempDir)).toBe('yarn');
50
+ });
51
+ it('detects pnpm from pnpm-lock.yaml', () => {
52
+ writeFileSync(join(tempDir, 'package.json'), JSON.stringify({ name: 'test' }));
53
+ writeFileSync(join(tempDir, 'pnpm-lock.yaml'), '');
54
+ expect(detectPackageManager(tempDir)).toBe('pnpm');
55
+ });
56
+ it('detects npm from package-lock.json', () => {
57
+ writeFileSync(join(tempDir, 'package.json'), JSON.stringify({ name: 'test' }));
58
+ writeFileSync(join(tempDir, 'package-lock.json'), '{}');
59
+ expect(detectPackageManager(tempDir)).toBe('npm');
60
+ });
61
+ it('detects bun from bun.lockb', () => {
62
+ writeFileSync(join(tempDir, 'package.json'), JSON.stringify({ name: 'test' }));
63
+ writeFileSync(join(tempDir, 'bun.lockb'), '');
64
+ expect(detectPackageManager(tempDir)).toBe('bun');
65
+ });
66
+ it('detects bun from bun.lock', () => {
67
+ writeFileSync(join(tempDir, 'package.json'), JSON.stringify({ name: 'test' }));
68
+ writeFileSync(join(tempDir, 'bun.lock'), '');
69
+ expect(detectPackageManager(tempDir)).toBe('bun');
70
+ });
71
+ it('packageManager field takes priority over lockfile', () => {
72
+ writeFileSync(join(tempDir, 'package.json'), JSON.stringify({
73
+ name: 'test',
74
+ packageManager: 'yarn@4.1.0',
75
+ }));
76
+ // Also has a pnpm lockfile — packageManager should win
77
+ writeFileSync(join(tempDir, 'pnpm-lock.yaml'), '');
78
+ expect(detectPackageManager(tempDir)).toBe('yarn');
79
+ });
80
+ it('handles malformed package.json gracefully', () => {
81
+ writeFileSync(join(tempDir, 'package.json'), 'not valid json{{{');
82
+ expect(detectPackageManager(tempDir)).toBe('pnpm');
83
+ });
84
+ // wp-calypso case: yarn project with yarn.lock
85
+ it('detects yarn for wp-calypso-like project', () => {
86
+ writeFileSync(join(tempDir, 'package.json'), JSON.stringify({
87
+ name: 'wp-calypso',
88
+ scripts: { build: 'yarn build:client', test: 'jest' },
89
+ }));
90
+ writeFileSync(join(tempDir, 'yarn.lock'), '# yarn lockfile v1\n');
91
+ expect(detectPackageManager(tempDir)).toBe('yarn');
92
+ });
93
+ });
94
+ describe('buildValidationCommand', () => {
95
+ let tempDir;
96
+ beforeEach(() => {
97
+ tempDir = mkdtempSync(join(tmpdir(), 'pm-cmd-'));
98
+ });
99
+ afterEach(() => {
100
+ rmSync(tempDir, { recursive: true, force: true });
101
+ });
102
+ it('returns pnpm build && pnpm test for pnpm project with both scripts', () => {
103
+ writeFileSync(join(tempDir, 'package.json'), JSON.stringify({
104
+ name: 'test',
105
+ scripts: { build: 'tsc', test: 'vitest' },
106
+ }));
107
+ writeFileSync(join(tempDir, 'pnpm-lock.yaml'), '');
108
+ expect(buildValidationCommand(tempDir)).toBe('pnpm build && pnpm test');
109
+ });
110
+ it('returns yarn build && yarn test for yarn project with both scripts', () => {
111
+ writeFileSync(join(tempDir, 'package.json'), JSON.stringify({
112
+ name: 'test',
113
+ scripts: { build: 'tsc', test: 'jest' },
114
+ }));
115
+ writeFileSync(join(tempDir, 'yarn.lock'), '');
116
+ expect(buildValidationCommand(tempDir)).toBe('yarn build && yarn test');
117
+ });
118
+ it('returns npm run build && npm run test for npm project with both scripts', () => {
119
+ writeFileSync(join(tempDir, 'package.json'), JSON.stringify({
120
+ name: 'test',
121
+ scripts: { build: 'tsc', test: 'jest' },
122
+ }));
123
+ writeFileSync(join(tempDir, 'package-lock.json'), '{}');
124
+ expect(buildValidationCommand(tempDir)).toBe('npm run build && npm run test');
125
+ });
126
+ it('returns bun build && bun test for bun project with both scripts', () => {
127
+ writeFileSync(join(tempDir, 'package.json'), JSON.stringify({
128
+ name: 'test',
129
+ scripts: { build: 'bun build', test: 'bun test' },
130
+ }));
131
+ writeFileSync(join(tempDir, 'bun.lockb'), '');
132
+ expect(buildValidationCommand(tempDir)).toBe('bun build && bun test');
133
+ });
134
+ it('returns only test command when build script is missing', () => {
135
+ writeFileSync(join(tempDir, 'package.json'), JSON.stringify({
136
+ name: 'test',
137
+ scripts: { test: 'jest' },
138
+ }));
139
+ writeFileSync(join(tempDir, 'yarn.lock'), '');
140
+ expect(buildValidationCommand(tempDir)).toBe('yarn test');
141
+ });
142
+ it('returns only build command when test script is missing', () => {
143
+ writeFileSync(join(tempDir, 'package.json'), JSON.stringify({
144
+ name: 'test',
145
+ scripts: { build: 'tsc' },
146
+ }));
147
+ writeFileSync(join(tempDir, 'yarn.lock'), '');
148
+ expect(buildValidationCommand(tempDir)).toBe('yarn build');
149
+ });
150
+ it('falls back to both commands when no package.json exists', () => {
151
+ // No package.json, no lockfile → pnpm fallback
152
+ expect(buildValidationCommand(tempDir)).toBe('pnpm build && pnpm test');
153
+ });
154
+ it('falls back to both commands when scripts object is empty', () => {
155
+ writeFileSync(join(tempDir, 'package.json'), JSON.stringify({
156
+ name: 'test',
157
+ scripts: {},
158
+ }));
159
+ writeFileSync(join(tempDir, 'yarn.lock'), '');
160
+ expect(buildValidationCommand(tempDir)).toBe('yarn build && yarn test');
161
+ });
162
+ // wp-calypso scenario
163
+ it('generates yarn commands for wp-calypso-like project', () => {
164
+ writeFileSync(join(tempDir, 'package.json'), JSON.stringify({
165
+ name: 'wp-calypso',
166
+ packageManager: 'yarn@4.5.0',
167
+ scripts: { build: 'yarn workspaces foreach build', test: 'jest' },
168
+ }));
169
+ writeFileSync(join(tempDir, 'yarn.lock'), '# yarn lockfile v1\n');
170
+ expect(buildValidationCommand(tempDir)).toBe('yarn build && yarn test');
171
+ });
172
+ });
package/dist/api.js CHANGED
@@ -785,6 +785,33 @@ export function registerDashboardAPI(fastify, config) {
785
785
  }
786
786
  return { denied: true, id, command: approval.command };
787
787
  });
788
+ // --- Office Status ---
789
+ fastify.get('/api/dashboard/office-status', async (request) => {
790
+ // Phase 1: return mock data
791
+ return {
792
+ state: 'working',
793
+ currentTask: 'Processing dashboard updates',
794
+ updatedAt: new Date().toISOString(),
795
+ };
796
+ });
797
+ // --- Office Scene Image ---
798
+ fastify.get('/api/dashboard/office-scene.png', async (_request, reply) => {
799
+ const home = homedir();
800
+ const candidates = [
801
+ join(home, '.skimpyclaw', 'office-preview-check.png'),
802
+ join(home, '.skimpyclaw', 'office-offline.png'),
803
+ // Container/workspace fallbacks (e.g. Codespaces, Docker where HOME=/workspace)
804
+ '/workspace/.skimpyclaw/office-preview-check.png',
805
+ '/workspace/.skimpyclaw/office-offline.png',
806
+ ].filter((p, i, arr) => arr.indexOf(p) === i); // dedupe if home IS /workspace
807
+ for (const candidate of candidates) {
808
+ if (existsSync(candidate)) {
809
+ const buffer = readFileSync(candidate);
810
+ return reply.type('image/png').send(buffer);
811
+ }
812
+ }
813
+ return reply.code(404).send({ error: 'Office scene image not found' });
814
+ });
788
815
  // --- Digests ---
789
816
  fastify.get('/api/dashboard/digests', async () => {
790
817
  const digests = getDigests();
package/dist/cli.js CHANGED
@@ -780,10 +780,18 @@ function sandboxImageExists(runtime, image) {
780
780
  return spawnSync(runtime, ['image', 'inspect', image], { encoding: 'utf-8' }).status === 0;
781
781
  }
782
782
  function resolveSandboxDir() {
783
+ // 1. Check CWD (user is in repo root)
783
784
  const cwdSandbox = join(process.cwd(), 'sandbox');
784
785
  if (existsSync(join(cwdSandbox, 'Dockerfile'))) {
785
786
  return cwdSandbox;
786
787
  }
788
+ // 2. Check relative to package root (global/npm install)
789
+ const thisFile = fileURLToPath(import.meta.url);
790
+ const pkgRoot = join(thisFile, '..', '..'); // dist/src/cli.js -> repo root
791
+ const pkgSandbox = join(pkgRoot, 'sandbox');
792
+ if (existsSync(join(pkgSandbox, 'Dockerfile'))) {
793
+ return pkgSandbox;
794
+ }
787
795
  return null;
788
796
  }
789
797
  function parseSandboxOption(args, flag) {
@@ -1,4 +1,20 @@
1
1
  import type { CodeAgentBackgroundOptions, ValidationResult } from './types.js';
2
+ /** Supported JS package managers. */
3
+ export type PackageManager = 'pnpm' | 'yarn' | 'npm' | 'bun';
4
+ /**
5
+ * Detect the package manager for a project directory.
6
+ * Detection order (first match wins):
7
+ * 1. package.json `packageManager` field (e.g. "yarn@4.1.0")
8
+ * 2. Lockfile presence: yarn.lock → yarn, pnpm-lock.yaml → pnpm, bun.lockb / bun.lock → bun, package-lock.json → npm
9
+ * 3. Fallback: 'pnpm' (SkimpyClaw default)
10
+ */
11
+ export declare function detectPackageManager(workdir: string): PackageManager;
12
+ /**
13
+ * Build the validation command for a project directory.
14
+ * Checks for `build` and `test` scripts in package.json, then runs them
15
+ * with the detected package manager. Falls back to `<pm> build && <pm> test`.
16
+ */
17
+ export declare function buildValidationCommand(workdir: string): string;
2
18
  /** Run build/test validation. Shared by solo agents and team orchestrator. */
3
19
  export declare function runValidation(workdir: string): Promise<ValidationResult>;
4
20
  /** Background execution of a coding agent. Updates task status throughout. */
@@ -1,6 +1,6 @@
1
1
  // Code Agent Executor - Background execution logic
2
2
  import { spawn, exec } from 'child_process';
3
- import { createWriteStream } from 'fs';
3
+ import { createWriteStream, existsSync, readFileSync } from 'fs';
4
4
  import { join } from 'path';
5
5
  // SKIMPYCLAW_ROOT for log paths
6
6
  const SKIMPYCLAW_ROOT = join(import.meta.dirname || process.cwd(), '..', '..');
@@ -12,10 +12,83 @@ import { startTrace, addEvent, endTrace } from '../audit.js';
12
12
  import { buildUsageRecord, recordUsage } from '../usage.js';
13
13
  import { ensureContainer, SANDBOX_DEFAULTS, getRuntime } from '../sandbox/index.js';
14
14
  const CANCELLED_MESSAGE = 'Cancelled by user';
15
+ /**
16
+ * Detect the package manager for a project directory.
17
+ * Detection order (first match wins):
18
+ * 1. package.json `packageManager` field (e.g. "yarn@4.1.0")
19
+ * 2. Lockfile presence: yarn.lock → yarn, pnpm-lock.yaml → pnpm, bun.lockb / bun.lock → bun, package-lock.json → npm
20
+ * 3. Fallback: 'pnpm' (SkimpyClaw default)
21
+ */
22
+ export function detectPackageManager(workdir) {
23
+ // 1. Check package.json packageManager field
24
+ try {
25
+ const pkgPath = join(workdir, 'package.json');
26
+ if (existsSync(pkgPath)) {
27
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
28
+ if (typeof pkg.packageManager === 'string') {
29
+ const name = pkg.packageManager.split('@')[0].toLowerCase();
30
+ if (name === 'yarn')
31
+ return 'yarn';
32
+ if (name === 'pnpm')
33
+ return 'pnpm';
34
+ if (name === 'npm')
35
+ return 'npm';
36
+ if (name === 'bun')
37
+ return 'bun';
38
+ }
39
+ }
40
+ }
41
+ catch { /* ignore parse errors, fall through */ }
42
+ // 2. Check lockfiles
43
+ if (existsSync(join(workdir, 'yarn.lock')))
44
+ return 'yarn';
45
+ if (existsSync(join(workdir, 'pnpm-lock.yaml')))
46
+ return 'pnpm';
47
+ if (existsSync(join(workdir, 'bun.lockb')) || existsSync(join(workdir, 'bun.lock')))
48
+ return 'bun';
49
+ if (existsSync(join(workdir, 'package-lock.json')))
50
+ return 'npm';
51
+ // 3. Fallback
52
+ return 'pnpm';
53
+ }
54
+ /**
55
+ * Build the validation command for a project directory.
56
+ * Checks for `build` and `test` scripts in package.json, then runs them
57
+ * with the detected package manager. Falls back to `<pm> build && <pm> test`.
58
+ */
59
+ export function buildValidationCommand(workdir) {
60
+ const pm = detectPackageManager(workdir);
61
+ const run = pm === 'npm' ? 'npm run' : pm;
62
+ // Check which scripts exist in package.json
63
+ let hasBuild = false;
64
+ let hasTest = false;
65
+ try {
66
+ const pkgPath = join(workdir, 'package.json');
67
+ if (existsSync(pkgPath)) {
68
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
69
+ const scripts = pkg.scripts || {};
70
+ hasBuild = !!scripts.build;
71
+ hasTest = !!scripts.test;
72
+ }
73
+ }
74
+ catch { /* ignore */ }
75
+ const parts = [];
76
+ if (hasBuild)
77
+ parts.push(`${run} build`);
78
+ if (hasTest)
79
+ parts.push(`${run} test`);
80
+ // If neither build nor test scripts exist, still try — the scripts
81
+ // might be defined in a workspace root or the commands may work anyway
82
+ if (parts.length === 0) {
83
+ parts.push(`${run} build`, `${run} test`);
84
+ }
85
+ return parts.join(' && ');
86
+ }
15
87
  /** Run build/test validation. Shared by solo agents and team orchestrator. */
16
88
  export function runValidation(workdir) {
89
+ const cmd = buildValidationCommand(workdir);
17
90
  return new Promise((resolve) => {
18
- exec('pnpm build && pnpm test', {
91
+ exec(cmd, {
19
92
  cwd: workdir,
20
93
  timeout: VALIDATE_TIMEOUT_MS,
21
94
  maxBuffer: 5 * 1024 * 1024,
@@ -242,8 +315,9 @@ export async function runCodeAgentBackground(id, agent, task, workdir, validate,
242
315
  caTask.outputPreview = agentOutput.slice(0, 500);
243
316
  caTask.liveOutput = undefined;
244
317
  writeCodeAgentTask(caTask);
318
+ const validationCmd = buildValidationCommand(workdir);
245
319
  const runValidationPromise = () => new Promise((res) => {
246
- const validationProc = exec('pnpm build && pnpm test', {
320
+ const validationProc = exec(validationCmd, {
247
321
  cwd: workdir,
248
322
  timeout: VALIDATE_TIMEOUT_MS,
249
323
  maxBuffer: 5 * 1024 * 1024,
@@ -2,6 +2,7 @@
2
2
  import { execSync } from 'child_process';
3
3
  import { resolve, join } from 'path';
4
4
  import { homedir } from 'os';
5
+ import { buildValidationCommand } from './executor.js';
5
6
  // Resolve CLI paths once at import time so spawn doesn't get ENOENT
6
7
  function resolveCliPath(name) {
7
8
  try {
@@ -113,7 +114,7 @@ export function buildCodeAgentArgs(input) {
113
114
  '--mcp-config', playwrightMcp,
114
115
  ...toolArgs,
115
116
  '--max-turns', maxTurns,
116
- '--append-system-prompt', 'Output text only. Never use say or TTS. Focus on the coding task. Run pnpm build && pnpm test to verify changes.',
117
+ '--append-system-prompt', `Output text only. Never use say or TTS. Focus on the coding task. Run ${buildValidationCommand(input.workdir || process.cwd())} to verify changes.`,
117
118
  ];
118
119
  // Only pass model to Claude CLI if it's not a known non-Claude model.
119
120
  // GPT/Codex/Kimi/o-series models would be rejected by the Claude CLI.
@@ -152,6 +153,10 @@ export function buildTeamNotification(task, getChildTask) {
152
153
  lines.push(` ${childIcon} ${child.id} (${childDur}): ${subtask}`);
153
154
  }
154
155
  }
156
+ // Synthesis result
157
+ if (task.outputPreview) {
158
+ lines.push(`\nResult: ${task.outputPreview}`);
159
+ }
155
160
  // Errors
156
161
  if (task.error && task.error !== 'Validation failed') {
157
162
  lines.push(`\nError: ${task.error}`);
@@ -169,8 +174,7 @@ export function buildSoloNotification(task) {
169
174
  const validation = task.validationPassed ? ' Build/tests pass.' : '';
170
175
  let message = `✅ Coding agent ${task.id} completed (${dur}).${validation}\n\nTask: ${taskPreview}`;
171
176
  if (task.outputPreview) {
172
- const preview = task.outputPreview.slice(0, 300);
173
- message += `\n\nResult: ${preview}`;
177
+ message += `\n\nResult: ${task.outputPreview}`;
174
178
  }
175
179
  return message;
176
180
  }
package/dist/cron.js CHANGED
@@ -4,6 +4,7 @@ import { exec } from 'child_process';
4
4
  import { existsSync, mkdirSync, appendFileSync, readFileSync, watch } from 'fs';
5
5
  import { join } from 'path';
6
6
  import { getLogsDir, getConfigPath, loadConfig } from './config.js';
7
+ import { homedir } from 'node:os';
7
8
  import { runAgentTurn } from './agent.js';
8
9
  import { startTrace, addEvent, endTrace } from './audit.js';
9
10
  import { sendActiveChannelProactiveMessage, sendActiveChannelProactiveVoice, getActiveChannelId } from './channels.js';
@@ -135,7 +136,13 @@ async function executeJobPayload(jobDef, config) {
135
136
  if (jobDef.payload.kind === 'agentTurn') {
136
137
  const message = expandVariables(resolveMessageSource(jobDef.payload.message || ''));
137
138
  appendCronLogLine(jobDef.id, `Agent turn started (prompt: ${message.slice(0, 100)}...)`);
138
- const response = await runAgentTurn(config.agents.default, message, config, jobDef.model, jobDef.payload.tools, undefined, {
139
+ const defaultTools = {
140
+ enabled: true,
141
+ allowedPaths: [`${homedir()}/.skimpyclaw`],
142
+ maxIterations: 30,
143
+ bashTimeout: 15000,
144
+ };
145
+ const response = await runAgentTurn(config.agents.default, message, config, jobDef.model, jobDef.payload.tools || defaultTools, undefined, {
139
146
  channel: getActiveChannelId() || 'telegram',
140
147
  trigger: 'cron',
141
148
  sessionId: jobDef.id,