codehere 0.6.0 → 0.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/README.md +8 -8
  2. package/dist/application/use-cases/generate-final-report.js +57 -2
  3. package/dist/application/use-cases/generate-narration.js +9 -3
  4. package/dist/index.js +34 -4
  5. package/dist/infrastructure/cost/budget-config.js +1 -1
  6. package/dist/infrastructure/cost/spend-aggregator.js +5 -5
  7. package/dist/infrastructure/hooks/claude-sdk-hook.js +48 -2
  8. package/dist/infrastructure/orchestration/codex-adapter.js +11 -6
  9. package/dist/infrastructure/orchestration/context-handoff.js +1 -1
  10. package/dist/infrastructure/orchestration/gemini-adapter.js +3 -15
  11. package/dist/infrastructure/orchestration/graphify-context.js +6 -13
  12. package/dist/infrastructure/orchestration/memory-injector.js +11 -16
  13. package/dist/infrastructure/orchestration/opencode-adapter.js +42 -1
  14. package/dist/infrastructure/orchestration/progress-aggregator.js +1 -2
  15. package/dist/infrastructure/orchestration/session-manager.js +178 -76
  16. package/dist/infrastructure/protocols/cloud-sync.js +9 -5
  17. package/dist/infrastructure/protocols/mcp-server-impl.js +368 -334
  18. package/dist/infrastructure/storage/cross-tool-agents.js +19 -2
  19. package/dist/infrastructure/storage/project-thread-migration.js +12 -7
  20. package/dist/infrastructure/storage/project-thread-reader.js +11 -1
  21. package/dist/infrastructure/storage/project-thread-title-backfill.js +6 -2
  22. package/dist/presentation/cli/commands/budget-command.js +2 -2
  23. package/dist/presentation/cli/commands/init-command.js +5 -4
  24. package/dist/presentation/cli/commands/memory-command.js +29 -15
  25. package/dist/presentation/cli/commands/protocols-builder.js +2 -2
  26. package/dist/presentation/cli/commands/repo-map-builder.js +4 -4
  27. package/dist/presentation/cli/repl.js +20 -1
  28. package/dist/ui.js +36 -28
  29. package/dist/utils/typo-guard.js +1 -1
  30. package/package.json +2 -1
@@ -22,14 +22,14 @@ import { buildGenericAdapter, loadAgentsConfig } from './generic-cli-adapter.js'
22
22
  import { buildConductorAdapter } from './conductor-adapter.js';
23
23
  import { seedAgentsConfig } from './agents-config-seed.js';
24
24
  import { AUDIT_FILE } from '../hooks/claude-sdk-hook.js';
25
- import { appendSpawned, appendCompleted, appendFinalReport, loadRecent } from './session-persistence.js';
25
+ import { appendSpawned, appendCompleted, appendFinalReport, loadRecent, } from './session-persistence.js';
26
26
  import { generateFinalReport, printFinalReportToTerminal } from '../../application/use-cases/generate-final-report.js';
27
27
  import { mirrorSpawnedToThread, mirrorCompletedToThread } from '../storage/project-thread-mirror.js';
28
28
  import { scanPromptBeforeSpawn } from '../hooks/pre-prompt-scan.js';
29
29
  import { generateNarration } from '../../application/use-cases/generate-narration.js';
30
30
  import { randomUUID } from 'crypto';
31
31
  import { classifyFailoverEligibility } from './failover-classifier.js';
32
- import { resolveFailoverTarget, toAvailability, } from './failover-policy.js';
32
+ import { resolveFailoverTarget, toAvailability } from './failover-policy.js';
33
33
  import { loadFailoverConfig, resolveChain } from './failover-config.js';
34
34
  import { enrichPromptWithPackSync, compileSessionPackSync, persistContextPack } from './graphify-context.js';
35
35
  import { buildFailoverHandoff, enrichPromptWithHandoff } from './context-handoff.js';
@@ -39,7 +39,13 @@ import { mirrorFailoverToThread } from '../storage/project-thread-mirror.js';
39
39
  // order. Built-in adapters first; user-config-driven ones appended from
40
40
  // ~/.codehere/agents.yaml (generic-cli-adapter). isInstalled() gates visibility
41
41
  // so an adapter registering for an un-installed CLI is harmless.
