copper-pilot-cli 0.1.11__tar.gz → 1.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (203) hide show
  1. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/HISTORY.md +99 -0
  2. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/PKG-INFO +1 -1
  3. copper_pilot_cli-1.0.0/VERSION +1 -0
  4. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/src/copper_pilot_cli/copper_acp.py +5 -1
  5. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/src/copper_pilot_cli/copper_config.py +42 -0
  6. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/src/copper_pilot_cli/copper_graph.py +10 -2
  7. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/src/copper_pilot_cli/copper_preferences.py +2 -5
  8. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/src/copper_pilot_cli/copper_protocol.py +74 -11
  9. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/src/copper_pilot_cli/copper_workspace.py +24 -15
  10. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/src/copper_pilot_cli/validate/design.py +16 -0
  11. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/src/copper_pilot_cli/validate/reviewer.py +7 -4
  12. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/tests/test_acp.py +18 -0
  13. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/tests/test_durability.py +113 -1
  14. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/tests/test_protocol.py +79 -0
  15. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/tests/test_validate.py +72 -0
  16. copper_pilot_cli-1.0.0/tests/test_workspace.py +103 -0
  17. copper_pilot_cli-0.1.11/VERSION +0 -1
  18. copper_pilot_cli-0.1.11/tests/test_workspace.py +0 -32
  19. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/.claude-plugin/marketplace.json +0 -0
  20. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/.gitignore +0 -0
  21. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/CODE_OF_CONDUCT.md +0 -0
  22. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/CONTRIBUTING.md +0 -0
  23. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/LICENSE +0 -0
  24. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/NOTICE +0 -0
  25. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/README.md +0 -0
  26. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/SECURITY.md +0 -0
  27. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/SUPPORT.md +0 -0
  28. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/UPSTREAM.md +0 -0
  29. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/docs/assets/esp32-mini-1-h4.gif +0 -0
  30. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/docs/assets/logo-dark.png +0 -0
  31. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/docs/assets/logo-light.png +0 -0
  32. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/docs/assets/logo.png +0 -0
  33. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/docs/cli.md +0 -0
  34. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/docs/development.md +0 -0
  35. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/docs/harness.md +0 -0
  36. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/docs/validate.md +0 -0
  37. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/examples/.gitignore +0 -0
  38. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/examples/001_esp32/.copperpilot/PLANS.md +0 -0
  39. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/examples/001_esp32/.copperpilot/README.md +0 -0
  40. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/examples/001_esp32/.copperpilot/TOOLS.md +0 -0
  41. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/examples/001_esp32/.copperpilot/plans/esp32-dev-board.plan.md +0 -0
  42. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/examples/001_esp32/.gitignore +0 -0
  43. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/examples/001_esp32/README.md +0 -0
  44. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/examples/001_esp32/esp32/3d-models/SW-SMD_L3.9-W2.9-H2.0-LS4.8.step +0 -0
  45. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/examples/001_esp32/esp32/3d-models/TYPE-C-31-M-12.step +0 -0
  46. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/examples/001_esp32/esp32/esp32.kicad_pcb +0 -0
  47. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/examples/001_esp32/esp32/esp32.kicad_pro +0 -0
  48. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/examples/001_esp32/esp32/esp32.kicad_sch +0 -0
  49. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/examples/001_esp32/esp32/esp32.png +0 -0
  50. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/examples/001_esp32/helper.py +0 -0
  51. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/examples/001_esp32/main.py +0 -0
  52. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/examples/002_cm5_camera/.copperpilot/PLANS.md +0 -0
  53. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/examples/002_cm5_camera/.copperpilot/README.md +0 -0
  54. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/examples/002_cm5_camera/.copperpilot/plans/cm5-camera-carrier.plan.md +0 -0
  55. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/examples/002_cm5_camera/.gitignore +0 -0
  56. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/examples/002_cm5_camera/README.md +0 -0
  57. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/examples/002_cm5_camera/assembly_spec.json +0 -0
  58. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/examples/002_cm5_camera/cm5_camera/3dmodels/CM4.step +0 -0
  59. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/examples/002_cm5_camera/cm5_camera/3dmodels/CM5.step +0 -0
  60. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/examples/002_cm5_camera/cm5_camera/3dmodels/Camera_IMX219-D160.step +0 -0
  61. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/examples/002_cm5_camera/cm5_camera/3dmodels/USB_A_Molex_105057-0001_Vertical.wrl +0 -0
  62. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/examples/002_cm5_camera/cm5_camera/3dmodels/USB_A_vertical.step +0 -0
  63. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/examples/002_cm5_camera/cm5_camera/CM5.3dshapes/5033981892.stp +0 -0
  64. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/examples/002_cm5_camera/cm5_camera/CM5.3dshapes/DF40C-100DS.stp +0 -0
  65. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/examples/002_cm5_camera/cm5_camera/CM5.3dshapes/JTHDA-19F08.STEP +0 -0
  66. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/examples/002_cm5_camera/cm5_camera/CM5.3dshapes/SRP5030CC.stp +0 -0
  67. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/examples/002_cm5_camera/cm5_camera/cm5_camera.kicad_dru +0 -0
  68. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/examples/002_cm5_camera/cm5_camera/cm5_camera.kicad_pcb +0 -0
  69. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/examples/002_cm5_camera/cm5_camera/cm5_camera.kicad_pro +0 -0
  70. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/examples/002_cm5_camera/cm5_camera/cm5_camera.kicad_sch +0 -0
  71. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/examples/002_cm5_camera/cm5_camera/cm5_camera.kicad_sym +0 -0
  72. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/examples/002_cm5_camera/cm5_camera/cm5_camera.png +0 -0
  73. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/examples/002_cm5_camera/cm5_camera/cm5_camera.pretty/C_0402_1005Metric.kicad_mod +0 -0
  74. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/examples/002_cm5_camera/cm5_camera/cm5_camera.pretty/C_0805_2012Metric.kicad_mod +0 -0
  75. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/examples/002_cm5_camera/cm5_camera/cm5_camera.pretty/Camera_Module_IMX219-D160.kicad_mod +0 -0
  76. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/examples/002_cm5_camera/cm5_camera/cm5_camera.pretty/Crystal_SMD_3225-4Pin_3.2x2.5mm.kicad_mod +0 -0
  77. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/examples/002_cm5_camera/cm5_camera/cm5_camera.pretty/HDMI_Mini-C_Molex_47151-0001.kicad_mod +0 -0
  78. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/examples/002_cm5_camera/cm5_camera/cm5_camera.pretty/LED_0603_1608Metric.kicad_mod +0 -0
  79. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/examples/002_cm5_camera/cm5_camera/cm5_camera.pretty/L_Bourns_SRP5030CC.kicad_mod +0 -0
  80. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/examples/002_cm5_camera/cm5_camera/cm5_camera.pretty/L_CommonModeChoke_Coilank_ACM1210.kicad_mod +0 -0
  81. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/examples/002_cm5_camera/cm5_camera/cm5_camera.pretty/MOLEX_USB_67298-4090.kicad_mod +0 -0
  82. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/examples/002_cm5_camera/cm5_camera/cm5_camera.pretty/Molex_200528-0150_1x15-1MP_P1.00mm_Horizontal.kicad_mod +0 -0
  83. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/examples/002_cm5_camera/cm5_camera/cm5_camera.pretty/PinHeader_1x03_P2.54mm_Vertical.kicad_mod +0 -0
  84. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/examples/002_cm5_camera/cm5_camera/cm5_camera.pretty/PinHeader_2x20_P2.54mm_Vertical.kicad_mod +0 -0
  85. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/examples/002_cm5_camera/cm5_camera/cm5_camera.pretty/R_0402_1005Metric.kicad_mod +0 -0
  86. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/examples/002_cm5_camera/cm5_camera/cm5_camera.pretty/R_0603_1608Metric.kicad_mod +0 -0
  87. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/examples/002_cm5_camera/cm5_camera/cm5_camera.pretty/Raspberry-Pi-4-Compute-Module.kicad_mod +0 -0
  88. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/examples/002_cm5_camera/cm5_camera/cm5_camera.pretty/SDCARD_MOLEX_503398-1892.kicad_mod +0 -0
  89. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/examples/002_cm5_camera/cm5_camera/cm5_camera.pretty/SOT-23-5.kicad_mod +0 -0
  90. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/examples/002_cm5_camera/cm5_camera/cm5_camera.pretty/SW_SPST_PTS645Sx43SMTR92.kicad_mod +0 -0
  91. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/examples/002_cm5_camera/cm5_camera/cm5_camera.pretty/TSOT-23-6.kicad_mod +0 -0
  92. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/examples/002_cm5_camera/cm5_camera/cm5_camera.pretty/Texas_DRT-3.kicad_mod +0 -0
  93. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/examples/002_cm5_camera/cm5_camera/cm5_camera.pretty/UQFN-10_1.4x1.8mm_P0.4mm.kicad_mod +0 -0
  94. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/examples/002_cm5_camera/cm5_camera/cm5_camera.pretty/USB_C_Receptacle_GCT_USB4105-xx-A_16P_TopMnt_Horizontal.kicad_mod +0 -0
  95. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/examples/002_cm5_camera/cm5_camera/cm5_camera_bottom.png +0 -0
  96. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/examples/002_cm5_camera/cm5_camera/fp-lib-table +0 -0
  97. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/examples/002_cm5_camera/cm5_camera/sym-lib-table +0 -0
  98. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/examples/002_cm5_camera/generate_3d_models.py +0 -0
  99. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/examples/002_cm5_camera/helper.py +0 -0
  100. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/examples/002_cm5_camera/main.py +0 -0
  101. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/plugins/copper-pilot/.claude-plugin/plugin.json +0 -0
  102. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/plugins/copper-pilot/.mcp.json +0 -0
  103. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/plugins/copper-pilot/agents/copper-pilot.md +0 -0
  104. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/plugins/copper-pilot/skills/copper-pilot-review/SKILL.md +0 -0
  105. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/pyproject.toml +0 -0
  106. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/skills/copper-pilot-review/SKILL.md +0 -0
  107. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/src/copper_pilot_cli/__init__.py +0 -0
  108. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/src/copper_pilot_cli/__main__.py +0 -0
  109. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/src/copper_pilot_cli/_upstream/__init__.py +0 -0
  110. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/src/copper_pilot_cli/_upstream/dcode_0_1_69/PROVENANCE.json +0 -0
  111. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/src/copper_pilot_cli/_upstream/dcode_0_1_69/__init__.py +0 -0
  112. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/src/copper_pilot_cli/_upstream/dcode_0_1_69/diff_utils.py +0 -0
  113. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/src/copper_pilot_cli/_version.py +0 -0
  114. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/src/copper_pilot_cli/clipboard.py +0 -0
  115. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/src/copper_pilot_cli/copper_api.py +0 -0
  116. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/src/copper_pilot_cli/copper_app.py +0 -0
  117. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/src/copper_pilot_cli/copper_auth.py +0 -0
  118. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/src/copper_pilot_cli/copper_background.py +0 -0
  119. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/src/copper_pilot_cli/copper_chrome.py +0 -0
  120. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/src/copper_pilot_cli/copper_features.py +0 -0
  121. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/src/copper_pilot_cli/copper_hooks.py +0 -0
  122. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/src/copper_pilot_cli/copper_logo.py +0 -0
  123. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/src/copper_pilot_cli/copper_main.py +0 -0
  124. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/src/copper_pilot_cli/copper_mcp.py +0 -0
  125. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/src/copper_pilot_cli/copper_models.py +0 -0
  126. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/src/copper_pilot_cli/copper_presentation.py +0 -0
  127. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/src/copper_pilot_cli/copper_status_events.py +0 -0
  128. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/src/copper_pilot_cli/copper_theme.py +0 -0
  129. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/src/copper_pilot_cli/copper_tools.py +0 -0
  130. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/src/copper_pilot_cli/copper_update.py +0 -0
  131. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/src/copper_pilot_cli/copper_widgets.py +0 -0
  132. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/src/copper_pilot_cli/deepagents_tools.py +0 -0
  133. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/src/copper_pilot_cli/diagnostics.py +0 -0
  134. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/src/copper_pilot_cli/langchain.py +0 -0
  135. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/src/copper_pilot_cli/media_utils.py +0 -0
  136. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/src/copper_pilot_cli/py.typed +0 -0
  137. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/src/copper_pilot_cli/sessions.py +0 -0
  138. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/src/copper_pilot_cli/textual_patches.py +0 -0
  139. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/src/copper_pilot_cli/tool_batch.py +0 -0
  140. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/src/copper_pilot_cli/validate/__init__.py +0 -0
  141. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/src/copper_pilot_cli/validate/builtins.py +0 -0
  142. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/src/copper_pilot_cli/validate/cli.py +0 -0
  143. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/src/copper_pilot_cli/validate/config.py +0 -0
  144. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/src/copper_pilot_cli/validate/default.yaml +0 -0
  145. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/src/copper_pilot_cli/validate/kicad.py +0 -0
  146. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/src/copper_pilot_cli/validate/report.py +0 -0
  147. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/src/copper_pilot_cli/validate/runner.py +0 -0
  148. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/tests/conftest.py +0 -0
  149. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/tests/snapshots/approval.txt +0 -0
  150. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/tests/snapshots/idle-input-120-ascii.txt +0 -0
  151. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/tests/snapshots/idle-input-120-color.txt +0 -0
  152. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/tests/snapshots/idle-input-120-nocolor.txt +0 -0
  153. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/tests/snapshots/idle-input-50-ascii.txt +0 -0
  154. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/tests/snapshots/idle-input-50-color.txt +0 -0
  155. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/tests/snapshots/idle-input-50-nocolor.txt +0 -0
  156. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/tests/snapshots/idle-input-80-ascii.txt +0 -0
  157. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/tests/snapshots/idle-input-80-color.txt +0 -0
  158. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/tests/snapshots/idle-input-80-nocolor.txt +0 -0
  159. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/tests/snapshots/slash-menu.txt +0 -0
  160. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/tests/snapshots/status-agent-balanced.txt +0 -0
  161. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/tests/snapshots/status-agent-intelligence.txt +0 -0
  162. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/tests/snapshots/status-ask-balanced.txt +0 -0
  163. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/tests/snapshots/status-ask-intelligence.txt +0 -0
  164. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/tests/snapshots/status-plan-balanced.txt +0 -0
  165. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/tests/snapshots/status-plan-intelligence.txt +0 -0
  166. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/tests/snapshots/welcome-120-ascii.txt +0 -0
  167. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/tests/snapshots/welcome-120-color.txt +0 -0
  168. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/tests/snapshots/welcome-120-nocolor.txt +0 -0
  169. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/tests/snapshots/welcome-50-ascii.txt +0 -0
  170. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/tests/snapshots/welcome-50-color.txt +0 -0
  171. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/tests/snapshots/welcome-50-nocolor.txt +0 -0
  172. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/tests/snapshots/welcome-80-ascii.txt +0 -0
  173. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/tests/snapshots/welcome-80-color.txt +0 -0
  174. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/tests/snapshots/welcome-80-nocolor.txt +0 -0
  175. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/tests/snapshots/working-color.txt +0 -0
  176. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/tests/test_auth.py +0 -0
  177. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/tests/test_chrome_snapshots.py +0 -0
  178. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/tests/test_clipboard.py +0 -0
  179. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/tests/test_cm5_camera_example.py +0 -0
  180. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/tests/test_copper_api.py +0 -0
  181. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/tests/test_e2e.py +0 -0
  182. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/tests/test_esp32_example.py +0 -0
  183. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/tests/test_features.py +0 -0
  184. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/tests/test_graph_batch.py +0 -0
  185. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/tests/test_langchain.py +0 -0
  186. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/tests/test_live_chat_e2e.py +0 -0
  187. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/tests/test_logo.py +0 -0
  188. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/tests/test_main.py +0 -0
  189. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/tests/test_mcp.py +0 -0
  190. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/tests/test_models.py +0 -0
  191. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/tests/test_plugin_manifest.py +0 -0
  192. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/tests/test_preferences.py +0 -0
  193. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/tests/test_presentation.py +0 -0
  194. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/tests/test_protocol_batch.py +0 -0
  195. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/tests/test_release_gates.py +0 -0
  196. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/tests/test_sessions.py +0 -0
  197. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/tests/test_status_events.py +0 -0
  198. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/tests/test_themes_and_speed.py +0 -0
  199. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/tests/test_tool_batch.py +0 -0
  200. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/tests/test_tools.py +0 -0
  201. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/tests/test_tui.py +0 -0
  202. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/tests/test_upstream_provenance.py +0 -0
  203. {copper_pilot_cli-0.1.11 → copper_pilot_cli-1.0.0}/tests/test_validate_live.py +0 -0
