claude-ws 0.4.9-beta.3 → 0.4.9-beta.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-ws",
3
- "version": "0.4.9-beta.3",
3
+ "version": "0.4.9-beta.5",
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": [
@@ -90,9 +90,11 @@
90
90
  "pm2-logs": "pm2 logs claudews",
91
91
  "pm2-monit": "pm2 monit",
92
92
  "build": "cross-env NODE_ENV=production next build",
93
- "start": "cross-env NODE_ENV=production tsx server.ts",
93
+ "start": "pnpm projects:migrate:defaults && cross-env NODE_ENV=production tsx server.ts",
94
94
  "db:generate": "drizzle-kit generate",
95
95
  "db:fix": "tsx scripts/db-fix-columns.ts",
96
+ "db:migrate:health": "tsx scripts/db-migrate-health.ts",
97
+ "projects:migrate:defaults": "tsx scripts/migrate-existing-projects.ts",
96
98
  "migrate:sessions": "tsx scripts/migrate-sdk-sessions.ts"
97
99
  },
98
100
  "dependencies": {
@@ -139,9 +141,9 @@
139
141
  "@tailwindcss/postcss": "^4.2.2",
140
142
  "@types/adm-zip": "^0.5.8",
141
143
  "@types/better-sqlite3": "^7.6.13",
142
- "@types/js-yaml": "^4.0.9",
143
144
  "@types/compression": "^1.8.1",
144
145
  "@types/dompurify": "^3.2.0",
146
+ "@types/js-yaml": "^4.0.9",
145
147
  "@types/node": "^20.19.37",
146
148
  "@types/react": "^19.2.14",
147
149
  "@types/react-dom": "^19.2.3",
@@ -201,5 +203,8 @@
201
203
  "optionalDependencies": {
202
204
  "@homebridge/node-pty-prebuilt-multiarch": "^0.13.1",
203
205
  "node-pty": "^1.1.0"
206
+ },
207
+ "devDependencies": {
208
+ "@types/js-yaml": "^4.0.9"
204
209
  }
205
210
  }
