@proflandrigan/shards 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +475 -0
- package/package.json +37 -0
- package/src/agents/academic.md +276 -0
- package/src/agents/ai-engineer.md +377 -0
- package/src/agents/analytics-engineer.md +364 -0
- package/src/agents/applied-ml-scientist.md +410 -0
- package/src/agents/backend-engineer.md +255 -0
- package/src/agents/bi-engineer.md +333 -0
- package/src/agents/data-analyst.md +343 -0
- package/src/agents/data-engineer.md +260 -0
- package/src/agents/data-modeller.md +386 -0
- package/src/agents/data-scientist.md +366 -0
- package/src/agents/deep-learning-engineer.md +389 -0
- package/src/agents/ml-engineer.md +424 -0
- package/src/agents/mlops-engineer.md +339 -0
- package/src/agents/researcher.md +187 -0
- package/src/agents/specific_instructions/academic/critical_review.md +263 -0
- package/src/agents/specific_instructions/academic/report.md +113 -0
- package/src/agents/specific_instructions/ai_engineer/advise.md +162 -0
- package/src/agents/specific_instructions/ai_engineer/bi_engineer_handoff.md +86 -0
- package/src/agents/specific_instructions/ai_engineer/experiment.md +471 -0
- package/src/agents/specific_instructions/ai_engineer/experiment_ui_mode.md +44 -0
- package/src/agents/specific_instructions/ai_engineer/phases/index.md +45 -0
- package/src/agents/specific_instructions/ai_engineer/phases/phase-1.md +55 -0
- package/src/agents/specific_instructions/ai_engineer/phases/phase-2.md +86 -0
- package/src/agents/specific_instructions/ai_engineer/phases/phase-3.md +96 -0
- package/src/agents/specific_instructions/ai_engineer/phases/phase-4.md +138 -0
- package/src/agents/specific_instructions/ai_engineer/phases/phase-5.md +157 -0
- package/src/agents/specific_instructions/ai_engineer/phases/phase-6.md +196 -0
- package/src/agents/specific_instructions/ai_engineer/phases/phase-7.md +313 -0
- package/src/agents/specific_instructions/ai_engineer/phases.md +1011 -0
- package/src/agents/specific_instructions/ai_engineer/prompt_lab.md +161 -0
- package/src/agents/specific_instructions/ai_engineer/prompt_lab_ui_mode.md +28 -0
- package/src/agents/specific_instructions/ai_engineer/research.md +393 -0
- package/src/agents/specific_instructions/ai_engineer/research_ui_mode.md +66 -0
- package/src/agents/specific_instructions/ai_engineer/review.md +159 -0
- package/src/agents/specific_instructions/ai_engineer/validation_checklist.md +182 -0
- package/src/agents/specific_instructions/analytics_engineer/advise.md +155 -0
- package/src/agents/specific_instructions/analytics_engineer/bi_engineer_handoff.md +91 -0
- package/src/agents/specific_instructions/analytics_engineer/data_analyst_handoff.md +84 -0
- package/src/agents/specific_instructions/analytics_engineer/deep_phases.md +818 -0
- package/src/agents/specific_instructions/analytics_engineer/phases_deep/index.md +24 -0
- package/src/agents/specific_instructions/analytics_engineer/phases_deep/phase-1.md +77 -0
- package/src/agents/specific_instructions/analytics_engineer/phases_deep/phase-2.md +106 -0
- package/src/agents/specific_instructions/analytics_engineer/phases_deep/phase-3.md +93 -0
- package/src/agents/specific_instructions/analytics_engineer/phases_deep/phase-4.md +79 -0
- package/src/agents/specific_instructions/analytics_engineer/phases_deep/phase-5.md +61 -0
- package/src/agents/specific_instructions/analytics_engineer/phases_deep/phase-6.md +45 -0
- package/src/agents/specific_instructions/analytics_engineer/phases_deep/phase-7.md +235 -0
- package/src/agents/specific_instructions/analytics_engineer/phases_deep/phase-8.md +221 -0
- package/src/agents/specific_instructions/analytics_engineer/phases_quick/index.md +19 -0
- package/src/agents/specific_instructions/analytics_engineer/phases_quick/phase-1.md +47 -0
- package/src/agents/specific_instructions/analytics_engineer/phases_quick/phase-2.md +78 -0
- package/src/agents/specific_instructions/analytics_engineer/quick_phases.md +112 -0
- package/src/agents/specific_instructions/analytics_engineer/review.md +167 -0
- package/src/agents/specific_instructions/analytics_engineer/service_mode.md +369 -0
- package/src/agents/specific_instructions/analytics_engineer/ui_mode.md +45 -0
- package/src/agents/specific_instructions/analytics_engineer/update.md +162 -0
- package/src/agents/specific_instructions/analytics_engineer/validation_checklist.md +121 -0
- package/src/agents/specific_instructions/applied_ml_scientist/advise.md +143 -0
- package/src/agents/specific_instructions/applied_ml_scientist/phases/index.md +21 -0
- package/src/agents/specific_instructions/applied_ml_scientist/phases/phase-1.md +51 -0
- package/src/agents/specific_instructions/applied_ml_scientist/phases/phase-2.md +66 -0
- package/src/agents/specific_instructions/applied_ml_scientist/phases/phase-3.md +113 -0
- package/src/agents/specific_instructions/applied_ml_scientist/phases/phase-4.md +104 -0
- package/src/agents/specific_instructions/applied_ml_scientist/phases/phase-5.md +156 -0
- package/src/agents/specific_instructions/applied_ml_scientist/phases.md +428 -0
- package/src/agents/specific_instructions/applied_ml_scientist/research.md +379 -0
- package/src/agents/specific_instructions/applied_ml_scientist/review.md +142 -0
- package/src/agents/specific_instructions/applied_ml_scientist/validation_checklist.md +136 -0
- package/src/agents/specific_instructions/backend_engineer/clean.md +149 -0
- package/src/agents/specific_instructions/backend_engineer/review.md +91 -0
- package/src/agents/specific_instructions/backend_engineer/review_checklist.md +54 -0
- package/src/agents/specific_instructions/backend_engineer/service_mode.md +67 -0
- package/src/agents/specific_instructions/bi_engineer/advise.md +137 -0
- package/src/agents/specific_instructions/bi_engineer/data_analyst_handoff.md +77 -0
- package/src/agents/specific_instructions/bi_engineer/incoming_handoff.md +45 -0
- package/src/agents/specific_instructions/bi_engineer/phases/index.md +20 -0
- package/src/agents/specific_instructions/bi_engineer/phases/phase-1.md +164 -0
- package/src/agents/specific_instructions/bi_engineer/phases/phase-2.md +92 -0
- package/src/agents/specific_instructions/bi_engineer/phases/phase-3.md +121 -0
- package/src/agents/specific_instructions/bi_engineer/phases/phase-4.md +106 -0
- package/src/agents/specific_instructions/bi_engineer/phases.md +451 -0
- package/src/agents/specific_instructions/bi_engineer/review.md +166 -0
- package/src/agents/specific_instructions/bi_engineer/update.md +147 -0
- package/src/agents/specific_instructions/bi_engineer/validation_checklist.md +124 -0
- package/src/agents/specific_instructions/data_analyst/advise.md +138 -0
- package/src/agents/specific_instructions/data_analyst/explain.md +221 -0
- package/src/agents/specific_instructions/data_analyst/incoming_handoff.md +40 -0
- package/src/agents/specific_instructions/data_analyst/phases/index.md +20 -0
- package/src/agents/specific_instructions/data_analyst/phases/phase-1.md +159 -0
- package/src/agents/specific_instructions/data_analyst/phases/phase-2.md +112 -0
- package/src/agents/specific_instructions/data_analyst/phases/phase-3.md +265 -0
- package/src/agents/specific_instructions/data_analyst/phases/phase-4.md +100 -0
- package/src/agents/specific_instructions/data_analyst/phases.md +501 -0
- package/src/agents/specific_instructions/data_analyst/review.md +138 -0
- package/src/agents/specific_instructions/data_analyst/ui_mode.md +26 -0
- package/src/agents/specific_instructions/data_analyst/update.md +144 -0
- package/src/agents/specific_instructions/data_analyst/validation_checklist.md +95 -0
- package/src/agents/specific_instructions/data_engineer/advise.md +137 -0
- package/src/agents/specific_instructions/data_engineer/phases.md +466 -0
- package/src/agents/specific_instructions/data_engineer/phases_deep/index.md +23 -0
- package/src/agents/specific_instructions/data_engineer/phases_deep/phase-1.md +49 -0
- package/src/agents/specific_instructions/data_engineer/phases_deep/phase-2.md +93 -0
- package/src/agents/specific_instructions/data_engineer/phases_deep/phase-3.md +55 -0
- package/src/agents/specific_instructions/data_engineer/phases_deep/phase-4.md +48 -0
- package/src/agents/specific_instructions/data_engineer/phases_deep/phase-5.md +40 -0
- package/src/agents/specific_instructions/data_engineer/phases_deep/phase-6.md +102 -0
- package/src/agents/specific_instructions/data_engineer/phases_deep/phase-7.md +87 -0
- package/src/agents/specific_instructions/data_engineer/phases_quick/index.md +19 -0
- package/src/agents/specific_instructions/data_engineer/phases_quick/phase-1.md +45 -0
- package/src/agents/specific_instructions/data_engineer/phases_quick/phase-2.md +54 -0
- package/src/agents/specific_instructions/data_engineer/review.md +135 -0
- package/src/agents/specific_instructions/data_engineer/validation_checklist.md +136 -0
- package/src/agents/specific_instructions/data_modeller/advise.md +137 -0
- package/src/agents/specific_instructions/data_modeller/phases.md +581 -0
- package/src/agents/specific_instructions/data_modeller/phases_deep/index.md +23 -0
- package/src/agents/specific_instructions/data_modeller/phases_deep/phase-1.md +52 -0
- package/src/agents/specific_instructions/data_modeller/phases_deep/phase-2.md +113 -0
- package/src/agents/specific_instructions/data_modeller/phases_deep/phase-3.md +47 -0
- package/src/agents/specific_instructions/data_modeller/phases_deep/phase-4.md +51 -0
- package/src/agents/specific_instructions/data_modeller/phases_deep/phase-5.md +45 -0
- package/src/agents/specific_instructions/data_modeller/phases_deep/phase-6.md +105 -0
- package/src/agents/specific_instructions/data_modeller/phases_deep/phase-7.md +136 -0
- package/src/agents/specific_instructions/data_modeller/phases_quick/index.md +19 -0
- package/src/agents/specific_instructions/data_modeller/phases_quick/phase-1.md +47 -0
- package/src/agents/specific_instructions/data_modeller/phases_quick/phase-2.md +65 -0
- package/src/agents/specific_instructions/data_modeller/review.md +141 -0
- package/src/agents/specific_instructions/data_modeller/service_mode.md +218 -0
- package/src/agents/specific_instructions/data_modeller/validation_checklist.md +125 -0
- package/src/agents/specific_instructions/data_scientist/advise.md +158 -0
- package/src/agents/specific_instructions/data_scientist/bi_engineer_handoff.md +63 -0
- package/src/agents/specific_instructions/data_scientist/experiment.md +482 -0
- package/src/agents/specific_instructions/data_scientist/experiment_ui_mode.md +44 -0
- package/src/agents/specific_instructions/data_scientist/explain.md +247 -0
- package/src/agents/specific_instructions/data_scientist/greenfield_data.md +35 -0
- package/src/agents/specific_instructions/data_scientist/ml_engineer_handoff.md +52 -0
- package/src/agents/specific_instructions/data_scientist/notebook_walkthrough.md +76 -0
- package/src/agents/specific_instructions/data_scientist/phases/index.md +24 -0
- package/src/agents/specific_instructions/data_scientist/phases/phase-1.md +45 -0
- package/src/agents/specific_instructions/data_scientist/phases/phase-2.md +67 -0
- package/src/agents/specific_instructions/data_scientist/phases/phase-3.md +89 -0
- package/src/agents/specific_instructions/data_scientist/phases/phase-4.md +143 -0
- package/src/agents/specific_instructions/data_scientist/phases/phase-5.md +71 -0
- package/src/agents/specific_instructions/data_scientist/phases/phase-6.md +239 -0
- package/src/agents/specific_instructions/data_scientist/phases/phase-7.md +207 -0
- package/src/agents/specific_instructions/data_scientist/phases.md +651 -0
- package/src/agents/specific_instructions/data_scientist/research.md +345 -0
- package/src/agents/specific_instructions/data_scientist/research_ui_mode.md +52 -0
- package/src/agents/specific_instructions/data_scientist/review.md +136 -0
- package/src/agents/specific_instructions/data_scientist/service_mode.md +247 -0
- package/src/agents/specific_instructions/data_scientist/validation_checklist.md +183 -0
- package/src/agents/specific_instructions/deep_learning_engineer/advise.md +145 -0
- package/src/agents/specific_instructions/deep_learning_engineer/phases/index.md +21 -0
- package/src/agents/specific_instructions/deep_learning_engineer/phases/phase-1.md +74 -0
- package/src/agents/specific_instructions/deep_learning_engineer/phases/phase-2.md +98 -0
- package/src/agents/specific_instructions/deep_learning_engineer/phases/phase-3.md +76 -0
- package/src/agents/specific_instructions/deep_learning_engineer/phases/phase-4.md +128 -0
- package/src/agents/specific_instructions/deep_learning_engineer/phases/phase-5.md +292 -0
- package/src/agents/specific_instructions/deep_learning_engineer/phases.md +567 -0
- package/src/agents/specific_instructions/deep_learning_engineer/research.md +389 -0
- package/src/agents/specific_instructions/deep_learning_engineer/review.md +155 -0
- package/src/agents/specific_instructions/deep_learning_engineer/validation_checklist.md +147 -0
- package/src/agents/specific_instructions/ml_engineer/advise.md +174 -0
- package/src/agents/specific_instructions/ml_engineer/bi_engineer_handoff.md +71 -0
- package/src/agents/specific_instructions/ml_engineer/experiment.md +474 -0
- package/src/agents/specific_instructions/ml_engineer/experiment_ui_mode.md +44 -0
- package/src/agents/specific_instructions/ml_engineer/notebook_walkthrough.md +75 -0
- package/src/agents/specific_instructions/ml_engineer/phases/index.md +25 -0
- package/src/agents/specific_instructions/ml_engineer/phases/phase-1.md +49 -0
- package/src/agents/specific_instructions/ml_engineer/phases/phase-2.md +75 -0
- package/src/agents/specific_instructions/ml_engineer/phases/phase-3.md +124 -0
- package/src/agents/specific_instructions/ml_engineer/phases/phase-4.md +279 -0
- package/src/agents/specific_instructions/ml_engineer/phases/phase-5.md +160 -0
- package/src/agents/specific_instructions/ml_engineer/phases/phase-6-5.md +170 -0
- package/src/agents/specific_instructions/ml_engineer/phases/phase-6.md +295 -0
- package/src/agents/specific_instructions/ml_engineer/phases/phase-7.md +337 -0
- package/src/agents/specific_instructions/ml_engineer/phases.md +1068 -0
- package/src/agents/specific_instructions/ml_engineer/research.md +437 -0
- package/src/agents/specific_instructions/ml_engineer/research_ui_mode.md +71 -0
- package/src/agents/specific_instructions/ml_engineer/review.md +187 -0
- package/src/agents/specific_instructions/ml_engineer/service_mode.md +273 -0
- package/src/agents/specific_instructions/ml_engineer/validation_checklist.md +185 -0
- package/src/agents/specific_instructions/mlops_engineer/advise.md +139 -0
- package/src/agents/specific_instructions/mlops_engineer/phases/index.md +23 -0
- package/src/agents/specific_instructions/mlops_engineer/phases/phase-1.md +52 -0
- package/src/agents/specific_instructions/mlops_engineer/phases/phase-2.md +86 -0
- package/src/agents/specific_instructions/mlops_engineer/phases/phase-3.md +105 -0
- package/src/agents/specific_instructions/mlops_engineer/phases/phase-4.md +128 -0
- package/src/agents/specific_instructions/mlops_engineer/phases/phase-5.md +106 -0
- package/src/agents/specific_instructions/mlops_engineer/phases/phase-6.md +128 -0
- package/src/agents/specific_instructions/mlops_engineer/phases/phase-7.md +144 -0
- package/src/agents/specific_instructions/mlops_engineer/phases.md +671 -0
- package/src/agents/specific_instructions/mlops_engineer/review.md +164 -0
- package/src/agents/specific_instructions/mlops_engineer/service_mode.md +81 -0
- package/src/agents/specific_instructions/mlops_engineer/validation_checklist.md +151 -0
- package/src/agents/specific_instructions/researcher/critical_review.md +292 -0
- package/src/agents/specific_instructions/researcher/review_checklist.md +67 -0
- package/src/agents/specific_instructions/researcher/service_mode.md +224 -0
- package/src/agents/specific_instructions/shared/auto_verify_mode.md +141 -0
- package/src/agents/specific_instructions/shared/autonomous_research.md +1289 -0
- package/src/agents/specific_instructions/shared/behavioral_rules.md +36 -0
- package/src/agents/specific_instructions/shared/diverge_protocol.md +387 -0
- package/src/agents/specific_instructions/shared/engineering_guidelines.md +136 -0
- package/src/agents/specific_instructions/shared/experiment_versioning.md +184 -0
- package/src/agents/specific_instructions/shared/goal_mode.md +187 -0
- package/src/agents/specific_instructions/shared/incremental_testing.md +139 -0
- package/src/agents/specific_instructions/shared/intent_discovery.md +223 -0
- package/src/agents/specific_instructions/shared/join_path_protocol.md +168 -0
- package/src/agents/specific_instructions/shared/knowledge_checkpoint.md +83 -0
- package/src/agents/specific_instructions/shared/knowledge_harvest.md +220 -0
- package/src/agents/specific_instructions/shared/knowledge_retrieval.md +100 -0
- package/src/agents/specific_instructions/shared/notebook_walkthrough_protocol.md +367 -0
- package/src/agents/specific_instructions/shared/reviewer_verdict_protocol.md +74 -0
- package/src/agents/specific_instructions/shared/swarm_protocol.md +97 -0
- package/src/agents/specific_instructions/shared/validation_protocol.md +139 -0
- package/src/agents/specific_instructions/syn/arbiter.md +140 -0
- package/src/agents/specific_instructions/syn/brainstorm.md +550 -0
- package/src/agents/specific_instructions/syn/code_review.md +232 -0
- package/src/agents/specific_instructions/syn/diff.md +239 -0
- package/src/agents/specific_instructions/syn/final_review.md +65 -0
- package/src/agents/specific_instructions/syn/fixer.md +240 -0
- package/src/agents/specific_instructions/syn/free_form.md +130 -0
- package/src/agents/specific_instructions/syn/knowledge.md +468 -0
- package/src/agents/specific_instructions/syn/notebook_walkthrough.md +78 -0
- package/src/agents/specific_instructions/syn/panel_review.md +634 -0
- package/src/agents/specific_instructions/syn/pm.md +453 -0
- package/src/agents/specific_instructions/syn/pr_review.md +255 -0
- package/src/agents/specific_instructions/syn/slides.md +417 -0
- package/src/agents/syn.md +729 -0
- package/src/commands/academic.md +41 -0
- package/src/commands/ai-engineer.md +45 -0
- package/src/commands/analytics-engineer.md +48 -0
- package/src/commands/applied-ml-scientist.md +45 -0
- package/src/commands/backend-engineer.md +35 -0
- package/src/commands/bi-engineer.md +40 -0
- package/src/commands/brainstorm.md +24 -0
- package/src/commands/data-analyst.md +38 -0
- package/src/commands/data-engineer.md +37 -0
- package/src/commands/data-modeller.md +38 -0
- package/src/commands/data-scientist.md +38 -0
- package/src/commands/deep-learning-engineer.md +47 -0
- package/src/commands/end.md +49 -0
- package/src/commands/knowledge.md +24 -0
- package/src/commands/ml-engineer.md +42 -0
- package/src/commands/mlops-engineer.md +47 -0
- package/src/commands/notebook-walkthrough.md +58 -0
- package/src/commands/researcher.md +40 -0
- package/src/commands/resume.md +57 -0
- package/src/commands/review-pr.md +26 -0
- package/src/commands/shards-guide.md +41 -0
- package/src/commands/shards-ui.md +32 -0
- package/src/commands/shards.md +41 -0
- package/src/docs/01-getting-started/concepts.md +109 -0
- package/src/docs/01-getting-started/first-session.md +79 -0
- package/src/docs/01-getting-started/install.md +61 -0
- package/src/docs/02-agents/academic.md +71 -0
- package/src/docs/02-agents/ai-engineer.md +78 -0
- package/src/docs/02-agents/analytics-engineer.md +58 -0
- package/src/docs/02-agents/applied-ml-scientist.md +59 -0
- package/src/docs/02-agents/backend-engineer.md +58 -0
- package/src/docs/02-agents/bi-engineer.md +65 -0
- package/src/docs/02-agents/data-analyst.md +67 -0
- package/src/docs/02-agents/data-engineer.md +57 -0
- package/src/docs/02-agents/data-modeller.md +51 -0
- package/src/docs/02-agents/data-scientist.md +78 -0
- package/src/docs/02-agents/deep-learning-engineer.md +64 -0
- package/src/docs/02-agents/ml-engineer.md +80 -0
- package/src/docs/02-agents/mlops-engineer.md +59 -0
- package/src/docs/02-agents/overview.md +62 -0
- package/src/docs/02-agents/researcher.md +73 -0
- package/src/docs/02-agents/syn.md +88 -0
- package/src/docs/03-protocols/auto-verify.md +82 -0
- package/src/docs/03-protocols/autonomous-research.md +59 -0
- package/src/docs/03-protocols/behavioral-rules.md +35 -0
- package/src/docs/03-protocols/diverge.md +50 -0
- package/src/docs/03-protocols/engineering-guidelines.md +56 -0
- package/src/docs/03-protocols/experiment-versioning.md +38 -0
- package/src/docs/03-protocols/gate-pattern.md +65 -0
- package/src/docs/03-protocols/incremental-testing.md +68 -0
- package/src/docs/03-protocols/join-path.md +46 -0
- package/src/docs/03-protocols/knowledge-ledger.md +70 -0
- package/src/docs/03-protocols/reviewer-verdicts.md +39 -0
- package/src/docs/03-protocols/swarm.md +40 -0
- package/src/docs/03-protocols/validation.md +174 -0
- package/src/docs/04-ui/activity-bar.md +70 -0
- package/src/docs/04-ui/chat-pane.md +80 -0
- package/src/docs/04-ui/code-intel.md +62 -0
- package/src/docs/04-ui/file-editing.md +61 -0
- package/src/docs/04-ui/git.md +54 -0
- package/src/docs/04-ui/keybindings.md +79 -0
- package/src/docs/04-ui/knowledge-map.md +76 -0
- package/src/docs/04-ui/overview.md +93 -0
- package/src/docs/04-ui/panels.md +49 -0
- package/src/docs/04-ui/pinboard-selection.md +66 -0
- package/src/docs/04-ui/quick-open-palette.md +56 -0
- package/src/docs/04-ui/sessions.md +81 -0
- package/src/docs/04-ui/settings-permissions.md +56 -0
- package/src/docs/05-commands/reference.md +59 -0
- package/src/docs/06-outputs/directory-map.md +116 -0
- package/src/docs/07-workflows/ai-eval-first.md +57 -0
- package/src/docs/07-workflows/deep-study-to-production.md +76 -0
- package/src/docs/07-workflows/diverge-exploration.md +77 -0
- package/src/docs/07-workflows/quick-analysis.md +45 -0
- package/src/docs/08-integrations/claude-code-auto-mode.md +191 -0
- package/src/docs/08-integrations/google-slides.md +175 -0
- package/src/docs/README.md +30 -0
- package/src/docs/manifest.json +108 -0
- package/src/templates/analysis-template.md +20 -0
- package/src/templates/branch-report.md +46 -0
- package/src/templates/diff-report.md +88 -0
- package/src/templates/knowledge-index.md +7 -0
- package/src/templates/model-card-schema.json +186 -0
- package/src/templates/model-card-schema.md +88 -0
- package/src/templates/model-card.md +124 -0
- package/src/templates/project-plan.md +47 -0
- package/src/templates/project-specs.md +81 -0
- package/src/templates/report-template.md +43 -0
- package/src/templates/study-template.md +25 -0
- package/src/ui/cc-readonly.js +181 -0
- package/src/ui/chat-session.js +466 -0
- package/src/ui/css/base.css +136 -0
- package/src/ui/css/brainstorm.css +525 -0
- package/src/ui/css/chat.css +1405 -0
- package/src/ui/css/editor.css +546 -0
- package/src/ui/css/eval-dashboard.css +157 -0
- package/src/ui/css/experiment.css +237 -0
- package/src/ui/css/guide.css +186 -0
- package/src/ui/css/knowledge-map.css +383 -0
- package/src/ui/css/layout.css +431 -0
- package/src/ui/css/model-card.css +161 -0
- package/src/ui/css/notebook-walkthrough.css +271 -0
- package/src/ui/css/pr-review.css +403 -0
- package/src/ui/css/prompt-lab.css +325 -0
- package/src/ui/css/sessions.css +258 -0
- package/src/ui/css/sidebar.css +661 -0
- package/src/ui/css/terminal.css +113 -0
- package/src/ui/css/theme-light.css +542 -0
- package/src/ui/index.html +389 -0
- package/src/ui/js/agents.js +32 -0
- package/src/ui/js/bookmarks.js +230 -0
- package/src/ui/js/chat.js +1776 -0
- package/src/ui/js/code-intel.js +328 -0
- package/src/ui/js/command-palette.js +142 -0
- package/src/ui/js/events.js +591 -0
- package/src/ui/js/explorer.js +317 -0
- package/src/ui/js/file-view.js +477 -0
- package/src/ui/js/git.js +536 -0
- package/src/ui/js/guide.js +198 -0
- package/src/ui/js/hud.js +75 -0
- package/src/ui/js/init.js +351 -0
- package/src/ui/js/knowledge-map.js +906 -0
- package/src/ui/js/markdown.js +114 -0
- package/src/ui/js/monaco.js +164 -0
- package/src/ui/js/notebook-walkthrough.js +272 -0
- package/src/ui/js/notebook.js +448 -0
- package/src/ui/js/panels.js +2681 -0
- package/src/ui/js/pinboard.js +186 -0
- package/src/ui/js/quick-open.js +164 -0
- package/src/ui/js/selection-context.js +131 -0
- package/src/ui/js/sessions.js +256 -0
- package/src/ui/js/settings.js +476 -0
- package/src/ui/js/split-view.js +82 -0
- package/src/ui/js/state.js +343 -0
- package/src/ui/js/table.js +161 -0
- package/src/ui/js/tabs.js +284 -0
- package/src/ui/js/tabular.js +125 -0
- package/src/ui/js/terminal.js +354 -0
- package/src/ui/js/timeline.js +137 -0
- package/src/ui/js/utils.js +293 -0
- package/src/ui/notebook-kernel.py +790 -0
- package/src/ui/open-browser.js +55 -0
- package/src/ui/permission-pattern.js +42 -0
- package/src/ui/relay.js +513 -0
- package/src/ui/server.js +3072 -0
- package/src/ui/session-index.js +225 -0
- package/src/ui/shards_icon.png +0 -0
- package/src/ui/spawn-server.js +41 -0
- package/src/ui/symbol-index.js +813 -0
- package/src/ui/ui-push.js +177 -0
- package/tools/gate-hook/VALIDATION_SPEC.md +273 -0
- package/tools/gate-hook/__tests__/auto-verify.test.js +343 -0
- package/tools/gate-hook/auto-allowlist.js +179 -0
- package/tools/gate-hook/auto-state.js +68 -0
- package/tools/gate-hook/classify.js +21 -0
- package/tools/gate-hook/log.js +57 -0
- package/tools/gate-hook/parser.js +205 -0
- package/tools/gate-hook/sql-guard.js +230 -0
- package/tools/gate-hook/state.js +170 -0
- package/tools/gate-hook/sweep.js +139 -0
- package/tools/gate-hook/transcript.js +45 -0
- package/tools/gate-hook/validation.js +321 -0
- package/tools/gate-hook.js +475 -0
- package/tools/install.js +914 -0
- package/tools/shards-gates.js +311 -0
- package/tools/shards-sessions.js +261 -0
- package/tools/shards-ui.js +377 -0
package/src/ui/server.js
ADDED
|
@@ -0,0 +1,3072 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
'use strict';
|
|
4
|
+
|
|
5
|
+
const http = require('http');
|
|
6
|
+
const https = require('https');
|
|
7
|
+
const fs = require('fs');
|
|
8
|
+
const path = require('path');
|
|
9
|
+
const os = require('os');
|
|
10
|
+
const { execSync, execFileSync } = require('child_process');
|
|
11
|
+
const { randomUUID } = require('crypto');
|
|
12
|
+
const { ChatSession, reconnectOrCleanup } = require('./chat-session');
|
|
13
|
+
const symbolIndex = require('./symbol-index');
|
|
14
|
+
const { permissionPattern } = require('./permission-pattern');
|
|
15
|
+
const { isCcReadOnlyAutoApprovable } = require('./cc-readonly');
|
|
16
|
+
const sessionIndex = require('./session-index');
|
|
17
|
+
|
|
18
|
+
let PROJECT_DIR = process.cwd();
|
|
19
|
+
let SHARDS_DIR = path.join(PROJECT_DIR, '.shards');
|
|
20
|
+
let PORT_FILE = path.join(SHARDS_DIR, 'ui.port');
|
|
21
|
+
let PID_FILE = path.join(SHARDS_DIR, 'ui.pid');
|
|
22
|
+
let LOG_FILE = path.join(SHARDS_DIR, 'ui.log');
|
|
23
|
+
let AGENTS_DIR = path.join(PROJECT_DIR, '.claude', 'agents');
|
|
24
|
+
let COMMANDS_DIR = path.join(PROJECT_DIR, '.claude', 'commands');
|
|
25
|
+
let SESSIONS_DIR = path.join(SHARDS_DIR, 'sessions');
|
|
26
|
+
let KNOWLEDGE_DIR = path.join(SHARDS_DIR, 'knowledge');
|
|
27
|
+
let INDEX_HTML = path.join(__dirname, 'index.html');
|
|
28
|
+
|
|
29
|
+
function initPaths(projectDir, options = {}) {
|
|
30
|
+
PROJECT_DIR = projectDir;
|
|
31
|
+
SHARDS_DIR = path.join(PROJECT_DIR, '.shards');
|
|
32
|
+
PORT_FILE = path.join(SHARDS_DIR, 'ui.port');
|
|
33
|
+
PID_FILE = path.join(SHARDS_DIR, 'ui.pid');
|
|
34
|
+
LOG_FILE = path.join(SHARDS_DIR, 'ui.log');
|
|
35
|
+
AGENTS_DIR = path.join(PROJECT_DIR, '.claude', 'agents');
|
|
36
|
+
COMMANDS_DIR = path.join(PROJECT_DIR, '.claude', 'commands');
|
|
37
|
+
SESSIONS_DIR = path.join(SHARDS_DIR, 'sessions');
|
|
38
|
+
KNOWLEDGE_DIR = path.join(SHARDS_DIR, 'knowledge');
|
|
39
|
+
const uiDir = options.uiDir || __dirname;
|
|
40
|
+
INDEX_HTML = path.join(uiDir, 'index.html');
|
|
41
|
+
ELECTRON_MODE = !!options.electronMode;
|
|
42
|
+
VENDOR_DIR = options.vendorDir || null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
let ELECTRON_MODE = false;
|
|
46
|
+
let VENDOR_DIR = null;
|
|
47
|
+
|
|
48
|
+
// ─── Logging ─────────────────────────────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
function log(msg) {
|
|
51
|
+
const ts = new Date().toISOString();
|
|
52
|
+
const line = `[${ts}] ${msg}\n`;
|
|
53
|
+
try { fs.appendFileSync(LOG_FILE, line); } catch {}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const PORTS = [7842, 7843, 7844, 7845];
|
|
57
|
+
const OUTPUT_DIRS = ['analysis', 'studies', 'models', 'services', 'research', 'dashboards', 'brainstorm', 'data_models', 'experiments'];
|
|
58
|
+
|
|
59
|
+
// ─── P1: Auth token ──────────────────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
const AUTH_TOKEN = randomUUID();
|
|
62
|
+
|
|
63
|
+
// ─── P5: TLS configuration ──────────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
const TLS_CERT = process.env.SHARDS_TLS_CERT || null;
|
|
66
|
+
const TLS_KEY = process.env.SHARDS_TLS_KEY || null;
|
|
67
|
+
const BIND_ADDR = process.env.SHARDS_BIND || '127.0.0.1';
|
|
68
|
+
const TRUST_PROXY = process.env.SHARDS_TRUST_PROXY === '1';
|
|
69
|
+
|
|
70
|
+
// ─── File-poll guards (see pollFiles / scanDir) ─────────────────────────────
|
|
71
|
+
|
|
72
|
+
const POLL_MAX_BYTES = Number(process.env.SHARDS_POLL_MAX_BYTES) || 2 * 1024 * 1024; // 2 MB
|
|
73
|
+
const POLL_SKIP_EXT = new Set([
|
|
74
|
+
// Binary data formats
|
|
75
|
+
'.pkl', '.pickle', '.parquet', '.feather', '.h5', '.hdf5',
|
|
76
|
+
'.npy', '.npz', '.arrow', '.orc',
|
|
77
|
+
// Archives
|
|
78
|
+
'.zip', '.gz', '.tgz', '.tar', '.bz2', '.xz', '.7z',
|
|
79
|
+
// Compiled / opaque
|
|
80
|
+
'.so', '.dylib', '.dll', '.exe', '.bin',
|
|
81
|
+
// Images & PDFs (served via /browse/file/raw, no need to broadcast)
|
|
82
|
+
'.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.ico', '.svg', '.pdf',
|
|
83
|
+
// Media
|
|
84
|
+
'.mp4', '.mov', '.mp3', '.wav',
|
|
85
|
+
]);
|
|
86
|
+
const pollSkipWarned = new Set();
|
|
87
|
+
|
|
88
|
+
// ─── P3: Session Store ──────────────────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
// Output dirs Shards specialists write under — used to derive a project slug
|
|
91
|
+
// from a touched file path (e.g. analysis/churn-q4/queries.sql → "churn-q4").
|
|
92
|
+
const PROJECT_OUTPUT_DIRS = [
|
|
93
|
+
'analysis', 'studies', 'models', 'services', 'data_models', 'research',
|
|
94
|
+
'dashboards', 'brainstorm', 'experiments', 'fixes', 'presentations', 'projects',
|
|
95
|
+
];
|
|
96
|
+
const PROJECT_PATH_RE = new RegExp(`^(${PROJECT_OUTPUT_DIRS.join('|')})/([^/]+)/`);
|
|
97
|
+
|
|
98
|
+
const AGENT_META = {
|
|
99
|
+
'syn': { color: '#FFD700', label: 'Syn (Orchestrator)', category: 'route', keywords: [] },
|
|
100
|
+
'data-analyst': { color: '#4CAF50', label: 'Data Analyst', category: 'analytics', keywords: ['sql', 'query', 'adhoc'] },
|
|
101
|
+
'data-scientist': { color: '#2196F3', label: 'Data Scientist', category: 'data', keywords: ['eda', 'exploratory', 'statistical'] },
|
|
102
|
+
'data-engineer': { color: '#FF9800', label: 'Data Engineer', category: 'data', keywords: ['pipeline', 'etl', 'warehouse'] },
|
|
103
|
+
'data-modeller': { color: '#00BCD4', label: 'Data Modeller', category: 'data', keywords: ['schema', 'erd', 'dimensional'] },
|
|
104
|
+
'analytics-engineer': { color: '#8BC34A', label: 'Analytics Engineer', category: 'data', keywords: ['dbt', 'transform', 'staging'] },
|
|
105
|
+
'ml-engineer': { color: '#F44336', label: 'ML Engineer', category: 'mlai', keywords: ['recommender', 'classification', 'production ml'] },
|
|
106
|
+
'ai-engineer': { color: '#9C27B0', label: 'AI Engineer', category: 'mlai', keywords: ['llm', 'rag', 'prompt'] },
|
|
107
|
+
'applied-ml-scientist': { color: '#673AB7', label: 'Applied ML Scientist', category: 'mlai', keywords: ['novel', 'research', 'architecture'] },
|
|
108
|
+
'deep-learning-engineer': { color: '#03A9F4', label: 'Deep Learning Engineer', category: 'mlai', keywords: ['neural', 'pytorch', 'transformer'] },
|
|
109
|
+
'mlops-engineer': { color: '#FF5722', label: 'MLOps Engineer', category: 'mlai', keywords: ['deploy', 'serving', 'monitoring'] },
|
|
110
|
+
'bi-engineer': { color: '#E91E63', label: 'BI Engineer', category: 'analytics', keywords: ['dashboard', 'visualization', 'streamlit'] },
|
|
111
|
+
'backend-engineer': { color: '#9E9E9E', label: 'Backend Engineer', category: 'review', keywords: ['python', 'fastapi', 'code review'] },
|
|
112
|
+
'researcher': { color: '#795548', label: 'Researcher', category: 'review', keywords: ['statistics', 'methodology'] },
|
|
113
|
+
'academic': { color: '#607D8B', label: 'Academic', category: 'review', keywords: ['safety', 'ethics', 'bias'] },
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
function detectProjectFromRelPath(relPath) {
|
|
117
|
+
if (!relPath || relPath.startsWith('..')) return null;
|
|
118
|
+
const norm = relPath.replace(/\\/g, '/');
|
|
119
|
+
const m = norm.match(PROJECT_PATH_RE);
|
|
120
|
+
if (!m) return null;
|
|
121
|
+
return { projectDir: `${m[1]}/${m[2]}`, projectName: m[2] };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Read the gate state file and reduce it to a (phase, gateOpenAtEnd) snapshot
|
|
125
|
+
// suitable for stamping into the session index. We treat the "current phase"
|
|
126
|
+
// as: the open gate's phase if one is open, otherwise the most recent closed
|
|
127
|
+
// gate's phase. Returns nulls if there's no gate activity to report.
|
|
128
|
+
function readGateSnapshot(shardsDir) {
|
|
129
|
+
const file = path.join(shardsDir, 'gates', 'state.json');
|
|
130
|
+
try {
|
|
131
|
+
const raw = fs.readFileSync(file, 'utf8');
|
|
132
|
+
const state = JSON.parse(raw);
|
|
133
|
+
// Reduce to the single most-recent open gate. Handles both the legacy
|
|
134
|
+
// single-slot shape ({ open, ... }) and the v2 per-session shape
|
|
135
|
+
// ({ version: 2, sessions: { ... } }).
|
|
136
|
+
const open = reduceOpenGate(state);
|
|
137
|
+
if (open) {
|
|
138
|
+
return { phase: typeof open.phase === 'number' ? open.phase : null, gateOpenAtEnd: open.id || null };
|
|
139
|
+
}
|
|
140
|
+
const history = Array.isArray(state && state.history) ? state.history : [];
|
|
141
|
+
for (let i = history.length - 1; i >= 0; i--) {
|
|
142
|
+
const h = history[i];
|
|
143
|
+
if (h && typeof h.phase === 'number') return { phase: h.phase, gateOpenAtEnd: null };
|
|
144
|
+
}
|
|
145
|
+
return { phase: null, gateOpenAtEnd: null };
|
|
146
|
+
} catch {
|
|
147
|
+
return { phase: null, gateOpenAtEnd: null };
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
class SessionStore {
|
|
152
|
+
constructor({ sessionId, agent }) {
|
|
153
|
+
this.sessionId = sessionId;
|
|
154
|
+
this.agent = agent;
|
|
155
|
+
this.title = null;
|
|
156
|
+
this.transcript = [];
|
|
157
|
+
this.sessionFiles = new Set();
|
|
158
|
+
this.chatSession = null;
|
|
159
|
+
this.createdAt = new Date();
|
|
160
|
+
this.lastActivityAt = new Date();
|
|
161
|
+
this.projectName = null;
|
|
162
|
+
this.projectDir = null;
|
|
163
|
+
this.contextUsage = null;
|
|
164
|
+
|
|
165
|
+
// Attempt to load existing session data from disk
|
|
166
|
+
try {
|
|
167
|
+
const filePath = path.join(SESSIONS_DIR, `${sessionId}-transcript.json`);
|
|
168
|
+
if (fs.existsSync(filePath)) {
|
|
169
|
+
const data = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
170
|
+
if (data.transcript) this.transcript = data.transcript;
|
|
171
|
+
if (data.title) this.title = data.title;
|
|
172
|
+
if (data.createdAt) this.createdAt = new Date(data.createdAt);
|
|
173
|
+
if (data.lastActivityAt) this.lastActivityAt = new Date(data.lastActivityAt);
|
|
174
|
+
if (data.projectName) this.projectName = data.projectName;
|
|
175
|
+
if (data.projectDir) this.projectDir = data.projectDir;
|
|
176
|
+
if (data.contextUsage) this.contextUsage = data.contextUsage;
|
|
177
|
+
}
|
|
178
|
+
} catch (e) {
|
|
179
|
+
// Ignore errors loading session data — starts fresh
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
touch() {
|
|
184
|
+
this.lastActivityAt = new Date();
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Returns true if projectName/projectDir changed (caller should broadcast).
|
|
188
|
+
setProjectFromRelPath(relPath) {
|
|
189
|
+
if (this.projectName) return false;
|
|
190
|
+
const detected = detectProjectFromRelPath(relPath);
|
|
191
|
+
if (!detected) return false;
|
|
192
|
+
this.projectName = detected.projectName;
|
|
193
|
+
this.projectDir = detected.projectDir;
|
|
194
|
+
return true;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
save() {
|
|
198
|
+
try {
|
|
199
|
+
if (!fs.existsSync(SESSIONS_DIR)) fs.mkdirSync(SESSIONS_DIR, { recursive: true });
|
|
200
|
+
const filePath = path.join(SESSIONS_DIR, `${this.sessionId}-transcript.json`);
|
|
201
|
+
fs.writeFileSync(filePath, JSON.stringify({
|
|
202
|
+
sessionId: this.sessionId,
|
|
203
|
+
agent: this.agent,
|
|
204
|
+
title: this.title,
|
|
205
|
+
transcript: this.transcript,
|
|
206
|
+
createdAt: this.createdAt,
|
|
207
|
+
lastActivityAt: this.lastActivityAt,
|
|
208
|
+
projectName: this.projectName,
|
|
209
|
+
projectDir: this.projectDir,
|
|
210
|
+
contextUsage: this.contextUsage,
|
|
211
|
+
}, null, 2));
|
|
212
|
+
} catch (err) {
|
|
213
|
+
log(`Error saving session ${this.sessionId}: ${err.message}`);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const sessions = new Map(); // sessionId -> SessionStore
|
|
219
|
+
|
|
220
|
+
function getSession(sessionId) {
|
|
221
|
+
const s = sessions.get(sessionId);
|
|
222
|
+
if (s) s.touch();
|
|
223
|
+
return s || null;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// ─── Permission request store ────────────────────────────────────────────────
|
|
227
|
+
|
|
228
|
+
const permissionRequests = new Map();
|
|
229
|
+
// id → { id, tool, command, sessionId, decision: null|'allow'|'deny', createdAt }
|
|
230
|
+
|
|
231
|
+
// Pending entries used to be retained indefinitely "in case the user comes
|
|
232
|
+
// back to decide" — but a permission card whose browser session ended (closed
|
|
233
|
+
// tab, refresh, abandoned chat) is never going to be decided. The relay
|
|
234
|
+
// polling that entry will sit in `pending` for its full poll timeout
|
|
235
|
+
// (SHARDS_UI_PERMISSION_TIMEOUT_MS, default 10 min) and the entry itself never
|
|
236
|
+
// frees. Cleanup now removes pending entries that have exceeded the relay's
|
|
237
|
+
// poll timeout — by then the relay has already fallen through to CC's native
|
|
238
|
+
// prompt, so the entry serves no purpose. Decided entries still age out at
|
|
239
|
+
// the 5-minute mark.
|
|
240
|
+
const DECIDED_TTL_MS = 5 * 60 * 1000;
|
|
241
|
+
const PENDING_TTL_MS = 11 * 60 * 1000; // relay default + 1min buffer
|
|
242
|
+
setInterval(() => {
|
|
243
|
+
const now = Date.now();
|
|
244
|
+
for (const [id, req] of permissionRequests) {
|
|
245
|
+
const ttl = req.decision === null ? PENDING_TTL_MS : DECIDED_TTL_MS;
|
|
246
|
+
if (now - req.createdAt > ttl) permissionRequests.delete(id);
|
|
247
|
+
}
|
|
248
|
+
}, 60_000);
|
|
249
|
+
|
|
250
|
+
// ─── Settings cache for permission fast paths ───────────────────────────────
|
|
251
|
+
|
|
252
|
+
const READONLY_PRESET = [
|
|
253
|
+
// Git read-only
|
|
254
|
+
'Bash(git log:*)', 'Bash(git status:*)', 'Bash(git diff:*)',
|
|
255
|
+
'Bash(git branch:*)', 'Bash(git show:*)', 'Bash(git rev-parse:*)',
|
|
256
|
+
'Bash(git remote:*)', 'Bash(git tag:*)', 'Bash(git stash list:*)',
|
|
257
|
+
// File exploration
|
|
258
|
+
'Bash(find:*)', 'Bash(ls:*)', 'Bash(tree:*)', 'Bash(file:*)',
|
|
259
|
+
'Bash(stat:*)', 'Bash(du:*)', 'Bash(df:*)',
|
|
260
|
+
// File reading
|
|
261
|
+
'Bash(cat:*)', 'Bash(head:*)', 'Bash(tail:*)', 'Bash(less:*)',
|
|
262
|
+
// Search
|
|
263
|
+
'Bash(grep:*)', 'Bash(rg:*)', 'Bash(ag:*)', 'Bash(fzf:*)',
|
|
264
|
+
// Inspection
|
|
265
|
+
'Bash(wc:*)', 'Bash(echo:*)', 'Bash(pwd:*)', 'Bash(which:*)',
|
|
266
|
+
'Bash(whoami:*)', 'Bash(env:*)', 'Bash(printenv:*)',
|
|
267
|
+
'Bash(type:*)', 'Bash(command -v:*)', 'Bash(uname:*)',
|
|
268
|
+
// Data inspection
|
|
269
|
+
'Bash(sort:*)', 'Bash(uniq:*)', 'Bash(cut:*)', 'Bash(awk:*)',
|
|
270
|
+
'Bash(diff:*)', 'Bash(comm:*)', 'Bash(jq:*)',
|
|
271
|
+
];
|
|
272
|
+
|
|
273
|
+
let settingsCache = { allow: [], deny: [] };
|
|
274
|
+
|
|
275
|
+
function loadSettingsCache() {
|
|
276
|
+
let allow = [];
|
|
277
|
+
let deny = [];
|
|
278
|
+
let hasSettingsJson = false;
|
|
279
|
+
|
|
280
|
+
// Read .claude/settings.json (project-level, managed by UI)
|
|
281
|
+
try {
|
|
282
|
+
const raw = JSON.parse(fs.readFileSync(
|
|
283
|
+
path.join(PROJECT_DIR, '.claude', 'settings.json'), 'utf8'
|
|
284
|
+
));
|
|
285
|
+
allow = (raw.permissions && raw.permissions.allow) || [];
|
|
286
|
+
deny = (raw.permissions && raw.permissions.deny) || [];
|
|
287
|
+
hasSettingsJson = true;
|
|
288
|
+
} catch {}
|
|
289
|
+
|
|
290
|
+
// Merge .claude/settings.local.json (user-level, managed by Claude Code)
|
|
291
|
+
try {
|
|
292
|
+
const local = JSON.parse(fs.readFileSync(
|
|
293
|
+
path.join(PROJECT_DIR, '.claude', 'settings.local.json'), 'utf8'
|
|
294
|
+
));
|
|
295
|
+
const localAllow = (local.permissions && local.permissions.allow) || [];
|
|
296
|
+
const localDeny = (local.permissions && local.permissions.deny) || [];
|
|
297
|
+
for (const entry of localAllow) {
|
|
298
|
+
if (!allow.includes(entry)) allow.push(entry);
|
|
299
|
+
}
|
|
300
|
+
for (const entry of localDeny) {
|
|
301
|
+
if (!deny.includes(entry)) deny.push(entry);
|
|
302
|
+
}
|
|
303
|
+
} catch {}
|
|
304
|
+
|
|
305
|
+
// Fall back to readonly preset when no settings.json exists
|
|
306
|
+
if (!hasSettingsJson && allow.length === 0) {
|
|
307
|
+
allow = [...READONLY_PRESET];
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
settingsCache = { allow, deny };
|
|
311
|
+
}
|
|
312
|
+
loadSettingsCache();
|
|
313
|
+
|
|
314
|
+
// Ensure the PreToolUse hook exists — without it, non-whitelisted Bash commands
|
|
315
|
+
// are denied by Claude Code's internal permission system in stream-json mode
|
|
316
|
+
// and never reach the browser permission card flow.
|
|
317
|
+
//
|
|
318
|
+
// Hook timeout is in seconds. Default Claude Code hook timeout is 600s;
|
|
319
|
+
// we extend to 1800s (30 min) so users have a realistic window to respond
|
|
320
|
+
// to a permission card before Claude Code falls back to its internal rules.
|
|
321
|
+
const PRETOOL_HOOK_TIMEOUT_SEC = 1800;
|
|
322
|
+
|
|
323
|
+
function ensurePreToolUseHook() {
|
|
324
|
+
const settingsPath = path.join(PROJECT_DIR, '.claude', 'settings.json');
|
|
325
|
+
let settings = {};
|
|
326
|
+
try { settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); } catch {}
|
|
327
|
+
|
|
328
|
+
if (!settings.hooks) settings.hooks = {};
|
|
329
|
+
if (!settings.hooks.PreToolUse) settings.hooks.PreToolUse = [];
|
|
330
|
+
|
|
331
|
+
const relayPath = path.join(__dirname, 'relay.js');
|
|
332
|
+
let mutated = false;
|
|
333
|
+
|
|
334
|
+
// Find an existing relay entry if any, and patch its timeout in place
|
|
335
|
+
// so older installations pick up the longer window without manual edits.
|
|
336
|
+
let hasRelay = false;
|
|
337
|
+
for (const entry of settings.hooks.PreToolUse) {
|
|
338
|
+
if (!entry.hooks) continue;
|
|
339
|
+
for (const h of entry.hooks) {
|
|
340
|
+
if (h.command && h.command.includes('relay.js')) {
|
|
341
|
+
hasRelay = true;
|
|
342
|
+
if (h.timeout !== PRETOOL_HOOK_TIMEOUT_SEC) {
|
|
343
|
+
h.timeout = PRETOOL_HOOK_TIMEOUT_SEC;
|
|
344
|
+
mutated = true;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
if (!hasRelay) {
|
|
351
|
+
settings.hooks.PreToolUse.push({
|
|
352
|
+
matcher: 'Bash',
|
|
353
|
+
hooks: [{
|
|
354
|
+
type: 'command',
|
|
355
|
+
command: `node ${relayPath} pre-tool-use`,
|
|
356
|
+
timeout: PRETOOL_HOOK_TIMEOUT_SEC,
|
|
357
|
+
}],
|
|
358
|
+
});
|
|
359
|
+
mutated = true;
|
|
360
|
+
console.log('[shards-ui] Added missing PreToolUse hook to settings.json');
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
if (mutated) {
|
|
364
|
+
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
ensurePreToolUseHook();
|
|
368
|
+
|
|
369
|
+
function matchesPermission(command, patterns) {
|
|
370
|
+
for (const pattern of patterns) {
|
|
371
|
+
const m = pattern.match(/^Bash\((.+)\)$/);
|
|
372
|
+
if (!m) continue;
|
|
373
|
+
const glob = m[1];
|
|
374
|
+
// The colon in Bash(cmd:*) is Claude Code's delimiter between command prefix
|
|
375
|
+
// and argument glob — replace with a pattern matching space-or-end-of-string
|
|
376
|
+
const regex = new RegExp('^' + glob
|
|
377
|
+
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
|
378
|
+
.replace(/:/, '( |$)')
|
|
379
|
+
.replace(/\*/g, '.*') + '$');
|
|
380
|
+
if (regex.test(command)) return true;
|
|
381
|
+
}
|
|
382
|
+
return false;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function persistPermission(command, decision) {
|
|
386
|
+
const settingsPath = path.join(PROJECT_DIR, '.claude', 'settings.json');
|
|
387
|
+
let settings = {};
|
|
388
|
+
try { settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); } catch {}
|
|
389
|
+
|
|
390
|
+
if (!settings.permissions) settings.permissions = {};
|
|
391
|
+
const key = decision === 'allow' ? 'allow' : 'deny';
|
|
392
|
+
if (!settings.permissions[key]) settings.permissions[key] = [];
|
|
393
|
+
|
|
394
|
+
// Persist as a prefix glob (Bash(cmd:*)) rather than the literal command, so
|
|
395
|
+
// clicking Always Allow on `python3 foo.py` also covers `python3 bar.py`.
|
|
396
|
+
// For launcher commands (git, dbt, npm, …) the subcommand is included in the
|
|
397
|
+
// prefix — Bash(git status:*) — so scope doesn't widen to every git call.
|
|
398
|
+
const entry = permissionPattern(command);
|
|
399
|
+
if (!settings.permissions[key].includes(entry)) {
|
|
400
|
+
settings.permissions[key].push(entry);
|
|
401
|
+
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
|
|
402
|
+
loadSettingsCache();
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// ─── State ───────────────────────────────────────────────────────────────────
|
|
407
|
+
|
|
408
|
+
let clients = []; // SSE response objects: { res, sessionId (optional filter) }
|
|
409
|
+
let files = {}; // relPath -> content
|
|
410
|
+
let panelSources = {}; // panelId -> { filePath, panel, title, agent, lastContent }
|
|
411
|
+
let panelWatchers = {}; // panelId -> fs.FSWatcher
|
|
412
|
+
|
|
413
|
+
// Dedicated SSE channel for gate-state / gate-block / auto-state events.
|
|
414
|
+
// Kept separate from the main `clients` channel so subscribers that only care
|
|
415
|
+
// about gate transitions don't pay the cost of decoding every tool / file
|
|
416
|
+
// broadcast. Each entry: { res } — no per-session filtering since gates are
|
|
417
|
+
// project-scoped.
|
|
418
|
+
let gateClients = [];
|
|
419
|
+
|
|
420
|
+
// ─── Gate-mode env vars ─────────────────────────────────────────────────────
|
|
421
|
+
// Captured at server startup. The Claude Code hook processes spawn in their
|
|
422
|
+
// own env (which may or may not inherit these), but the UI server can only
|
|
423
|
+
// honestly report what *it* sees. If the user disables enforcement only in
|
|
424
|
+
// the CC side without also exporting it to the UI server, the banner won't
|
|
425
|
+
// fire — documented in the /gate-mode handler below.
|
|
426
|
+
const GATE_ENFORCE = process.env.SHARDS_GATE_ENFORCE !== '0';
|
|
427
|
+
const CHECKPOINT_ENFORCE = process.env.SHARDS_CHECKPOINT_ENFORCE !== '0';
|
|
428
|
+
const AUTO_VERIFY = process.env.SHARDS_AUTO_VERIFY !== '0';
|
|
429
|
+
|
|
430
|
+
// ─── Broadcast ───────────────────────────────────────────────────────────────
|
|
431
|
+
|
|
432
|
+
function broadcast(event) {
|
|
433
|
+
let data;
|
|
434
|
+
try {
|
|
435
|
+
data = `data: ${JSON.stringify(event)}\n\n`;
|
|
436
|
+
} catch (err) {
|
|
437
|
+
const type = event && event.type;
|
|
438
|
+
const path = event && event.path;
|
|
439
|
+
console.error(`broadcast: failed to serialize event type=${type} path=${path}: ${err.message}`);
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
clients = clients.filter((client) => {
|
|
443
|
+
// If client subscribed to a specific session, only send matching events
|
|
444
|
+
if (client.sessionId && event.sessionId && client.sessionId !== event.sessionId) {
|
|
445
|
+
return true; // keep client, just skip this event
|
|
446
|
+
}
|
|
447
|
+
try {
|
|
448
|
+
client.res.write(data);
|
|
449
|
+
return true;
|
|
450
|
+
} catch {
|
|
451
|
+
return false;
|
|
452
|
+
}
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// ─── Gate SSE broadcast ──────────────────────────────────────────────────────
|
|
457
|
+
//
|
|
458
|
+
// Broadcasts gate-related events (gate-state changes, gate-block reasons,
|
|
459
|
+
// auto-state changes) on a dedicated SSE channel. Replaces the 2s polling the
|
|
460
|
+
// browser previously did on /gate-state — see Bug H2 in the integration audit.
|
|
461
|
+
|
|
462
|
+
function broadcastGate(event) {
|
|
463
|
+
let data;
|
|
464
|
+
try {
|
|
465
|
+
data = `data: ${JSON.stringify(event)}\n\n`;
|
|
466
|
+
} catch (err) {
|
|
467
|
+
log(`broadcastGate: failed to serialize event type=${event && event.type}: ${err.message}`);
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
gateClients = gateClients.filter((client) => {
|
|
471
|
+
try { client.res.write(data); return true; } catch { return false; }
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// ─── Gate / auto / violations watchers ──────────────────────────────────────
|
|
476
|
+
//
|
|
477
|
+
// fs.watch on the JSON state files (plus violations log). We debounce by 50ms
|
|
478
|
+
// to coalesce mid-write events (atomic-write replaces emit two change events
|
|
479
|
+
// in quick succession on most platforms) and ignore JSON.parse errors so the
|
|
480
|
+
// watcher survives a half-written file — the next change event will retry.
|
|
481
|
+
|
|
482
|
+
const GATE_STATE_FILE = () => path.join(SHARDS_DIR, 'gates', 'state.json');
|
|
483
|
+
const AUTO_STATE_FILE = () => path.join(SHARDS_DIR, 'auto', 'state.json');
|
|
484
|
+
const VIOLATIONS_FILE = () => path.join(SHARDS_DIR, 'gates', 'violations.jsonl');
|
|
485
|
+
|
|
486
|
+
let _gateWatchers = [];
|
|
487
|
+
let _gateStateDebounce = null;
|
|
488
|
+
let _autoStateDebounce = null;
|
|
489
|
+
let _violationsDebounce = null;
|
|
490
|
+
let _violationsOffset = 0;
|
|
491
|
+
|
|
492
|
+
// Reduce a parsed gate-state object to its single most-recent OPEN gate, or
|
|
493
|
+
// null if none is open. Handles the legacy single-slot shape ({ open, id, ...})
|
|
494
|
+
// and the v2 per-session shape ({ version: 2, sessions: { "<id>": {...} } }).
|
|
495
|
+
function reduceOpenGate(state) {
|
|
496
|
+
if (!state || typeof state !== 'object') return null;
|
|
497
|
+
// v2 per-session map.
|
|
498
|
+
if (state.sessions && typeof state.sessions === 'object') {
|
|
499
|
+
let best = null;
|
|
500
|
+
let bestT = -Infinity;
|
|
501
|
+
for (const g of Object.values(state.sessions)) {
|
|
502
|
+
if (!g || !g.open) continue;
|
|
503
|
+
const t = g.opened_at ? Date.parse(g.opened_at) : NaN;
|
|
504
|
+
const score = Number.isNaN(t) ? -Infinity : t;
|
|
505
|
+
if (best === null || score > bestT) { best = g; bestT = score; }
|
|
506
|
+
}
|
|
507
|
+
return best;
|
|
508
|
+
}
|
|
509
|
+
// Legacy single slot.
|
|
510
|
+
return state.open ? state : null;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// Reduce a parsed gate-state object to the flat shape the browser expects:
|
|
514
|
+
// { open, id, phase, kind, agent, opened_at, history }. The v2 per-session map
|
|
515
|
+
// collapses to the single most-recent open gate; `history` is always the shared
|
|
516
|
+
// top-level array. This preserves the browser-facing JSON contract unchanged.
|
|
517
|
+
function reduceGateState(state) {
|
|
518
|
+
const history = Array.isArray(state && state.history) ? state.history : [];
|
|
519
|
+
const open = reduceOpenGate(state);
|
|
520
|
+
if (!open) return { open: false, history };
|
|
521
|
+
return {
|
|
522
|
+
open: true,
|
|
523
|
+
id: open.id || null,
|
|
524
|
+
phase: typeof open.phase !== 'undefined' ? open.phase : null,
|
|
525
|
+
kind: open.kind || null,
|
|
526
|
+
agent: open.agent || null,
|
|
527
|
+
opened_at: open.opened_at || null,
|
|
528
|
+
history,
|
|
529
|
+
};
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// Read state.json and reduce it to the flat browser contract. Returns null only
|
|
533
|
+
// when the file is unreadable (caller substitutes a closed default).
|
|
534
|
+
function readGateStateFile() {
|
|
535
|
+
try {
|
|
536
|
+
const raw = fs.readFileSync(GATE_STATE_FILE(), 'utf8');
|
|
537
|
+
return reduceGateState(JSON.parse(raw));
|
|
538
|
+
} catch {
|
|
539
|
+
return null;
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
function readAutoStateFile() {
|
|
544
|
+
try {
|
|
545
|
+
const raw = fs.readFileSync(AUTO_STATE_FILE(), 'utf8');
|
|
546
|
+
return JSON.parse(raw);
|
|
547
|
+
} catch {
|
|
548
|
+
return null;
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
// Pull only the bytes appended since we last read (so we don't replay every
|
|
553
|
+
// historical violation each time the file changes).
|
|
554
|
+
function readNewViolations() {
|
|
555
|
+
const file = VIOLATIONS_FILE();
|
|
556
|
+
let stat;
|
|
557
|
+
try { stat = fs.statSync(file); } catch { return []; }
|
|
558
|
+
if (stat.size < _violationsOffset) {
|
|
559
|
+
// File was truncated/rotated — reset offset and treat from start.
|
|
560
|
+
_violationsOffset = 0;
|
|
561
|
+
}
|
|
562
|
+
if (stat.size === _violationsOffset) return [];
|
|
563
|
+
let chunk;
|
|
564
|
+
try {
|
|
565
|
+
const fd = fs.openSync(file, 'r');
|
|
566
|
+
const length = stat.size - _violationsOffset;
|
|
567
|
+
const buf = Buffer.alloc(length);
|
|
568
|
+
fs.readSync(fd, buf, 0, length, _violationsOffset);
|
|
569
|
+
fs.closeSync(fd);
|
|
570
|
+
chunk = buf.toString('utf8');
|
|
571
|
+
} catch {
|
|
572
|
+
return [];
|
|
573
|
+
}
|
|
574
|
+
_violationsOffset = stat.size;
|
|
575
|
+
const out = [];
|
|
576
|
+
for (const line of chunk.split('\n')) {
|
|
577
|
+
const trimmed = line.trim();
|
|
578
|
+
if (!trimmed) continue;
|
|
579
|
+
try { out.push(JSON.parse(trimmed)); } catch { /* skip half-written line; next event retries */ }
|
|
580
|
+
}
|
|
581
|
+
return out;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
function emitGateState() {
|
|
585
|
+
const state = readGateStateFile() || { open: false, history: [] };
|
|
586
|
+
broadcastGate({ type: 'gate-state', state });
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
function emitAutoState() {
|
|
590
|
+
const state = readAutoStateFile();
|
|
591
|
+
if (state) broadcastGate({ type: 'auto-state', state });
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
function emitViolations() {
|
|
595
|
+
const entries = readNewViolations();
|
|
596
|
+
for (const v of entries) {
|
|
597
|
+
broadcastGate({
|
|
598
|
+
type: 'gate-block',
|
|
599
|
+
reason: v.reason || v.type || 'Gate violation',
|
|
600
|
+
kind: v.type || null,
|
|
601
|
+
gateId: v.gate_id || null,
|
|
602
|
+
agent: v.agent || null,
|
|
603
|
+
phase: v.phase || null,
|
|
604
|
+
sessionId: v.session_id || null,
|
|
605
|
+
ts: v.ts || null,
|
|
606
|
+
});
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
function startGateWatchers() {
|
|
611
|
+
// Initialise violations offset to current size so we don't replay history.
|
|
612
|
+
try {
|
|
613
|
+
const stat = fs.statSync(VIOLATIONS_FILE());
|
|
614
|
+
_violationsOffset = stat.size;
|
|
615
|
+
} catch {
|
|
616
|
+
_violationsOffset = 0;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
const setupWatch = (file, kind) => {
|
|
620
|
+
try {
|
|
621
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
622
|
+
} catch {}
|
|
623
|
+
try {
|
|
624
|
+
const watcher = fs.watch(path.dirname(file), (event, filename) => {
|
|
625
|
+
if (!filename) return;
|
|
626
|
+
if (path.basename(file) !== filename) return;
|
|
627
|
+
if (kind === 'gate') {
|
|
628
|
+
if (_gateStateDebounce) clearTimeout(_gateStateDebounce);
|
|
629
|
+
_gateStateDebounce = setTimeout(emitGateState, 50);
|
|
630
|
+
} else if (kind === 'auto') {
|
|
631
|
+
if (_autoStateDebounce) clearTimeout(_autoStateDebounce);
|
|
632
|
+
_autoStateDebounce = setTimeout(emitAutoState, 50);
|
|
633
|
+
} else if (kind === 'violations') {
|
|
634
|
+
if (_violationsDebounce) clearTimeout(_violationsDebounce);
|
|
635
|
+
_violationsDebounce = setTimeout(emitViolations, 50);
|
|
636
|
+
}
|
|
637
|
+
});
|
|
638
|
+
watcher.on('error', (err) => {
|
|
639
|
+
log(`gate watcher (${kind}) error: ${err.message}`);
|
|
640
|
+
});
|
|
641
|
+
_gateWatchers.push(watcher);
|
|
642
|
+
} catch (err) {
|
|
643
|
+
log(`gate watcher (${kind}) setup failed: ${err.message}`);
|
|
644
|
+
}
|
|
645
|
+
};
|
|
646
|
+
|
|
647
|
+
setupWatch(GATE_STATE_FILE(), 'gate');
|
|
648
|
+
setupWatch(AUTO_STATE_FILE(), 'auto');
|
|
649
|
+
setupWatch(VIOLATIONS_FILE(), 'violations');
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
function stopGateWatchers() {
|
|
653
|
+
for (const w of _gateWatchers) {
|
|
654
|
+
try { w.close(); } catch {}
|
|
655
|
+
}
|
|
656
|
+
_gateWatchers = [];
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
function stopPanelWatcher(panelId) {
|
|
660
|
+
if (panelWatchers[panelId]) {
|
|
661
|
+
try { panelWatchers[panelId].close(); } catch {}
|
|
662
|
+
delete panelWatchers[panelId];
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
function startPanelWatcher(panelId, info) {
|
|
667
|
+
stopPanelWatcher(panelId); // ensure clean start
|
|
668
|
+
|
|
669
|
+
try {
|
|
670
|
+
const watcher = fs.watch(info.filePath, (event) => {
|
|
671
|
+
if (event === 'change') {
|
|
672
|
+
// debounce slightly to ensure file is written
|
|
673
|
+
setTimeout(() => {
|
|
674
|
+
const currentInfo = panelSources[panelId];
|
|
675
|
+
if (!currentInfo) return; // panel was closed
|
|
676
|
+
const content = readFileSafe(currentInfo.filePath);
|
|
677
|
+
if (content !== null && content !== currentInfo.lastContent) {
|
|
678
|
+
panelSources[panelId] = { ...currentInfo, lastContent: content };
|
|
679
|
+
const parsedData = parsePanelFileContent(currentInfo.filePath, content);
|
|
680
|
+
broadcast({
|
|
681
|
+
type: 'ui-panel-update',
|
|
682
|
+
panelId,
|
|
683
|
+
panel: currentInfo.panel,
|
|
684
|
+
title: currentInfo.title,
|
|
685
|
+
agent: currentInfo.agent,
|
|
686
|
+
data: parsedData,
|
|
687
|
+
});
|
|
688
|
+
}
|
|
689
|
+
}, 100);
|
|
690
|
+
}
|
|
691
|
+
});
|
|
692
|
+
watcher.on('error', (err) => {
|
|
693
|
+
log(`Panel watcher error for ${info.filePath}: ${err.message}`);
|
|
694
|
+
stopPanelWatcher(panelId);
|
|
695
|
+
});
|
|
696
|
+
panelWatchers[panelId] = watcher;
|
|
697
|
+
} catch (err) {
|
|
698
|
+
log(`Failed to start watcher for ${info.filePath}: ${err.message}`);
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
// ─── File scanning (polling) ──────────────────────────────────────────────────
|
|
703
|
+
|
|
704
|
+
function readFileSafe(fullPath) {
|
|
705
|
+
try {
|
|
706
|
+
return fs.readFileSync(fullPath, 'utf8');
|
|
707
|
+
} catch {
|
|
708
|
+
return null;
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
// ─── CSV/TSV parser (RFC 4180) ────────────────────────────────────────────
|
|
713
|
+
|
|
714
|
+
function parseDelimited(text, delimiter) {
|
|
715
|
+
const rows = [];
|
|
716
|
+
let row = [];
|
|
717
|
+
let field = '';
|
|
718
|
+
let inQuotes = false;
|
|
719
|
+
let i = 0;
|
|
720
|
+
|
|
721
|
+
while (i < text.length) {
|
|
722
|
+
const ch = text[i];
|
|
723
|
+
|
|
724
|
+
if (inQuotes) {
|
|
725
|
+
if (ch === '"') {
|
|
726
|
+
if (i + 1 < text.length && text[i + 1] === '"') {
|
|
727
|
+
field += '"';
|
|
728
|
+
i += 2;
|
|
729
|
+
} else {
|
|
730
|
+
inQuotes = false;
|
|
731
|
+
i++;
|
|
732
|
+
}
|
|
733
|
+
} else {
|
|
734
|
+
field += ch;
|
|
735
|
+
i++;
|
|
736
|
+
}
|
|
737
|
+
} else {
|
|
738
|
+
if (ch === '"') {
|
|
739
|
+
inQuotes = true;
|
|
740
|
+
i++;
|
|
741
|
+
} else if (ch === delimiter) {
|
|
742
|
+
row.push(field);
|
|
743
|
+
field = '';
|
|
744
|
+
i++;
|
|
745
|
+
} else if (ch === '\r') {
|
|
746
|
+
if (i + 1 < text.length && text[i + 1] === '\n') i++;
|
|
747
|
+
row.push(field);
|
|
748
|
+
field = '';
|
|
749
|
+
rows.push(row);
|
|
750
|
+
row = [];
|
|
751
|
+
i++;
|
|
752
|
+
} else if (ch === '\n') {
|
|
753
|
+
row.push(field);
|
|
754
|
+
field = '';
|
|
755
|
+
rows.push(row);
|
|
756
|
+
row = [];
|
|
757
|
+
i++;
|
|
758
|
+
} else {
|
|
759
|
+
field += ch;
|
|
760
|
+
i++;
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
// Last field/row
|
|
766
|
+
if (field || row.length > 0) {
|
|
767
|
+
row.push(field);
|
|
768
|
+
rows.push(row);
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
if (rows.length === 0) return { columns: [], data: [] };
|
|
772
|
+
|
|
773
|
+
const columns = rows[0];
|
|
774
|
+
const data = [];
|
|
775
|
+
for (let r = 1; r < rows.length; r++) {
|
|
776
|
+
const obj = {};
|
|
777
|
+
for (let c = 0; c < columns.length; c++) {
|
|
778
|
+
obj[columns[c]] = rows[r][c] !== undefined ? rows[r][c] : '';
|
|
779
|
+
}
|
|
780
|
+
data.push(obj);
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
return { columns, data };
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
// Cap recursion depth for the 3s output-dir poll. 12 is more permissive than
|
|
787
|
+
// walkSearch's 8 (the user's own output dirs can nest deeper than ad-hoc search
|
|
788
|
+
// scope) but still bounded. Symlinks are skipped outright via lstat to prevent
|
|
789
|
+
// a symlink cycle under analysis/ from re-running pollFiles indefinitely.
|
|
790
|
+
const SCAN_MAX_DEPTH = 12;
|
|
791
|
+
|
|
792
|
+
function scanDir(dir, result, depth) {
|
|
793
|
+
if (depth === undefined) depth = 0;
|
|
794
|
+
if (depth > SCAN_MAX_DEPTH) return;
|
|
795
|
+
try {
|
|
796
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
797
|
+
const fullPath = path.join(dir, entry.name);
|
|
798
|
+
|
|
799
|
+
// Skip symlinks (both file and dir) — prevents infinite recursion via
|
|
800
|
+
// symlink loops on the 3s poll. Dirent.isSymbolicLink() reflects the
|
|
801
|
+
// type the OS reported in readdir; lstat is the authoritative check in
|
|
802
|
+
// case the dirent type wasn't populated by the filesystem.
|
|
803
|
+
if (entry.isSymbolicLink()) continue;
|
|
804
|
+
try {
|
|
805
|
+
if (fs.lstatSync(fullPath).isSymbolicLink()) continue;
|
|
806
|
+
} catch {
|
|
807
|
+
continue;
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
if (entry.isDirectory()) {
|
|
811
|
+
scanDir(fullPath, result, depth + 1);
|
|
812
|
+
continue;
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
const ext = path.extname(entry.name).toLowerCase();
|
|
816
|
+
if (POLL_SKIP_EXT.has(ext)) {
|
|
817
|
+
if (!pollSkipWarned.has(fullPath)) {
|
|
818
|
+
pollSkipWarned.add(fullPath);
|
|
819
|
+
console.warn(`pollFiles: skipping ${fullPath} (extension ${ext} in skip list)`);
|
|
820
|
+
}
|
|
821
|
+
continue;
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
let size;
|
|
825
|
+
try {
|
|
826
|
+
size = fs.statSync(fullPath).size;
|
|
827
|
+
} catch {
|
|
828
|
+
continue;
|
|
829
|
+
}
|
|
830
|
+
if (size > POLL_MAX_BYTES) {
|
|
831
|
+
if (!pollSkipWarned.has(fullPath)) {
|
|
832
|
+
pollSkipWarned.add(fullPath);
|
|
833
|
+
console.warn(`pollFiles: skipping ${fullPath} (${size} bytes > ${POLL_MAX_BYTES})`);
|
|
834
|
+
}
|
|
835
|
+
continue;
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
const relPath = path.relative(PROJECT_DIR, fullPath);
|
|
839
|
+
const content = readFileSafe(fullPath);
|
|
840
|
+
if (content !== null) result[relPath] = content;
|
|
841
|
+
}
|
|
842
|
+
} catch {}
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
function pollFiles() {
|
|
846
|
+
const current = {};
|
|
847
|
+
for (const dir of OUTPUT_DIRS) {
|
|
848
|
+
const dirPath = path.join(PROJECT_DIR, dir);
|
|
849
|
+
if (fs.existsSync(dirPath)) scanDir(dirPath, current);
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
// Collect all sessionFiles across active sessions
|
|
853
|
+
const allSessionFiles = new Set();
|
|
854
|
+
for (const store of sessions.values()) {
|
|
855
|
+
for (const f of store.sessionFiles) allSessionFiles.add(f);
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
for (const [relPath, content] of Object.entries(current)) {
|
|
859
|
+
if (files[relPath] !== content) {
|
|
860
|
+
files[relPath] = content;
|
|
861
|
+
broadcast({ type: 'artifact-updated', path: relPath, content, sessionFile: allSessionFiles.has(relPath) });
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
setTimeout(pollFiles, 3000);
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
// ─── Panel file content parser ────────────────────────────────────────────────
|
|
869
|
+
|
|
870
|
+
function parsePanelFileContent(filePath, content) {
|
|
871
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
872
|
+
if (ext === '.json') {
|
|
873
|
+
try { return JSON.parse(content); } catch { return content; }
|
|
874
|
+
}
|
|
875
|
+
if (ext === '.csv') {
|
|
876
|
+
return parseDelimited(content, ',');
|
|
877
|
+
}
|
|
878
|
+
if (ext === '.tsv') {
|
|
879
|
+
return parseDelimited(content, '\t');
|
|
880
|
+
}
|
|
881
|
+
return content;
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
// ─── Event handler (observer mode - relay) ──────────────────────────────────
|
|
885
|
+
|
|
886
|
+
function handleEvent(body) {
|
|
887
|
+
let payload;
|
|
888
|
+
try {
|
|
889
|
+
payload = JSON.parse(body);
|
|
890
|
+
} catch {
|
|
891
|
+
return { ack: false };
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
const { eventType, seq, sessionId: eventSessionId, ...data } = payload;
|
|
895
|
+
|
|
896
|
+
// Prefer the session id carried by the relay event itself (forwarded from
|
|
897
|
+
// the Claude Code hook's stdin payload — same value as the SessionStore
|
|
898
|
+
// key for UI-spawned sessions, since we pass --session-id to the CLI). If
|
|
899
|
+
// a matching store exists, use it directly. This is the fix for two
|
|
900
|
+
// concurrent sessions seeing each other's events misattributed to "most
|
|
901
|
+
// recently active session."
|
|
902
|
+
//
|
|
903
|
+
// Fall back to the previous "most recently active" heuristic when:
|
|
904
|
+
// - the event lacks a sessionId (legacy relay processes before this fix
|
|
905
|
+
// ship, or queued events written under the old shape)
|
|
906
|
+
// - the event references a session not tracked by this server (claude run
|
|
907
|
+
// outside the UI in the same project — hooks still fire)
|
|
908
|
+
let activeStore = null;
|
|
909
|
+
let activeSessionId = null;
|
|
910
|
+
if (eventSessionId) {
|
|
911
|
+
const store = sessions.get(eventSessionId);
|
|
912
|
+
if (store) {
|
|
913
|
+
activeStore = store;
|
|
914
|
+
activeSessionId = eventSessionId;
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
if (!activeStore) {
|
|
918
|
+
for (const [id, store] of sessions) {
|
|
919
|
+
if (store.chatSession && store.chatSession.isRunning) {
|
|
920
|
+
if (!activeStore || store.lastActivityAt > activeStore.lastActivityAt) {
|
|
921
|
+
activeStore = store;
|
|
922
|
+
activeSessionId = id;
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
switch (eventType) {
|
|
929
|
+
case 'user-message':
|
|
930
|
+
if (activeStore) activeStore.transcript.push({ role: 'user', content: data.content, agent: data.agent, source: 'observer' });
|
|
931
|
+
broadcast({ type: 'user-message', content: data.content, agent: data.agent, sessionId: activeSessionId });
|
|
932
|
+
break;
|
|
933
|
+
case 'agent-message':
|
|
934
|
+
if (activeStore) activeStore.transcript.push({ role: 'assistant', content: data.content, agent: data.agent, source: 'observer' });
|
|
935
|
+
broadcast({ type: 'agent-message', content: data.content, agent: data.agent, sessionId: activeSessionId });
|
|
936
|
+
break;
|
|
937
|
+
case 'agent-activated':
|
|
938
|
+
broadcast({ type: 'agent-activated', agent: data.agent, sessionId: activeSessionId });
|
|
939
|
+
break;
|
|
940
|
+
case 'agent-changed':
|
|
941
|
+
broadcast({ type: 'agent-changed', from: data.from, to: data.to, sessionId: activeSessionId });
|
|
942
|
+
break;
|
|
943
|
+
case 'agent-consulting':
|
|
944
|
+
broadcast({ type: 'agent-consulting', agent: data.agent, sessionId: activeSessionId });
|
|
945
|
+
break;
|
|
946
|
+
case 'event-log':
|
|
947
|
+
broadcast({ type: 'event-log', text: data.text, sessionId: activeSessionId });
|
|
948
|
+
break;
|
|
949
|
+
case 'file-touched': {
|
|
950
|
+
const relPath = path.relative(PROJECT_DIR, data.filePath);
|
|
951
|
+
if (activeStore) {
|
|
952
|
+
activeStore.sessionFiles.add(relPath);
|
|
953
|
+
if (activeStore.setProjectFromRelPath(relPath)) {
|
|
954
|
+
activeStore.save();
|
|
955
|
+
try {
|
|
956
|
+
sessionIndex.updateActivity(SESSIONS_DIR, activeSessionId, {
|
|
957
|
+
projectName: activeStore.projectName,
|
|
958
|
+
projectDir: activeStore.projectDir,
|
|
959
|
+
});
|
|
960
|
+
} catch (err) {
|
|
961
|
+
log(`session-index updateActivity (file-touched) failed: ${err.message}`);
|
|
962
|
+
}
|
|
963
|
+
broadcast({
|
|
964
|
+
type: 'session-context',
|
|
965
|
+
sessionId: activeSessionId,
|
|
966
|
+
projectName: activeStore.projectName,
|
|
967
|
+
projectDir: activeStore.projectDir,
|
|
968
|
+
});
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
broadcast({ type: 'file-touched', path: relPath, sessionId: activeSessionId });
|
|
972
|
+
break;
|
|
973
|
+
}
|
|
974
|
+
case 'session-end':
|
|
975
|
+
broadcast({ type: 'session-end', sessionId: activeSessionId });
|
|
976
|
+
if (activeStore) activeStore.sessionFiles = new Set();
|
|
977
|
+
break;
|
|
978
|
+
|
|
979
|
+
// ─── ui-panel: agent pushes an interactive panel to the browser ─────
|
|
980
|
+
case 'ui-panel': {
|
|
981
|
+
const { panel, panelId, title, data: panelData, source, type: panelType, agent: panelAgent } = data;
|
|
982
|
+
if (!panel || !panelId) break;
|
|
983
|
+
|
|
984
|
+
let resolvedData = panelData !== undefined ? panelData : null;
|
|
985
|
+
|
|
986
|
+
// If source file path provided, read it and watch for changes
|
|
987
|
+
if (source) {
|
|
988
|
+
const content = readFileSafe(source);
|
|
989
|
+
if (content !== null) {
|
|
990
|
+
resolvedData = parsePanelFileContent(source, content);
|
|
991
|
+
const info = {
|
|
992
|
+
filePath: source,
|
|
993
|
+
panel,
|
|
994
|
+
title: title || panel,
|
|
995
|
+
agent: panelAgent || null,
|
|
996
|
+
lastContent: content,
|
|
997
|
+
};
|
|
998
|
+
panelSources[panelId] = info;
|
|
999
|
+
startPanelWatcher(panelId, info);
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
broadcast({
|
|
1004
|
+
type: 'ui-panel',
|
|
1005
|
+
panel,
|
|
1006
|
+
panelId,
|
|
1007
|
+
title: title || panel,
|
|
1008
|
+
data: resolvedData,
|
|
1009
|
+
source: source || null,
|
|
1010
|
+
panelType: panelType || null,
|
|
1011
|
+
agent: panelAgent || null,
|
|
1012
|
+
});
|
|
1013
|
+
break;
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
// ─── ui-panel-close: agent or server closes a panel ─────────────────
|
|
1017
|
+
case 'ui-panel-close': {
|
|
1018
|
+
const { panelId } = data;
|
|
1019
|
+
if (panelId) {
|
|
1020
|
+
if (panelSources[panelId]) delete panelSources[panelId];
|
|
1021
|
+
stopPanelWatcher(panelId);
|
|
1022
|
+
}
|
|
1023
|
+
broadcast({ type: 'ui-panel-close', panelId: panelId || null });
|
|
1024
|
+
break;
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
return { ack: true, seq: seq || null };
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
// ─── Agent listing ──────────────────────────────────────────────────────────
|
|
1032
|
+
|
|
1033
|
+
function listAgents() {
|
|
1034
|
+
const agents = [];
|
|
1035
|
+
try {
|
|
1036
|
+
const entries = fs.readdirSync(AGENTS_DIR, { withFileTypes: true });
|
|
1037
|
+
for (const entry of entries) {
|
|
1038
|
+
if (!entry.isFile() || !entry.name.endsWith('.md')) continue;
|
|
1039
|
+
const name = entry.name.replace(/\.md$/, '');
|
|
1040
|
+
const content = readFileSafe(path.join(AGENTS_DIR, entry.name));
|
|
1041
|
+
if (!content) continue;
|
|
1042
|
+
|
|
1043
|
+
// Parse YAML frontmatter
|
|
1044
|
+
let description = '';
|
|
1045
|
+
const fmMatch = content.match(/^---\n([\s\S]*?)\n---/);
|
|
1046
|
+
if (fmMatch) {
|
|
1047
|
+
const descMatch = fmMatch[1].match(/description:\s*["']?(.+?)["']?\s*$/m);
|
|
1048
|
+
if (descMatch) description = descMatch[1];
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
agents.push({ name, description });
|
|
1052
|
+
}
|
|
1053
|
+
} catch {}
|
|
1054
|
+
// Enrich with metadata
|
|
1055
|
+
for (const a of agents) {
|
|
1056
|
+
const meta = AGENT_META[a.name] || {};
|
|
1057
|
+
a.description = a.description || meta.description || '';
|
|
1058
|
+
a.label = meta.label || a.name;
|
|
1059
|
+
a.color = meta.color || '#3860c0';
|
|
1060
|
+
a.category = meta.category || 'other';
|
|
1061
|
+
a.keywords = meta.keywords || [];
|
|
1062
|
+
}
|
|
1063
|
+
return agents;
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
function listSkills() {
|
|
1067
|
+
const roots = [
|
|
1068
|
+
{ dir: path.join(PROJECT_DIR, '.claude', 'skills'), scope: 'project' },
|
|
1069
|
+
{ dir: path.join(os.homedir(), '.claude', 'skills'), scope: 'user' },
|
|
1070
|
+
];
|
|
1071
|
+
const seen = new Set();
|
|
1072
|
+
const skills = [];
|
|
1073
|
+
|
|
1074
|
+
for (const { dir, scope } of roots) {
|
|
1075
|
+
let entries;
|
|
1076
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { continue; }
|
|
1077
|
+
|
|
1078
|
+
for (const entry of entries) {
|
|
1079
|
+
if (!entry.isDirectory() || seen.has(entry.name)) continue;
|
|
1080
|
+
const skillPath = path.join(dir, entry.name, 'SKILL.md');
|
|
1081
|
+
if (!fs.existsSync(skillPath)) continue;
|
|
1082
|
+
const content = readFileSafe(skillPath);
|
|
1083
|
+
if (!content) continue;
|
|
1084
|
+
|
|
1085
|
+
let description = '';
|
|
1086
|
+
const fmMatch = content.match(/^---\n([\s\S]*?)\n---/);
|
|
1087
|
+
if (fmMatch) {
|
|
1088
|
+
const descMatch = fmMatch[1].match(/description:\s*["']?(.+?)["']?\s*$/m);
|
|
1089
|
+
if (descMatch) description = descMatch[1];
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
seen.add(entry.name);
|
|
1093
|
+
skills.push({ name: entry.name, description, scope });
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
return skills;
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
// ─── Chat session management ────────────────────────────────────────────────
|
|
1100
|
+
|
|
1101
|
+
function handleChatEvent(event) {
|
|
1102
|
+
const { type, sessionId } = event;
|
|
1103
|
+
const store = getSession(sessionId);
|
|
1104
|
+
|
|
1105
|
+
switch (type) {
|
|
1106
|
+
case 'chat-token':
|
|
1107
|
+
broadcast({ type: 'chat-token', text: event.text, index: event.index, sessionId });
|
|
1108
|
+
break;
|
|
1109
|
+
|
|
1110
|
+
case 'chat-tool-use':
|
|
1111
|
+
broadcast({ type: 'chat-tool-use', tool: event.tool, id: event.id, index: event.index, sessionId });
|
|
1112
|
+
break;
|
|
1113
|
+
|
|
1114
|
+
case 'chat-tool-input-delta':
|
|
1115
|
+
broadcast({ type: 'chat-tool-input-delta', partial_json: event.partial_json, index: event.index, sessionId });
|
|
1116
|
+
break;
|
|
1117
|
+
|
|
1118
|
+
case 'chat-block-stop':
|
|
1119
|
+
broadcast({ type: 'chat-block-stop', index: event.index, sessionId });
|
|
1120
|
+
break;
|
|
1121
|
+
|
|
1122
|
+
case 'chat-message': {
|
|
1123
|
+
const content = event.content;
|
|
1124
|
+
let textContent = '';
|
|
1125
|
+
if (Array.isArray(content)) {
|
|
1126
|
+
textContent = content
|
|
1127
|
+
.filter(b => b.type === 'text')
|
|
1128
|
+
.map(b => b.text)
|
|
1129
|
+
.join('\n');
|
|
1130
|
+
} else if (typeof content === 'string') {
|
|
1131
|
+
textContent = content;
|
|
1132
|
+
}
|
|
1133
|
+
const agent = store ? store.agent : 'unknown';
|
|
1134
|
+
if (store) {
|
|
1135
|
+
store.transcript.push({ role: 'assistant', content: textContent, agent, source: 'chat' });
|
|
1136
|
+
store.save();
|
|
1137
|
+
}
|
|
1138
|
+
broadcast({ type: 'chat-message', content: textContent, agent, sessionId });
|
|
1139
|
+
break;
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
case 'chat-turn-end':
|
|
1143
|
+
broadcast({ type: 'chat-turn-end', sessionId, cost: event.cost, duration: event.duration });
|
|
1144
|
+
break;
|
|
1145
|
+
|
|
1146
|
+
case 'chat-init': {
|
|
1147
|
+
// When forking, the CLI assigns a new session ID — re-key the store
|
|
1148
|
+
if (event.oldSessionId && event.sessionId !== event.oldSessionId) {
|
|
1149
|
+
const store = sessions.get(event.oldSessionId);
|
|
1150
|
+
if (store) {
|
|
1151
|
+
sessions.delete(event.oldSessionId);
|
|
1152
|
+
sessions.set(event.sessionId, store);
|
|
1153
|
+
store.sessionId = event.sessionId;
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
1156
|
+
broadcast({ type: 'chat-init', sessionId });
|
|
1157
|
+
break;
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
case 'chat-error':
|
|
1161
|
+
broadcast({ type: 'chat-error', error: event.error, sessionId });
|
|
1162
|
+
break;
|
|
1163
|
+
|
|
1164
|
+
case 'chat-usage': {
|
|
1165
|
+
if (store && event.usage) {
|
|
1166
|
+
store.contextUsage = event.usage;
|
|
1167
|
+
store.save();
|
|
1168
|
+
broadcast({
|
|
1169
|
+
type: 'chat-usage',
|
|
1170
|
+
sessionId,
|
|
1171
|
+
usage: event.usage,
|
|
1172
|
+
});
|
|
1173
|
+
}
|
|
1174
|
+
break;
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
case 'chat-stderr':
|
|
1178
|
+
broadcast({ type: 'chat-stderr', text: event.text, sessionId });
|
|
1179
|
+
break;
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
function handleChatExit({ code, sessionId }) {
|
|
1184
|
+
const store = getSession(sessionId);
|
|
1185
|
+
if (store) {
|
|
1186
|
+
store.chatSession = null;
|
|
1187
|
+
// Remove session metadata file
|
|
1188
|
+
try { fs.unlinkSync(path.join(SESSIONS_DIR, `${sessionId}.json`)); } catch {}
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
// Abort any pending permission requests tied to this session. Without this,
|
|
1192
|
+
// a relay process started by the now-dead chat session keeps polling for a
|
|
1193
|
+
// decision that will never come (the browser card disappeared with the
|
|
1194
|
+
// session). Marking these as denied lets the relay's next poll resolve
|
|
1195
|
+
// immediately and fall through to CC's native permission flow.
|
|
1196
|
+
for (const [id, req] of permissionRequests) {
|
|
1197
|
+
if (req.sessionId === sessionId && req.decision === null) {
|
|
1198
|
+
req.decision = 'deny';
|
|
1199
|
+
req.createdAt = Date.now(); // restart TTL so the entry isn't immediately collected
|
|
1200
|
+
broadcast({ type: 'permission-resolved', id, decision: 'deny', reason: 'session-ended' });
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
// Mark ended in the index if not already (covers natural CLI exit, crashes,
|
|
1204
|
+
// and the path where the user closes the browser without hitting End Chat).
|
|
1205
|
+
try {
|
|
1206
|
+
const entry = sessionIndex.getEntry(SESSIONS_DIR, sessionId);
|
|
1207
|
+
if (entry && entry.status === 'active') {
|
|
1208
|
+
const snap = readGateSnapshot(SHARDS_DIR);
|
|
1209
|
+
sessionIndex.markEnded(SESSIONS_DIR, sessionId, {
|
|
1210
|
+
gateOpenAtEnd: snap.gateOpenAtEnd,
|
|
1211
|
+
reason: code === 0 ? 'process_exit' : 'process_crash',
|
|
1212
|
+
});
|
|
1213
|
+
if (typeof snap.phase === 'number') sessionIndex.updatePhase(SESSIONS_DIR, sessionId, snap.phase);
|
|
1214
|
+
}
|
|
1215
|
+
} catch (err) {
|
|
1216
|
+
log(`session-index markEnded (handleChatExit) failed: ${err.message}`);
|
|
1217
|
+
}
|
|
1218
|
+
broadcast({ type: 'chat-ended', sessionId, code: code || 0 });
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
// ─── Slash command parsing ──────────────────────────────────────────────────
|
|
1222
|
+
|
|
1223
|
+
function parseSlashCommand(message) {
|
|
1224
|
+
const trimmed = message.trim();
|
|
1225
|
+
if (!trimmed.startsWith('/')) return null;
|
|
1226
|
+
|
|
1227
|
+
const cmd = trimmed.slice(1).split(/\s+/)[0].toLowerCase();
|
|
1228
|
+
if (!cmd) return null;
|
|
1229
|
+
|
|
1230
|
+
// Utility commands
|
|
1231
|
+
const utilities = ['stop', 'clear', 'help'];
|
|
1232
|
+
if (utilities.includes(cmd)) {
|
|
1233
|
+
return { type: 'utility', command: cmd };
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
// Claude Code built-in commands handled by the UI
|
|
1237
|
+
const builtins = ['context', 'rewind', 'exit', 'model'];
|
|
1238
|
+
if (builtins.includes(cmd)) {
|
|
1239
|
+
const rest = trimmed.slice(1 + cmd.length).trim();
|
|
1240
|
+
return { type: 'builtin', command: cmd, args: rest || null };
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
// Agent commands — check against installed agents
|
|
1244
|
+
const agents = listAgents();
|
|
1245
|
+
|
|
1246
|
+
// Explicit /agent <name> form — always unambiguous
|
|
1247
|
+
if (cmd === 'agent') {
|
|
1248
|
+
const name = trimmed.slice(1 + cmd.length).trim();
|
|
1249
|
+
if (agents.some(a => a.name === name)) {
|
|
1250
|
+
return { type: 'agent', agent: name };
|
|
1251
|
+
}
|
|
1252
|
+
return null;
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
// Bare agent name — backward compatible shortcut
|
|
1256
|
+
if (agents.some(a => a.name === cmd)) {
|
|
1257
|
+
return { type: 'agent', agent: cmd };
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
// Everything else — skills, project commands, CC builtins we don't
|
|
1261
|
+
// special-case — goes to the CLI verbatim.
|
|
1262
|
+
return { type: 'passthrough' };
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
// ─── Chat session creation helper ───────────────────────────────────────────
|
|
1266
|
+
|
|
1267
|
+
function startNewChatSession(agent, options = {}) {
|
|
1268
|
+
const { resumeSessionId, permissionMode, callerSessionId, initialMessage, model } = options;
|
|
1269
|
+
|
|
1270
|
+
// Kill existing session for the caller if running
|
|
1271
|
+
if (callerSessionId) {
|
|
1272
|
+
const oldStore = getSession(callerSessionId);
|
|
1273
|
+
if (oldStore && oldStore.chatSession && oldStore.chatSession.isRunning) {
|
|
1274
|
+
oldStore.chatSession.stop();
|
|
1275
|
+
}
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
const sessionId = randomUUID();
|
|
1279
|
+
|
|
1280
|
+
const store = new SessionStore({ sessionId, agent });
|
|
1281
|
+
sessions.set(sessionId, store);
|
|
1282
|
+
|
|
1283
|
+
try {
|
|
1284
|
+
sessionIndex.appendSession(SESSIONS_DIR, {
|
|
1285
|
+
sessionId,
|
|
1286
|
+
agent,
|
|
1287
|
+
resumedFrom: resumeSessionId || null,
|
|
1288
|
+
});
|
|
1289
|
+
} catch (err) {
|
|
1290
|
+
log(`session-index appendSession failed: ${err.message}`);
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
const chatSess = new ChatSession({
|
|
1294
|
+
agent,
|
|
1295
|
+
sessionId,
|
|
1296
|
+
resumeSessionId,
|
|
1297
|
+
cwd: PROJECT_DIR,
|
|
1298
|
+
sessionsDir: SESSIONS_DIR,
|
|
1299
|
+
permissionMode: permissionMode || 'acceptEdits',
|
|
1300
|
+
model,
|
|
1301
|
+
onEvent: handleChatEvent,
|
|
1302
|
+
onExit: handleChatExit,
|
|
1303
|
+
});
|
|
1304
|
+
|
|
1305
|
+
store.chatSession = chatSess;
|
|
1306
|
+
chatSess.start();
|
|
1307
|
+
|
|
1308
|
+
// Opening message. For an agent session this is the activation prompt from
|
|
1309
|
+
// .claude/commands/<agent>.md; for a plain session it's the user's own first
|
|
1310
|
+
// message, or nothing at all if they haven't typed yet.
|
|
1311
|
+
let firstMessage = null;
|
|
1312
|
+
|
|
1313
|
+
if (agent) {
|
|
1314
|
+
const cmdFile = path.join(COMMANDS_DIR, `${agent}.md`);
|
|
1315
|
+
try {
|
|
1316
|
+
const raw = fs.readFileSync(cmdFile, 'utf8');
|
|
1317
|
+
firstMessage = raw.replace(/^---\n[\s\S]*?\n---\n*/, '').trim();
|
|
1318
|
+
} catch {
|
|
1319
|
+
firstMessage = `You are now activated as the ${agent} agent. Greet the user and display your activation menu.`;
|
|
1320
|
+
}
|
|
1321
|
+
if (initialMessage) {
|
|
1322
|
+
firstMessage += '\n\nThe user has already provided their request upfront. ' +
|
|
1323
|
+
'**Skip the greeting and activation menu entirely.** Go directly to Phase 0 triage.' +
|
|
1324
|
+
'\n\nUser request:\n\n' + initialMessage;
|
|
1325
|
+
}
|
|
1326
|
+
} else if (initialMessage) {
|
|
1327
|
+
firstMessage = initialMessage;
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
if (firstMessage) {
|
|
1331
|
+
try {
|
|
1332
|
+
chatSess.send(firstMessage);
|
|
1333
|
+
} catch (err) {
|
|
1334
|
+
chatSess.stop();
|
|
1335
|
+
sessions.delete(sessionId);
|
|
1336
|
+
broadcast({ type: 'chat-error', error: `Session start failed: ${err.message}`, sessionId });
|
|
1337
|
+
return { sessionId, agent };
|
|
1338
|
+
}
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
broadcast({ type: 'chat-started', agent, sessionId, autoActivated: !!agent });
|
|
1342
|
+
return { sessionId, agent };
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
// ─── Request body parser ────────────────────────────────────────────────────
|
|
1346
|
+
|
|
1347
|
+
function readBody(req) {
|
|
1348
|
+
return new Promise((resolve) => {
|
|
1349
|
+
let body = '';
|
|
1350
|
+
req.on('data', (chunk) => { body += chunk; });
|
|
1351
|
+
req.on('end', () => resolve(body));
|
|
1352
|
+
});
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
function jsonResponse(res, cors, statusCode, data) {
|
|
1356
|
+
res.writeHead(statusCode, { ...cors, 'Content-Type': 'application/json' });
|
|
1357
|
+
res.end(JSON.stringify(data));
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
// ─── Server ──────────────────────────────────────────────────────────────────
|
|
1361
|
+
|
|
1362
|
+
// ─── P1: Auth middleware ──────────────────────────────────────────────────────
|
|
1363
|
+
|
|
1364
|
+
function checkAuth(req, parsedUrl) {
|
|
1365
|
+
// Check Authorization header first
|
|
1366
|
+
const authHeader = req.headers['authorization'];
|
|
1367
|
+
if (authHeader === `Bearer ${AUTH_TOKEN}`) return true;
|
|
1368
|
+
// Check query param (for SSE EventSource which can't set headers)
|
|
1369
|
+
if (parsedUrl && parsedUrl.searchParams.get('token') === AUTH_TOKEN) return true;
|
|
1370
|
+
return false;
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
function rejectAuth(res, cors) {
|
|
1374
|
+
jsonResponse(res, cors, 401, { error: 'Unauthorized' });
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
function createHandler() {
|
|
1378
|
+
return async (req, res) => {
|
|
1379
|
+
const cors = {
|
|
1380
|
+
'Access-Control-Allow-Origin': '*',
|
|
1381
|
+
'Access-Control-Allow-Methods': 'GET, POST',
|
|
1382
|
+
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
|
1383
|
+
};
|
|
1384
|
+
|
|
1385
|
+
// Log client IP if trust-proxy is enabled
|
|
1386
|
+
if (TRUST_PROXY) {
|
|
1387
|
+
const clientIp = req.headers['x-forwarded-for'] || req.socket.remoteAddress;
|
|
1388
|
+
log(`${req.method} ${req.url} from ${clientIp}`);
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
const parsedUrl = new URL(req.url, `http://${req.headers.host}`);
|
|
1392
|
+
|
|
1393
|
+
if (req.method === 'OPTIONS') {
|
|
1394
|
+
res.writeHead(200, cors);
|
|
1395
|
+
res.end();
|
|
1396
|
+
return;
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1399
|
+
// ─── P1: Public endpoints (no auth required) ────────────────
|
|
1400
|
+
|
|
1401
|
+
// Serve HTML page — inject auth token as meta tag
|
|
1402
|
+
if (req.method === 'GET' && parsedUrl.pathname === '/') {
|
|
1403
|
+
try {
|
|
1404
|
+
let html = fs.readFileSync(INDEX_HTML, 'utf8');
|
|
1405
|
+
// Inject token into HTML as a meta tag before </head>
|
|
1406
|
+
html = html.replace('</head>', `<meta name="shards-token" content="${AUTH_TOKEN}">\n</head>`);
|
|
1407
|
+
// Electron mode: swap CDN URLs for local vendor paths and inject detection flag
|
|
1408
|
+
if (ELECTRON_MODE) {
|
|
1409
|
+
html = html.replace('</head>', `<script>window.__shardsElectronServer = true;</script>\n</head>`);
|
|
1410
|
+
if (VENDOR_DIR) {
|
|
1411
|
+
html = html.replace('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap', 'vendor/inter-font/inter.css');
|
|
1412
|
+
html = html.replace('https://unpkg.com/tabulator-tables@6.3.1/dist/css/tabulator_midnight.min.css', 'vendor/tabulator-tables/tabulator_midnight.min.css');
|
|
1413
|
+
html = html.replace('https://unpkg.com/tabulator-tables@6.3.1/dist/js/tabulator.min.js', 'vendor/tabulator-tables/tabulator.min.js');
|
|
1414
|
+
html = html.replace('https://cdn.plot.ly/plotly-2.35.2.min.js', 'vendor/plotly/plotly.min.js');
|
|
1415
|
+
html = html.replace('https://cdn.jsdelivr.net/npm/mermaid@11.4.1/dist/mermaid.min.js', 'vendor/mermaid/mermaid.min.js');
|
|
1416
|
+
// Inject xterm.js and fit addon for terminal support
|
|
1417
|
+
html = html.replace('</head>',
|
|
1418
|
+
'<link rel="stylesheet" href="vendor/xterm/xterm.css">\n' +
|
|
1419
|
+
'<script src="vendor/xterm/xterm.js"><\/script>\n' +
|
|
1420
|
+
'<script src="vendor/xterm/xterm-addon-fit.js"><\/script>\n' +
|
|
1421
|
+
'</head>');
|
|
1422
|
+
}
|
|
1423
|
+
}
|
|
1424
|
+
res.writeHead(200, { ...cors, 'Content-Type': 'text/html; charset=utf-8' });
|
|
1425
|
+
res.end(html);
|
|
1426
|
+
} catch {
|
|
1427
|
+
res.writeHead(500);
|
|
1428
|
+
res.end('index.html not found');
|
|
1429
|
+
}
|
|
1430
|
+
return;
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1433
|
+
// Static file serving (JS/CSS/images) — no auth (public assets)
|
|
1434
|
+
if (req.method === 'GET') {
|
|
1435
|
+
const MIME = {
|
|
1436
|
+
'.js': 'application/javascript', '.css': 'text/css',
|
|
1437
|
+
'.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
|
|
1438
|
+
'.gif': 'image/gif', '.svg': 'image/svg+xml', '.webp': 'image/webp',
|
|
1439
|
+
'.ico': 'image/x-icon',
|
|
1440
|
+
'.woff': 'font/woff', '.woff2': 'font/woff2', '.ttf': 'font/ttf',
|
|
1441
|
+
};
|
|
1442
|
+
const ext = path.extname(parsedUrl.pathname).toLowerCase();
|
|
1443
|
+
if (MIME[ext]) {
|
|
1444
|
+
const decodedPath = decodeURIComponent(parsedUrl.pathname);
|
|
1445
|
+
let filePath = path.join(__dirname, decodedPath);
|
|
1446
|
+
let resolved = path.resolve(filePath);
|
|
1447
|
+
// In Electron mode, also serve from vendor directory
|
|
1448
|
+
if (ELECTRON_MODE && VENDOR_DIR && decodedPath.startsWith('/vendor/')) {
|
|
1449
|
+
filePath = path.join(VENDOR_DIR, decodedPath.slice('/vendor/'.length));
|
|
1450
|
+
resolved = path.resolve(filePath);
|
|
1451
|
+
if (!resolved.startsWith(path.resolve(VENDOR_DIR))) {
|
|
1452
|
+
res.writeHead(403);
|
|
1453
|
+
res.end('Forbidden');
|
|
1454
|
+
return;
|
|
1455
|
+
}
|
|
1456
|
+
} else if (!resolved.startsWith(__dirname)) {
|
|
1457
|
+
res.writeHead(403);
|
|
1458
|
+
res.end('Forbidden');
|
|
1459
|
+
return;
|
|
1460
|
+
}
|
|
1461
|
+
const isBinary = !['.js', '.css', '.svg'].includes(ext);
|
|
1462
|
+
try {
|
|
1463
|
+
const content = fs.readFileSync(resolved, isBinary ? null : 'utf8');
|
|
1464
|
+
res.writeHead(200, { ...cors, 'Content-Type': MIME[ext] });
|
|
1465
|
+
res.end(content);
|
|
1466
|
+
} catch {
|
|
1467
|
+
res.writeHead(404);
|
|
1468
|
+
res.end('Not found');
|
|
1469
|
+
}
|
|
1470
|
+
return;
|
|
1471
|
+
}
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
// ─── Developer Guide (docs) ──────────────────────────────────────────────
|
|
1475
|
+
// Serves the Shards Developer Guide from .shards/ui/docs/ — no auth,
|
|
1476
|
+
// public static docs. __dirname here is the .shards/ui/ directory in the
|
|
1477
|
+
// target install.
|
|
1478
|
+
const DOCS_DIR = path.join(__dirname, 'docs');
|
|
1479
|
+
|
|
1480
|
+
if (req.method === 'GET' && parsedUrl.pathname === '/docs/manifest') {
|
|
1481
|
+
try {
|
|
1482
|
+
const raw = fs.readFileSync(path.join(DOCS_DIR, 'manifest.json'), 'utf8');
|
|
1483
|
+
res.writeHead(200, { ...cors, 'Content-Type': 'application/json' });
|
|
1484
|
+
res.end(raw);
|
|
1485
|
+
} catch {
|
|
1486
|
+
res.writeHead(404, { ...cors });
|
|
1487
|
+
res.end('Guide manifest not found');
|
|
1488
|
+
}
|
|
1489
|
+
return;
|
|
1490
|
+
}
|
|
1491
|
+
|
|
1492
|
+
if (req.method === 'GET' && parsedUrl.pathname === '/docs/page') {
|
|
1493
|
+
const file = parsedUrl.searchParams.get('file');
|
|
1494
|
+
if (!file) {
|
|
1495
|
+
res.writeHead(400, { ...cors });
|
|
1496
|
+
res.end('Missing file');
|
|
1497
|
+
return;
|
|
1498
|
+
}
|
|
1499
|
+
const filePath = path.join(DOCS_DIR, file);
|
|
1500
|
+
const resolved = path.resolve(filePath);
|
|
1501
|
+
if (!resolved.startsWith(path.resolve(DOCS_DIR) + path.sep)) {
|
|
1502
|
+
res.writeHead(403, { ...cors });
|
|
1503
|
+
res.end('Forbidden');
|
|
1504
|
+
return;
|
|
1505
|
+
}
|
|
1506
|
+
if (!resolved.endsWith('.md')) {
|
|
1507
|
+
res.writeHead(400, { ...cors });
|
|
1508
|
+
res.end('Only .md files are served');
|
|
1509
|
+
return;
|
|
1510
|
+
}
|
|
1511
|
+
try {
|
|
1512
|
+
const content = fs.readFileSync(resolved, 'utf8');
|
|
1513
|
+
res.writeHead(200, { ...cors, 'Content-Type': 'text/markdown; charset=utf-8' });
|
|
1514
|
+
res.end(content);
|
|
1515
|
+
} catch {
|
|
1516
|
+
res.writeHead(404, { ...cors });
|
|
1517
|
+
res.end('Not found');
|
|
1518
|
+
}
|
|
1519
|
+
return;
|
|
1520
|
+
}
|
|
1521
|
+
|
|
1522
|
+
// ─── Knowledge Ledger bulk-read ──────────────────────────────────────────
|
|
1523
|
+
if (req.method === 'GET' && parsedUrl.pathname === '/knowledge/entries') {
|
|
1524
|
+
if (!checkAuth(req, parsedUrl)) { rejectAuth(res, cors); return; }
|
|
1525
|
+
const KNOWLEDGE_CATEGORIES = ['entities', 'infrastructure', 'patterns', 'features'];
|
|
1526
|
+
const indexContent = readFileSafe(path.join(KNOWLEDGE_DIR, 'INDEX.md')) || '';
|
|
1527
|
+
const entries = [];
|
|
1528
|
+
for (const cat of KNOWLEDGE_CATEGORIES) {
|
|
1529
|
+
const catDir = path.join(KNOWLEDGE_DIR, cat);
|
|
1530
|
+
try {
|
|
1531
|
+
const files = fs.readdirSync(catDir, { withFileTypes: true });
|
|
1532
|
+
for (const f of files) {
|
|
1533
|
+
if (!f.isFile() || !f.name.endsWith('.md')) continue;
|
|
1534
|
+
const fullPath = path.join(catDir, f.name);
|
|
1535
|
+
const content = readFileSafe(fullPath);
|
|
1536
|
+
if (content !== null) {
|
|
1537
|
+
entries.push({
|
|
1538
|
+
path: path.relative(PROJECT_DIR, fullPath),
|
|
1539
|
+
content,
|
|
1540
|
+
});
|
|
1541
|
+
}
|
|
1542
|
+
}
|
|
1543
|
+
} catch { /* directory missing — skip */ }
|
|
1544
|
+
}
|
|
1545
|
+
jsonResponse(res, cors, 200, { indexContent, entries });
|
|
1546
|
+
return;
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1549
|
+
/**
|
|
1550
|
+
* Resolve a user-supplied path and verify it stays within PROJECT_DIR.
|
|
1551
|
+
* Returns the resolved absolute path, or null if out of bounds.
|
|
1552
|
+
*/
|
|
1553
|
+
function resolveSafe(userPath) {
|
|
1554
|
+
const resolved = path.resolve(userPath);
|
|
1555
|
+
if (resolved !== PROJECT_DIR && !resolved.startsWith(PROJECT_DIR + path.sep)) {
|
|
1556
|
+
return null;
|
|
1557
|
+
}
|
|
1558
|
+
return resolved;
|
|
1559
|
+
}
|
|
1560
|
+
|
|
1561
|
+
// Raw file serving (images, PDFs) — auth via query param
|
|
1562
|
+
if (req.method === 'GET' && parsedUrl.pathname === '/browse/file/raw') {
|
|
1563
|
+
if (!checkAuth(req, parsedUrl)) {
|
|
1564
|
+
rejectAuth(res, cors);
|
|
1565
|
+
return;
|
|
1566
|
+
}
|
|
1567
|
+
const filePath = parsedUrl.searchParams.get('path');
|
|
1568
|
+
if (!filePath) {
|
|
1569
|
+
res.writeHead(400);
|
|
1570
|
+
res.end('Missing path');
|
|
1571
|
+
return;
|
|
1572
|
+
}
|
|
1573
|
+
const resolved = resolveSafe(filePath);
|
|
1574
|
+
if (!resolved) {
|
|
1575
|
+
res.writeHead(403);
|
|
1576
|
+
res.end('Forbidden');
|
|
1577
|
+
return;
|
|
1578
|
+
}
|
|
1579
|
+
const RAW_MIME = {
|
|
1580
|
+
'.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
|
|
1581
|
+
'.gif': 'image/gif', '.webp': 'image/webp', '.svg': 'image/svg+xml',
|
|
1582
|
+
'.bmp': 'image/bmp', '.ico': 'image/x-icon',
|
|
1583
|
+
'.pdf': 'application/pdf',
|
|
1584
|
+
};
|
|
1585
|
+
const rawExt = path.extname(resolved).toLowerCase();
|
|
1586
|
+
const mimeType = RAW_MIME[rawExt];
|
|
1587
|
+
if (!mimeType) {
|
|
1588
|
+
res.writeHead(415);
|
|
1589
|
+
res.end('Unsupported file type');
|
|
1590
|
+
return;
|
|
1591
|
+
}
|
|
1592
|
+
try {
|
|
1593
|
+
const stat = fs.statSync(resolved);
|
|
1594
|
+
if (stat.size > 50 * 1024 * 1024) {
|
|
1595
|
+
res.writeHead(413);
|
|
1596
|
+
res.end('File too large (>50MB)');
|
|
1597
|
+
return;
|
|
1598
|
+
}
|
|
1599
|
+
const data = fs.readFileSync(resolved);
|
|
1600
|
+
res.writeHead(200, {
|
|
1601
|
+
...cors,
|
|
1602
|
+
'Content-Type': mimeType,
|
|
1603
|
+
'Content-Length': data.length,
|
|
1604
|
+
});
|
|
1605
|
+
res.end(data);
|
|
1606
|
+
} catch (err) {
|
|
1607
|
+
res.writeHead(404);
|
|
1608
|
+
res.end('File not found');
|
|
1609
|
+
}
|
|
1610
|
+
return;
|
|
1611
|
+
}
|
|
1612
|
+
|
|
1613
|
+
// Text file serving (for pinboard context)
|
|
1614
|
+
if (req.method === 'GET' && parsedUrl.pathname === '/browse/file/text') {
|
|
1615
|
+
if (!checkAuth(req, parsedUrl)) {
|
|
1616
|
+
rejectAuth(res, cors);
|
|
1617
|
+
return;
|
|
1618
|
+
}
|
|
1619
|
+
const filePath = parsedUrl.searchParams.get('path');
|
|
1620
|
+
if (!filePath) {
|
|
1621
|
+
res.writeHead(400, { ...cors, 'Content-Type': 'application/json' });
|
|
1622
|
+
res.end(JSON.stringify({ error: 'Missing path' }));
|
|
1623
|
+
return;
|
|
1624
|
+
}
|
|
1625
|
+
const resolved = resolveSafe(filePath);
|
|
1626
|
+
if (!resolved) {
|
|
1627
|
+
res.writeHead(403, { ...cors, 'Content-Type': 'application/json' });
|
|
1628
|
+
res.end(JSON.stringify({ error: 'Forbidden' }));
|
|
1629
|
+
return;
|
|
1630
|
+
}
|
|
1631
|
+
try {
|
|
1632
|
+
const stat = fs.statSync(resolved);
|
|
1633
|
+
if (!stat.isFile()) {
|
|
1634
|
+
res.writeHead(400, { ...cors, 'Content-Type': 'application/json' });
|
|
1635
|
+
res.end(JSON.stringify({ error: 'Not a file' }));
|
|
1636
|
+
return;
|
|
1637
|
+
}
|
|
1638
|
+
if (stat.size > 1024 * 1024) {
|
|
1639
|
+
res.writeHead(413, { ...cors, 'Content-Type': 'application/json' });
|
|
1640
|
+
res.end(JSON.stringify({ error: 'File too large (>1MB)' }));
|
|
1641
|
+
return;
|
|
1642
|
+
}
|
|
1643
|
+
const content = fs.readFileSync(resolved, 'utf-8');
|
|
1644
|
+
res.writeHead(200, { ...cors, 'Content-Type': 'text/plain; charset=utf-8' });
|
|
1645
|
+
res.end(content);
|
|
1646
|
+
} catch (err) {
|
|
1647
|
+
res.writeHead(404, { ...cors, 'Content-Type': 'application/json' });
|
|
1648
|
+
res.end(JSON.stringify({ error: 'File not found' }));
|
|
1649
|
+
}
|
|
1650
|
+
return;
|
|
1651
|
+
}
|
|
1652
|
+
|
|
1653
|
+
// ─── P1: All remaining endpoints require auth ───────────────
|
|
1654
|
+
|
|
1655
|
+
if (!checkAuth(req, parsedUrl)) {
|
|
1656
|
+
rejectAuth(res, cors);
|
|
1657
|
+
return;
|
|
1658
|
+
}
|
|
1659
|
+
|
|
1660
|
+
// ─── SSE endpoint (auth via query param token) ──────────────
|
|
1661
|
+
|
|
1662
|
+
if (req.method === 'GET' && parsedUrl.pathname === '/events') {
|
|
1663
|
+
const filterSessionId = parsedUrl.searchParams.get('sessionId') || null;
|
|
1664
|
+
res.writeHead(200, {
|
|
1665
|
+
...cors,
|
|
1666
|
+
'Content-Type': 'text/event-stream',
|
|
1667
|
+
'Cache-Control': 'no-cache',
|
|
1668
|
+
'Connection': 'keep-alive',
|
|
1669
|
+
});
|
|
1670
|
+
res.write('data: {"type":"connected"}\n\n');
|
|
1671
|
+
clients.push({ res, sessionId: filterSessionId });
|
|
1672
|
+
req.on('close', () => {
|
|
1673
|
+
clients = clients.filter((c) => c.res !== res);
|
|
1674
|
+
});
|
|
1675
|
+
return;
|
|
1676
|
+
}
|
|
1677
|
+
|
|
1678
|
+
if (req.method === 'GET' && parsedUrl.pathname === '/transcript') {
|
|
1679
|
+
const sid = parsedUrl.searchParams.get('sessionId');
|
|
1680
|
+
if (sid) {
|
|
1681
|
+
const store = getSession(sid);
|
|
1682
|
+
jsonResponse(res, cors, 200, store ? store.transcript : []);
|
|
1683
|
+
} else {
|
|
1684
|
+
// Return all transcripts merged
|
|
1685
|
+
const all = [];
|
|
1686
|
+
for (const store of sessions.values()) {
|
|
1687
|
+
all.push(...store.transcript);
|
|
1688
|
+
}
|
|
1689
|
+
jsonResponse(res, cors, 200, all);
|
|
1690
|
+
}
|
|
1691
|
+
return;
|
|
1692
|
+
}
|
|
1693
|
+
|
|
1694
|
+
if (req.method === 'GET' && parsedUrl.pathname === '/files') {
|
|
1695
|
+
const allSessionFiles = new Set();
|
|
1696
|
+
for (const store of sessions.values()) {
|
|
1697
|
+
for (const f of store.sessionFiles) allSessionFiles.add(f);
|
|
1698
|
+
}
|
|
1699
|
+
jsonResponse(res, cors, 200, { files, sessionFiles: [...allSessionFiles] });
|
|
1700
|
+
return;
|
|
1701
|
+
}
|
|
1702
|
+
|
|
1703
|
+
// ─── P3: Sessions list endpoint ─────────────────────────────
|
|
1704
|
+
|
|
1705
|
+
if (req.method === 'GET' && parsedUrl.pathname === '/sessions') {
|
|
1706
|
+
const list = [];
|
|
1707
|
+
for (const [id, store] of sessions) {
|
|
1708
|
+
list.push({
|
|
1709
|
+
sessionId: id,
|
|
1710
|
+
agent: store.agent,
|
|
1711
|
+
title: store.title,
|
|
1712
|
+
active: !!(store.chatSession && store.chatSession.isRunning),
|
|
1713
|
+
createdAt: store.createdAt.toISOString(),
|
|
1714
|
+
lastActivityAt: store.lastActivityAt.toISOString(),
|
|
1715
|
+
messageCount: store.transcript.length,
|
|
1716
|
+
projectName: store.projectName,
|
|
1717
|
+
projectDir: store.projectDir,
|
|
1718
|
+
});
|
|
1719
|
+
}
|
|
1720
|
+
jsonResponse(res, cors, 200, list);
|
|
1721
|
+
return;
|
|
1722
|
+
}
|
|
1723
|
+
|
|
1724
|
+
// ─── Disk-backed session index (includes ended/abandoned) ───
|
|
1725
|
+
if (req.method === 'GET' && parsedUrl.pathname === '/sessions/index') {
|
|
1726
|
+
const project = parsedUrl.searchParams.get('project') || undefined;
|
|
1727
|
+
const agent = parsedUrl.searchParams.get('agent') || undefined;
|
|
1728
|
+
const status = parsedUrl.searchParams.get('status') || undefined;
|
|
1729
|
+
let entries;
|
|
1730
|
+
try {
|
|
1731
|
+
entries = sessionIndex.listSessions(SESSIONS_DIR, { project, agent, status });
|
|
1732
|
+
} catch (err) {
|
|
1733
|
+
jsonResponse(res, cors, 500, { error: err.message });
|
|
1734
|
+
return;
|
|
1735
|
+
}
|
|
1736
|
+
// Annotate each entry with `active: true` if there's a live in-memory
|
|
1737
|
+
// session for it. The disk record may be stale; the in-memory map is
|
|
1738
|
+
// authoritative for "is the process actually running right now."
|
|
1739
|
+
const annotated = entries.map((e) => {
|
|
1740
|
+
const live = sessions.get(e.sessionId);
|
|
1741
|
+
return {
|
|
1742
|
+
...e,
|
|
1743
|
+
active: !!(live && live.chatSession && live.chatSession.isRunning),
|
|
1744
|
+
};
|
|
1745
|
+
});
|
|
1746
|
+
jsonResponse(res, cors, 200, { version: sessionIndex.INDEX_VERSION, sessions: annotated });
|
|
1747
|
+
return;
|
|
1748
|
+
}
|
|
1749
|
+
|
|
1750
|
+
// ─── Directory browser endpoints ───────────────────────────────────
|
|
1751
|
+
|
|
1752
|
+
if (req.method === 'GET' && parsedUrl.pathname.startsWith('/browse')) {
|
|
1753
|
+
|
|
1754
|
+
// ─── File search endpoint ──────────────────────────────────
|
|
1755
|
+
if (parsedUrl.pathname === '/browse/search') {
|
|
1756
|
+
const query = (parsedUrl.searchParams.get('q') || '').toLowerCase();
|
|
1757
|
+
if (!query) {
|
|
1758
|
+
jsonResponse(res, cors, 200, { results: [] });
|
|
1759
|
+
return;
|
|
1760
|
+
}
|
|
1761
|
+
const results = [];
|
|
1762
|
+
const skip = new Set(['.git', 'node_modules', '__pycache__', '.shards', '.venv', 'venv']);
|
|
1763
|
+
function walkSearch(dir, depth) {
|
|
1764
|
+
if (depth > 8 || results.length >= 50) return;
|
|
1765
|
+
try {
|
|
1766
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
1767
|
+
for (const entry of entries) {
|
|
1768
|
+
if (results.length >= 50) break;
|
|
1769
|
+
if (entry.name.startsWith('.') && skip.has(entry.name)) continue;
|
|
1770
|
+
if (skip.has(entry.name)) continue;
|
|
1771
|
+
const full = path.join(dir, entry.name);
|
|
1772
|
+
if (entry.isDirectory()) {
|
|
1773
|
+
walkSearch(full, depth + 1);
|
|
1774
|
+
} else {
|
|
1775
|
+
const rel = path.relative(PROJECT_DIR, full);
|
|
1776
|
+
if (rel.toLowerCase().indexOf(query) !== -1) {
|
|
1777
|
+
results.push(rel);
|
|
1778
|
+
}
|
|
1779
|
+
}
|
|
1780
|
+
}
|
|
1781
|
+
} catch {}
|
|
1782
|
+
}
|
|
1783
|
+
walkSearch(PROJECT_DIR, 0);
|
|
1784
|
+
jsonResponse(res, cors, 200, { results });
|
|
1785
|
+
return;
|
|
1786
|
+
}
|
|
1787
|
+
|
|
1788
|
+
if (parsedUrl.pathname === '/browse') {
|
|
1789
|
+
let dir = parsedUrl.searchParams.get('dir') || PROJECT_DIR;
|
|
1790
|
+
dir = resolveSafe(dir);
|
|
1791
|
+
if (!dir) {
|
|
1792
|
+
jsonResponse(res, cors, 403, { error: 'Forbidden' });
|
|
1793
|
+
return;
|
|
1794
|
+
}
|
|
1795
|
+
|
|
1796
|
+
try {
|
|
1797
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
1798
|
+
const items = [];
|
|
1799
|
+
for (const entry of entries) {
|
|
1800
|
+
if (entry.name.startsWith('.') && entry.name !== '..') continue;
|
|
1801
|
+
const fullPath = path.join(dir, entry.name);
|
|
1802
|
+
const isDir = entry.isDirectory();
|
|
1803
|
+
let size = 0;
|
|
1804
|
+
if (!isDir) {
|
|
1805
|
+
try { size = fs.statSync(fullPath).size; } catch {}
|
|
1806
|
+
}
|
|
1807
|
+
items.push({ name: entry.name, type: isDir ? 'dir' : 'file', size });
|
|
1808
|
+
}
|
|
1809
|
+
items.sort((a, b) => {
|
|
1810
|
+
if (a.type !== b.type) return a.type === 'dir' ? -1 : 1;
|
|
1811
|
+
return a.name.localeCompare(b.name);
|
|
1812
|
+
});
|
|
1813
|
+
|
|
1814
|
+
const parent = path.dirname(dir);
|
|
1815
|
+
jsonResponse(res, cors, 200, {
|
|
1816
|
+
path: dir,
|
|
1817
|
+
parent: parent !== dir ? parent : null,
|
|
1818
|
+
entries: items,
|
|
1819
|
+
});
|
|
1820
|
+
} catch (err) {
|
|
1821
|
+
jsonResponse(res, cors, 400, { error: `Cannot read directory: ${err.message}` });
|
|
1822
|
+
}
|
|
1823
|
+
return;
|
|
1824
|
+
}
|
|
1825
|
+
|
|
1826
|
+
if (parsedUrl.pathname === '/browse/file') {
|
|
1827
|
+
const filePath = parsedUrl.searchParams.get('path');
|
|
1828
|
+
if (!filePath) {
|
|
1829
|
+
jsonResponse(res, cors, 400, { error: 'Missing path parameter' });
|
|
1830
|
+
return;
|
|
1831
|
+
}
|
|
1832
|
+
const resolved = resolveSafe(filePath);
|
|
1833
|
+
if (!resolved) {
|
|
1834
|
+
jsonResponse(res, cors, 403, { error: 'Forbidden' });
|
|
1835
|
+
return;
|
|
1836
|
+
}
|
|
1837
|
+
try {
|
|
1838
|
+
const stat = fs.statSync(resolved);
|
|
1839
|
+
const isNotebook = resolved.endsWith('.ipynb');
|
|
1840
|
+
const maxSize = isNotebook ? 10 * 1024 * 1024 : 2 * 1024 * 1024;
|
|
1841
|
+
if (stat.size > maxSize) {
|
|
1842
|
+
jsonResponse(res, cors, 400, { error: `File too large (>${isNotebook ? '10' : '2'}MB)` });
|
|
1843
|
+
return;
|
|
1844
|
+
}
|
|
1845
|
+
const content = fs.readFileSync(resolved, 'utf8');
|
|
1846
|
+
const relPath = path.relative(PROJECT_DIR, resolved);
|
|
1847
|
+
jsonResponse(res, cors, 200, { path: resolved, relPath, content });
|
|
1848
|
+
} catch (err) {
|
|
1849
|
+
jsonResponse(res, cors, 400, { error: `Cannot read file: ${err.message}` });
|
|
1850
|
+
}
|
|
1851
|
+
return;
|
|
1852
|
+
}
|
|
1853
|
+
}
|
|
1854
|
+
|
|
1855
|
+
// ─── Symbol / Code Intelligence endpoints ─────────────────────
|
|
1856
|
+
|
|
1857
|
+
if (req.method === 'GET' && parsedUrl.pathname === '/symbols/status') {
|
|
1858
|
+
jsonResponse(res, cors, 200, symbolIndex.getStatus());
|
|
1859
|
+
return;
|
|
1860
|
+
}
|
|
1861
|
+
|
|
1862
|
+
if (req.method === 'POST' && parsedUrl.pathname === '/symbols/reindex') {
|
|
1863
|
+
try {
|
|
1864
|
+
symbolIndex.buildIndex(PROJECT_DIR, log);
|
|
1865
|
+
jsonResponse(res, cors, 200, { ok: true, ...symbolIndex.getStatus() });
|
|
1866
|
+
} catch (err) {
|
|
1867
|
+
jsonResponse(res, cors, 500, { error: `Reindex failed: ${err.message}` });
|
|
1868
|
+
}
|
|
1869
|
+
return;
|
|
1870
|
+
}
|
|
1871
|
+
|
|
1872
|
+
if (req.method === 'GET' && parsedUrl.pathname === '/symbols/definition') {
|
|
1873
|
+
const name = parsedUrl.searchParams.get('name');
|
|
1874
|
+
const file = parsedUrl.searchParams.get('file') || '';
|
|
1875
|
+
if (!name) {
|
|
1876
|
+
jsonResponse(res, cors, 400, { error: 'Missing name parameter' });
|
|
1877
|
+
return;
|
|
1878
|
+
}
|
|
1879
|
+
const definitions = symbolIndex.getDefinitions(name, file);
|
|
1880
|
+
// Add preview line from disk
|
|
1881
|
+
for (const def of definitions) {
|
|
1882
|
+
try {
|
|
1883
|
+
const absPath = path.join(PROJECT_DIR, def.file);
|
|
1884
|
+
const lines = fs.readFileSync(absPath, 'utf8').split('\n');
|
|
1885
|
+
def.preview = lines[def.line - 1] || def.pattern || '';
|
|
1886
|
+
} catch {
|
|
1887
|
+
def.preview = def.pattern || '';
|
|
1888
|
+
}
|
|
1889
|
+
}
|
|
1890
|
+
jsonResponse(res, cors, 200, { definitions });
|
|
1891
|
+
return;
|
|
1892
|
+
}
|
|
1893
|
+
|
|
1894
|
+
if (req.method === 'GET' && parsedUrl.pathname === '/symbols/references') {
|
|
1895
|
+
const name = parsedUrl.searchParams.get('name');
|
|
1896
|
+
const file = parsedUrl.searchParams.get('file') || '';
|
|
1897
|
+
if (!name) {
|
|
1898
|
+
jsonResponse(res, cors, 400, { error: 'Missing name parameter' });
|
|
1899
|
+
return;
|
|
1900
|
+
}
|
|
1901
|
+
const references = symbolIndex.getReferences(name, PROJECT_DIR);
|
|
1902
|
+
jsonResponse(res, cors, 200, { references });
|
|
1903
|
+
return;
|
|
1904
|
+
}
|
|
1905
|
+
|
|
1906
|
+
if (req.method === 'GET' && parsedUrl.pathname === '/symbols/hover') {
|
|
1907
|
+
const name = parsedUrl.searchParams.get('name');
|
|
1908
|
+
const file = parsedUrl.searchParams.get('file') || '';
|
|
1909
|
+
const line = parseInt(parsedUrl.searchParams.get('line') || '0', 10);
|
|
1910
|
+
if (!name) {
|
|
1911
|
+
jsonResponse(res, cors, 400, { error: 'Missing name parameter' });
|
|
1912
|
+
return;
|
|
1913
|
+
}
|
|
1914
|
+
const info = symbolIndex.getHoverEnriched(name, file, line);
|
|
1915
|
+
if (info) {
|
|
1916
|
+
jsonResponse(res, cors, 200, info);
|
|
1917
|
+
} else {
|
|
1918
|
+
jsonResponse(res, cors, 200, { found: false });
|
|
1919
|
+
}
|
|
1920
|
+
return;
|
|
1921
|
+
}
|
|
1922
|
+
|
|
1923
|
+
if (req.method === 'GET' && parsedUrl.pathname === '/symbols/completions') {
|
|
1924
|
+
const prefix = parsedUrl.searchParams.get('prefix') || '';
|
|
1925
|
+
const file = parsedUrl.searchParams.get('file') || '';
|
|
1926
|
+
if (!prefix) {
|
|
1927
|
+
jsonResponse(res, cors, 200, { completions: [] });
|
|
1928
|
+
return;
|
|
1929
|
+
}
|
|
1930
|
+
const completions = symbolIndex.getCompletions(prefix, file);
|
|
1931
|
+
jsonResponse(res, cors, 200, { completions });
|
|
1932
|
+
return;
|
|
1933
|
+
}
|
|
1934
|
+
|
|
1935
|
+
// ─── File save endpoint ─────────────────────────────────────────
|
|
1936
|
+
if (req.method === 'POST' && parsedUrl.pathname === '/browse/file/save') {
|
|
1937
|
+
const body = await readBody(req);
|
|
1938
|
+
let params;
|
|
1939
|
+
try {
|
|
1940
|
+
params = JSON.parse(body);
|
|
1941
|
+
} catch {
|
|
1942
|
+
jsonResponse(res, cors, 400, { error: 'Invalid JSON' });
|
|
1943
|
+
return;
|
|
1944
|
+
}
|
|
1945
|
+
|
|
1946
|
+
const { path: filePath, content } = params;
|
|
1947
|
+
if (!filePath || typeof content !== 'string') {
|
|
1948
|
+
jsonResponse(res, cors, 400, { error: 'Missing path or content parameter' });
|
|
1949
|
+
return;
|
|
1950
|
+
}
|
|
1951
|
+
|
|
1952
|
+
const resolved = resolveSafe(filePath);
|
|
1953
|
+
if (!resolved) {
|
|
1954
|
+
jsonResponse(res, cors, 403, { error: 'Forbidden' });
|
|
1955
|
+
return;
|
|
1956
|
+
}
|
|
1957
|
+
try {
|
|
1958
|
+
fs.writeFileSync(resolved, content, 'utf8');
|
|
1959
|
+
// Update symbol index for saved file
|
|
1960
|
+
try {
|
|
1961
|
+
const relPath = path.relative(PROJECT_DIR, resolved);
|
|
1962
|
+
symbolIndex.updateFileIndex(PROJECT_DIR, relPath);
|
|
1963
|
+
} catch {}
|
|
1964
|
+
jsonResponse(res, cors, 200, { ok: true, path: resolved });
|
|
1965
|
+
} catch (err) {
|
|
1966
|
+
jsonResponse(res, cors, 500, { error: `Cannot write file: ${err.message}` });
|
|
1967
|
+
}
|
|
1968
|
+
return;
|
|
1969
|
+
}
|
|
1970
|
+
|
|
1971
|
+
// ─── CSV/TSV parse endpoint ────────────────────────────────────
|
|
1972
|
+
if (req.method === 'POST' && parsedUrl.pathname === '/browse/file/parse-csv') {
|
|
1973
|
+
const body = await readBody(req);
|
|
1974
|
+
let params;
|
|
1975
|
+
try {
|
|
1976
|
+
params = JSON.parse(body);
|
|
1977
|
+
} catch {
|
|
1978
|
+
jsonResponse(res, cors, 400, { error: 'Invalid JSON' });
|
|
1979
|
+
return;
|
|
1980
|
+
}
|
|
1981
|
+
|
|
1982
|
+
const { path: filePath, delimiter } = params;
|
|
1983
|
+
if (!filePath || (delimiter !== ',' && delimiter !== '\t')) {
|
|
1984
|
+
jsonResponse(res, cors, 400, { error: 'Missing path or invalid delimiter (use "," or "\\t")' });
|
|
1985
|
+
return;
|
|
1986
|
+
}
|
|
1987
|
+
|
|
1988
|
+
const resolved = resolveSafe(filePath);
|
|
1989
|
+
if (!resolved) {
|
|
1990
|
+
jsonResponse(res, cors, 403, { error: 'Forbidden' });
|
|
1991
|
+
return;
|
|
1992
|
+
}
|
|
1993
|
+
try {
|
|
1994
|
+
const stat = fs.statSync(resolved);
|
|
1995
|
+
if (stat.size > 2 * 1024 * 1024) {
|
|
1996
|
+
jsonResponse(res, cors, 400, { error: 'File too large (>2MB)' });
|
|
1997
|
+
return;
|
|
1998
|
+
}
|
|
1999
|
+
const content = fs.readFileSync(resolved, 'utf8');
|
|
2000
|
+
const result = parseDelimited(content, delimiter);
|
|
2001
|
+
jsonResponse(res, cors, 200, result);
|
|
2002
|
+
} catch (err) {
|
|
2003
|
+
jsonResponse(res, cors, 400, { error: `Parse failed: ${err.message}` });
|
|
2004
|
+
}
|
|
2005
|
+
return;
|
|
2006
|
+
}
|
|
2007
|
+
|
|
2008
|
+
// ─── Git endpoints ─────────────────────────────────────────────
|
|
2009
|
+
|
|
2010
|
+
// Resolve the active repo directory from an optional ?repo=<relPath> param.
|
|
2011
|
+
// Returns { repoDir, repoRel } where repoRel is the stripped prefix (e.g. "repo-a").
|
|
2012
|
+
function getRepoDir(pu) {
|
|
2013
|
+
const repo = pu.searchParams.get('repo');
|
|
2014
|
+
if (!repo || repo === '.' || repo === '') return { repoDir: PROJECT_DIR, repoRel: '' };
|
|
2015
|
+
// Strip any path traversal attempts
|
|
2016
|
+
const safe = path.normalize(repo).replace(/^(\.\.\/|\/|\.\.\\|\\)+/, '');
|
|
2017
|
+
if (!safe) return { repoDir: PROJECT_DIR, repoRel: '' };
|
|
2018
|
+
const resolved = path.resolve(PROJECT_DIR, safe);
|
|
2019
|
+
// Must stay inside PROJECT_DIR
|
|
2020
|
+
if (resolved !== PROJECT_DIR && !resolved.startsWith(PROJECT_DIR + path.sep)) {
|
|
2021
|
+
return { repoDir: PROJECT_DIR, repoRel: '' };
|
|
2022
|
+
}
|
|
2023
|
+
return { repoDir: resolved, repoRel: safe };
|
|
2024
|
+
}
|
|
2025
|
+
|
|
2026
|
+
// Strip a repo-relative prefix from a file path so git commands get the
|
|
2027
|
+
// right path relative to their cwd (the subrepo root).
|
|
2028
|
+
function stripRepoPrefix(filePath, repoRel) {
|
|
2029
|
+
if (!repoRel) return filePath;
|
|
2030
|
+
const prefix = repoRel.replace(/\\/g, '/') + '/';
|
|
2031
|
+
const fp = filePath.replace(/\\/g, '/');
|
|
2032
|
+
return fp.startsWith(prefix) ? fp.slice(prefix.length) : fp;
|
|
2033
|
+
}
|
|
2034
|
+
|
|
2035
|
+
// GET /git/repos — discover all git repos at PROJECT_DIR and one level deep
|
|
2036
|
+
if (req.method === 'GET' && parsedUrl.pathname === '/git/repos') {
|
|
2037
|
+
const repos = [];
|
|
2038
|
+
// Check root itself
|
|
2039
|
+
try {
|
|
2040
|
+
const branch = execSync('git rev-parse --abbrev-ref HEAD', { cwd: PROJECT_DIR, encoding: 'utf8', timeout: 5000 }).trim();
|
|
2041
|
+
repos.push({ name: path.basename(PROJECT_DIR), relPath: '.', branch, isRoot: true });
|
|
2042
|
+
} catch {}
|
|
2043
|
+
// Scan one level of subdirectories
|
|
2044
|
+
try {
|
|
2045
|
+
const entries = fs.readdirSync(PROJECT_DIR, { withFileTypes: true });
|
|
2046
|
+
for (const entry of entries) {
|
|
2047
|
+
if (!entry.isDirectory() || entry.name.startsWith('.')) continue;
|
|
2048
|
+
const subDir = path.join(PROJECT_DIR, entry.name);
|
|
2049
|
+
if (!fs.existsSync(path.join(subDir, '.git'))) continue;
|
|
2050
|
+
try {
|
|
2051
|
+
const branch = execSync('git rev-parse --abbrev-ref HEAD', { cwd: subDir, encoding: 'utf8', timeout: 5000 }).trim();
|
|
2052
|
+
repos.push({ name: entry.name, relPath: entry.name, branch, isRoot: false });
|
|
2053
|
+
} catch {}
|
|
2054
|
+
}
|
|
2055
|
+
} catch {}
|
|
2056
|
+
jsonResponse(res, cors, 200, { repos });
|
|
2057
|
+
return;
|
|
2058
|
+
}
|
|
2059
|
+
|
|
2060
|
+
if (req.method === 'GET' && parsedUrl.pathname === '/git/status') {
|
|
2061
|
+
const { repoDir, repoRel } = getRepoDir(parsedUrl);
|
|
2062
|
+
try {
|
|
2063
|
+
const branch = execSync('git rev-parse --abbrev-ref HEAD', { cwd: repoDir, encoding: 'utf8', timeout: 5000 }).trim();
|
|
2064
|
+
const statusRaw = execSync('git status --porcelain', { cwd: repoDir, encoding: 'utf8', timeout: 5000 });
|
|
2065
|
+
const changes = [];
|
|
2066
|
+
for (const line of statusRaw.split('\n')) {
|
|
2067
|
+
if (!line) continue;
|
|
2068
|
+
const xy = line.substring(0, 2);
|
|
2069
|
+
const filePath = line.substring(3).trim();
|
|
2070
|
+
// Skip renamed file arrow notation — use destination
|
|
2071
|
+
const actualPath = filePath.includes(' -> ') ? filePath.split(' -> ')[1] : filePath;
|
|
2072
|
+
let status = 'modified';
|
|
2073
|
+
if (xy[0] === '?' || xy[1] === '?') status = 'untracked';
|
|
2074
|
+
else if (xy[0] === 'A' || xy[1] === 'A') status = 'added';
|
|
2075
|
+
else if (xy[0] === 'D' || xy[1] === 'D') status = 'deleted';
|
|
2076
|
+
else if (xy[0] === 'R' || xy[1] === 'R') status = 'renamed';
|
|
2077
|
+
const staged = xy[0] !== ' ' && xy[0] !== '?';
|
|
2078
|
+
// Prefix path with repoRel so the browser can locate the file from PROJECT_DIR
|
|
2079
|
+
const fullPath = repoRel && repoRel !== '.' ? repoRel + '/' + actualPath : actualPath;
|
|
2080
|
+
changes.push({ path: fullPath, status, staged });
|
|
2081
|
+
}
|
|
2082
|
+
jsonResponse(res, cors, 200, { branch, changes, repoRel: repoRel || null });
|
|
2083
|
+
} catch (err) {
|
|
2084
|
+
// Not a git repo or git not available
|
|
2085
|
+
jsonResponse(res, cors, 200, { branch: null, changes: [], error: err.message });
|
|
2086
|
+
}
|
|
2087
|
+
return;
|
|
2088
|
+
}
|
|
2089
|
+
|
|
2090
|
+
if (req.method === 'GET' && parsedUrl.pathname === '/git/diff') {
|
|
2091
|
+
const filePath = parsedUrl.searchParams.get('path');
|
|
2092
|
+
if (!filePath) {
|
|
2093
|
+
jsonResponse(res, cors, 400, { error: 'Missing path parameter' });
|
|
2094
|
+
return;
|
|
2095
|
+
}
|
|
2096
|
+
const { repoDir, repoRel } = getRepoDir(parsedUrl);
|
|
2097
|
+
// File path relative to the repo root (strip subrepo prefix if present)
|
|
2098
|
+
const gitPath = stripRepoPrefix(filePath, repoRel);
|
|
2099
|
+
try {
|
|
2100
|
+
// Try staged diff first, then unstaged
|
|
2101
|
+
let diff = '';
|
|
2102
|
+
try {
|
|
2103
|
+
diff = execFileSync('git', ['diff', '--', gitPath], { cwd: repoDir, encoding: 'utf8', timeout: 10000 });
|
|
2104
|
+
} catch {}
|
|
2105
|
+
if (!diff) {
|
|
2106
|
+
try {
|
|
2107
|
+
diff = execFileSync('git', ['diff', '--cached', '--', gitPath], { cwd: repoDir, encoding: 'utf8', timeout: 10000 });
|
|
2108
|
+
} catch {}
|
|
2109
|
+
}
|
|
2110
|
+
// For untracked files, show full content as "added"
|
|
2111
|
+
if (!diff) {
|
|
2112
|
+
const resolved = path.resolve(repoDir, gitPath);
|
|
2113
|
+
try {
|
|
2114
|
+
const content = fs.readFileSync(resolved, 'utf8');
|
|
2115
|
+
diff = '--- /dev/null\n+++ b/' + gitPath + '\n@@ -0,0 +1,' + content.split('\n').length + ' @@\n' +
|
|
2116
|
+
content.split('\n').map(l => '+' + l).join('\n');
|
|
2117
|
+
} catch {}
|
|
2118
|
+
}
|
|
2119
|
+
// Get original (HEAD) content for side-by-side diff
|
|
2120
|
+
let original = '';
|
|
2121
|
+
try {
|
|
2122
|
+
original = execFileSync('git', ['show', `HEAD:${gitPath}`], { cwd: repoDir, encoding: 'utf8', timeout: 5000 });
|
|
2123
|
+
} catch {}
|
|
2124
|
+
// Get current working copy
|
|
2125
|
+
let modified = '';
|
|
2126
|
+
try {
|
|
2127
|
+
const resolved = path.resolve(repoDir, gitPath);
|
|
2128
|
+
modified = fs.readFileSync(resolved, 'utf8');
|
|
2129
|
+
} catch {}
|
|
2130
|
+
jsonResponse(res, cors, 200, { diff, original, modified, path: filePath });
|
|
2131
|
+
} catch (err) {
|
|
2132
|
+
jsonResponse(res, cors, 400, { error: err.message });
|
|
2133
|
+
}
|
|
2134
|
+
return;
|
|
2135
|
+
}
|
|
2136
|
+
|
|
2137
|
+
// ─── GitHub PR endpoints ──────────────────────────────────────────
|
|
2138
|
+
|
|
2139
|
+
if (req.method === 'GET' && parsedUrl.pathname === '/git/pr-info') {
|
|
2140
|
+
const { repoDir: prInfoDir } = getRepoDir(parsedUrl);
|
|
2141
|
+
try {
|
|
2142
|
+
const raw = execSync('gh pr view --json number,title,state,url,headRefName,baseRefName,author,reviewDecision', {
|
|
2143
|
+
cwd: prInfoDir, encoding: 'utf8', timeout: 10000,
|
|
2144
|
+
});
|
|
2145
|
+
const pr = JSON.parse(raw);
|
|
2146
|
+
jsonResponse(res, cors, 200, { pr: {
|
|
2147
|
+
number: pr.number,
|
|
2148
|
+
title: pr.title,
|
|
2149
|
+
state: pr.state,
|
|
2150
|
+
url: pr.url,
|
|
2151
|
+
branch: pr.headRefName,
|
|
2152
|
+
base: pr.baseRefName,
|
|
2153
|
+
author: (pr.author && pr.author.login) ? pr.author.login : String(pr.author || ''),
|
|
2154
|
+
reviewDecision: pr.reviewDecision || null,
|
|
2155
|
+
}});
|
|
2156
|
+
} catch (err) {
|
|
2157
|
+
const msg = err.message || '';
|
|
2158
|
+
if (msg.includes('ENOENT') || msg.includes('executable not found')) {
|
|
2159
|
+
jsonResponse(res, cors, 200, { pr: null, ghMissing: true });
|
|
2160
|
+
} else {
|
|
2161
|
+
jsonResponse(res, cors, 200, { pr: null, error: msg.split('\n')[0] });
|
|
2162
|
+
}
|
|
2163
|
+
}
|
|
2164
|
+
return;
|
|
2165
|
+
}
|
|
2166
|
+
|
|
2167
|
+
if (req.method === 'GET' && parsedUrl.pathname === '/git/pr-comments') {
|
|
2168
|
+
const prNumber = parsedUrl.searchParams.get('pr');
|
|
2169
|
+
if (!prNumber) { jsonResponse(res, cors, 400, { error: 'Missing pr parameter' }); return; }
|
|
2170
|
+
if (!/^\d+$/.test(prNumber)) { jsonResponse(res, cors, 400, { error: 'Invalid pr parameter' }); return; }
|
|
2171
|
+
const { repoDir: prCommentsDir } = getRepoDir(parsedUrl);
|
|
2172
|
+
try {
|
|
2173
|
+
// Resolve owner/repo from gh CLI
|
|
2174
|
+
let repo = null;
|
|
2175
|
+
try {
|
|
2176
|
+
repo = execSync("gh repo view --json owner,name --jq '.owner.login + \"/\" + .name'", {
|
|
2177
|
+
cwd: prCommentsDir, encoding: 'utf8', timeout: 10000,
|
|
2178
|
+
}).trim();
|
|
2179
|
+
} catch {}
|
|
2180
|
+
|
|
2181
|
+
if (!repo) {
|
|
2182
|
+
jsonResponse(res, cors, 200, { error: 'Could not determine repository', threads: [], generalComments: [], reviews: [] });
|
|
2183
|
+
return;
|
|
2184
|
+
}
|
|
2185
|
+
|
|
2186
|
+
// Inline review comments
|
|
2187
|
+
let reviewComments = [];
|
|
2188
|
+
try {
|
|
2189
|
+
const raw = execSync(`gh api repos/${repo}/pulls/${prNumber}/comments --paginate`, {
|
|
2190
|
+
cwd: PROJECT_DIR, encoding: 'utf8', timeout: 30000,
|
|
2191
|
+
});
|
|
2192
|
+
reviewComments = JSON.parse(raw);
|
|
2193
|
+
if (reviewComments.length > 500) reviewComments = reviewComments.slice(0, 500);
|
|
2194
|
+
} catch {}
|
|
2195
|
+
|
|
2196
|
+
// General PR conversation comments
|
|
2197
|
+
let generalComments = [];
|
|
2198
|
+
try {
|
|
2199
|
+
const raw = execSync(`gh api repos/${repo}/issues/${prNumber}/comments --paginate`, {
|
|
2200
|
+
cwd: PROJECT_DIR, encoding: 'utf8', timeout: 30000,
|
|
2201
|
+
});
|
|
2202
|
+
generalComments = JSON.parse(raw).map(function(c) {
|
|
2203
|
+
return {
|
|
2204
|
+
id: c.id,
|
|
2205
|
+
body: c.body,
|
|
2206
|
+
author: (c.user && c.user.login) ? c.user.login : '',
|
|
2207
|
+
createdAt: c.created_at,
|
|
2208
|
+
updatedAt: c.updated_at,
|
|
2209
|
+
url: c.html_url,
|
|
2210
|
+
};
|
|
2211
|
+
});
|
|
2212
|
+
} catch {}
|
|
2213
|
+
|
|
2214
|
+
// Reviews (APPROVED / CHANGES_REQUESTED / etc.)
|
|
2215
|
+
let reviews = [];
|
|
2216
|
+
try {
|
|
2217
|
+
const raw = execSync(`gh api repos/${repo}/pulls/${prNumber}/reviews`, {
|
|
2218
|
+
cwd: PROJECT_DIR, encoding: 'utf8', timeout: 15000,
|
|
2219
|
+
});
|
|
2220
|
+
reviews = JSON.parse(raw).map(function(r) {
|
|
2221
|
+
return {
|
|
2222
|
+
id: r.id,
|
|
2223
|
+
state: r.state,
|
|
2224
|
+
body: r.body || '',
|
|
2225
|
+
author: (r.user && r.user.login) ? r.user.login : '',
|
|
2226
|
+
submittedAt: r.submitted_at,
|
|
2227
|
+
};
|
|
2228
|
+
}).filter(function(r) { return r.state !== 'COMMENTED' || r.body; });
|
|
2229
|
+
} catch {}
|
|
2230
|
+
|
|
2231
|
+
// Thread inline comments: group by path + line + review_id
|
|
2232
|
+
const commentMap = {};
|
|
2233
|
+
for (const c of reviewComments) commentMap[c.id] = c;
|
|
2234
|
+
|
|
2235
|
+
const threadMap = {};
|
|
2236
|
+
const rootComments = reviewComments.filter(function(c) { return !c.in_reply_to_id; });
|
|
2237
|
+
for (const c of rootComments) {
|
|
2238
|
+
const tid = String(c.pull_request_review_id) + ':' + c.path + ':' + (c.line || c.original_line || 0);
|
|
2239
|
+
if (!threadMap[tid]) {
|
|
2240
|
+
threadMap[tid] = {
|
|
2241
|
+
threadId: tid,
|
|
2242
|
+
file: c.path,
|
|
2243
|
+
line: c.line || c.original_line || 0,
|
|
2244
|
+
startLine: c.start_line || c.line || c.original_line || 0,
|
|
2245
|
+
diffHunk: c.diff_hunk || '',
|
|
2246
|
+
resolved: false,
|
|
2247
|
+
comments: [],
|
|
2248
|
+
};
|
|
2249
|
+
}
|
|
2250
|
+
threadMap[tid].comments.push({
|
|
2251
|
+
id: c.id,
|
|
2252
|
+
author: (c.user && c.user.login) ? c.user.login : '',
|
|
2253
|
+
body: c.body,
|
|
2254
|
+
createdAt: c.created_at,
|
|
2255
|
+
url: c.html_url,
|
|
2256
|
+
isReply: false,
|
|
2257
|
+
});
|
|
2258
|
+
}
|
|
2259
|
+
|
|
2260
|
+
// Attach replies
|
|
2261
|
+
const replyComments = reviewComments.filter(function(c) { return !!c.in_reply_to_id; });
|
|
2262
|
+
for (const c of replyComments) {
|
|
2263
|
+
const parent = commentMap[c.in_reply_to_id];
|
|
2264
|
+
if (!parent) continue;
|
|
2265
|
+
const tid = String(parent.pull_request_review_id) + ':' + parent.path + ':' + (parent.line || parent.original_line || 0);
|
|
2266
|
+
if (threadMap[tid]) {
|
|
2267
|
+
threadMap[tid].comments.push({
|
|
2268
|
+
id: c.id,
|
|
2269
|
+
author: (c.user && c.user.login) ? c.user.login : '',
|
|
2270
|
+
body: c.body,
|
|
2271
|
+
createdAt: c.created_at,
|
|
2272
|
+
url: c.html_url,
|
|
2273
|
+
isReply: true,
|
|
2274
|
+
});
|
|
2275
|
+
}
|
|
2276
|
+
}
|
|
2277
|
+
|
|
2278
|
+
const threads = Object.values(threadMap).sort(function(a, b) {
|
|
2279
|
+
return a.file.localeCompare(b.file) || a.line - b.line;
|
|
2280
|
+
});
|
|
2281
|
+
|
|
2282
|
+
jsonResponse(res, cors, 200, { pr: parseInt(prNumber), threads, generalComments, reviews });
|
|
2283
|
+
} catch (err) {
|
|
2284
|
+
const msg = err.message || '';
|
|
2285
|
+
if (msg.includes('ENOENT') || msg.includes('executable not found')) {
|
|
2286
|
+
jsonResponse(res, cors, 200, { error: 'gh CLI not installed', ghMissing: true, threads: [], generalComments: [], reviews: [] });
|
|
2287
|
+
} else {
|
|
2288
|
+
jsonResponse(res, cors, 200, { error: msg.split('\n')[0], threads: [], generalComments: [], reviews: [] });
|
|
2289
|
+
}
|
|
2290
|
+
}
|
|
2291
|
+
return;
|
|
2292
|
+
}
|
|
2293
|
+
|
|
2294
|
+
// ─── Permission approval endpoints ─────────────────────────
|
|
2295
|
+
|
|
2296
|
+
// POST /pre-tool-use — relay posts permission request
|
|
2297
|
+
if (req.method === 'POST' && parsedUrl.pathname === '/pre-tool-use') {
|
|
2298
|
+
loadSettingsCache(); // Refresh from disk so CLI flag changes take effect immediately
|
|
2299
|
+
const body = await readBody(req);
|
|
2300
|
+
let params;
|
|
2301
|
+
try { params = JSON.parse(body); } catch {
|
|
2302
|
+
jsonResponse(res, cors, 400, { error: 'Invalid JSON' });
|
|
2303
|
+
return;
|
|
2304
|
+
}
|
|
2305
|
+
|
|
2306
|
+
const { tool, command, sessionId: claudeSessionId } = params;
|
|
2307
|
+
|
|
2308
|
+
// Fast path: deny first (CC evaluates deny before any allow/hook
|
|
2309
|
+
// decision), then settings allowlist, then CC's built-in read-only
|
|
2310
|
+
// classifier — so read-only commands the CLI auto-approves (ls, cat,
|
|
2311
|
+
// grep, read-only git forms, read-only tools) never reach a card.
|
|
2312
|
+
if (matchesPermission(command, settingsCache.deny)) {
|
|
2313
|
+
jsonResponse(res, cors, 200, { id: randomUUID(), status: 'denied' });
|
|
2314
|
+
return;
|
|
2315
|
+
}
|
|
2316
|
+
if (matchesPermission(command, settingsCache.allow) ||
|
|
2317
|
+
isCcReadOnlyAutoApprovable(tool, { command })) {
|
|
2318
|
+
jsonResponse(res, cors, 200, { id: randomUUID(), status: 'allowed' });
|
|
2319
|
+
return;
|
|
2320
|
+
}
|
|
2321
|
+
|
|
2322
|
+
// Pending — store and broadcast
|
|
2323
|
+
const id = randomUUID();
|
|
2324
|
+
permissionRequests.set(id, {
|
|
2325
|
+
id, tool, command, sessionId: claudeSessionId, decision: null, createdAt: Date.now(),
|
|
2326
|
+
});
|
|
2327
|
+
|
|
2328
|
+
// Find most-recently-active running session for routing
|
|
2329
|
+
let activeStore = null;
|
|
2330
|
+
let activeSessionId = null;
|
|
2331
|
+
for (const [sid, store] of sessions) {
|
|
2332
|
+
if (store.chatSession && store.chatSession.isRunning) {
|
|
2333
|
+
if (!activeStore || store.lastActivityAt > activeStore.lastActivityAt) {
|
|
2334
|
+
activeStore = store;
|
|
2335
|
+
activeSessionId = sid;
|
|
2336
|
+
}
|
|
2337
|
+
}
|
|
2338
|
+
}
|
|
2339
|
+
|
|
2340
|
+
broadcast({
|
|
2341
|
+
type: 'permission-request',
|
|
2342
|
+
id,
|
|
2343
|
+
tool: tool || 'Bash',
|
|
2344
|
+
command: command || '',
|
|
2345
|
+
sessionId: activeSessionId,
|
|
2346
|
+
});
|
|
2347
|
+
|
|
2348
|
+
jsonResponse(res, cors, 200, { id, status: 'pending' });
|
|
2349
|
+
return;
|
|
2350
|
+
}
|
|
2351
|
+
|
|
2352
|
+
// GET /chat/permission/:id — relay polls for decision
|
|
2353
|
+
if (req.method === 'GET' && parsedUrl.pathname.startsWith('/chat/permission/')) {
|
|
2354
|
+
const id = parsedUrl.pathname.split('/').pop();
|
|
2355
|
+
const entry = permissionRequests.get(id);
|
|
2356
|
+
|
|
2357
|
+
if (!entry) {
|
|
2358
|
+
jsonResponse(res, cors, 200, { status: 'not_found' });
|
|
2359
|
+
return;
|
|
2360
|
+
}
|
|
2361
|
+
if (entry.decision === null) {
|
|
2362
|
+
jsonResponse(res, cors, 200, { status: 'pending' });
|
|
2363
|
+
return;
|
|
2364
|
+
}
|
|
2365
|
+
|
|
2366
|
+
const decision = entry.decision;
|
|
2367
|
+
permissionRequests.delete(id);
|
|
2368
|
+
|
|
2369
|
+
broadcast({
|
|
2370
|
+
type: 'permission-resolved',
|
|
2371
|
+
id,
|
|
2372
|
+
decision,
|
|
2373
|
+
});
|
|
2374
|
+
|
|
2375
|
+
jsonResponse(res, cors, 200, { status: decision });
|
|
2376
|
+
return;
|
|
2377
|
+
}
|
|
2378
|
+
|
|
2379
|
+
// POST /chat/permission — browser submits decision
|
|
2380
|
+
if (req.method === 'POST' && parsedUrl.pathname === '/chat/permission') {
|
|
2381
|
+
const body = await readBody(req);
|
|
2382
|
+
let params;
|
|
2383
|
+
try { params = JSON.parse(body); } catch {
|
|
2384
|
+
jsonResponse(res, cors, 400, { error: 'Invalid JSON' });
|
|
2385
|
+
return;
|
|
2386
|
+
}
|
|
2387
|
+
|
|
2388
|
+
const { id, decision, persist, command } = params;
|
|
2389
|
+
const entry = permissionRequests.get(id);
|
|
2390
|
+
if (!entry) {
|
|
2391
|
+
jsonResponse(res, cors, 404, { error: 'Permission request not found' });
|
|
2392
|
+
return;
|
|
2393
|
+
}
|
|
2394
|
+
|
|
2395
|
+
entry.decision = decision;
|
|
2396
|
+
|
|
2397
|
+
if (persist && command) {
|
|
2398
|
+
persistPermission(command, decision);
|
|
2399
|
+
}
|
|
2400
|
+
|
|
2401
|
+
jsonResponse(res, cors, 200, { ok: true });
|
|
2402
|
+
return;
|
|
2403
|
+
}
|
|
2404
|
+
|
|
2405
|
+
// ─── Gate state endpoint ────────────────────────────────────────
|
|
2406
|
+
|
|
2407
|
+
// GET /gate-state — returns current gate state from .shards/gates/state.json
|
|
2408
|
+
if (req.method === 'GET' && parsedUrl.pathname === '/gate-state') {
|
|
2409
|
+
const gateStatePath = path.join(SHARDS_DIR, 'gates', 'state.json');
|
|
2410
|
+
try {
|
|
2411
|
+
const raw = fs.readFileSync(gateStatePath, 'utf8');
|
|
2412
|
+
// Collapse the v2 per-session map (or legacy single slot) to the flat
|
|
2413
|
+
// gate object the browser expects. Contract unchanged.
|
|
2414
|
+
jsonResponse(res, cors, 200, reduceGateState(JSON.parse(raw)));
|
|
2415
|
+
} catch {
|
|
2416
|
+
jsonResponse(res, cors, 200, { open: false, history: [] });
|
|
2417
|
+
}
|
|
2418
|
+
return;
|
|
2419
|
+
}
|
|
2420
|
+
|
|
2421
|
+
// GET /gate-state-stream — SSE channel pushing gate-state / gate-block /
|
|
2422
|
+
// auto-state events on file change. Browser uses this in place of the
|
|
2423
|
+
// legacy 2s poll (Bug H2 + H5 in the integration audit). On connect we
|
|
2424
|
+
// immediately send the current gate-state so the client doesn't need a
|
|
2425
|
+
// second /gate-state request to seed initial state.
|
|
2426
|
+
if (req.method === 'GET' && parsedUrl.pathname === '/gate-state-stream') {
|
|
2427
|
+
res.writeHead(200, {
|
|
2428
|
+
...cors,
|
|
2429
|
+
'Content-Type': 'text/event-stream',
|
|
2430
|
+
'Cache-Control': 'no-cache',
|
|
2431
|
+
'Connection': 'keep-alive',
|
|
2432
|
+
'X-Accel-Buffering': 'no',
|
|
2433
|
+
});
|
|
2434
|
+
res.write('data: {"type":"connected"}\n\n');
|
|
2435
|
+
// Seed initial gate state for this client only — broadcast helpers
|
|
2436
|
+
// append to all subscribers, but a fresh subscriber needs the snapshot
|
|
2437
|
+
// before its first change event arrives.
|
|
2438
|
+
const initial = readGateStateFile() || { open: false, history: [] };
|
|
2439
|
+
try { res.write(`data: ${JSON.stringify({ type: 'gate-state', state: initial })}\n\n`); } catch {}
|
|
2440
|
+
gateClients.push({ res });
|
|
2441
|
+
req.on('close', () => {
|
|
2442
|
+
gateClients = gateClients.filter((c) => c.res !== res);
|
|
2443
|
+
});
|
|
2444
|
+
return;
|
|
2445
|
+
}
|
|
2446
|
+
|
|
2447
|
+
// GET /gate-mode — returns enforcement flags as the UI server sees them.
|
|
2448
|
+
// The Claude Code hook process runs in a different env; if the user only
|
|
2449
|
+
// exports SHARDS_GATE_ENFORCE=0 to CC without also exporting it to the UI
|
|
2450
|
+
// server, this endpoint will still report enforcement on. The browser
|
|
2451
|
+
// surfaces a banner reflecting whatever the server reports — accurate for
|
|
2452
|
+
// the common case where both processes are launched from the same shell.
|
|
2453
|
+
if (req.method === 'GET' && parsedUrl.pathname === '/gate-mode') {
|
|
2454
|
+
jsonResponse(res, cors, 200, {
|
|
2455
|
+
enforce: GATE_ENFORCE,
|
|
2456
|
+
checkpointEnforce: CHECKPOINT_ENFORCE,
|
|
2457
|
+
autoVerify: AUTO_VERIFY,
|
|
2458
|
+
});
|
|
2459
|
+
return;
|
|
2460
|
+
}
|
|
2461
|
+
|
|
2462
|
+
// ─── Permissions management endpoints ──────────────────────────
|
|
2463
|
+
|
|
2464
|
+
// GET /permissions — return current allow/deny lists + available presets
|
|
2465
|
+
if (req.method === 'GET' && parsedUrl.pathname === '/permissions') {
|
|
2466
|
+
loadSettingsCache();
|
|
2467
|
+
jsonResponse(res, cors, 200, {
|
|
2468
|
+
allow: settingsCache.allow,
|
|
2469
|
+
deny: settingsCache.deny,
|
|
2470
|
+
presets: {
|
|
2471
|
+
permissive: [
|
|
2472
|
+
...READONLY_PRESET,
|
|
2473
|
+
'Bash(python:*)', 'Bash(python3:*)', 'Bash(node:*)',
|
|
2474
|
+
'Bash(npm:*)', 'Bash(pip:*)', 'Bash(pip3:*)',
|
|
2475
|
+
],
|
|
2476
|
+
readonly: [...READONLY_PRESET],
|
|
2477
|
+
},
|
|
2478
|
+
});
|
|
2479
|
+
return;
|
|
2480
|
+
}
|
|
2481
|
+
|
|
2482
|
+
// POST /permissions — update allow list
|
|
2483
|
+
if (req.method === 'POST' && parsedUrl.pathname === '/permissions') {
|
|
2484
|
+
const body = await readBody(req);
|
|
2485
|
+
let params;
|
|
2486
|
+
try { params = JSON.parse(body); } catch {
|
|
2487
|
+
jsonResponse(res, cors, 400, { error: 'Invalid JSON' });
|
|
2488
|
+
return;
|
|
2489
|
+
}
|
|
2490
|
+
|
|
2491
|
+
const settingsPath = path.join(PROJECT_DIR, '.claude', 'settings.json');
|
|
2492
|
+
let settings = {};
|
|
2493
|
+
try { settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); } catch {}
|
|
2494
|
+
|
|
2495
|
+
if (!settings.permissions) settings.permissions = {};
|
|
2496
|
+
if (!settings.permissions.allow) settings.permissions.allow = [];
|
|
2497
|
+
|
|
2498
|
+
// params.add: array of patterns to add
|
|
2499
|
+
if (Array.isArray(params.add)) {
|
|
2500
|
+
for (const p of params.add) {
|
|
2501
|
+
if (typeof p === 'string' && p.trim() && !settings.permissions.allow.includes(p.trim())) {
|
|
2502
|
+
settings.permissions.allow.push(p.trim());
|
|
2503
|
+
}
|
|
2504
|
+
}
|
|
2505
|
+
}
|
|
2506
|
+
|
|
2507
|
+
// params.remove: array of patterns to remove
|
|
2508
|
+
if (Array.isArray(params.remove)) {
|
|
2509
|
+
const toRemove = new Set(params.remove.map(p => (typeof p === 'string' ? p.trim() : '')));
|
|
2510
|
+
settings.permissions.allow = settings.permissions.allow.filter(p => !toRemove.has(p));
|
|
2511
|
+
}
|
|
2512
|
+
|
|
2513
|
+
// params.set: replace the entire allow list
|
|
2514
|
+
if (Array.isArray(params.set)) {
|
|
2515
|
+
settings.permissions.allow = params.set.filter(p => typeof p === 'string' && p.trim());
|
|
2516
|
+
}
|
|
2517
|
+
|
|
2518
|
+
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
|
|
2519
|
+
loadSettingsCache();
|
|
2520
|
+
|
|
2521
|
+
jsonResponse(res, cors, 200, { ok: true, allow: settingsCache.allow });
|
|
2522
|
+
return;
|
|
2523
|
+
}
|
|
2524
|
+
|
|
2525
|
+
// ─── P2: Event endpoint with ack ────────────────────────────
|
|
2526
|
+
if (req.method === 'POST' && parsedUrl.pathname === '/event') {
|
|
2527
|
+
const body = await readBody(req);
|
|
2528
|
+
const result = handleEvent(body);
|
|
2529
|
+
jsonResponse(res, cors, 200, result);
|
|
2530
|
+
return;
|
|
2531
|
+
}
|
|
2532
|
+
|
|
2533
|
+
// ─── Chat endpoints ─────────────────────────────────────────────
|
|
2534
|
+
|
|
2535
|
+
if (req.method === 'GET' && parsedUrl.pathname === '/agents') {
|
|
2536
|
+
jsonResponse(res, cors, 200, listAgents());
|
|
2537
|
+
return;
|
|
2538
|
+
}
|
|
2539
|
+
|
|
2540
|
+
if (req.method === 'GET' && parsedUrl.pathname === '/api/skills') {
|
|
2541
|
+
jsonResponse(res, cors, 200, listSkills());
|
|
2542
|
+
return;
|
|
2543
|
+
}
|
|
2544
|
+
|
|
2545
|
+
if (req.method === 'GET' && parsedUrl.pathname === '/chat/status') {
|
|
2546
|
+
const activeSessions = [];
|
|
2547
|
+
for (const [id, store] of sessions) {
|
|
2548
|
+
if (store.chatSession && store.chatSession.isRunning) {
|
|
2549
|
+
activeSessions.push({
|
|
2550
|
+
sessionId: id,
|
|
2551
|
+
agent: store.agent,
|
|
2552
|
+
title: store.title,
|
|
2553
|
+
transcript: store.transcript,
|
|
2554
|
+
startedAt: store.createdAt.toISOString(),
|
|
2555
|
+
projectName: store.projectName,
|
|
2556
|
+
projectDir: store.projectDir,
|
|
2557
|
+
contextUsage: store.contextUsage,
|
|
2558
|
+
});
|
|
2559
|
+
}
|
|
2560
|
+
}
|
|
2561
|
+
jsonResponse(res, cors, 200, { sessions: activeSessions });
|
|
2562
|
+
return;
|
|
2563
|
+
}
|
|
2564
|
+
|
|
2565
|
+
if (req.method === 'POST' && parsedUrl.pathname === '/chat/start') {
|
|
2566
|
+
const body = await readBody(req);
|
|
2567
|
+
let params;
|
|
2568
|
+
try {
|
|
2569
|
+
params = JSON.parse(body);
|
|
2570
|
+
} catch {
|
|
2571
|
+
jsonResponse(res, cors, 400, { error: 'Invalid JSON' });
|
|
2572
|
+
return;
|
|
2573
|
+
}
|
|
2574
|
+
|
|
2575
|
+
const { agent, permissionMode, sessionId: callerSessionId, initialMessage,
|
|
2576
|
+
resumeSessionId, model } = params;
|
|
2577
|
+
|
|
2578
|
+
// agent is optional — null/omitted starts a plain session.
|
|
2579
|
+
if (agent) {
|
|
2580
|
+
const agentFile = path.join(AGENTS_DIR, `${agent}.md`);
|
|
2581
|
+
if (!fs.existsSync(agentFile)) {
|
|
2582
|
+
jsonResponse(res, cors, 404, { error: `Agent "${agent}" not found` });
|
|
2583
|
+
return;
|
|
2584
|
+
}
|
|
2585
|
+
}
|
|
2586
|
+
|
|
2587
|
+
// Validate resume target. We allow resuming only sessions that have
|
|
2588
|
+
// ended or been abandoned — resuming a session whose process is still
|
|
2589
|
+
// believed to be live would race with the existing CLI on --session-id.
|
|
2590
|
+
if (resumeSessionId) {
|
|
2591
|
+
const entry = sessionIndex.getEntry(SESSIONS_DIR, resumeSessionId);
|
|
2592
|
+
if (!entry) {
|
|
2593
|
+
jsonResponse(res, cors, 404, { error: `Resume target "${resumeSessionId}" not found in session index` });
|
|
2594
|
+
return;
|
|
2595
|
+
}
|
|
2596
|
+
if (entry.status === 'active') {
|
|
2597
|
+
const live = sessions.get(resumeSessionId);
|
|
2598
|
+
if (live && live.chatSession && live.chatSession.isRunning) {
|
|
2599
|
+
jsonResponse(res, cors, 409, { error: 'Resume target is still active. End it first.' });
|
|
2600
|
+
return;
|
|
2601
|
+
}
|
|
2602
|
+
}
|
|
2603
|
+
}
|
|
2604
|
+
|
|
2605
|
+
log(`/chat/start requested for agent="${agent || '(plain)'}"${resumeSessionId ? ` resume="${resumeSessionId}"` : ''}`);
|
|
2606
|
+
const result = startNewChatSession(agent || null, {
|
|
2607
|
+
permissionMode, callerSessionId, initialMessage, resumeSessionId, model,
|
|
2608
|
+
});
|
|
2609
|
+
jsonResponse(res, cors, 200, result);
|
|
2610
|
+
return;
|
|
2611
|
+
}
|
|
2612
|
+
|
|
2613
|
+
// ─── Permission mode switch ─────────────────────────────────────────────
|
|
2614
|
+
if (req.method === 'POST' && parsedUrl.pathname === '/chat/mode') {
|
|
2615
|
+
const body = await readBody(req);
|
|
2616
|
+
let params;
|
|
2617
|
+
try {
|
|
2618
|
+
params = JSON.parse(body);
|
|
2619
|
+
} catch {
|
|
2620
|
+
jsonResponse(res, cors, 400, { error: 'Invalid JSON' });
|
|
2621
|
+
return;
|
|
2622
|
+
}
|
|
2623
|
+
|
|
2624
|
+
const { sessionId: targetSessionId, mode } = params;
|
|
2625
|
+
const validModes = ['default', 'acceptEdits', 'plan'];
|
|
2626
|
+
if (!mode || !validModes.includes(mode)) {
|
|
2627
|
+
jsonResponse(res, cors, 400, { error: `Invalid mode. Use one of: ${validModes.join(', ')}` });
|
|
2628
|
+
return;
|
|
2629
|
+
}
|
|
2630
|
+
|
|
2631
|
+
const store = targetSessionId ? getSession(targetSessionId) : null;
|
|
2632
|
+
if (store && store.chatSession && store.chatSession.isRunning) {
|
|
2633
|
+
const agent = store.agent;
|
|
2634
|
+
broadcast({ type: 'chat-system-notice', text: 'Switching to **' + mode + '** mode…', sessionId: targetSessionId });
|
|
2635
|
+
const result = startNewChatSession(agent, { callerSessionId: targetSessionId, resumeSessionId: targetSessionId, permissionMode: mode });
|
|
2636
|
+
jsonResponse(res, cors, 200, { ok: true, switched: true, agent: result.agent, sessionId: result.sessionId });
|
|
2637
|
+
} else {
|
|
2638
|
+
// No active session — mode will be applied on next session start
|
|
2639
|
+
jsonResponse(res, cors, 200, { ok: true, deferred: true });
|
|
2640
|
+
}
|
|
2641
|
+
return;
|
|
2642
|
+
}
|
|
2643
|
+
|
|
2644
|
+
if (req.method === 'POST' && parsedUrl.pathname === '/chat/send') {
|
|
2645
|
+
const body = await readBody(req);
|
|
2646
|
+
let params;
|
|
2647
|
+
try {
|
|
2648
|
+
params = JSON.parse(body);
|
|
2649
|
+
} catch {
|
|
2650
|
+
jsonResponse(res, cors, 400, { error: 'Invalid JSON' });
|
|
2651
|
+
return;
|
|
2652
|
+
}
|
|
2653
|
+
|
|
2654
|
+
const { message, sessionId: targetSessionId, attachments: rawAttachments } = params;
|
|
2655
|
+
const attachments = Array.isArray(rawAttachments) ? rawAttachments : [];
|
|
2656
|
+
if (!message && attachments.length === 0) {
|
|
2657
|
+
jsonResponse(res, cors, 400, { error: 'Missing message parameter' });
|
|
2658
|
+
return;
|
|
2659
|
+
}
|
|
2660
|
+
|
|
2661
|
+
// ─── Attachment validation ──────────────────────────────
|
|
2662
|
+
// Mirrors the client-side checks in chat.js so we don't ship malformed
|
|
2663
|
+
// image blocks to the Claude CLI. Anthropic's Messages API rejects
|
|
2664
|
+
// unsupported MIME types and oversize payloads; better to fail fast here
|
|
2665
|
+
// with a clear error than to surface an opaque CLI error mid-turn.
|
|
2666
|
+
const ATTACH_MAX_COUNT = 5;
|
|
2667
|
+
const ATTACH_MAX_BYTES = 5 * 1024 * 1024;
|
|
2668
|
+
const ATTACH_ALLOWED_MIME = new Set(['image/jpeg', 'image/png', 'image/gif', 'image/webp']);
|
|
2669
|
+
if (attachments.length > ATTACH_MAX_COUNT) {
|
|
2670
|
+
jsonResponse(res, cors, 400, { error: `Too many attachments (max ${ATTACH_MAX_COUNT}).` });
|
|
2671
|
+
return;
|
|
2672
|
+
}
|
|
2673
|
+
for (const a of attachments) {
|
|
2674
|
+
if (!a || typeof a !== 'object') {
|
|
2675
|
+
jsonResponse(res, cors, 400, { error: 'Invalid attachment entry' });
|
|
2676
|
+
return;
|
|
2677
|
+
}
|
|
2678
|
+
if (!ATTACH_ALLOWED_MIME.has(a.mediaType)) {
|
|
2679
|
+
jsonResponse(res, cors, 400, { error: `Unsupported attachment mediaType: ${a.mediaType}` });
|
|
2680
|
+
return;
|
|
2681
|
+
}
|
|
2682
|
+
if (typeof a.dataBase64 !== 'string' || a.dataBase64.length === 0) {
|
|
2683
|
+
jsonResponse(res, cors, 400, { error: 'Attachment missing dataBase64' });
|
|
2684
|
+
return;
|
|
2685
|
+
}
|
|
2686
|
+
// Decoded size = ceil(base64_len * 3 / 4) - padding
|
|
2687
|
+
const padding = a.dataBase64.endsWith('==') ? 2 : a.dataBase64.endsWith('=') ? 1 : 0;
|
|
2688
|
+
const decodedBytes = Math.floor(a.dataBase64.length * 3 / 4) - padding;
|
|
2689
|
+
if (decodedBytes > ATTACH_MAX_BYTES) {
|
|
2690
|
+
jsonResponse(res, cors, 400, { error: 'Attachment exceeds 5MB limit' });
|
|
2691
|
+
return;
|
|
2692
|
+
}
|
|
2693
|
+
}
|
|
2694
|
+
|
|
2695
|
+
// ─── Slash command interception ─────────────────────────
|
|
2696
|
+
const slashCmd = parseSlashCommand(message);
|
|
2697
|
+
|
|
2698
|
+
if (slashCmd) {
|
|
2699
|
+
if (slashCmd.type === 'agent') {
|
|
2700
|
+
const toAgent = slashCmd.agent;
|
|
2701
|
+
const agentFile = path.join(AGENTS_DIR, `${toAgent}.md`);
|
|
2702
|
+
if (!fs.existsSync(agentFile)) {
|
|
2703
|
+
jsonResponse(res, cors, 404, { error: `Agent "${toAgent}" not found` });
|
|
2704
|
+
return;
|
|
2705
|
+
}
|
|
2706
|
+
|
|
2707
|
+
// Find caller's current session to get fromAgent
|
|
2708
|
+
const callerStore = targetSessionId ? getSession(targetSessionId) : null;
|
|
2709
|
+
const fromAgent = callerStore ? callerStore.agent : null;
|
|
2710
|
+
|
|
2711
|
+
broadcast({ type: 'chat-agent-switching', from: fromAgent, to: toAgent, sessionId: targetSessionId });
|
|
2712
|
+
const result = startNewChatSession(toAgent, { callerSessionId: targetSessionId });
|
|
2713
|
+
jsonResponse(res, cors, 200, { ok: true, switched: true, agent: result.agent, sessionId: result.sessionId });
|
|
2714
|
+
return;
|
|
2715
|
+
}
|
|
2716
|
+
|
|
2717
|
+
if (slashCmd.command === 'stop') {
|
|
2718
|
+
const store = targetSessionId ? getSession(targetSessionId) : null;
|
|
2719
|
+
if (store && store.chatSession && store.chatSession.isRunning) {
|
|
2720
|
+
store.chatSession.stop();
|
|
2721
|
+
}
|
|
2722
|
+
jsonResponse(res, cors, 200, { ok: true, stopped: true });
|
|
2723
|
+
return;
|
|
2724
|
+
}
|
|
2725
|
+
|
|
2726
|
+
if (slashCmd.command === 'help') {
|
|
2727
|
+
const agents = listAgents();
|
|
2728
|
+
let helpText = '**Available Commands**\n\n';
|
|
2729
|
+
helpText += '**Agents:**\n';
|
|
2730
|
+
for (const a of agents) {
|
|
2731
|
+
helpText += ` \`/${a.name}\` — ${a.description || 'Switch to ' + a.name}\n`;
|
|
2732
|
+
}
|
|
2733
|
+
helpText += '\n**Utility:**\n';
|
|
2734
|
+
helpText += ' `/compact` — Summarize conversation to free up context\n';
|
|
2735
|
+
helpText += ' `/stop` — Stop the current session\n';
|
|
2736
|
+
helpText += ' `/clear` — Clear the messages panel\n';
|
|
2737
|
+
helpText += ' `/help` — Show this help message\n';
|
|
2738
|
+
helpText += '\n**Claude Code:**\n';
|
|
2739
|
+
helpText += ' `/init` — Initialize a CLAUDE.md file\n';
|
|
2740
|
+
helpText += ' `/exit` — End the current session\n';
|
|
2741
|
+
helpText += ' `/context` — Show token usage (terminal only)\n';
|
|
2742
|
+
helpText += ' `/rewind` — Restore to previous point (terminal only)\n';
|
|
2743
|
+
helpText += ' `/config` — Open settings panel\n';
|
|
2744
|
+
helpText += ' `/model <name>` — Change the active LLM model\n';
|
|
2745
|
+
helpText += ' `/effort <level>` — Set compute intensity (low/medium/high)\n';
|
|
2746
|
+
helpText += ' `/mode` — Cycle permission mode (Shift+Tab)\n';
|
|
2747
|
+
broadcast({ type: 'chat-system-notice', text: helpText, sessionId: targetSessionId });
|
|
2748
|
+
jsonResponse(res, cors, 200, { ok: true, help: true });
|
|
2749
|
+
return;
|
|
2750
|
+
}
|
|
2751
|
+
|
|
2752
|
+
if (slashCmd.command === 'clear') {
|
|
2753
|
+
broadcast({ type: 'chat-clear-messages', sessionId: targetSessionId });
|
|
2754
|
+
jsonResponse(res, cors, 200, { ok: true, cleared: true });
|
|
2755
|
+
return;
|
|
2756
|
+
}
|
|
2757
|
+
}
|
|
2758
|
+
|
|
2759
|
+
// ─── Claude Code built-in command handling ─────────────
|
|
2760
|
+
if (slashCmd && slashCmd.type === 'builtin') {
|
|
2761
|
+
if (slashCmd.command === 'exit') {
|
|
2762
|
+
const store = targetSessionId ? getSession(targetSessionId) : null;
|
|
2763
|
+
if (store && store.chatSession && store.chatSession.isRunning) {
|
|
2764
|
+
store.chatSession.stop();
|
|
2765
|
+
}
|
|
2766
|
+
jsonResponse(res, cors, 200, { ok: true, stopped: true });
|
|
2767
|
+
return;
|
|
2768
|
+
}
|
|
2769
|
+
|
|
2770
|
+
if (slashCmd.command === 'model') {
|
|
2771
|
+
if (!slashCmd.args) {
|
|
2772
|
+
broadcast({ type: 'chat-system-notice', text: 'Usage: `/model <model-name>` — e.g., `/model claude-sonnet-4-6`', sessionId: targetSessionId });
|
|
2773
|
+
jsonResponse(res, cors, 200, { ok: true });
|
|
2774
|
+
return;
|
|
2775
|
+
}
|
|
2776
|
+
const store = targetSessionId ? getSession(targetSessionId) : null;
|
|
2777
|
+
if (store && store.chatSession && store.chatSession.isRunning) {
|
|
2778
|
+
const agent = store.agent;
|
|
2779
|
+
broadcast({ type: 'chat-system-notice', text: 'Switching model to `' + slashCmd.args + '`…', sessionId: targetSessionId });
|
|
2780
|
+
const result = startNewChatSession(agent, { callerSessionId: targetSessionId, resumeSessionId: targetSessionId, model: slashCmd.args });
|
|
2781
|
+
jsonResponse(res, cors, 200, { ok: true, switched: true, agent: result.agent, sessionId: result.sessionId });
|
|
2782
|
+
} else {
|
|
2783
|
+
broadcast({ type: 'chat-system-notice', text: 'No active session. Start a session first.', sessionId: targetSessionId });
|
|
2784
|
+
jsonResponse(res, cors, 200, { ok: true });
|
|
2785
|
+
}
|
|
2786
|
+
return;
|
|
2787
|
+
}
|
|
2788
|
+
}
|
|
2789
|
+
|
|
2790
|
+
// ─── Regular message send ───────────────────────────────
|
|
2791
|
+
// Find the target session
|
|
2792
|
+
let store = targetSessionId ? getSession(targetSessionId) : null;
|
|
2793
|
+
if (!store) {
|
|
2794
|
+
// Fall back to most recently active session
|
|
2795
|
+
for (const s of sessions.values()) {
|
|
2796
|
+
if (s.chatSession && s.chatSession.isRunning) {
|
|
2797
|
+
if (!store || s.lastActivityAt > store.lastActivityAt) store = s;
|
|
2798
|
+
}
|
|
2799
|
+
}
|
|
2800
|
+
}
|
|
2801
|
+
if (!store || !store.chatSession || !store.chatSession.isRunning) {
|
|
2802
|
+
jsonResponse(res, cors, 400, { error: 'No active chat session' });
|
|
2803
|
+
return;
|
|
2804
|
+
}
|
|
2805
|
+
|
|
2806
|
+
try {
|
|
2807
|
+
store.chatSession.send(message, attachments);
|
|
2808
|
+
const agent = store.agent;
|
|
2809
|
+
// Transcript-form attachments mirror the wire format. We keep
|
|
2810
|
+
// dataBase64 so the chat history can re-render thumbnails on reload;
|
|
2811
|
+
// the alternative (writing files to .shards/sessions/<id>/attachments/)
|
|
2812
|
+
// is worth doing only if transcript sizes start to bite.
|
|
2813
|
+
const transcriptAttachments = attachments.map((a) => ({
|
|
2814
|
+
mediaType: a.mediaType,
|
|
2815
|
+
dataBase64: a.dataBase64,
|
|
2816
|
+
name: a.name,
|
|
2817
|
+
sizeBytes: a.sizeBytes,
|
|
2818
|
+
}));
|
|
2819
|
+
const transcriptEntry = { role: 'user', content: message, agent, source: 'chat' };
|
|
2820
|
+
if (transcriptAttachments.length > 0) transcriptEntry.attachments = transcriptAttachments;
|
|
2821
|
+
store.transcript.push(transcriptEntry);
|
|
2822
|
+
store.save();
|
|
2823
|
+
try {
|
|
2824
|
+
sessionIndex.updateActivity(SESSIONS_DIR, store.sessionId, {
|
|
2825
|
+
lastUserPrompt: message,
|
|
2826
|
+
messageCount: store.transcript.length,
|
|
2827
|
+
projectName: store.projectName,
|
|
2828
|
+
projectDir: store.projectDir,
|
|
2829
|
+
});
|
|
2830
|
+
} catch (err) {
|
|
2831
|
+
log(`session-index updateActivity failed: ${err.message}`);
|
|
2832
|
+
}
|
|
2833
|
+
const broadcastMsg = { type: 'chat-user-message', content: message, agent, sessionId: store.sessionId };
|
|
2834
|
+
if (transcriptAttachments.length > 0) broadcastMsg.attachments = transcriptAttachments;
|
|
2835
|
+
broadcast(broadcastMsg);
|
|
2836
|
+
jsonResponse(res, cors, 200, { ok: true });
|
|
2837
|
+
} catch (err) {
|
|
2838
|
+
jsonResponse(res, cors, 500, { error: err.message });
|
|
2839
|
+
}
|
|
2840
|
+
return;
|
|
2841
|
+
}
|
|
2842
|
+
|
|
2843
|
+
if (req.method === 'POST' && parsedUrl.pathname === '/chat/rename') {
|
|
2844
|
+
const body = await readBody(req);
|
|
2845
|
+
let params;
|
|
2846
|
+
try {
|
|
2847
|
+
params = JSON.parse(body);
|
|
2848
|
+
} catch {
|
|
2849
|
+
jsonResponse(res, cors, 400, { error: 'Invalid JSON' });
|
|
2850
|
+
return;
|
|
2851
|
+
}
|
|
2852
|
+
|
|
2853
|
+
const { sessionId: renameSessionId, title } = params;
|
|
2854
|
+
if (!renameSessionId) {
|
|
2855
|
+
jsonResponse(res, cors, 400, { error: 'Missing sessionId' });
|
|
2856
|
+
return;
|
|
2857
|
+
}
|
|
2858
|
+
|
|
2859
|
+
const renameStore = getSession(renameSessionId);
|
|
2860
|
+
if (renameStore) {
|
|
2861
|
+
renameStore.title = title || null;
|
|
2862
|
+
renameStore.save();
|
|
2863
|
+
}
|
|
2864
|
+
|
|
2865
|
+
jsonResponse(res, cors, 200, { ok: true });
|
|
2866
|
+
return;
|
|
2867
|
+
}
|
|
2868
|
+
|
|
2869
|
+
if (req.method === 'POST' && parsedUrl.pathname === '/chat/end') {
|
|
2870
|
+
const body = await readBody(req);
|
|
2871
|
+
let params = {};
|
|
2872
|
+
try { params = JSON.parse(body); } catch {}
|
|
2873
|
+
const { sessionId: endSessionId } = params;
|
|
2874
|
+
if (!endSessionId) {
|
|
2875
|
+
jsonResponse(res, cors, 400, { error: 'Missing sessionId' });
|
|
2876
|
+
return;
|
|
2877
|
+
}
|
|
2878
|
+
|
|
2879
|
+
const store = getSession(endSessionId);
|
|
2880
|
+
const snap = readGateSnapshot(SHARDS_DIR);
|
|
2881
|
+
|
|
2882
|
+
// Stop the CLI if still running. We intentionally do not mutate
|
|
2883
|
+
// .shards/gates/state.json — gates are project-scoped state and may
|
|
2884
|
+
// still be legitimately open for a future resume of this same project.
|
|
2885
|
+
if (store && store.chatSession && store.chatSession.isRunning) {
|
|
2886
|
+
try { store.chatSession.stop(); } catch (err) { log(`/chat/end stop failed: ${err.message}`); }
|
|
2887
|
+
}
|
|
2888
|
+
|
|
2889
|
+
let entry = null;
|
|
2890
|
+
try {
|
|
2891
|
+
entry = sessionIndex.markEnded(SESSIONS_DIR, endSessionId, {
|
|
2892
|
+
gateOpenAtEnd: snap.gateOpenAtEnd,
|
|
2893
|
+
reason: 'user_ended',
|
|
2894
|
+
});
|
|
2895
|
+
if (typeof snap.phase === 'number') {
|
|
2896
|
+
sessionIndex.updatePhase(SESSIONS_DIR, endSessionId, snap.phase);
|
|
2897
|
+
}
|
|
2898
|
+
} catch (err) {
|
|
2899
|
+
log(`session-index markEnded failed: ${err.message}`);
|
|
2900
|
+
}
|
|
2901
|
+
|
|
2902
|
+
broadcast({ type: 'chat-ended', sessionId: endSessionId, code: 0, ended: true });
|
|
2903
|
+
jsonResponse(res, cors, 200, { ok: true, entry });
|
|
2904
|
+
return;
|
|
2905
|
+
}
|
|
2906
|
+
|
|
2907
|
+
if (req.method === 'POST' && parsedUrl.pathname === '/chat/stop') {
|
|
2908
|
+
const body = await readBody(req);
|
|
2909
|
+
let params = {};
|
|
2910
|
+
try { params = JSON.parse(body); } catch {}
|
|
2911
|
+
|
|
2912
|
+
const targetSessionId = params.sessionId;
|
|
2913
|
+
const store = targetSessionId ? getSession(targetSessionId) : null;
|
|
2914
|
+
|
|
2915
|
+
if (store && store.chatSession && store.chatSession.isRunning) {
|
|
2916
|
+
store.chatSession.stop();
|
|
2917
|
+
jsonResponse(res, cors, 200, { ok: true });
|
|
2918
|
+
} else {
|
|
2919
|
+
// Stop the most recently active session
|
|
2920
|
+
let active = null;
|
|
2921
|
+
for (const s of sessions.values()) {
|
|
2922
|
+
if (s.chatSession && s.chatSession.isRunning) {
|
|
2923
|
+
if (!active || s.lastActivityAt > active.lastActivityAt) active = s;
|
|
2924
|
+
}
|
|
2925
|
+
}
|
|
2926
|
+
if (active) {
|
|
2927
|
+
active.chatSession.stop();
|
|
2928
|
+
jsonResponse(res, cors, 200, { ok: true });
|
|
2929
|
+
} else {
|
|
2930
|
+
jsonResponse(res, cors, 200, { ok: true, note: 'No active session' });
|
|
2931
|
+
}
|
|
2932
|
+
}
|
|
2933
|
+
return;
|
|
2934
|
+
}
|
|
2935
|
+
|
|
2936
|
+
res.writeHead(404);
|
|
2937
|
+
res.end('Not found');
|
|
2938
|
+
};
|
|
2939
|
+
}
|
|
2940
|
+
|
|
2941
|
+
function startServer(portIndex, resolve, reject) {
|
|
2942
|
+
if (portIndex >= PORTS.length) {
|
|
2943
|
+
const msg = 'Shards UI: all ports in use (7842-7845)';
|
|
2944
|
+
if (reject) { reject(new Error(msg)); return; }
|
|
2945
|
+
console.error(msg);
|
|
2946
|
+
process.exit(1);
|
|
2947
|
+
}
|
|
2948
|
+
|
|
2949
|
+
const port = PORTS[portIndex];
|
|
2950
|
+
const handler = createHandler();
|
|
2951
|
+
|
|
2952
|
+
// P5: Create HTTPS server if TLS cert/key are provided
|
|
2953
|
+
let server;
|
|
2954
|
+
let protocol = 'http';
|
|
2955
|
+
if (TLS_CERT && TLS_KEY) {
|
|
2956
|
+
try {
|
|
2957
|
+
const tlsOptions = {
|
|
2958
|
+
cert: fs.readFileSync(TLS_CERT),
|
|
2959
|
+
key: fs.readFileSync(TLS_KEY),
|
|
2960
|
+
};
|
|
2961
|
+
server = https.createServer(tlsOptions, handler);
|
|
2962
|
+
protocol = 'https';
|
|
2963
|
+
} catch (err) {
|
|
2964
|
+
const msg = `Shards UI: Failed to load TLS cert/key: ${err.message}`;
|
|
2965
|
+
if (reject) { reject(new Error(msg)); return; }
|
|
2966
|
+
console.error(msg);
|
|
2967
|
+
process.exit(1);
|
|
2968
|
+
}
|
|
2969
|
+
} else {
|
|
2970
|
+
server = http.createServer(handler);
|
|
2971
|
+
}
|
|
2972
|
+
|
|
2973
|
+
// P5: Warn if binding to 0.0.0.0 without TLS
|
|
2974
|
+
if (BIND_ADDR === '0.0.0.0' && protocol === 'http') {
|
|
2975
|
+
console.warn('WARNING: Binding to 0.0.0.0 without TLS. All traffic is unencrypted. Use SHARDS_TLS_CERT and SHARDS_TLS_KEY for secure transport, or use a reverse proxy.');
|
|
2976
|
+
}
|
|
2977
|
+
|
|
2978
|
+
server.on('error', (e) => {
|
|
2979
|
+
if (e.code === 'EADDRINUSE') {
|
|
2980
|
+
startServer(portIndex + 1, resolve, reject);
|
|
2981
|
+
} else {
|
|
2982
|
+
const msg = 'Shards UI server error: ' + e.message;
|
|
2983
|
+
if (reject) { reject(new Error(msg)); return; }
|
|
2984
|
+
console.error(msg);
|
|
2985
|
+
process.exit(1);
|
|
2986
|
+
}
|
|
2987
|
+
});
|
|
2988
|
+
|
|
2989
|
+
server.listen(port, BIND_ADDR, () => {
|
|
2990
|
+
fs.mkdirSync(SHARDS_DIR, { recursive: true });
|
|
2991
|
+
fs.mkdirSync(SESSIONS_DIR, { recursive: true });
|
|
2992
|
+
|
|
2993
|
+
// P1: Write port file as JSON with auth token
|
|
2994
|
+
try {
|
|
2995
|
+
fs.writeFileSync(PORT_FILE, JSON.stringify({ port, token: AUTH_TOKEN }));
|
|
2996
|
+
fs.writeFileSync(PID_FILE, String(process.pid));
|
|
2997
|
+
} catch {}
|
|
2998
|
+
|
|
2999
|
+
console.log(`Shards UI server running on ${protocol}://${BIND_ADDR === '0.0.0.0' ? 'localhost' : BIND_ADDR}:${port}`);
|
|
3000
|
+
|
|
3001
|
+
// P4: Reconnect to orphaned sessions or clean up
|
|
3002
|
+
reconnectOrCleanup(SESSIONS_DIR, sessions, SessionStore, handleChatEvent, handleChatExit, PROJECT_DIR);
|
|
3003
|
+
|
|
3004
|
+
// Sweep stale active sessions in INDEX.json. Anything reconnected above
|
|
3005
|
+
// is back in the in-memory map; anything that wasn't and is past the
|
|
3006
|
+
// abandonment window gets reclassified so the picker can show it as
|
|
3007
|
+
// such instead of a phantom "active" row.
|
|
3008
|
+
try {
|
|
3009
|
+
const swept = sessionIndex.sweepAbandoned(SESSIONS_DIR);
|
|
3010
|
+
if (swept > 0) log(`session-index: reclassified ${swept} stale active sessions as abandoned`);
|
|
3011
|
+
} catch (err) {
|
|
3012
|
+
log(`session-index sweep failed: ${err.message}`);
|
|
3013
|
+
}
|
|
3014
|
+
|
|
3015
|
+
// Initial file scan then poll
|
|
3016
|
+
pollFiles();
|
|
3017
|
+
|
|
3018
|
+
// Start gate / auto / violations watchers (replaces browser-side 2s poll)
|
|
3019
|
+
startGateWatchers();
|
|
3020
|
+
|
|
3021
|
+
// Build symbol index asynchronously (reference cache is built lazily on hover)
|
|
3022
|
+
setTimeout(() => {
|
|
3023
|
+
try {
|
|
3024
|
+
symbolIndex.buildIndex(PROJECT_DIR, log);
|
|
3025
|
+
symbolIndex.startWatcher(PROJECT_DIR, log);
|
|
3026
|
+
log(`Symbol index ready: ${symbolIndex.getStatus().symbolCount} symbols from ${symbolIndex.getStatus().fileCount} files`);
|
|
3027
|
+
} catch (err) {
|
|
3028
|
+
log(`Symbol index build failed: ${err.message}`);
|
|
3029
|
+
}
|
|
3030
|
+
}, 100);
|
|
3031
|
+
|
|
3032
|
+
// Resolve the promise for createServer callers
|
|
3033
|
+
if (resolve) {
|
|
3034
|
+
resolve({
|
|
3035
|
+
port,
|
|
3036
|
+
authToken: AUTH_TOKEN,
|
|
3037
|
+
httpServer: server,
|
|
3038
|
+
shutdown: () => {
|
|
3039
|
+
server.close();
|
|
3040
|
+
cleanup();
|
|
3041
|
+
}
|
|
3042
|
+
});
|
|
3043
|
+
}
|
|
3044
|
+
});
|
|
3045
|
+
}
|
|
3046
|
+
|
|
3047
|
+
// Clean up on exit — don't kill detached chat processes, they survive restarts
|
|
3048
|
+
function cleanup() {
|
|
3049
|
+
symbolIndex.stopWatcher();
|
|
3050
|
+
stopGateWatchers();
|
|
3051
|
+
try { fs.unlinkSync(PORT_FILE); } catch {}
|
|
3052
|
+
try { fs.unlinkSync(PID_FILE); } catch {}
|
|
3053
|
+
}
|
|
3054
|
+
process.on('exit', cleanup);
|
|
3055
|
+
process.on('SIGTERM', () => { cleanup(); process.exit(0); });
|
|
3056
|
+
process.on('SIGINT', () => { cleanup(); process.exit(0); });
|
|
3057
|
+
|
|
3058
|
+
// ─── Factory for Electron / programmatic use ────────────────────────────────
|
|
3059
|
+
|
|
3060
|
+
function createServer(projectDir, options = {}) {
|
|
3061
|
+
initPaths(projectDir, options);
|
|
3062
|
+
return new Promise((resolve, reject) => {
|
|
3063
|
+
startServer(0, resolve, reject);
|
|
3064
|
+
});
|
|
3065
|
+
}
|
|
3066
|
+
|
|
3067
|
+
// Standalone mode — preserves existing CLI behavior
|
|
3068
|
+
if (require.main === module) {
|
|
3069
|
+
startServer(0);
|
|
3070
|
+
}
|
|
3071
|
+
|
|
3072
|
+
module.exports = { createServer };
|