@@ -5,6 +5,104 @@ Changelog
5
5
  (unreleased)
6
6
  ------------
7
7
 
8
+ Fix
9
+ ~~~
10
+ - Do not count echoed request fields toward the stream byte budget.
11
+ [AdityaNG, Claude Opus 5.5]
12
+
13
+ The hosted service echoes the request `query` (and, in the final frame,
14
+ `context`) in every chat_response. A live probe showed the echo is ~97% of
15
+ each frame. A validate arch.review prompt is ~76 KB, so a long review on a
16
+ large board crossed the 64 MiB client budget after ~815 frames (2601 s)
17
+ although the hosted output itself was ~4.4 MB, and the check ERRORed with
18
+ "Chat exceeded the client stream byte budget."
19
+
20
+ The budget now counts frame bytes minus the echoed request fields the
21
+ client never reads, and the error names the counted size and frame count.
22
+ - Stop cutting long hosted answers at the 16 KB status limit. [AdityaNG,
23
+ Claude Opus 5.5]
24
+
25
+ Every hosted response frame went through sanitize_status, which caps
26
+ status lines at 16 KB. The hosted side can send a whole answer as one
27
+ response frame, so a validate review longer than 16 KB lost the end of
28
+ its findings JSON. The reviewer then found no json block, sent a repair
29
+ turn into the already huge thread, and the server refused it ("This
30
+ conversation is too large to continue"), so arch.review ERRORed. Chat
31
+ and ACP answers over 16 KB were cut the same way.
32
+
33
+ Response text now gets a 1 MiB bound; status and reasoning lines keep
34
+ 16 KB. A validate check that fails with a hosted failed final also
35
+ records the hosted turn id, as chat does.
36
+ - Surface stalled or failed hosted turns instead of hanging. [AdityaNG,
37
+ Claude Opus 5.5]
38
+
39
+ Three ways a long ACP turn could hang or hide its cause:
40
+
41
+ - idle_frame_timeout was None, so a hosted server that kept the socket
42
+ (and keepalive) up but stopped sending hung the prompt forever. It now
43
+ defaults to 10 minutes (COPPER_PILOT_IDLE_TIMEOUT_SECONDS, 0 disables)
44
+ and raises the existing retryable CHAT_IDLE_TIMEOUT with the silence
45
+ length in the message. The hosted side heartbeats every 25 s.
46
+ - A failed hosted final (success false) still waited, unbounded, for
47
+ background shells and then ran a continuation turn. A runaway background
48
+ command held the hosted error back until the caller timed out, and a
49
+ continuation that succeeded still reported the stale error. A failed
50
+ final now ends the turn and kills leftover background processes.
51
+ - The hosted copy "CopperPilot ran into an error." carries no detail. The
52
+ ACP error now names the hosted turn id, and the client logs the failing
53
+ final frame's keys, so the server-side cause can be found.
54
+ - Guard the state lock with sys.platform so ty passes on Windows.
55
+ [AdityaNG, Claude Opus 5.5]
56
+ - Stop concurrent sessions racing on recent-projects state. [AdityaNG,
57
+ Claude Opus 5.5]
58
+
59
+ Every process wrote recent-projects.json (and config.json) through the
60
+ same fixed temp name, so parallel `copper-pilot acp` session starts moved
61
+ or clobbered each other's temp file and session/new failed with
62
+ FileNotFoundError on os.replace.
63
+
64
+ Writes now go through a per-write mkstemp temp file, the recent-projects
65
+ read-modify-write holds a POSIX flock so no entry is dropped, and the
66
+ bookkeeping logs instead of raising so it can never fail a session.
67
+
68
+ Other
69
+ ~~~~~
70
+ - Merge pull request #37 from CopperPilot/fix/stream-byte-budget.
71
+ [Aditya]
72
+
73
+ fix: do not count echoed request fields toward the stream byte budget
74
+ - Merge pull request #35 from CopperPilot/fix/long-response-truncation.
75
+ [Aditya]
76
+
77
+ fix: stop cutting long hosted answers at the 16 KB status limit
78
+ - Merge pull request #33 from CopperPilot/fix/hosted-stream-stall.
79
+ [Aditya]
80
+
81
+ fix: surface stalled or failed hosted turns instead of hanging
82
+ - Merge pull request #34 from CopperPilot/fix/validate-hierarchical-
83
+ nets. [Aditya]
84
+
85
+ fix(validate): ground findings that cite label names of hierarchical nets
86
+ - Fix(validate): ground findings that cite label names of hierarchical
87
+ nets. [AdityaNG, Claude Opus 5.5]
88
+
89
+ KiCad names local and hierarchical label nets with their sheet path
90
+ ("/RAIL_IO_3", "/power/RAIL_IO_3"), but reviewers cite the label
91
+ ("RAIL_IO_3"). Grounding compared the cited name to the full net name
92
+ only, so every real finding on such a board was marked UNGROUNDED and
93
+ could never fail a blocking check (WARN / exit 3 instead of FAIL).
94
+
95
+ A cited net now grounds when it equals a design net name or is a
96
+ "/"-boundary suffix of one; invented names still do not.
97
+ - Merge pull request #32 from CopperPilot/fix/recent-projects-race.
98
+ [Aditya]
99
+
100
+ fix: stop concurrent ACP sessions racing on recent-projects state
101
+
102
+
103
+ 0.1.11 (2026-10-25)
104
+ -------------------
105
+
8
106
  Fix
9
107
  ~~~
10
108
  - Do not block turn cancellation on loading-widget removal. [Cursor,
@@ -40,6 +138,7 @@ Fix
40
138
 
41
139
  Other
42
140
  ~~~~~
141
+ - Release: version 0.1.11 🚀 [AdityaNG]
43
142
  - Merge pull request #31 from naveen-
44
143
  forkd/feat/cli_chat_chrome_2026_10_24. [Aditya]
45
144
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: copper-pilot-cli
3
- Version: 0.1.11
3
+ Version: 1.0.0
4
4
  Summary: CopperPilot hosted agent for the terminal and LangChain
5
5
  Project-URL: Homepage, https://copperpilot.ai
6
6
  Project-URL: Repository, https://github.com/CopperPilot/copper-pilot-cli
@@ -0,0 +1 @@
1
+ 1.0.0
@@ -1252,7 +1252,11 @@ class CopperAcpAgent:
1252
1252
  await send(session_id=session_id, update=update_agent_thought_text(f"{label}\n"))
1253
1253
  elif kind == EventKind.FINAL.value and isinstance(payload, dict):
1254
1254
  if payload.get("success") is False or payload.get("error"):
1255
- return str(payload.get("error") or "hosted failure")
1255
+ message = str(payload.get("error") or "hosted failure")
1256
+ # The hosted copy is generic; the turn id lets the service
1257
+ # owners find the server-side cause.
1258
+ turn_id = event.get("turn_id")
1259
+ return f"{message} (hosted turn {turn_id})" if turn_id else message
1256
1260
  return None
1257
1261
 
1258
1262
  # -------------------------------------------------------- broker handlers
@@ -2,7 +2,11 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import contextlib
5
6
  import os
7
+ import sys
8
+ import tempfile
9
+ from collections.abc import Generator
6
10
  from dataclasses import dataclass
7
11
  from pathlib import Path
8
12
  from urllib.parse import urlparse
@@ -60,3 +64,41 @@ def paths() -> AppPaths:
60
64
  logs=state / "logs",
61
65
  recent_projects=state / "recent-projects.json",
62
66
  )
67
+
68
+
69
+ def atomic_write_text(destination: Path, text: str, *, mode: int | None = None) -> None:
70
+ """Replace ``destination`` via a temp file unique to this write.
71
+
72
+ A shared temp name lets concurrent processes move or clobber each other's
73
+ half-written file, so every write gets its own name in the same directory.
74
+ """
75
+ fd, name = tempfile.mkstemp(
76
+ prefix=f".{destination.name}.", suffix=".tmp", dir=destination.parent
77
+ )
78
+ temporary = Path(name)
79
+ try:
80
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
81
+ handle.write(text)
82
+ if mode is not None:
83
+ temporary.chmod(mode)
84
+ temporary.replace(destination)
85
+ except BaseException:
86
+ with contextlib.suppress(OSError):
87
+ temporary.unlink()
88
+ raise
89
+
90
+
91
+ @contextlib.contextmanager
92
+ def state_lock(destination: Path) -> Generator[None]:
93
+ """Serialize read-modify-write of a state file across processes (POSIX only)."""
94
+ if sys.platform == "win32":
95
+ yield
96
+ return
97
+ import fcntl
98
+
99
+ with open(destination.with_name(f".{destination.name}.lock"), "a") as handle:
100
+ fcntl.flock(handle, fcntl.LOCK_EX)
101
+ try:
102
+ yield
103
+ finally:
104
+ fcntl.flock(handle, fcntl.LOCK_UN)
@@ -216,6 +216,8 @@ class _TurnRun:
216
216
  self.context_parts: list[str] = []
217
217
  self.failure: ErrorInfo | None = None
218
218
  self.cancelled = False
219
+ # The hosted service ended the turn with ``success: false``.
220
+ self.final_failed = False
219
221
 
220
222
  # ------------------------------------------------------------ streaming
221
223
 
@@ -244,6 +246,8 @@ class _TurnRun:
244
246
  self._track(event)
245
247
 
246
248
  def _track(self, event: CopperEvent) -> None:
249
+ if event.kind is EventKind.FINAL and isinstance(event.data, dict):
250
+ self.final_failed = event.data.get("success") is False or bool(event.data.get("error"))
247
251
  if event.kind is EventKind.TEXT:
248
252
  self.text.append(str(event.data))
249
253
  elif event.kind is EventKind.STATUS_EVENT and isinstance(event.data, dict):
@@ -426,6 +430,8 @@ class _TurnRun:
426
430
  ]
