File size: 99,035 Bytes
69f9cd1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b31ff59
69f9cd1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a625b1a
 
 
 
 
 
 
 
 
69f9cd1
 
3f30086
 
 
 
69f9cd1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
46e3de3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
69f9cd1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a625b1a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
69f9cd1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
68500e7
69f9cd1
 
 
a625b1a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
69f9cd1
 
 
 
 
 
 
 
 
 
 
a625b1a
 
69f9cd1
a625b1a
 
69f9cd1
a625b1a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
69f9cd1
 
a625b1a
69f9cd1
a625b1a
 
 
 
 
 
 
 
 
 
 
 
 
69f9cd1
 
 
 
 
 
 
 
a625b1a
69f9cd1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
68500e7
 
69f9cd1
 
 
 
 
 
 
 
 
 
 
 
 
a625b1a
 
 
 
 
 
 
69f9cd1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a625b1a
 
69f9cd1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a625b1a
69f9cd1
 
 
a625b1a
 
 
69f9cd1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a625b1a
 
 
69f9cd1
68500e7
69f9cd1
a625b1a
 
 
 
 
69f9cd1
a625b1a
69f9cd1
 
a625b1a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
69f9cd1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
68500e7
69f9cd1
 
 
 
 
 
 
 
 
 
 
 
b31ff59
 
69f9cd1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b31ff59
69f9cd1
 
 
 
 
b31ff59
69f9cd1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3f30086
69f9cd1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
46e3de3
 
 
69f9cd1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a625b1a
 
 
69f9cd1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b31ff59
 
69f9cd1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b31ff59
69f9cd1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3f30086
69f9cd1
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
1748
1749
1750
1751
1752
1753
1754
1755
1756
1757
1758
1759
1760
1761
1762
1763
1764
1765
1766
1767
1768
1769
1770
1771
1772
1773
1774
1775
1776
1777
1778
1779
1780
1781
1782
1783
1784
1785
1786
1787
1788
1789
1790
1791
1792
1793
1794
1795
1796
1797
1798
1799
1800
1801
1802
1803
1804
1805
1806
1807
1808
1809
1810
1811
1812
1813
1814
1815
1816
1817
1818
1819
1820
1821
1822
1823
1824
1825
1826
1827
1828
1829
1830
1831
1832
1833
1834
1835
1836
1837
1838
1839
1840
1841
1842
1843
1844
1845
1846
1847
1848
1849
1850
1851
1852
1853
1854
1855
1856
1857
from __future__ import annotations

import os
import json
import re
import shutil
import socket
import subprocess
import sys
import threading
import time
import urllib.request
from pathlib import Path
from typing import Any

from voyager_learning import read_learning_state
from voyager_runtime_contract import (
    EXPECTED_DIR, REFERENCE_DIR, UPSTREAM_COMMIT, apply_curriculum_retry_adaptation,
    read_attestation,
)
from naldo_opencode import (
    ALLOWED_MODELS,
    BRAIN_PROFILE as NALDO_BRAIN_PROFILE,
    BRIDGE_BASE as NALDO_BRIDGE_BASE,
    BRIDGE_PORT as NALDO_BRIDGE_PORT,
    OPENCODE_BASE as NALDO_OPENCODE_BASE,
    OPENCODE_PORT as NALDO_OPENCODE_PORT,
    PRIMARY_MODEL as NALDO_PRIMARY_MODEL,
    check_bridge_health as _check_bridge_health,
    check_opencode_health as _check_opencode_health,
    ensure_opencode_installed,
    is_loopback_url,
    opencode_serve_command,
    opencode_subprocess_env,
)

ROOT = Path(os.environ.get("VOYAGER_RUNTIME_HOME", "/tmp/voyager-runtime"))
PERSIST_ROOT = Path(os.environ.get("MINECRAFT_PERSIST_HOME", "/persist/minecraft-friends"))
CKPT_DIR = Path(os.environ.get("VOYAGER_PERSIST_CKPT", str(PERSIST_ROOT / "voyager" / "ckpt-20261003-normal-v1")))
# The new map starts EMPTY (resume=False): no skills, tasks or positions are
# ever carried from the old checkpoint. The old directory is preserved as-is
# for history, never read, modified or deleted by the new map.
CKPT_CORE_STATE = (
    "curriculum/completed_tasks.json",
    "curriculum/failed_tasks.json",
    "skill/skills.json",
)
BOT_USERNAME = os.environ.get("MC_USERNAME", "Naldo_Bot")
BOT_DISPLAY_NAME = os.environ.get("BOT_DISPLAY_NAME", "Naldo Bot")
EMBED_BASE_URL = os.environ.get(
    "VOYAGER_EMBED_BASE_URL",
    os.environ.get("VOYAGER_BRAIN_BASE_URL", "https://erinaldordso-mine.hf.space/v1"),
).rstrip("/")
SRC = ROOT / "Voyager"
LOG_PATH = ROOT / "voyager.log"
SETUP_LOG = ROOT / "setup.log"
UPSTREAM = "https://github.com/MineDojo/Voyager.git"


def _log(message: str) -> None:
    ROOT.mkdir(parents=True, exist_ok=True)
    line = f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] {message}"
    print(line, flush=True)
    with SETUP_LOG.open("a", encoding="utf-8", errors="replace") as fh:
        fh.write(line + "\n")


def _wait_tcp(host: str, port: int, timeout: float) -> bool:
    deadline = time.time() + timeout
    while time.time() < deadline:
        try:
            with socket.create_connection((host, port), timeout=1):
                return True
        except OSError:
            time.sleep(1)
    return False


def _wait_embedding_ready(base_url: str, timeout: float = 180.0) -> bool:
    deadline = time.monotonic() + timeout
    payload = json.dumps({"model": "text-embedding-ada-002", "input": "health"}).encode("utf-8")
    while time.monotonic() < deadline:
        request = urllib.request.Request(
            base_url.rstrip("/") + "/embeddings", data=payload, method="POST",
            headers={"Content-Type": "application/json"},
        )
        try:
            remaining = max(1.0, deadline - time.monotonic())
            with urllib.request.urlopen(request, timeout=min(remaining, 120.0)) as response:
                body = json.loads(response.read().decode("utf-8"))
            if isinstance(body, dict) and body.get("data"):
                return True
        except Exception:
            pass
        time.sleep(1.0)
    return False


def _terminate_naldo_child(stack: Any) -> None:
    """Stop only the Voyager child; brain processes are owned separately."""
    try:
        with stack.lock:
            proc = stack.proc
    except Exception:
        proc = getattr(stack, "proc", None)
    if proc is None or proc.poll() is not None:
        return
    try:
        proc.terminate()
        proc.wait(timeout=15)
    except Exception:
        try:
            proc.kill()
        except Exception:
            pass


# Long autonomous sessions append to voyager.log for days. status() is called
# every 30s by the supervisor, so it must never read the whole file into
# memory. Read at most max_lines trailing lines via fixed-size seeks.
def tail_lines(path: Path, max_lines: int = 20000) -> list[str]:
    try:
        with path.open("rb") as fh:
            fh.seek(0, os.SEEK_END)
            size = fh.tell()
            chunk_size = 65536
            data = b""
            position = size
            while position > 0 and data.count(b"\n") <= max_lines:
                step = min(chunk_size, position)
                position -= step
                fh.seek(position)
                data = fh.read(step) + data
                if len(data) > (max_lines + 1) * 512:
                    break
        text = data.decode("utf-8", errors="replace")
        lines = text.splitlines()
        return lines[-max_lines:]
    except OSError:
        return []


# Restart backoff must not stay pinned at the maximum after an isolated crash
# that follows a long healthy run; otherwise the bot looks stopped for minutes
# after a single transient failure. Reset only on proven healthy uptime.
def restart_backoff_after(
    backoff: int, uptime_s: float, initial: int = 5,
    maximum: int = 120, healthy_s: float = 600.0,
) -> int:
    if uptime_s >= healthy_s:
        return initial
    return min(backoff * 2, maximum)


def _run(args: list[str], cwd: Path, env: dict[str, str] | None = None, timeout: int = 900) -> None:
    args = list(args)
    if os.name == "nt" and args and args[0] in {"npm", "npx"}:
        args[0] += ".cmd"
    _log("exec " + " ".join(args))
    proc = subprocess.run(
        args,
        cwd=str(cwd),
        env=env,
        text=True,
        stdout=subprocess.PIPE,
        stderr=subprocess.STDOUT,
        timeout=timeout,
        check=False,
    )
    output = proc.stdout or ""
    with SETUP_LOG.open("a", encoding="utf-8", errors="replace") as fh:
        fh.write(output)
        if output and not output.endswith("\n"):
            fh.write("\n")
    if proc.returncode != 0:
        raise RuntimeError(f"command failed rc={proc.returncode}: {' '.join(args)}\n{output[-4000:]}")


# The last Babel 7 releases. Upstream calls babel.parse() synchronously and Babel 8
# made parse() callback-only, which fails every action.
BABEL_PINS = {"@babel/core": "7.26.9", "@babel/generator": "7.26.9"}

# README "Installation": Voyager requires Python >= 3.9 and Node.js >= 16.13.0.
MIN_PYTHON = (3, 9)
MIN_NODE = (16, 13, 0)


def _require_python_version() -> str:
    if sys.version_info[:2] < MIN_PYTHON:
        raise RuntimeError(
            f"Voyager requires Python >= {MIN_PYTHON[0]}.{MIN_PYTHON[1]}, "
            f"this Space runs {sys.version_info[0]}.{sys.version_info[1]}"
        )
    return ".".join(str(part) for part in sys.version_info[:3])


def _require_node_version() -> str:
    """Read the Node.js version and enforce the README's minimum."""
    proc = subprocess.run(
        ["node", "--version"],
        stdout=subprocess.PIPE,
        stderr=subprocess.STDOUT,
        text=True,
        timeout=60,
    )
    reported = (proc.stdout or "").strip().lstrip("v")
    try:
        parts = tuple(int(part) for part in reported.split(".")[:3])
    except ValueError as exc:
        raise RuntimeError(f"could not read the Node.js version from {reported!r}") from exc
    if parts < MIN_NODE:
        raise RuntimeError(
            f"Voyager requires Node.js >= {'.'.join(str(p) for p in MIN_NODE)}, this Space has {reported}"
        )
    return reported


def _replace_exact(text: str, old: str, new: str, what: str) -> str:
    """Replace exactly one occurrence; fail the boot instead of silently skipping.

    A str.replace that misses its upstream marker leaves the file byte-identical
    and the Space boots looking healthy on unpatched code. Every connection
    guard below is load-bearing for EPIPE recovery, so a missed marker must
    raise instead of being silently skipped.
    """
    count = text.count(old)
    if count != 1:
        raise RuntimeError(
            f"Voyager {what} marker changed upstream (found {count}x); refusing unsafe patch"
        )
    return text.replace(old, new, 1)


def _is_fresh_checkpoint(ckpt: Path) -> bool:
    """True when a checkpoint directory holds no carried state.

    Mirrors the runner's resume decision (any of the core state files present
    means persisted state): a fresh map directory must contain none of them,
    so the runner boots with resume=False and an empty library.
    """
    return not any((ckpt / relative).exists() for relative in CKPT_CORE_STATE)


