codehere 0.6.0-alpha.1 → 0.6.0

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.
@@ -28,6 +28,12 @@ import { mirrorSpawnedToThread, mirrorCompletedToThread } from '../storage/proje
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
+ import { classifyFailoverEligibility } from './failover-classifier.js';
32
+ import { resolveFailoverTarget, toAvailability, } from './failover-policy.js';
33
+ import { loadFailoverConfig, resolveChain } from './failover-config.js';
34
+ import { enrichPromptWithPackSync, compileSessionPackSync, persistContextPack } from './graphify-context.js';
35
+ import { buildFailoverHandoff, enrichPromptWithHandoff } from './context-handoff.js';
36
+ import { mirrorFailoverToThread } from '../storage/project-thread-mirror.js';
31
37
  // ── Agent registry ─────────────────────────────────────────────────────────────
32
38
  // All backends codehere can spawn. Order here drives the default UI dropdown
33
39
  // order. Built-in adapters first; user-config-driven ones appended from
@@ -132,6 +138,21 @@ export class SessionManager {
132
138
  narrationBySession = new Map();
133
139
  narrationTimers = new Map();
134
140
  narrationStepsAtLastFire = new Map();
141
+ /** Stores active failover offers keyed by sessionId+target. API layer
142
+ * reads these via getFailoverOffers() for the /api/failover-offers endpoint. */
143
+ failoverOffers;
144
+ /** Tracks the failover error code per session so acceptFailover can emit
145
+ * the correct reason in the thread event. Populated by handleFailover. */
146
+ failoverReasonBySession = new Map();
147
+ /** Session threading — tracks which session continued as which after failover.
148
+ * continuedAsBySession: original → new session ID
149
+ * continuedFromBySession: new → original session ID */
150
+ continuedAsBySession = new Map();
151
+ continuedFromBySession = new Map();
152
+ /** 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. */
155
+ failedReportBySession = new Set();
135
156
  spawn(prompt, agentId = 'claude-code', options) {
136
157
  const adapter = getAdapter(agentId);
137
158
  if (!adapter) {
@@ -158,12 +179,17 @@ export class SessionManager {
158
179
  if (!scan.allowed) {
159
180
  throw new Error(scan.blockReason || 'pre-prompt scan blocked the spawn');
160
181
  }
161
- const session = adapter.spawn(prompt, options);
182
+ // P1 — graphify-ts structural memory: enrich prompt with compact context
183
+ // pack so the agent starts with a verified knowledge graph of the workspace.
184
+ // Respects CODEHERE_GRAPHIFY env var (auto|force|off, default=auto).
185
+ // Synchronous call — degrades to original prompt if graphify fails.
186
+ const cwd = options?.cwd ?? process.cwd();
187
+ const enrichedPrompt = enrichPromptWithPackSync(prompt, cwd);
188
+ const session = adapter.spawn(enrichedPrompt, options);
162
189
  this.sessions.set(session.sessionId, session);
163
190
  // Persistence: write the spawned record immediately so a daemon crash still
164
191
  // leaves an audit trail of what was attempted. Subscribe to exit so we can
165
192
  // capture the final status + steps + artifacts when the session settles.
166
- const cwd = options?.cwd ?? process.cwd();
167
193
  this.cwdBySession.set(session.sessionId, cwd);
168
194
  appendSpawned({
169
195
  id: session.sessionId,
@@ -182,6 +208,20 @@ export class SessionManager {
182
208
  startedAt: session.startedAt,
183
209
  });
184
210
  const unsubscribe = session.subscribe((event) => {
211
+ // ── Failover check — intercept rate-limit / quota errors before exit ──
212
+ // Adapters emit 'codehere:error' (structured: { code, message, recovery })
213
+ // or 'error' (spawn-level: { message }). Both need classification.
214
+ if (event.type === 'error' || event.type === 'codehere:error') {
215
+ const err = event.data;
216
+ if (err?.code && err?.message) {
217
+ const signal = classifyFailoverEligibility(session.agent, err.code, err.message);
218
+ if (signal) {
219
+ const newSession = this.handleFailover(session, signal);
220
+ if (newSession)
221
+ return; // auto-spawned — keep listening on new session
222
+ }
223
+ }
224
+ }
185
225
  if (event.type !== 'exit')
186
226
  return;
187
227
  const data = event.data;
@@ -248,7 +288,28 @@ export class SessionManager {
248
288
  }
249
289
  })
250
290
  .catch(() => {
251
- // generateFinalReport never throws by contract; defensive only.
291
+ // generateFinalReport rejected — record it so the UI can show
292
+ // "Report unavailable" instead of "Generating report…" forever.
293
+ this.failedReportBySession.add(session.sessionId);
294
+ });
295
+ // P1 — persist context pack at session exit for cross-session handoff (P2).
296
+ // Fire-and-forget: graphify-ts may not be installed, graph may be empty,
297
+ // or pack compilation may fail. All degrade silently.
298
+ void Promise.resolve().then(() => {
299
+ try {
300
+ const stepsStr = session.steps?.map((s) => `${s.label}: ${s.output || s.state}`).filter(Boolean).join('\n') ?? '';
301
+ const pack = compileSessionPackSync(cwd, {
302
+ prompt: session.prompt,
303
+ artifacts: JSON.stringify(session.artifacts, null, 2),
304
+ steps: stepsStr,
305
+ agent: session.agent,
306
+ });
307
+ if (pack)
308
+ persistContextPack(pack, cwd);
309
+ }
310
+ catch {
311
+ // Silent degradation — context pack is enrichment, not the primary path.
312
+ }
252
313
  });
253
314
  // v0.6 M3 — clean up the narration timer for this session.
254
315
  const t = this.narrationTimers.get(session.sessionId);
@@ -267,6 +328,346 @@ export class SessionManager {
267
328
  this.startNarrationTimer(session);
268
329
  return session;
269
330
  }
331
+ /** Tracks which agents have already been tried for a given prompt during
332
+ * a failover chain. Maps sessionId → set of agent ids already attempted. */
333
+ failoverTriedBySession = new Map();
334
+ /** v0.6.x failover — emit a failover offer when an adapter hits a
335
+ * rate-limit / quota error. The offer is surfaced via the API layer
336
+ * (Slice E) and optionally auto-spawned (Slice D). Returns the new
337
+ * session if auto-spawn was triggered, undefined otherwise. */
338
+ handleFailover(session, signal) {
339
+ const config = loadFailoverConfig();
340
+ const chain = resolveChain(config?.chain);
341
+ const tried = this.failoverTriedBySession.get(session.sessionId) ?? new Set();
342
+ tried.add(signal.fromAgent);
343
+ this.failoverTriedBySession.set(session.sessionId, tried);
344
+ const adapters = allAdapters();
345
+ const available = toAvailability(adapters, (a) => {
346
+ return a.id !== signal.fromAgent && a.isInstalled();
347
+ });
348
+ const target = resolveFailoverTarget({
349
+ failedAgent: signal.fromAgent,
350
+ alreadyTried: Array.from(tried),
351
+ available,
352
+ preferredChain: config?.chain,
353
+ });
354
+ if (!target)
355
+ return undefined;
356
+ const targetAdapter = getAdapter(target);
357
+ if (!targetAdapter || !targetAdapter.isInstalled())
358
+ return undefined;
359
+ // Auto-failover: spawn on the next agent in the chain
360
+ const autoFailover = config?.autoFailover ?? true;
361
+ if (autoFailover) {
362
+ // Spawn replacement session on the next agent, preserving prompt + cwd
363
+ const cwd = this.cwdBySession.get(session.sessionId) ?? process.cwd();
364
+ // P2 — build handoff context for continuity across agent swap
365
+ const handoff = buildFailoverHandoff(cwd, {
366
+ failedAgent: session.agent,
367
+ failedPrompt: session.prompt,
368
+ });
369
+ // Enrich prompt with handoff if available (P2)
370
+ const promptWithHandoff = handoff
371
+ ? enrichPromptWithHandoff(session.prompt, {
372
+ packId: handoff.packId,
373
+ agent: session.agent,
374
+ prompt: session.prompt,
375
+ status: 'failed',
376
+ timestamp: handoff.timestamp,
377
+ nodeCount: handoff.nodeCount,
378
+ edgeCount: handoff.edgeCount,
379
+ task: handoff.task,
380
+ payloadPreview: handoff.payloadPreview,
381
+ })
382
+ : session.prompt;
383
+ try {
384
+ const newSession = targetAdapter.spawn(promptWithHandoff, {
385
+ cwd,
386
+ env: process.env,
387
+ preferSubscription: true,
388
+ });
389
+ // Track this new session in the same way
390
+ this.sessions.set(newSession.sessionId, newSession);
391
+ 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
414
+ const unsubscribe = newSession.subscribe((event) => {
415
+ if (event.type !== 'exit')
416
+ return;
417
+ const data = event.data;
418
+ const completedAt = Date.now();
419
+ this.completedAtBySession.set(newSession.sessionId, completedAt);
420
+ appendCompleted({
421
+ id: newSession.sessionId,
422
+ status: newSession.status,
423
+ durationMs: Number(data?.durationMs ?? completedAt - newSession.startedAt),
424
+ completedAt,
425
+ steps: newSession.steps ? [...newSession.steps] : undefined,
426
+ artifacts: newSession.artifacts,
427
+ });
428
+ mirrorCompletedToThread({
429
+ sessionId: newSession.sessionId,
430
+ cwd,
431
+ status: newSession.status,
432
+ durationMs: Number(data?.durationMs ?? completedAt - newSession.startedAt),
433
+ completedAt,
434
+ steps: newSession.steps ? [...newSession.steps] : undefined,
435
+ artifacts: newSession.artifacts,
436
+ });
437
+ const reportInput = {
438
+ sessionId: newSession.sessionId,
439
+ prompt: newSession.prompt,
440
+ agent: newSession.agent,
441
+ status: newSession.status,
442
+ durationMs: completedAt - newSession.startedAt,
443
+ steps: newSession.steps ? [...newSession.steps] : [],
444
+ artifacts: newSession.artifacts,
445
+ };
446
+ generateFinalReport(reportInput)
447
+ .then((report) => {
448
+ if (report) {
449
+ this.finalReportBySession.set(newSession.sessionId, report);
450
+ appendFinalReport({
451
+ id: newSession.sessionId,
452
+ summary: report.summary,
453
+ generatedAt: report.generatedAt,
454
+ durationMs: report.durationMs,
455
+ });
456
+ try {
457
+ printFinalReportToTerminal(reportInput, report);
458
+ }
459
+ catch { /* defensive */ }
460
+ }
461
+ })
462
+ .catch(() => { });
463
+ // P1 — persist context pack at failover-replacement session exit.
464
+ void Promise.resolve().then(() => {
465
+ try {
466
+ const stepsStr = newSession.steps?.map((s) => `${s.label}: ${s.output || s.state}`).filter(Boolean).join('\n') ?? '';
467
+ const pack = compileSessionPackSync(cwd, {
468
+ prompt: newSession.prompt,
469
+ artifacts: JSON.stringify(newSession.artifacts, null, 2),
470
+ steps: stepsStr,
471
+ agent: newSession.agent,
472
+ });
473
+ if (pack)
474
+ persistContextPack(pack, cwd);
475
+ }
476
+ catch {
477
+ // Silent degradation — context pack is enrichment, not primary path.
478
+ }
479
+ });
480
+ const t = this.narrationTimers.get(newSession.sessionId);
481
+ if (t) {
482
+ clearInterval(t);
483
+ this.narrationTimers.delete(newSession.sessionId);
484
+ }
485
+ unsubscribe();
486
+ });
487
+ this.startNarrationTimer(newSession);
488
+ return newSession;
489
+ }
490
+ catch {
491
+ // Spawn failed — fall through to offer-only mode
492
+ return undefined;
493
+ }
494
+ }
495
+ // Offer-only mode: signal the API layer to present a failover banner
496
+ // (Slice E handles the actual UI surfacing)
497
+ this.emitFailoverOffer({ session, signal, target });
498
+ this.failoverReasonBySession.set(session.sessionId, signal.errorCode);
499
+ return undefined;
500
+ }
501
+ /** Emitted when a failover offer is generated. The API layer listens
502
+ * to this to surface the banner in the web UI. */
503
+ 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
+ if (!this.failoverOffers) {
507
+ this.failoverOffers = new Map();
508
+ }
509
+ const key = `${offer.session.sessionId}-${offer.target}`;
510
+ this.failoverOffers.set(key, offer);
511
+ // Auto-expire offers after 60 seconds to keep the map bounded
512
+ setTimeout(() => {
513
+ this.failoverOffers?.delete(key);
514
+ }, 60_000);
515
+ }
516
+ /** Get all active failover offers for the API layer. */
517
+ getFailoverOffers() {
518
+ if (!this.failoverOffers)
519
+ return [];
520
+ const offers = [];
521
+ for (const [key, offer] of this.failoverOffers) {
522
+ offers.push({
523
+ sessionId: offer.session.sessionId,
524
+ signal: offer.signal,
525
+ target: offer.target,
526
+ expiresAt: Date.now() + 60_000,
527
+ });
528
+ }
529
+ return offers;
530
+ }
531
+ /** 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. */
535
+ acceptFailover(sessionId, targetAgent) {
536
+ const session = this.sessions.get(sessionId);
537
+ if (!session)
538
+ return undefined;
539
+ const targetAdapter = getAdapter(targetAgent);
540
+ if (!targetAdapter || !targetAdapter.isInstalled())
541
+ return undefined;
542
+ const cwd = this.cwdBySession.get(sessionId) ?? process.cwd();
543
+ // P2 — build handoff context for continuity across agent swap
544
+ const handoff = buildFailoverHandoff(cwd, {
545
+ failedAgent: session.agent,
546
+ failedPrompt: session.prompt,
547
+ });
548
+ const promptWithHandoff = handoff
549
+ ? enrichPromptWithHandoff(session.prompt, {
550
+ packId: handoff.packId,
551
+ agent: session.agent,
552
+ prompt: session.prompt,
553
+ status: 'failed',
554
+ timestamp: handoff.timestamp,
555
+ nodeCount: handoff.nodeCount,
556
+ edgeCount: handoff.edgeCount,
557
+ task: handoff.task,
558
+ payloadPreview: handoff.payloadPreview,
559
+ })
560
+ : session.prompt;
561
+ const newSession = targetAdapter.spawn(promptWithHandoff, {
562
+ cwd,
563
+ env: process.env,
564
+ preferSubscription: true,
565
+ });
566
+ // Track the new session
567
+ this.sessions.set(newSession.sessionId, newSession);
568
+ 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
594
+ const unsubscribe = newSession.subscribe((event) => {
595
+ if (event.type !== 'exit')
596
+ return;
597
+ const data = event.data;
598
+ const completedAt = Date.now();
599
+ this.completedAtBySession.set(newSession.sessionId, completedAt);
600
+ appendCompleted({
601
+ id: newSession.sessionId,
602
+ status: newSession.status,
603
+ durationMs: Number(data?.durationMs ?? completedAt - newSession.startedAt),
604
+ completedAt,
605
+ steps: newSession.steps ? [...newSession.steps] : undefined,
606
+ artifacts: newSession.artifacts,
607
+ });
608
+ mirrorCompletedToThread({
609
+ sessionId: newSession.sessionId,
610
+ cwd,
611
+ status: newSession.status,
612
+ durationMs: Number(data?.durationMs ?? completedAt - newSession.startedAt),
613
+ completedAt,
614
+ steps: newSession.steps ? [...newSession.steps] : undefined,
615
+ artifacts: newSession.artifacts,
616
+ });
617
+ const reportInput = {
618
+ sessionId: newSession.sessionId,
619
+ prompt: newSession.prompt,
620
+ agent: newSession.agent,
621
+ status: newSession.status,
622
+ durationMs: completedAt - newSession.startedAt,
623
+ steps: newSession.steps ? [...newSession.steps] : [],
624
+ artifacts: newSession.artifacts,
625
+ };
626
+ generateFinalReport(reportInput)
627
+ .then((report) => {
628
+ if (report) {
629
+ this.finalReportBySession.set(newSession.sessionId, report);
630
+ appendFinalReport({
631
+ id: newSession.sessionId,
632
+ summary: report.summary,
633
+ generatedAt: report.generatedAt,
634
+ durationMs: report.durationMs,
635
+ });
636
+ try {
637
+ printFinalReportToTerminal(reportInput, report);
638
+ }
639
+ catch { /* defensive */ }
640
+ }
641
+ })
642
+ .catch(() => { });
643
+ // P1 — persist context pack at accept-failover replacement session exit.
644
+ void Promise.resolve().then(() => {
645
+ try {
646
+ const stepsStr = newSession.steps?.map((s) => `${s.label}: ${s.output || s.state}`).filter(Boolean).join('\n') ?? '';
647
+ const pack = compileSessionPackSync(cwd, {
648
+ prompt: newSession.prompt,
649
+ artifacts: JSON.stringify(newSession.artifacts, null, 2),
650
+ steps: stepsStr,
651
+ agent: newSession.agent,
652
+ });
653
+ if (pack)
654
+ persistContextPack(pack, cwd);
655
+ }
656
+ catch {
657
+ // Silent degradation — context pack is enrichment, not primary path.
658
+ }
659
+ });
660
+ const t = this.narrationTimers.get(newSession.sessionId);
661
+ if (t) {
662
+ clearInterval(t);
663
+ this.narrationTimers.delete(newSession.sessionId);
664
+ }
665
+ this.startNarrationTimer(newSession);
666
+ unsubscribe();
667
+ });
668
+ this.startNarrationTimer(newSession);
669
+ return newSession;
670
+ }
270
671
  /** v0.6 M3 — periodic narration loop per session. */
271
672
  startNarrationTimer(session) {
272
673
  if (process.env.CODEHERE_DISABLE_NARRATOR === '1')
@@ -320,13 +721,26 @@ export class SessionManager {
320
721
  getCwd(sessionId) {
321
722
  return this.cwdBySession.get(sessionId);
322
723
  }
724
+ /** Session threading — which session did this one continue from (after failover)? */
725
+ getContinuedFrom(sessionId) {
726
+ return this.continuedFromBySession.get(sessionId);
727
+ }
728
+ /** Session threading — which session did this one continue as (after failover)? */
729
+ getContinuedAs(sessionId) {
730
+ return this.continuedAsBySession.get(sessionId);
731
+ }
323
732
  /** PM-UX Layer 3 · prose Final Report for an in-memory exited session.
324
- * Returns undefined while generation is in flight OR if the provider chain
325
- * failed (no claude-free, no claude, no credit, etc). The web UI reads this
326
- * through the /api/tasks serializer to render the prose summary inline. */
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. */
327
736
  getFinalReport(sessionId) {
328
737
  return this.finalReportBySession.get(sessionId);
329
738
  }
739
+ /** Returns true if Final Report generation explicitly failed for this session.
740
+ * Lets the API distinguish "not yet" from "never coming". */
741
+ hasFailedReport(sessionId) {
742
+ return this.failedReportBySession.has(sessionId);
743
+ }
330
744
  get(sessionId) {
331
745
  return this.sessions.get(sessionId);
332
746
  }
@@ -0,0 +1,208 @@
1
+ /**
2
+ * Cloud sync bridge for codehere remote dashboard.
3
+ *
4
+ * Pushes session state to codehere.uk/api/sync every 30s.
5
+ * Pulls task assignments from cloud.
6
+ *
7
+ * What leaves the machine:
8
+ * - Session metadata (id, title, status, agent, project, tags, duration, tools, files, trustScore)
9
+ * - Machine identity (name, token hash)
10
+ *
11
+ * What stays local:
12
+ * - Code, file contents, API keys, git repos, full conversation threads
13
+ */
14
+ import { randomUUID } from 'crypto';
15
+ import { readFileSync, existsSync, writeFileSync, mkdirSync } from 'fs';
16
+ import { join, dirname } from 'path';
17
+ import { homedir, hostname } from 'os';
18
+ const CLOUD_URL = 'https://codehere.uk/api/sync';
19
+ const TOKEN_PATH = join(homedir(), '.codehere', 'cloud-token');
20
+ const SESSIONS_FILE = join(homedir(), '.codehere', 'sessions.jsonl');
21
+ // Lazy-loaded session manager — imported once to avoid circular deps
22
+ let _sessionManager = null;
23
+ async function getSessionManager() {
24
+ if (!_sessionManager) {
25
+ const { SessionManager } = await import('../orchestration/session-manager.js');
26
+ _sessionManager = SessionManager.getInstance();
27
+ }
28
+ return _sessionManager;
29
+ }
30
+ // ── Token management ─────────────────────────────────────────────────────────
31
+ export function getOrCreateToken() {
32
+ if (existsSync(TOKEN_PATH)) {
33
+ return readFileSync(TOKEN_PATH, 'utf-8').trim();
34
+ }
35
+ const token = randomUUID();
36
+ const dir = dirname(TOKEN_PATH);
37
+ if (!existsSync(dir)) {
38
+ mkdirSync(dir, { recursive: true });
39
+ }
40
+ writeFileSync(TOKEN_PATH, token);
41
+ return token;
42
+ }
43
+ function formatDuration(ms) {
44
+ const seconds = Math.floor(ms / 1000);
45
+ if (seconds < 60)
46
+ return `${seconds}s`;
47
+ const minutes = Math.floor(seconds / 60);
48
+ const remainingSeconds = seconds % 60;
49
+ return `${minutes}m ${remainingSeconds}s`;
50
+ }
51
+ function extractProjectFromCwd(cwd) {
52
+ // Extract project name from cwd path (last directory component)
53
+ const parts = cwd.split('/').filter(Boolean);
54
+ return parts[parts.length - 1] || 'unknown';
55
+ }
56
+ function extractTrustScore(artifacts) {
57
+ return artifacts?.trustScore;
58
+ }
59
+ export function collectSessionMetadata() {
60
+ if (!existsSync(SESSIONS_FILE)) {
61
+ return [];
62
+ }
63
+ try {
64
+ const content = readFileSync(SESSIONS_FILE, 'utf-8');
65
+ const lines = content.split('\n').filter(l => l.trim().length > 0);
66
+ // Parse records and pair spawned + completed
67
+ const spawnedMap = new Map();
68
+ const completedMap = new Map();
69
+ const finalReportMap = new Map();
70
+ for (const line of lines) {
71
+ try {
72
+ const record = JSON.parse(line);
73
+ if (record.t === 'spawned') {
74
+ spawnedMap.set(record.id, record);
75
+ }
76
+ else if (record.t === 'completed') {
77
+ completedMap.set(record.id, record);
78
+ }
79
+ else if (record.t === 'finalReport') {
80
+ finalReportMap.set(record.id, record);
81
+ }
82
+ }
83
+ catch {
84
+ // Skip malformed lines
85
+ }
86
+ }
87
+ const sessions = [];
88
+ for (const [id, spawned] of spawnedMap.entries()) {
89
+ const completed = completedMap.get(id);
90
+ const finalReport = finalReportMap.get(id);
91
+ const status = completed
92
+ ? (completed.status === 'completed' ? 'done' : completed.status === 'failed' ? 'failed' : 'done')
93
+ : 'running';
94
+ const durationMs = completed?.durationMs ?? (Date.now() - spawned.startedAt);
95
+ const tools = completed?.steps?.length ?? 0;
96
+ const files = completed?.artifacts?.filesChanged?.length ?? 0;
97
+ const trustScore = extractTrustScore(completed?.artifacts);
98
+ // Extract title from prompt (first line or first 80 chars)
99
+ const title = spawned.prompt.split('\n')[0].slice(0, 80) || 'Untitled task';
100
+ sessions.push({
101
+ id,
102
+ title,
103
+ status,
104
+ agent: spawned.agent || 'Unknown',
105
+ project: extractProjectFromCwd(spawned.cwd || ''),
106
+ tags: [], // Tags not yet implemented in session schema
107
+ duration: formatDuration(durationMs),
108
+ tools,
109
+ files,
110
+ ...(trustScore !== undefined ? { trustScore } : {}),
111
+ });
112
+ }
113
+ return sessions.sort((a, b) => {
114
+ // Running first, then by duration descending
115
+ if (a.status === 'running' && b.status !== 'running')
116
+ return -1;
117
+ if (a.status !== 'running' && b.status === 'running')
118
+ return 1;
119
+ return 0;
120
+ });
121
+ }
122
+ catch {
123
+ return [];
124
+ }
125
+ }
126
+ // ── Sync loop ────────────────────────────────────────────────────────────────
127
+ let syncInterval = null;
128
+ export function startCloudSync(machineName) {
129
+ const token = getOrCreateToken();
130
+ // Use hostname + first 8 chars of token as machine ID — unique per machine
131
+ const machineId = machineName || `${hostname()}-${token.slice(0, 8)}`;
132
+ console.log(` Cloud sync enabled — pushing to codehere.uk/app`);
133
+ console.log(` Machine: ${machineId}`);
134
+ console.log(` Token: ${TOKEN_PATH}`);
135
+ const push = async () => {
136
+ try {
137
+ const sessions = collectSessionMetadata();
138
+ const res = await fetch(CLOUD_URL, {
139
+ method: 'POST',
140
+ headers: { 'Content-Type': 'application/json' },
141
+ body: JSON.stringify({
142
+ machineId,
143
+ name: machineId,
144
+ token,
145
+ sessions,
146
+ timestamp: Date.now(),
147
+ }),
148
+ });
149
+ if (!res.ok) {
150
+ const body = await res.text();
151
+ console.error(` Cloud sync failed: ${res.status} ${body}`);
152
+ }
153
+ else {
154
+ const data = await res.json();
155
+ if (data.sessionsPushed && data.sessionsPushed > 0) {
156
+ console.log(` Cloud sync: ${data.sessionsPushed} session(s) pushed`);
157
+ }
158
+ }
159
+ }
160
+ catch (err) {
161
+ console.error(` Cloud sync error: ${err instanceof Error ? err.message : String(err)}`);
162
+ }
163
+ };
164
+ const pullTasks = async () => {
165
+ try {
166
+ const res = await fetch(`${CLOUD_URL}?action=pull-tasks&machineId=${machineId}`);
167
+ if (!res.ok)
168
+ return;
169
+ const data = await res.json();
170
+ const tasks = data.tasks || [];
171
+ for (const task of tasks) {
172
+ if (task.prompt) {
173
+ console.log(` Cloud task received: ${task.prompt.slice(0, 60)}...`);
174
+ try {
175
+ const manager = await getSessionManager();
176
+ manager.spawn(task.prompt, task.agent || 'claude-code');
177
+ // Mark task as claimed
178
+ await fetch(`${CLOUD_URL}?action=claim-task&machineId=${machineId}&taskId=${task.id}`, {
179
+ method: 'POST',
180
+ headers: { 'Content-Type': 'application/json' },
181
+ body: JSON.stringify({ machineId, token, taskId: task.id }),
182
+ });
183
+ }
184
+ catch (err) {
185
+ console.error(` Failed to spawn cloud task: ${err instanceof Error ? err.message : String(err)}`);
186
+ }
187
+ }
188
+ }
189
+ }
190
+ catch (err) {
191
+ // Silent fail for task pull — not critical
192
+ }
193
+ };
194
+ // Push immediately, then every 30s
195
+ push();
196
+ pullTasks();
197
+ syncInterval = setInterval(() => {
198
+ push();
199
+ pullTasks();
200
+ }, 30000);
201
+ }
202
+ export function stopCloudSync() {
203
+ if (syncInterval) {
204
+ clearInterval(syncInterval);
205
+ syncInterval = null;
206
+ }
207
+ }
208
+ //# sourceMappingURL=cloud-sync.js.map