427
431
  if not await self.run_with_retry(continuation, first_messages=messages):
428
432
  return False
433
+ if self.final_failed:
434
+ return False
429
435
  return True
430
436
 
431
437
  # ---------------------------------------------------------------- close
@@ -526,10 +532,12 @@ async def create_copper_graph(agent: CopperPilotAgent) -> Any:
526
532
  query if isinstance(query, str) else json.dumps(query),
527
533
  first_messages=original,
528
534
  )
529
- if completed:
535
+ # A failed final ends the turn: waiting on background shells first
536
+ # would hold the hosted error back for as long as they run.
537
+ if completed and not run.final_failed:
530
538
  await run.wait_for_background()
531
539
  finally:
532
- if run.failure is not None or run.cancelled:
540
+ if run.failure is not None or run.cancelled or run.final_failed:
533
541
  manager = _background_manager(agent)
534
542
  if manager is not None:
535
543
  await manager.kill_all()
@@ -7,7 +7,7 @@ import stat
7
7
  from pathlib import Path
8
8
  from typing import Any
9
9
 
10
- from copper_pilot_cli.copper_config import paths
10
+ from copper_pilot_cli.copper_config import atomic_write_text, paths
11
11
  from copper_pilot_cli.copper_tools import ApprovalMode
12
12
 
