claude-ws 0.4.9-beta.6 → 0.4.9-beta.8

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 (32) hide show
  1. package/package.json +11 -5
  2. package/packages/agentic-sdk/README.md +3 -1
  3. package/packages/agentic-sdk/src/services/attempt/attempt-creation-orchestrator.ts +3 -2
  4. package/packages/agentic-sdk/src/services/command/slash-command-listing.ts +10 -5
  5. package/scripts/build-test-pm2.sh +12 -2
  6. package/scripts/check-dependencies.sh +3 -2
  7. package/scripts/migrate-all-projects.ts +287 -0
  8. package/scripts/migrate-existing-projects.ts +23 -9
  9. package/server.ts +22 -1
  10. package/src/app/api/attempts/route.ts +3 -2
  11. package/src/app/api/projects/route.ts +7 -2
  12. package/src/components/claude/tool-use-block-write-diff-renderer.tsx +24 -0
  13. package/src/components/claude/tool-use-block.tsx +12 -2
  14. package/src/components/kanban/create-task-dialog.tsx +17 -2
  15. package/src/components/sidebar/file-browser/use-file-tab-save-copy-download-operations.ts +4 -0
  16. package/src/components/sidebar/file-browser/use-file-tab-state.ts +5 -1
  17. package/src/components/task/file-mention-dropdown.tsx +5 -5
  18. package/src/components/task/floating-chat-window.tsx +9 -6
  19. package/src/components/task/prompt-input-form-body.tsx +1 -0
  20. package/src/components/task/task-detail-panel.tsx +2 -1
  21. package/src/components/terminal/terminal-local-echo.ts +133 -0
  22. package/src/components/terminal/use-terminal-lifecycle.ts +11 -3
  23. package/src/hooks/template/hooks/minio-pull-sync.ts +103 -23
  24. package/src/hooks/template/hooks/minio-push-sync.ts +86 -26
  25. package/src/lib/minio-pull-queue.ts +26 -40
  26. package/src/lib/minio-push-queue.ts +20 -36
  27. package/src/lib/project-utils.ts +22 -39
  28. package/src/lib/socket-service.ts +3 -0
  29. package/src/stores/project-store-crud-api-actions.ts +1 -1
  30. package/src/stores/terminal-store-socket-session-actions.ts +66 -10
  31. package/src/stores/terminal-store.ts +21 -4
  32. package/src/hooks/template/hooks/.env.example +0 -14
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-ws",
3
- "version": "0.4.9-beta.6",
3
+ "version": "0.4.9-beta.8",
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": [
@@ -67,7 +67,9 @@
67
67
  },
68
68
  "pnpm": {
69
69
  "overrides": {
70
- "axios": "0.30.3"
70
+ "axios": "0.30.3",
71
+ "picomatch@^2.3.1": "2.3.2",
72
+ "picomatch@^4.0.0": "4.0.4"
71
73
  },
72
74
  "onlyBuiltDependencies": [
73
75
  "@parcel/watcher",
@@ -95,7 +97,10 @@
95
97
  "db:fix": "tsx scripts/db-fix-columns.ts",
96
98
  "db:migrate:health": "tsx scripts/db-migrate-health.ts",
97
99
  "projects:migrate:defaults": "tsx scripts/migrate-existing-projects.ts",
98
- "migrate:sessions": "tsx scripts/migrate-sdk-sessions.ts"
100
+ "migrate:sessions": "tsx scripts/migrate-sdk-sessions.ts",
101
+ "migrate-hook-env": "tsx scripts/migrate-hook-env.ts",
102
+ "migrate-all-projects": "tsx scripts/migrate-all-projects.ts",
103
+ "heal-hooks": "tsx scripts/heal-hook-env.ts"
99
104
  },
100
105
  "dependencies": {
101
106
  "@anthropic-ai/claude-agent-sdk": "^0.2.81",
@@ -164,7 +169,6 @@
164
169
  "diff": "^8.0.3",
165
170
  "dompurify": "^3.3.3",
166
171
  "dotenv": "^17.3.1",
167
- "drizzle-kit": "^0.31.10",
168
172
  "drizzle-orm": "^0.45.1",
169
173
  "eslint": "^9.39.4",
170
174
  "eslint-config-next": "^16.2.1",
@@ -205,6 +209,8 @@
205
209
  "node-pty": "^1.1.0"
206
210
  },
207
211
  "devDependencies": {
208
- "@types/js-yaml": "^4.0.9"
212
+ "@tailwindcss/oxide-linux-x64-musl": "4.2.2",
213
+ "@types/js-yaml": "^4.0.9",
214
+ "drizzle-kit": "^0.31.10"
209
215
  }
210
216
  }