@@ -24,6 +24,8 @@ interface ClaudeOutput {
24
24
  interface FormattedResponse {
25
25
  formatted_data: string;
26
26
  format: string;
27
+ messages?: ClaudeOutput[];
28
+ status?: string;
27
29
  attempt: {
28
30
  id: string;
29
31
  taskId: string;
@@ -75,9 +77,21 @@ export function formatOutput(
75
77
  break;
76
78
  }
77
79
 
78
- return { formatted_data: formattedData, format, attempt: { ...attemptMetadata, status: attemptMetadata.status as any } };
80
+ return {
81
+ formatted_data: formattedData,
82
+ format,
83
+ messages,
84
+ status: attemptMetadata.status as any,
85
+ attempt: { ...attemptMetadata, status: attemptMetadata.status as any }
86
+ };
79
87
  } catch {
80
- return { formatted_data: toJson(messages), format: 'json', attempt: { ...attemptMetadata, status: attemptMetadata.status as any } };
88
+ return {
89
+ formatted_data: toJson(messages),
90
+ format: 'json',
91
+ messages,
92
+ status: attemptMetadata.status as any,
93
+ attempt: { ...attemptMetadata, status: attemptMetadata.status as any }
94
+ };
81
95
  }
82
96
  }
83
97
 
@@ -0,0 +1,223 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * Database health migration script
4
+ *
5
+ * Goals:
6
+ * - Backup database before touching data
7
+ * - Add legacy missing columns (idempotent)
8
+ * - Clean orphan rows that can trigger FK-related runtime errors
9
+ * - Verify FK integrity after cleanup
10
+ *
11
+ * Usage:
12
+ * pnpm db:migrate:health
13
+ */
14
+
15
+ import Database from 'better-sqlite3';
16
+ import path from 'path';
17
+ import fs from 'fs';
18
+ import { config } from 'dotenv';
19
+
20
+ type CleanupItem = {
21
+ label: string;
22
+ run: (db: Database.Database) => number;
23
+ };
24
+
25
+ function nowStamp(): string {
26
+ const d = new Date();
27
+ const pad = (n: number) => String(n).padStart(2, '0');
28
+ return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}_${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`;
29
+ }
30
+
31
+ function addColumnIfMissing(db: Database.Database, table: string, column: string, type: string): boolean {
32
+ try {
33
+ db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${type}`);
34
+ return true;
35
+ } catch {
36
+ return false;
37
+ }
38
+ }
39
+
40
+ function ensureLegacyColumns(db: Database.Database): number {
41
+ let added = 0;
42
+ const attemptsColumns = [
43
+ ['total_tokens', 'INTEGER NOT NULL DEFAULT 0'],
44
+ ['input_tokens', 'INTEGER NOT NULL DEFAULT 0'],
45
+ ['output_tokens', 'INTEGER NOT NULL DEFAULT 0'],
46
+ ['cache_creation_tokens', 'INTEGER NOT NULL DEFAULT 0'],
47
+ ['cache_read_tokens', 'INTEGER NOT NULL DEFAULT 0'],
48
+ ['total_cost_usd', "TEXT NOT NULL DEFAULT '0'"],
49
+ ['num_turns', 'INTEGER NOT NULL DEFAULT 0'],
50
+ ['duration_ms', 'INTEGER NOT NULL DEFAULT 0'],
51
+ ['context_used', 'INTEGER NOT NULL DEFAULT 0'],
52
+ ['context_limit', 'INTEGER NOT NULL DEFAULT 200000'],
53
+ ['context_percentage', 'INTEGER NOT NULL DEFAULT 0'],
54
+ ['baseline_context', 'INTEGER NOT NULL DEFAULT 0'],
55
+ ['output_format', 'TEXT'],
56
+ ['output_schema', 'TEXT'],
57
+ ] as const;
58
+
59
+ const tasksColumns = [
60
+ ['chat_init', 'INTEGER NOT NULL DEFAULT 0'],
61
+ ['rewind_session_id', 'TEXT'],
62
+ ['rewind_message_uuid', 'TEXT'],
63
+ ['last_model', 'TEXT'],
64
+ ['last_provider', 'TEXT'],
65
+ ['pending_file_ids', 'TEXT'],
66
+ ] as const;
67
+
68
+ const checkpointsColumns = [
69
+ ['git_commit_hash', 'TEXT'],
70
+ ] as const;
71
+
72
+ for (const [name, type] of attemptsColumns) if (addColumnIfMissing(db, 'attempts', name, type)) added++;
73
+ for (const [name, type] of tasksColumns) if (addColumnIfMissing(db, 'tasks', name, type)) added++;
74
+ for (const [name, type] of checkpointsColumns) if (addColumnIfMissing(db, 'checkpoints', name, type)) added++;
75
+
76
+ return added;
77
+ }
78
+
79
+ function deleteCount(db: Database.Database, sql: string): number {
80
+ const stmt = db.prepare(sql);
81
+ const result = stmt.run();
82
+ return Number(result.changes || 0);
83
+ }
84
+
85
+ function updateCount(db: Database.Database, sql: string): number {
86
+ const stmt = db.prepare(sql);
87
+ const result = stmt.run();
88
+ return Number(result.changes || 0);
89
+ }
90
+
91
+ function run(): void {
92
+ const userCwd = process.env.CLAUDE_WS_USER_CWD || process.cwd();
93
+ config({ path: path.join(userCwd, '.env') });
94
+
95
+ const dbDir = process.env.DATA_DIR || path.join(userCwd, 'data');
96
+ const dbPath = path.join(dbDir, 'claude-ws.db');
97
+
98
+ if (!fs.existsSync(dbPath)) {
99
+ console.log(`[db:migrate:health] DB not found: ${dbPath}`);
100
+ process.exit(0);
101
+ }
102
+
103
+ fs.mkdirSync(dbDir, { recursive: true });
104
+ const backupPath = path.join(dbDir, `claude-ws.db.backup-${nowStamp()}`);
105
+ fs.copyFileSync(dbPath, backupPath);
106
+ console.log(`[db:migrate:health] Backup created: ${backupPath}`);
107
+
108
+ const db = new Database(dbPath);
109
+ db.pragma('journal_mode = WAL');
110
+ db.pragma('foreign_keys = ON');
111
+
112
+ const fkBefore = db.prepare('PRAGMA foreign_key_check').all() as any[];
113
+ console.log(`[db:migrate:health] FK violations before: ${fkBefore.length}`);
114
+
115
+ const cleanup: CleanupItem[] = [
116
+ { label: 'tasks without project', run: (x) => deleteCount(x, `DELETE FROM tasks WHERE project_id NOT IN (SELECT id FROM projects)`) },
117
+ { label: 'attempts without task', run: (x) => deleteCount(x, `DELETE FROM attempts WHERE task_id NOT IN (SELECT id FROM tasks)`) },
118
+ { label: 'attempt_logs without attempt', run: (x) => deleteCount(x, `DELETE FROM attempt_logs WHERE attempt_id NOT IN (SELECT id FROM attempts)`) },
119
+ { label: 'attempt_files without attempt', run: (x) => deleteCount(x, `DELETE FROM attempt_files WHERE attempt_id NOT IN (SELECT id FROM attempts)`) },
120
+ {
121
+ label: 'checkpoints without task/attempt',
122
+ run: (x) => deleteCount(
123
+ x,
124
+ `DELETE FROM checkpoints
125
+ WHERE task_id NOT IN (SELECT id FROM tasks)
126
+ OR attempt_id NOT IN (SELECT id FROM attempts)`
127
+ ),
128
+ },
129
+ {
130
+ label: 'shells with missing attempt -> NULL',
131
+ run: (x) => updateCount(
132
+ x,
133
+ `UPDATE shells
134
+ SET attempt_id = NULL
135
+ WHERE attempt_id IS NOT NULL
136
+ AND attempt_id NOT IN (SELECT id FROM attempts)`
137
+ ),
138
+ },
139
+ {
140
+ label: 'project_plugins orphan refs',
141
+ run: (x) => deleteCount(
142
+ x,
143
+ `DELETE FROM project_plugins
144
+ WHERE project_id NOT IN (SELECT id FROM projects)
145
+ OR plugin_id NOT IN (SELECT id FROM agent_factory_plugins)`
146
+ ),
147
+ },
148
+ {
149
+ label: 'plugin_dependencies without owner plugin',
150
+ run: (x) => deleteCount(
151
+ x,
152
+ `DELETE FROM plugin_dependencies
153
+ WHERE plugin_id NOT IN (SELECT id FROM agent_factory_plugins)`
154
+ ),
155
+ },
156
+ {
157
+ label: 'plugin_dependencies missing referenced plugin -> NULL',
158
+ run: (x) => updateCount(
159
+ x,
160
+ `UPDATE plugin_dependencies
161
+ SET plugin_dependency_id = NULL
162
+ WHERE plugin_dependency_id IS NOT NULL
163
+ AND plugin_dependency_id NOT IN (SELECT id FROM agent_factory_plugins)`
164
+ ),
165
+ },
166
+ {
167
+ label: 'plugin_dependency_cache missing plugin -> NULL',
168
+ run: (x) => updateCount(
169
+ x,
170
+ `UPDATE plugin_dependency_cache
171
+ SET plugin_id = NULL
172
+ WHERE plugin_id IS NOT NULL
173
+ AND plugin_id NOT IN (SELECT id FROM agent_factory_plugins)`
174
+ ),
175
+ },
176
+ {
177
+ label: 'subagents without attempt',
178
+ run: (x) => deleteCount(x, `DELETE FROM subagents WHERE attempt_id NOT IN (SELECT id FROM attempts)`),
179
+ },
180
+ {
181
+ label: 'tracked_tasks without attempt',
182
+ run: (x) => deleteCount(x, `DELETE FROM tracked_tasks WHERE attempt_id NOT IN (SELECT id FROM attempts)`),
183
+ },
184
+ {
185
+ label: 'agent_messages without attempt',
186
+ run: (x) => deleteCount(x, `DELETE FROM agent_messages WHERE attempt_id NOT IN (SELECT id FROM attempts)`),
187
+ },
188
+ ];
189
+
190
+ const tx = db.transaction(() => {
191
+ const addedColumns = ensureLegacyColumns(db);
192
+ console.log(`[db:migrate:health] Added missing columns: ${addedColumns}`);
193
+
194
+ let totalChanges = 0;
195
+ for (const item of cleanup) {
196
+ const changed = item.run(db);
197
+ totalChanges += changed;
198
+ if (changed > 0) {
199
+ console.log(`[db:migrate:health] ${item.label}: ${changed}`);
200
+ }
201
+ }
202
+ return totalChanges;
203
+ });
204
+
205
+ const changedRows = tx();
206
+
207
+ const fkAfter = db.prepare('PRAGMA foreign_key_check').all() as any[];
208
+ const quickCheck = db.prepare('PRAGMA quick_check').get() as { quick_check?: string } | undefined;
209
+ db.close();
210
+
211
+ console.log(`[db:migrate:health] Total repaired rows: ${changedRows}`);
212
+ console.log(`[db:migrate:health] FK violations after: ${fkAfter.length}`);
213
+ console.log(`[db:migrate:health] quick_check: ${quickCheck?.quick_check ?? 'unknown'}`);
214
+
215
+ if (fkAfter.length > 0) {
216
+ console.log('[db:migrate:health] WARNING: FK violations remain. Restore backup and inspect manually if needed.');
217
+ process.exit(1);
218
+ }
219
+
220
+ console.log('[db:migrate:health] Done.');
221
+ }
222
+
223
+ run();
@@ -0,0 +1,107 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * Migrate existing projects to current default Claude workspace structure.
4
+ *
5
+ * What it does per project:
6
+ * - Ensures `.claude/hooks` and `.claude/commands` exist
7
+ * - Syncs hook templates (`minio-pull-sync.ts`, `minio-push-sync.ts`)
8
+ * - Ensures `.claude/hooks/.env.example`, `.claude/settings.json`, `.claude/CLAUDE.md`
9
+ * - Creates `.claude/hooks/.env` when missing
10
+ *
11
+ * Usage:
12
+ * pnpm projects:migrate:defaults
13
+ */
14
+
15
+ import path from 'path';
16
+ import fs from 'fs';
17
+ import { config } from 'dotenv';
18
+ import { db, schema } from '../src/lib/db';
19
+ import { setupProjectDefaults } from '../src/lib/project-utils';
20
+
21
+ type ProjectTarget = {
22
+ id: string;
23
+ projectPath: string;
24
+ source: 'db' | 'scan';
25
+ };
26
+
27
+ function inferProjectIdFromDirName(dirName: string): string {
28
+ // Folders may be: "<projectId>" or "<projectId>-<name>"
29
+ const firstDash = dirName.indexOf('-');
30
+ if (firstDash <= 0) return dirName;
31
+ return dirName.slice(0, firstDash);
32
+ }
33
+
34
+ async function run(): Promise<void> {
35
+ const workspaceRoot = process.env.CLAUDE_WS_USER_CWD || process.cwd();
36
+ config({ path: path.join(workspaceRoot, '.env') });
37
+
38
+ const dataDir = process.env.DATA_DIR || path.join(workspaceRoot, 'data');
39
+ const projectsDir = path.join(dataDir, 'projects');
40
+
41
+ const targets = new Map<string, ProjectTarget>();
42
+
43
+ // 1) Projects tracked in DB
44
+ const dbProjects = db.select({
45
+ id: schema.projects.id,
46
+ path: schema.projects.path,
47
+ }).from(schema.projects).all();
48
+
49
+ for (const p of dbProjects) {
50
+ targets.set(path.resolve(p.path), {
51
+ id: p.id,
52
+ projectPath: path.resolve(p.path),
53
+ source: 'db',
54
+ });
55
+ }
56
+
57
+ // 2) Extra dirs under data/projects not present in DB
58
+ if (fs.existsSync(projectsDir)) {
59
+ const entries = fs.readdirSync(projectsDir, { withFileTypes: true });
60
+ for (const entry of entries) {
61
+ if (!entry.isDirectory()) continue;
62
+ const projectPath = path.resolve(path.join(projectsDir, entry.name));
63
+ if (targets.has(projectPath)) continue;
64
+ targets.set(projectPath, {
65
+ id: inferProjectIdFromDirName(entry.name),
66
+ projectPath,
67
+ source: 'scan',
68
+ });
69
+ }
70
+ }
71
+
72
+ if (targets.size === 0) {
73
+ console.log('[projects:migrate:defaults] No projects found.');
74
+ return;
75
+ }
76
+
77
+ console.log(`[projects:migrate:defaults] Found ${targets.size} projects.`);
78
+
79
+ let ok = 0;
80
+ let skipped = 0;
81
+ let failed = 0;
82
+
83
+ for (const target of targets.values()) {
84
+ try {
85
+ if (!fs.existsSync(target.projectPath)) {
86
+ console.log(`[projects:migrate:defaults] SKIP missing path: ${target.projectPath}`);
87
+ skipped++;
88
+ continue;
89
+ }
90
+
91
+ await setupProjectDefaults(target.projectPath, target.id, workspaceRoot);
92
+ console.log(`[projects:migrate:defaults] OK ${target.source} id=${target.id} path=${target.projectPath}`);
93
+ ok++;
94
+ } catch (error) {
95
+ console.error(`[projects:migrate:defaults] FAIL id=${target.id} path=${target.projectPath}`, error);
96
+ failed++;
97
+ }
98
+ }
99
+
100
+ console.log(`[projects:migrate:defaults] Done. ok=${ok} skipped=${skipped} failed=${failed}`);
101
+ if (failed > 0) process.exit(1);
102
+ }
103
+
104
+ run().catch((error) => {
105
+ console.error('[projects:migrate:defaults] Fatal error', error);
106
+ process.exit(1);
107
+ });
package/server.ts CHANGED
@@ -59,6 +59,13 @@ import type { AutopilotMode } from './src/lib/autopilot';
59
59
 