13
13
  YOLO_ACK_VERSION = 1
@@ -30,10 +30,7 @@ def save_preferences(value: dict[str, Any]) -> None:
30
30
  destination = _path()
31
31
  destination.parent.mkdir(parents=True, exist_ok=True)
32
32
  destination.parent.chmod(stat.S_IRWXU)
33
- temporary = destination.with_suffix(".tmp")
34
- temporary.write_text(json.dumps(value, indent=2), encoding="utf-8")
35
- temporary.chmod(stat.S_IRUSR | stat.S_IWUSR)
36
- temporary.replace(destination)
33
+ atomic_write_text(destination, json.dumps(value, indent=2), mode=stat.S_IRUSR | stat.S_IWUSR)
37
34
 
38
35
 
39
36
  def saved_approval_mode() -> ApprovalMode:
@@ -6,6 +6,7 @@ import asyncio
6
6
  import hashlib
7
7
  import json
8
8
  import logging
9
+ import os
9
10
  import re
10
11
  import ssl
11
12
  from collections.abc import AsyncGenerator, AsyncIterator, Awaitable, Callable, Sequence
@@ -37,6 +38,34 @@ MAX_CONTEXT_MESSAGE_BYTES = 64 * 1024
37
38
  MAX_CONTEXT_TOTAL_BYTES = 4 * 1024 * 1024
