ultraclaude-agent 0.0.21 → 0.0.23

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 (61) hide show
  1. package/__tests__/daemon-reconcile.test.ts +1 -0
  2. package/__tests__/daemon.test.ts +1 -0
  3. package/__tests__/hide-branches.test.ts +129 -0
  4. package/__tests__/logger-multistream.test.ts +151 -0
  5. package/__tests__/repl-reset.test.ts +3 -0
  6. package/__tests__/repl-status-account.test.ts +3 -0
  7. package/__tests__/repl.test.ts +7 -2
  8. package/__tests__/snapshot-sync.test.ts +6 -6
  9. package/__tests__/status-command.test.ts +479 -0
  10. package/__tests__/status-service-type.test.ts +177 -0
  11. package/__tests__/sync-bugs.test.ts +8 -7
  12. package/__tests__/sync-queue-credentials.test.ts +4 -4
  13. package/__tests__/sync-reorder.test.ts +8 -8
  14. package/__tests__/sync.test.ts +4 -3
  15. package/__tests__/version-check.test.ts +1 -1
  16. package/__tests__/version-watcher.test.ts +8 -2
  17. package/__tests__/watcher-branch.test.ts +68 -0
  18. package/dist/cli.js +6 -96
  19. package/dist/cli.js.map +1 -1
  20. package/dist/daemon.d.ts.map +1 -1
  21. package/dist/daemon.js +3 -70
  22. package/dist/daemon.js.map +1 -1
  23. package/dist/repl.d.ts.map +1 -1
  24. package/dist/repl.js +6 -143
  25. package/dist/repl.js.map +1 -1
  26. package/dist/sync.d.ts +13 -8
  27. package/dist/sync.d.ts.map +1 -1
  28. package/dist/sync.js +45 -21
  29. package/dist/sync.js.map +1 -1
  30. package/dist/watcher.d.ts +6 -0
  31. package/dist/watcher.d.ts.map +1 -1
  32. package/dist/watcher.js +92 -7
  33. package/dist/watcher.js.map +1 -1
  34. package/node_modules/@ultra-claude/shared/dist/api/schemas/projects.d.ts +11 -0
  35. package/node_modules/@ultra-claude/shared/dist/api/schemas/projects.d.ts.map +1 -1
  36. package/node_modules/@ultra-claude/shared/dist/api/schemas/projects.js +8 -0
  37. package/node_modules/@ultra-claude/shared/dist/api/schemas/projects.js.map +1 -1
  38. package/node_modules/@ultra-claude/shared/dist/api/schemas/sync.d.ts +11 -0
  39. package/node_modules/@ultra-claude/shared/dist/api/schemas/sync.d.ts.map +1 -1
  40. package/node_modules/@ultra-claude/shared/dist/api/schemas/sync.js +11 -0
  41. package/node_modules/@ultra-claude/shared/dist/api/schemas/sync.js.map +1 -1
  42. package/node_modules/@ultra-claude/shared/dist/index.d.ts +3 -3
  43. package/node_modules/@ultra-claude/shared/dist/index.d.ts.map +1 -1
  44. package/node_modules/@ultra-claude/shared/dist/index.js +2 -2
  45. package/node_modules/@ultra-claude/shared/dist/index.js.map +1 -1
  46. package/node_modules/@ultra-claude/shared/dist/types.d.ts +0 -32
  47. package/node_modules/@ultra-claude/shared/dist/types.d.ts.map +1 -1
  48. package/package.json +1 -1
  49. package/src/cli.ts +6 -120
  50. package/src/daemon.ts +3 -82
  51. package/src/repl.ts +6 -166
  52. package/src/sync.ts +56 -14
  53. package/src/watcher.ts +101 -7
  54. package/__tests__/claude-profiles-ops.test.ts +0 -441
  55. package/__tests__/claude-profiles.test.ts +0 -407
  56. package/__tests__/credential-watcher.test.ts +0 -229
  57. package/dist/claude-profiles.d.ts +0 -83
  58. package/dist/claude-profiles.d.ts.map +0 -1
  59. package/dist/claude-profiles.js +0 -499
  60. package/dist/claude-profiles.js.map +0 -1
  61. package/src/claude-profiles.ts +0 -597