60
60
  import { getPort, getHostname } from './src/lib/server-port-configuration';
61
61
 
62
+ function isSqliteForeignKeyError(error: unknown): boolean {
63
+ if (!error || typeof error !== 'object') return false;
64
+ const maybeError = error as { code?: string; message?: string };
65
+ return maybeError.code === 'SQLITE_CONSTRAINT_FOREIGNKEY'
66
+ || (typeof maybeError.message === 'string' && maybeError.message.includes('FOREIGN KEY constraint failed'));
67
+ }
68
+
62
69
  const dev = process.env.NODE_ENV !== 'production';
63
70
  const hostname = getHostname();
64
71
  const port = getPort();
@@ -72,6 +79,26 @@ app.prepare().then(async () => {
72
79
  const httpServer = createServer((req, res) => {
73
80
  const parsedUrl = parse(req.url!, true);
74
81
  const pathname = parsedUrl.pathname || '';
82
+ const requestStartedAt = Date.now();
83
+ const shouldTraceRequest =
84
+ pathname.startsWith('/api/attempts')
85
+ || pathname.includes('/conversation')
86
+ || pathname.includes('/running-attempt')
87
+ || pathname.includes('/pending-question')
88
+ || pathname.includes('/agent-factory/projects/');
89
+
90
+ if (shouldTraceRequest) {
91
+ res.on('finish', () => {
92
+ const durationMs = Date.now() - requestStartedAt;
93
+ log.info({
94
+ method: req.method,
95
+ path: pathname,
96
+ statusCode: res.statusCode,
97
+ durationMs,
98
+ hasApiKey: Boolean(req.headers['x-api-key']),
99
+ }, '[Server] HTTP request traced');
100
+ });
101
+ }
75
102
 
76
103
  // API authentication check - read from process.env directly for immediate effect
77
104
  const apiAccessKey = process.env.API_ACCESS_KEY;
@@ -1036,11 +1063,19 @@ app.prepare().then(async () => {
1036
1063
 
1037
1064
  if (!isStreamingDelta) {
1038
1065
  // Save to database (only complete messages)
1039
- await db.insert(schema.attemptLogs).values({
1040
- attemptId,
1041
- type: 'json',
1042
- content: JSON.stringify(data),
1043
- });
1066
+ try {
1067
+ await db.insert(schema.attemptLogs).values({
1068
+ attemptId,
1069
+ type: 'json',
1070
+ content: JSON.stringify(data),
1071
+ });
1072
+ } catch (error) {
1073
+ if (isSqliteForeignKeyError(error)) {
1074
+ log.warn({ attemptId }, '[Server] Skipping JSON log write because attempt no longer exists');
1075
+ } else {
1076
+ throw error;
1077
+ }
1078
+ }
1044
1079
  }
1045
1080
 
1046
1081
  // Check how many clients are in the room
@@ -1106,11 +1141,19 @@ app.prepare().then(async () => {
1106
1141
  });
1107
1142
 
1108
1143
  agentManager.on('stderr', async ({ attemptId, content }) => {
1109
- await db.insert(schema.attemptLogs).values({
1110
- attemptId,
1111
- type: 'stderr',
1112
- content,
1113
- });
1144
+ try {
1145
+ await db.insert(schema.attemptLogs).values({
1146
+ attemptId,
1147
+ type: 'stderr',
1148
+ content,
1149
+ });
1150
+ } catch (error) {
1151
+ if (isSqliteForeignKeyError(error)) {
1152
+ log.warn({ attemptId }, '[Server] Skipping stderr log write because attempt no longer exists');
1153
+ } else {
1154
+ throw error;
1155
+ }
1156
+ }
1114
1157
 
1115
1158
  io.to(`attempt:${attemptId}`).emit('output:stderr', { attemptId, content });
1116
1159
  });
@@ -1382,6 +1425,7 @@ app.prepare().then(async () => {
1382
1425
 
1383
1426
  // Register exit event handler
1384
1427
  agentManager.on('exit', async ({ attemptId, code }) => {
1428
+ try {
1385
1429
  // Get attempt to retrieve taskId and current status
1386
1430
  const attempt = await db.query.attempts.findFirst({
1387
1431
  where: eq(schema.attempts.id, attemptId),
@@ -1599,6 +1643,13 @@ app.prepare().then(async () => {
1599
1643
  usageTracker.clearSession(attemptId);
1600
1644
  workflowTracker.clearWorkflow(attemptId);
1601
1645
  gitStatsCache.clear(attemptId);
1646
+ } catch (error) {
1647
+ if (isSqliteForeignKeyError(error)) {
1648
+ log.warn({ attemptId }, '[Server] Ignoring FK error in exit handler (attempt/task deleted)');
1649
+ } else {
1650
+ log.error({ attemptId, error }, '[Server] Exit handler failed');
1651
+ }
1652
+ }
1602
1653
  });
1603
1654
 
1604
1655
  // Forward tracking module events to Socket.io clients
@@ -82,6 +82,14 @@ export async function POST(
82
82
  return NextResponse.json({ error: 'Project not found' }, { status: 404 });
83
83
  }
84
84
 
85
+ log.info(
86
+ {
87
+ projectId,
88
+ projectPath: project.path,
89
+ },
90
+ 'Resolved project path for sync'
91
+ );
92
+
85
93
  let parsedBody: unknown = {};
86
94
  try {
87
95
  parsedBody = await request.json();
@@ -95,6 +103,7 @@ export async function POST(
95
103
  log.info(
96
104
  {
97
105
  projectId,
106
+ projectPath: project.path,
98
107
  body,
99
108
  componentIds,
100
109
  agentSetIds,
@@ -106,12 +115,39 @@ export async function POST(
106
115
  await upsertProjectSettings(project.path, componentIds, agentSetIds);
107
116
  }
108
117
 
118
+ log.info(
119
+ {
120
+ projectId,
121
+ projectPath: project.path,
122
+ },
123
+ 'Starting AgentFactory project sync'
124
+ );
125
+
109
126
  const result = await agentFactoryService.syncProject(projectId, project.path);
110
127
 
111
128
  if (!result.success && result.error) {
129
+ log.warn(
130
+ {
131
+ projectId,
132
+ projectPath: project.path,
133
+ error: result.error,
134
+ },
135
+ 'AgentFactory project sync failed'
136
+ );
112
137
  return NextResponse.json({ error: result.error }, { status: 400 });
113
138
  }
114
139
 
140
+ log.info(
141
+ {
142
+ projectId,
143
+ projectPath: project.path,
144
+ installedCount: result.installed?.length ?? 0,
145
+ skippedCount: result.skipped?.length ?? 0,
146
+ errorCount: result.errors?.length ?? 0,
147
+ },
148
+ 'AgentFactory project sync completed'
149
+ );
150
+
115
151
  return NextResponse.json({
116
152
  success: true,
117
153
  message: `Installed ${result.installed?.length ?? 0} components to project`,
@@ -7,4 +7,7 @@
7
7
  API_HOOK_URL_LOCAL="http://localhost:5005/api/sync/"
8
8
  API_HOOK_URL_DOMAIN="https://privos-chat-dev.roxane.one/api/v1/internal/rooms/room_id/files/"
9
9
  API_HOOK_API_KEY=
10
+ API_QUEUE_URL="http://localhost:8052"
11
+ # Optional comma-separated extra queue endpoints/base URLs
12
+ # API_QUEUE_FALLBACK_URLS="http://localhost:8053,http://localhost:3000"
10
13
  PROJECT_ID="your-project-id"
@@ -20,6 +20,8 @@ if (!config.apiBaseUrl) {
20
20
  }
21
21
 
22
22
  const PULL_DB_PATH = path.join(hooksDir, "pull-sync-state.db");
23
+ const TMP_DIR = path.join(process.cwd(), ".claude", "tmp");
24
+ const PUSH_STATE_FILE = path.join(TMP_DIR, "local-sync-state.json");
23
25
 
24
26
  function buildApiUrl(endpoint: string): string {
25
27
  const base = String(config.apiBaseUrl || "").replace(/\/+$/g, "");
@@ -111,12 +113,17 @@ async function ensurePullDb(): Promise<Database.Database> {
111
113
  return sqlite;
112
114
  }
113
115
 
114
- async function fetchManifest(folder: string, label: string): Promise<ManifestEntry[]> {
116
+ async function fetchManifest(folder: string, label: string, allowNotFound = false): Promise<ManifestEntry[]> {
115
117
  console.error(`🔍 Calling API to get manifest for '${label}' (${folder})...`);
116
118
 
117
119
  const url = buildApiUrl(`manifest?folder=${encodeURIComponent(folder)}`);
118
120
  const response = await fetch(url, { headers: buildApiHeaders() });
119
121
 
122
+ if (response.status === 404 && allowNotFound) {
123
+ console.error(`ℹ️ ${label} not found on remote, treating as empty manifest.`);
124
+ return [];
125
+ }
126
+
120
127
  if (!response.ok) {
121
128
  throw new Error(`API manifest failed for ${label}: HTTP ${response.status} ${response.statusText}`);
122
129
  }
@@ -132,13 +139,13 @@ async function fetchManifest(folder: string, label: string): Promise<ManifestEnt
132
139
  return objects;
133
140
  }
134
141
 
135
- async function getQueueCandidates(): Promise<QueueFileCandidate[]> {
142
+ async function getQueueCandidates(): Promise<{ candidates: QueueFileCandidate[]; manifestData: ManifestEntry[] }> {
136
143
  const mainPrefix = config.projectId;
137
144
  const markdownPrefix = `markdown/${config.projectId}`;
138
145
 
139
146
  const [mainManifest, markdownManifest] = await Promise.all([
140
147
  fetchManifest(mainPrefix, "main folder"),
141
- fetchManifest(markdownPrefix, "markdown folder"),
148
+ fetchManifest(markdownPrefix, "markdown folder", true),
142
149
  ]);
143
150
 
144
151
  const normalize = (entries: ManifestEntry[], folder: FolderType): QueueFileCandidate[] => (
@@ -152,7 +159,15 @@ async function getQueueCandidates(): Promise<QueueFileCandidate[]> {
152
159
  }))
153
160
  );
154
161
 
155
- return [...normalize(mainManifest, "main"), ...normalize(markdownManifest, "markdown")];
162
+ return {
163
+ candidates: [...normalize(mainManifest, "main"), ...normalize(markdownManifest, "markdown")],
164
+ manifestData: [...mainManifest, ...markdownManifest],
165
+ };
166
+ }
167
+
168
+ async function writePushStateFile(manifestData: ManifestEntry[]): Promise<void> {
169
+ await fs.mkdir(TMP_DIR, { recursive: true });
170
+ await fs.writeFile(PUSH_STATE_FILE, JSON.stringify(manifestData, null, 2), "utf-8");
156
171
  }
157
172
 
158
173
  function enqueueCandidates(
@@ -249,12 +264,14 @@ async function runEnqueue() {
249
264
  let sqlite: Database.Database | null = null;
250
265
  try {
251
266
  console.error("\n=================== ENQUEUE MINIO PULL JOB ===================");
252
- const candidates = await getQueueCandidates();
267
+ const { candidates, manifestData } = await getQueueCandidates();
268
+ await writePushStateFile(manifestData);
253
269
 
254
270
  sqlite = await ensurePullDb();
255
271
  const result = enqueueCandidates(sqlite, candidates);
256
272
 
257
273
  console.error(`✅ Queue DB: ${PULL_DB_PATH}`);
274
+ console.error(`✅ Push state file: ${PUSH_STATE_FILE}`);
258
275
  console.error(`🧾 Job ID: ${result.jobId}`);
259
276
  console.error(`📊 Total: ${result.totalFiles} | Enqueued: ${result.enqueuedFiles} | Skipped: ${result.skippedFiles}`);
260
277
  console.error(`📌 Job status: ${result.status}`);
@@ -35,6 +35,12 @@ function createConcurrencyLimit(concurrency: number) {
35
35
  const config = {
36
36
  apiBaseUrl: process.env.API_HOOK_URL as string,
37
37
  apiHookApiKey: (process.env.API_HOOK_API_KEY || "").trim(),
38
+ apiQueueUrl: (process.env.API_QUEUE_URL || process.env.CLAUDE_WS_API_BASE_URL || "").trim(),
39
+ apiQueueFallbackUrls: (process.env.API_QUEUE_FALLBACK_URLS || "").trim(),
40
+ apiQueueKey: (process.env.API_HOOK_KEY || process.env.API_ACCESS_KEY || "").trim(),
41
+ appPort: (process.env.PORT || "").trim(),
42
+ queueHost: (process.env.API_QUEUE_HOST || "localhost").trim(),
43
+ projectId: (process.env.PROJECT_ID || "__PROJECT_ID__") as string,
38
44
  targetPrefix: (process.env.PROJECT_ID || "__PROJECT_ID__") as string,
39
45
  };
40
46
 
@@ -63,6 +69,83 @@ function buildApiHeaders(baseHeaders: Record<string, string> = {}): HeadersInit
63
69
  return headers;
64
70
  }
65
71
 
72
+ function toPushEndpoint(baseOrEndpoint: string): string {
73
+ const trimmed = String(baseOrEndpoint || "").trim().replace(/\/+$/g, "");
74
+ if (!trimmed) return "";
75
+ if (/\/api\/sync\/minio\/push$/i.test(trimmed)) return trimmed;
76
+ return `${trimmed}/api/sync/minio/push`;
77
+ }
78
+
79
+ function buildQueueCandidates(): string[] {
80
+ const candidates: string[] = [];
81
+ const push = (value: string) => {
82
+ const endpoint = toPushEndpoint(value);
83
+ if (endpoint && !candidates.includes(endpoint)) {
84
+ candidates.push(endpoint);
85
+ }
86
+ };
87
+
88
+ // Highest priority: explicit queue base URL
89
+ if (config.apiQueueUrl) {
90
+ push(config.apiQueueUrl);
91
+ }
92
+
93
+ // Default queue endpoint: localhost + PORT from environment
94
+ if (!config.apiQueueUrl && config.appPort) {
95
+ push(`http://${config.queueHost}:${config.appPort}`);
96
+ }
97
+
98
+ // Optional comma-separated list of extra queue base URLs/endpoints
99
+ if (config.apiQueueFallbackUrls) {
100
+ for (const raw of config.apiQueueFallbackUrls.split(",")) {
101
+ const value = raw.trim();
102
+ if (value) push(value);
103
+ }
104
+ }
105
+
106
+ // Last fallback: same origin as sync API base
107
+ try {
108
+ const origin = new URL(config.apiBaseUrl).origin;
109
+ push(origin);
110
+ } catch {
111
+ // ignore invalid URL
112
+ }
113
+
114
+ return candidates;
115
+ }
116
+
117
+ async function enqueuePushJob(): Promise<boolean> {
118
+ const endpoints = buildQueueCandidates();
119
+ if (endpoints.length === 0) return false;
120
+
121
+ for (const endpoint of endpoints) {
122
+ try {
123
+ const headers: Record<string, string> = { "Content-Type": "application/json" };
124
+ if (config.apiQueueKey) {
125
+ headers["x-api-key"] = config.apiQueueKey;
126
+ }
127
+
128
+ const response = await fetch(endpoint, {
129
+ method: "POST",
130
+ headers,
131
+ body: JSON.stringify({ projectId: config.projectId }),
132
+ });
133
+
134
+ if (response.ok) {
135
+ console.error(`✅ Queue accepted via ${endpoint}`);
136
+ return true;
137
+ }
138
+
139
+ console.error(`⚠️ Queue endpoint failed ${endpoint}: HTTP ${response.status}`);
140
+ } catch (error) {
141
+ const message = error instanceof Error ? error.message : String(error);
142
+ console.error(`⚠️ Queue endpoint unreachable ${endpoint}: ${message}`);
143
+ }
144
+ }
145
+
146
+ return false;
147
+ }
148
+
66
149
  // Function to create tmp directory
67
150
  async function ensureTmpDir() {
68
151
  await fs.mkdir(TMP_DIR, { recursive: true });
@@ -138,6 +221,14 @@ async function scanDirectory(dir: string, fileList: string[] = []) {
138
221
  // MAIN SYNC FUNCTION
139
222
  // ==========================================
140
223
  async function runCheck() {
224
+ // Preferred path: enqueue into server-side push queue (records jobs in push-sync-state.db)
225
+ const enqueued = await enqueuePushJob();
226
+ if (enqueued) {
227
+ return;
228
+ }
229
+
230
+ console.error("⚠️ Queue enqueue failed on all endpoints, falling back to direct upload mode.");
231
+
141
232
  await ensureTmpDir(); // Create tmp directory before running
142
233
  console.error(`🔍 Starting to scan files in '${LOCAL_DATA_DIR}' and compare with '${STATE_FILE}'...`);
143
234
 
@@ -268,6 +268,17 @@ export function initDb() {
268
268
  // Column already exists
269
269
  }
270
270
  }
271
+
272
+ // Cleanup inconsistent legacy rows created while FK checks were disabled.
273
+ // Keeps runtime stable and avoids unexpected FK errors in later writes.
274
+ sqlite.exec(`
275
+ DELETE FROM checkpoints
276
+ WHERE task_id NOT IN (SELECT id FROM tasks)
277
+ OR attempt_id NOT IN (SELECT id FROM attempts);
278
+
279
+ DELETE FROM attempts
280
+ WHERE task_id NOT IN (SELECT id FROM tasks);
281
+ `);
271
282
  }
272
283
 
273
284
  // Initialize on first import
@@ -52,6 +52,8 @@ export function formatOutput(
52
52
  return {
53
53
  formatted_data: formattedData,
54
54
  format,
55
+ messages,
56
+ status: attemptMetadata.status as any,
55
57
  attempt: {
56
58
  id: attemptMetadata.id,
57
59
  taskId: attemptMetadata.taskId,
@@ -66,6 +68,8 @@ export function formatOutput(
66
68
  return {
67
69
  formatted_data: toJson(messages),
68
70
  format: 'json',
71
+ messages,
72
+ status: attemptMetadata.status as any,
69
73
  attempt: {
70
74
  id: attemptMetadata.id,
71
75
  taskId: attemptMetadata.taskId,
@@ -101,12 +101,23 @@ export class ClaudeSDKProvider extends EventEmitter implements Provider {
101
101
  canUseToolCallback: this.makeCanUseTool(attemptId),
102
102
  });
103
103
 
104
+ const promptPreview = prompt.substring(0, 400) + (prompt.length > 400 ? '...' : '');
105
+ const shouldLogFullPrompt = process.env.SDK_LOG_FULL_PROMPT === '1';
106
+
104
107
  log.info({
108
+ attemptId,
105
109
  endpoint: process.env.ANTHROPIC_BASE_URL || 'https://api.anthropic.com',
106
- prompt: prompt.substring(0, 200) + (prompt.length > 200 ? '...' : ''),
107
- model: opts.model, cwd: opts.cwd,
110
+ projectPath,
111
+ cwd: opts.cwd,
112
+ model: opts.model,
113
+ promptLength: prompt.length,
114
+ promptPreview,
108
115
  }, 'SDK Query starting');
109
116
 
117
+ if (shouldLogFullPrompt) {
118
+ log.info({ attemptId, projectPath, prompt }, 'SDK Query full prompt');
119
+ }
120
+
110
121
  const response = query({ prompt, options: opts });
111
122
  session.queryRef = response;
112
123
 
@@ -41,6 +41,19 @@ export interface QueryOptionsBuilderParams {
41
41
  canUseToolCallback: CanUseToolCallback;
42
42
  }
43
43
 
44
+ type SettingSource = 'user' | 'project' | 'local';
45
+
46
+ function resolveSettingSources(): SettingSource[] {
47
+ const allowed = new Set<SettingSource>(['user', 'project', 'local']);
48
+ const raw = process.env.CLAUDE_SDK_SETTING_SOURCES;
49
+ if (!raw || raw.trim().length === 0) return ['project'];
50
+ const values = raw
51
+ .split(',')
52
+ .map((value) => value.trim())
53
+ .filter((value): value is SettingSource => allowed.has(value as SettingSource));
54
+ return values.length > 0 ? values : ['project'];
55
+ }
56
+
44
57
  /**
45
58
  * Build the options object for SDK query(), minus prompt.
46
59
  */
@@ -74,6 +87,7 @@ export function buildQueryOptions(params: QueryOptionsBuilderParams) {
74
87
  ...(maxTurns ? { maxTurns } : {}),
75
88
  abortController: controller,
76
89
  canUseTool: canUseToolCallback,
90
+ settingSources: resolveSettingSources(),
77
91
  env: buildIsolatedSubprocessEnv(model),
78
92
  };
79
93
 
@@ -14,6 +14,9 @@ export type RequestMethod = 'sync' | 'queue';
14
14
  export interface FormattedResponse {
15
15
  formatted_data: string;
16
16
  format: OutputFormat;
17
+ // Backward-compatible fields for clients expecting parsed messages/status.
18
+ messages?: ClaudeOutput[];
19
+ status?: AttemptStatus;
17
20
  attempt: {
18
21
  id: string;
19
22
  taskId: string;
package/tsconfig.json CHANGED
@@ -45,6 +45,8 @@
45
45
  "exclude": [
46
46
  "node_modules",
47
47
  "docs/**/*.ts",
48
- "data/**"
48
+ "data/projects/**/*.ts",
49
+ "data/projects/**/*.tsx",
50
+ "data/projects/**/*.mts"
49
51
  ]
50
52
  }