38
39
  MAX_CONTEXT_ENTRIES = 2_000
39
40
  MAX_STREAM_BYTES = 64 * 1024 * 1024
41
+ # Request fields the hosted service echoes back in every chat_response. The
42
+ # client never reads them, and the echoed ``query`` alone (a whole validate
43
+ # prompt) can be most of each frame, so they do not count toward the budget.
44
+ ECHOED_REQUEST_FIELDS = (
45
+ "query",
46
+ "context",
47
+ "document_context",
48
+ "pcb_data",
49
+ "pcb_info",
50
+ "schematic_data",
51
+ "schematic_info",
52
+ )
53
+ MAX_STATUS_BYTES = 16 * 1024
54
+ # The answer itself can be long (a validate review's findings JSON easily
55
+ # passes 16 KB) and the hosted side may send it as one frame.
56
+ MAX_RESPONSE_TEXT_BYTES = 1024 * 1024
57
+ IDLE_FRAME_TIMEOUT_ENV = "COPPER_PILOT_IDLE_TIMEOUT_SECONDS"
58
+ DEFAULT_IDLE_FRAME_TIMEOUT = 10 * 60.0
59
+
60
+
61
+ def idle_frame_timeout() -> float | None:
62
+ """Seconds a streaming turn may stay silent; ``None`` (env ``0``) waits forever."""
63
+ raw = os.getenv(IDLE_FRAME_TIMEOUT_ENV, "").strip()
64
+ try:
65
+ value = float(raw) if raw else DEFAULT_IDLE_FRAME_TIMEOUT
66
+ except ValueError:
67
+ value = DEFAULT_IDLE_FRAME_TIMEOUT
68
+ return value if value > 0 else None
40
69
 