package/src/sync.ts CHANGED
@@ -107,10 +107,11 @@ export async function apiRequestWithDiskCredentials(
107
107
 
108
108
  type ManifestError = 'NOT_LOGGED_IN' | 'NETWORK_ERROR' | 'MANIFEST_FETCH_FAILED' | 'PROJECT_NOT_FOUND';
109
109
 
110
- export async function fetchManifest(projectId: string, credentials: AgentCredentials): Promise<Result<ManifestEntry[], ManifestError>> {
110
+ export async function fetchManifest(projectId: string, credentials: AgentCredentials, branch?: string): Promise<Result<ManifestEntry[], ManifestError>> {
111
111
  const log = logger.child({ projectId, op: 'fetchManifest' });
112
112
 
113
- const result = await apiRequest('GET', `/api/sync/manifest?projectId=${projectId}`, undefined, credentials);
113
+ const branchParam = branch ? `&branch=${encodeURIComponent(branch)}` : '';
114
+ const result = await apiRequest('GET', `/api/sync/manifest?projectId=${projectId}${branchParam}`, undefined, credentials);
114
115
  if (!result.success) return result;
115
116
 
116
117
  if (result.data.status === 404) {
@@ -142,9 +143,9 @@ export async function fetchManifest(projectId: string, credentials: AgentCredent
142
143
  /**
143
144
  * Load manifest from server and populate local section hash cache.
144
145
  */
145
- export async function loadManifestIntoCache(projectId: string, credentials: AgentCredentials): Promise<void> {
146
+ export async function loadManifestIntoCache(projectId: string, credentials: AgentCredentials, branch?: string): Promise<void> {
146
147
  const state = getOrCreateState(projectId);
147
- const result = await fetchManifest(projectId, credentials);
148
+ const result = await fetchManifest(projectId, credentials, branch);
148
149
 
149
150
  if (!result.success) {
150
151
  logger.warn({ projectId, error: result.error, message: result.message }, 'Could not load manifest — will push all sections');
@@ -172,6 +173,7 @@ export async function syncMarkdownFile(
172
173
  projectPath: string,
173
174
  absoluteFilePath: string,
174
175
  credentials: AgentCredentials,
176
+ branch: string,
175
177
  ): Promise<number> {
176
178
  const log = logger.child({ projectId, file: absoluteFilePath });
177
179
 
@@ -233,6 +235,7 @@ export async function syncMarkdownFile(
233
235
 
234
236
  const payload = {
235
237
  projectId,
238
+ branch,
236
239
  file: relativePath,
237
240
  file_hash: fileHash,
238
241
  sections: sectionsToSend.map((s) => ({
@@ -279,6 +282,7 @@ export async function syncJsonFile(
279
282
  projectPath: string,
280
283
  absoluteFilePath: string,
281
284
  credentials: AgentCredentials,
285
+ branch: string,
282
286
  ): Promise<boolean> {
283
287
  const log = logger.child({ projectId, file: absoluteFilePath });
284
288
 
@@ -301,6 +305,7 @@ export async function syncJsonFile(
301
305
 
302
306
  const payload = {
303
307
  projectId,
308
+ branch,
304
309
  files: [
305
310
  {
306
311
  path: relativePath,
@@ -336,11 +341,12 @@ export async function syncFile(
336
341
  projectPath: string,
337
342
  absoluteFilePath: string,
338
343
  credentials: AgentCredentials,
344
+ branch: string,
339
345
  ): Promise<void> {
340
346
  if (absoluteFilePath.endsWith('.md')) {
341
- await syncMarkdownFile(projectId, projectPath, absoluteFilePath, credentials);
347
+ await syncMarkdownFile(projectId, projectPath, absoluteFilePath, credentials, branch);
342
348
  } else if (absoluteFilePath.endsWith('.json')) {
343
- await syncJsonFile(projectId, projectPath, absoluteFilePath, credentials);
349
+ await syncJsonFile(projectId, projectPath, absoluteFilePath, credentials, branch);
344
350
  }
345
351
  }
346
352
 
@@ -426,6 +432,7 @@ export async function createProjectOnServer(
426
432
  export async function createSnapshot(
427
433
  projectId: string,
428
434
  name: string,
435
+ branch: string,
429
436
  commitHash?: string,
430
437
  trigger: string = 'manual',
431
438
  credentials?: AgentCredentials,
@@ -433,11 +440,13 @@ export async function createSnapshot(
433
440
  const log = logger.child({ projectId, op: 'snapshot' });
434
441
  const idempotencyKey = crypto.randomUUID();
435
442
 
443
+ const body = { projectId, name, branch, commitHash, trigger };
444
+
436
445
  const requestFn = credentials
437
- ? apiRequest('POST', '/api/sync/snapshot', { projectId, name, commitHash, trigger }, credentials, {
446
+ ? apiRequest('POST', '/api/sync/snapshot', body, credentials, {
438
447
  'Idempotency-Key': idempotencyKey,
439
448
  })
440
- : apiRequestWithDiskCredentials('POST', '/api/sync/snapshot', { projectId, name, commitHash, trigger }, {
449
+ : apiRequestWithDiskCredentials('POST', '/api/sync/snapshot', body, {
441
450
  'Idempotency-Key': idempotencyKey,
442
451
  });
443
452
  const result = await requestFn;
@@ -467,13 +476,14 @@ export async function deleteFiles(
467
476
  projectPath: string,
468
477
  absoluteFilePaths: string[],
469
478
  credentials: AgentCredentials,
479
+ branch: string,
470
480
  ): Promise<boolean> {
471
481
  const log = logger.child({ projectId, op: 'deleteFiles' });
472
482
  const docDir = join(projectPath, 'documentation');
473
483
 
474
484
  const relativePaths = absoluteFilePaths.map((p) => relative(docDir, p));
475
485
 
476
- const payload = { projectId, files: relativePaths };
486
+ const payload = { projectId, branch, files: relativePaths };
477
487
  const idempotencyKey = crypto.randomUUID();
478
488
 
479
489
  const result = await apiRequest('POST', '/api/sync/delete-files', payload, credentials, {
@@ -513,10 +523,11 @@ async function reconcile(
513
523
  projectId: string,
514
524
  activePaths: string[],
515
525
  credentials: AgentCredentials,
526
+ branch: string,
516
527
  ): Promise<void> {
517
528
  const log = logger.child({ projectId, op: 'reconcile' });
518
529
 
519
- const payload = { projectId, filePaths: activePaths };
530
+ const payload = { projectId, branch, filePaths: activePaths };
520
531
  const idempotencyKey = crypto.randomUUID();
521
532
 
522
533
  const result = await apiRequest('POST', '/api/sync/reconcile', payload, credentials, {
@@ -541,17 +552,48 @@ async function reconcile(
541
552
  }
542
553
  }
543
554
 
555
+ // --- Branch hide ---
556
+
557
+ /**
558
+ * Notify the server that branches have been deleted locally.
559
+ * Server marks them as hidden (not deleted — they can be revealed in the dashboard).
560
+ */
561
+ export async function hideBranches(
562
+ projectId: string,
563
+ branches: string[],
564
+ credentials: AgentCredentials,
565
+ ): Promise<boolean> {
566
+ const log = logger.child({ projectId, op: 'hideBranches' });
567
+
568
+ const payload = { projectId, branches };
569
+ const idempotencyKey = crypto.randomUUID();
570
+
571
+ const result = await apiRequest('POST', '/api/sync/branch-hide', payload, credentials, {
572
+ 'Idempotency-Key': idempotencyKey,
573
+ });
574
+
575
+ if (!result.success || !result.data.ok) {
576
+ const reason = !result.success ? result.message : `HTTP ${result.data.status}`;
577
+ log.warn({ error: reason, branches }, 'Branch hide request failed');
578
+ return false;
579
+ }
580
+
581
+ log.info({ branches }, 'Branches hidden on server');
582
+ return true;
583
+ }
584
+
544
585
  // --- Initial sync for a project ---
545
586
 
546
587
  export async function initialSync(
547
588
  projectId: string,
548
589
  projectPath: string,
549
590
  credentials: AgentCredentials,
591
+ branch: string,
550
592
  ): Promise<void> {
551
593
  const log = logger.child({ projectId, op: 'initialSync' });
552
- log.info('Starting initial sync');
594
+ log.info({ branch }, 'Starting initial sync');
553
595
 
554
- await loadManifestIntoCache(projectId, credentials);
596
+ await loadManifestIntoCache(projectId, credentials, branch);
555
597
 
556
598
  // Walk documentation/ and sync all files, collecting relative paths
557
599
  const { glob } = await import('node:fs/promises');
@@ -567,11 +609,11 @@ export async function initialSync(
567
609
  const relPath = relative(docDir, entry);
568
610
  activePaths.push(relPath);
569
611
 
570
- await syncFile(projectId, projectPath, entry, credentials);
612
+ await syncFile(projectId, projectPath, entry, credentials, branch);
571
613
  }
572
614
 
573
615
  // Reconcile: prune server-side data for files that no longer exist on disk
574
- await reconcile(projectId, activePaths, credentials);
616
+ await reconcile(projectId, activePaths, credentials, branch);
575
617
 
576
618
  log.info('Initial sync complete');
577
619
  } catch (err) {
package/src/watcher.ts CHANGED
@@ -2,8 +2,10 @@
2
2
  // Watches documentation/ for changes, triggers section-level sync
3
3
 
4
4
  import chokidar from 'chokidar';
5
+ import { execFile } from 'node:child_process';
6
+ import { readFileSync } from 'node:fs';
5
7
  import { join, basename } from 'node:path';
6
- import { syncFile, deleteFiles, initialSync, createSnapshot, pushVersionMetadata } from './sync.js';
8
+ import { syncFile, deleteFiles, initialSync, createSnapshot, pushVersionMetadata, hideBranches } from './sync.js';
7
9
  import { logger } from './logger.js';
8
10
  import type { AgentCredentials } from '@ultra-claude/shared';
9
11
 
@@ -22,6 +24,49 @@ export interface ProjectWatcher {
22
24
  close(): Promise<void>;
23
25
  }
24
26
 
27
+ /**
28
+ * Read the current branch name from .git/HEAD.
29
+ * Returns branch name for normal refs, "HEAD-{short-sha}" for detached HEAD,
30
+ * or "unknown" if .git/HEAD cannot be read.
31
+ */
32
+ export function getCurrentBranch(projectPath: string): string {
33
+ const headPath = join(projectPath, '.git', 'HEAD');
34
+ try {
35
+ const content = readFileSync(headPath, 'utf8').trim();
36
+ if (content.startsWith('ref: refs/heads/')) {
37
+ return content.slice('ref: refs/heads/'.length);
38
+ }
39
+ // Detached HEAD — use short SHA
40
+ if (/^[0-9a-f]{40}$/.test(content)) {
41
+ return `HEAD-${content.slice(0, 8)}`;
42
+ }
43
+ return 'unknown';
44
+ } catch {
45
+ return 'unknown';
46
+ }
47
+ }
48
+
49
+ /**
50
+ * List local branches using git for-each-ref (handles packed-refs).
51
+ * Returns a promise resolving to an array of branch names.
52
+ */
53
+ function getLocalBranches(projectPath: string): Promise<string[]> {
54
+ return new Promise((resolve) => {
55
+ execFile(
56
+ 'git',
57
+ ['for-each-ref', '--format=%(refname:short)', 'refs/heads/'],
58
+ { cwd: projectPath, encoding: 'utf8' },
59
+ (error, stdout) => {
60
+ if (error) {
61
+ resolve([]);
62
+ return;
63
+ }
64
+ resolve(stdout.trim().split('\n').filter(Boolean));
65
+ },
66
+ );
67
+ });
68
+ }
69
+
25
70
  /**
26
71
  * Start watching a project's documentation/ directory for changes.
27
72
  * Also watches .git/HEAD for branch switches (triggers full resync).
@@ -36,6 +81,19 @@ export function startProjectWatcher(options: ProjectWatcherOptions): ProjectWatc
36
81
  // Credentials are always provided by the daemon — no disk fallback needed
37
82
  const credentialsPromise = Promise.resolve(credentials);
38
83
 
84
+ // Branch state — read on startup, updated on .git/HEAD change
85
+ let currentBranch = getCurrentBranch(projectPath);
86
+ log.info({ branch: currentBranch }, 'Initial branch detected');
87
+
88
+ // Track known branches for deletion detection
89
+ const knownBranches = new Set<string>();
90
+ knownBranches.add(currentBranch);
91
+
92
+ // Populate known branches from git on startup
93
+ getLocalBranches(projectPath).then((branches) => {
94
+ for (const b of branches) knownBranches.add(b);
95
+ }).catch(() => {});
96
+
39
97
  // Documentation watcher
40
98
  const docWatcher = chokidar.watch(docDir, {
41
99
  ignored: (filePath: string, stats) => {
@@ -61,7 +119,7 @@ export function startProjectWatcher(options: ProjectWatcherOptions): ProjectWatc
61
119
  const handleChange = (filePath: string) => {
62
120
  log.debug({ file: filePath }, 'File changed');
63
121
  credentialsPromise
64
- .then(creds => syncFile(projectId, projectPath, filePath, creds))
122
+ .then(creds => syncFile(projectId, projectPath, filePath, creds, currentBranch))
65
123
  .then(() => {
66
124
  onPushSuccess?.(projectPath);
67
125
  })
@@ -77,7 +135,7 @@ export function startProjectWatcher(options: ProjectWatcherOptions): ProjectWatc
77
135
  .on('unlink', (filePath) => {
78
136
  log.info({ file: filePath }, 'File deleted — notifying server');
79
137
  credentialsPromise
80
- .then(creds => deleteFiles(projectId, projectPath, [filePath], creds))
138
+ .then(creds => deleteFiles(projectId, projectPath, [filePath], creds, currentBranch))
81
139
  .catch((deleteErr) => {
82
140
  log.error({ err: deleteErr, file: filePath }, 'Delete notification failed for file');
83
141
  });
@@ -93,9 +151,12 @@ export function startProjectWatcher(options: ProjectWatcherOptions): ProjectWatc
93
151
  });
94
152
 
95
153
  gitWatcher.on('change', () => {
96
- log.info('Git HEAD changed — triggering full resync');
154
+ const newBranch = getCurrentBranch(projectPath);
155
+ log.info({ oldBranch: currentBranch, newBranch }, 'Git HEAD changed — triggering full resync');
156
+ currentBranch = newBranch;
157
+ knownBranches.add(newBranch);
97
158
  credentialsPromise
98
- .then(creds => initialSync(projectId, projectPath, creds))
159
+ .then(creds => initialSync(projectId, projectPath, creds, currentBranch))
99
160
  .catch((err) => {
100
161
  log.error({ err }, 'Full resync after git HEAD change failed');
101
162
  });
@@ -132,11 +193,11 @@ export function startProjectWatcher(options: ProjectWatcherOptions): ProjectWatc
132
193
  log.info('Git commit detected — creating auto-snapshot');
133
194
  credentialsPromise
134
195
  .then(async (creds) => {
135
- const success = await createSnapshot(projectId, `auto: commit`, undefined, 'git_commit', creds);
196
+ const success = await createSnapshot(projectId, `auto: commit`, currentBranch, undefined, 'git_commit', creds);
136
197
  if (!success) {
137
198
  log.warn('Auto-snapshot failed — retrying in 2s');
138
199
  await new Promise(resolve => setTimeout(resolve, 2000));
139
- await createSnapshot(projectId, `auto: commit`, undefined, 'git_commit', creds);
200
+ await createSnapshot(projectId, `auto: commit`, currentBranch, undefined, 'git_commit', creds);
140
201
  }
141
202
  })
142
203
  .catch((err) => {
@@ -145,11 +206,44 @@ export function startProjectWatcher(options: ProjectWatcherOptions): ProjectWatc
145
206
  }
146
207
  });
147
208
 
209
+ // Periodic branch deletion detection (every 5 minutes)
210
+ const branchCheckInterval = setInterval(() => {
211
+ credentialsPromise
212
+ .then(async (creds) => {
213
+ const localBranches = await getLocalBranches(projectPath);
214
+ const localSet = new Set(localBranches);
215
+ const deletedBranches: string[] = [];
216
+
217
+ for (const branch of knownBranches) {
218
+ // Skip detached HEAD entries and "unknown" — they aren't real branches
219
+ if (branch.startsWith('HEAD-') || branch === 'unknown') continue;
220
+ if (!localSet.has(branch)) {
221
+ deletedBranches.push(branch);
222
+ }
223
+ }
224
+
225
+ if (deletedBranches.length > 0) {
226
+ log.info({ deletedBranches }, 'Detected deleted branches — notifying server');
227
+ const success = await hideBranches(projectId, deletedBranches, creds);
228
+ if (success) {
229
+ for (const b of deletedBranches) knownBranches.delete(b);
230
+ }
231
+ }
232
+
233
+ // Update known branches from current local state
234
+ for (const b of localBranches) knownBranches.add(b);
235
+ })
236
+ .catch((checkErr) => {
237
+ log.error({ err: checkErr }, 'Branch deletion check failed');
238
+ });
239
+ }, 5 * 60 * 1000);
240
+
148
241
  return {
149
242
  projectId,
150
243
  projectPath,
151
244
  credentials,
152
245
  async close() {
246
+ clearInterval(branchCheckInterval);
153
247
  await Promise.all([docWatcher.close(), gitWatcher.close(), gitRefsWatcher.close(), versionWatcher.close()]);
154
248
  log.info('Watchers closed');
155
249
  },