claude-ws 0.4.8 → 0.4.9-beta.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.
Files changed (33) hide show
  1. package/eslint.config.mjs +14 -0
  2. package/next.config.ts +8 -1
  3. package/package.json +11 -4
  4. package/packages/agentic-sdk/src/routes/projects/route.ts +2 -2
  5. package/packages/agentic-sdk/src/services/agent-factory/upload-analysis-and-import.ts +20 -7
  6. package/packages/agentic-sdk/src/services/agent-factory/upload-filesystem-helpers.ts +98 -15
  7. package/packages/agentic-sdk/src/services/attempt/attempt-creation-orchestrator.ts +10 -6
  8. package/packages/agentic-sdk/src/services/force-create-project-and-task.ts +4 -1
  9. package/packages/agentic-sdk/src/services/project/project-crud.ts +1 -1
  10. package/scripts/build-test-pm2.sh +78 -0
  11. package/server.ts +24 -6
  12. package/src/app/[locale]/page.tsx +40 -7
  13. package/src/app/api/agent-factory/projects/[projectId]/sync/route.ts +81 -1
  14. package/src/app/api/attempts/route.ts +4 -0
  15. package/src/app/api/projects/route.ts +4 -2
  16. package/src/app/api/sync/minio/pull/route.ts +130 -0
  17. package/src/app/api/sync/minio/push/route.ts +131 -0
  18. package/src/app/api/tasks/route.ts +3 -2
  19. package/src/components/header/project-selector.tsx +1 -1
  20. package/src/components/kanban/create-task-dialog.tsx +7 -1
  21. package/src/components/ui/popover.tsx +1 -1
  22. package/src/hooks/template/CLAUDE.template.md +20 -0
  23. package/src/hooks/template/hooks/.env.example +10 -0
  24. package/src/hooks/template/hooks/minio-pull-sync.ts +270 -0
  25. package/src/hooks/template/hooks/minio-push-sync.ts +219 -0
  26. package/src/hooks/template/settings.json +28 -0
  27. package/src/lib/agent-manager.ts +1 -1
  28. package/src/lib/api-hook-url.ts +74 -0
  29. package/src/lib/minio-pull-queue.ts +1110 -0
  30. package/src/lib/minio-push-queue.ts +939 -0
  31. package/src/lib/project-utils.ts +119 -0
  32. package/src/stores/project-store-crud-api-actions.ts +1 -1
  33. package/src/stores/project-store.ts +1 -1