41
70
 
42
71
  class ProtocolError(RuntimeError):
@@ -414,7 +443,7 @@ def websocket_url(base_url: str, *, agentic: bool = True) -> str:
414
443
  return urlunparse((scheme, parsed.netloc, path, "", "", ""))
415
444
 
416
445
 
417
- def sanitize_status(value: str) -> str:
446
+ def sanitize_status(value: str, limit: int = MAX_STATUS_BYTES) -> str:
418
447
  """Convert hosted status HTML to bounded terminal-safe plain markdown."""
419
448
  safe = re.sub(
420
449
  r"<(?:script|style)\b[^>]*>[\s\S]*?</(?:script|style)>",
@@ -429,7 +458,7 @@ def sanitize_status(value: str) -> str:
429
458
  )
430
459
  text = _TERMINAL_ESCAPE.sub("", text)
431
460
  text = "".join(character for character in text if character in "\n\t" or ord(character) >= 32)
432
- return _truncate_utf8(text, 16 * 1024)
461
+ return _truncate_utf8(text, limit)
433
462
 
434
463
 
435
464
  def usage_reset_time(frame: dict[str, Any]) -> str | None:
@@ -457,6 +486,20 @@ def error_message(value: Any) -> str:
457
486
  ToolHandler = Callable[[ToolRequest], Awaitable[Any]]
458
487
 
459
488
 