@@ -61,7 +61,7 @@ KEY="my-secret-key"
61
61
  # Create a project
62
62
  curl -X POST http://localhost:3100/api/projects \
63
63
  -H "x-api-key: $KEY" -H "Content-Type: application/json" \
64
- -d '{"name": "my-app", "path": "/home/user/my-app"}'
64
+ -d '{"name": "my-app", "path": "/home/user/my-app", "useHookTemplate": true}'
65
65
 
66
66
  # Create a task
67
67
  curl -X POST http://localhost:3100/api/tasks \
@@ -135,6 +135,7 @@ curl -X POST http://localhost:3100/api/attempts \
135
135
  "taskId": "task_...",
136
136
  "prompt": "Build the auth module",
137
137
  "force_create": true,
138
+ "use_hook_template": true,
138
139
  "projectName": "my-project",
139
140
  "taskTitle": "Auth module",
140
141
  "projectRootPath": "/path/to/project",
@@ -146,6 +147,7 @@ curl -X POST http://localhost:3100/api/attempts \
146
147
  ```
147
148
 
148
149
  - `force_create` — auto-creates project + task if they don't exist
150
+ - `use_hook_template` — only applied when `force_create=true` creates a new project (default: `true`)
149
151
  - `request_method` — `queue` (default) or `sync` (waits for completion)
150
152
  - `output_format` / `output_schema` — structured output instructions
151
153
 
@@ -11,6 +11,7 @@ export interface AttemptCreationInput {
11
11
  taskId: string;
12
12
  prompt: string;
13
13
  force_create?: boolean;
14
+ use_hook_template?: boolean;
14
15
  projectId?: string;
15
16
  projectName?: string;
16
17
  taskTitle?: string;
@@ -67,7 +68,7 @@ interface OrchestratorDeps {
67
68
  /** Callback to start the agent process (runtime singleton, lives outside SDK) */
68
69
  startAgent: (params: AgentStartParams) => void;
69
70
  defaultBasePath: string;
70
- onProjectForceCreated?: (project: any) => Promise<void> | void;
71
+ onProjectForceCreated?: (project: any, input: AttemptCreationInput) => Promise<void> | void;
71
72
  }
72
73
 
73
74
  export function createAttemptOrchestrator(deps: OrchestratorDeps) {
@@ -98,7 +99,7 @@ export function createAttemptOrchestrator(deps: OrchestratorDeps) {
98
99
  const project = await projectService.getById(task.projectId);
99
100
  if (!project) throw new AttemptValidationError('Project not found', 404);
100
101
  if (projectCreatedByForceCreate && onProjectForceCreated) {
101
- await onProjectForceCreated(project);
102
+ await onProjectForceCreated(project, input);
102
103
  }
103
104
 
104
105
  // Create attempt record
@@ -63,13 +63,15 @@ const BUILTIN_COMMANDS: CommandInfo[] = [
63
63
  { name: 'vim', description: 'Enter vim mode for multi-line input', isBuiltIn: true },
64
64
  ];
65
65
 
66
- function parseFrontmatter(content: string): { description?: string; argumentHint?: string } {
66
+ function parseFrontmatter(content: string): { name?: string; description?: string; argumentHint?: string } {
67
67
  const match = content.match(/^---\n([\s\S]*?)\n---/);
68
68
  if (!match) return {};
69
69
  const fm = match[1];
70
+ const nameMatch = fm.match(/name:\s*(.+)/);
70
71
  const desc = fm.match(/description:\s*(.+)/);
71
72
  const arg = fm.match(/argument-hint:\s*(.+)/);
72
73
  return {
74
+ name: nameMatch ? nameMatch[1].trim().replace(/^["']|["']$/g, '') : undefined,
73
75
  description: desc ? desc[1].trim().replace(/^["']|["']$/g, '') : undefined,
74
76
  argumentHint: arg ? arg[1].trim().replace(/^["']|["']$/g, '') : undefined,
75
77
  };
@@ -106,9 +108,7 @@ function scanSkillsDir(dir: string): CommandInfo[] {
106
108
  if (existsSync(skillFile)) {
107
109
  const content = readFileSync(skillFile, 'utf-8');
108
110
  const fm = parseFrontmatter(content);
109
- // parseFrontmatter needs to also extract 'name' field
110
- const nameMatch = content.match(/^---\n[\s\S]*?name:\s*(.+?)[\s\S]*?\n---/);
111
- const name = nameMatch ? nameMatch[1].trim().replace(/^["']|["']$/g, '') : item;
111
+ const name = fm.name || item;
112
112
  skills.push({ name, description: fm.description || `Run /${item} skill`, argumentHint: fm.argumentHint });
113
113
  } else {
114
114
  skills.push(...scanSkillsDir(itemPath));
@@ -182,7 +182,12 @@ export function createCommandService() {
182
182
  }
183
183
  }
184
184
 
185
- const all = [...BUILTIN_COMMANDS, ...userCommands, ...skills];
185
+ // Deduplicate: user commands and skills override built-ins with same name
186
+ const byName = new Map<string, CommandInfo>();
187
+ for (const cmd of [...BUILTIN_COMMANDS, ...userCommands, ...skills]) {
188
+ byName.set(cmd.name, cmd);
189
+ }
190
+ const all = Array.from(byName.values());
186
191
  all.sort((a, b) => a.name.localeCompare(b.name));
187
192
  return all;
188
193
  },
@@ -14,7 +14,17 @@ if [[ -n "${HEALTH_URL:-}" ]]; then
14
14
  else
15
15
  PORT_VALUE="${PORT:-8556}"
16
16
  if [[ -f .env ]]; then
17
- ENV_PORT="$(grep -E '^PORT=' .env | tail -n1 | cut -d'=' -f2- | tr -d '"' | tr -d "'" || true)"
17
+ ENV_PORT="$(
18
+ awk -F '=' '
19
+ /^[[:space:]]*(export[[:space:]]+)?PORT[[:space:]]*=/ {
20
+ sub(/^[[:space:]]*(export[[:space:]]+)?PORT[[:space:]]*=[[:space:]]*/, "", $0)
21
+ gsub(/^[[:space:]]+|[[:space:]]+$/, "", $0)
22
+ gsub(/^["'"'"']|["'"'"']$/, "", $0)
23
+ gsub(/\r/, "", $0)
24
+ print $0
25
+ }
26
+ ' .env | tail -n1
27
+ )"
18
28
  if [[ -n "$ENV_PORT" ]]; then
19
29
  PORT_VALUE="$ENV_PORT"
20
30
  fi
@@ -37,7 +47,7 @@ need_cmd pm2
37
47
  need_cmd curl
38
48
 
39
49
  echo "📦 Building project..."
40
- $BUILD_CMD
50
+ bash -lc "$BUILD_CMD"
41
51
 
42
52
  echo "🚀 Starting/Restarting PM2 app: $APP_NAME"
43
53
  if pm2 describe "$APP_NAME" >/dev/null 2>&1; then
@@ -5,9 +5,10 @@ echo "🔍 Checking dependencies for production build..."
5
5
  echo ""
6
6
 
7
7
  # Find all imports from source files
8
- echo "Scanning imports in src/, server.ts, drizzle.config.ts..."
8
+ echo "Scanning imports in src/, server.ts, next.config.ts..."
9
9
  IMPORTS=$(find src -name "*.ts" -o -name "*.tsx" | xargs grep -h "^import.*from ['\"]" | sed -E "s/.*from ['\"]([^'\"\/]+).*/\1/" | sort -u)
10
- ROOT_IMPORTS=$(grep -h "^import.*from ['\"]" drizzle.config.ts next.config.ts server.ts 2>/dev/null | sed -E "s/.*from ['\"]([^'\"\/]+).*/\1/" | sort -u)
10
+ # Exclude dev-only config files (drizzle.config.ts) they only run during development
11
+ ROOT_IMPORTS=$(grep -h "^import.*from ['\"]" next.config.ts server.ts 2>/dev/null | sed -E "s/.*from ['\"]([^'\"\/]+).*/\1/" | sort -u)
11
12
 
12
13
  ALL_IMPORTS=$(echo -e "$IMPORTS\n$ROOT_IMPORTS" | sort -u | grep -v "^\." | grep -v "^@/" | grep -v "^node:")
13
14
 
@@ -0,0 +1,287 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * Migrate all projects to the current hook template contract.
4
+ *
5
+ * What it does:
6
+ * 1. Sync `minio-pull-sync.ts`, `minio-push-sync.ts`, `.claude/settings.json`
7
+ * 2. Inject project id into hook scripts (via setupProjectDefaults)
8
+ * 3. Remove legacy hook env files (`hook.env`, `.env`, `hook.env.example`, `.env.example`)
9
+ *
10
+ * Usage:
11
+ * pnpm migrate-all-projects # Dry run
12
+ * pnpm migrate-all-projects --force # Execute migration
13
+ */
14
+
15
+ import path from 'path';
16
+ import fs from 'fs/promises';
17
+ import fsSync from 'fs';
18
+ import { db, schema } from '../src/lib/db';
19
+ import { setupProjectDefaults } from '../src/lib/project-utils';
20
+
21
+ interface ProjectTarget {
22
+ id: string;
23
+ name: string;
24
+ path: string;
25
+ source: 'db' | 'scan';
26
+ }
27
+
28
+ interface MigrationResult {
29
+ projectId: string;
30
+ projectName: string;
31
+ projectPath: string;
32
+ actions: string[];
33
+ status: 'success' | 'error' | 'skipped' | 'updated';
34
+ error?: string;
35
+ }
36
+
37
+ interface MigrationReport {
38
+ totalProjects: number;
39
+ processed: number;
40
+ updated: number;
41
+ skipped: number;
42
+ errors: number;
43
+ results: MigrationResult[];
44
+ }
45
+
46
+ function inferProjectIdFromDirName(dirName: string): string {
47
+ const firstDash = dirName.indexOf('-');
48
+ if (firstDash <= 0) return dirName;
49
+ return dirName.slice(0, firstDash);
50
+ }
51
+
52
+ async function getAllProjectTargets(workspaceRoot: string): Promise<ProjectTarget[]> {
53
+ const dataDir = process.env.DATA_DIR || path.join(workspaceRoot, 'data');
54
+ const projectsDir = path.join(dataDir, 'projects');
55
+ const targets = new Map<string, ProjectTarget>();
56
+
57
+ const dbProjects = db.select({
58
+ id: schema.projects.id,
59
+ name: schema.projects.name,
60
+ path: schema.projects.path,
61
+ }).from(schema.projects).all();
62
+
63
+ for (const project of dbProjects) {
64
+ const absPath = path.resolve(project.path);
65
+ targets.set(absPath, {
66
+ id: project.id,
67
+ name: project.name || project.id,
68
+ path: absPath,
69
+ source: 'db',
70
+ });
71
+ }
72
+
73
+ if (fsSync.existsSync(projectsDir)) {
74
+ const entries = fsSync.readdirSync(projectsDir, { withFileTypes: true });
75
+ for (const entry of entries) {
76
+ if (!entry.isDirectory()) continue;
77
+ const absPath = path.resolve(path.join(projectsDir, entry.name));
78
+ if (targets.has(absPath)) continue;
79
+
80
+ targets.set(absPath, {
81
+ id: inferProjectIdFromDirName(entry.name),
82
+ name: entry.name,
83
+ path: absPath,
84
+ source: 'scan',
85
+ });
86
+ }
87
+ }
88
+
89
+ return [...targets.values()];
90
+ }
91
+
92
+ async function fileContent(filePath: string): Promise<string> {
93
+ return fs.readFile(filePath, 'utf-8');
94
+ }
95
+
96
+ function expectedPullContent(template: string, projectId: string): string {
97
+ return template.replace(/__PROJECT_ID__/g, projectId);
98
+ }
99
+
100
+ function expectedPushContent(template: string, projectId: string): string {
101
+ return template.replace(/__PROJECT_ID__/g, projectId);
102
+ }
103
+
104
+ async function getProjectDiffs(projectPath: string, projectId: string, workspaceRoot: string): Promise<string[]> {
105
+ const diffs: string[] = [];
106
+ const hooksDir = path.join(projectPath, '.claude', 'hooks');
107
+ const claudeDir = path.join(projectPath, '.claude');
108
+ const templateDir = path.join(workspaceRoot, 'src', 'hooks', 'template');
109
+ const templateHooksDir = path.join(templateDir, 'hooks');
110
+
111
+ const pullTemplate = await fileContent(path.join(templateHooksDir, 'minio-pull-sync.ts'));
112
+ const pushTemplate = await fileContent(path.join(templateHooksDir, 'minio-push-sync.ts'));
113
+ const settingsTemplate = await fileContent(path.join(templateDir, 'settings.json'));
114
+
115
+ const expectedPull = expectedPullContent(pullTemplate, projectId);
116
+ const expectedPush = expectedPushContent(pushTemplate, projectId);
117
+
118
+ const pullPath = path.join(hooksDir, 'minio-pull-sync.ts');
119
+ const pushPath = path.join(hooksDir, 'minio-push-sync.ts');
120
+ const settingsPath = path.join(claudeDir, 'settings.json');
121
+
122
+ if (!fsSync.existsSync(pullPath) || fsSync.readFileSync(pullPath, 'utf-8') !== expectedPull) {
123
+ diffs.push('sync:minio-pull-sync.ts');
124
+ }
125
+ if (!fsSync.existsSync(pushPath) || fsSync.readFileSync(pushPath, 'utf-8') !== expectedPush) {
126
+ diffs.push('sync:minio-push-sync.ts');
127
+ }
128
+ if (!fsSync.existsSync(settingsPath) || fsSync.readFileSync(settingsPath, 'utf-8') !== settingsTemplate) {
129
+ diffs.push('sync:.claude/settings.json');
130
+ }
131
+ for (const legacyName of ['hook.env', '.env', 'hook.env.example', '.env.example']) {
132
+ if (fsSync.existsSync(path.join(hooksDir, legacyName))) {
133
+ diffs.push(`remove:${legacyName}`);
134
+ }
135
+ }
136
+
137
+ return diffs;
138
+ }
139
+
140
+ async function removeLegacyHookFiles(projectPath: string): Promise<string[]> {
141
+ const hooksDir = path.join(projectPath, '.claude', 'hooks');
142
+ const removed: string[] = [];
143
+ for (const fileName of ['hook.env', '.env', 'hook.env.example', '.env.example']) {
144
+ const filePath = path.join(hooksDir, fileName);
145
+ if (!fsSync.existsSync(filePath)) continue;
146
+ await fs.rm(filePath, { force: true });
147
+ removed.push(fileName);
148
+ }
149
+ return removed;
150
+ }
151
+
152
+ async function migrateProject(target: ProjectTarget, execute: boolean, workspaceRoot: string): Promise<MigrationResult> {
153
+ const result: MigrationResult = {
154
+ projectId: target.id,
155
+ projectName: target.name,
156
+ projectPath: target.path,
157
+ actions: [],
158
+ status: 'success',
159
+ };
160
+
161
+ try {
162
+ if (!fsSync.existsSync(target.path)) {
163
+ result.status = 'skipped';
164
+ result.actions.push('Path does not exist');
165
+ return result;
166
+ }
167
+
168
+ const diffs = await getProjectDiffs(target.path, target.id, workspaceRoot);
169
+ if (diffs.length === 0) {
170
+ result.status = 'skipped';
171
+ result.actions.push('No changes needed');
172
+ return result;
173
+ }
174
+
175
+ if (!execute) {
176
+ result.status = 'updated';
177
+ for (const diff of diffs) {
178
+ result.actions.push(`[DRY RUN] Would ${diff}`);
179
+ }
180
+ return result;
181
+ }
182
+
183
+ await setupProjectDefaults(target.path, target.id, workspaceRoot, { useHookTemplate: true });
184
+ result.actions.push('Synced hook templates + settings');
185
+
186
+ const removed = await removeLegacyHookFiles(target.path);
187
+ for (const fileName of removed) {
188
+ result.actions.push(`Removed legacy ${fileName}`);
189
+ }
190
+
191
+ result.status = 'updated';
192
+ return result;
193
+ } catch (error: any) {
194
+ result.status = 'error';
195
+ result.error = error?.message || String(error);
196
+ result.actions.push(`Failed: ${result.error}`);
197
+ return result;
198
+ }
199
+ }
200
+
201
+ function printReport(report: MigrationReport, execute: boolean): void {
202
+ console.log('\n' + '='.repeat(80));
203
+ console.log(`📊 MIGRATION REPORT ${execute ? '(EXECUTED)' : '(DRY RUN)'}`);
204
+ console.log('='.repeat(80));
205
+ console.log(`Total projects: ${report.totalProjects}`);
206
+ console.log(`Processed: ${report.processed}`);
207
+ console.log(`Updated: ${report.updated}`);
208
+ console.log(`Skipped: ${report.skipped}`);
209
+ console.log(`Errors: ${report.errors}`);
210
+ console.log('='.repeat(80) + '\n');
211
+
212
+ for (const result of report.results) {
213
+ const statusIcon = result.status === 'error'
214
+ ? '❌'
215
+ : result.status === 'updated'
216
+ ? '🔄'
217
+ : result.status === 'skipped'
218
+ ? '⏭️'
219
+ : '✅';
220
+
221
+ console.log(`${statusIcon} ${result.projectName} (${result.projectId})`);
222
+ console.log(` Path: ${result.projectPath}`);
223
+ for (const action of result.actions) {
224
+ console.log(` - ${action}`);
225
+ }
226
+ if (result.error) {
227
+ console.log(` Error: ${result.error}`);
228
+ }
229
+ console.log('');
230
+ }
231
+ }
232
+
233
+ async function main() {
234
+ const args = process.argv.slice(2);
235
+ const execute = args.includes('--force');
236
+ const workspaceRoot = process.env.CLAUDE_WS_USER_CWD || process.cwd();
237
+
238
+ console.log('🚀 Starting project migration...');
239
+ console.log(`Mode: ${execute ? 'EXECUTE' : 'DRY RUN'}`);
240
+ console.log(`Workspace: ${workspaceRoot}\n`);
241
+
242
+ const targets = await getAllProjectTargets(workspaceRoot);
243
+ if (targets.length === 0) {
244
+ console.log('No projects found.');
245
+ return;
246
+ }
247
+
248
+ console.log(`Found ${targets.length} projects to process.\n`);
249
+
250
+ const report: MigrationReport = {
251
+ totalProjects: targets.length,
252
+ processed: 0,
253
+ updated: 0,
254
+ skipped: 0,
255
+ errors: 0,
256
+ results: [],
257
+ };
258
+
259
+ for (const target of targets) {
260
+ const result = await migrateProject(target, execute, workspaceRoot);
261
+ report.results.push(result);
262
+ report.processed += 1;
263
+
264
+ if (result.status === 'updated') report.updated += 1;
265
+ if (result.status === 'skipped') report.skipped += 1;
266
+ if (result.status === 'error') report.errors += 1;
267
+ }
268
+
269
+ printReport(report, execute);
270
+
271
+ if (!execute) {
272
+ console.log('🔎 Dry run completed. Re-run with --force to apply changes.');
273
+ return;
274
+ }
275
+
276
+ if (report.errors > 0) {
277
+ console.error(`\n⚠️ Migration completed with ${report.errors} error(s).`);
278
+ process.exit(1);
279
+ }
280
+
281
+ console.log('\n✅ Migration completed successfully.');
282
+ }
283
+
284
+ main().catch((error) => {
285
+ console.error('\n❌ Migration failed with fatal error:', error);
286
+ process.exit(1);
287
+ });
@@ -3,10 +3,9 @@
3
3
  * Migrate existing projects to current default Claude workspace structure.
4
4
  *
5
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
6
+ * - Syncs hook templates (`minio-pull-sync.ts`, `minio-push-sync.ts`) with injected project id
7
+ * - Syncs `.claude/settings.json` and `.claude/CLAUDE.md` from template
8
+ * - Removes legacy hook env files (`hook.env`, `.env`, `hook.env.example`, `.env.example`)
10
9
  *
11
10
  * Usage:
12
11
  * pnpm projects:migrate:defaults
@@ -25,12 +24,26 @@ type ProjectTarget = {
25
24
  };
26
25
 
27
26
  function inferProjectIdFromDirName(dirName: string): string {
28
- // Folders may be: "<projectId>" or "<projectId>-<name>"
29
27
  const firstDash = dirName.indexOf('-');
30
28
  if (firstDash <= 0) return dirName;
31
29
  return dirName.slice(0, firstDash);
32
30
  }
33
31
 
32
+ function removeLegacyHookEnvFiles(projectPath: string): number {
33
+ const hooksDir = path.join(projectPath, '.claude', 'hooks');
34
+ const legacyFiles = ['hook.env', '.env', 'hook.env.example', '.env.example'];
35
+ let removed = 0;
36
+
37
+ for (const fileName of legacyFiles) {
38
+ const filePath = path.join(hooksDir, fileName);
39
+ if (!fs.existsSync(filePath)) continue;
40
+ fs.rmSync(filePath, { force: true });
41
+ removed += 1;
42
+ }
43
+
44
+ return removed;
45
+ }
46
+
34
47
  async function run(): Promise<void> {
35
48
  const workspaceRoot = process.env.CLAUDE_WS_USER_CWD || process.cwd();
36
49
  config({ path: path.join(workspaceRoot, '.env') });
@@ -40,7 +53,6 @@ async function run(): Promise<void> {
40
53
 
41
54
  const targets = new Map<string, ProjectTarget>();
42
55
 
43
- // 1) Projects tracked in DB
44
56
  const dbProjects = db.select({
45
57
  id: schema.projects.id,
46
58
  path: schema.projects.path,
@@ -54,7 +66,6 @@ async function run(): Promise<void> {
54
66
  });
55
67
  }
56
68
 
57
- // 2) Extra dirs under data/projects not present in DB
58
69
  if (fs.existsSync(projectsDir)) {
59
70
  const entries = fs.readdirSync(projectsDir, { withFileTypes: true });
60
71
  for (const entry of entries) {
@@ -79,6 +90,7 @@ async function run(): Promise<void> {
79
90
  let ok = 0;
80
91
  let skipped = 0;
81
92
  let failed = 0;
93
+ let legacyRemoved = 0;
82
94
 
83
95
  for (const target of targets.values()) {
84
96
  try {
@@ -88,7 +100,9 @@ async function run(): Promise<void> {
88
100
  continue;
89
101
  }
90
102
 
91
- await setupProjectDefaults(target.projectPath, target.id, workspaceRoot);
103
+ await setupProjectDefaults(target.projectPath, target.id, workspaceRoot, { useHookTemplate: true });
104
+ legacyRemoved += removeLegacyHookEnvFiles(target.projectPath);
105
+
92
106
  console.log(`[projects:migrate:defaults] OK ${target.source} id=${target.id} path=${target.projectPath}`);
93
107
  ok++;
94
108
  } catch (error) {
@@ -97,7 +111,7 @@ async function run(): Promise<void> {
97
111
  }
98
112
  }
99
113
 
100
- console.log(`[projects:migrate:defaults] Done. ok=${ok} skipped=${skipped} failed=${failed}`);
114
+ console.log(`[projects:migrate:defaults] Done. ok=${ok} skipped=${skipped} failed=${failed} legacyRemoved=${legacyRemoved}`);
101
115
  if (failed > 0) process.exit(1);
102
116
  }
103
117
 
package/server.ts CHANGED
@@ -155,6 +155,10 @@ app.prepare().then(async () => {
155
155
  // Keep connections alive through Cloudflare Tunnel (100s idle timeout)
156
156
  pingInterval: 10000,
157
157
  pingTimeout: 10000,
158
+ // Compress WebSocket messages — terminal output is highly compressible text
159
+ perMessageDeflate: {
160
+ threshold: 256, // only compress messages larger than 256 bytes
161
+ },
158
162
  });
159
163
 
160
164
  // Restore autopilot state from DB (needs io for worker callbacks)
@@ -1040,8 +1044,25 @@ app.prepare().then(async () => {
1040
1044
  // Terminal Manager Event Handlers
1041
1045
  // ========================================
1042
1046
 
1047
+ // Batch rapid PTY output bursts into single socket messages (~16ms window).
1048
+ // Reduces message count significantly for high-throughput commands (ls, cat, etc.)
1049
+ const terminalOutputBuffers = new Map<string, string>();
1050
+ let terminalOutputFlushTimer: ReturnType<typeof setTimeout> | null = null;
1051
+
1052
+ function flushTerminalOutputBuffers() {
1053
+ terminalOutputFlushTimer = null;
1054
+ for (const [terminalId, buffered] of terminalOutputBuffers) {
1055
+ io.to(`terminal:${terminalId}`).emit('terminal:output', { terminalId, data: buffered });
1056
+ }
1057
+ terminalOutputBuffers.clear();
1058
+ }
1059
+
1043
1060
  terminalManager.on('output', ({ terminalId, data }) => {
1044
- io.to(`terminal:${terminalId}`).emit('terminal:output', { terminalId, data });
1061
+ const existing = terminalOutputBuffers.get(terminalId) || '';
1062
+ terminalOutputBuffers.set(terminalId, existing + data);
1063
+ if (!terminalOutputFlushTimer) {
1064
+ terminalOutputFlushTimer = setTimeout(flushTerminalOutputBuffers, 16);
1065
+ }
1045
1066
  });
1046
1067
 
1047
1068
  terminalManager.on('exit', ({ terminalId, exitCode, signal }) => {
@@ -18,8 +18,9 @@ function getOrchestrator() {
18
18
  sessionManager,
19
19
  startAgent: (params) => agentManager.start(params),
20
20
  defaultBasePath: process.env.CLAUDE_WS_USER_CWD || /* turbopackIgnore: true */ process.cwd(),
21
- onProjectForceCreated: async (project) => {
22
- await setupProjectDefaults(project.path, project.id);
21
+ onProjectForceCreated: async (project, input) => {
22
+ const shouldUseHookTemplate = typeof input?.use_hook_template === 'boolean' ? input.use_hook_template : true;
23
+ await setupProjectDefaults(project.path, project.id, process.cwd(), { useHookTemplate: shouldUseHookTemplate });
23
24
  },
24
25
  });
25
26
  }
@@ -17,9 +17,14 @@ export async function GET() {
17
17
  // POST /api/projects - Create a new project
18
18
  export async function POST(request: NextRequest) {
19
19
  try {
20
- const { id, name, path, projectId } = await request.json();
20
+ const { id, name, path, projectId, useHookTemplate, use_hook_template } = await request.json();
21
21
  const project = await projectService.createProject({ id: projectId || id, name, path });
22
- await setupProjectDefaults(project.path, project.id);
22
+ const shouldUseHookTemplate = typeof useHookTemplate === 'boolean'
23
+ ? useHookTemplate
24
+ : typeof use_hook_template === 'boolean'
25
+ ? use_hook_template
26
+ : true;
27
+ await setupProjectDefaults(project.path, project.id, process.cwd(), { useHookTemplate: shouldUseHookTemplate });
23
28
  return NextResponse.json(project, { status: 201 });
24
29
  } catch (error: any) {
25
30
  if (error instanceof ProjectValidationError) {
@@ -0,0 +1,24 @@
1
+ 'use client';
2
+
3
+ import { DiffView } from '@/components/claude/diff-view';
4
+
5
+ interface WriteBlockProps {
6
+ input: any;
7
+ result?: string;
8
+ isError?: boolean;
9
+ }
10
+
11
+ /** Renders a Write tool invocation as a diff view (shows new file content) */
12
+ export function WriteBlock({ input, result: _result, isError: _isError }: WriteBlockProps) {
13
+ if (!input?.content) {
14
+ return null;
15
+ }
16
+
17
+ return (
18
+ <DiffView
19
+ oldText=""
20
+ newText={input.content}
21
+ filePath={input.file_path}
22
+ />
23
+ );
24
+ }