package/eslint.config.mjs CHANGED
@@ -5,6 +5,20 @@ import nextTs from "eslint-config-next/typescript";
5
5
  const eslintConfig = defineConfig([
6
6
  ...nextVitals,
7
7
  ...nextTs,
8
+ {
9
+ rules: {
10
+ "@typescript-eslint/no-explicit-any": "off",
11
+ "@typescript-eslint/no-require-imports": "off",
12
+ "react-hooks/preserve-manual-memoization": "off",
13
+ "react-hooks/refs": "off",
14
+ "react-hooks/set-state-in-effect": "off",
15
+ "react-hooks/immutability": "off",
16
+ "react-hooks/purity": "off",
17
+ "react-hooks/static-components": "off",
18
+ "@next/next/no-html-link-for-pages": "off",
19
+ "prefer-const": "off",
20
+ },
21
+ },
8
22
  // Override default ignores of eslint-config-next.
9
23
  globalIgnores([
10
24
  // Default ignores of eslint-config-next:
package/next.config.ts CHANGED
@@ -13,7 +13,14 @@ const nextConfig: NextConfig = {
13
13
  // Enable gzip compression for responses
14
14
  compress: true,
15
15
  // Transpile xterm packages for proper CSS/ESM handling
16
- transpilePackages: ['@xterm/xterm', '@xterm/addon-fit', '@xterm/addon-web-links', '@pierre/diffs'],
16
+ transpilePackages: [
17
+ '@xterm/xterm', '@xterm/addon-fit', '@xterm/addon-web-links', '@pierre/diffs',
18
+ // Force Turbopack to compile these as a single bundle to avoid
19
+ // "module factory not available" HMR errors with shared @lezer/common
20
+ '@codemirror/state', '@codemirror/view', '@codemirror/language',
21
+ '@lezer/common', '@lezer/highlight', '@lezer/lr',
22
+ '@uiw/react-codemirror',
23
+ ],
17
24
  outputFileTracingRoot: path.join(__dirname),
18
25
  outputFileTracingIncludes: {
19
26
  '/': ['./src/**/*'],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-ws",
3
- "version": "0.4.8",
3
+ "version": "0.4.9-beta.2",
4
4
  "private": false,
5
5
  "description": "AI-powered workspace for solo CEOs and indie builders — manage your entire business with AI agents, not just code. Kanban board, code editor, Git integration, claw agent hub, local-first SQLite.",
6
6
  "keywords": [
@@ -66,6 +66,9 @@
66
66
  "pnpm": ">=9.0.0"
67
67
  },
68
68
  "pnpm": {
69
+ "overrides": {
70
+ "axios": "0.30.3"
71
+ },
69
72
  "onlyBuiltDependencies": [
70
73
  "@parcel/watcher",
71
74
  "@swc/core",
@@ -81,6 +84,11 @@
81
84
  "scripts": {
82
85
  "postinstall": "node scripts/postinstall-optional-native-deps.js",
83
86
  "dev": "cross-env CLAUDECODE= tsx server.ts",
87
+ "pm2": "pm2 start ecosystem.config.js && pm2 save",
88
+ "pm2-restart": "pm2 restart claudews",
89
+ "pm2-stop": "pm2 stop claudews",
90
+ "pm2-logs": "pm2 logs claudews",
91
+ "pm2-monit": "pm2 monit",
84
92
  "build": "cross-env NODE_ENV=production next build",
85
93
  "start": "cross-env NODE_ENV=production tsx server.ts",
86
94
  "db:generate": "drizzle-kit generate",
@@ -119,6 +127,7 @@
119
127
  "@radix-ui/react-dialog": "^1.1.15",
120
128
  "@radix-ui/react-dropdown-menu": "^2.1.16",
121
129
  "@radix-ui/react-label": "^2.1.8",
130
+ "@radix-ui/react-popover": "^1.1.15",
122
131
  "@radix-ui/react-scroll-area": "^1.2.10",
123
132
  "@radix-ui/react-select": "^2.2.6",
124
133
  "@radix-ui/react-separator": "^1.1.8",
@@ -130,6 +139,7 @@
130
139
  "@tailwindcss/postcss": "^4.2.2",
131
140
  "@types/adm-zip": "^0.5.8",
132
141
  "@types/better-sqlite3": "^7.6.13",
142
+ "@types/js-yaml": "^4.0.9",
133
143
  "@types/compression": "^1.8.1",
134
144
  "@types/dompurify": "^3.2.0",
135
145
  "@types/node": "^20.19.37",
@@ -191,8 +201,5 @@
191
201
  "optionalDependencies": {
192
202
  "@homebridge/node-pty-prebuilt-multiarch": "^0.13.1",
193
203
  "node-pty": "^1.1.0"
194
- },
195
- "devDependencies": {
196
- "@types/js-yaml": "^4.0.9"
197
204
  }
198
205
  }
@@ -12,8 +12,8 @@ export default async function projectsRoute(fastify: FastifyInstance) {
12
12
 
13
13
  fastify.post('/api/projects', async (request, reply) => {
14
14
  try {
15
- const { name, path: projectPath } = request.body as any;
16
- const project = await fastify.services.project.createProject({ name, path: projectPath });
15
+ const { id, projectId, name, path: projectPath } = request.body as any;
16
+ const project = await fastify.services.project.createProject({ id: projectId || id, name, path: projectPath });
17
17
  return reply.code(201).send(project);
18
18
  } catch (error: any) {
19
19
  if (error instanceof ProjectValidationError) {
@@ -37,7 +37,12 @@ export async function analyzeForPreview(extractDir: string, agentFactoryDir: str
37
37
 
38
38
  if (entry.isDirectory()) {
39
39
  if (entry.name === 'skills' || entry.name === 'commands' || entry.name === 'agents') {
40
- await previewDirectoryContents(entryPath, agentFactoryDir, items, entry.name as 'skill' | 'command' | 'agent');
40
+ const componentType = entry.name === 'skills'
41
+ ? 'skill'
42
+ : entry.name === 'commands'
43
+ ? 'command'
44
+ : 'agent';
45
+ await previewDirectoryContents(entryPath, agentFactoryDir, items, componentType);
41
46
  } else {
42
47
  const subEntries = await readdir(entryPath, { withFileTypes: true });
43
48
  const hasSubdirs = subEntries.some(e =>
@@ -110,8 +115,12 @@ export async function analyzeAndOrganize(extractDir: string, agentFactoryDir: st
110
115
 
111
116
  if (entry.isDirectory()) {
112
117
  if (entry.name === 'skills' || entry.name === 'commands' || entry.name === 'agents') {
113
- const targetDir = join(agentFactoryDir, entry.name);
114
- await moveDirectoryContents(entryPath, targetDir, items, entry.name as 'skill' | 'command' | 'agent');
118
+ const componentType = entry.name === 'skills'
119
+ ? 'skill'
120
+ : entry.name === 'commands'
121
+ ? 'command'
122
+ : 'agent';
123
+ await moveDirectoryContents(entryPath, agentFactoryDir, items, componentType);
115
124
  } else {
116
125
  const subEntries = await readdir(entryPath, { withFileTypes: true });
117
126
  const hasSubdirs = subEntries.some(e =>
@@ -165,11 +174,11 @@ export async function importFromSession(
165
174
  if (item.type === 'agent_set') {
166
175
  targetPath = join(targetBaseDir, 'agent-sets', item.name);
167
176
  } else if (item.type === 'skill') {
168
- targetPath = join(targetBaseDir, 'skills', item.name, 'SKILL.md');
177
+ targetPath = join(targetBaseDir, 'skills', item.relativePath || item.name, 'SKILL.md');
169
178
  } else if (item.type === 'agent') {
170
- targetPath = join(targetBaseDir, 'agents', `${item.name}.md`);
179
+ targetPath = join(targetBaseDir, 'agents', item.relativePath || `${item.name}.md`);
171
180
  } else {
172
- targetPath = join(targetBaseDir, 'commands', `${item.name}.md`);
181
+ targetPath = join(targetBaseDir, 'commands', item.relativePath || `${item.name}.md`);
173
182
  }
174
183
 
175
184
  if (item.type === 'agent_set') {
@@ -207,7 +216,11 @@ export async function importFromSession(
207
216
  description = extractDescriptionFromMarkdown(content);
208
217
  } catch { /* ignore */ }
209
218
 
210
- const componentType = item.type === 'unknown' ? 'command' : item.type;
219
+ const componentType = item.type === 'skill'
220
+ ? 'skill'
221
+ : item.type === 'agent'
222
+ ? 'agent'
223
+ : 'command';
211
224
  await registryService.upsertPlugin(item.name, componentType, {
212
225
  description,
213
226
  sourcePath: targetPath,
@@ -11,6 +11,8 @@ export interface ExtractedItem {
11
11
  sourcePath: string;
12
12
  targetPath: string;
13
13
  name: string;
14
+ // Relative path under skills/commands/agents root. Used to preserve command namespaces.
15
+ relativePath?: string;
14
16
  componentCount?: number;
15
17
  }
16
18
 
@@ -71,9 +73,10 @@ export async function moveDirectory(source: string, target: string): Promise<voi
71
73
 
72
74
  export async function moveDirectoryContents(
73
75
  sourceDir: string,
74
- targetDir: string,
76
+ targetBaseDir: string,
75
77
  items: ExtractedItem[],
76
- type: 'skill' | 'command' | 'agent'
78
+ type: 'skill' | 'command' | 'agent',
79
+ relativePrefix = ''
77
80
  ): Promise<void> {
78
81
  const entries = await readdir(sourceDir, { withFileTypes: true });
79
82
 
@@ -81,15 +84,57 @@ export async function moveDirectoryContents(
81
84
  if (entry.name.startsWith('.')) continue;
82
85
 
83
86
  const sourcePath = join(sourceDir, entry.name);
84
- const targetPath = join(targetDir, entry.name);
87
+ const relPath = relativePrefix ? join(relativePrefix, entry.name) : entry.name;
88
+
89
+ if (type === 'skill') {
90
+ if (!entry.isDirectory()) continue;
91
+ const skillMdPath = join(sourcePath, 'SKILL.md');
92
+ if (existsSync(skillMdPath)) {
93
+ const targetPath = join(targetBaseDir, 'skills', relPath);
94
+ await moveDirectory(sourcePath, targetPath);
95
+ items.push({
96
+ type: 'skill',
97
+ sourcePath,
98
+ targetPath: join(targetPath, 'SKILL.md'),
99
+ name: relPath,
100
+ relativePath: relPath,
101
+ });
102
+ } else {
103
+ await moveDirectoryContents(sourcePath, targetBaseDir, items, type, relPath);
104
+ }
105
+ continue;
106
+ }
85
107
 
86
- if (entry.isDirectory()) {
87
- await moveDirectory(sourcePath, targetPath);
88
- items.push({ type, sourcePath, targetPath, name: entry.name });
89
- } else if (entry.isFile()) {
108
+ if (type === 'command') {
109
+ if (entry.isDirectory()) {
110
+ await moveDirectoryContents(sourcePath, targetBaseDir, items, type, relPath);
111
+ } else if (entry.isFile() && entry.name.endsWith('.md')) {
112
+ const targetPath = join(targetBaseDir, 'commands', relPath);
113
+ await mkdir(dirname(targetPath), { recursive: true });
114
+ await copyFile(sourcePath, targetPath);
115
+ items.push({
116
+ type: 'command',
117
+ sourcePath,
118
+ targetPath,
119
+ name: relPath.replace(/\.md$/i, '').split('/').join(':'),
120
+ relativePath: relPath,
121
+ });
122
+ }
123
+ continue;
124
+ }
125
+
126
+ // Agents: only allow markdown files directly under agents/ (no subdirectories)
127
+ if (!relativePrefix && entry.isFile() && entry.name.endsWith('.md')) {
128
+ const targetPath = join(targetBaseDir, 'agents', entry.name);
90
129
  await mkdir(dirname(targetPath), { recursive: true });
91
130
  await copyFile(sourcePath, targetPath);
92
- items.push({ type, sourcePath, targetPath, name: entry.name });
131
+ items.push({
132
+ type: 'agent',
133
+ sourcePath,
134
+ targetPath,
135
+ name: basename(entry.name, '.md'),
136
+ relativePath: entry.name,
137
+ });
93
138
  }
94
139
  }
95
140
  }
@@ -173,9 +218,10 @@ export async function previewDirectory(
173
218
 
174
219
  export async function previewDirectoryContents(
175
220
  sourceDir: string,
176
- targetDir: string,
221
+ targetBaseDir: string,
177
222
  items: ExtractedItem[],
178
- type: 'skill' | 'command' | 'agent'
223
+ type: 'skill' | 'command' | 'agent',
224
+ relativePrefix = ''
179
225
  ): Promise<void> {
180
226
  const entries = await readdir(sourceDir, { withFileTypes: true });
181
227
 
@@ -183,12 +229,49 @@ export async function previewDirectoryContents(
183
229
  if (entry.name.startsWith('.')) continue;
184
230
 
185
231
  const sourcePath = join(sourceDir, entry.name);
186
- const targetPath = join(targetDir, entry.name);
232
+ const relPath = relativePrefix ? join(relativePrefix, entry.name) : entry.name;
233
+
234
+ if (type === 'skill') {
235
+ if (!entry.isDirectory()) continue;
236
+ const skillMdPath = join(sourcePath, 'SKILL.md');
237
+ if (existsSync(skillMdPath)) {
238
+ items.push({
239
+ type: 'skill',
240
+ sourcePath,
241
+ targetPath: join(targetBaseDir, 'skills', relPath, 'SKILL.md'),
242
+ name: relPath,
243
+ relativePath: relPath,
244
+ });
245
+ } else {
246
+ await previewDirectoryContents(sourcePath, targetBaseDir, items, type, relPath);
247
+ }
248
+ continue;
249
+ }
187
250
 
188
- if (entry.isDirectory()) {
189
- items.push({ type, sourcePath, targetPath, name: entry.name });
190
- } else if (entry.isFile() && entry.name.endsWith('.md')) {
191
- items.push({ type, sourcePath, targetPath, name: entry.name });
251
+ if (type === 'command') {
252
+ if (entry.isDirectory()) {
253
+ await previewDirectoryContents(sourcePath, targetBaseDir, items, type, relPath);
254
+ } else if (entry.isFile() && entry.name.endsWith('.md')) {
255
+ items.push({
256
+ type: 'command',
257
+ sourcePath,
258
+ targetPath: join(targetBaseDir, 'commands', relPath),
259
+ name: relPath.replace(/\.md$/i, '').split('/').join(':'),
260
+ relativePath: relPath,
261
+ });
262
+ }
263
+ continue;
264
+ }
265
+
266
+ // Agents: only allow markdown files directly under agents/ (no subdirectories)
267
+ if (!relativePrefix && entry.isFile() && entry.name.endsWith('.md')) {
268
+ items.push({
269
+ type: 'agent',
270
+ sourcePath,
271
+ targetPath: join(targetBaseDir, 'agents', entry.name),
272
+ name: basename(entry.name, '.md'),
273
+ relativePath: entry.name,
274
+ });
192
275
  }
193
276
  }
194
277
  }
@@ -67,10 +67,11 @@ interface OrchestratorDeps {
67
67
  /** Callback to start the agent process (runtime singleton, lives outside SDK) */
68
68
  startAgent: (params: AgentStartParams) => void;
69
69
  defaultBasePath: string;
70
+ onProjectForceCreated?: (project: any) => Promise<void> | void;
70
71
  }
71
72
 
72
73
  export function createAttemptOrchestrator(deps: OrchestratorDeps) {
73
- const { taskService, projectService, attemptService, forceCreateService, sessionManager, startAgent, defaultBasePath } = deps;
74
+ const { taskService, projectService, attemptService, forceCreateService, sessionManager, startAgent, defaultBasePath, onProjectForceCreated } = deps;
74
75
 
75
76
  return {
76
77
  /**
@@ -91,11 +92,14 @@ export function createAttemptOrchestrator(deps: OrchestratorDeps) {
91
92
  }
92
93
 
93
94
  // Resolve task (lookup or force-create)
94
- const task = await this.resolveTask(input);
95
+ const { task, projectCreatedByForceCreate } = await this.resolveTask(input);
95
96
 
96
97
  // Resolve project
97
98
  const project = await projectService.getById(task.projectId);
98
99
  if (!project) throw new AttemptValidationError('Project not found', 404);
100
+ if (projectCreatedByForceCreate && onProjectForceCreated) {
101
+ await onProjectForceCreated(project);
102
+ }
99
103
 
100
104
  // Create attempt record
101
105
  const attempt = await attemptService.create({
@@ -166,18 +170,18 @@ export function createAttemptOrchestrator(deps: OrchestratorDeps) {
166
170
  },
167
171
 
168
172
  /** Resolve task: find existing or force-create project+task */
169
- async resolveTask(input: AttemptCreationInput): Promise<any> {
173
+ async resolveTask(input: AttemptCreationInput): Promise<{ task: any; projectCreatedByForceCreate: boolean }> {
170
174
  const { taskId, force_create, projectId, projectName, taskTitle, projectRootPath } = input;
171
175
 
172
176
  if (!force_create) {
173
177
  const task = await taskService.getById(taskId);
174
178
  if (!task) throw new AttemptValidationError('Task not found', 404);
175
- return task;
179
+ return { task, projectCreatedByForceCreate: false };
176
180
  }
177
181
 
178
182
  // Force-create: check if task already exists
179
183
  const existingTask = await taskService.getById(taskId);
180
- if (existingTask) return existingTask;
184
+ if (existingTask) return { task: existingTask, projectCreatedByForceCreate: false };
181
185
 
182
186
  // Task doesn't exist — force-create project + task
183
187
  if (!projectId) throw new AttemptValidationError('projectId required', 400);
@@ -185,7 +189,7 @@ export function createAttemptOrchestrator(deps: OrchestratorDeps) {
185
189
  const result = await forceCreateService.ensureProjectAndTask({
186
190
  taskId, projectId, projectName, taskTitle, projectRootPath, defaultBasePath,
187
191
  });
188
- return result.task;
192
+ return { task: result.task, projectCreatedByForceCreate: Boolean(result.projectCreated) };
189
193
  },
190
194
 
191
195
  /** Poll attempt status until terminal state or timeout */
@@ -31,6 +31,7 @@ export interface ForceCreateParams {
31
31
  export interface ForceCreateResult {
32
32
  task: typeof schema.tasks.$inferSelect;
33
33
  project: typeof schema.projects.$inferSelect;
34
+ projectCreated: boolean;
34
35
  }
35
36
 
36
37
  export function createForceCreateService(db: any) {
@@ -42,6 +43,7 @@ export function createForceCreateService(db: any) {
42
43
  */
43
44
  async ensureProjectAndTask(params: ForceCreateParams): Promise<ForceCreateResult> {
44
45
  const { taskId, projectId, projectName, taskTitle, projectRootPath, defaultBasePath } = params;
46
+ let projectCreated = false;
45
47
 
46
48
  // Check if project exists
47
49
  let project = await db.select().from(schema.projects)
@@ -79,6 +81,7 @@ export function createForceCreateService(db: any) {
79
81
  path: projectPath,
80
82
  createdAt: Date.now(),
81
83
  });
84
+ projectCreated = true;
82
85
 
83
86
  project = await db.select().from(schema.projects)
84
87
  .where(eq(schema.projects.id, projectId)).get();
@@ -114,7 +117,7 @@ export function createForceCreateService(db: any) {
114
117
  const task = await db.select().from(schema.tasks)
115
118
  .where(eq(schema.tasks.id, taskId)).get();
116
119
 
117
- return { task, project };
120
+ return { task, project, projectCreated };
118
121
  },
119
122
  };
120
123
  }
@@ -97,7 +97,7 @@ export function createProjectService(db: any) {
97
97
  },
98
98
 
99
99
  /** Validate + setup directory + create project. Throws ProjectValidationError on constraint violation. */
100
- async createProject(data: { name: string; path: string }) {
100
+ async createProject(data: { id?: string; name: string; path: string }) {
101
101
  if (!data.name || !data.path) throw new ProjectValidationError('Name and path are required', 400);
102
102
  try {
103
103
  await this.setupProjectDirectory(data.path);
@@ -0,0 +1,78 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
5
+ cd "$ROOT_DIR"
6
+
7
+ APP_NAME="${PM2_APP_NAME:-claudews}"
8
+ ECOSYSTEM_FILE="${PM2_ECOSYSTEM_FILE:-ecosystem.config.js}"
9
+ BUILD_CMD="${BUILD_CMD:-pnpm build}"
10
+
11
+ # Resolve health URL from env override or fallback to localhost:PORT/api/tunnel/status
12
+ if [[ -n "${HEALTH_URL:-}" ]]; then
13
+ HEALTH_URL="$HEALTH_URL"
14
+ else
15
+ PORT_VALUE="${PORT:-8556}"
16
+ if [[ -f .env ]]; then
17
+ ENV_PORT="$(grep -E '^PORT=' .env | tail -n1 | cut -d'=' -f2- | tr -d '"' | tr -d "'" || true)"
18
+ if [[ -n "$ENV_PORT" ]]; then
19
+ PORT_VALUE="$ENV_PORT"
20
+ fi
21
+ fi
22
+ HEALTH_URL="http://127.0.0.1:${PORT_VALUE}/api/tunnel/status"
23
+ fi
24
+
25
+ RETRY_COUNT="${HEALTH_RETRY_COUNT:-20}"
26
+ RETRY_DELAY_SECONDS="${HEALTH_RETRY_DELAY_SECONDS:-2}"
27
+
28
+ need_cmd() {
29
+ if ! command -v "$1" >/dev/null 2>&1; then
30
+ echo "❌ Missing required command: $1"
31
+ exit 1
32
+ fi
33
+ }
34
+
35
+ need_cmd pnpm
36
+ need_cmd pm2
37
+ need_cmd curl
38
+
39
+ echo "📦 Building project..."
40
+ $BUILD_CMD
41
+
42
+ echo "🚀 Starting/Restarting PM2 app: $APP_NAME"
43
+ if pm2 describe "$APP_NAME" >/dev/null 2>&1; then
44
+ pm2 restart "$APP_NAME" --update-env
45
+ else
46
+ if [[ ! -f "$ECOSYSTEM_FILE" ]]; then
47
+ echo "❌ Ecosystem file not found: $ECOSYSTEM_FILE"
48
+ exit 1
49
+ fi
50
+
51
+ # Prefer starting the specific app in ecosystem; fallback to starting entire file.
52
+ if ! pm2 start "$ECOSYSTEM_FILE" --only "$APP_NAME"; then
53
+ pm2 start "$ECOSYSTEM_FILE"
54
+ fi
55
+ fi
56
+
57
+ pm2 save >/dev/null
58
+
59
+ echo "🩺 Health check: $HEALTH_URL"
60
+ attempt=1
61
+ while (( attempt <= RETRY_COUNT )); do
62
+ if curl -fsS --max-time 8 "$HEALTH_URL" >/dev/null; then
63
+ echo "✅ Health check passed (attempt $attempt/$RETRY_COUNT)"
64
+ pm2 status "$APP_NAME"
65
+ echo "✅ PM2 build + test completed"
66
+ exit 0
67
+ fi
68
+
69
+ echo "⏳ Waiting for service... ($attempt/$RETRY_COUNT)"
70
+ sleep "$RETRY_DELAY_SECONDS"
71
+ ((attempt++))
72
+ done
73
+
74
+ echo "❌ Health check failed after $RETRY_COUNT attempts"
75
+ pm2 status "$APP_NAME" || true
76
+ echo "--- Last 120 lines of PM2 logs for $APP_NAME ---"
77
+ pm2 logs "$APP_NAME" --lines 120 --nostream || true
78
+ exit 1
package/server.ts CHANGED
@@ -52,6 +52,8 @@ import { usageTracker } from './src/lib/usage-tracker';
52
52
  import { workflowTracker } from './src/lib/workflow-tracker';
53
53
  import { gitStatsCache } from './src/lib/git-stats-collector';
54
54
  import { tunnelService } from './src/lib/tunnel-service';
55
+ import { getMinioPullQueueWorker } from './src/lib/minio-pull-queue';
56
+ import { getMinioPushQueueWorker } from './src/lib/minio-push-queue';
55
57
  import { createAutopilotManager, appendQuestionAnswer, appendSubagentEnded, appendTrackedTaskUpdate } from './src/lib/autopilot';
56
58
  import type { AutopilotMode } from './src/lib/autopilot';
57
59
 
@@ -63,6 +65,8 @@ const port = getPort();
63
65
 
64
66
  const app = next({ dev, hostname, port, turbopack: false });
65
67
  const handle = app.getRequestHandler();
68
+ const minioPullQueueWorker = getMinioPullQueueWorker();
69
+ const minioPushQueueWorker = getMinioPushQueueWorker();
66
70
 
67
71
  app.prepare().then(async () => {
68
72
  const httpServer = createServer((req, res) => {
@@ -224,14 +228,18 @@ app.prepare().then(async () => {
224
228
  const { mkdir } = await import('fs/promises');
225
229
  const { join } = await import('path');
226
230
 
227
- const projectDirName = projectId;
228
- const projectPath = projectRootPath
231
+ const projectDirName = `${projectId}-${projectName}`;
232
+ log.info({ projectRootPath, userCwd, projectDirName }, '[Socket] Debug project path creation');
233
+ const projectPath = (projectRootPath && projectRootPath.trim() !== '')
229
234
  ? join(projectRootPath, projectDirName)
230
235
  : join(userCwd, 'data', 'projects', projectDirName);
236
+ log.info({ projectPath, projectRootPath, userCwd }, '[Socket] Final project path');
231
237
 
232
238
  try {
239
+ const { setupProjectDefaults } = await import('./src/lib/project-utils');
233
240
  await mkdir(projectPath, { recursive: true });
234
- log.info({ projectPath }, '[Socket] Created project directory');
241
+ await setupProjectDefaults(projectPath, projectId);
242
+ log.info({ projectPath, projectId }, '[Socket] Created project directory and defaults');
235
243
  } catch (mkdirError: any) {
236
244
  if (mkdirError?.code !== 'EEXIST') {
237
245
  log.error({ mkdirError }, '[Socket] Failed to create project folder');
@@ -615,7 +623,7 @@ app.prepare().then(async () => {
615
623
 
616
624
  log.warn(`[Server] Auto-retried answer for ${attemptId} as new attempt ${newAttemptId}`);
617
625
  } catch (error) {
618
- log.error({error},`[Server] Auto-retry failed for ${attemptId}:`);
626
+ log.error({ error }, `[Server] Auto-retry failed for ${attemptId}:`);
619
627
  socket.emit('error', { message: 'Auto-retry failed: ' + (error instanceof Error ? error.message : 'Unknown error') });
620
628
  }
621
629
  }
@@ -1632,9 +1640,9 @@ app.prepare().then(async () => {
1632
1640
  taskTitle: task?.title || 'Unknown',
1633
1641
  summary: expanded.summary,
1634
1642
  });
1635
- }).catch(() => {});
1643
+ }).catch(() => { });
1636
1644
  }
1637
- }).catch(() => {});
1645
+ }).catch(() => { });
1638
1646
  }
1639
1647
  });
1640
1648
 
@@ -1828,6 +1836,9 @@ app.prepare().then(async () => {
1828
1836
  httpServer.listen(port, () => {
1829
1837
  log.info(`> Ready on http://${hostname}:${port}`);
1830
1838
 
1839
+ // Start MinIO sync queue workers with server lifecycle
1840
+ minioPullQueueWorker.start();
1841
+ minioPushQueueWorker.start();
1831
1842
  if (!process.env.NEXT_PUBLIC_URL) {
1832
1843
  log.warn('[Server] NEXT_PUBLIC_URL is not set. Access is limited to localhost only. Set NEXT_PUBLIC_URL in .env to enable remote access.');
1833
1844
  }
@@ -1851,6 +1862,13 @@ app.prepare().then(async () => {
1851
1862
  await tunnelService.stop();
1852
1863
  log.info('> Tunnel stopped');
1853
1864
 
1865
+ // Stop MinIO sync queue workers
1866
+ minioPullQueueWorker.stop();
1867
+ log.info('> MinIO pull queue worker stopped');
1868
+
1869
+ minioPushQueueWorker.stop();
1870
+ log.info('> MinIO push queue worker stopped');
1871
+
1854
1872
  // Cancel all Claude agents first
1855
1873
  agentManager.cancelAll();
1856
1874
  log.info('> Cancelled all Claude agents');
@@ -3,13 +3,7 @@
3
3
  import { useEffect, useState } from 'react';
4
4
  import { SearchProvider } from '@/components/search/search-provider';
5
5
  import { Header } from '@/components/header';
6
- import { Board } from '@/components/kanban/board';
7
- import { CreateTaskDialog } from '@/components/kanban/create-task-dialog';
8
- import { TaskDetailPanel } from '@/components/task/task-detail-panel';
9
- import { FloatingChatWindowsContainer } from '@/components/task/floating-chat-windows-container';
10
- import { SettingsPage } from '@/components/settings/settings-page';
11
- import { SetupDialog } from '@/components/settings/setup-dialog';
12
- import { SidebarPanel, FileTabsPanel, DiffTabsPanel } from '@/components/sidebar';
6
+ import dynamic from 'next/dynamic';
13
7
  import { RightSidebar } from '@/components/right-sidebar';
14
8
  import { QuestionsPanel } from '@/components/questions/questions-panel';
15
9
  import { TeamView } from '@/components/team-view/team-view';
@@ -28,6 +22,45 @@ import { useKanbanKeyboardShortcuts } from '@/hooks/use-kanban-keyboard-shortcut
28
22
  import type { Task } from '@/types';
29
23
  import { useTranslations } from 'next-intl';
30
24
 
25
+ // Lazy-load heavy components to isolate third-party chunks (@dnd-kit, @codemirror, @lezer)
26
+ // and avoid Turbopack HMR "module factory not available" errors from stale browser cache.
27
+ const Board = dynamic(
28
+ () => import('@/components/kanban/board').then(m => ({ default: m.Board })),
29
+ { ssr: false }
30
+ );
31
+ const CreateTaskDialog = dynamic(
32
+ () => import('@/components/kanban/create-task-dialog').then(m => ({ default: m.CreateTaskDialog })),
33
+ { ssr: false }
34
+ );
35
+ const TaskDetailPanel = dynamic(
36
+ () => import('@/components/task/task-detail-panel').then(m => ({ default: m.TaskDetailPanel })),
37
+ { ssr: false }
38
+ );
39
+ const FloatingChatWindowsContainer = dynamic(
40
+ () => import('@/components/task/floating-chat-windows-container').then(m => ({ default: m.FloatingChatWindowsContainer })),
41
+ { ssr: false }
42
+ );
43
+ const SettingsPage = dynamic(
44
+ () => import('@/components/settings/settings-page').then(m => ({ default: m.SettingsPage })),
45
+ { ssr: false }
46
+ );
47
+ const SetupDialog = dynamic(
48
+ () => import('@/components/settings/setup-dialog').then(m => ({ default: m.SetupDialog })),
49
+ { ssr: false }
50
+ );
51
+ const SidebarPanel = dynamic(
52
+ () => import('@/components/sidebar/sidebar-panel').then(m => ({ default: m.SidebarPanel })),
53
+ { ssr: false }
54
+ );
55
+ const FileTabsPanel = dynamic(
56
+ () => import('@/components/sidebar/file-browser/file-tabs-panel').then(m => ({ default: m.FileTabsPanel })),
57
+ { ssr: false }
58
+ );
59
+ const DiffTabsPanel = dynamic(
60
+ () => import('@/components/sidebar/git-changes/diff-tabs-panel').then(m => ({ default: m.DiffTabsPanel })),
61
+ { ssr: false }
62
+ );
63
+
31
64
  function KanbanApp() {
32
65
  const tCommon = useTranslations('common');
33
66
  const [createTaskOpen, setCreateTaskOpen] = useState(false);