claude-ws 0.4.9-beta.7 → 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 +6 -1
  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.7",
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
@@ -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
+ }
@@ -11,6 +11,7 @@ import { getToolIcon, getToolActiveVerb, getToolDisplay, getResultSummary } from
11
11
  import { BashBlock } from './tool-use-block-bash-command-renderer';
12
12
  import { TodoListBlock, type TodoItem } from './tool-use-block-todo-list-renderer';
13
13
  import { EditBlock } from './tool-use-block-edit-diff-renderer';
14
+ import { WriteBlock } from './tool-use-block-write-diff-renderer';
14
15
  import { AgentSpawnedCard } from './agent-spawned-card';
15
16
 
16
17
  interface ToolUseBlockProps {
@@ -57,13 +58,15 @@ export const ToolUseBlock = memo(function ToolUseBlock({ name, id, input, result
57
58
  // Determine if we have special display modes
58
59
  const isBash = name === 'Bash';
59
60
  const isEdit = name === 'Edit';
61
+ const isWrite = name === 'Write';
60
62
  const isTodoWrite = name === 'TodoWrite';
61
63
  const isAskUserQuestion = name === 'AskUserQuestion';
62
64
  const hasEditDiff = isEdit && Boolean(inputObj?.old_string) && Boolean(inputObj?.new_string);
65
+ const hasWriteContent = isWrite && Boolean(inputObj?.content);
63
66
  const hasTodos = isTodoWrite && Array.isArray(inputObj?.todos) && (inputObj.todos as TodoItem[]).length > 0;
64
67
 
65
- // For bash, edit with diff, and todo list, we show expanded content differently
66
- const showSpecialView = isBash || hasEditDiff || hasTodos;
68
+ // For bash, edit with diff, write with content, and todo list, we show expanded content differently
69
+ const showSpecialView = isBash || hasEditDiff || hasWriteContent || hasTodos;
67
70
 
68
71
  // For other tools, check if we have expandable details
69
72
  const hasOtherDetails = !showSpecialView && Boolean(result || (inputObj && Object.keys(inputObj).length > 1));
@@ -166,6 +169,13 @@ export const ToolUseBlock = memo(function ToolUseBlock({ name, id, input, result
166
169
  </div>
167
170
  )}
168
171
 
172
+ {/* Special view for Write with diff */}
173
+ {hasWriteContent && (
174
+ <div className="mt-1.5 ml-5 w-full max-w-full overflow-hidden pr-5">
175
+ <WriteBlock input={inputObj} result={result} isError={isError} />
176
+ </div>
177
+ )}
178
+
169
179
  {/* Special view for TodoWrite */}
170
180
  {hasTodos && (
171
181
  <div className="mt-1.5 ml-5 w-full max-w-full overflow-hidden pr-5">