42
- const BUILTIN_REGISTRY = [ClaudeCodeAdapter, CodexAdapter, AiderAdapter, OpenCodeAdapter, GeminiAdapter];
42
+ const BUILTIN_REGISTRY = [
43
+ ClaudeCodeAdapter,
44
+ CodexAdapter,
45
+ AiderAdapter,
46
+ OpenCodeAdapter,
47
+ GeminiAdapter,
48
+ ];
43
49
  // Seed the default agents.yaml if this user has never configured one, so Gemini
44
50
  // / Cursor / Pi / Antigravity / Goose appear in the registry pre-wired. They
45
51
  // stay hidden in the UI until isInstalled() flips to true. Idempotent.
@@ -108,7 +114,14 @@ function parseEtime(s) {
108
114
  return 0;
109
115
  }
110
116
  export class SessionManager {
117
+ /** Max completed sessions kept in memory. Older sessions are evicted to
118
+ * prevent unbounded memory growth in the internal tracking maps. The data
119
+ * survives on disk via sessions.jsonl + thread.jsonl. */
120
+ static MAX_COMPLETED_SESSIONS = 100;
111
121
  sessions = new Map();
122
+ /** Spawn order FIFO — used to evict oldest completed sessions when the cap
123
+ * is reached. Pushed on spawn, referenced by evictOldCompleted(). */
124
+ spawnOrder = [];
112
125
  /** Read-only sessions replayed from ~/.codehere/sessions.jsonl on daemon start.
113
126
  * These subprocesses are long-dead; we surface them so the user doesn't lose
114
127
  * their Final reports across restarts. */
@@ -142,16 +155,16 @@ export class SessionManager {
142
155
  * reads these via getFailoverOffers() for the /api/failover-offers endpoint. */
143
156
  failoverOffers;
144
157
  /** Tracks the failover error code per session so acceptFailover can emit
145
- * the correct reason in the thread event. Populated by handleFailover. */
158
+ * the correct reason in the thread event. Populated by handleFailover. */
146
159
  failoverReasonBySession = new Map();
147
160
  /** Session threading — tracks which session continued as which after failover.
148
- * continuedAsBySession: original → new session ID
149
- * continuedFromBySession: new → original session ID */
161
+ * continuedAsBySession: original → new session ID
162
+ * continuedFromBySession: new → original session ID */
150
163
  continuedAsBySession = new Map();
151
164
  continuedFromBySession = new Map();
152
165
  /** Final Report failure tracking — when generateFinalReport rejects, we
153
- * record it so the API can return a definitive "failed" state instead
154
- * of leaving the UI showing "Generating report…" forever. */
166
+ * record it so the API can return a definitive "failed" status instead
167
+ * of leaving the UI showing "Generating report…" forever. */
155
168
  failedReportBySession = new Set();
156
169
  spawn(prompt, agentId = 'claude-code', options) {
157
170
  const adapter = getAdapter(agentId);
@@ -187,6 +200,7 @@ export class SessionManager {
187
200
  const enrichedPrompt = enrichPromptWithPackSync(prompt, cwd);
188
201
  const session = adapter.spawn(enrichedPrompt, options);
189
202
  this.sessions.set(session.sessionId, session);
203
+ this.spawnOrder.push(session.sessionId);
190
204
  // Persistence: write the spawned record immediately so a daemon crash still
191
205
  // leaves an audit trail of what was attempted. Subscribe to exit so we can
192
206
  // capture the final status + steps + artifacts when the session settles.
@@ -297,7 +311,10 @@ export class SessionManager {
297
311
  // or pack compilation may fail. All degrade silently.
298
312
  void Promise.resolve().then(() => {
299
313
  try {
300
- const stepsStr = session.steps?.map((s) => `${s.label}: ${s.output || s.state}`).filter(Boolean).join('\n') ?? '';
314
+ const stepsStr = session.steps
315
+ ?.map((s) => `${s.label}: ${s.output || s.state}`)
316
+ .filter(Boolean)
317
+ .join('\n') ?? '';
301
318
  const pack = compileSessionPackSync(cwd, {
302
319
  prompt: session.prompt,
303
320
  artifacts: JSON.stringify(session.artifacts, null, 2),
@@ -317,6 +334,7 @@ export class SessionManager {
317
334
  clearInterval(t);
318
335
  this.narrationTimers.delete(session.sessionId);
319
336
  }
337
+ this.evictOldCompleted();
320
338
  unsubscribe();
321
339
  });
322
340
  // v0.6 M3 — PM Narrator. Mid-task plain-English summaries fired
@@ -388,29 +406,10 @@ export class SessionManager {
388
406
  });
389
407
  // Track this new session in the same way
390
408
  this.sessions.set(newSession.sessionId, newSession);
409
+ this.spawnOrder.push(newSession.sessionId);
391
410
  this.cwdBySession.set(newSession.sessionId, cwd);
392
- appendSpawned({
393
- id: newSession.sessionId,
394
- agent: newSession.agent,
395
- prompt: promptWithHandoff,
396
- cwd,
397
- startedAt: newSession.startedAt,
398
- });
399
- mirrorSpawnedToThread({
400
- sessionId: newSession.sessionId,
401
- agent: newSession.agent,
402
- prompt: promptWithHandoff,
403
- cwd,
404
- startedAt: newSession.startedAt,
405
- });
406
- mirrorFailoverToThread({
407
- sessionId: newSession.sessionId,
408
- fromAgent: signal.fromAgent,
409
- toAgent: target,
410
- reason: signal.errorCode,
411
- cwd,
412
- });
413
- // Subscribe to the new session with the same failover logic
411
+ // Subscribe BEFORE persistence so exit events are never lost if the
412
+ // adapter exits synchronously (command not found, instant crash, etc).
414
413
  const unsubscribe = newSession.subscribe((event) => {
415
414
  if (event.type !== 'exit')
416
415
  return;
@@ -456,14 +455,21 @@ export class SessionManager {
456
455
  try {
457
456
  printFinalReportToTerminal(reportInput, report);
458
457
  }
459
- catch { /* defensive */ }
458
+ catch {
459
+ /* defensive */
460
+ }
460
461
  }
461
462
  })
462
- .catch(() => { });
463
+ .catch(() => {
464
+ /* defensive */
465
+ });
463
466
  // P1 — persist context pack at failover-replacement session exit.
464
467
  void Promise.resolve().then(() => {
465
468
  try {
466
- const stepsStr = newSession.steps?.map((s) => `${s.label}: ${s.output || s.state}`).filter(Boolean).join('\n') ?? '';
469
+ const stepsStr = newSession.steps
470
+ ?.map((s) => `${s.label}: ${s.output || s.state}`)
471
+ .filter(Boolean)
472
+ .join('\n') ?? '';
467
473
  const pack = compileSessionPackSync(cwd, {
468
474
  prompt: newSession.prompt,
469
475
  artifacts: JSON.stringify(newSession.artifacts, null, 2),
@@ -482,8 +488,36 @@ export class SessionManager {
482
488
  clearInterval(t);
483
489
  this.narrationTimers.delete(newSession.sessionId);
484
490
  }
491
+ this.evictOldCompleted();
485
492
  unsubscribe();
486
493
  });
494
+ // Persistence + threading — safe after subscribe because the exit
495
+ // handler is already registered. These can run even if the session
496
+ // already exited (spawned record still useful, exit handler handles
497
+ // the completion).
498
+ appendSpawned({
499
+ id: newSession.sessionId,
500
+ agent: newSession.agent,
501
+ prompt: promptWithHandoff,
502
+ cwd,
503
+ startedAt: newSession.startedAt,
504
+ });
505
+ mirrorSpawnedToThread({
506
+ sessionId: newSession.sessionId,
507
+ agent: newSession.agent,
508
+ prompt: promptWithHandoff,
509
+ cwd,
510
+ startedAt: newSession.startedAt,
511
+ });
512
+ mirrorFailoverToThread({
513
+ sessionId: newSession.sessionId,
514
+ fromAgent: signal.fromAgent,
515
+ toAgent: target,
516
+ reason: signal.errorCode,
517
+ cwd,
518
+ });
519
+ this.continuedAsBySession.set(session.sessionId, newSession.sessionId);
520
+ this.continuedFromBySession.set(newSession.sessionId, session.sessionId);
487
521
  this.startNarrationTimer(newSession);
488
522
  return newSession;
489
523
  }
@@ -501,13 +535,12 @@ export class SessionManager {
501
535
  /** Emitted when a failover offer is generated. The API layer listens
502
536
  * to this to surface the banner in the web UI. */
503
537
  emitFailoverOffer(offer) {
504
- // Store in a map that the API layer can poll — Slice E wires the
505
- // /api/failover-offers endpoint to read from here.
506
538
  if (!this.failoverOffers) {
507
539
  this.failoverOffers = new Map();
508
540
  }
509
541
  const key = `${offer.session.sessionId}-${offer.target}`;
510
- this.failoverOffers.set(key, offer);
542
+ const expiresAt = Date.now() + 60_000;
543
+ this.failoverOffers.set(key, { offer, createdAt: Date.now() });
511
544
  // Auto-expire offers after 60 seconds to keep the map bounded
512
545
  setTimeout(() => {
513
546
  this.failoverOffers?.delete(key);
@@ -518,20 +551,20 @@ export class SessionManager {
518
551
  if (!this.failoverOffers)
519
552
  return [];
520
553
  const offers = [];
521
- for (const [key, offer] of this.failoverOffers) {
554
+ for (const [key, { offer, createdAt }] of this.failoverOffers) {
522
555
  offers.push({
523
556
  sessionId: offer.session.sessionId,
524
557
  signal: offer.signal,
525
558
  target: offer.target,
526
- expiresAt: Date.now() + 60_000,
559
+ expiresAt: createdAt + 60_000,
527
560
  });
528
561
  }
529
562
  return offers;
530
563
  }
531
564
  /** v0.6.x P0 — user-initiated failover accept. Spawns a replacement
532
- * session on the target agent, preserving the original prompt + cwd.
533
- * P2 — injects handoff context for cross-agent continuity.
534
- * Returns the new session or undefined if the target is unavailable. */
565
+ * session on the target agent, preserving the original prompt + cwd.
566
+ * P2 — injects handoff context for cross-agent continuity.
567
+ * Returns the new session or undefined if the target is unavailable. */
535
568
  acceptFailover(sessionId, targetAgent) {
536
569
  const session = this.sessions.get(sessionId);
537
570
  if (!session)
@@ -565,32 +598,10 @@ export class SessionManager {
565
598
  });
566
599
  // Track the new session
567
600
  this.sessions.set(newSession.sessionId, newSession);
601
+ this.spawnOrder.push(newSession.sessionId);
568
602
  this.cwdBySession.set(newSession.sessionId, cwd);
569
- // Session threading: record the parent-child relationship
570
- this.continuedAsBySession.set(sessionId, newSession.sessionId);
571
- this.continuedFromBySession.set(newSession.sessionId, sessionId);
572
- appendSpawned({
573
- id: newSession.sessionId,
574
- agent: newSession.agent,
575
- prompt: promptWithHandoff,
576
- cwd,
577
- startedAt: newSession.startedAt,
578
- });
579
- mirrorSpawnedToThread({
580
- sessionId: newSession.sessionId,
581
- agent: newSession.agent,
582
- prompt: promptWithHandoff,
583
- cwd,
584
- startedAt: newSession.startedAt,
585
- });
586
- mirrorFailoverToThread({
587
- sessionId: newSession.sessionId,
588
- fromAgent: session.agent,
589
- toAgent: targetAgent,
590
- reason: this.failoverReasonBySession.get(session.sessionId) ?? 'error',
591
- cwd,
592
- });
593
- // Subscribe to the new session with the same failover logic
603
+ // Subscribe BEFORE persistence so exit events are never lost if the
604
+ // adapter exits synchronously.
594
605
  const unsubscribe = newSession.subscribe((event) => {
595
606
  if (event.type !== 'exit')
596
607
  return;
@@ -636,14 +647,21 @@ export class SessionManager {
636
647
  try {
637
648
  printFinalReportToTerminal(reportInput, report);
638
649
  }
639
- catch { /* defensive */ }
650
+ catch {
651
+ /* defensive */
652
+ }
640
653
  }
641
654
  })
642
- .catch(() => { });
655
+ .catch(() => {
656
+ /* defensive */
657
+ });
643
658
  // P1 — persist context pack at accept-failover replacement session exit.
644
659
  void Promise.resolve().then(() => {
645
660
  try {
646
- const stepsStr = newSession.steps?.map((s) => `${s.label}: ${s.output || s.state}`).filter(Boolean).join('\n') ?? '';
661
+ const stepsStr = newSession.steps
662
+ ?.map((s) => `${s.label}: ${s.output || s.state}`)
663
+ .filter(Boolean)
664
+ .join('\n') ?? '';
647
665
  const pack = compileSessionPackSync(cwd, {
648
666
  prompt: newSession.prompt,
649
667
  artifacts: JSON.stringify(newSession.artifacts, null, 2),
@@ -662,9 +680,34 @@ export class SessionManager {
662
680
  clearInterval(t);
663
681
  this.narrationTimers.delete(newSession.sessionId);
664
682
  }
665
- this.startNarrationTimer(newSession);
683
+ this.evictOldCompleted();
666
684
  unsubscribe();
667
685
  });
686
+ // Persistence + threading — safe after subscribe because the exit
687
+ // handler is already registered.
688
+ this.continuedAsBySession.set(sessionId, newSession.sessionId);
689
+ this.continuedFromBySession.set(newSession.sessionId, sessionId);
690
+ appendSpawned({
691
+ id: newSession.sessionId,
692
+ agent: newSession.agent,
693
+ prompt: promptWithHandoff,
694
+ cwd,
695
+ startedAt: newSession.startedAt,
696
+ });
697
+ mirrorSpawnedToThread({
698
+ sessionId: newSession.sessionId,
699
+ agent: newSession.agent,
700
+ prompt: promptWithHandoff,
701
+ cwd,
702
+ startedAt: newSession.startedAt,
703
+ });
704
+ mirrorFailoverToThread({
705
+ sessionId: newSession.sessionId,
706
+ fromAgent: session.agent,
707
+ toAgent: targetAgent,
708
+ reason: this.failoverReasonBySession.get(session.sessionId) ?? 'error',
709
+ cwd,
710
+ });
668
711
  this.startNarrationTimer(newSession);
669
712
  return newSession;
670
713
  }
@@ -698,7 +741,9 @@ export class SessionManager {
698
741
  if (out)
699
742
  this.narrationBySession.set(session.sessionId, out);
700
743
  })
701
- .catch(() => { });
744
+ .catch(() => {
745
+ /* defensive — generateNarration never throws */
746
+ });
702
747
  }, intervalMs);
703
748
  this.narrationTimers.set(session.sessionId, timer);
704
749
  }
@@ -730,20 +775,52 @@ export class SessionManager {
730
775
  return this.continuedAsBySession.get(sessionId);
731
776
  }
732
777
  /** PM-UX Layer 3 · prose Final Report for an in-memory exited session.
733
- * Returns undefined while generation is in flight OR if the provider chain
734
- * failed (no claude-free, no claude, no credit, etc). The web UI reads this
735
- * through the /api/tasks serializer to render the prose summary inline. */
778
+ * Returns undefined while generation is in flight OR if the provider chain
779
+ * failed (no claude-free, no claude, no credit, etc). The web UI reads this
780
+ * through the /api/tasks serializer to render the prose summary inline. */
736
781
  getFinalReport(sessionId) {
737
782
  return this.finalReportBySession.get(sessionId);
738
783
  }
739
784
  /** Returns true if Final Report generation explicitly failed for this session.
740
- * Lets the API distinguish "not yet" from "never coming". */
785
+ * Lets the API distinguish "not yet" from "never coming". */
741
786
  hasFailedReport(sessionId) {
742
787
  return this.failedReportBySession.has(sessionId);
743
788
  }
744
789
  get(sessionId) {
745
790
  return this.sessions.get(sessionId);
746
791
  }
792
+ /** Evict the oldest completed sessions when the in-memory cap is reached.
793
+ * Prevents unbounded memory growth in the internal tracking maps. The
794
+ * data survives on disk via sessions.jsonl + thread.jsonl. */
795
+ evictOldCompleted() {
796
+ const completed = [];
797
+ for (const id of this.spawnOrder) {
798
+ const at = this.completedAtBySession.get(id);
799
+ if (at !== undefined)
800
+ completed.push({ id, completedAt: at });
801
+ }
802
+ if (completed.length <= SessionManager.MAX_COMPLETED_SESSIONS)
803
+ return;
804
+ const toEvict = completed.slice(0, completed.length - SessionManager.MAX_COMPLETED_SESSIONS);
805
+ const evictSet = new Set(toEvict.map((e) => e.id));
806
+ for (const id of evictSet) {
807
+ const t = this.narrationTimers.get(id);
808
+ if (t) {
809
+ clearInterval(t);
810
+ this.narrationTimers.delete(id);
811
+ }
812
+ this.sessions.delete(id);
813
+ this.completedAtBySession.delete(id);
814
+ this.cwdBySession.delete(id);
815
+ this.finalReportBySession.delete(id);
816
+ this.narrationBySession.delete(id);
817
+ this.failoverReasonBySession.delete(id);
818
+ this.continuedAsBySession.delete(id);
819
+ this.continuedFromBySession.delete(id);
820
+ this.failedReportBySession.delete(id);
821
+ this.failoverTriedBySession.delete(id);
822
+ }
823
+ }
747
824
  list() {
748
825
  return Array.from(this.sessions.values());
749
826
  }
@@ -754,6 +831,17 @@ export class SessionManager {
754
831
  session.kill();
755
832
  return true;
756
833
  }
834
+ /** Graceful shutdown: kill all running sessions and clear all timers. */
835
+ shutdown() {
836
+ for (const [id, timer] of this.narrationTimers) {
837
+ clearInterval(timer);
838
+ }
839
+ this.narrationTimers.clear();
840
+ for (const session of this.sessions.values()) {
841
+ if (session.status === 'running')
842
+ session.kill();
843
+ }
844
+ }
757
845
  /**
758
846
  * Active Claude Code sessions on this machine, derived from the audit log.
759
847
  * A session is "active" if any tool call from it was recorded within the
@@ -879,5 +967,19 @@ export class SessionManager {
879
967
  static getInstance() {
880
968
  return (this._instance ??= new SessionManager());
881
969
  }
970
+ /** @internal Resets the singleton for test isolation. */
971
+ static __testOnlyReset() {
972
+ this._instance = undefined;
973
+ }
974
+ }
975
+ // ── Test seam ─────────────────────────────────────────────────────────────────
976
+ // Avoids refactoring the module-level REGISTRY const to DI. Usage:
977
+ // import { __testOnlyRegisterAdapter, SessionManager } from '...';
978
+ // __testOnlyRegisterAdapter(mockAdapter);
979
+ // SessionManager.__testOnlyReset();
980
+ // const sm = SessionManager.getInstance();
981
+ /** @internal Exported for testing only — allows mock adapter injection. */
982
+ export function __testOnlyRegisterAdapter(adapter) {
983
+ REGISTRY.push(adapter);
882
984
  }
883
985
  //# sourceMappingURL=session-manager.js.map
@@ -62,7 +62,7 @@ export function collectSessionMetadata() {
62
62
  }
63
63
  try {
64
64
  const content = readFileSync(SESSIONS_FILE, 'utf-8');
65
- const lines = content.split('\n').filter(l => l.trim().length > 0);
65
+ const lines = content.split('\n').filter((l) => l.trim().length > 0);
66
66
  // Parse records and pair spawned + completed
67
67
  const spawnedMap = new Map();
68
68
  const completedMap = new Map();
@@ -89,9 +89,13 @@ export function collectSessionMetadata() {
89
89
  const completed = completedMap.get(id);
90
90
  const finalReport = finalReportMap.get(id);
91
91
  const status = completed
92
- ? (completed.status === 'completed' ? 'done' : completed.status === 'failed' ? 'failed' : 'done')
92
+ ? completed.status === 'completed'
93
+ ? 'done'
94
+ : completed.status === 'failed'
95
+ ? 'failed'
96
+ : 'done'
93
97
  : 'running';
94
- const durationMs = completed?.durationMs ?? (Date.now() - spawned.startedAt);
98
+ const durationMs = completed?.durationMs ?? Date.now() - spawned.startedAt;
95
99
  const tools = completed?.steps?.length ?? 0;
96
100
  const files = completed?.artifacts?.filesChanged?.length ?? 0;
97
101
  const trustScore = extractTrustScore(completed?.artifacts);
@@ -151,7 +155,7 @@ export function startCloudSync(machineName) {
151
155
  console.error(` Cloud sync failed: ${res.status} ${body}`);
152
156
  }
153
157
  else {
154
- const data = await res.json();
158
+ const data = (await res.json());
155
159
  if (data.sessionsPushed && data.sessionsPushed > 0) {
156
160
  console.log(` Cloud sync: ${data.sessionsPushed} session(s) pushed`);
157
161
  }
@@ -166,7 +170,7 @@ export function startCloudSync(machineName) {
166
170
  const res = await fetch(`${CLOUD_URL}?action=pull-tasks&machineId=${machineId}`);
167
171
  if (!res.ok)
168
172
  return;
169
- const data = await res.json();
173
+ const data = (await res.json());
170
174
  const tasks = data.tasks || [];
171
175
  for (const task of tasks) {
172
176
  if (task.prompt) {