dsh-compound-engineering 0.0.0-stage → 0.1.1
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/LICENSE +21 -0
- package/README.md +160 -2
- package/README.zh.md +161 -0
- package/cordis.patch.yml +6 -0
- package/lib/frontmatter.js +167 -0
- package/lib/index.js +46 -0
- package/lib/provider.js +201 -0
- package/package.json +62 -4
- package/skills/CODING_STANDARDS.md +68 -0
- package/skills/ce-babysit-pr/SKILL.md +59 -0
- package/skills/ce-babysit-pr/references/branch-currency.md +22 -0
- package/skills/ce-babysit-pr/references/envelope.md +44 -0
- package/skills/ce-babysit-pr/references/pipeline.md +15 -0
- package/skills/ce-babysit-pr/references/report.md +20 -0
- package/skills/ce-babysit-pr/references/settle.md +37 -0
- package/skills/ce-babysit-pr/references/setup.md +33 -0
- package/skills/ce-babysit-pr/references/stack-commands.md +39 -0
- package/skills/ce-babysit-pr/references/stack.md +43 -0
- package/skills/ce-babysit-pr/references/tick.md +102 -0
- package/skills/ce-babysit-pr/references/watch-loop.md +222 -0
- package/skills/ce-babysit-pr/scripts/pr-snapshot +3656 -0
- package/skills/ce-bakeoff/SKILL.md +48 -0
- package/skills/ce-bakeoff/references/candidates.md +37 -0
- package/skills/ce-bakeoff/references/judging.md +19 -0
- package/skills/ce-bakeoff/references/output.md +11 -0
- package/skills/ce-bakeoff/references/verification.md +15 -0
- package/skills/ce-brainstorm/SKILL.md +69 -0
- package/skills/ce-brainstorm/references/agents/slack-researcher.md +127 -0
- package/skills/ce-brainstorm/references/approaches.md +57 -0
- package/skills/ce-brainstorm/references/bakeoff.md +9 -0
- package/skills/ce-brainstorm/references/blindspot-pass.md +70 -0
- package/skills/ce-brainstorm/references/brainstorm-sections.md +458 -0
- package/skills/ce-brainstorm/references/dialogue.md +89 -0
- package/skills/ce-brainstorm/references/handoff.md +187 -0
- package/skills/ce-brainstorm/references/html-rendering.md +638 -0
- package/skills/ce-brainstorm/references/interaction-rules.md +33 -0
- package/skills/ce-brainstorm/references/markdown-rendering.md +242 -0
- package/skills/ce-brainstorm/references/model-tiers.md +13 -0
- package/skills/ce-brainstorm/references/output-mode.md +26 -0
- package/skills/ce-brainstorm/references/phase-0.md +104 -0
- package/skills/ce-brainstorm/references/plan-write.md +32 -0
- package/skills/ce-brainstorm/references/product-pressure-test.md +42 -0
- package/skills/ce-brainstorm/references/reasoning-elevation.md +124 -0
- package/skills/ce-brainstorm/references/settled-decisions.md +45 -0
- package/skills/ce-brainstorm/references/synthesis-summary.md +149 -0
- package/skills/ce-brainstorm/references/universal-brainstorming.md +72 -0
- package/skills/ce-brainstorm/references/verdict-routing.md +26 -0
- package/skills/ce-brainstorm/references/visual-probes.md +164 -0
- package/skills/ce-brainstorm/scripts/elevation-dispatch.sh +288 -0
- package/skills/ce-brainstorm/scripts/light-webserver.js +1275 -0
- package/skills/ce-brainstorm/scripts/packs-resolve.py +737 -0
- package/skills/ce-brainstorm/scripts/peer-job-runner.py +2594 -0
- package/skills/ce-code-review/SKILL.md +40 -0
- package/skills/ce-code-review/references/action-class-rubric.md +67 -0
- package/skills/ce-code-review/references/cross-model-recovery.md +38 -0
- package/skills/ce-code-review/references/cross-model-review.md +191 -0
- package/skills/ce-code-review/references/depth-paths.md +32 -0
- package/skills/ce-code-review/references/diff-scope.md +59 -0
- package/skills/ce-code-review/references/dispatch-reviewers.md +135 -0
- package/skills/ce-code-review/references/findings-schema.json +125 -0
- package/skills/ce-code-review/references/finish-input.md +118 -0
- package/skills/ce-code-review/references/finish-review.md +202 -0
- package/skills/ce-code-review/references/intent-and-plan.md +73 -0
- package/skills/ce-code-review/references/modes-and-output.md +156 -0
- package/skills/ce-code-review/references/persona-catalog.md +66 -0
- package/skills/ce-code-review/references/personas/adversarial-reviewer.md +111 -0
- package/skills/ce-code-review/references/personas/agent-native-reviewer.md +173 -0
- package/skills/ce-code-review/references/personas/api-contract-reviewer.md +44 -0
- package/skills/ce-code-review/references/personas/correctness-reviewer.md +48 -0
- package/skills/ce-code-review/references/personas/data-migration-reviewer.md +111 -0
- package/skills/ce-code-review/references/personas/deployment-verification-agent.md +157 -0
- package/skills/ce-code-review/references/personas/julik-frontend-races-reviewer.md +45 -0
- package/skills/ce-code-review/references/personas/learnings-researcher.md +260 -0
- package/skills/ce-code-review/references/personas/maintainability-reviewer.md +85 -0
- package/skills/ce-code-review/references/personas/performance-reviewer.md +45 -0
- package/skills/ce-code-review/references/personas/previous-comments-reviewer.md +59 -0
- package/skills/ce-code-review/references/personas/project-standards-reviewer.md +77 -0
- package/skills/ce-code-review/references/personas/reliability-reviewer.md +49 -0
- package/skills/ce-code-review/references/personas/security-reviewer.md +50 -0
- package/skills/ce-code-review/references/personas/swift-ios-reviewer.md +99 -0
- package/skills/ce-code-review/references/personas/testing-reviewer.md +51 -0
- package/skills/ce-code-review/references/review-output-template.md +172 -0
- package/skills/ce-code-review/references/scope.md +176 -0
- package/skills/ce-code-review/references/select-and-route.md +93 -0
- package/skills/ce-code-review/references/subagent-template.md +206 -0
- package/skills/ce-code-review/references/validator-batch-template.md +67 -0
- package/skills/ce-code-review/scripts/cross-model-adversarial-review.sh +1275 -0
- package/skills/ce-code-review/scripts/findings-mechanics.py +274 -0
- package/skills/ce-code-review/scripts/packs-resolve.py +737 -0
- package/skills/ce-code-review/scripts/peer-job-runner.py +2594 -0
- package/skills/ce-code-review/scripts/review-scope.py +419 -0
- package/skills/ce-code-review/scripts/run-log.py +280 -0
- package/skills/ce-commit/SKILL.md +60 -0
- package/skills/ce-commit-push-pr/SKILL.md +69 -0
- package/skills/ce-commit-push-pr/references/apply-and-handoff.md +79 -0
- package/skills/ce-commit-push-pr/references/branch-creation.md +47 -0
- package/skills/ce-commit-push-pr/references/commit-and-push.md +26 -0
- package/skills/ce-commit-push-pr/references/compose.md +29 -0
- package/skills/ce-commit-push-pr/references/context.md +42 -0
- package/skills/ce-commit-push-pr/references/gh-stack-cli.md +83 -0
- package/skills/ce-commit-push-pr/references/pr-description-writing.md +208 -0
- package/skills/ce-commit-push-pr/references/stack-submit.md +81 -0
- package/skills/ce-compound/SKILL.md +79 -0
- package/skills/ce-compound/assets/resolution-template.md +96 -0
- package/skills/ce-compound/references/agents/best-practices-researcher.md +115 -0
- package/skills/ce-compound/references/agents/data-integrity-guardian.md +68 -0
- package/skills/ce-compound/references/agents/framework-docs-researcher.md +93 -0
- package/skills/ce-compound/references/agents/pattern-recognition-specialist.md +55 -0
- package/skills/ce-compound/references/agents/performance-oracle.md +108 -0
- package/skills/ce-compound/references/agents/security-sentinel.md +91 -0
- package/skills/ce-compound/references/agents/session-historian.md +82 -0
- package/skills/ce-compound/references/assembly.md +127 -0
- package/skills/ce-compound/references/concepts-vocabulary.md +98 -0
- package/skills/ce-compound/references/enhancement.md +42 -0
- package/skills/ce-compound/references/grounding-validation.md +89 -0
- package/skills/ce-compound/references/lightweight.md +56 -0
- package/skills/ce-compound/references/modes.md +13 -0
- package/skills/ce-compound/references/refresh-and-discoverability.md +96 -0
- package/skills/ce-compound/references/report.md +115 -0
- package/skills/ce-compound/references/research.md +144 -0
- package/skills/ce-compound/references/schema.yaml +236 -0
- package/skills/ce-compound/references/session-history.md +73 -0
- package/skills/ce-compound/references/yaml-schema.md +130 -0
- package/skills/ce-compound/scripts/packs-resolve.py +737 -0
- package/skills/ce-compound/scripts/session-history/__pycache__/dsh_session.cpython-314.pyc +0 -0
- package/skills/ce-compound/scripts/session-history/discover-sessions.sh +290 -0
- package/skills/ce-compound/scripts/session-history/discover_dsh.py +123 -0
- package/skills/ce-compound/scripts/session-history/dsh_session.py +225 -0
- package/skills/ce-compound/scripts/session-history/extract-errors.py +178 -0
- package/skills/ce-compound/scripts/session-history/extract-metadata.py +301 -0
- package/skills/ce-compound/scripts/session-history/extract-skeleton.py +346 -0
- package/skills/ce-compound/scripts/validate-doc-claims.py +497 -0
- package/skills/ce-compound/scripts/validate-frontmatter.py +137 -0
- package/skills/ce-compound-refresh/SKILL.md +88 -0
- package/skills/ce-compound-refresh/assets/resolution-template.md +96 -0
- package/skills/ce-compound-refresh/references/classify.md +42 -0
- package/skills/ce-compound-refresh/references/commit.md +7 -0
- package/skills/ce-compound-refresh/references/concepts-vocabulary.md +116 -0
- package/skills/ce-compound-refresh/references/discoverability.md +15 -0
- package/skills/ce-compound-refresh/references/investigate.md +23 -0
- package/skills/ce-compound-refresh/references/modes.md +27 -0
- package/skills/ce-compound-refresh/references/per-action-flows.md +142 -0
- package/skills/ce-compound-refresh/references/report.md +23 -0
- package/skills/ce-compound-refresh/references/schema.yaml +236 -0
- package/skills/ce-compound-refresh/references/scope.md +18 -0
- package/skills/ce-compound-refresh/references/worth-audit.md +35 -0
- package/skills/ce-compound-refresh/references/yaml-schema.md +130 -0
- package/skills/ce-compound-refresh/scripts/validate-doc-claims.py +497 -0
- package/skills/ce-compound-refresh/scripts/validate-frontmatter.py +137 -0
- package/skills/ce-debug/SKILL.md +122 -0
- package/skills/ce-debug/references/anti-patterns.md +91 -0
- package/skills/ce-debug/references/defense-in-depth.md +39 -0
- package/skills/ce-debug/references/fix.md +21 -0
- package/skills/ce-debug/references/investigate.md +114 -0
- package/skills/ce-debug/references/investigation-techniques.md +384 -0
- package/skills/ce-debug/references/pipeline-mode.md +78 -0
- package/skills/ce-debug/references/post-fix-handoff.md +60 -0
- package/skills/ce-debug/references/return-to-caller.md +57 -0
- package/skills/ce-doc-review/SKILL.md +75 -0
- package/skills/ce-doc-review/references/bulk-preview.md +146 -0
- package/skills/ce-doc-review/references/cross-model-review.md +207 -0
- package/skills/ce-doc-review/references/decision-primer.md +47 -0
- package/skills/ce-doc-review/references/dispatch.md +56 -0
- package/skills/ce-doc-review/references/document-intake.md +44 -0
- package/skills/ce-doc-review/references/findings-schema.json +85 -0
- package/skills/ce-doc-review/references/modes.md +20 -0
- package/skills/ce-doc-review/references/open-questions-defer.md +158 -0
- package/skills/ce-doc-review/references/persona-selection.md +41 -0
- package/skills/ce-doc-review/references/personas/adversarial-document-reviewer.md +109 -0
- package/skills/ce-doc-review/references/personas/coherence-reviewer.md +69 -0
- package/skills/ce-doc-review/references/personas/design-lens-reviewer.md +48 -0
- package/skills/ce-doc-review/references/personas/feasibility-reviewer.md +50 -0
- package/skills/ce-doc-review/references/personas/product-lens-reviewer.md +85 -0
- package/skills/ce-doc-review/references/personas/scope-guardian-reviewer.md +83 -0
- package/skills/ce-doc-review/references/personas/security-lens-reviewer.md +41 -0
- package/skills/ce-doc-review/references/personas/whole-doc-reviewer.md +25 -0
- package/skills/ce-doc-review/references/rendering-floor.md +175 -0
- package/skills/ce-doc-review/references/review-output-template.md +123 -0
- package/skills/ce-doc-review/references/subagent-template.md +188 -0
- package/skills/ce-doc-review/references/synthesis-and-presentation.md +350 -0
- package/skills/ce-doc-review/references/walkthrough.md +347 -0
- package/skills/ce-doc-review/scripts/cross-model-doc-review.sh +1270 -0
- package/skills/ce-doc-review/scripts/packs-resolve.py +737 -0
- package/skills/ce-doc-review/scripts/peer-job-runner.py +2594 -0
- package/skills/ce-dogfood/SKILL.md +66 -0
- package/skills/ce-dogfood/agents/openai.yaml +2 -0
- package/skills/ce-dogfood/references/dogfood-report-template.md +97 -0
- package/skills/ce-dogfood/references/phases.md +167 -0
- package/skills/ce-dogfood/references/test-matrix-taxonomy.md +62 -0
- package/skills/ce-dogfood/scripts/packs-resolve.py +737 -0
- package/skills/ce-explain/SKILL.md +81 -0
- package/skills/ce-explain/references/agents/behavior-trace-scout.md +19 -0
- package/skills/ce-explain/references/agents/work-recap-scout.md +23 -0
- package/skills/ce-explain/references/check-in.md +18 -0
- package/skills/ce-explain/references/destinations.md +35 -0
- package/skills/ce-explain/references/explainer-html.md +29 -0
- package/skills/ce-explain/references/explainer-markdown.md +24 -0
- package/skills/ce-explain/references/intake.md +50 -0
- package/skills/ce-explain/references/orchestration.md +46 -0
- package/skills/ce-handoff/SKILL.md +54 -0
- package/skills/ce-handoff/references/create.md +88 -0
- package/skills/ce-handoff/references/resume.md +46 -0
- package/skills/ce-ideate/SKILL.md +75 -0
- package/skills/ce-ideate/references/agents/issue-intelligence-analyst.md +166 -0
- package/skills/ce-ideate/references/agents/learnings-researcher.md +247 -0
- package/skills/ce-ideate/references/agents/slack-researcher.md +127 -0
- package/skills/ce-ideate/references/agents/web-researcher.md +121 -0
- package/skills/ce-ideate/references/decomposition.md +39 -0
- package/skills/ce-ideate/references/divergent-ideation.md +112 -0
- package/skills/ce-ideate/references/grounding.md +138 -0
- package/skills/ce-ideate/references/html-rendering.md +638 -0
- package/skills/ce-ideate/references/ideation-sections.md +191 -0
- package/skills/ce-ideate/references/issue-intelligence.md +44 -0
- package/skills/ce-ideate/references/markdown-rendering.md +242 -0
- package/skills/ce-ideate/references/output-mode.md +34 -0
- package/skills/ce-ideate/references/post-ideation-workflow.md +169 -0
- package/skills/ce-ideate/references/scope-gates.md +148 -0
- package/skills/ce-ideate/references/universal-ideation.md +119 -0
- package/skills/ce-ideate/references/user-research-artifacts.md +28 -0
- package/skills/ce-ideate/references/web-research-cache.md +56 -0
- package/skills/ce-noslop/SKILL.md +45 -0
- package/skills/ce-noslop/references/patterns.md +61 -0
- package/skills/ce-noslop/references/terminology.md +3 -0
- package/skills/ce-optimize/SKILL.md +64 -0
- package/skills/ce-optimize/references/agents/learnings-researcher.md +247 -0
- package/skills/ce-optimize/references/agents/repo-research-analyst.md +258 -0
- package/skills/ce-optimize/references/example-expensive-benchmark-spec.yaml +88 -0
- package/skills/ce-optimize/references/example-hard-spec.yaml +64 -0
- package/skills/ce-optimize/references/example-judge-spec.yaml +83 -0
- package/skills/ce-optimize/references/example-text-target-spec.yaml +80 -0
- package/skills/ce-optimize/references/experiment-log-schema.yaml +563 -0
- package/skills/ce-optimize/references/experiment-prompt-template.md +143 -0
- package/skills/ce-optimize/references/judge-prompt-template.md +117 -0
- package/skills/ce-optimize/references/loop.md +286 -0
- package/skills/ce-optimize/references/measurement.md +168 -0
- package/skills/ce-optimize/references/optimize-spec-schema.yaml +570 -0
- package/skills/ce-optimize/references/persistence.md +93 -0
- package/skills/ce-optimize/references/spec.md +107 -0
- package/skills/ce-optimize/references/text-targets.md +51 -0
- package/skills/ce-optimize/references/usage-guide.md +147 -0
- package/skills/ce-optimize/references/wrap-up.md +54 -0
- package/skills/ce-optimize/scripts/decide.mjs +676 -0
- package/skills/ce-optimize/scripts/experiment-worktree.sh +330 -0
- package/skills/ce-optimize/scripts/measure.sh +140 -0
- package/skills/ce-optimize/scripts/parallel-probe.sh +134 -0
- package/skills/ce-plan/SKILL.md +60 -0
- package/skills/ce-plan/references/agents/agent-native-planning-strategist.md +66 -0
- package/skills/ce-plan/references/agents/architecture-strategist.md +46 -0
- package/skills/ce-plan/references/agents/best-practices-researcher.md +118 -0
- package/skills/ce-plan/references/agents/data-integrity-guardian.md +68 -0
- package/skills/ce-plan/references/agents/framework-docs-researcher.md +97 -0
- package/skills/ce-plan/references/agents/git-history-analyzer.md +40 -0
- package/skills/ce-plan/references/agents/learnings-researcher.md +268 -0
- package/skills/ce-plan/references/agents/pattern-recognition-specialist.md +55 -0
- package/skills/ce-plan/references/agents/performance-oracle.md +108 -0
- package/skills/ce-plan/references/agents/repo-research-analyst.md +262 -0
- package/skills/ce-plan/references/agents/security-sentinel.md +91 -0
- package/skills/ce-plan/references/agents/slack-researcher.md +131 -0
- package/skills/ce-plan/references/agents/spec-flow-analyzer.md +84 -0
- package/skills/ce-plan/references/agents/web-researcher.md +125 -0
- package/skills/ce-plan/references/approach-altitude.md +55 -0
- package/skills/ce-plan/references/bakeoff.md +9 -0
- package/skills/ce-plan/references/deepening-workflow.md +263 -0
- package/skills/ce-plan/references/final-review.md +134 -0
- package/skills/ce-plan/references/html-rendering.md +638 -0
- package/skills/ce-plan/references/intake.md +158 -0
- package/skills/ce-plan/references/markdown-rendering.md +242 -0
- package/skills/ce-plan/references/output-contracts.md +29 -0
- package/skills/ce-plan/references/output-mode.md +45 -0
- package/skills/ce-plan/references/plan-handoff.md +116 -0
- package/skills/ce-plan/references/plan-sections.md +489 -0
- package/skills/ce-plan/references/reasoning-elevation.md +124 -0
- package/skills/ce-plan/references/research.md +211 -0
- package/skills/ce-plan/references/resume.md +61 -0
- package/skills/ce-plan/references/settled-decisions.md +45 -0
- package/skills/ce-plan/references/structure.md +207 -0
- package/skills/ce-plan/references/synthesis-summary.md +260 -0
- package/skills/ce-plan/references/universal-planning.md +170 -0
- package/skills/ce-plan/scripts/elevation-dispatch.sh +288 -0
- package/skills/ce-plan/scripts/packs-resolve.py +737 -0
- package/skills/ce-plan/scripts/peer-job-runner.py +2594 -0
- package/skills/ce-polish/SKILL.md +22 -0
- package/skills/ce-polish/agents/openai.yaml +2 -0
- package/skills/ce-polish/references/dev-server-astro.md +58 -0
- package/skills/ce-polish/references/dev-server-detection.md +40 -0
- package/skills/ce-polish/references/dev-server-next.md +61 -0
- package/skills/ce-polish/references/dev-server-nuxt.md +58 -0
- package/skills/ce-polish/references/dev-server-procfile.md +59 -0
- package/skills/ce-polish/references/dev-server-rails.md +50 -0
- package/skills/ce-polish/references/dev-server-remix.md +58 -0
- package/skills/ce-polish/references/dev-server-sveltekit.md +58 -0
- package/skills/ce-polish/references/dev-server-vite.md +48 -0
- package/skills/ce-polish/references/install-riffrec.md +35 -0
- package/skills/ce-polish/references/launch-json-schema.md +177 -0
- package/skills/ce-polish/references/live-loop.md +98 -0
- package/skills/ce-polish/references/live-remote.md +35 -0
- package/skills/ce-polish/references/live-start.md +93 -0
- package/skills/ce-polish/references/live-stream-contract.md +253 -0
- package/skills/ce-polish/references/run.md +67 -0
- package/skills/ce-polish/scripts/detect-project-type.sh +277 -0
- package/skills/ce-polish/scripts/detect-riffrec.sh +225 -0
- package/skills/ce-polish/scripts/live-endpoint.js +2841 -0
- package/skills/ce-polish/scripts/read-launch-json.sh +117 -0
- package/skills/ce-polish/scripts/resolve-package-manager.sh +95 -0
- package/skills/ce-polish/scripts/resolve-port.sh +307 -0
- package/skills/ce-pov/SKILL.md +64 -0
- package/skills/ce-pov/references/agents/external-evidence-researcher.md +26 -0
- package/skills/ce-pov/references/agents/pov-peer.md +43 -0
- package/skills/ce-pov/references/agents/precedent-activity-scout.md +21 -0
- package/skills/ce-pov/references/agents/project-grounding-scout.md +32 -0
- package/skills/ce-pov/references/boundaries.md +36 -0
- package/skills/ce-pov/references/cross-model-panel.md +596 -0
- package/skills/ce-pov/references/followup.md +9 -0
- package/skills/ce-pov/references/grounding.md +47 -0
- package/skills/ce-pov/references/intake.md +61 -0
- package/skills/ce-pov/references/invocation.md +47 -0
- package/skills/ce-pov/references/method.md +68 -0
- package/skills/ce-pov/references/pov-schema.json +56 -0
- package/skills/ce-pov/references/report.md +7 -0
- package/skills/ce-pov/scripts/cross-model-pov.sh +1110 -0
- package/skills/ce-pov/scripts/peer-job-runner.py +2594 -0
- package/skills/ce-product-pulse/SKILL.md +81 -0
- package/skills/ce-product-pulse/agents/openai.yaml +2 -0
- package/skills/ce-product-pulse/references/config.md +20 -0
- package/skills/ce-product-pulse/references/interview.md +260 -0
- package/skills/ce-product-pulse/references/report-template.md +89 -0
- package/skills/ce-product-pulse/references/run.md +46 -0
- package/skills/ce-product-pulse/references/setup.md +43 -0
- package/skills/ce-promote/SKILL.md +90 -0
- package/skills/ce-promote/agents/openai.yaml +2 -0
- package/skills/ce-promote/references/spiral-cli.md +149 -0
- package/skills/ce-proof/SKILL.md +65 -0
- package/skills/ce-proof/references/api.md +176 -0
- package/skills/ce-proof/references/workflows.md +124 -0
- package/skills/ce-prototype/SKILL.md +59 -0
- package/skills/ce-prototype/assets/annotate.css +206 -0
- package/skills/ce-prototype/assets/annotate.js +837 -0
- package/skills/ce-prototype/references/annotation-loop.md +32 -0
- package/skills/ce-prototype/references/build.md +39 -0
- package/skills/ce-prototype/references/craft-floor.md +43 -0
- package/skills/ce-prototype/references/preview.md +118 -0
- package/skills/ce-prototype/references/scoping.md +38 -0
- package/skills/ce-prototype/references/write-back.md +37 -0
- package/skills/ce-prototype/scripts/light-webserver.js +1275 -0
- package/skills/ce-resolve-pr-feedback/SKILL.md +60 -0
- package/skills/ce-resolve-pr-feedback/references/agents/pr-comment-resolver.md +59 -0
- package/skills/ce-resolve-pr-feedback/references/evaluation-rubric.md +141 -0
- package/skills/ce-resolve-pr-feedback/references/full-mode.md +336 -0
- package/skills/ce-resolve-pr-feedback/references/pipeline-mode.md +23 -0
- package/skills/ce-resolve-pr-feedback/references/resume.md +60 -0
- package/skills/ce-resolve-pr-feedback/references/return-to-caller.md +111 -0
- package/skills/ce-resolve-pr-feedback/references/targeted-mode.md +48 -0
- package/skills/ce-resolve-pr-feedback/scripts/get-pr-comments +176 -0
- package/skills/ce-resolve-pr-feedback/scripts/get-thread-for-comment +84 -0
- package/skills/ce-resolve-pr-feedback/scripts/pending-feedback.py +287 -0
- package/skills/ce-resolve-pr-feedback/scripts/reply-to-pr-thread +57 -0
- package/skills/ce-resolve-pr-feedback/scripts/resolve-pr-thread +23 -0
- package/skills/ce-retune/SKILL.md +42 -0
- package/skills/ce-retune/agents/openai.yaml +2 -0
- package/skills/ce-retune/references/baseline-mining.md +122 -0
- package/skills/ce-retune/references/corpus-audit.md +128 -0
- package/skills/ce-retune/references/cut-passes.md +129 -0
- package/skills/ce-retune/references/halt-taxonomy.md +123 -0
- package/skills/ce-retune/references/noise-floor.md +131 -0
- package/skills/ce-retune/references/workflow-shapes.md +73 -0
- package/skills/ce-riffrec-feedback-analysis/SKILL.md +33 -0
- package/skills/ce-riffrec-feedback-analysis/references/analyzer.md +18 -0
- package/skills/ce-riffrec-feedback-analysis/references/compound-engineering-feedback-format.md +116 -0
- package/skills/ce-riffrec-feedback-analysis/references/extensive-analysis.md +98 -0
- package/skills/ce-riffrec-feedback-analysis/references/install-riffrec.md +27 -0
- package/skills/ce-riffrec-feedback-analysis/references/quick-bug-report.md +38 -0
- package/skills/ce-riffrec-feedback-analysis/scripts/analyze_riffrec_zip.py +1306 -0
- package/skills/ce-setup/SKILL.md +103 -0
- package/skills/ce-setup/agents/openai.yaml +2 -0
- package/skills/ce-setup/assets/compounding-directive.md +11 -0
- package/skills/ce-setup/assets/noslop-directive.md +7 -0
- package/skills/ce-setup/assets/pack-rule-template.md +11 -0
- package/skills/ce-setup/references/acpx-cache-warm.md +14 -0
- package/skills/ce-setup/references/config-template.yaml +210 -0
- package/skills/ce-setup/references/legacy-codex-tool-map.md +15 -0
- package/skills/ce-setup/references/pack-scaffold.md +45 -0
- package/skills/ce-setup/references/repo-fixes.md +121 -0
- package/skills/ce-setup/scripts/check-health +929 -0
- package/skills/ce-setup/scripts/packs-resolve.py +737 -0
- package/skills/ce-simplify-code/SKILL.md +76 -0
- package/skills/ce-simplify-code/references/personas/code-quality-reviewer.md +17 -0
- package/skills/ce-simplify-code/references/personas/code-reuse-reviewer.md +7 -0
- package/skills/ce-simplify-code/references/personas/comment-reviewer.md +24 -0
- package/skills/ce-simplify-code/references/personas/efficiency-reviewer.md +11 -0
- package/skills/ce-simplify-code/references/personas/test-value-reviewer.md +23 -0
- package/skills/ce-simplify-code/references/reviewer-dispatch.md +11 -0
- package/skills/ce-simplify-code/references/standalone-report.md +25 -0
- package/skills/ce-strategy/SKILL.md +61 -0
- package/skills/ce-strategy/references/grounding.md +22 -0
- package/skills/ce-strategy/references/interview.md +179 -0
- package/skills/ce-strategy/references/strategy-template.md +94 -0
- package/skills/ce-strategy/references/update-run.md +33 -0
- package/skills/ce-sweep/SKILL.md +93 -0
- package/skills/ce-sweep/agents/openai.yaml +2 -0
- package/skills/ce-sweep/references/agents/media-analyzer.md +54 -0
- package/skills/ce-sweep/references/interview.md +208 -0
- package/skills/ce-sweep/references/model-tiers.md +13 -0
- package/skills/ce-sweep/references/plan-template.md +62 -0
- package/skills/ce-sweep/references/run.md +126 -0
- package/skills/ce-sweep/references/sources/email.md +57 -0
- package/skills/ce-sweep/references/sources/github-issues.md +59 -0
- package/skills/ce-sweep/references/sources/slack.md +61 -0
- package/skills/ce-sweep/references/state-schema.md +157 -0
- package/skills/ce-sweep/references/subagent-template.md +60 -0
- package/skills/ce-sweep/scripts/analyze_riffrec_zip.py +1306 -0
- package/skills/ce-sweep/scripts/sweep-state.py +793 -0
- package/skills/ce-test-browser/SKILL.md +50 -0
- package/skills/ce-test-browser/references/agent-browser-driver.md +47 -0
- package/skills/ce-test-browser/references/pipeline-orchestration.md +45 -0
- package/skills/ce-test-browser/references/route-and-report.md +129 -0
- package/skills/ce-test-browser/scripts/resolve-port.sh +41 -0
- package/skills/ce-test-xcode/SKILL.md +26 -0
- package/skills/ce-test-xcode/agents/openai.yaml +2 -0
- package/skills/ce-test-xcode/references/setup-and-build.md +32 -0
- package/skills/ce-test-xcode/references/test-and-report.md +64 -0
- package/skills/ce-work/SKILL.md +62 -0
- package/skills/ce-work/references/agents/figma-design-sync.md +27 -0
- package/skills/ce-work/references/agents/implementation-worker.md +17 -0
- package/skills/ce-work/references/cross-model-execution.md +191 -0
- package/skills/ce-work/references/execution-engines.md +64 -0
- package/skills/ce-work/references/execution-strategy.md +73 -0
- package/skills/ce-work/references/implementation-loop.md +107 -0
- package/skills/ce-work/references/implementation-result-schema.json +52 -0
- package/skills/ce-work/references/input-triage.md +50 -0
- package/skills/ce-work/references/non-code-execution.md +23 -0
- package/skills/ce-work/references/return-to-caller.md +33 -0
- package/skills/ce-work/references/review-findings-followup.md +82 -0
- package/skills/ce-work/references/shipping-workflow.md +89 -0
- package/skills/ce-work/references/tracker-defer.md +150 -0
- package/skills/ce-work/references/work-intake.md +51 -0
- package/skills/ce-work/references/workspace-setup.md +27 -0
- package/skills/ce-work/scripts/__pycache__/unit_workspace_jobs.cpython-314.pyc +0 -0
- package/skills/ce-work/scripts/__pycache__/unit_workspace_state.cpython-314.pyc +0 -0
- package/skills/ce-work/scripts/cross-model-work.sh +1385 -0
- package/skills/ce-work/scripts/peer-job-runner.py +2594 -0
- package/skills/ce-work/scripts/unit-workspace.py +215 -0
- package/skills/ce-work/scripts/unit_workspace_ignored.py +93 -0
- package/skills/ce-work/scripts/unit_workspace_integration.py +677 -0
- package/skills/ce-work/scripts/unit_workspace_jobs.py +1216 -0
- package/skills/ce-work/scripts/unit_workspace_lifecycle.py +981 -0
- package/skills/ce-work/scripts/unit_workspace_state.py +1379 -0
- package/skills/ce-work/scripts/unit_workspace_transaction.py +612 -0
- package/skills/ce-worktree/SKILL.md +54 -0
- package/skills/lfg/SKILL.md +59 -0
- package/skills/lfg/references/debug-return.md +25 -0
- package/skills/lfg/references/intake.md +30 -0
- package/skills/lfg/references/next-work-handoff.md +96 -0
- package/skills/lfg/references/plan-brief.md +36 -0
- package/skills/lfg/references/review-followup.md +78 -0
- package/skills/lfg/references/shipping.md +65 -0
- package/skills/lfg/references/stage-routing.md +49 -0
- package/skills/lfg/references/task-visibility.md +11 -0
- package/skills/lfg/references/tracker-defer.md +150 -0
- package/skills/lfg/references/work-return.md +27 -0
- package/skills/wtf/SKILL.md +33 -0
- package/skills/wtf/agents/openai.yaml +2 -0
|
@@ -0,0 +1,1275 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { createHash, randomUUID } from "node:crypto"
|
|
3
|
+
import { execFileSync, spawn } from "node:child_process"
|
|
4
|
+
import fs from "node:fs"
|
|
5
|
+
import http from "node:http"
|
|
6
|
+
import path from "node:path"
|
|
7
|
+
import { fileURLToPath } from "node:url"
|
|
8
|
+
|
|
9
|
+
const scriptPath = fileURLToPath(import.meta.url)
|
|
10
|
+
const assetsDir = path.join(path.dirname(scriptPath), "..", "assets")
|
|
11
|
+
const DEFAULT_HOST = "127.0.0.1"
|
|
12
|
+
const DEFAULT_URL_HOST = "localhost"
|
|
13
|
+
const IDLE_TIMEOUT_MS = Number(process.env.CE_LIGHT_WEB_IDLE_TIMEOUT_MS) || 30 * 60 * 1000
|
|
14
|
+
const LIFECYCLE_CHECK_MS = Number(process.env.CE_LIGHT_WEB_LIFECYCLE_CHECK_MS) || 60 * 1000
|
|
15
|
+
const WAIT_TIMEOUT_MS = Number(process.env.CE_LIGHT_WEB_WAIT_TIMEOUT_MS) || 30 * 1000
|
|
16
|
+
const SSE_GRACE_MS = Number(process.env.CE_LIGHT_WEB_SSE_GRACE_MS) || 30000
|
|
17
|
+
const BODY_LIMIT = 64 * 1024
|
|
18
|
+
// Reserved URL namespace for the overlay, so a screen's own /annotate.js or
|
|
19
|
+
// /annotate.css under screens/ is never shadowed.
|
|
20
|
+
const OVERLAY_PREFIX = "/__ce-annotate"
|
|
21
|
+
const OVERLAY_FILES = {
|
|
22
|
+
[`${OVERLAY_PREFIX}/annotate.js`]: "annotate.js",
|
|
23
|
+
[`${OVERLAY_PREFIX}/annotate.css`]: "annotate.css",
|
|
24
|
+
}
|
|
25
|
+
// A Host header is reflected into the served document only in this shape.
|
|
26
|
+
const HOST_HEADER = /^[A-Za-z0-9.\-]+(:\d{1,5})?$|^\[[0-9A-Fa-f:.]+\](:\d{1,5})?$/
|
|
27
|
+
|
|
28
|
+
function usage() {
|
|
29
|
+
return [
|
|
30
|
+
"Usage:",
|
|
31
|
+
" node light-webserver.js start --root <dir> [--host 127.0.0.1] [--port 0] [--foreground] [--owner-pid <pid>] [--annotate]",
|
|
32
|
+
" node light-webserver.js stop --root <dir>",
|
|
33
|
+
" node light-webserver.js status --root <dir>",
|
|
34
|
+
" node light-webserver.js wait --root <dir>",
|
|
35
|
+
].join("\n")
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function parseArgs(argv) {
|
|
39
|
+
const command = argv[2]
|
|
40
|
+
const options = {
|
|
41
|
+
command,
|
|
42
|
+
host: DEFAULT_HOST,
|
|
43
|
+
port: 0,
|
|
44
|
+
foreground: false,
|
|
45
|
+
annotate: false,
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
for (let i = 3; i < argv.length; i++) {
|
|
49
|
+
const arg = argv[i]
|
|
50
|
+
if (arg === "--root") {
|
|
51
|
+
options.root = argv[++i]
|
|
52
|
+
} else if (arg === "--host") {
|
|
53
|
+
options.host = argv[++i]
|
|
54
|
+
} else if (arg === "--port") {
|
|
55
|
+
options.port = Number(argv[++i])
|
|
56
|
+
} else if (arg === "--foreground") {
|
|
57
|
+
options.foreground = true
|
|
58
|
+
} else if (arg === "--owner-pid") {
|
|
59
|
+
options.ownerPid = Number(argv[++i])
|
|
60
|
+
} else if (arg === "--annotate") {
|
|
61
|
+
options.annotate = true
|
|
62
|
+
} else {
|
|
63
|
+
throw new Error(`Unknown argument: ${arg}`)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (!["start", "serve", "stop", "status", "wait"].includes(command)) {
|
|
68
|
+
throw new Error(usage())
|
|
69
|
+
}
|
|
70
|
+
if (!options.root) {
|
|
71
|
+
throw new Error("--root is required")
|
|
72
|
+
}
|
|
73
|
+
if (!Number.isInteger(options.port) || options.port < 0 || options.port > 65535) {
|
|
74
|
+
throw new Error("--port must be an integer from 0 to 65535")
|
|
75
|
+
}
|
|
76
|
+
if (options.ownerPid !== undefined && (!Number.isInteger(options.ownerPid) || options.ownerPid <= 1)) {
|
|
77
|
+
throw new Error("--owner-pid must be an integer greater than 1")
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
options.root = path.resolve(options.root)
|
|
81
|
+
options.screensDir = path.join(options.root, "screens")
|
|
82
|
+
options.stateDir = path.join(options.root, "state")
|
|
83
|
+
options.pidFile = path.join(options.stateDir, "server.pid")
|
|
84
|
+
options.infoFile = path.join(options.stateDir, "display-info.json")
|
|
85
|
+
options.logFile = path.join(options.stateDir, "server.log")
|
|
86
|
+
return options
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function ensureDirs(options) {
|
|
90
|
+
fs.mkdirSync(options.screensDir, { recursive: true })
|
|
91
|
+
fs.mkdirSync(options.stateDir, { recursive: true })
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function jsonOut(value) {
|
|
95
|
+
process.stdout.write(`${JSON.stringify(value)}\n`)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function readJson(filePath) {
|
|
99
|
+
return JSON.parse(fs.readFileSync(filePath, "utf8"))
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function processAlive(pid) {
|
|
103
|
+
if (!pid || !Number.isInteger(pid)) return false
|
|
104
|
+
try {
|
|
105
|
+
process.kill(pid, 0)
|
|
106
|
+
return true
|
|
107
|
+
} catch (error) {
|
|
108
|
+
return error?.code === "EPERM"
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function processArgs(pid) {
|
|
113
|
+
try {
|
|
114
|
+
return execFileSync("ps", ["-p", String(pid), "-o", "args="], {
|
|
115
|
+
encoding: "utf8",
|
|
116
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
117
|
+
}).trim()
|
|
118
|
+
} catch {
|
|
119
|
+
return null
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function ownsServerProcess(options, pid) {
|
|
124
|
+
const args = processArgs(pid)
|
|
125
|
+
// Process-command inspection is best-effort; when unavailable, fall back to
|
|
126
|
+
// PID-file behavior so stop still works on platforms without a compatible ps.
|
|
127
|
+
if (args === null) return true
|
|
128
|
+
if (!args.includes(scriptPath) || !args.includes(options.root)) return false
|
|
129
|
+
const tokens = args.split(/\s+/)
|
|
130
|
+
// Detached start spawns `serve`; `--foreground` keeps `start` in-process.
|
|
131
|
+
return tokens.includes("serve") || tokens.includes("start")
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function resolveOwnerPid() {
|
|
135
|
+
const parentPid = process.ppid
|
|
136
|
+
if (!parentPid || parentPid <= 1) return null
|
|
137
|
+
try {
|
|
138
|
+
const grandparent = Number(execFileSync("ps", ["-o", "ppid=", "-p", String(parentPid)], {
|
|
139
|
+
encoding: "utf8",
|
|
140
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
141
|
+
}).trim())
|
|
142
|
+
if (Number.isInteger(grandparent) && grandparent > 1) return grandparent
|
|
143
|
+
} catch {
|
|
144
|
+
// Fall back to the direct parent when grandparent lookup is unavailable.
|
|
145
|
+
}
|
|
146
|
+
return parentPid
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function readPid(options) {
|
|
150
|
+
if (!fs.existsSync(options.pidFile)) return null
|
|
151
|
+
const pid = Number(fs.readFileSync(options.pidFile, "utf8").trim())
|
|
152
|
+
return Number.isInteger(pid) ? pid : null
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function getRunningInfo(options) {
|
|
156
|
+
const pid = readPid(options)
|
|
157
|
+
if (!processAlive(pid)) return null
|
|
158
|
+
if (!ownsServerProcess(options, pid)) return null
|
|
159
|
+
if (!fs.existsSync(options.infoFile)) return null
|
|
160
|
+
try {
|
|
161
|
+
return readJson(options.infoFile)
|
|
162
|
+
} catch {
|
|
163
|
+
return null
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// The recorded end of this root's session, or null while it is live or unknown.
|
|
168
|
+
function sessionEnd(options) {
|
|
169
|
+
try {
|
|
170
|
+
const info = readJson(options.infoFile)
|
|
171
|
+
return info.session_ended ? { reason: info.session_end_reason || null } : null
|
|
172
|
+
} catch {
|
|
173
|
+
return null
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function exitSessionEnded({ reason }) {
|
|
178
|
+
process.exitCode = 1
|
|
179
|
+
jsonOut({ status: "session-ended", ...(reason ? { reason } : {}) })
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Containment has to survive symlinks: path.resolve is lexical, so a link
|
|
183
|
+
// inside the run directory would otherwise be followed straight out of it.
|
|
184
|
+
// Every route that reads a file goes through this — the screen route and the
|
|
185
|
+
// asset route drifting apart is what left one of them unguarded before.
|
|
186
|
+
function containedRealPath(rootDir, candidate) {
|
|
187
|
+
let root
|
|
188
|
+
let real
|
|
189
|
+
try {
|
|
190
|
+
root = fs.realpathSync(rootDir)
|
|
191
|
+
real = fs.realpathSync(candidate)
|
|
192
|
+
} catch {
|
|
193
|
+
return null
|
|
194
|
+
}
|
|
195
|
+
if (real !== root && !real.startsWith(root + path.sep)) return null
|
|
196
|
+
return real
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function newestScreen(options) {
|
|
200
|
+
if (!fs.existsSync(options.screensDir)) return null
|
|
201
|
+
const files = fs.readdirSync(options.screensDir)
|
|
202
|
+
.filter((file) => file.endsWith(".html"))
|
|
203
|
+
.map((file) => containedRealPath(options.screensDir, path.join(options.screensDir, file)))
|
|
204
|
+
.filter(Boolean)
|
|
205
|
+
.map((filePath) => ({ filePath, mtimeMs: fs.statSync(filePath).mtimeMs }))
|
|
206
|
+
.sort((a, b) => b.mtimeMs - a.mtimeMs)
|
|
207
|
+
return files[0]?.filePath ?? null
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function isFullDocument(html) {
|
|
211
|
+
// After ignorable prologue (whitespace, HTML comments), a doctype or <html>
|
|
212
|
+
// is a complete document; anything else is a fragment.
|
|
213
|
+
let text = html
|
|
214
|
+
for (;;) {
|
|
215
|
+
text = text.trimStart()
|
|
216
|
+
if (!text.startsWith("<!--")) break
|
|
217
|
+
const end = text.indexOf("-->")
|
|
218
|
+
if (end === -1) return false
|
|
219
|
+
text = text.slice(end + 3)
|
|
220
|
+
}
|
|
221
|
+
const trimmed = text.toLowerCase()
|
|
222
|
+
return trimmed.startsWith("<!doctype") || trimmed.startsWith("<html")
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function screenVersion(options) {
|
|
226
|
+
const screen = newestScreen(options)
|
|
227
|
+
if (!screen) return { screen: null, mtimeMs: 0 }
|
|
228
|
+
return {
|
|
229
|
+
screen: path.basename(screen),
|
|
230
|
+
mtimeMs: fs.statSync(screen).mtimeMs,
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function versionKey(version) {
|
|
235
|
+
return `${version?.screen ?? ""}:${version?.mtimeMs ?? 0}`
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Annotate mode reloads the explorer's page on any change under screens/,
|
|
239
|
+
// including a stylesheet or script the newest screen links, so the change key
|
|
240
|
+
// covers every regular file there, not only the newest screen.
|
|
241
|
+
function screensChangeKey(options) {
|
|
242
|
+
const hash = createHash("sha1")
|
|
243
|
+
const walk = (dir, prefix) => {
|
|
244
|
+
let entries
|
|
245
|
+
try {
|
|
246
|
+
entries = fs.readdirSync(dir, { withFileTypes: true })
|
|
247
|
+
} catch {
|
|
248
|
+
return
|
|
249
|
+
}
|
|
250
|
+
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
251
|
+
const rel = `${prefix}${entry.name}`
|
|
252
|
+
if (entry.isDirectory()) {
|
|
253
|
+
walk(path.join(dir, entry.name), `${rel}/`)
|
|
254
|
+
continue
|
|
255
|
+
}
|
|
256
|
+
if (!entry.isFile()) continue
|
|
257
|
+
try {
|
|
258
|
+
const stat = fs.statSync(path.join(dir, entry.name))
|
|
259
|
+
hash.update(`${rel}:${stat.mtimeMs}:${stat.size}\n`)
|
|
260
|
+
} catch {
|
|
261
|
+
// Removed between readdir and stat: the next tick sees the settled tree.
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
walk(options.screensDir, "")
|
|
266
|
+
return hash.digest("hex")
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const WAITING_HTML = "<h1>Waiting for a page...</h1><p>The agent will update this page when a screen is ready.</p>"
|
|
270
|
+
const NO_STORE = { "Cache-Control": "no-store" }
|
|
271
|
+
|
|
272
|
+
function refreshScript(options) {
|
|
273
|
+
const initialVersion = JSON.stringify(screenVersion(options))
|
|
274
|
+
return `<script>
|
|
275
|
+
(function(){
|
|
276
|
+
var currentVersion = ${initialVersion};
|
|
277
|
+
function key(version) {
|
|
278
|
+
return String(version && version.screen) + ":" + String(version && version.mtimeMs);
|
|
279
|
+
}
|
|
280
|
+
async function checkForVisualProbeUpdate() {
|
|
281
|
+
try {
|
|
282
|
+
var response = await fetch("/version", { cache: "no-store" });
|
|
283
|
+
if (!response.ok) return;
|
|
284
|
+
var nextVersion = await response.json();
|
|
285
|
+
if (key(nextVersion) !== key(currentVersion)) {
|
|
286
|
+
window.location.reload();
|
|
287
|
+
}
|
|
288
|
+
} catch (error) {
|
|
289
|
+
// Keep the current sketch visible if the transient version check fails.
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
setInterval(checkForVisualProbeUpdate, 1000);
|
|
293
|
+
})();
|
|
294
|
+
</script>`
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Ahead of the authored document: deferred overlay. Its URL is absolute on
|
|
298
|
+
// the request's own origin, so a screen's <base href> cannot redirect it.
|
|
299
|
+
// data-ce-page is the path this response served, so a later History API
|
|
300
|
+
// rewrite is not the screen.
|
|
301
|
+
function htmlAttr(value) {
|
|
302
|
+
return String(value).replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<")
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function encodePagePath(page) {
|
|
306
|
+
if (typeof page !== "string" || !page.startsWith("/") || page === "/") return "/"
|
|
307
|
+
return `/${page.slice(1).split("/").map((segment) => {
|
|
308
|
+
try {
|
|
309
|
+
return encodeURIComponent(decodeURIComponent(segment))
|
|
310
|
+
} catch {
|
|
311
|
+
return encodeURIComponent(segment)
|
|
312
|
+
}
|
|
313
|
+
}).join("/")}`
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function annotateBoot(origin, page = "/") {
|
|
317
|
+
const servedPage = encodePagePath(typeof page === "string" && page.startsWith("/") ? page : "/")
|
|
318
|
+
return `<script defer src="${origin}${OVERLAY_PREFIX}/annotate.js" data-ce-page="${htmlAttr(servedPage)}"></script>`
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function wrapFragment(options, content) {
|
|
322
|
+
return `<!doctype html>
|
|
323
|
+
<html>
|
|
324
|
+
<head>
|
|
325
|
+
<meta charset="utf-8">
|
|
326
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
327
|
+
<title>CE local web</title>
|
|
328
|
+
<style>
|
|
329
|
+
body { margin: 0; font-family: system-ui, -apple-system, BlinkMacSystemFont, sans-serif; background: #f7f7f8; color: #1f2328; }
|
|
330
|
+
header { padding: 10px 18px; border-bottom: 1px solid #d8dee4; background: #fff; color: #57606a; font-size: 13px; }
|
|
331
|
+
main { padding: 24px; }
|
|
332
|
+
</style>
|
|
333
|
+
</head>
|
|
334
|
+
<body>
|
|
335
|
+
<header>CE local web - newest screen, reloads on change</header>
|
|
336
|
+
<main>${content}</main>
|
|
337
|
+
${refreshScript(options)}
|
|
338
|
+
</body>
|
|
339
|
+
</html>`
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function wrapAnnotateFragment(content, boot) {
|
|
343
|
+
return `<!doctype html>
|
|
344
|
+
<html>
|
|
345
|
+
<head>
|
|
346
|
+
<meta charset="utf-8">
|
|
347
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
348
|
+
<title>CE local web</title>
|
|
349
|
+
${boot}
|
|
350
|
+
<style>
|
|
351
|
+
body { margin: 0; font-family: system-ui, -apple-system, BlinkMacSystemFont, sans-serif; background: #f7f7f8; color: #1f2328; }
|
|
352
|
+
header { padding: 10px 18px; border-bottom: 1px solid #d8dee4; background: #fff; color: #57606a; font-size: 13px; }
|
|
353
|
+
main { padding: 24px; }
|
|
354
|
+
</style>
|
|
355
|
+
</head>
|
|
356
|
+
<body>
|
|
357
|
+
<header>CE local web - newest screen</header>
|
|
358
|
+
<main>${content}</main>
|
|
359
|
+
</body>
|
|
360
|
+
</html>`
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function injectRefresh(options, html) {
|
|
364
|
+
if (html.includes("</body>")) {
|
|
365
|
+
return html.replace("</body>", `${refreshScript(options)}\n</body>`)
|
|
366
|
+
}
|
|
367
|
+
return `${html}\n${refreshScript(options)}`
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// The document the annotate client sees. A full document is served unchanged
|
|
371
|
+
// behind our doctype and the boot script: the parser opens html/head for the
|
|
372
|
+
// script, then merges the authored <html> attributes, processes the authored
|
|
373
|
+
// head children in head, ignores the second doctype and <head> start tag, and
|
|
374
|
+
// creates <body> with its attributes. Nothing in the authored text is located
|
|
375
|
+
// or rewritten, so a "</body>" in a script string or comment cannot mislead it.
|
|
376
|
+
function annotateScreen(html, origin, page = "/") {
|
|
377
|
+
const boot = annotateBoot(origin, page)
|
|
378
|
+
const text = html.replace(/^\uFEFF/, "")
|
|
379
|
+
if (!isFullDocument(text)) return wrapAnnotateFragment(text, boot)
|
|
380
|
+
return `<!doctype html>\n${boot}\n${text}`
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function annotateDocument(options, origin) {
|
|
384
|
+
const screen = newestScreen(options)
|
|
385
|
+
if (!screen) return wrapAnnotateFragment(WAITING_HTML, annotateBoot(origin))
|
|
386
|
+
return annotateScreen(fs.readFileSync(screen, "utf8"), origin, pageForScreen(options, screen))
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function renderPage(options, origin) {
|
|
390
|
+
if (options.annotate) return annotateDocument(options, origin)
|
|
391
|
+
const screen = newestScreen(options)
|
|
392
|
+
if (!screen) return wrapFragment(options, WAITING_HTML)
|
|
393
|
+
const html = fs.readFileSync(screen, "utf8")
|
|
394
|
+
return isFullDocument(html) ? injectRefresh(options, html) : wrapFragment(options, html)
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function cookieValue(req, name) {
|
|
398
|
+
const header = req.headers.cookie
|
|
399
|
+
if (typeof header !== "string") return null
|
|
400
|
+
for (const part of header.split(";")) {
|
|
401
|
+
const eq = part.indexOf("=")
|
|
402
|
+
if (eq === -1) continue
|
|
403
|
+
if (part.slice(0, eq).trim() === name) return part.slice(eq + 1).trim()
|
|
404
|
+
}
|
|
405
|
+
return null
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// Every credential the request presents. A prototype may use ?token= for its
|
|
409
|
+
// own purposes, so no single source may shadow another: the gate accepts the
|
|
410
|
+
// request when any of these matches.
|
|
411
|
+
function requestCredentials(req, cookieName) {
|
|
412
|
+
const url = new URL(req.url, "http://127.0.0.1")
|
|
413
|
+
const auth = req.headers.authorization
|
|
414
|
+
return [
|
|
415
|
+
url.searchParams.get("token"),
|
|
416
|
+
req.headers["x-session-token"],
|
|
417
|
+
typeof auth === "string" && auth.startsWith("Bearer ") ? auth.slice(7) : null,
|
|
418
|
+
cookieName ? cookieValue(req, cookieName) : null,
|
|
419
|
+
].filter((value) => typeof value === "string" && value)
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function tokenMatches(candidate, expected) {
|
|
423
|
+
return typeof candidate === "string" && candidate === expected
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function sendJson(res, status, value) {
|
|
427
|
+
res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" })
|
|
428
|
+
res.end(`${JSON.stringify(value)}\n`)
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function readBody(req, limit = BODY_LIMIT) {
|
|
432
|
+
return new Promise((resolve, reject) => {
|
|
433
|
+
const chunks = []
|
|
434
|
+
let size = 0
|
|
435
|
+
req.on("data", (chunk) => {
|
|
436
|
+
size += chunk.length
|
|
437
|
+
if (size > limit) {
|
|
438
|
+
req.destroy()
|
|
439
|
+
reject(new Error("payload too large"))
|
|
440
|
+
return
|
|
441
|
+
}
|
|
442
|
+
chunks.push(chunk)
|
|
443
|
+
})
|
|
444
|
+
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")))
|
|
445
|
+
req.on("error", reject)
|
|
446
|
+
})
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// Screens-relative URL path for a file we just served, so the overlay names
|
|
450
|
+
// that file rather than "/". "/" would re-resolve to newestScreen at POST
|
|
451
|
+
// time, and a newer sibling would steal the pin.
|
|
452
|
+
function pageForScreen(options, filePath) {
|
|
453
|
+
let root
|
|
454
|
+
try {
|
|
455
|
+
root = fs.realpathSync(options.screensDir)
|
|
456
|
+
} catch {
|
|
457
|
+
return "/"
|
|
458
|
+
}
|
|
459
|
+
const relative = path.relative(root, filePath).split(path.sep).join("/")
|
|
460
|
+
if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) return "/"
|
|
461
|
+
return `/${relative}`
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// The screens/-relative HTML file the annotated page resolves to, or null.
|
|
465
|
+
// "/" (also a missing page, from an older overlay) is the newest screen at
|
|
466
|
+
// that moment; any other path must name an HTML file under screens/, through
|
|
467
|
+
// the same containment as the route that served it. This is the file the
|
|
468
|
+
// agent edits, so it is resolved here rather than trusted from the client.
|
|
469
|
+
function screenForPage(options, page = "/") {
|
|
470
|
+
if (typeof page !== "string" || !page.startsWith("/")) return null
|
|
471
|
+
let filePath
|
|
472
|
+
if (page === "/") {
|
|
473
|
+
filePath = newestScreen(options)
|
|
474
|
+
} else {
|
|
475
|
+
let name
|
|
476
|
+
try {
|
|
477
|
+
name = decodeURIComponent(page)
|
|
478
|
+
} catch {
|
|
479
|
+
return null
|
|
480
|
+
}
|
|
481
|
+
filePath = containedRealPath(options.screensDir, path.resolve(options.screensDir, name.replace(/^\/+/, "")))
|
|
482
|
+
if (!filePath || contentType(filePath) !== CONTENT_TYPES[".html"]) return null
|
|
483
|
+
try {
|
|
484
|
+
if (!fs.statSync(filePath).isFile()) return null
|
|
485
|
+
} catch {
|
|
486
|
+
return null
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
if (!filePath) return null
|
|
490
|
+
let root
|
|
491
|
+
try {
|
|
492
|
+
root = fs.realpathSync(options.screensDir)
|
|
493
|
+
} catch {
|
|
494
|
+
return null
|
|
495
|
+
}
|
|
496
|
+
return path.relative(root, filePath).split(path.sep).join("/")
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function parseAnnotation(raw, options) {
|
|
500
|
+
if (!raw || !raw.trim()) return null
|
|
501
|
+
let body
|
|
502
|
+
try {
|
|
503
|
+
body = JSON.parse(raw)
|
|
504
|
+
} catch {
|
|
505
|
+
return null
|
|
506
|
+
}
|
|
507
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) return null
|
|
508
|
+
const comment = typeof body.comment === "string" ? body.comment.trim() : ""
|
|
509
|
+
const selector = typeof body.selector === "string" ? body.selector.trim() : ""
|
|
510
|
+
if (!comment || !selector) return null
|
|
511
|
+
const screen = screenForPage(options, body.page)
|
|
512
|
+
if (!screen) return null
|
|
513
|
+
const textSnippet = typeof body.textSnippet === "string" ? body.textSnippet : null
|
|
514
|
+
const variant =
|
|
515
|
+
Array.isArray(body.variant) && body.variant.length > 0 && body.variant.every((name) => typeof name === "string")
|
|
516
|
+
? body.variant
|
|
517
|
+
: null
|
|
518
|
+
const plainObject = (value) => (value && typeof value === "object" && !Array.isArray(value) ? value : null)
|
|
519
|
+
const rect = plainObject(body.rect)
|
|
520
|
+
const point = plainObject(body.point)
|
|
521
|
+
return {
|
|
522
|
+
id: randomUUID(),
|
|
523
|
+
screen,
|
|
524
|
+
comment,
|
|
525
|
+
selector,
|
|
526
|
+
textSnippet,
|
|
527
|
+
variant,
|
|
528
|
+
rect,
|
|
529
|
+
point,
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// Whether a request for an HTML file is the browser navigating to it, as
|
|
534
|
+
// opposed to a script fetching it. Fetch metadata decides when the browser
|
|
535
|
+
// sends it (frames stay raw: only a top-level document is a screen); without
|
|
536
|
+
// it, a request that accepts HTML and states no fetch mode is a navigation.
|
|
537
|
+
function isDocumentNavigation(req) {
|
|
538
|
+
const dest = req.headers["sec-fetch-dest"]
|
|
539
|
+
if (dest) return dest === "document"
|
|
540
|
+
return !req.headers["sec-fetch-mode"] && /\btext\/html\b/.test(req.headers.accept || "")
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// The regular file under rootDir that the request names, or null after the
|
|
544
|
+
// error response has been written.
|
|
545
|
+
function resolveContainedFile(rootDir, req, res) {
|
|
546
|
+
let name
|
|
547
|
+
try {
|
|
548
|
+
// A malformed percent-escape throws URIError; without this the throw is
|
|
549
|
+
// uncaught in the request handler and takes the whole server down.
|
|
550
|
+
name = decodeURIComponent(req.url.split("?")[0].split("#")[0])
|
|
551
|
+
} catch {
|
|
552
|
+
res.writeHead(400)
|
|
553
|
+
res.end("Bad request")
|
|
554
|
+
return null
|
|
555
|
+
}
|
|
556
|
+
name = name.replace(/^\/+/, "")
|
|
557
|
+
// Serve nested paths so a screen can keep the asset layout it was copied
|
|
558
|
+
// from, but never resolve outside the run's screens directory.
|
|
559
|
+
const filePath = containedRealPath(rootDir, path.resolve(rootDir, name))
|
|
560
|
+
if (!filePath) {
|
|
561
|
+
res.writeHead(404)
|
|
562
|
+
res.end("Not found")
|
|
563
|
+
return null
|
|
564
|
+
}
|
|
565
|
+
let stat
|
|
566
|
+
try {
|
|
567
|
+
stat = fs.statSync(filePath)
|
|
568
|
+
} catch {
|
|
569
|
+
res.writeHead(404)
|
|
570
|
+
res.end("Not found")
|
|
571
|
+
return null
|
|
572
|
+
}
|
|
573
|
+
// `/files/%2e` resolves to the screens directory itself, which passes an
|
|
574
|
+
// existence check and then throws EISDIR on read — uncaught, killing the server.
|
|
575
|
+
if (!stat.isFile()) {
|
|
576
|
+
res.writeHead(404)
|
|
577
|
+
res.end("Not found")
|
|
578
|
+
return null
|
|
579
|
+
}
|
|
580
|
+
return filePath
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
function sendFile(filePath, res, headers = {}) {
|
|
584
|
+
res.writeHead(200, { "Content-Type": contentType(filePath), ...headers })
|
|
585
|
+
res.end(fs.readFileSync(filePath))
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
function safeFileResponse(rootDir, req, res, headers = {}) {
|
|
589
|
+
const filePath = resolveContainedFile(rootDir, req, res)
|
|
590
|
+
if (filePath) sendFile(filePath, res, headers)
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
// A prototype recreated from a real product brings whatever that product uses,
|
|
594
|
+
// so this covers the ordinary web asset set rather than an allowlist that has
|
|
595
|
+
// to grow every time a screen references a new kind of file.
|
|
596
|
+
const CONTENT_TYPES = {
|
|
597
|
+
".html": "text/html; charset=utf-8",
|
|
598
|
+
".css": "text/css; charset=utf-8",
|
|
599
|
+
".js": "text/javascript; charset=utf-8",
|
|
600
|
+
".mjs": "text/javascript; charset=utf-8",
|
|
601
|
+
".json": "application/json; charset=utf-8",
|
|
602
|
+
".txt": "text/plain; charset=utf-8",
|
|
603
|
+
".svg": "image/svg+xml",
|
|
604
|
+
".png": "image/png",
|
|
605
|
+
".jpg": "image/jpeg",
|
|
606
|
+
".jpeg": "image/jpeg",
|
|
607
|
+
".gif": "image/gif",
|
|
608
|
+
".webp": "image/webp",
|
|
609
|
+
".avif": "image/avif",
|
|
610
|
+
".ico": "image/x-icon",
|
|
611
|
+
".woff": "font/woff",
|
|
612
|
+
".woff2": "font/woff2",
|
|
613
|
+
".ttf": "font/ttf",
|
|
614
|
+
".otf": "font/otf",
|
|
615
|
+
".mp4": "video/mp4",
|
|
616
|
+
".webm": "video/webm",
|
|
617
|
+
".mp3": "audio/mpeg",
|
|
618
|
+
".wasm": "application/wasm",
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
function contentType(filePath) {
|
|
622
|
+
return CONTENT_TYPES[path.extname(filePath).toLowerCase()] ?? "application/octet-stream"
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
async function start(options) {
|
|
626
|
+
ensureDirs(options)
|
|
627
|
+
options.ownerPid = options.ownerPid ?? resolveOwnerPid()
|
|
628
|
+
const running = getRunningInfo(options)
|
|
629
|
+
if (running && Boolean(running.annotate) === options.annotate && !running.session_ended) {
|
|
630
|
+
jsonOut({ ...running, status: "running" })
|
|
631
|
+
return
|
|
632
|
+
}
|
|
633
|
+
// A server in the other mode cannot serve this start: a default server has
|
|
634
|
+
// no token for wait, and an annotate server would gate a default preview.
|
|
635
|
+
if (running) await stopServer(options)
|
|
636
|
+
|
|
637
|
+
fs.rmSync(options.pidFile, { force: true })
|
|
638
|
+
fs.rmSync(options.infoFile, { force: true })
|
|
639
|
+
|
|
640
|
+
if (options.foreground) {
|
|
641
|
+
await serve(options)
|
|
642
|
+
return
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
const logFd = fs.openSync(options.logFile, "a")
|
|
646
|
+
const child = spawn(process.execPath, [
|
|
647
|
+
scriptPath,
|
|
648
|
+
"serve",
|
|
649
|
+
"--root",
|
|
650
|
+
options.root,
|
|
651
|
+
"--host",
|
|
652
|
+
options.host,
|
|
653
|
+
"--port",
|
|
654
|
+
String(options.port),
|
|
655
|
+
...(options.ownerPid ? ["--owner-pid", String(options.ownerPid)] : []),
|
|
656
|
+
...(options.annotate ? ["--annotate"] : []),
|
|
657
|
+
], {
|
|
658
|
+
detached: true,
|
|
659
|
+
stdio: ["ignore", logFd, logFd],
|
|
660
|
+
})
|
|
661
|
+
child.unref()
|
|
662
|
+
fs.closeSync(logFd)
|
|
663
|
+
|
|
664
|
+
const started = await waitForInfo(options, child.pid)
|
|
665
|
+
if (!started) {
|
|
666
|
+
throw new Error(`Server failed to start. See ${options.logFile}`)
|
|
667
|
+
}
|
|
668
|
+
jsonOut({ ...started, status: "started" })
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
async function waitForInfo(options, pid) {
|
|
672
|
+
for (let i = 0; i < 100; i++) {
|
|
673
|
+
if (fs.existsSync(options.infoFile)) {
|
|
674
|
+
try {
|
|
675
|
+
return readJson(options.infoFile)
|
|
676
|
+
} catch {
|
|
677
|
+
// Truncated write; keep polling.
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
if (pid && !processAlive(pid)) return null
|
|
681
|
+
await new Promise((resolve) => setTimeout(resolve, 50))
|
|
682
|
+
}
|
|
683
|
+
return null
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
// The address a local client uses to reach the bound interface: a wildcard
|
|
687
|
+
// bind is reached through its loopback, anything else through itself.
|
|
688
|
+
function localAddressFor(host) {
|
|
689
|
+
if (!host || host === "0.0.0.0") return DEFAULT_HOST
|
|
690
|
+
if (host === "::" || host === "[::]") return "[::1]"
|
|
691
|
+
return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
async function wait(options) {
|
|
695
|
+
process.stdout.on("error", (error) => {
|
|
696
|
+
console.error(error.message)
|
|
697
|
+
process.exitCode = 2
|
|
698
|
+
})
|
|
699
|
+
const info = getRunningInfo(options)
|
|
700
|
+
if (!info?.port) {
|
|
701
|
+
// Idle/owner shutdown records session_ended and exits; wait must still
|
|
702
|
+
// report that terminal status rather than "not running".
|
|
703
|
+
const ended = sessionEnd(options)
|
|
704
|
+
if (ended) return exitSessionEnded(ended)
|
|
705
|
+
console.error("Server is not running")
|
|
706
|
+
process.exit(2)
|
|
707
|
+
}
|
|
708
|
+
if (!info.token) {
|
|
709
|
+
console.error("Annotation is not enabled for this server")
|
|
710
|
+
process.exit(2)
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
const url = `http://${localAddressFor(info.host)}:${info.port}/wait?token=${encodeURIComponent(info.token)}`
|
|
714
|
+
while (true) {
|
|
715
|
+
let response
|
|
716
|
+
try {
|
|
717
|
+
response = await fetch(url)
|
|
718
|
+
} catch {
|
|
719
|
+
const ended = sessionEnd(options)
|
|
720
|
+
if (ended) return exitSessionEnded(ended)
|
|
721
|
+
process.exit(2)
|
|
722
|
+
}
|
|
723
|
+
if (response.status === 200 || response.status === 410) {
|
|
724
|
+
const text = await response.text()
|
|
725
|
+
// Let pending stdout writes drain instead of truncating a piped batch.
|
|
726
|
+
process.exitCode = response.status === 200 ? 0 : 1
|
|
727
|
+
process.stdout.write(text.endsWith("\n") ? text : `${text}\n`)
|
|
728
|
+
return
|
|
729
|
+
}
|
|
730
|
+
if (response.status === 204) continue
|
|
731
|
+
process.exit(2)
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
async function serve(options) {
|
|
736
|
+
ensureDirs(options)
|
|
737
|
+
|
|
738
|
+
const sessionToken = options.annotate ? randomUUID() : null
|
|
739
|
+
const overlaySession = options.annotate ? randomUUID() : ""
|
|
740
|
+
let cookieName = null
|
|
741
|
+
const heldQueue = []
|
|
742
|
+
const annotationQueue = []
|
|
743
|
+
// id -> held | queued | working | done, in POST order. The overlay's pin
|
|
744
|
+
// status follows this, never the screen changes an annotation happens to cause.
|
|
745
|
+
const annotationStates = new Map()
|
|
746
|
+
const waiters = []
|
|
747
|
+
const sseClients = new Set()
|
|
748
|
+
let sessionEnded = false
|
|
749
|
+
let sessionEndReason = null
|
|
750
|
+
let publishedInfo = null
|
|
751
|
+
let sawSseClient = false
|
|
752
|
+
let sseGraceTimer = null
|
|
753
|
+
const pendingDocuments = new Map()
|
|
754
|
+
let lastBroadcastKey = options.annotate ? screensChangeKey(options) : null
|
|
755
|
+
let lastActivity = Date.now()
|
|
756
|
+
const touch = () => {
|
|
757
|
+
lastActivity = Date.now()
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
function endedBody() {
|
|
761
|
+
return { status: "session-ended", ...(sessionEndReason ? { reason: sessionEndReason } : {}) }
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
// reason: user-ended | tab-closed | idle | owner-exited | stopped
|
|
765
|
+
function endSession(reason) {
|
|
766
|
+
if (sessionEnded) return
|
|
767
|
+
sessionEnded = true
|
|
768
|
+
sessionEndReason = reason
|
|
769
|
+
if (publishedInfo && options.infoFile) {
|
|
770
|
+
publishedInfo = { ...publishedInfo, session_ended: true, session_end_reason: reason }
|
|
771
|
+
try {
|
|
772
|
+
fs.writeFileSync(options.infoFile, `${JSON.stringify(publishedInfo, null, 2)}\n`)
|
|
773
|
+
} catch {
|
|
774
|
+
// Reuse without this flag would report a live session that cannot wait.
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
for (const id of [...pendingDocuments.keys()]) forgetPendingDocument(id)
|
|
778
|
+
if (sseGraceTimer) {
|
|
779
|
+
clearTimeout(sseGraceTimer)
|
|
780
|
+
sseGraceTimer = null
|
|
781
|
+
}
|
|
782
|
+
for (const [id, state] of annotationStates) {
|
|
783
|
+
if (state === "working") annotationStates.set(id, "done")
|
|
784
|
+
}
|
|
785
|
+
flushHeld()
|
|
786
|
+
broadcastAnnotations()
|
|
787
|
+
fulfillWaiters()
|
|
788
|
+
const body = `${JSON.stringify(endedBody())}\n`
|
|
789
|
+
const draining = []
|
|
790
|
+
while (waiters.length > 0) {
|
|
791
|
+
const parked = waiters.shift()
|
|
792
|
+
clearTimeout(parked.timer)
|
|
793
|
+
if (!parked.res.writableEnded) {
|
|
794
|
+
parked.res.writeHead(410, { "Content-Type": "application/json; charset=utf-8" })
|
|
795
|
+
draining.push(new Promise((resolve) => parked.res.end(body, resolve)))
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
for (const client of sseClients) {
|
|
799
|
+
if (!client.writableEnded) {
|
|
800
|
+
client.write("event: session-ended\ndata: {}\n\n")
|
|
801
|
+
draining.push(new Promise((resolve) => client.end(resolve)))
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
sseClients.clear()
|
|
805
|
+
return Promise.all(draining)
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
function annotationsPayload() {
|
|
809
|
+
return JSON.stringify(Object.fromEntries(annotationStates))
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
function broadcastAnnotations() {
|
|
813
|
+
const frame = `event: annotations\ndata: ${annotationsPayload()}\n\n`
|
|
814
|
+
for (const client of sseClients) {
|
|
815
|
+
if (!client.writableEnded) client.write(frame)
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
// The agent asking for the next annotation is the completion signal for the
|
|
820
|
+
// one it was serving; the wait CLI only re-enters after a 204 while idle.
|
|
821
|
+
function completeWorking() {
|
|
822
|
+
let changed = false
|
|
823
|
+
for (const [id, state] of annotationStates) {
|
|
824
|
+
if (state === "working") {
|
|
825
|
+
annotationStates.set(id, "done")
|
|
826
|
+
changed = true
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
if (changed) broadcastAnnotations()
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
function flushHeld() {
|
|
833
|
+
if (heldQueue.length === 0) return
|
|
834
|
+
for (const item of heldQueue) {
|
|
835
|
+
annotationQueue.push(item)
|
|
836
|
+
if (annotationStates.get(item.id) === "held") annotationStates.set(item.id, "queued")
|
|
837
|
+
}
|
|
838
|
+
heldQueue.length = 0
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
function serveBatch(res, items) {
|
|
842
|
+
for (const item of items) annotationStates.set(item.id, "working")
|
|
843
|
+
res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" })
|
|
844
|
+
res.end(`${JSON.stringify(items)}\n`)
|
|
845
|
+
broadcastAnnotations()
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
function fulfillWaiters() {
|
|
849
|
+
while (waiters.length > 0 && annotationQueue.length > 0) {
|
|
850
|
+
const parked = waiters.shift()
|
|
851
|
+
clearTimeout(parked.timer)
|
|
852
|
+
if (parked.res.writableEnded) continue
|
|
853
|
+
serveBatch(parked.res, annotationQueue.splice(0, annotationQueue.length))
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
// The client reloads on this event; the browser then owns every
|
|
858
|
+
// reconciliation (head, html/body attributes, linked assets, scripts).
|
|
859
|
+
function broadcastScreenChange(key) {
|
|
860
|
+
lastBroadcastKey = key
|
|
861
|
+
const payload = JSON.stringify({ version: key })
|
|
862
|
+
for (const client of sseClients) {
|
|
863
|
+
if (!client.writableEnded) {
|
|
864
|
+
client.write(`event: screen-changed\ndata: ${payload}\n\n`)
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
function broadcastIfChanged() {
|
|
870
|
+
const key = screensChangeKey(options)
|
|
871
|
+
if (key !== lastBroadcastKey) broadcastScreenChange(key)
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
function authorized(req) {
|
|
875
|
+
return requestCredentials(req, cookieName).some((candidate) => tokenMatches(candidate, sessionToken))
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
function requireAnnotateToken(req, res) {
|
|
879
|
+
if (authorized(req)) return true
|
|
880
|
+
sendJson(res, 401, { error: "unauthorized" })
|
|
881
|
+
return false
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
function requireLiveAnnotate(req, res) {
|
|
885
|
+
if (!requireAnnotateToken(req, res)) return false
|
|
886
|
+
if (sessionEnded) {
|
|
887
|
+
sendJson(res, 410, endedBody())
|
|
888
|
+
return false
|
|
889
|
+
}
|
|
890
|
+
return true
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
function requestOrigin(req) {
|
|
894
|
+
const host = req.headers.host
|
|
895
|
+
if (typeof host === "string" && HOST_HEADER.test(host)) return `http://${host}`
|
|
896
|
+
return `http://${DEFAULT_URL_HOST}:${server.address().port}`
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
function stampOverlayDocument(html, documentId) {
|
|
900
|
+
const marker = `${OVERLAY_PREFIX}/annotate.js"`
|
|
901
|
+
const at = html.indexOf(marker)
|
|
902
|
+
if (at === -1) return html
|
|
903
|
+
const after = at + marker.length
|
|
904
|
+
return `${html.slice(0, after)} data-ce-session="${htmlAttr(overlaySession)}" data-ce-document="${htmlAttr(documentId)}"${html.slice(after)}`
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
function forgetPendingDocument(id) {
|
|
908
|
+
return pendingDocuments.delete(id)
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
function abandonPendingDocument(id) {
|
|
912
|
+
if (forgetPendingDocument(id) && sseClients.size === 0 && sawSseClient && !sessionEnded) {
|
|
913
|
+
armSseGrace()
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
function unbindPendingFromSocket(socket, exceptId) {
|
|
918
|
+
if (!socket) return
|
|
919
|
+
for (const [id, entry] of pendingDocuments) {
|
|
920
|
+
if (entry.socket !== socket || id === exceptId) continue
|
|
921
|
+
entry.socket = null
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
function retainPendingDocument(id, socket) {
|
|
926
|
+
unbindPendingFromSocket(socket, id)
|
|
927
|
+
pendingDocuments.set(id, { socket: socket || null })
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
// Every document that carries the overlay is served the same way.
|
|
931
|
+
// `renderedKey` is captured with `html`; scanning screens/ here races a rewrite.
|
|
932
|
+
function serveAnnotateDocument(req, res, html, renderedKey) {
|
|
933
|
+
// A page being served is a tab loading, not the last tab closing. The
|
|
934
|
+
// overlay is deferred and may sit behind parser-blocking work, so cancel
|
|
935
|
+
// the reconnect grace until that document's /events connects; ending on
|
|
936
|
+
// the short elapsed timeout would kill a still-loading tab. The old
|
|
937
|
+
// stream's close can arrive after this response; it must not start grace
|
|
938
|
+
// while any replacement is still pending. /events names the document it
|
|
939
|
+
// completes, so another tab's reconnect cannot consume this pending load.
|
|
940
|
+
// The handshake dies when this document can no longer open /events: the
|
|
941
|
+
// request aborted before the body was delivered, /events completed it, or
|
|
942
|
+
// the session ended. A completed response is not that; the overlay may
|
|
943
|
+
// connect on a new connection. Keep-alive reuse is not replacement:
|
|
944
|
+
// another document on the same socket leaves this pending in place.
|
|
945
|
+
// A script fetching the page is not a tab loading, so it does not create
|
|
946
|
+
// a handshake.
|
|
947
|
+
const pending = randomUUID()
|
|
948
|
+
if (isDocumentNavigation(req)) {
|
|
949
|
+
retainPendingDocument(pending, req.socket)
|
|
950
|
+
if (sseGraceTimer) {
|
|
951
|
+
clearTimeout(sseGraceTimer)
|
|
952
|
+
sseGraceTimer = null
|
|
953
|
+
}
|
|
954
|
+
req.on("close", () => {
|
|
955
|
+
if (res.writableEnded) return
|
|
956
|
+
abandonPendingDocument(pending)
|
|
957
|
+
})
|
|
958
|
+
}
|
|
959
|
+
// Sync the change key to what this page will render, so a stream that
|
|
960
|
+
// connects right after load does not reload the same screen. Any
|
|
961
|
+
// already-open stream still receives the change.
|
|
962
|
+
if (renderedKey !== lastBroadcastKey) broadcastScreenChange(renderedKey)
|
|
963
|
+
const headers = {
|
|
964
|
+
"Content-Type": CONTENT_TYPES[".html"],
|
|
965
|
+
...NO_STORE,
|
|
966
|
+
"Referrer-Policy": "no-referrer",
|
|
967
|
+
}
|
|
968
|
+
if (cookieName && sessionToken) {
|
|
969
|
+
headers["Set-Cookie"] = `${cookieName}=${sessionToken}; HttpOnly; SameSite=Strict; Path=/`
|
|
970
|
+
}
|
|
971
|
+
res.writeHead(200, headers)
|
|
972
|
+
res.end(stampOverlayDocument(html, pending))
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
function armSseGrace() {
|
|
976
|
+
if (pendingDocuments.size > 0 || sessionEnded) return
|
|
977
|
+
if (sseGraceTimer) clearTimeout(sseGraceTimer)
|
|
978
|
+
sseGraceTimer = setTimeout(() => {
|
|
979
|
+
if (sseClients.size === 0 && pendingDocuments.size === 0) endSession("tab-closed")
|
|
980
|
+
}, SSE_GRACE_MS)
|
|
981
|
+
sseGraceTimer.unref()
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
async function handleRequest(req, res) {
|
|
985
|
+
const urlPath = req.url.split("?")[0].split("#")[0]
|
|
986
|
+
|
|
987
|
+
if (req.method === "GET" && urlPath === "/version") {
|
|
988
|
+
unbindPendingFromSocket(req.socket)
|
|
989
|
+
res.writeHead(200, {
|
|
990
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
991
|
+
"Cache-Control": "no-store",
|
|
992
|
+
})
|
|
993
|
+
res.end(`${JSON.stringify(screenVersion(options))}\n`)
|
|
994
|
+
return
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
if (options.annotate) {
|
|
998
|
+
if (req.method === "GET" && urlPath === "/wait") {
|
|
999
|
+
unbindPendingFromSocket(req.socket)
|
|
1000
|
+
if (!requireAnnotateToken(req, res)) return
|
|
1001
|
+
// A waiting agent is attending the session; an open tab alone is not.
|
|
1002
|
+
touch()
|
|
1003
|
+
broadcastIfChanged()
|
|
1004
|
+
completeWorking()
|
|
1005
|
+
if (annotationQueue.length > 0) {
|
|
1006
|
+
serveBatch(res, annotationQueue.splice(0, annotationQueue.length))
|
|
1007
|
+
return
|
|
1008
|
+
}
|
|
1009
|
+
if (sessionEnded) {
|
|
1010
|
+
sendJson(res, 410, endedBody())
|
|
1011
|
+
return
|
|
1012
|
+
}
|
|
1013
|
+
const parked = { res, timer: null }
|
|
1014
|
+
parked.timer = setTimeout(() => {
|
|
1015
|
+
const index = waiters.indexOf(parked)
|
|
1016
|
+
if (index !== -1) waiters.splice(index, 1)
|
|
1017
|
+
if (!res.writableEnded) {
|
|
1018
|
+
res.writeHead(204)
|
|
1019
|
+
res.end()
|
|
1020
|
+
}
|
|
1021
|
+
}, WAIT_TIMEOUT_MS)
|
|
1022
|
+
waiters.push(parked)
|
|
1023
|
+
req.on("close", () => {
|
|
1024
|
+
clearTimeout(parked.timer)
|
|
1025
|
+
const index = waiters.indexOf(parked)
|
|
1026
|
+
if (index !== -1) waiters.splice(index, 1)
|
|
1027
|
+
})
|
|
1028
|
+
return
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
if (req.method === "POST" && urlPath === "/annotation") {
|
|
1032
|
+
unbindPendingFromSocket(req.socket)
|
|
1033
|
+
if (!requireLiveAnnotate(req, res)) return
|
|
1034
|
+
let raw
|
|
1035
|
+
try {
|
|
1036
|
+
raw = await readBody(req)
|
|
1037
|
+
} catch {
|
|
1038
|
+
sendJson(res, 400, { error: "invalid annotation" })
|
|
1039
|
+
return
|
|
1040
|
+
}
|
|
1041
|
+
// The session can end while the body is still arriving.
|
|
1042
|
+
if (sessionEnded) {
|
|
1043
|
+
sendJson(res, 410, endedBody())
|
|
1044
|
+
return
|
|
1045
|
+
}
|
|
1046
|
+
const record = parseAnnotation(raw, options)
|
|
1047
|
+
if (!record) {
|
|
1048
|
+
sendJson(res, 400, { error: "invalid annotation" })
|
|
1049
|
+
return
|
|
1050
|
+
}
|
|
1051
|
+
heldQueue.push(record)
|
|
1052
|
+
annotationStates.set(record.id, "held")
|
|
1053
|
+
touch()
|
|
1054
|
+
broadcastAnnotations()
|
|
1055
|
+
sendJson(res, 200, { ok: true, id: record.id })
|
|
1056
|
+
return
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
if (req.method === "POST" && urlPath === "/session/flush") {
|
|
1060
|
+
unbindPendingFromSocket(req.socket)
|
|
1061
|
+
if (!requireLiveAnnotate(req, res)) return
|
|
1062
|
+
flushHeld()
|
|
1063
|
+
broadcastAnnotations()
|
|
1064
|
+
fulfillWaiters()
|
|
1065
|
+
sendJson(res, 200, { ok: true })
|
|
1066
|
+
return
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
if (req.method === "POST" && urlPath === "/session/end") {
|
|
1070
|
+
unbindPendingFromSocket(req.socket)
|
|
1071
|
+
if (!requireAnnotateToken(req, res)) return
|
|
1072
|
+
endSession("user-ended")
|
|
1073
|
+
sendJson(res, 200, endedBody())
|
|
1074
|
+
return
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
if (req.method === "GET" && urlPath === "/events") {
|
|
1078
|
+
if (!requireLiveAnnotate(req, res)) return
|
|
1079
|
+
res.writeHead(200, {
|
|
1080
|
+
"Content-Type": "text/event-stream",
|
|
1081
|
+
"Cache-Control": "no-cache",
|
|
1082
|
+
Connection: "keep-alive",
|
|
1083
|
+
})
|
|
1084
|
+
res.write(":ok\n\n")
|
|
1085
|
+
// A client that just reloaded reconciles its pins from this frame.
|
|
1086
|
+
res.write(`event: annotations\ndata: ${annotationsPayload()}\n\n`)
|
|
1087
|
+
sawSseClient = true
|
|
1088
|
+
const documentId = new URL(req.url, "http://127.0.0.1").searchParams.get("document")
|
|
1089
|
+
if (documentId) forgetPendingDocument(documentId)
|
|
1090
|
+
unbindPendingFromSocket(req.socket, documentId)
|
|
1091
|
+
if (sseGraceTimer) {
|
|
1092
|
+
clearTimeout(sseGraceTimer)
|
|
1093
|
+
sseGraceTimer = null
|
|
1094
|
+
}
|
|
1095
|
+
sseClients.add(res)
|
|
1096
|
+
req.on("close", () => {
|
|
1097
|
+
sseClients.delete(res)
|
|
1098
|
+
if (sseClients.size === 0 && sawSseClient && !sessionEnded) armSseGrace()
|
|
1099
|
+
})
|
|
1100
|
+
return
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
if (req.method === "GET" && OVERLAY_FILES[urlPath]) {
|
|
1104
|
+
safeFileResponse(assetsDir, { url: `/${OVERLAY_FILES[urlPath]}` }, res, NO_STORE)
|
|
1105
|
+
return
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
if (req.method === "GET" && urlPath === "/") {
|
|
1109
|
+
touch()
|
|
1110
|
+
if (isDocumentNavigation(req)) {
|
|
1111
|
+
const renderedKey = screensChangeKey(options)
|
|
1112
|
+
serveAnnotateDocument(req, res, renderPage(options, requestOrigin(req)), renderedKey)
|
|
1113
|
+
return
|
|
1114
|
+
}
|
|
1115
|
+
const screen = newestScreen(options)
|
|
1116
|
+
if (screen) {
|
|
1117
|
+
sendFile(screen, res, NO_STORE)
|
|
1118
|
+
return
|
|
1119
|
+
}
|
|
1120
|
+
res.writeHead(200, { "Content-Type": CONTENT_TYPES[".html"], ...NO_STORE })
|
|
1121
|
+
res.end(WAITING_HTML)
|
|
1122
|
+
return
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
// A linked page under screens/ is a screen too: navigated to, it carries
|
|
1126
|
+
// the same overlay and stream, or the session would end at the first
|
|
1127
|
+
// navigation. It stays ungated like every other screen file. Fetched by
|
|
1128
|
+
// a script, the same file is a partial and is served raw.
|
|
1129
|
+
if (req.method === "GET") {
|
|
1130
|
+
touch()
|
|
1131
|
+
const filePath = resolveContainedFile(options.screensDir, req, res)
|
|
1132
|
+
if (!filePath) return
|
|
1133
|
+
if (contentType(filePath) === CONTENT_TYPES[".html"] && isDocumentNavigation(req)) {
|
|
1134
|
+
const renderedKey = screensChangeKey(options)
|
|
1135
|
+
serveAnnotateDocument(req, res, annotateScreen(fs.readFileSync(filePath, "utf8"), requestOrigin(req), urlPath), renderedKey)
|
|
1136
|
+
return
|
|
1137
|
+
}
|
|
1138
|
+
// A reload must pick up a revised stylesheet or script whose URL did
|
|
1139
|
+
// not change; a cached copy would show the old screen.
|
|
1140
|
+
sendFile(filePath, res, NO_STORE)
|
|
1141
|
+
return
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
if (req.method === "GET" && urlPath === "/") {
|
|
1146
|
+
touch()
|
|
1147
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" })
|
|
1148
|
+
res.end(renderPage(options))
|
|
1149
|
+
return
|
|
1150
|
+
}
|
|
1151
|
+
if (req.method === "GET") {
|
|
1152
|
+
touch()
|
|
1153
|
+
safeFileResponse(options.screensDir, req, res)
|
|
1154
|
+
return
|
|
1155
|
+
}
|
|
1156
|
+
res.writeHead(404)
|
|
1157
|
+
res.end("Not found")
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
const server = http.createServer((req, res) => {
|
|
1161
|
+
Promise.resolve(handleRequest(req, res)).catch(() => {
|
|
1162
|
+
if (!res.headersSent) {
|
|
1163
|
+
res.writeHead(500)
|
|
1164
|
+
res.end("Internal error")
|
|
1165
|
+
}
|
|
1166
|
+
})
|
|
1167
|
+
})
|
|
1168
|
+
|
|
1169
|
+
server.listen(options.port, options.host, () => {
|
|
1170
|
+
const address = server.address()
|
|
1171
|
+
const port = typeof address === "object" && address ? address.port : options.port
|
|
1172
|
+
cookieName = `ce-light-web-${port}`
|
|
1173
|
+
const baseUrl = `http://${DEFAULT_URL_HOST}:${port}`
|
|
1174
|
+
const info = {
|
|
1175
|
+
status: "running",
|
|
1176
|
+
root: options.root,
|
|
1177
|
+
host: options.host,
|
|
1178
|
+
port,
|
|
1179
|
+
url: baseUrl,
|
|
1180
|
+
screen_dir: options.screensDir,
|
|
1181
|
+
state_dir: options.stateDir,
|
|
1182
|
+
pid: process.pid,
|
|
1183
|
+
owner_pid: options.ownerPid ?? null,
|
|
1184
|
+
...(sessionToken ? { token: sessionToken, annotate: true } : {}),
|
|
1185
|
+
}
|
|
1186
|
+
publishedInfo = info
|
|
1187
|
+
fs.writeFileSync(options.pidFile, `${process.pid}\n`)
|
|
1188
|
+
fs.writeFileSync(options.infoFile, `${JSON.stringify(info, null, 2)}\n`)
|
|
1189
|
+
console.log(JSON.stringify(info))
|
|
1190
|
+
})
|
|
1191
|
+
|
|
1192
|
+
// An open change stream or parked wait is an active connection, and
|
|
1193
|
+
// server.close waits for those forever; end the session so they drain.
|
|
1194
|
+
// CLI `stop` sends SIGTERM; without this handler the process exits before
|
|
1195
|
+
// waiters receive session-ended.
|
|
1196
|
+
function shutdown(reason) {
|
|
1197
|
+
Promise.resolve(endSession(reason)).finally(() => {
|
|
1198
|
+
server.close(() => process.exit(0))
|
|
1199
|
+
server.closeAllConnections()
|
|
1200
|
+
})
|
|
1201
|
+
}
|
|
1202
|
+
process.on("SIGTERM", () => shutdown("stopped"))
|
|
1203
|
+
process.on("SIGINT", () => shutdown("stopped"))
|
|
1204
|
+
|
|
1205
|
+
const idleTimer = setInterval(() => {
|
|
1206
|
+
// A parked wait is ongoing activity for as long as it stays connected,
|
|
1207
|
+
// however the wait timeout compares with the idle budget.
|
|
1208
|
+
if (waiters.length > 0) touch()
|
|
1209
|
+
if (options.ownerPid && !processAlive(options.ownerPid)) {
|
|
1210
|
+
shutdown("owner-exited")
|
|
1211
|
+
} else if (Date.now() - lastActivity > IDLE_TIMEOUT_MS) {
|
|
1212
|
+
shutdown("idle")
|
|
1213
|
+
}
|
|
1214
|
+
}, LIFECYCLE_CHECK_MS)
|
|
1215
|
+
idleTimer.unref()
|
|
1216
|
+
|
|
1217
|
+
if (options.annotate) {
|
|
1218
|
+
const changeTimer = setInterval(() => {
|
|
1219
|
+
if (sseClients.size > 0) broadcastIfChanged()
|
|
1220
|
+
}, 250)
|
|
1221
|
+
changeTimer.unref()
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
async function stopServer(options) {
|
|
1226
|
+
const pid = readPid(options)
|
|
1227
|
+
if (processAlive(pid) && ownsServerProcess(options, pid)) {
|
|
1228
|
+
process.kill(pid)
|
|
1229
|
+
for (let i = 0; i < 20; i++) {
|
|
1230
|
+
if (!processAlive(pid)) break
|
|
1231
|
+
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
1232
|
+
}
|
|
1233
|
+
if (processAlive(pid)) {
|
|
1234
|
+
try {
|
|
1235
|
+
process.kill(pid, "SIGKILL")
|
|
1236
|
+
} catch {
|
|
1237
|
+
// Process may have exited between the liveness check and kill.
|
|
1238
|
+
}
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
1241
|
+
fs.rmSync(options.pidFile, { force: true })
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
async function stop(options) {
|
|
1245
|
+
await stopServer(options)
|
|
1246
|
+
jsonOut({ status: "stopped", root: options.root })
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
function status(options) {
|
|
1250
|
+
const info = getRunningInfo(options)
|
|
1251
|
+
if (!info) {
|
|
1252
|
+
jsonOut({ status: "stopped", root: options.root })
|
|
1253
|
+
return
|
|
1254
|
+
}
|
|
1255
|
+
jsonOut({ ...info, status: "running" })
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
async function main() {
|
|
1259
|
+
let command
|
|
1260
|
+
try {
|
|
1261
|
+
const options = parseArgs(process.argv)
|
|
1262
|
+
command = options.command
|
|
1263
|
+
if (options.command === "start") await start(options)
|
|
1264
|
+
else if (options.command === "serve") await serve(options)
|
|
1265
|
+
else if (options.command === "stop") await stop(options)
|
|
1266
|
+
else if (options.command === "status") status(options)
|
|
1267
|
+
else if (options.command === "wait") await wait(options)
|
|
1268
|
+
} catch (error) {
|
|
1269
|
+
console.error(error.message)
|
|
1270
|
+
// Wait reserves exit 1 for session-ended; any other failure is exit 2.
|
|
1271
|
+
process.exit((command ?? process.argv[2]) === "wait" ? 2 : 1)
|
|
1272
|
+
}
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
await main()
|