class VoyagerStack:
    def __init__(self) -> None:
        self.lock = threading.RLock()
        self.worker: threading.Thread | None = None
        self.proc: subprocess.Popen[str] | None = None
        self.opencode_proc: subprocess.Popen[str] | None = None
        self.bridge_proc: subprocess.Popen[str] | None = None
        self.state = "stopped"
        self.error = ""
        self.started_at: float | None = None
        self.stop_event = threading.Event()
        self.restart_count = 0

    def _brain_procs(self) -> tuple[subprocess.Popen[str] | None, subprocess.Popen[str] | None]:
        with self.lock:
            return self.opencode_proc, self.bridge_proc

    def _snapshot_upstream_reference(self) -> None:
        """Keep a pristine copy of every file the patcher may rewrite.

        The boot-time attestation compares the live decision methods against
        these snapshots, so a substituted Curriculum/Action/Critic cannot pass
        as an unmodified upstream checkout.
        """
        for relative in (
            "voyager/voyager.py",
            "voyager/agents/curriculum.py",
            "voyager/agents/action.py",
            "voyager/agents/critic.py",
            "voyager/agents/skill.py",
            "voyager/prompts/curriculum.txt",
            "voyager/prompts/action_template.txt",
            "voyager/prompts/critic.txt",
        ):
            source = SRC / relative
            if not source.exists():
                raise RuntimeError(f"Upstream file missing before patching: {relative}")
            for directory in (REFERENCE_DIR, EXPECTED_DIR):
                destination = SRC / directory / relative
                destination.parent.mkdir(parents=True, exist_ok=True)
                shutil.copyfile(source, destination)

    def _publish_expected_curriculum(self) -> None:
        """Record the single declared adaptation to Curriculum.propose_next_ai_task."""
        relative = "voyager/agents/curriculum.py"
        expected = SRC / EXPECTED_DIR / relative
        expected.write_text(
            apply_curriculum_retry_adaptation(expected.read_text(encoding="utf-8")),
            encoding="utf-8",
        )

    def _patch_runtime_source(self) -> None:
        self._snapshot_upstream_reference()
        index_js = SRC / "voyager" / "env" / "mineflayer" / "index.js"
        text = index_js.read_text(encoding="utf-8")
        text = text.replace('username: "bot",', 'username: process.env.MC_USERNAME || "Naldo_Bot",')
        text = text.replace(
            '        port: req.body.port, // minecraft server port\n        username: process.env.MC_USERNAME || "Naldo_Bot",',
            '        port: req.body.port, // minecraft server port\n        version: "1.19", // server is pinned to Java 1.19; never auto-detect a different patch protocol\n        username: process.env.MC_USERNAME || "Naldo_Bot",',
        )
        # The per-connection helpers are copied to lib/voyager_connection.js
        # below. This import is what makes them real: without it the copy is
        # dead code and a pending /step parks Python with position frozen.
        text = _replace_exact(
            text,
            'const { plugin: tool } = require("mineflayer-tool");',
            'const { plugin: tool } = require("mineflayer-tool");\n'
            'const { installConnectionGuards, isReady, startLifecycle, restoreCreativeInventory } = require("./lib/voyager_connection");',
            "connection-helper import",
        )
        # Per-connection startup: paced chat queue + physics-wait guards plus a
        # 120s startup deadline that answers a pre-spawn kick with 503 instead
        # of hanging Python. startLifecycle captures THIS instance, so a delayed
        # "end" from a previous socket can never null a new bot. The lifecycle
        # owns startup errors EXCLUSIVELY: upstream's unfiltered
        # onConnectionFailed is removed below, because it nulled the global bot
        # on ANY error, including a benign post-ready one.
        text = _replace_exact(
            text,
            '    bot.once("error", onConnectionFailed);',
            '    installConnectionGuards(bot);\n'
            '    const startupBot = bot;\n'
            '    const startupLifecycle = startLifecycle(bot, res, () => {\n'
            '        if (bot === startupBot) bot = null;\n'
            '    });',
            "startup lifecycle",
        )
        # A delayed "end"/"kicked" from a previous socket must never stop the
        # new bot: upstream's handlers act on the GLOBAL bot, so an old socket
        # closing after a restart ends the replacement connection. Shut the
        # restarting instance down inline and make the handlers instance-aware.
        text = _replace_exact(
            text,
            '    if (bot) onDisconnect("Restarting bot");\n    bot = null;',
            '    const restartingBot = bot;\n'
            '    if (restartingBot) {\n'
            '        if (restartingBot.viewer) {\n'
            '            restartingBot.viewer.close();\n'
            '        }\n'
            '        restartingBot.end();\n'
            '        console.log("Restarting bot");\n'
            '    }\n'
            '    bot = null;',
            "restart isolation",
        )
        # Upstream's unfiltered onConnectionFailed nulled the global bot on ANY
        # error, including a benign post-ready one. The lifecycle above owns all
        # startup errors now, so the legacy handler is deleted entirely.
        text = _replace_exact(
            text,
            '    function onConnectionFailed(e) {\n'
            '        console.log(e);\n'
            '        bot = null;\n'
            '        res.status(400).json({ error: e });\n'
            '    }\n',
            '    // onConnectionFailed removed: startLifecycle owns startup errors.\n',
            "remove legacy error handler",
        )
        text = _replace_exact(
            text,
            '    function onDisconnect(message) {\n'
            '        if (bot.viewer) {\n'
            '            bot.viewer.close();\n'
            '        }\n'
            '        bot.end();\n'
            '        console.log(message);\n'
            '        bot = null;\n'
            '    }',
            '    function onDisconnect(message) {\n'
            '        if (startupBot.viewer) {\n'
            '            startupBot.viewer.close();\n'
            '        }\n'
            '        startupBot.end();\n'
            '        console.log(message);\n'
            '        if (bot === startupBot) bot = null;\n'
            '    }',
            "disconnect isolation",
        )
        text = _replace_exact(
            text,
            '    bot.once("spawn", async () => {\n'
            '        bot.removeListener("error", onConnectionFailed);\n'
            '        let itemTicks = 1;',
            '    bot.once("spawn", async () => {\n'
            '        // A late spawn from a replaced socket must not touch the new\n'
            '        // bot, and the lifecycle may already have answered 503 on a\n'
            '        // pre-spawn timeout/disconnect: never answer twice.\n'
            '        if (bot !== startupBot || res.headersSent) return;\n'
            '        let itemTicks = 1;',
            "spawn isolation",
        )
        # Upstream rebuilds a hard-reset inventory with one chat /give per item,
        # which vanilla 1.19 rate-limits as spam. Restore through the Creative
        # inventory protocol instead; existing stacks and NBT are preserved and
        # only missing items are added, so no per-item tick wait is needed.
        hard_reset_old = '''        let itemTicks = 1;
        if (req.body.reset === "hard") {
            bot.chat("/clear @s");
            bot.chat("/kill @s");
            const inventory = req.body.inventory ? req.body.inventory : {};
            const equipment = req.body.equipment
                ? req.body.equipment
                : [null, null, null, null, null, null];
            for (let key in inventory) {
                bot.chat(`/give @s minecraft:${key} ${inventory[key]}`);
                itemTicks += 1;
            }
            const equipmentNames = [
                "armor.head",
                "armor.chest",
                "armor.legs",
                "armor.feet",
                "weapon.mainhand",
                "weapon.offhand",
            ];
            for (let i = 0; i < 6; i++) {
                if (i === 4) continue;
                if (equipment[i]) {
                    bot.chat(
                        `/item replace entity @s ${equipmentNames[i]} with minecraft:${equipment[i]}`
                    );
                    itemTicks += 1;
                }
            }
        }
'''
        hard_reset_new = '''        let itemTicks = 1;
        if (req.body.reset === "hard") {
            const hardInventory = req.body.inventory ? req.body.inventory : {};
            const hardEquipment = req.body.equipment
                ? req.body.equipment
                : [null, null, null, null, null, null];
            await restoreCreativeInventory(startupBot, hardInventory, hardEquipment);
        }
'''
        text = _replace_exact(text, hard_reset_old, hard_reset_new, "creative hard reset")
        text = _replace_exact(
            text,
            '        await bot.waitForTicks(bot.waitTicks * itemTicks);\n        res.json(bot.observe());',
            '        await startupBot.waitForTicks(startupBot.waitTicks * itemTicks);\n'
            '        startupLifecycle.ready();\n'
            '        res.json(startupBot.observe());',
            "startup ready",
        )
        creative_spawn = '''        // Creative mode is the documented Voyager world mode. Populate a\n        // compact toolkit through Mineflayer\'s creative inventory protocol,\n        // not chat commands (which vanilla can rate-limit as spam).\n        const CreativeItem = require("prismarine-item")(bot.registry);\n        const voyagerToolkit = [\n            ["netherite_pickaxe", 1], ["netherite_axe", 1],\n            ["netherite_shovel", 1], ["netherite_sword", 1],\n            ["netherite_hoe", 1], ["shears", 1], ["flint_and_steel", 1],\n            ["bow", 1], ["crossbow", 1], ["fishing_rod", 1],\n            ["shield", 1], ["bucket", 1], ["arrow", 64],\n            ["crafting_table", 1], ["furnace", 1]\n        ];\n        for (const [item, count] of voyagerToolkit) {\n            const current = bot.inventory.items()\n                .filter((stack) => stack.name === item)\n                .reduce((sum, stack) => sum + stack.count, 0);\n            if (current >= count) continue;\n            const itemData = bot.registry.itemsByName[item];\n            if (!itemData) continue;\n            let slot = -1;\n            for (let i = 9; i <= 44; i++) {\n                if (!bot.inventory.slots[i]) { slot = i; break; }\n            }\n            if (slot === -1) break;\n            await bot.creative.setInventorySlot(\n                slot, new CreativeItem(itemData.id, Math.min(count - current, itemData.stackSize || 64))\n            );\n        }\n\n'''
        spawn_marker = "        if (req.body.position) {\n"
        if creative_spawn.strip() not in text:
            if spawn_marker not in text:
                raise RuntimeError("Voyager spawn marker changed; refusing creative-mode patch")
            text = text.replace(spawn_marker, creative_spawn + spawn_marker, 1)

        text = text.replace(
            'app.post("/step", async (req, res) => {\n    // import useful package',
            'app.post("/step", async (req, res) => {\n    if (!bot || !bot.entity) {\n        res.status(503).json({ error: "Bot not spawned" });\n        return;\n    }\n    // import useful package',
        )
        text = _replace_exact(
            text,
            '    let response_sent = false;\n    function otherError(err) {',
            '''    let response_sent = false;\n    let step_finished = false;\n    const stepBot = bot;\n    function failStepOnDisconnect(reason) {\n        // Always answer THIS pending request, even if the global bot was\n        // swapped or nulled since: this only resolves the old request with\n        // 503 and never touches the new connection.\n        if (step_finished || response_sent) return;\n        response_sent = true;\n        let detail = "disconnected";\n        try {\n            detail = typeof reason === "string" ? reason : JSON.stringify(reason);\n        } catch (_) {}\n        console.error(`Mineflayer disconnected during /step: ${detail}`);\n        if (!res.headersSent) {\n            res.status(503).json({ error: `Mineflayer disconnected during step: ${detail}` });\n        }\n        cleanupStepDisconnectListeners();\n    }\n    function failStepOnSocketError(error) {\n        const code = error && error.code;\n        const message = String((error && error.message) || error || "");\n        if (code === "EPIPE" || code === "ECONNRESET" || message.includes("EPIPE") || message.includes("ECONNRESET") || message.includes("Framer") || message.includes("Socket closed")) {\n            failStepOnDisconnect(error);\n        }\n    }\n    function cleanupStepDisconnectListeners() {\n        step_finished = true;\n        stepBot.removeListener("end", failStepOnDisconnect);\n        stepBot.removeListener("kicked", failStepOnDisconnect);\n        stepBot.removeListener("error", failStepOnSocketError);\n    }\n    stepBot.once("end", failStepOnDisconnect);\n    stepBot.once("kicked", failStepOnDisconnect);\n    // Persistent filtered error listener: a benign action error must neither\n    // answer the step nor consume a once() handler that a later EPIPE needs.\n    stepBot.on("error", failStepOnSocketError);\n    function otherError(err) {''',
            "step disconnect guard",
        )
        text = _replace_exact(
            text,
            '    const r = await evaluateCode(code, programs);\n    process.off("uncaughtException", otherError);',
            '    const r = await evaluateCode(code, programs);\n'
            '    process.off("uncaughtException", otherError);\n'
            '    // A disconnect mid-step already answered 503 and may have nulled\n'
            '    // the global bot. Never continue into returnItems/observe on a\n'
            '    // dead connection; just detach and leave the 503 standing.\n'
            '    if (response_sent) {\n'
            '        stepBot.removeListener("physicTick", onTick);\n'
            '        cleanupStepDisconnectListeners();\n'
            '        return;\n'
            '    }',
            "step early exit after disconnect",
        )
        text = _replace_exact(
            text,
            '    bot.removeListener("physicTick", onTick);',
            '    bot.removeListener("physicTick", onTick);\n    cleanupStepDisconnectListeners();',
            "step listener cleanup",
        )
        text = _replace_exact(
            text,
            '    function otherError(err) {\n        console.log("Uncaught Error");',
            '    function otherError(err) {\n'
            '        if (!bot || response_sent) return;\n'
            '        console.log("Uncaught Error");',
            "uncaught-error guard",
        )
        text = _replace_exact(
            text,
            '    // Event subscriptions',
            '''    // Mineflayer/pathfinder can occasionally generate NaN/Infinity movement\n    // coordinates after a failed path/reset. Vanilla rejects those with\n    // "Invalid move player packet received" and kicks the bot. Block only\n    // malformed movement packets; valid movement is untouched.\n    if (bot._client && typeof bot._client.write === "function") {\n        const originalWrite = bot._client.write.bind(bot._client);\n        bot._client.write = (name, data, ...args) => {\n            if (data && ["position", "position_look", "look", "flying"].includes(name)) {\n                const numericFields = ["x", "y", "z", "yaw", "pitch"];\n                const invalid = numericFields.some((field) =>\n                    Object.prototype.hasOwnProperty.call(data, field) &&\n                    typeof data[field] === "number" &&\n                    !Number.isFinite(data[field])\n                );\n                if (invalid) {\n                    console.warn("Blocked invalid movement packet", name, data);\n                    return;\n                }\n            }\n            return originalWrite(name, data, ...args);\n        };\n    }\n\n    // Event subscriptions''',
            "movement guard",
        )
        evaluate_marker = "    async function evaluateCode(code, programs) {\n"
        inventory_guard = '''        // Official Voyager skills use bot.inventory.count(...) for quantities.\n        // Reject stack-count code from the local LLM before it can consume resources.\n        const directLengthBug = /bot\\.inventory\\.items\\(\\)(?:\\.filter\\([\\s\\S]{0,300}?\\))?\\.length\\b/;\n        const assignedLengthBug = /(?:const|let|var)\\s+([A-Za-z_$][\\w$]*)\\s*=\\s*bot\\.inventory\\.items\\(\\)\\.filter\\([\\s\\S]{0,300}?\\);[\\s\\S]{0,500}?\\1\\.length\\b/;\n        if (directLengthBug.test(code) || assignedLengthBug.test(code)) {\n            throw new Error("Inventory quantity bug: array.length counts stacks, not items. Use bot.inventory.count(mcData.itemsByName.<item>.id) or sum item.count.");\n        }\n'''
        if inventory_guard.strip() not in text:
            if evaluate_marker not in text:
                raise RuntimeError("Voyager evaluateCode marker changed; refusing inventory guard patch")
            text = text.replace(evaluate_marker, evaluate_marker + inventory_guard, 1)

        health_route = '''app.get("/health", (req, res) => {\n    const inventory = {};\n    if (bot && bot.inventory) {\n        for (const item of bot.inventory.items()) {\n            inventory[item.name] = (inventory[item.name] || 0) + item.count;\n        }\n    }\n    res.json({\n        ready: isReady(bot),\n        username: bot?.username || null,\n        game_mode: bot?.game?.gameMode || null,\n        position: bot?.entity?.position ? {\n            x: bot.entity.position.x, y: bot.entity.position.y, z: bot.entity.position.z\n        } : null,\n        inventory\n    });\n});\n\n'''
        stop_marker = 'app.post("/stop", (req, res) => {\n'
        if health_route.strip() not in text:
            if stop_marker not in text:
                raise RuntimeError("Voyager stop marker changed; refusing health-route patch")
            text = text.replace(stop_marker, health_route + stop_marker, 1)

        text = text.replace(
            'app.post("/stop", (req, res) => {\n    bot.end();',
            'app.post("/stop", (req, res) => {\n    if (bot) bot.end();',
        )
        # /pause toggles the mod's GLOBAL forcePause, not one player's state.
        # Independent Voyagers and Xbox players share this always-on server.
        pause_route = text.index('app.post("/pause", (req, res) => {')
        pause_end = text.index("// Server listening", pause_route)
        text = text[:pause_route] + '''app.post("/pause", (req, res) => {
    if (!bot || !bot.entity) {
        res.status(503).json({ error: "Bot not spawned" });
        return;
    }
    res.json({ message: "Success", shared_world: true });
});

''' + text[pause_end:]
        text = text.replace(
            '    bot.cumulativeObs = [];\n    await bot.waitForTicks(bot.waitTicks);',
            '    bot.cumulativeObs = [];\n'
            '    bot._voyagerVerifiedActions = [];\n'
            '    bot._voyagerStepStart = {x:bot.entity.position.x,y:bot.entity.position.y,z:bot.entity.position.z};\n'
            '    await bot.waitForTicks(bot.waitTicks);', 1,
        )
        text = text.replace(
            '    bot.pathfinder.setMovements(movements);',
            '    if (bot.game.gameMode === "creative") {\n'
            # canDig stays at its upstream default on purpose. A mining bot digs
            # its own shaft, and only the pathfinder can dig it back out; turning
            # this off strands the bot in a hole it can never leave.
            '        movements.allow1by1towers = false;\n'
            '        movements.maxDropDown = 2;\n'
            '    }\n'
            '    bot.pathfinder.thinkTimeout = 5000;\n'
            '    bot.pathfinder.tickTimeout = 40;\n'
            '    bot.pathfinder.searchRadius = 64;\n'
            '    bot.pathfinder.setMovements(movements);', 1,
        )
        index_js.write_text(text, encoding="utf-8")
        observation_path = SRC / "voyager/env/mineflayer/lib/observation/base.js"
        observation_text = observation_path.read_text(encoding="utf-8")
        observation_text = observation_text.replace(
            '        bot.cumulativeObs.push([event_name, result]);',
            '        result.verified_actions = JSON.parse(JSON.stringify(bot._voyagerVerifiedActions || []));\n'
            '        result.step_start_position = bot._voyagerStepStart || null;\n'
            '        bot.cumulativeObs.push([event_name, result]);', 1,
        )
        observation_path.write_text(observation_text, encoding="utf-8")
        support = Path(__file__).resolve().parent
        shutil.copyfile(support / "voyager_verification.py", SRC / "voyager_verification.py")
        shutil.copyfile(support / "minecraft_ids_1_19.json", SRC / "minecraft_ids_1_19.json")
        shutil.copyfile(support / "voyager_place.js", SRC / "voyager/control_primitives/placeItem.js")
        # Per-connection guards: paced chat queue (limit 128, reject on close),
        # physics-wait rejection on end/kicked/error, and startup/disconnect
        # deadlines. Without this copy the EPIPE/Socket/Framer path has no
        # guard and a pending /step parks Python with position frozen.
        connection_lib = SRC / "voyager" / "env" / "mineflayer" / "lib" / "voyager_connection.js"
        connection_lib.parent.mkdir(parents=True, exist_ok=True)
        shutil.copyfile(support / "voyager_connection.js", connection_lib)
        # The runner's brain lock imports these constants; keep a copy inside
        # the runtime tree so the child process resolves the same module.
        shutil.copyfile(support / "naldo_opencode.py", SRC / "naldo_opencode.py")
        # No local build template is installed. Upstream ships no buildProject
        # and no placeBlocks: geometry is the Action Agent's responsibility, via
        # the official placeItem primitive. The earlier closed build catalog was
        # a local planning layer, not upstream behavior.

        # Upstream mineBlock calls findBlocks(maxDistance=32, count=1024) and
        # passes a large target set to collectBlock. On this shared CPU runtime,
        # that can starve Mineflayer's keepalive long enough for vanilla to kick
        # the client with "Timed out". Mine one nearest block at a time instead.
        mine_block = SRC / "voyager" / "control_primitives" / "mineBlock.js"
        primitive = r'''async function mineBlock(bot, name, count = 1) {
    if (typeof name !== "string") {
        throw new Error(`name for mineBlock must be a string`);
    }
    if (typeof count !== "number" || !Number.isFinite(count) || count < 1) {
        throw new Error(`count for mineBlock must be a positive number`);
    }
    const blockByName = mcData.blocksByName[name];
    if (!blockByName) {
        throw new Error(`No block named ${name}`);
    }

    const wanted = Math.max(1, Math.floor(count));
    let mined = 0;
    const minedPositions = [];
    while (mined < wanted) {
        // Upstream searches every block within range. This keeps a floor so a
        // mining bot digs a shaft it can walk out of instead of chasing a seam
        // indefinitely; the bot's own level and the two layers under its feet are
        // the working face, and it descends one step per mined block.
        const playerFloorY = Math.floor(bot.entity.position.y) - 2;
        const block = bot.findBlock({
            matching: (candidate) => Boolean(candidate) &&
                Boolean(candidate.position) &&
                [candidate.position.x, candidate.position.y, candidate.position.z]
                    .every(Number.isFinite) &&
                candidate.type === blockByName.id &&
                candidate.position.y >= playerFloorY,
            maxDistance: 32,
        });
        if (!block) {
            bot.chat(`No ${name} nearby, please explore first`);
            throw new Error(`No ${name} within 32 blocks; explore on the surface before calling mineBlock again`);
        }

        // Creative mode has no block drops. collectBlock is designed to dig
        // and then collect the dropped item, so use the native Mineflayer dig
        // primitive instead. Mineflayer documents creative dig time as zero.
        if (bot.game && bot.game.gameMode === "creative") {
            bot.pathfinder.thinkTimeout = 5000;
            bot.pathfinder.tickTimeout = 40;
            bot.pathfinder.searchRadius = 64;
            try {
                await bot.pathfinder.goto(new GoalLookAtBlock(block.position, bot.world));
                await bot.dig(block, true);
                await bot.waitForTicks(2);
                const remaining = bot.blockAt(block.position);
                if (!remaining || remaining.type === block.type) {
                    throw new Error(`Creative dig made no block change for ${name}`);
                }
                mined++;
                minedPositions.push([block.position.x, block.position.y, block.position.z]);
                continue;
            } catch (err) {
                try { bot.pathfinder.setGoal(null); } catch (_) {}
                throw err;
            }
        }

        // Survival branch: require a real harvesting tool and use collectBlock
        // so Voyager also picks up the dropped resource.
        const canHarvest = block.canHarvest(null) ||
            bot.inventory.items().some((item) => block.canHarvest(item.type));
        if (!canHarvest) {
            const tools = Object.keys(blockByName.harvestTools || {})
                .map((id) => mcData.items[id]?.name).filter(Boolean);
            throw new Error(`Missing harvest tool for ${name}; obtain/craft one of: ${tools.join(", ")}`);
        }

        bot.pathfinder.thinkTimeout = 5000;
        bot.pathfinder.tickTimeout = 40;
        bot.pathfinder.searchRadius = 64;
        try {
            await bot.collectBlock.collect(block, {
                ignoreNoPath: false,
                count: 1,
            });
            const remaining = bot.blockAt(block.position);
            if (!remaining || remaining.type === block.type) {
                throw new Error(`Collection made no block change for ${name}; inspect prerequisites and path`);
            }
            mined++;
            minedPositions.push([block.position.x, block.position.y, block.position.z]);
            await bot.waitForTicks(2);
        } catch (err) {
            try { bot.pathfinder.setGoal(null); } catch (_) {}
            throw err;
        }
    }
    bot._voyagerVerifiedActions = bot._voyagerVerifiedActions || [];
    bot._voyagerVerifiedActions.push({kind:"mine",name,positions:minedPositions});
    bot.save(`${name}_mined`);
}
'''
        mine_block.write_text(primitive, encoding="utf-8")

        creative_give = SRC / "voyager" / "control_primitives" / "creativeGiveItem.js"
        creative_give.write_text(r'''async function creativeGiveItem(bot, name, count = 1) {
    if (typeof name !== "string" || typeof count !== "number" || count < 1) {
        throw new Error("creativeGiveItem requires an item name and positive count");
    }
    if (bot.game.gameMode !== "creative") {
        throw new Error("creativeGiveItem requires Creative mode");
    }
    const itemData = bot.registry.itemsByName[name];
    if (!itemData) throw new Error(`No item named ${name}`);
    const current = bot.inventory.items()
        .filter((stack) => stack.name === name)
        .reduce((sum, stack) => sum + stack.count, 0);
    let remaining = Math.max(0, Math.floor(count) - current);
    if (remaining === 0) return;
    const Item = require("prismarine-item")(bot.registry);
    // Keep the persistent toolkit and armor. In Creative, replace an old
    // expendable stack when all 36 ordinary inventory slots are occupied.
    // Prefer a building block, then another non-tool item; never clear the
    // entire inventory or require a chat /give command.
    const protectedItems = new Set([
        "netherite_pickaxe", "netherite_axe", "netherite_shovel", "netherite_sword",
        "netherite_hoe", "shears", "flint_and_steel", "bow", "crossbow",
        "fishing_rod", "shield", "bucket", "arrow", "crafting_table", "furnace",
        "netherite_helmet", "netherite_chestplate", "netherite_leggings", "netherite_boots"
    ]);
    while (remaining > 0) {
        let slot = -1;
        for (const [start, end] of [[9, 35], [36, 44]]) {
            for (let i = end; i >= start; i--) {
                if (!bot.inventory.slots[i]) { slot = i; break; }
            }
            if (slot !== -1) break;
        }
        for (let phase = 0; slot === -1 && phase < 2; phase++) {
            for (const [start, end] of [[9, 35], [36, 44]]) {
                for (let i = end; i >= start; i--) {
                    const old = bot.inventory.slots[i];
                    if (!old || old.name === name || protectedItems.has(old.name)) continue;
                    if (phase === 0 && !bot.registry.blocksByName[old.name]) continue;
                    slot = i;
                    break;
                }
                if (slot !== -1) break;
            }
        }
        if (slot === -1) throw new Error(`No replaceable Creative inventory slot for ${name}`);
        const stackCount = Math.min(remaining, itemData.stackSize || 64);
        await bot.creative.setInventorySlot(slot, new Item(itemData.id, stackCount));
        remaining -= stackCount;
    }
}
''', encoding="utf-8")
        creative_give_context = SRC / "voyager" / "control_primitives_context" / "creativeGiveItem.js"
        creative_give_context.write_text(r'''// Creative-only helper. Use this when an Obtain/Get/Craft task requires
// a vanilla item that is missing from the live Inventory.
async function creativeGiveItem(bot, name, count = 1) {
    // Adds up to `count` total items of `name` using Mineflayer's
    // Creative inventory API. Throws outside Creative mode or for bad names.
}
''', encoding="utf-8")

        # Voyager's upstream exploreUntil allows Vec3(0,-1,0), which makes
        # GoalNear select a point 10-29 blocks below the bot every two seconds.
        # That behavior is useful in Survival cave exploration but is harmful in
        # this Creative agent: it blindly drives the pathfinder underground even
        # when the requested block is already inside findBlock's 32-block range.
        explore_until = SRC / "voyager" / "control_primitives" / "exploreUntil.js"
        explore_until.write_text(r'''async function exploreUntil(
    bot,
    direction,
    maxTime = 60,
    callback = () => false
) {
    if (typeof maxTime !== "number" || !Number.isFinite(maxTime) || maxTime <= 0) {
        throw new Error("maxTime must be a finite positive number");
    }
    if (typeof callback !== "function") throw new Error("callback must be a function");

    const initial = callback();
    if (initial) {
        return Promise.resolve(initial);
    }

    if (!direction || ![direction.x, direction.y, direction.z]
        .every((value) => Number.isFinite(Number(value)))) {
        throw new Error("direction must contain three finite coordinates");
    }
    // Models may generate a continuous random vector. Preserve its signs while
    // adapting it to the discrete directions required by the Voyager primitive.
    let dx = Math.sign(Number(direction.x));
    let dy = Math.sign(Number(direction.y));
    let dz = Math.sign(Number(direction.z));
    if (dx === 0 && dy === 0 && dz === 0) {
        throw new Error("direction cannot be 0, 0, 0");
    }

    // Creative safety policy: exploration is horizontal. Mining primitives may
    // still path to blocks above/below when an actual block coordinate exists.
    if (bot.game && bot.game.gameMode === "creative" && dx === 0 && dz === 0) {
        const candidates = [[1,0],[-1,0],[0,1],[0,-1],[1,1],[1,-1],[-1,1],[-1,-1]];
        [dx, dz] = candidates[Math.floor(Math.random() * candidates.length)];
    }
    if (bot.game && bot.game.gameMode === "creative") dy = 0;

    maxTime = Math.min(maxTime, 120);
    const start = {x: bot.entity.position.x, z: bot.entity.position.z};
    return new Promise((resolve, reject) => {
        let explorationInterval;
        let maxTimeTimeout;
        const cleanUp = () => {
            clearInterval(explorationInterval);
            clearTimeout(maxTimeTimeout);
            try { bot.pathfinder.setGoal(null); } catch (_) {}
        };
        const explore = () => {
            try {
                const result = callback();
                if (result) {
                    cleanUp();
                    resolve(result);
                    return;
                }
                let distance = Math.floor(Math.random() * 12 + 8);
                if (bot.game && bot.game.gameMode === "creative") {
                    const travelled = Math.hypot(
                        bot.entity.position.x - start.x, bot.entity.position.z - start.z
                    );
                    const remaining = 32 - travelled;
                    if (remaining <= 1) {
                        cleanUp();
                        resolve(null);
                        return;
                    }
                    distance = Math.min(distance, remaining / Math.hypot(dx, dz));
                }
                const x = bot.entity.position.x + distance * dx;
                const y = bot.entity.position.y + distance * dy;
                const z = bot.entity.position.z + distance * dz;
                const goal = dy === 0 ? new GoalNearXZ(x, z) : new GoalNear(x, y, z);
                bot.pathfinder.setGoal(goal);
            } catch (err) {
                cleanUp();
                reject(err);
            }
        };
        explorationInterval = setInterval(explore, 1500);
        maxTimeTimeout = setTimeout(() => {
            cleanUp();
            resolve(null);
        }, maxTime * 1000);
    });
}
''', encoding="utf-8")

        explore_context = SRC / "voyager" / "control_primitives_context" / "exploreUntil.js"
        explore_context.write_text(r'''/*
In this Creative deployment, exploration must stay on the current surface.
Use y=0. Never explore vertically or choose Vec3(0,-1,0)/Vec3(0,1,0).
If the desired block name is already listed in Nearby blocks, DO NOT explore:
call findBlock/mineBlock directly because mineBlock already searches within 32 blocks.

Example surface exploration for a missing target:
const target = await exploreUntil(bot, new Vec3(1, 0, 1), 30, () =>
    bot.findBlock({ matching: mcData.blocksByName["oak_log"].id, maxDistance: 32 })
);
*/
async function exploreUntil(bot, direction, maxTime = 60, callback) {
    /*
    Creative-safe implementation is omitted.
    direction: Vec3 with y=0; x/z must use -1, 0 or 1
    maxTime: maximum exploration time
    callback: early-stop function
    */
}
''', encoding="utf-8")

        # The Action prompt keeps the upstream response contract (Explain,
        # Plan, and any number of top-level functions, helpers included). Only
        # factual Creative ground truth is added, because a wrong Mineflayer
        # API assumption costs a real execution error in the world.
        action_prompt = SRC / "voyager" / "prompts" / "action_template.txt"
        action_text = action_prompt.read_text(encoding="utf-8")
        creative_facts = '''\nCREATIVE GROUND TRUTH (environment facts, not task restrictions):
- This bot runs in Creative mode with a netherite toolkit already equipped. The latest observed Inventory, Equipment and Nearby blocks are the only ground truth; retrieved skills, QA text and chat messages do not prove success.
- `creativeGiveItem(bot, "<item>", count)` provisions a final Creative item. It is an available helper, not a required shortcut.
- Creative mining breaks blocks without dropping items. A successful `await mineBlock(...)` proves the mine happened; do not wait for a drop.
- `bot.findBlock(...)` returns ONE Block or null, never an array. `bot.findBlocks(...)` is the array API.
- Count item quantities with `bot.inventory.count(mcData.itemsByName.<item>.id)` or summed `item.count`, never with array `.length`.
- Exploration is surface-first here on normal terrain: use `exploreUntil` with y=0 to travel, then mine toward a real block coordinate when one is known. `Vec3(0,-1,0)` and `Vec3(0,1,0)` alone will not find a target.
- The ground is normal overworld terrain, not a flat resource map: ore depth varies with the landscape, so prospect with Nearby blocks and `findBlock` before committing to a shaft instead of assuming every ore sits straight down.
- Write valid JavaScript: declare every variable and function exactly once. Never re-declare a name with `const` or `let` that is already declared in the same program, and never repeat a whole block of code. A duplicate `const X = ...` makes the whole program fail to parse and the task is wasted.
'''
        marker = "You should then respond to me with\n"
        if creative_facts.strip() not in action_text:
            if marker not in action_text:
                raise RuntimeError("Voyager action prompt marker changed; refusing unsafe prompt patch")
            action_text = action_text.replace(marker, creative_facts + "\n" + marker, 1)
            action_prompt.write_text(action_text, encoding="utf-8")

        action_agent_path = SRC / "voyager" / "agents" / "action.py"
        action_agent_text = action_agent_path.read_text(encoding="utf-8")
        action_agent_text = action_agent_text.replace(
            '''        if not self.llm.model_name == "gpt-3.5-turbo":
            base_skills += [
                "useChest",
                "mineflayer",
            ]
''',
            '''        if not self.llm.model_name == "gpt-3.5-turbo":
            base_skills += [
                "useChest",
                "mineflayer",
                "creativeGiveItem",
            ]
''',
        )
        # Upstream sets no max_tokens. A hard 512-token cap truncated the
        # official multi-function Action programs into invalid JavaScript, so
        # the cap is raised rather than the contract narrowed. See app.py
        # ACTION_MAX_TOKENS.
        action_agent_text = action_agent_text.replace(
            '''        self.llm = ChatOpenAI(
            model_name=model_name,
            temperature=temperature,
            request_timeout=request_timout,
        )
''',
            '''        self.llm = ChatOpenAI(
            model_name=model_name,
            temperature=temperature,
            request_timeout=request_timout,
            max_tokens=2048,
        )
''',
            1,
        )
        action_agent_text = action_agent_text.replace(
            '                error = "\\n".join(error_messages)',
            '                error = "\\n".join(error_messages[-4:])[-1800:]',
        )
        action_agent_text = action_agent_text.replace(
            '                chat_log = "\\n".join(chat_messages)',
            '                chat_log = "\\n".join(chat_messages[-6:])[-1200:]',
        )
        action_agent_text = action_agent_text.replace(
            '            observation += f"Context: {context}\\n\\n"',
            '            observation += f"Context: {context[:1400]}\\n\\n"',
        )
        action_agent_text = action_agent_text.replace(
            '            observation += f"Critique: {critique}\\n\\n"',
            '            observation += f"Critique: {critique[:1200]}\\n\\n"',
        )
        # Write the patched agent out. Without this line every replace above was
        # discarded and the Space silently ran a pristine action.py: the Action
        # agent sent no max_tokens, app.py fell back to its 384-token default, the
        # answer was cut off mid-function, and every action failed to parse.
        action_agent_path.write_text(action_agent_text, encoding="utf-8")
        applied = action_agent_path.read_text(encoding="utf-8")
        for marker, what in (
            ('"creativeGiveItem",', "action base skills"),
            ("max_tokens=2048", "action max_tokens"),
            ('Code from the last round', "action previous code"),
            ("error_messages[-4:]", "action error bound"),
            ("chat_messages[-6:]", "action chat-log bound"),
            ("{context[:1400]}", "action context bound"),
            ("{critique[:1200]}", "action critique bound"),
        ):
            if marker not in applied:
                raise RuntimeError(
                    f"Voyager {what} patch did not apply; refusing to run a runtime "
                    "that the patch layer does not describe"
                )

        # No local override of get_task_context. Upstream's QA question/answer
        # path (curriculum.py:417-434) is the official source of task context
        # and is restored below together with the observation QA warm-up.

        # Upstream's process_ai_message is the official contract: collect every
        # top-level function (helpers included), join all bodies, exec the last
        # async one. Replacing it with a single-function template both narrowed
        # the contract and let the parser overwrite the model's own code.
        # No local override is applied here.
        # The observation/truncation bounds below stay: they are a documented
        # 8k-context adaptation, not a contract change.

        # Bound curriculum memory rendered into every task proposal. The full
        # persistent lists remain on disk; only the prompt view is compacted.
        curriculum_path = SRC / "voyager" / "agents" / "curriculum.py"
        curriculum_text = curriculum_path.read_text(encoding="utf-8")
        curriculum_text = curriculum_text.replace(
            '", ".join(self.completed_tasks) if self.completed_tasks else "None"',
            'task_history(self.completed_tasks) if self.completed_tasks else "None"',
        )
        curriculum_text = curriculum_text.replace(
            'failed_tasks = ", ".join(self.failed_tasks) if self.failed_tasks else "None"',
            'failed_tasks = task_history(self.failed_tasks) if self.failed_tasks else "None"',
        )

        # Upstream's get_task_context (QA question/answer) and
        # propose_next_ai_task (free-form "Task:" proposal) are restored
        # verbatim. The only local change is the parse-failure retry feedback,
        # recorded in .upstream-expected/ and declared in
        # voyager_runtime_contract.ADAPTED_METHODS: at temperature 0 upstream
        # re-sends an identical prompt, which deterministically reproduces the
        # same rejected task.
        curriculum_text = curriculum_text.replace(
            "from langchain.schema import HumanMessage, SystemMessage",
            "from langchain.schema import AIMessage, HumanMessage, SystemMessage",
        )
        # Apply the declared adaptation to the live file, and publish the same
        # text as the attestation reference.
        curriculum_text = apply_curriculum_retry_adaptation(curriculum_text)
        self._publish_expected_curriculum()
        # The remaining upstream curriculum/agent transforms below (AIMessage
        # import, nullable numerics, history compaction) are integration fixes.
        # They must not alter the attested decision methods.
        curriculum_text = curriculum_text.replace(
            'from __future__ import annotations',
            'from __future__ import annotations\nfrom voyager_verification import task_history',
            1,
        )
        curriculum_path.write_text(curriculum_text, encoding="utf-8")

        # The official curriculum prompt is kept as-is. It is the contract that
        # lets the model propose any objective, including repeating a task when
        # resources require it.
        creative_facts = (
            "\n\nENVIRONMENT FACTS (do not change the task format above):\n"
            "- The bot is in Creative mode on normal overworld terrain with a netherite toolkit "
            "already equipped, so survival prerequisites are usually not limiting.\n"
            "- Creative mining changes blocks but produces no item drops.\n"
            "- The bot has creativeGiveItem(bot, item, count) available as a helper.\n"
            "- This is not a flat resource map: ore depth follows the natural landscape, "
            "so mining and obtaining tasks must be prospected from Nearby blocks and "
            "reachable on foot from the live position.\n"
        )
        curriculum_prompt = SRC / "voyager" / "prompts" / "curriculum.txt"
        curriculum_prompt_text = curriculum_prompt.read_text(encoding="utf-8")
        if "ENVIRONMENT FACTS (do not change the task format above)" not in curriculum_prompt_text:
            curriculum_prompt.write_text(curriculum_prompt_text + creative_facts, encoding="utf-8")

        # A disconnect can yield a final observation with health/food/position
        # fields set to null. Upstream formats them with :.1f, which raises
        # NoneType.__format__ and triggers Voyager's destructive hard reset.
        # Preserve each file's original indentation while normalizing nullable
        # numeric fields so this patch is safe across Action/Critic/Curriculum.
        for rel in (
            "voyager/agents/action.py",
            "voyager/agents/critic.py",
            "voyager/agents/curriculum.py",
        ):
            agent_path = SRC / rel
            agent = agent_path.read_text(encoding="utf-8")
            replacements = (
                (
                    r'(?m)^(\s*)health = event\["status"\]\["health"\]\s*$',
                    r'\1health = event["status"].get("health")\n\1health = float(health) if isinstance(health, (int, float)) else 0.0',
                ),
                (
                    r'(?m)^(\s*)hunger = event\["status"\]\["food"\]\s*$',
                    r'\1hunger = event["status"].get("food")\n\1hunger = float(hunger) if isinstance(hunger, (int, float)) else 0.0',
                ),
                (
                    r'(?m)^(\s*)position = event\["status"\]\["position"\]\s*$',
                    r'\1position = event["status"].get("position") or {}\n\1position = {axis: (float(position.get(axis)) if isinstance(position.get(axis), (int, float)) else 0.0) for axis in ("x", "y", "z")}',
                ),
                (
                    r'(?m)^(\s*)health = events\[-1\]\[1\]\["status"\]\["health"\]\s*$',
                    r'\1health = events[-1][1]["status"].get("health")\n\1health = float(health) if isinstance(health, (int, float)) else 0.0',
                ),
                (
                    r'(?m)^(\s*)hunger = events\[-1\]\[1\]\["status"\]\["food"\]\s*$',
                    r'\1hunger = events[-1][1]["status"].get("food")\n\1hunger = float(hunger) if isinstance(hunger, (int, float)) else 0.0',
                ),
                (
                    r'(?m)^(\s*)position = events\[-1\]\[1\]\["status"\]\["position"\]\s*$',
                    r'\1position = events[-1][1]["status"].get("position") or {}\n\1position = {axis: (float(position.get(axis)) if isinstance(position.get(axis), (int, float)) else 0.0) for axis in ("x", "y", "z")}',
                ),
            )
            for pattern, replacement in replacements:
                agent = re.sub(pattern, replacement, agent)
            agent_path.write_text(agent, encoding="utf-8")

        # A disconnect during /pause used to leave Python blocked indefinitely,
        # preventing both recovery and the existing process-exit watchdog.
        # EPIPE on the Mineflayer socket must also fail a pending /step and a
        # pending /start quickly so the runner can hard-reset instead of
        # parking on a dead bridge with position frozen and no new model calls.
        bridge_path = SRC / "voyager/env/bridge.py"
        bridge_text = bridge_path.read_text(encoding="utf-8")
        for endpoint in ("pause", "stop"):
            old_post = f'requests.post(f"{{self.server}}/{endpoint}")'
            if old_post not in bridge_text:
                raise RuntimeError(
                    f"Voyager bridge /{endpoint} marker changed; refusing silent patch"
                )
            bridge_text = bridge_text.replace(
                old_post,
                f'requests.post(f"{{self.server}}/{endpoint}", timeout=10)',
            )
        # /start drives the 120s Node startup lifecycle; bound it just above at
        # 135s so a dead Mineflayer port raises instead of hanging env.reset().
        # Upstream already carries timeout=self.request_timeout (600s) here, so
        # the old plain-form replace matched nothing and silently did nothing.
        start_old = "json=self.reset_options,\n                timeout=self.request_timeout,"
        if start_old not in bridge_text:
            raise RuntimeError("Voyager bridge /start marker changed; refusing silent patch")
        bridge_text = bridge_text.replace(
            start_old,
            "json=self.reset_options,\n                timeout=135,",
            1,
        )
        # /step keeps upstream's request deadline so a wedged bridge raises on
        # the Python side too; the Node failStep guard answers first (503).
        if 'f"{self.server}/step", json=data, timeout=self.request_timeout' not in bridge_text:
            raise RuntimeError("Voyager bridge /step lost its deadline; refusing silent boot")
        bridge_path.write_text(bridge_text, encoding="utf-8")

        # Upstream hard-resets Mineflayer after every Python exception, even if
        # the Minecraft connection is healthy. That makes benign formatting or
        # model-output errors visible as leave/rejoin loops. Print a full
        # traceback, then recover an observation in-place; only fall back to a
        # hard reset when the live Mineflayer step itself fails.
        voyager_py = SRC / "voyager" / "voyager.py"
        voyager_src = voyager_py.read_text(encoding="utf-8")
        if "import traceback" not in voyager_src:
            voyager_src = voyager_src.replace("import time\n", "import time\nimport traceback\n", 1)

        # Upstream calls recorder.record([], task) when an Action response cannot
        # be parsed. EventRecorder immediately indexes events[0], so a truncated
        # LLM response turns a recoverable parse retry into IndexError. Record
        # the last valid observation instead and let Voyager retry the action.
        voyager_src = voyager_src.replace(
            '''            self.recorder.record([], self.task)
            print(f"\\033[34m{parsed_result} Trying again!\\033[0m")
''',
            '''            if self.last_events:
                self.recorder.record(self.last_events, self.task)
            print(f"\\033[34m{parsed_result} Trying again!\\033[0m")
''',
            1,
        )
        # Upstream calls the Action LLM on every rollout iteration and parses
        # its answer with the official process_ai_message. The earlier local
        # variant skipped the model entirely on the first iteration of
        # Place/Build/Obtain/Equip tasks and substituted a canonical template.
        # That removed model code generation from the learning loop, so it is
        # removed here. The call site keeps upstream's signature.

        old_recovery = '''            except Exception as e:\n                time.sleep(3)  # wait for mineflayer to exit\n                info = {\n                    "task": task,\n                    "success": False,\n                }\n                # reset bot status here\n                self.last_events = self.env.reset(\n                    options={\n                        "mode": "hard",\n                        "wait_ticks": self.env_wait_ticks,\n                        "inventory": self.last_events[-1][1]["inventory"],\n                        "equipment": self.last_events[-1][1]["status"]["equipment"],\n                        "position": self.last_events[-1][1]["status"]["position"],\n                    }\n                )\n                # use red color background to print the error\n                print("Your last round rollout terminated due to error:")\n                print(f"\\033[41m{e}\\033[0m")\n'''
        new_recovery = '''            except Exception as e:\n                info = {\n                    "task": task,\n                    "success": False,\n                }\n                print("Your last round rollout terminated due to error:")\n                traceback.print_exc()\n                print(f"\\033[41m{type(e).__name__}: {e}\\033[0m")\n                try:\n                    # Preserve the current Mineflayer connection whenever it is\n                    # still responsive. This prevents needless leave/rejoin.\n                    self.last_events = self.env.step("")\n                    print("Recovered Voyager observation without reconnecting Mineflayer")\n                except Exception as recovery_error:\n                    print("Live recovery failed; performing one hard Mineflayer reset")\n                    traceback.print_exc()\n                    previous = self.last_events[-1][1] if self.last_events else {}\n                    status = previous.get("status", {}) if isinstance(previous, dict) else {}\n                    self.last_events = self.env.reset(\n                        options={\n                            "mode": "hard",\n                            "wait_ticks": self.env_wait_ticks,\n                            "inventory": previous.get("inventory", {}) if isinstance(previous, dict) else {},\n                            "equipment": status.get("equipment", []),\n                            "position": status.get("position", None),\n                        }\n                    )\n'''
        if old_recovery not in voyager_src:
            raise RuntimeError("Voyager upstream exception block changed; refusing unsafe patch")
        voyager_src = voyager_src.replace(old_recovery, new_recovery, 1)
        voyager_py.write_text(voyager_src, encoding="utf-8")

        _log(f"Patched Mineflayer identity, safe observations, full tracebacks, non-destructive recovery, movement guards, and bounded mineBlock targets")

    def _ensure_source(self) -> None:
        ROOT.mkdir(parents=True, exist_ok=True)
        env = os.environ.copy()
        env["GIT_LFS_SKIP_SMUDGE"] = "1"
        if not (SRC / ".git").exists():
            if SRC.exists():
                shutil.rmtree(SRC)
            _run(["git", "clone", "--filter=blob:none", "--no-checkout", UPSTREAM, str(SRC)], ROOT, env=env)
        _run(["git", "fetch", "--depth", "1", "origin", UPSTREAM_COMMIT], SRC, env=env)
        _run(["git", "checkout", "--force", UPSTREAM_COMMIT], SRC, env=env)
        self._patch_runtime_source()

    def _ensure_python_package(self) -> None:
        """Run the README's `pip install -e .` for the pinned Voyager checkout.

        --no-deps keeps the Space's own pinned requirements authoritative: the
        Space runs langchain and chromadb versions that this Space's build
        resolved, and letting pip re-resolve them at boot would silently move
        the inference stack underneath a running Space. The distribution is
        still installed and verified, which is what the documented install
        provides here.
        """
        marker = ROOT / ".voyager-installed"
        if not marker.exists():
            _run(
                [sys.executable, "-m", "pip", "install", "-e", ".", "--no-deps", "--no-build-isolation"],
                SRC,
            )
        probe = (
            "import importlib.metadata as m;"
            "d = m.distribution('voyager');"
            "print(d.version)"
        )
        proc = subprocess.run(
            [sys.executable, "-c", probe],
            stdout=subprocess.PIPE,
            stderr=subprocess.STDOUT,
            text=True,
            timeout=120,
        )
        installed = (proc.stdout or "").strip()
        if proc.returncode != 0 or not installed:
            raise RuntimeError(
                f"Voyager editable install is missing after pip install -e .: {installed!r}"
            )
        # Recorded only once the install is known to be live, so a failed attempt
        # is retried instead of being remembered as done.
        if not marker.exists():
            marker.write_text("ok\n", encoding="utf-8")
        _log(f"Voyager package installed: voyager {installed} from {SRC}")

    def _terminate_proc(self, proc: subprocess.Popen[str] | None, name: str) -> None:
        if proc is None or proc.poll() is not None:
            return
        try:
            proc.terminate()
            proc.wait(timeout=15)
        except Exception:
            try:
                proc.kill()
            except Exception:
                pass
        else:
            return
        try:
            proc.wait(timeout=5)
        except Exception:
            pass
        _log(f"{name} stopped")

    def _ensure_naldo_brain(self) -> None:
        """Install/pin OpenCode and start loopback-only server + bridge.

        Never binds 0.0.0.0, never copies credential files, never requires
        OPENCODE_API_KEY. Both children are owned by this stack and stopped
        together with Naldo.
        """
        if not is_loopback_url(NALDO_OPENCODE_BASE) or not is_loopback_url(NALDO_BRIDGE_BASE):
            raise RuntimeError("Naldo brain endpoints must stay loopback-only")
        opencode_binary, reported = ensure_opencode_installed()
        _log(
            f"OpenCode CLI ready: {reported[:80]} local_binary={opencode_binary} "
            "(pinned opencode-ai@1.18.33)"
        )
        support = Path(__file__).resolve().parent
        bridge_entry = support / "naldo_opencode_bridge.py"
        if not bridge_entry.exists():
            raise RuntimeError("Naldo OpenCode bridge entrypoint missing")
        self._stop_brain()
        opencode_log = ROOT / "opencode.log"
        bridge_log = ROOT / "naldo-bridge.log"
        opencode_log.write_text("", encoding="utf-8")
        bridge_log.write_text("", encoding="utf-8")
        # Isolate OpenCode's HOME/XDG dirs in the runtime tree so it cannot
        # inherit any account credential/config from the container user.
        env = opencode_subprocess_env(os.environ.copy())
        env["OPENCODE_BASE"] = NALDO_OPENCODE_BASE
        env["NALDO_BRIDGE_BASE"] = NALDO_BRIDGE_BASE
        env["OPENCODE_PORT"] = str(NALDO_OPENCODE_PORT)
        env["NALDO_BRIDGE_PORT"] = str(NALDO_BRIDGE_PORT)
        env["NALDO_EMBED_BASE"] = EMBED_BASE_URL
        with opencode_log.open("a", encoding="utf-8", errors="replace") as op_log:
            op_log.write(f"cmd: opencode serve --hostname 127.0.0.1 --port {NALDO_OPENCODE_PORT}\n")
            op_log.flush()
            self.opencode_proc = subprocess.Popen(
                opencode_serve_command(opencode_binary, NALDO_OPENCODE_PORT),
                cwd=str(ROOT),
                env=env,
                stdout=op_log,
                stderr=subprocess.STDOUT,
                text=True,
            )
        if not _wait_tcp("127.0.0.1", NALDO_OPENCODE_PORT, 120):
            raise RuntimeError("OpenCode server did not open 127.0.0.1:{port}".format(port=NALDO_OPENCODE_PORT))
        if self.opencode_proc.poll() is not None:
            raise RuntimeError(f"OpenCode server exited rc={self.opencode_proc.returncode}")
        health = _check_opencode_health(NALDO_OPENCODE_BASE, timeout=10.0)
        _log(f"OpenCode health: ok={health.get('ok')} ready={health.get('ready')}")
        with bridge_log.open("a", encoding="utf-8", errors="replace") as br_log:
            br_log.write(f"cmd: bridge on 127.0.0.1:{NALDO_BRIDGE_PORT} -> {NALDO_OPENCODE_BASE}\n")
            br_log.flush()
            self.bridge_proc = subprocess.Popen(
                [sys.executable, "-u", str(bridge_entry)],
                cwd=str(support),
                env=env,
                stdout=br_log,
                stderr=subprocess.STDOUT,
                text=True,
            )
        if not _wait_tcp("127.0.0.1", NALDO_BRIDGE_PORT, 120):
            raise RuntimeError("Naldo bridge did not open 127.0.0.1:{port}".format(port=NALDO_BRIDGE_PORT))
        if self.bridge_proc.poll() is not None:
            raise RuntimeError(f"Naldo bridge exited rc={self.bridge_proc.returncode}")
        bridge_health = _check_bridge_health(NALDO_BRIDGE_BASE, timeout=10.0)
        _log(f"Naldo bridge health: ok={bridge_health.get('ok')} ready={bridge_health.get('ready')}")
        if not bridge_health.get("ready"):
            raise RuntimeError("Naldo bridge is not ready for OpenCode inference")
        if not _wait_embedding_ready(NALDO_BRIDGE_BASE, timeout=180.0):
            raise RuntimeError("Naldo embedding backend did not become ready within 180s")
        _log("Naldo embedding probe: ready")

    def _brain_healthy(self) -> bool:
        opencode_proc, bridge_proc = self._brain_procs()
        if not opencode_proc or opencode_proc.poll() is not None:
            return False
        if not bridge_proc or bridge_proc.poll() is not None:
            return False
        try:
            if not _check_opencode_health(NALDO_OPENCODE_BASE, timeout=3.0).get("ready"):
                return False
            return bool(_check_bridge_health(NALDO_BRIDGE_BASE, timeout=3.0).get("ready"))
        except Exception:
            return False

    def _stop_brain(self) -> None:
        with self.lock:
            opencode_proc = self.opencode_proc
            bridge_proc = self.bridge_proc
            self.opencode_proc = None
            self.bridge_proc = None
        self._terminate_proc(bridge_proc, "Naldo bridge")
        self._terminate_proc(opencode_proc, "OpenCode server")

    def brain_status(self) -> dict[str, Any]:
        opencode_proc, bridge_proc = self._brain_procs()
        opencode = _check_opencode_health(NALDO_OPENCODE_BASE, timeout=2.0)
        bridge = _check_bridge_health(NALDO_BRIDGE_BASE, timeout=2.0)
        # Never expose env values beyond loopback base URLs; no credentials
        # are stored here at all.
        return {
            "provider": "opencode",
            "profile": NALDO_BRAIN_PROFILE,
            "primary_model": NALDO_PRIMARY_MODEL,
            "fallback_models": list(ALLOWED_MODELS),
            "opencode_base": NALDO_OPENCODE_BASE,
            "bridge_base": NALDO_BRIDGE_BASE,
            "loopback_only": bool(is_loopback_url(NALDO_OPENCODE_BASE) and is_loopback_url(NALDO_BRIDGE_BASE)),
            "opencode_running": bool(opencode_proc and opencode_proc.poll() is None),
            "opencode_pid": opencode_proc.pid if opencode_proc and opencode_proc.poll() is None else None,
            "opencode": opencode,
            "bridge_running": bool(bridge_proc and bridge_proc.poll() is None),
            "bridge_pid": bridge_proc.pid if bridge_proc and bridge_proc.poll() is None else None,
            "bridge": bridge,
            "temperature_note": "session API has no temperature field; requested value recorded only",
        }

    def _pin_node_versions(self) -> None:
        root_pkg = SRC / "voyager" / "env" / "mineflayer" / "package.json"
        data = json.loads(root_pkg.read_text(encoding="utf-8"))
        pinned = {
            # Freeze Voyager's own 2023-era dependency line instead of mixing
            # it with later protocol packages. The upstream commit is tested
            # against Minecraft/Fabric 1.19 and starts from Mineflayer 4.8.1.
            "body-parser": "1.20.2", "express": "4.18.2", "magic-string": "0.30.0",
            "minecraft-data": "3.31.0", "minecrafthawkeye": "1.3.6", "mineflayer": "4.8.1",
            "mineflayer-pathfinder": "2.4.2", "mineflayer-pvp": "1.3.2", "mineflayer-tool": "1.2.0",
            "mocha": "10.2.0", "prismarine-biome": "1.3.0", "prismarine-block": "1.16.3",
            "prismarine-entity": "2.2.0", "prismarine-item": "1.12.1", "prismarine-nbt": "2.2.1",
            "prismarine-recipe": "1.3.1", "prismarine-viewer": "1.24.0", "typescript": "4.9.5",
            "vec3": "0.1.8", "graceful-fs": "4.2.11",
        }
        for section in ("dependencies", "devDependencies"):
            for key in list(data.get(section, {})):
                if key in pinned:
                    data[section][key] = pinned[key]
        data["dependencies"]["mineflayer-collectblock"] = "file:mineflayer-collectblock"
        data["overrides"] = {
            # Keep transitive protocol readers on the same generation as
            # Mineflayer 4.8.1 / Voyager's Minecraft 1.19 setup.
            "mineflayer": "4.8.1",
            "minecraft-protocol": "1.41.2",
            "protodef": "1.15.0",
            "minecraft-data": "3.31.0",
            "prismarine-block": "1.16.3",
            "prismarine-nbt": "2.2.1",
            "prismarine-chat": "1.7.1",
            "prismarine-item": "1.12.1",
            "prismarine-biome": "1.3.0",
            "prismarine-chunk": "1.32.0",
            "prismarine-world": "3.6.0",
            "prismarine-entity": "2.2.0",
            "prismarine-recipe": "1.3.1",
            "prismarine-physics": "1.6.0",
            "prismarine-windows": "2.5.0",
            "prismarine-registry": "1.5.0",
            "mineflayer-pathfinder": "2.4.2",
            "mineflayer-pvp": "1.3.2",
            "mineflayer-tool": "1.2.0",
        }
        root_pkg.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")

        cb_pkg = SRC / "voyager" / "env" / "mineflayer" / "mineflayer-collectblock" / "package.json"
        cb = json.loads(cb_pkg.read_text(encoding="utf-8"))
        cb["dependencies"].update({
            "mineflayer": "4.8.1",
            "mineflayer-pathfinder": "2.4.2",
            "mineflayer-tool": "1.2.0",
        })
        cb["devDependencies"].update({
            "@types/node": "18.6.4",
            "require-self": "0.2.3",
            "ts-standard": "11.0.0",
            "typescript": "4.9.5",
        })
        cb["overrides"] = dict(data["overrides"])
        cb_pkg.write_text(json.dumps(cb, indent=2) + "\n", encoding="utf-8")

        # The vendored collectblock source predates stricter TS indexing rules.
        # Keep its behavior, but make the two tool lookups type-safe so the
        # official local plugin can still be compiled deterministically.
        cb_source = cb_pkg.parent / "src" / "CollectBlock.ts"
        cb_text = cb_source.read_text(encoding="utf-8")
        cb_text = cb_text.replace(
            '''                            const properties =
                                bot.registry.blocksByName[closest.name];
                            const leastTool = Object.keys(
                                properties.harvestTools
                            )[0];
                            const item = bot.registry.items[leastTool];
                            bot.chat(
                                `I need at least a ${item.name} to mine ${closest.name}!  Skip it!`
                            );
''',
            '''                            const blockName = closest.name;
                            if (!blockName) return;
                            const properties =
                                bot.registry.blocksByName[blockName];
                            const harvestTools = properties.harvestTools || {};
                            const leastTool = Number(Object.keys(harvestTools)[0]);
                            const item = bot.registry.items[leastTool];
                            bot.chat(
                                `I need at least a ${item?.name || "valid tool"} to mine ${blockName}!  Skip it!`
                            );
''',
        )
        cb_text = cb_text.replace("// @ts-expect-error", "// @ts-ignore")
        cb_source.write_text(cb_text, encoding="utf-8")

        for lock in (root_pkg.parent / "package-lock.json", cb_pkg.parent / "package-lock.json"):
            lock.unlink(missing_ok=True)
        _log("Pinned Voyager/Mineflayer dependency tree to 2023-compatible versions")

    def _verify_node_versions(self, mineflayer: Path) -> None:
        expected = {
            "mineflayer": "4.8.1",
            "minecraft-protocol": "1.41.2",
            "protodef": "1.15.0",
            "minecraft-data": "3.31.0",
            "prismarine-block": "1.16.3",
            "prismarine-nbt": "2.2.1",
            "prismarine-chat": "1.7.1",
            "prismarine-item": "1.12.1",
            "prismarine-biome": "1.3.0",
            "prismarine-chunk": "1.32.0",
            "prismarine-world": "3.6.0",
            "prismarine-entity": "2.2.0",
            "prismarine-recipe": "1.3.1",
            "prismarine-physics": "1.6.0",
            "prismarine-windows": "2.5.0",
            "prismarine-registry": "1.5.0",
        }
        installed: dict[str, str] = {}
        for package, wanted in expected.items():
            package_json = mineflayer / "node_modules" / package / "package.json"
            if not package_json.exists():
                raise RuntimeError(f"Missing pinned Node package: {package}")
            actual = str(json.loads(package_json.read_text(encoding="utf-8")).get("version", ""))
            installed[package] = actual
            if actual != wanted:
                raise RuntimeError(f"Node dependency drift: {package} expected {wanted}, got {actual}")
        _log("Verified Voyager protocol tree: " + ", ".join(f"{k}={v}" for k, v in installed.items()))

    def _verify_babel(self, babel: Path) -> None:
        """Fail the boot unless Babel is 7.x and parse() stays synchronous."""
        installed = {}
        for package in ("@babel/core", "@babel/generator"):
            package_json = babel / "node_modules" / package / "package.json"
            if not package_json.exists():
                raise RuntimeError(f"Missing pinned Babel package: {package}")
            version = str(json.loads(package_json.read_text(encoding="utf-8")).get("version", ""))
            installed[package] = version
            if not version.startswith("7."):
                raise RuntimeError(
                    f"Babel {package} must stay on 7.x because upstream calls babel.parse() "
                    f"synchronously; got {version}"
                )
        # Prove the actual contract the Action agent depends on, in the same Node
        # runtime that will run it, rather than trusting the version string.
        probe = (
            "const babel = require('@babel/core');"
            "const parsed = babel.parse('async function f(){return 1}');"
            "if (!parsed || !parsed.program) { throw new Error('babel.parse did not return an AST'); }"
            "console.log('sync-ok');"
        )
        proc = subprocess.run(
            ["node", "-e", probe],
            env={**os.environ, "NODE_PATH": str(babel / "node_modules")},
            stdout=subprocess.PIPE,
            stderr=subprocess.STDOUT,
            text=True,
            timeout=120,
        )
        output = (proc.stdout or "").strip()
        if proc.returncode != 0 or "sync-ok" not in output:
            raise RuntimeError(
                f"babel.parse() is not synchronous in this Node runtime; upstream's "
                f"process_ai_message would fail on every action: {output[-400:]}"
            )
        _log("Verified synchronous Babel parse: " + ", ".join(f"{k}={v}" for k, v in installed.items()))

    def _pin_javascript_babel(self) -> None:
        """Force the `javascript` library's lazily-installed Babel to synchronous 7.x.

        Upstream's process_ai_message calls babel.parse(code) synchronously.
        The javascript package resolves require("@babel/core") through JSPyBridge,
        which lazily runs `npm install @babel/core@npm:latest` into
        javascript/js/node_modules and records the result in javascript/js/package.json.
        "latest" is now Babel 8, where parse() is callback-only, so every action
        fails. Pin the version JSPyBridge will see before it installs anything, and
        install it here, so the resolved copy is the last Babel 7 line.
        """
        try:
            import javascript  # noqa: PLC0415
        except Exception as exc:  # pragma: no cover - javascript is a hard dep
            raise RuntimeError(f"the javascript package is required by upstream: {exc}")
        js_dir = Path(javascript.__file__).resolve().parent / "js"
        js_dir.mkdir(parents=True, exist_ok=True)
        node_modules = js_dir / "node_modules"
        # deps.js reads this file to decide what is already installed. Recording the
        # pinned version here stops it from resolving "latest" to Babel 8 later.
        package_path = js_dir / "package.json"
        data = {"name": "js-modules", "private": True, "dependencies": {}}
        if package_path.exists():
            try:
                data = json.loads(package_path.read_text(encoding="utf-8"))
            except ValueError:
                data = {"name": "js-modules", "private": True, "dependencies": {}}
        data.setdefault("name", "js-modules")
        data["private"] = True
        data["dependencies"] = {**data.get("dependencies", {}), **BABEL_PINS}
        package_path.write_text(json.dumps(data, indent="\t") + "\n", encoding="utf-8")
        # Install the pinned packages into exactly the tree JSPyBridge resolves from.
        _run(
            [
                "npm", "install", "--no-audit", "--no-fund",
                "--prefix", str(js_dir),
                "@babel/core@7.26.9", "@babel/generator@7.26.9",
            ],
            ROOT,
        )
        # Verify the copy that bridge.js will actually require is 7.x.
        for package in ("@babel/core", "@babel/generator"):
            package_json = node_modules / package / "package.json"
            if not package_json.exists():
                raise RuntimeError(f"missing pinned Babel package after override: {package}")
            version = str(json.loads(package_json.read_text(encoding="utf-8")).get("version", ""))
            if not version.startswith("7."):
                raise RuntimeError(
                    f"the javascript package still resolves Babel {package} {version}; "
                    "upstream's babel.parse() needs the synchronous 7.x line"
                )
        _log(f"Pinned javascript's resolved Babel to 7.x under {node_modules}")

    def _ensure_node(self) -> str:
        mineflayer = SRC / "voyager" / "env" / "mineflayer"
        marker = ROOT / ".node-ready"
        if not marker.exists():
            self._pin_node_versions()
            collectblock = mineflayer / "mineflayer-collectblock"
            _run(["npm", "install", "--no-audit", "--no-fund"], collectblock)
            _run(["node", str(collectblock / "node_modules" / "typescript" / "bin" / "tsc")], collectblock)
            _run(["npm", "install", "--no-audit", "--no-fund"], mineflayer)
            self._verify_node_versions(mineflayer)
            babel = ROOT / "babel"
            babel.mkdir(parents=True, exist_ok=True)
            # Upstream's process_ai_message calls babel.parse(code)
            # synchronously. Babel 8 made parse() callback-only, so an unpinned
            # install that lands on 8 makes every action fail to parse. Pin the
            # last Babel 7 releases, which keep parse() synchronous.
            _run(
                [
                    "npm", "install", "--no-audit", "--no-fund",
                    "--prefix", str(babel),
                    "@babel/core@7.26.9", "@babel/generator@7.26.9",
                ],
                ROOT,
            )
            self._verify_babel(babel)
            self._pin_javascript_babel()
            marker.write_text("ok\n", encoding="utf-8")
        paths = [
            str(ROOT / "babel" / "node_modules"),
            str(mineflayer / "node_modules"),
        ]
        return os.pathsep.join(paths)

    def _pump(self, proc: subprocess.Popen[str]) -> None:
        assert proc.stdout is not None
        with LOG_PATH.open("a", encoding="utf-8", errors="replace") as fh:
            for line in proc.stdout:
                fh.write(line)
                fh.flush()
                print("[voyager] " + line.rstrip(), flush=True)

    def _boot(self) -> None:
        with self.lock:
            self.state = "waiting-minecraft"
            self.error = ""
            self.started_at = time.time()
        try:
            if not _wait_tcp("127.0.0.1", 25565, 180):
                raise RuntimeError("Minecraft server did not open 127.0.0.1:25565")
            ready_file = Path(os.environ.get("MINECRAFT_READY_FILE", "/tmp/minecraft-stack/minecraft.ready"))
            deadline = time.time() + 180
            while time.time() < deadline and not ready_file.exists():
                time.sleep(1)
            if not ready_file.exists():
                raise RuntimeError("Minecraft port opened but world readiness marker was not created")
            with self.lock:
                self.state = "preparing"
            python_version = _require_python_version()
            node_version = _require_node_version()
            _log(f"Runtime versions: python {python_version}, node {node_version}")
            self._ensure_source()
            self._ensure_python_package()
            node_path = self._ensure_node()
            CKPT_DIR.mkdir(parents=True, exist_ok=True)
            # Fresh map, fresh tasks: the checkpoint starts empty (resume=False).
            # Nothing is ever seeded or copied from the old checkpoint.
            _log(f"Checkpoint starts fresh: {_is_fresh_checkpoint(CKPT_DIR)}")
            # Naldo's generative brain is OpenCode only. Start the loopback
            # server + bridge BEFORE the Voyager child; the child must never
            # fall back to Granite.
            self._ensure_naldo_brain()
            env = os.environ.copy()
            env.update(
                {
                    "VOYAGER_HOME": str(SRC),
                    "VOYAGER_ATTESTATION_FILE": str(ROOT / "engine-attestation.json"),
                    "VOYAGER_OPENAI_BASE": NALDO_BRIDGE_BASE,
                    "VOYAGER_BRAIN_PROFILE": NALDO_BRAIN_PROFILE,
                    "VOYAGER_API_KEY": "local-voyager",
                    "VOYAGER_MODEL": NALDO_PRIMARY_MODEL,
                    "VOYAGER_AUX_MODEL": NALDO_PRIMARY_MODEL,
                    "VOYAGER_CKPT_DIR": str(CKPT_DIR),
                    "MC_USERNAME": BOT_USERNAME,
                    "BOT_DISPLAY_NAME": BOT_DISPLAY_NAME,
                    "MC_PORT": "25565",
                    "MINEFLAYER_API_PORT": "3000",
                    "MAX_ITERATIONS": os.environ.get("MAX_ITERATIONS", "1000000"),
                    "OPENAI_API_REQUEST_TIMEOUT": "240",
                    "ANONYMIZED_TELEMETRY": "False",
                    "NODE_PATH": node_path,
                    "PYTHONPATH": str(SRC),
                }
            )
            LOG_PATH.write_text("", encoding="utf-8")
            runner = Path(__file__).resolve().parent / "voyager_runner.py"
            backoff = 5
            while not self.stop_event.is_set():
                if not self._brain_healthy():
                    self.restart_count += 1
                    with self.lock:
                        self.state = "restarting"
                        self.error = (
                            f"OpenCode bridge unavailable; Naldo held without Granite fallback "
                            f"(restart #{self.restart_count})"
                        )
                    _terminate_naldo_child(self)
                    _log("Naldo brain unhealthy; restarting OpenCode server + bridge before Naldo")
                    if self.stop_event.wait(backoff):
                        break
                    try:
                        self._ensure_naldo_brain()
                    except Exception as exc:
                        _log(f"Naldo brain restart failed: {exc!r}")
                    backoff = restart_backoff_after(backoff, 0.0)
                    continue
                run_started = time.monotonic()
                self.proc = subprocess.Popen(
                    [sys.executable, "-u", str(runner)],
                    cwd=str(SRC),
                    env=env,
                    stdout=subprocess.PIPE,
                    stderr=subprocess.STDOUT,
                    text=True,
                    bufsize=1,
                )
                threading.Thread(target=self._pump, args=(self.proc,), daemon=True, name="voyager-log").start()
                time.sleep(5)
                if self.proc.poll() is None:
                    if not self._brain_healthy():
                        _log("Naldo started but brain died during boot; stopping child without Granite fallback")
                        _terminate_naldo_child(self)
                        rc = -1
                    else:
                        with self.lock:
                            self.state = "running"
                            self.error = ""
                        _log(f"Voyager running pid={self.proc.pid} as {BOT_USERNAME} via {NALDO_BRIDGE_BASE}")
                        # Do not block forever in wait(): actively verify the
                        # OpenCode server + bridge while Naldo is alive.
                        while self.proc.poll() is None and not self.stop_event.is_set():
                            if self.stop_event.wait(7):
                                break
                            if not self._brain_healthy():
                                _log("OpenCode/bridge died mid-run; stopping Naldo immediately")
                                _terminate_naldo_child(self)
                                break
                        if self.stop_event.is_set():
                            _terminate_naldo_child(self)
                        rc = self.proc.poll()
                        if rc is None:
                            try:
                                rc = self.proc.wait(timeout=2)
                            except Exception:
                                rc = -1
                else:
                    rc = self.proc.returncode
                if self.stop_event.is_set():
                    break
                # If the brain died mid-run, recycle it first; never let Naldo
                # keep calling a dead bridge or silently use another backend.
                if not self._brain_healthy():
                    _log("OpenCode/bridge died mid-run; recycling brain before Naldo restart")
                    try:
                        self._ensure_naldo_brain()
                    except Exception as exc:
                        _log(f"Naldo brain recycle failed: {exc!r}")
                self.restart_count += 1
                with self.lock:
                    self.state = "restarting"
                    self.error = f"Voyager exited rc={rc}; automatic restart #{self.restart_count}"
                _log(f"Voyager exited rc={rc}; restarting in {backoff}s (attempt {self.restart_count})")
                if self.stop_event.wait(backoff):
                    break
                backoff = restart_backoff_after(backoff, time.monotonic() - run_started)
            with self.lock:
                if self.stop_event.is_set():
                    self.state = "stopped"
        except Exception as exc:
            with self.lock:
                self.state = "error"
                self.error = repr(exc)
            _log(f"VOYAGER ERROR: {exc!r}")
        finally:
            _terminate_naldo_child(self)
            self._stop_brain()

    def start_async(self) -> dict[str, Any]:
        with self.lock:
            self.stop_event.clear()
            if self.proc and self.proc.poll() is None:
                return self.status()
            if self.worker and self.worker.is_alive():
                return self.status()
            self.worker = threading.Thread(target=self._boot, daemon=True, name="voyager-bootstrap")
            self.worker.start()
        return self.status()

    def stop(self) -> dict[str, Any]:
        self.stop_event.set()
        with self.lock:
            self.state = "stopped"
        # Stop Naldo first, then bridge + OpenCode, so no inference can race
        # a half-dead brain or fall back to another backend.
        _terminate_naldo_child(self)
        self._stop_brain()
        return self.status()

    def tail(self, lines: int = 160) -> str:
        chunks: list[str] = []
        for path in (SETUP_LOG, LOG_PATH):
            if path.exists():
                chunks.extend(tail_lines(path, lines))
        return "\n".join(chunks[-lines:])

    def _mineflayer_status(self) -> dict[str, Any]:
        try:
            with urllib.request.urlopen("http://127.0.0.1:3000/health", timeout=1.5) as response:
                data = json.loads(response.read().decode("utf-8"))
                return data if isinstance(data, dict) else {"ready": False}
        except Exception:
            return {"ready": False}

    def status(self) -> dict[str, Any]:
        mineflayer = self._mineflayer_status()
        lines = tail_lines(LOG_PATH)
        try:
            completed = json.loads((CKPT_DIR / "curriculum" / "completed_tasks.json").read_text(encoding="utf-8"))
            failed = json.loads((CKPT_DIR / "curriculum" / "failed_tasks.json").read_text(encoding="utf-8"))
        except (OSError, ValueError):
            completed, failed = [], []
        learning = read_learning_state(CKPT_DIR)
        # The Action agent is the only role whose output has to be executable
        # JavaScript, so a parse failure is the single most expensive silent
        # failure in the loop. Surface the reason and the completion size that
        # produced it: a completion pinned at the token cap means the program was
        # cut off mid-function rather than written wrong.
        parse_errors = [
            line.strip() for line in lines
            if "Error parsing action response (before program execution)" in line
        ]
        completion_tokens = []
        for line in lines:
            if "[llm_metric]" not in line:
                continue
            try:
                record = json.loads(line.split("[llm_metric]", 1)[1])
            except ValueError:
                continue
            if record.get("role") == "action" and record.get("completion_tokens"):
                completion_tokens.append(record["completion_tokens"])
        activity = {
            "completed_count": len(completed),
            "failed_count": len(failed),
            "recent_completed": completed[-5:],
            "recent_failed": failed[-8:],
            "curriculum_retry_exhausted": sum("Max retries reached, failed to propose ai task" in line for line in lines),
            "action_parse_errors": len(parse_errors),
            # The exception type and message lead the message; the Node stack
            # trace at the tail says nothing about what actually went wrong.
            "last_action_parse_error": (
                parse_errors[-1].split("(before program execution):", 1)[-1][:500]
                if parse_errors else None
            ),
            "action_completion_tokens": completion_tokens[-8:],
            "curriculum_parse_errors": sum("Error parsing curriculum" in line for line in lines),
            "action_model_messages": sum("****Action Agent ai message****" in line for line in lines),
            "curriculum_model_messages": sum("****Curriculum Agent ai message****" in line for line in lines),
            "partial_read_errors": sum("PartialReadError" in line for line in lines),
            "file_not_found_errors": sum("FileNotFoundError" in line for line in lines),
            "index_errors": sum("IndexError" in line for line in lines),
            "syntax_errors": sum("SyntaxError" in line for line in lines),
        }
        with self.lock:
            proc = self.proc
            state_name = self.state
            error = self.error or None
            started_at = self.started_at
            restart_count = self.restart_count
        # brain_status performs local HTTP probes; never hold the lifecycle
        # lock across those timeouts, otherwise stop/start can stall.
        brain = self.brain_status()
        running = bool(proc and proc.poll() is None)
        pid = proc.pid if running else None
        return {
            "state": state_name,
            "error": error,
            "started_at": started_at,
            "pid": pid,
            "running": running,
            "upstream_commit": UPSTREAM_COMMIT,
            "engine_runtime": read_attestation(ROOT / "engine-attestation.json", pid),
            "minecraft": "127.0.0.1:25565",
            "openai_base": NALDO_BRIDGE_BASE,
            "model": NALDO_PRIMARY_MODEL,
            "aux_model": NALDO_PRIMARY_MODEL,
            "agent_routing": {"action": "opencode-muse-spark", "curriculum": "opencode-muse-spark", "critic": "opencode-muse-spark", "qa": "opencode-muse-spark", "skill_manager": "opencode-muse-spark"},
            "decision_brain": "opencode-only",
            "brain_profile": NALDO_BRAIN_PROFILE,
            "brain": brain,
            "embedding_backend": EMBED_BASE_URL,
            "bot_username": BOT_USERNAME,
            "bot_display_name": BOT_DISPLAY_NAME,
            "checkpoint_dir": str(CKPT_DIR),
            "checkpoint_persistent": str(CKPT_DIR).startswith("/persist/"),
            "learning": learning,
            "activity": activity,
            "restart_count": restart_count,
            "mineflayer": mineflayer,
            "log_tail": self.tail(120),
        }


VOYAGER = VoyagerStack()