From 19837db74c766b97df1ce770b4ae5efb28183314 Mon Sep 17 00:00:00 2001 From: aaron Date: Sun, 9 Aug 2026 13:34:53 -0400 Subject: [PATCH 1/2] feat(ci): automate upstream sync with notturno sentinel (weekly + dispatch) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds .github/workflows/upstream-sync.yml — scheduled Mondays 09:00 UTC + workflow_dispatch, fetches anomalyco/opencode dev, opens notturno/merge-upstream-YYYY-MM-DD against local/amicode. - Zero-conflict merges: commit + push + open PR (hitl) ready for CI - Conflicted merges: abort markers, commit .upstream-sync/report.md with file list + policy, push + open PR for hand-merge Keeps the fork (never drops amicode surfaces) — just pulls mothership improvements automatically. Handles sst → anomalyco rename, sets git identity (fixes harmoniqs/amico#322). Related to #159 --- .github/workflows/upstream-sync.yml | 275 ++++++++++++++++++++++++++++ 1 file changed, 275 insertions(+) create mode 100644 .github/workflows/upstream-sync.yml diff --git a/.github/workflows/upstream-sync.yml b/.github/workflows/upstream-sync.yml new file mode 100644 index 000000000..c05d496f2 --- /dev/null +++ b/.github/workflows/upstream-sync.yml @@ -0,0 +1,275 @@ +# Upstream sync: keep harmoniqs/opencode current with anomalyco/opencode. +# Weekly + manual dispatch. Opens notturno/merge-upstream-YYYY-MM-DD against local/amicode. +# Keeps the fork — never drops amicode surfaces. One-off hand-merges still happen on the PR. +name: upstream-sync +on: + schedule: + # Mondays 09:00 UTC — offset from publish.yml (dev push) so upstream has landed. + - cron: "0 9 * * 1" + workflow_dispatch: + inputs: + upstream_ref: + description: "Upstream ref to merge (default: dev)" + required: false + default: "dev" + type: string + dry_run: + description: "Dry run — report overlap/conflicts but don't push a branch or open a PR" + required: false + default: false + type: boolean + +permissions: + contents: write + pull-requests: write + +concurrency: + group: upstream-sync + cancel-in-progress: false + +jobs: + sync: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + # checkout the fork's default branch head so push has a base + ref: local/amicode + + - name: Setup git committer + run: | + git config user.name "amico-sync-bot" + git config user.email "amico-sync@harmoniqs.local" + + - name: Add upstream and fetch + id: upstream + run: | + set -euo pipefail + if git remote get-url upstream >/dev/null 2>&1; then + git remote set-url upstream https://github.com/anomalyco/opencode.git + else + git remote add upstream https://github.com/anomalyco/opencode.git + fi + # sst/opencode → anomalyco/opencode (0cf029478). Keep sst as fallback fetch if anomalyco is slow. + git fetch upstream "${{ inputs.upstream_ref || 'dev' }}" --prune + SHA=$(git rev-parse "upstream/${{ inputs.upstream_ref || 'dev' }}") + SHORT=$(git rev-parse --short "$SHA") + DATE=$(date -u +%Y-%m-%d) + # version from upstream package.json if present + VER=$(git show "upstream/${{ inputs.upstream_ref || 'dev' }}:packages/opencode/package.json" 2>/dev/null | python3 -c "import json,sys; print(json.load(sys.stdin).get('version',''))" || echo "") + echo "sha=$SHA" >> "$GITHUB_OUTPUT" + echo "short=$SHORT" >> "$GITHUB_OUTPUT" + echo "date=$DATE" >> "$GITHUB_OUTPUT" + echo "version=$VER" >> "$GITHUB_OUTPUT" + echo "upstream SHA=$SHA ($SHORT) version=$VER date=$DATE" + + - name: Create sync branch + id: branch + run: | + set -euo pipefail + BRANCH="notturno/merge-upstream-${{ steps.upstream.outputs.date }}" + # if branch already exists locally or on origin, suffix with short SHA + if git rev-parse --verify "$BRANCH" >/dev/null 2>&1 || git ls-remote --exit-code origin "$BRANCH" >/dev/null 2>&1; then + BRANCH="${BRANCH}-${{ steps.upstream.outputs.short }}" + fi + git checkout -b "$BRANCH" "origin/local/amicode" + echo "branch=$BRANCH" >> "$GITHUB_OUTPUT" + echo "created $BRANCH from origin/local/amicode" + + - name: Attempt merge (no commit) + id: merge + run: | + set -euo pipefail + set +e + git merge --no-ff --no-commit "upstream/${{ inputs.upstream_ref || 'dev' }}" + EC=$? + set -e + echo "exit_code=$EC" >> "$GITHUB_OUTPUT" + if [ "$EC" -eq 0 ]; then + echo "conflicts=0" >> "$GITHUB_OUTPUT" + echo "conflict_files=" >> "$GITHUB_OUTPUT" + echo "merge clean" + else + # collect conflicted paths + FILES=$(git diff --name-only --diff-filter=U | tr '\n' ' ' | xargs || true) + COUNT=$(git diff --name-only --diff-filter=U | wc -l | xargs) + echo "conflicts=$COUNT" >> "$GITHUB_OUTPUT" + echo "conflict_files=$FILES" >> "$GITHUB_OUTPUT" + echo "conflicts=$COUNT files: $FILES" + # keep working tree conflicted for report step, then abort after report + fi + # overlap stats for report (even on clean merges) + git diff --name-only --diff-filter=U > /tmp/conflicted.txt 2>/dev/null || true + # overall diff stats upstream..HEAD + git diff --stat "upstream/${{ inputs.upstream_ref || 'dev' }}" -- . > /tmp/upstream-stat.txt 2>/dev/null || true + + - name: Dry run — report only + if: ${{ inputs.dry_run == true }} + run: | + cat <<'EOF' + Dry run — no branch pushed, no PR opened. + EOF + echo "upstream=${{ steps.upstream.outputs.sha }} (${{ steps.upstream.outputs.short }}) version=${{ steps.upstream.outputs.version }}" + echo "branch=${{ steps.branch.outputs.branch }}" + echo "exit_code=${{ steps.merge.outputs.exit_code }}" + echo "conflicts=${{ steps.merge.outputs.conflicts }}" + echo "files=${{ steps.merge.outputs.conflict_files }}" + echo "--- upstream diff stat (first 50 lines) ---" + head -n 50 /tmp/upstream-stat.txt || true + if [ "${{ steps.merge.outputs.exit_code }}" -ne 0 ]; then + git merge --abort || true + else + git merge --abort || true + fi + + - name: Commit clean merge + if: ${{ inputs.dry_run != true && steps.merge.outputs.exit_code == 0 }} + run: | + set -euo pipefail + BRANCH="${{ steps.branch.outputs.branch }}" + SHA="${{ steps.upstream.outputs.sha }}" + SHORT="${{ steps.upstream.outputs.short }}" + VER="${{ steps.upstream.outputs.version }}" + DATE="${{ steps.upstream.outputs.date }}" + cat > /tmp/commit-msg.txt < + EOF + git commit -m "$(cat /tmp/commit-msg.txt)" + git log --oneline -2 + git push -u origin "$BRANCH" + + - name: Commit conflict report (hand-merge needed) + if: ${{ inputs.dry_run != true && steps.merge.outputs.exit_code != 0 }} + run: | + set -euo pipefail + BRANCH="${{ steps.branch.outputs.branch }}" + SHA="${{ steps.upstream.outputs.sha }}" + SHORT="${{ steps.upstream.outputs.short }}" + VER="${{ steps.upstream.outputs.version }}" + COUNT="${{ steps.merge.outputs.conflicts }}" + FILES="${{ steps.merge.outputs.conflict_files }}" + # abort the conflicted merge state — we push a report branch, not conflict markers + git merge --abort || true + mkdir -p .upstream-sync + cat > .upstream-sync/report.md <} + \`\`\` + + ## Full conflict list + \`\`\` + $(git diff --name-only --diff-filter=U 2>/dev/null || cat /tmp/conflicted.txt 2>/dev/null || echo "") + \`\`\` + + ## Next steps + 1. \`git fetch upstream && git checkout ${BRANCH} && git merge upstream/${{ inputs.upstream_ref || 'dev' }}\` + 2. Resolve per AMICODE-PATCHES.md policy: + - adopt upstream bugfixes wholesale + - keep fork branding/KaTeX/AmicoSpinner/entity-rail/bug-dock (\`vaults.ts\`, \`draft-store.ts\`, \`marked\` macros) + - re-delete \`debug-bar.tsx\` (fork keeps it deleted) + - \`bun.lock\` → theirs + \`bun install\` + - i18n: re-run \`script/translate-app.ts\` or copy EN fallbacks + 3. Update \`AMICODE-PATCHES.md\` header + new sync section, bump \`package.json\` versions to \`${VER}\`. + 4. Verify: \`env -u OPENCODE_CONFIG_CONTENT -u OPENCODE_SERVER_PASSWORD bun test\`, \`bun run typecheck\`, and \`OPENCODE_CHANNEL=dev\` build gate (\`grep newLayoutDesigns\`). + 5. Push — PR auto-updates. + + _Generated by .github/workflows/upstream-sync.yml_ + EOF + cat .upstream-sync/report.md + git add .upstream-sync/report.md + git commit -m "chore: upstream sync report for ${VER:-dev} @ ${SHORT} — ${COUNT} conflicts need hand-merge + + Upstream ${SHA} into ${BRANCH}. See .upstream-sync/report.md. + Automated by .github/workflows/upstream-sync.yml" + git push -u origin "$BRANCH" + + - name: Open PR (clean merge) + if: ${{ inputs.dry_run != true && steps.merge.outputs.exit_code == 0 }} + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + BRANCH="${{ steps.branch.outputs.branch }}" + SHA="${{ steps.upstream.outputs.sha }}" + SHORT="${{ steps.upstream.outputs.short }}" + VER="${{ steps.upstream.outputs.version }}" + cat > /tmp/pr-body.md < /tmp/pr-body.md < Date: Sun, 16 Aug 2026 20:12:06 -0400 Subject: [PATCH 2/2] fix(app): show the agent picker whenever there is a real choice (#208) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit agentsVisible gated on hasCustomAgent — a server shipping only native plan/build (Amicode's plan-first posture, amicode#389) had its picker vanish, leaving read-only sessions no visible escalation affordance. Rule now: custom agent OR more than one selectable agent. Single-agent setups keep today's hidden behavior. --- packages/app/src/context/local-agent.test.ts | 17 ++++++++++++++++- packages/app/src/context/local-agent.ts | 8 ++++++++ packages/app/src/context/local.tsx | 4 ++-- 3 files changed, 26 insertions(+), 3 deletions(-) diff --git a/packages/app/src/context/local-agent.test.ts b/packages/app/src/context/local-agent.test.ts index c7a95f9a5..75b60b044 100644 --- a/packages/app/src/context/local-agent.test.ts +++ b/packages/app/src/context/local-agent.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import { hasCustomAgent, resolveAgent } from "./local-agent" +import { hasAgentChoice, hasCustomAgent, resolveAgent } from "./local-agent" describe("hasCustomAgent", () => { test("detects explicitly custom agents", () => { @@ -11,6 +11,21 @@ describe("hasCustomAgent", () => { }) }) +describe("hasAgentChoice", () => { + test("native plan/build alone IS a choice — the picker must show (#208)", () => { + expect(hasAgentChoice([{ native: true, name: "plan" }, { native: true, name: "build" }])).toBe(true) + }) + + test("a single agent is not a choice — picker stays hidden (today's behavior)", () => { + expect(hasAgentChoice([{ native: true, name: "build" }])).toBe(false) + expect(hasAgentChoice([])).toBe(false) + }) + + test("a lone custom agent is still a choice (upstream behavior unchanged)", () => { + expect(hasAgentChoice([{ native: false, name: "custom" }])).toBe(true) + }) +}) + describe("resolveAgent", () => { const agents = [{ name: "plan" }, { name: "build" }, { name: "custom" }] diff --git a/packages/app/src/context/local-agent.ts b/packages/app/src/context/local-agent.ts index f5c76d146..a1de48b46 100644 --- a/packages/app/src/context/local-agent.ts +++ b/packages/app/src/context/local-agent.ts @@ -2,6 +2,14 @@ export function hasCustomAgent(items: Array<{ native?: boolean }>) { return items.some((item) => item.native === false) } +/** The picker's visibility rule (#208): show when there is an actual CHOICE — + * a custom agent (upstream behavior) OR more than one selectable agent, so a + * native plan/build pair keeps its escalation affordance when a server ships + * no custom agents (plan-first posture, read-only default). */ +export function hasAgentChoice(items: T[]) { + return hasCustomAgent(items) || items.length > 1 +} + export function resolveAgent(items: T[], name?: string) { return items.find((item) => item.name === name) ?? items.find((item) => item.name === "build") ?? items[0] } diff --git a/packages/app/src/context/local.tsx b/packages/app/src/context/local.tsx index a674854c1..63812e17d 100644 --- a/packages/app/src/context/local.tsx +++ b/packages/app/src/context/local.tsx @@ -7,7 +7,7 @@ import { useModels } from "@/context/models" import { useSettings } from "@/context/settings" import { useProviders } from "@/hooks/use-providers" import { Persist, persisted } from "@/utils/persist" -import { hasCustomAgent, resolveAgent } from "./local-agent" +import { hasAgentChoice, resolveAgent } from "./local-agent" import { cycleModelVariant, getConfiguredAgentVariant, resolveModelVariant } from "./model-variant" import { useSDK } from "./sdk" import { useSync } from "./sync" @@ -68,7 +68,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ const id = createMemo(() => params.id || undefined) const list = createMemo(() => sync().data.agent.filter((item) => item.mode !== "subagent" && !item.hidden)) - const agentsVisible = createMemo(() => settings.visibility.customAgents() || hasCustomAgent(list())) + const agentsVisible = createMemo(() => settings.visibility.customAgents() || hasAgentChoice(list())) const connected = createMemo(() => new Set(providers.connected().map((item) => item.id))) const [saved, setSaved, , savedReady] = persisted(