489
+ def _counted_frame_bytes(raw: str | bytes, frame: dict[str, Any]) -> int:
490
+ """Bytes of one frame that count toward the stream budget, minus echoed request fields."""
491
+ size = len(raw.encode() if isinstance(raw, str) else raw)
492
+ response = frame.get("chat_response")
493
+ if isinstance(response, dict):
494
+ echoed = sum(
495
+ len(json.dumps(response[key]).encode())
496
+ for key in ECHOED_REQUEST_FIELDS
497
+ if key in response
498
+ )
499
+ size -= echoed
500
+ return max(size, 0)
501
+
502
+
460
503
  def tool_error_result(exc: BaseException) -> dict[str, Any]:
461
504
  """The tool_result payload reported when a local tool raises."""
462
505
  return {
@@ -489,10 +532,11 @@ class HostedChatClient:
489
532
  self._socket: Any = None
490
533
  self._owner: str | None = None
491
534
  self.first_frame_timeout = 60.0
492
- # The desktop client never times out between frames once a turn is
493
- # streaming: long hosted reasoning or subagent runs can stay silent for
494
- # many minutes while websocket keepalive still detects dead peers.
495
- self.idle_frame_timeout: float | None = None
535
+ # Long hosted reasoning or subagent runs can stay silent for minutes,
536
+ # and websocket keepalive only detects a dead peer. A server that keeps
537
+ # the socket open but stops sending would otherwise hang the turn
538
+ # forever, so a generous idle bound turns that stall into an error.
539
+ self.idle_frame_timeout: float | None = idle_frame_timeout()
496
540
  self.frame_budget = 10_000
497
541
 
498
542
  async def cancel(self) -> None:
@@ -582,7 +626,10 @@ class HostedChatClient:
582
626
  raw = await asyncio.wait_for(socket.recv(), timeout)
583
627
  except TimeoutError as exc:
584
628
  phase = "first frame" if frame_count == 0 else "next frame"
585
- message = f"Timed out waiting for the hosted chat {phase}."
629
+ message = (
630
+ f"Timed out waiting for the hosted chat {phase} "
631
+ f"(no data for {timeout:g} s)."
632
+ )
586
633
  raise ProtocolError(
587
634
  message,
588
635
  code="CHAT_FIRST_FRAME_TIMEOUT"
@@ -594,17 +641,22 @@ class HostedChatClient:
594
641
  closed = exc
595
642
  break
596
643
  frame_count += 1
597
- stream_bytes += len(raw.encode() if isinstance(raw, str) else raw)
598
644
  if frame_count > self.frame_budget:
599
645
  raise ProtocolError("Chat exceeded the client frame budget.")
600
- if stream_bytes > MAX_STREAM_BYTES:
601
- raise ProtocolError("Chat exceeded the client stream byte budget.")
602
646
  try:
603
647
  frame = json.loads(raw)
604
648
  except (json.JSONDecodeError, TypeError) as exc:
605
649
  raise ProtocolError("Hosted chat returned invalid JSON.") from exc
606
650
  if not isinstance(frame, dict):
607
651
  raise ProtocolError("Hosted chat frame must be a JSON object.")
652
+ stream_bytes += _counted_frame_bytes(raw, frame)
653
+ if stream_bytes > MAX_STREAM_BYTES:
654
+ raise ProtocolError(
655
+ "Chat exceeded the client stream byte budget "
656
+ f"({stream_bytes // (1024 * 1024)} MiB of hosted output "
657
+ f"in {frame_count} frames, limit "
658
+ f"{MAX_STREAM_BYTES // (1024 * 1024)} MiB)."
659
+ )
608
660
  frame_type = frame.get("type")
609
661
  if frame_type == "error":
610
662
  message = frame.get("public_message") or frame.get("message")
@@ -647,7 +699,9 @@ class HostedChatClient:
647
699
  }
648
700
  if response.response:
649
701
  yield CopperEvent(
650
- EventKind.TEXT, sanitize_status(response.response), **metadata
702
+ EventKind.TEXT,
703
+ sanitize_status(response.response, MAX_RESPONSE_TEXT_BYTES),
704
+ **metadata,
651
705
  )
652
706
  if response.reasoning_update:
653
707
  yield CopperEvent(
@@ -741,6 +795,15 @@ class HostedChatClient:
741
795
  break
742
796
  if frame_type == "final" or response.is_final:
743
797
  saw_final = True
798
+ if response.success is False or response.error:
799
+ logger.warning(
800
+ "Hosted turn ended unsuccessfully (turn_id=%s, error=%r, "
801
+ "frame keys=%s, extra fields=%s)",
802
+ response.turn_id,
803
+ response.error,
804
+ sorted(frame),
805
+ sorted(response.model_extra or {}),
806
+ )
744
807
  yield CopperEvent(
745
808
  EventKind.FINAL,
746
809
  {
@@ -3,11 +3,14 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import json
6
+ import logging
6
7
  import os
7
8
  from dataclasses import asdict, dataclass
8
9
  from pathlib import Path
9
10
 
10
- from copper_pilot_cli.copper_config import paths
11
+ from copper_pilot_cli.copper_config import atomic_write_text, paths, state_lock
12
+
13
+ logger = logging.getLogger(__name__)
11
14
 
12
15
  IGNORED_NAMES = {
13
16
  ".git",
@@ -117,22 +120,28 @@ def build_initial_file_tree(root: Path, limit: int = 2_000) -> str:
117
120
 
118
121
 
119
122
  def remember_workspace(context: WorkspaceContext, limit: int = 20) -> None:
120
- """Persist most-recently-used folders atomically."""
123
+ """Persist most-recently-used folders atomically.
124
+
125
+ Best-effort bookkeeping: a failure here is logged, never raised, so it
126
+ cannot fail the session that triggered it.
127
+ """
121
128
  destination = paths().recent_projects
122
- destination.parent.mkdir(parents=True, exist_ok=True)
123
- existing: list[dict[str, object]] = []
124
129
  try:
125
- loaded = json.loads(destination.read_text())
126
- if isinstance(loaded, list):
127
- existing = [row for row in loaded if isinstance(row, dict)]
128
- except (FileNotFoundError, OSError, json.JSONDecodeError):
129
- pass
130
- root = str(context.root)
131
- rows = [{"root": root, **asdict(context)}]
132
- rows.extend(row for row in existing if row.get("root") != root)
133
- temporary = destination.with_suffix(".tmp")
134
- temporary.write_text(json.dumps(rows[:limit], default=str, indent=2))
135
- temporary.replace(destination)
130
+ destination.parent.mkdir(parents=True, exist_ok=True)
131
+ with state_lock(destination):
132
+ existing: list[dict[str, object]] = []
133
+ try:
134
+ loaded = json.loads(destination.read_text())
135
+ if isinstance(loaded, list):
136
+ existing = [row for row in loaded if isinstance(row, dict)]
137
+ except (FileNotFoundError, OSError, json.JSONDecodeError):
138
+ pass
139
+ root = str(context.root)
140
+ rows = [{"root": root, **asdict(context)}]
141
+ rows.extend(row for row in existing if row.get("root") != root)
142
+ atomic_write_text(destination, json.dumps(rows[:limit], default=str, indent=2))
143
+ except OSError:
144
+ logger.warning("Could not update recent projects at %s", destination, exc_info=True)
136
145
 
137
146
 
138
147
  def recent_workspaces() -> list[Path]:
@@ -283,6 +283,22 @@ class DesignIndex:
283
283
  def net_names(self) -> set[str]:
284
284
  return {name.upper() for name in self.nets}
285
285
 
286
+ def has_net(self, name: str) -> bool:
287
+ """Whether ``name`` cites a design net, by full path or hierarchical leaf.
288
+
289
+ KiCad names a local or hierarchical label's net with its sheet path
290
+ (``/RAIL_IO_3``, ``/power/RAIL_IO_3``) while people cite the label
291
+ (``RAIL_IO_3``). Accept the full name or any ``/``-boundary suffix of it.
292
+ """
293
+ cited = name.strip().upper()
294
+ if not cited:
295
+ return False
296
+ for net in self.nets:
297
+ full = net.upper()
298
+ if full == cited or full.endswith("/" + cited.lstrip("/")):
299
+ return True
300
+ return False
301
+
286
302
  @property
287
303
  def layer_names(self) -> set[str]:
288
304
  return {name.lower() for name in self.layers}
@@ -205,9 +205,12 @@ class HostedTurnRunner:
205
205
  )
206
206
  elif kind == EventKind.FINAL.value and isinstance(payload, dict):
207
207
  if payload.get("success") is False or payload.get("error"):
208
- result.error = result.error or {
209
- "message": str(payload.get("error") or "hosted turn failed")
210
- }
208
+ message = str(payload.get("error") or "hosted turn failed")
209
+ # The hosted copy is generic; the turn id lets the
210
+ # service owners find the server-side cause.
211
+ if data.get("turn_id"):
212
+ message = f"{message} (hosted turn {data['turn_id']})"
213
+ result.error = result.error or {"message": message}
211
214
  except BaseException:
212
215
  await runtime.acancel()
213
216
  raise
@@ -460,7 +463,7 @@ def ground_findings(findings: list[Finding], index: DesignIndex | None) -> list[
460
463
  problems.append("design index unavailable")
461
464
  else:
462
465
  missing_refs = [ref for ref in loc.refs if ref.upper() not in index.refs]
463
- missing_nets = [net for net in loc.nets if net.upper() not in index.net_names]
466
+ missing_nets = [net for net in loc.nets if not index.has_net(net)]
464
467
  if missing_refs:
465
468
  problems.append(f"unknown refs {', '.join(missing_refs)}")
466
469
  if missing_nets:
@@ -826,6 +826,24 @@ async def test_prompt_reports_failed_final_event(tmp_path, monkeypatch) -> None:
826
826
  await agent.prompt(session_id=session_id, prompt=[TextContentBlock(type="text", text="x")])
827
827
 
828
828
 
829
+ async def test_failed_final_event_names_the_hosted_turn(tmp_path, monkeypatch) -> None:
830
+ # The hosted copy is the generic "CopperPilot ran into an error."; the turn
831
+ # id is what lets the service owners find the server-side cause.
832
+ runtime = FakeRuntime(
833
+ [
834
+ {
835
+ "type": "copper.final",
836
+ "data": {"success": False, "error": "CopperPilot ran into an error."},
837
+ "turn_id": "turn-42",
838
+ }
839
+ ]
840
+ )
841
+ agent, _conn, _runtime, session_id = await _connected_agent(tmp_path, monkeypatch, runtime)
842
+ with pytest.raises(RequestError) as caught:
843
+ await agent.prompt(session_id=session_id, prompt=[TextContentBlock(type="text", text="x")])
844
+ assert str(caught.value) == "CopperPilot ran into an error. (hosted turn turn-42)"
845
+
846
+
829
847
  @pytest.mark.parametrize(
830
848
  ("error", "code"),
831
849
  [