From cbf074fbfdbb6072cd237e6ce2897d619688abf2 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Mon, 31 Aug 2026 13:54:55 +0900 Subject: [PATCH 1/3] feat(clients): add Aside as an export and integration client Aside is a Chromium fork with a built-in browser agent, and its custom-provider catalog at ~/.aside/u//models.json reads the same contract Pi does. The evidence is a live file rather than a package manifest: a user had already wired opencodex into it by hand, with the same four provider keys, the same openai-completions dialect, the same opencodex-loopback placeholder, and a pi-style thinkingLevelMap. So this reuses buildPiClientConfig and summarizePi the way the prime client does, and only the ownership stamp is Aside's own. Two things are genuinely new. Aside ships no variable that relocates its state, so the resolver's variable is the ACCOUNT, read from Aside's own accounts.json. It throws rather than defaulting to account 0: a machine can hold several accounts, so a guess would name a real config file belonging to a different account, pass the installed check, and write into somebody else's catalog. And because both of Aside's paths derive from that account id, resolving configPath and detectDir independently could verify one account's install and then write another's. IntegrationClientSpec gains an optional resolvePaths, resolveIntegrationPaths becomes the one place that turns an id into the pair, and the writer's freeze plus the state read both go through it. Every other client falls through to the previous behavior. loopbackOnly is true: the observed provider block has four keys and none is headers, so the dedicated admission header has nowhere to live and a non-loopback bind would generate a config that 401s. Two repairs CI found in the first revision of this branch: The GUI registration is not separable from the union widening. FILE_INTEGRATION_CLIENTS and the CLIENTS tuple are compared against the backend registry by tests/integrations-invariants.test.ts, so they have to move together -- but widening those unions makes three exhaustive Record maps and the client-config label map incomplete, which does not compile until the i18n keys exist. Root typecheck passed while gui tsc failed with seven TS2345/TS2741 errors, because the missing keys were sitting in the child branch. The label keys, the three maps and the four client-list assertions now land here. And a refusal to resolve was reported as the wrong state. An absent accounts.json is the ORDINARY condition of an Aside that has been installed and never signed into, but the read-only state surface answered it with state: "unsafe" and configPath: "" -- the red Cannot-verify badge, naming no file. A client that can say where its config WOULD live now supplies an unresolvedPathHint, and the read reports absent/not-installed with that location. Mutation still refuses, because there is no account to write. OpenClaw's relative-selector refusal has no hint and keeps the danger badge, which is correct: that one is a misconfiguration. Verification: bun run typecheck clean; gui tsc -b exits 0 on this branch alone; bun test tests/aside-client.test.ts tests/integrations-invariants.test.ts tests/integrations-state.test.ts tests/management-integration-routes.test.ts tests/integrations-writer.test.ts -> 169 pass, 0 fail. --- .../000_plan.md | 46 ++++ .../001_aside_contract.md | 158 +++++++++++ .../002_registration_checklist.md | 72 +++++ .../003_integrations_ux_diagnosis.md | 96 +++++++ .../004_brand_mark_provenance.md | 73 +++++ .../010_wp2_aside_backend.md | 168 ++++++++++++ .../020_wp3_aside_gui.md | 83 ++++++ .../030_wp4_history_redesign.md | 97 +++++++ .../040_wp5_brand_marks.md | 92 +++++++ .../050_wp6_stacked_prs.md | 41 +++ gui/src/app-routing.ts | 1 + .../client-config-clients.ts | 3 +- gui/src/i18n/de.ts | 3 + gui/src/i18n/en.ts | 3 + gui/src/i18n/fr.ts | 3 + gui/src/i18n/ja.ts | 3 + gui/src/i18n/ko.ts | 3 + gui/src/i18n/ru.ts | 3 + gui/src/i18n/tr.ts | 3 + gui/src/i18n/zh-TW.ts | 3 + gui/src/i18n/zh.ts | 3 + .../integrations/FileIntegrationPage.tsx | 2 + gui/src/pages/integrations/integration-api.ts | 1 + .../pages/integrations/overview-clients.ts | 1 + gui/tests/client-config-panel.test.tsx | 5 +- gui/tests/fr-localization.test.ts | 2 + gui/tests/integrations-api.test.ts | 4 +- gui/tests/integrations-overview-rows.test.ts | 3 +- gui/tests/locale-parity.test.ts | 2 + src/cli/help.ts | 2 +- src/cli/registry.ts | 4 +- src/clients/config-export.ts | 124 ++++++++- src/integrations/registry.ts | 98 +++++++ src/integrations/state.ts | 30 ++- src/integrations/writer.ts | 12 +- tests/aside-client.test.ts | 249 ++++++++++++++++++ .../client-config-export-new-clients.test.ts | 7 +- tests/client-config-export.test.ts | 4 +- tests/integrations-invariants.test.ts | 19 +- tests/integrations-state.test.ts | 4 +- 40 files changed, 1499 insertions(+), 31 deletions(-) create mode 100644 devlog/_plan/260831_aside_client_and_integrations_ux/000_plan.md create mode 100644 devlog/_plan/260831_aside_client_and_integrations_ux/001_aside_contract.md create mode 100644 devlog/_plan/260831_aside_client_and_integrations_ux/002_registration_checklist.md create mode 100644 devlog/_plan/260831_aside_client_and_integrations_ux/003_integrations_ux_diagnosis.md create mode 100644 devlog/_plan/260831_aside_client_and_integrations_ux/004_brand_mark_provenance.md create mode 100644 devlog/_plan/260831_aside_client_and_integrations_ux/010_wp2_aside_backend.md create mode 100644 devlog/_plan/260831_aside_client_and_integrations_ux/020_wp3_aside_gui.md create mode 100644 devlog/_plan/260831_aside_client_and_integrations_ux/030_wp4_history_redesign.md create mode 100644 devlog/_plan/260831_aside_client_and_integrations_ux/040_wp5_brand_marks.md create mode 100644 devlog/_plan/260831_aside_client_and_integrations_ux/050_wp6_stacked_prs.md create mode 100644 tests/aside-client.test.ts diff --git a/devlog/_plan/260831_aside_client_and_integrations_ux/000_plan.md b/devlog/_plan/260831_aside_client_and_integrations_ux/000_plan.md new file mode 100644 index 0000000000..ff81cf010a --- /dev/null +++ b/devlog/_plan/260831_aside_client_and_integrations_ux/000_plan.md @@ -0,0 +1,46 @@ +# Aside client + Integrations UX repair + +Unit opened 2026-08-31. Two outcomes travel together because they land on the +same page: Aside becomes an export/integration client, and the Integrations +("연결") surface stops flooding itself with rollback rows. + +They are one unit rather than two because the Aside work ADDS a twelfth card to +a page that is already too crowded to absorb one. Shipping the client first +would make the page measurably worse before it got better. + +## The two problems + +**Aside is unsupported.** Aside is a Chromium fork with a built-in browser +agent. Its custom-provider catalog lives at `~/.aside/u//models.json` +and its schema is the one Pi reads. The user on this machine already wired +opencodex into it BY HAND: the live file carries a `providers.opencodex` block +with 24 routed models, `api: "openai-completions"`, and +`apiKey: "opencodex-loopback"` — byte-identical to what `buildPiClientConfig` +emits. A hand-maintained integration is the strongest possible argument that +the client belongs in the registry. + +**The Integrations page floods.** The rollback journal renders up to 50 rows, +each with its own border, at the bottom of the overview AND again on every file +client tab. The user's words were "로그 밑에 막 다닥다닥 뜨는 히스토리" — the +per-row borders are literally what produces that texture. + +## Work phases + +| Phase | Doc | Deliverable | +|---|---|---| +| wp1 | this unit | Research and roadmap (docs only) | +| wp2 | 010 | Aside export client + integration registry | +| wp3 | 020 | Aside GUI surface, marks entry, nine locales | +| wp4 | 030 | Rollback surface redesign | +| wp5 | 040 | Brand marks for the nine clients showing a monogram | +| wp6 | 050 | Stacked PR chain | + +Research docs: 001 (Aside contract), 002 (registration checklist), +003 (Integrations UX diagnosis), 004 (brand mark provenance). + +## Ordering constraint + +wp4 and wp5 do not depend on wp2/wp3, and wp3 depends on wp2. The stack is +therefore not a single line: the Aside pair (wp2 then wp3) and the page repair +pair (wp4, wp5) are independent chains that both branch off `dev`. wp6 puts +them in review order. diff --git a/devlog/_plan/260831_aside_client_and_integrations_ux/001_aside_contract.md b/devlog/_plan/260831_aside_client_and_integrations_ux/001_aside_contract.md new file mode 100644 index 0000000000..d0a94cdd5c --- /dev/null +++ b/devlog/_plan/260831_aside_client_and_integrations_ux/001_aside_contract.md @@ -0,0 +1,158 @@ +# What Aside actually reads + +Observed on this machine, 2026-08-31, against Aside CLI `1.26.810.1915` and app +bundle `1.0.825.1`. Every claim below was read off a real file or a live process, +not inferred from documentation. + +## Account roots + +`~/.aside/accounts.json` (mode 600) carries: + +```json +{ "version": ..., "currentAccountId": 0, "accounts": [ { "id": 0, "email": "..." }, { "id": 1 } ], ... } +``` + +Per-account state lives at `~/.aside/u//`. Both `u/0` and `u/1` exist here, +so multi-account is not hypothetical. The provider catalog is +`~/.aside/u//models.json`. + +**No environment override exists.** `strings` over the CLI binary yields +`ASIDE_DAEMON_BASE_URL`, `ASIDE_CLI_*`, `ASIDE_MCP_*`, `ASIDE_PRODUCT_VARIANT`, +`ASIDE_RELEASE_BASE_URL`, `ASIDE_NATIVE_HELPER_BINDING` — and nothing that +relocates the account root. The only literal `.aside` path in the binary is +`path.join(os.homedir(), ".aside", "cli", "update-check.json")`. + +This makes Aside unlike every existing client. `dshHomeDir` honors `DSH_HOME`; +`mcodeHomeDir` honors `MINIMAX_DATA_DIR` then `MAVIS_DATA_DIR`; `piAgentDir` +honors `PI_CODING_AGENT_DIR`. Aside has no such variable to honor, so the +resolver's variable is the ACCOUNT ID, not a path. + +### Decision: read the manifest, fail closed, add no opencodex env var + +A resolver that only reads `~/.aside` cannot be tested without writing to the +user's real Aside install. Every existing path helper takes `(env, home)` and +the tests redirect `home`; Aside does the same, so the root is +`join(home, ".aside")` and tests redirect `home` exactly as +`tests/prime-client.test.ts` does. + +An earlier draft added `OPENCODEX_ASIDE_ACCOUNT` so a user could target a +non-current account. **Dropped after audit.** This registry's contract is to +honor each client's OWN override (`registry.ts:45`) — `DSH_HOME`, +`MINIMAX_DATA_DIR`, `PI_CODING_AGENT_DIR` all belong to their clients. Aside +ships no such variable, so inventing an opencodex-namespaced one is new product +behavior dressed up as path resolution. Account selection, if it is ever wanted, +is its own unit with its own surface. + +We write the account Aside itself reports as current, and nothing else. + +**Fail closed rather than fall back.** An earlier draft defaulted to account `0` +when `accounts.json` was missing or unparseable. On this machine both `u/0` and +`u/1` exist, so that fallback could name a real config file belonging to the +WRONG account and then pass the installation gate — a silent write into another +account's catalog. Instead: + +- Manifest present with a non-negative integer `currentAccountId`: use it. +- Manifest absent, unreadable, unparseable, or the id malformed: throw + `ClientPathError`. The surface reports the client as unavailable with a real + reason, exactly as an unresolvable `DSH_HOME` does today. + +A missing manifest means Aside has not established which account is current, and +there is no honest value to guess. + +**Resolve both paths from one account read.** `freezeIntegrationInput` called +`configPath` and `detectDir` separately (`writer.ts:621`), and `readIntegrationState` +did the same. Both now depend on file contents rather than only `env` and `home`, +so a manifest rewritten between the two calls could verify one account's install +and then write a different account's catalog. + +A cache was the first idea and it does not work: any cache keyed on the manifest +re-reads exactly when the manifest changes, which is the case the consistency is +needed for, and nothing tells a path helper when an operation ends. The audit +caught that contradiction. + +The fix is a seam instead. `resolveIntegrationPaths(clientId, env, home)` in the +integration registry returns the PAIR, and an optional `resolvePaths` on a client +spec lets Aside derive both from a single `asideAccountDir` call. Every other +client keeps the default behavior, so the pair stays correct for them without +any of them knowing why the seam exists. + +## The provider block + +The live `~/.aside/u/0/models.json`, provider keys in their on-disk order with +values elided: + +```json +{ "providers": { "opencodex": { + "baseUrl": "http://127.0.0.1:10100/v1", + "apiKey": "opencodex-loopback", + "api": "openai-completions", + "models": [ { "id": "...", "name": "...", "reasoning": true, + "thinkingLevelMap": { "off": null, ..., "max": "max" }, + "input": ["text","image"], "contextWindow": 1000000, + "maxTokens": 32000 } ] +} } } +``` + +Four provider keys and 24 model entries. `thinkingLevelMap` uses the same seven +pi levels (`off`/`minimal`/`low`/`medium`/`high`/`xhigh`/`max`) with `null` for +levels the model does not declare. `input` is `["text","image"]` throughout, or +`["text"]` alone. + +### The same key SET, not the same byte order + +An earlier draft called this file "byte-identical" to `buildPiClientConfig` +output. That was wrong, and the audit caught it. + +The builder emits `baseUrl`, `api`, `apiKey`, `models` +(`config-export.ts:1038`); the hand-written file has `baseUrl`, `apiKey`, +`api`, `models`. Model entries differ the same way — the builder writes `input` +before the optional reasoning fields, the live file after. `serializeDocument` +preserves insertion order, so the emitted bytes really do differ. + +The true claim is weaker and sufficient: **the same four provider keys, the same +dialect string, the same placeholder, and the same model field vocabulary.** JSON +key order is not semantic and Aside parses this file rather than diffing it. What +the builder produces is a document Aside accepts; it is not the document a human +happened to type. + +So this unit claims compatibility, not equality. The test backing it must NOT be +`buildAside(ctx) === buildPi(ctx)`: both call the same function, so that +assertion is tautological. wp2 asserts against a fixture captured from the +observed Aside shape — same key set, same dialect, placeholder rather than a +credential, model fields drawn from the observed vocabulary. + +## Reuse, and the one thing not to reuse + +`prime` set the precedent: it reuses `buildPiClientConfig` and `summarizePi` +verbatim and adds only `buildPrimeContribution`, so ownership records carry +`clientId: "prime"`. Aside follows that exactly. Restating the shape would +create a second copy of one fact, which is the bug that comment warns about. + +## loopbackOnly: true + +The provider block has four keys and none of them is `headers`. A dedicated +`x-opencodex-api-key` header has nowhere to live, so a non-loopback bind would +generate a config that 401s. Same reasoning, same verdict as `dsh`, `kimi`, +`gajae`, `mcode`, and `zcode`. + +`apiKeyEnv` is therefore `""` and `exportHint` says loopback needs no key. + +## Hazard: Aside overwrites the file while running + +The Aside skill reference states that editing `models.json` while Aside is +running risks the daemon overwriting it, and `Aside Daemon` was live during +this investigation. The pattern for this already exists: Claude Desktop's copy +says "Fully quit and reopen it for this change to take effect" +(`integrations.dialog.desktop.restart`). Aside gets the same treatment in its +`integrations.semantics.aside` string. + +This is a copy problem, not a writer problem. The writer already snapshots +before every mutation and journals what it did, so an overwrite by Aside is +recoverable the same way any drift is. + +## Not in scope + +`~/.aside/u/0/models.json` can hold a plaintext key for a user's OWN providers, +and `credentials.json` certainly does. We write one fragment, +`providers.opencodex`, and the merge layer touches nothing else. No Aside +credential is ever read, printed, or serialized. diff --git a/devlog/_plan/260831_aside_client_and_integrations_ux/002_registration_checklist.md b/devlog/_plan/260831_aside_client_and_integrations_ux/002_registration_checklist.md new file mode 100644 index 0000000000..e594069d08 --- /dev/null +++ b/devlog/_plan/260831_aside_client_and_integrations_ux/002_registration_checklist.md @@ -0,0 +1,72 @@ +# Every surface a new export client must reach + +Derived from `git show c6fa2563d` (the `prime` client, 29 files) plus the +invariant tests. This is the checklist wp2 and wp3 execute. + +## Backend (wp2) + +- `src/clients/config-export.ts`: `asideHomeDir`/`asideAccountDir`/ + `asideConfigPath` helpers, `"aside"` in `ExportClientId`, + `buildAsideContribution`, and the `EXPORT_CLIENTS.aside` spec. The spec needs + all nine fields: `filename`, `destination`, `apiKeyEnv`, `exportHint`, + `build`, `format`, `summarize`, `buildContribution`, `loopbackOnly`. +- `src/integrations/registry.ts`: `INTEGRATION_CLIENTS.aside` with `configPath` + and `detectDir`. No `sourcePreservingYaml` (JSON) and no `writerLock`, same as + `pi` and `prime`. +- `src/cli/registry.ts`: the `export` entry's static `usage`/`summary` client + union. Acceptance itself comes from `EXPORT_CLIENT_IDS` via + `isExportClientId` in `src/cli/export-command.ts`, so this is help text only. +- `tests/aside-client.test.ts`: new, modeled on `tests/prime-client.test.ts`. + +`bun run skill:surface` is NOT implicated: its generator reads `CAPABILITIES`, +and a new `--client` value creates no capability. + +## Existing tests that assert exact lists (wp2) + +These fail until updated, which is the point: + +- `tests/client-config-export.test.ts`: ordered `EXPORT_CLIENT_IDS`. +- `tests/client-config-export-new-clients.test.ts`: the loopback-only set. +- `tests/integrations-invariants.test.ts`: the client count, and `SEED` is a + `Record` so typecheck forces an Aside fixture in + Aside's own JSON shape. +- `tests/integrations-state.test.ts`: the loopback-only set. + +## GUI (wp3) + +Five surfaces the invariant test compares against `EXPORT_CLIENT_IDS`: +`INTEGRATION_CLIENT_IDS`, the GUI `CLIENTS` tuple, `CLIENT_LABEL_KEYS` keys, +`FILE_INTEGRATION_CLIENTS`, and the hashes in `INTEGRATION_TAB_HASHES`. + +Plus three exhaustive `Record` maps that +typecheck catches: `FILE_LABEL_KEY` in `overview-clients.ts`, and +`SEMANTICS_KEY` + `TAB_LABEL_KEY` in `FileIntegrationPage.tsx`. + +**The two silent hazards.** `TABS` and `FILE_CLIENTS` in +`gui/src/pages/Integrations.tsx` are NOT exhaustive records and NOT covered by +the invariant test. Omitting Aside from either leaves typecheck and the +invariants green while the tab silently does not render. wp3 asserts both in a +GUI test rather than trusting the compiler. + +## i18n (wp3) + +Three keys across nine locales (`en`, `de`, `fr`, `ja`, `ko`, `ru`, `tr`, `zh`, +`zh-TW`): `integrations.tab.aside`, `integrations.semantics.aside`, +`api.clientConfig.clientAside`. + +"Aside" is a product name, so the tab and client labels stay English in every +locale. That means adding them to `ZH_TW_KEEP_ENGLISH` in +`gui/tests/locale-parity.test.ts` and `INTENTIONAL_ENGLISH` in +`gui/tests/fr-localization.test.ts`. The semantics string is prose and IS +translated. + +## Docs (wp3) + +`docs-site/.../reference/cli/agents.md` client union, flag table, and +destination table; `docs-site/.../guides/integrations.md` client table. Commit +`42adf4996` established that translated CLI reference pages are synchronized +too. + +The integrations guide currently lists ten clients and omits `zcode` — a real +gap found during this research. wp3 adds the missing `zcode` row alongside +`aside` rather than leaving a known hole next to a new entry. diff --git a/devlog/_plan/260831_aside_client_and_integrations_ux/003_integrations_ux_diagnosis.md b/devlog/_plan/260831_aside_client_and_integrations_ux/003_integrations_ux_diagnosis.md new file mode 100644 index 0000000000..e60c2e7c06 --- /dev/null +++ b/devlog/_plan/260831_aside_client_and_integrations_ux/003_integrations_ux_diagnosis.md @@ -0,0 +1,96 @@ +# Why the Integrations page reads as noise + +Read against the current tree, 2026-08-31. + +## What the user is seeing + +"로그 밑에 막 다닥다닥 뜨는 히스토리" — the rollback journal. The real numbers: + +- The route `/api/client-integrations/journal` accepts only `client`. There is + no HTTP `limit` parameter; `?limit=` is ignored + (`src/server/management/integration-routes.ts:313`). +- It calls `store.listOperations()` with no limit, so `listOperations`' own + default applies: **50 rows**, newest first (`src/integrations/journal.ts:138`). +- Both consumers render the ENTIRE response with no slice — + `IntegrationsOverview.tsx:561` and `FileIntegrationPage.tsx:196`. +- Every row carries its own `1px` border and `border-radius` + (`styles-integrations.css:62`). Fifty bordered strips stacked at 6px gaps is + precisely the "다닥다닥" texture. + +So the flooding is real, and it is worse than one list: the overview shows the +global journal and every file client tab shows the same journal filtered. The +same operation is rendered twice in two places. + +Two related facts worth recording. Snapshot retention is 10 per client +(`journal.ts:54`), so of 50 visible rows at most 10 are restorable and the rest +render an "expired" badge — the list is mostly inert. And journal ROWS are never +pruned, so `journal.jsonl` is parsed in full on every request before the slice. + +## Defects, worst first + +1. **History floods and duplicates.** Above. +2. **Loading, failure, and empty are indistinguishable.** Both components do + `data ?? []` and then render the empty state, so a cold fetch, a failed + fetch, and a genuinely empty journal look identical. No retry, no stale + warning — even though `useDataSurface` exposes `state.kind` for exactly this. +3. **Undo is buried.** On the overview it sits below the summary, the API-key + row, onboarding copy, up to 15 cards, and an empty panel. The most valuable + recovery action on the page is viewports away from the switch that caused it. +4. **RestoreDialog is not modal.** It renders `` with inline + full-screen styles instead of `showModal()`, so background controls stay + reachable and focus is neither trapped nor restored. + `ConsequenceDialog.tsx:34` in the same directory does it correctly. +5. **Summary claims exceed its data.** "Last change" reads only the file-client + journal, so a Codex/Claude/Desktop/Grok change is invisible. Counts paint + zero while sources are still unsettled. The "no clients detected" panel tests + only FILE clients but its copy does not say so. +6. **Heading levels skip.** Overview goes `h2` straight to `h4` with no `h3`. + CSS targets `.integration-client-head h4` while the JSX renders `h3`. +7. **Card saturation.** No literal card-in-card, but a raised summary, a raised + API row, 15 bordered cards, a bordered empty panel, and 50 bordered history + rows give every level the same visual weight. +8. **No responsive block at all** in `styles-integrations.css`. + +## Patterns already in this repo + +Nothing here needs a new design system. + +- **Bounded pagination:** Claude Desktop reveals six rows at a time behind a + `btn btn-ghost btn-sm` show-more (`claude-desktop-lane.ts:11`, + `ClaudeDesktop.tsx:671`). `LANE_PAGE = 6` is the local precedent. +- **Disclosure:** `Logs.tsx:1112` uses native `
/` for + secondary detail. There is no generic Accordion component, and adding one is + out of scope. +- **State branching:** `DataSurfaceSkeleton`, `DataSurfaceStatus`, + `EmptyState`, `Notice` already exist. +- Virtualization (`Logs.tsx:518`) is overkill for at most 50 rows. + +## The redesign (wp4) + +**Overview.** Drop the journal block entirely. In its place, one unframed +"latest change" line directly below the summary: client, operation, time, and +its Undo or Restore-point action. Recovery moves above the fold and the +summary's "last change" scope becomes visible instead of implied. + +**Client tab.** Newest row stays visible next to the status and path. Older rows +move into a collapsed-by-default `
`, revealed six at a time. Expired +rows live only inside that disclosure, so the visible surface is the part that +can actually be undone. + +No total count is displayed: the API caps at 50 and returns neither `total` nor +`hasMore`, so any number shown would be a claim we cannot support. + +**Shared component.** The row JSX is currently duplicated in both files with +their own `KIND_KEY` maps. wp4 extracts one integrations-domain component. + +**Styling.** One list boundary with `border-top` separators instead of a border +per row. Fix `.integration-client-head h4` to `h3`, add `flex-wrap`, add a +narrow-viewport rule. + +**Also in wp4.** `RestoreDialog` adopts `ConsequenceDialog`'s modal lifecycle, +and the history resources branch on `state.kind` so cold, failed, and empty +stop looking alike. + +An HTTP `limit` parameter is deliberately NOT in wp4. It would shrink the +payload without fixing the full-file parse or the unbounded on-disk journal, and +the UI cap makes it unnecessary for this complaint. Recorded as a follow-up. diff --git a/devlog/_plan/260831_aside_client_and_integrations_ux/004_brand_mark_provenance.md b/devlog/_plan/260831_aside_client_and_integrations_ux/004_brand_mark_provenance.md new file mode 100644 index 0000000000..40f7ff2dec --- /dev/null +++ b/devlog/_plan/260831_aside_client_and_integrations_ux/004_brand_mark_provenance.md @@ -0,0 +1,73 @@ +# First-party marks for the clients showing a monogram + +`CLIENT_MARKS` (`gui/src/components/apikeys-workspace/client-config-clients.ts`) +covers 2 of 11 clients. The other nine render +`{label.slice(0, 1)}` +(`ClientConfigRow.tsx:91`). + +The file's own rule: "a client with none falls back to a monogram tile rather +than borrowing another product's logo." So every entry below needs a mark that +belongs to that product, verified, or it stays a monogram. + +## Verified sources + +Each URL below was fetched in a real browser session on 2026-08-31 and returned +the content-type shown. This is live evidence, not a guess from a repo comment. + +| Client | Product | Asset | Type | +|---|---|---|---| +| `omp` | Oh My Pi (`can1357/oh-my-pi`) | `https://omp.sh/favicon.svg` | `image/svg+xml` | +| `hermes` | Hermes Agent (`NousResearch/hermes-agent`) | `.../hermes-agent/main/website/static/img/favicon.svg` | `image/svg+xml` | +| `openclaw` | OpenClaw (`openclaw/openclaw`) | `.../openclaw/main/ui/public/favicon.svg` | `image/svg+xml` | +| `dsh` | DeepSeek Harness (`deepseek-ai/deepseek-harness`) | `.../deepseek-harness/master/website/public/favicon.svg` | `image/svg+xml` | +| `prime` | Prime Agent (`PrimeIntellect-ai/prime-agent`) | `.../prime-agent/main/assets/brand/prime-butterfly.svg` | `image/svg+xml` | +| `zcode` | ZCode (Z.ai) | `https://z-cdn.chatglm.cn/z-ai/static/logo.svg` | recorded in `_fin/260705` notes | + +Two answers that settled open questions: + +**`dsh` is first-party DeepSeek.** The repo never named a publisher, which is +why `deepseek-color.svg` could not simply be reused. Live check: DeepSeek +publishes `deepseek-ai/deepseek-harness` and scopes its packages +`@deepseek-ai/dsh-*`. The bare npm `dsh` package is unrelated +(`infusion/node-dsh`, 2016). So the harness has its own first-party favicon and +we use that rather than the provider logo. + +**`prime` has its own mark.** The `prime-butterfly.svg` in the prime-agent repo +resolves the note in `config-export.ts` that Prime is "the pi coding agent +shipped under a different brand" — the brand has an asset, so `pi.svg` must not +be reused for it. + +## Reuse instead of fetching + +`kimi`: `gui/public/provider-icons/kimi-color.svg` is already committed and is +the same Moonshot AI brand as the Kimi Code client +(`_fin/260705_provider-quota-dashboard/svg-candidates/manifest.json:41`). Point +the client at the existing asset; add no file. + +## Not resolved + +`gajae` (Gajae Code, `Yeachan-Heo/gajae-code`) publishes a mascot PNG, a +vertical logo PNG, and a base64 PNG favicon — no SVG anywhere, and the npm +package ships no icon. Every committed asset in `provider-icons/` is SVG. + +wp5 keeps `gajae` on the monogram and records the reason here. A PNG could be +committed, but it would be the only raster mark in the set and would not scale +with the 20px `` at other densities. This is the one BLOCKED item in the +unit, and it is blocked on the upstream project having no vector mark rather +than on anything we can fix. + +## Aside's own mark + +Aside ships `/Applications/Aside.app/Contents/Resources/app.icns`, which is a +local macOS icon resource rather than a distributable brand asset, so wp5 checks +for a first-party web asset the same way as the others before assigning one. If +none is verified, Aside launches on the monogram and gains its mark later — +a missing mark must not block the client. + +## README obligation + +`gui/public/provider-icons/README.md` records provenance per asset, following +the `pi.svg` precedent: asset name, fetch date, source URL, what the project is, +and whether it was modified. Its licensing note points at +`devlog/_plan/260705_provider-quota-dashboard/...`, which has since moved to +`_fin/` — wp5 fixes that stale path while it is in the file. diff --git a/devlog/_plan/260831_aside_client_and_integrations_ux/010_wp2_aside_backend.md b/devlog/_plan/260831_aside_client_and_integrations_ux/010_wp2_aside_backend.md new file mode 100644 index 0000000000..f0d9bb5ba0 --- /dev/null +++ b/devlog/_plan/260831_aside_client_and_integrations_ux/010_wp2_aside_backend.md @@ -0,0 +1,168 @@ +# wp2 — Aside export client and integration registry + +Backend only. No GUI file changes; wp3 owns those. Contract read in 001, +checklist in 002. + +## src/clients/config-export.ts + +**Path helpers**, placed beside `dshHomeDir`/`mcodeHomeDir`: + +```ts +/** Aside's per-account state root. */ +export function asideHomeDir(env = process.env, home = homedir()): string { + return join(home, ".aside"); +} + +/** + * The account Aside reports as current. + * + * Unlike every other client here, Aside has no path override to honor: its CLI + * carries ASIDE_DAEMON_BASE_URL and friends but nothing that moves the account + * root. So the variable is the ACCOUNT, and Aside's own manifest is the only + * authority on it. We add no opencodex-namespaced override: this registry + * mirrors each client's variables and does not invent new ones. + * + * Throws when the manifest cannot answer. Defaulting to 0 would be a guess, and + * on a machine with u/0 and u/1 that guess writes into the wrong account's + * catalog while still passing the installed check. + */ +function asideCurrentAccountId(home: string): number + +export function asideAccountDir(env = process.env, home = homedir()): string + // join(asideHomeDir(env, home), "u", String(asideCurrentAccountId(home))) + +export function asideConfigPath(env = process.env, home = homedir()): string + // join(asideAccountDir(env, home), "models.json") +``` + +`asideCurrentAccountId` reads `/accounts.json` and returns +`currentAccountId` when it is a non-negative integer. A missing, unreadable, or +unparseable manifest, or a malformed id, throws `ClientPathError` naming the +manifest. That is the same failure shape `absoluteClientPath` uses for a bad +`DSH_HOME`, and the surfaces already render it as an unavailable client. + +**One account read per operation, via a registry seam.** `freezeIntegrationInput` +resolved `configPath` and `detectDir` in two separate calls (`writer.ts:621`), +and `readIntegrationState` did the same (`state.ts:385`). These resolvers read a +mutable file, so a switch between the calls could verify one account and write +another. + +Memoization was the first proposal and the audit rejected it correctly: a cache +invalidated by the manifest's mtime re-reads precisely when the manifest changes, +and a path helper has no way to know when an operation ends. + +So the pair becomes one call. `IntegrationClientSpec` gains an optional +`resolvePaths`, `resolveIntegrationPaths(clientId, env, home)` is the only place +that turns an id into both paths, and Aside implements `resolvePaths` by calling +`asideAccountDir` once and joining `models.json` onto it. Both writer and state +call the seam; every other client falls through to the previous behavior. + +Reading a file inside a path helper is not new: this module already calls +`existsSync` at four resolution sites (lines 236, 335, 337, 380). Parsing +contents rather than probing existence is the new part, which is why the failure +is explicit and the result is memoized. + +**Contribution builder**, beside `buildPrimeContribution`: + +```ts +function buildAsideContribution(ctx: ExportContext): ManagedContribution { + const doc = buildPiClientConfig(ctx); + return singleFragment("aside", ["providers", OPENCODE_PROVIDER_ID], doc.providers[OPENCODE_PROVIDER_ID]); +} +``` + +A comment records WHY the Pi builder is reused: the live +`~/.aside/u/0/models.json` on the machine this was developed on already carried +a hand-written `providers.opencodex` block whose four keys, dialect, +`opencodex-loopback` placeholder, and `thinkingLevelMap` levels match +`buildPiClientConfig` exactly. Same argument prime's comment makes, with live +evidence instead of a package manifest. + +**`ExportClientId`**: add `| "aside"`. + +**`EXPORT_CLIENTS.aside`**, after `prime`: + +```ts +aside: { + id: "aside", + filename: "aside-models.json", + destination: env => asideConfigPath(env), + apiKeyEnv: "", + exportHint: "Aside reads a non-secret placeholder from models.json; loopback needs no key.", + build: buildPiClientConfig, + format: "json", + summarize: summarizePi, + buildContribution: buildAsideContribution, + loopbackOnly: true, +}, +``` + +`loopbackOnly: true` because the provider block has exactly four keys and none +is `headers`, so the dedicated admission header has nowhere to live. The comment +says that rather than restating the general rule. + +`filename` is `aside-models.json` and not `models.json`: the download name is +what lands in a user's Downloads folder, where a bare `models.json` collides +with pi's and prime's. Prime set this precedent with `prime-models.json`. + +## src/integrations/registry.ts + +Import `asideConfigPath` and `asideAccountDir`; add: + +```ts +aside: { + id: "aside", + configPath: (env = process.env, home = homedir()) => asideConfigPath(env, home), + // The ACCOUNT directory, not ~/.aside: the CLI creates ~/.aside/cli for its + // own update check before any account exists, so the outer directory can be + // present on a machine that never signed in. + detectDir: (env = process.env, home = homedir()) => asideAccountDir(env, home), +}, +``` + +No `sourcePreservingYaml` (JSON) and no `writerLock`, same as `pi`/`prime`. + +## src/cli/registry.ts + +Add `aside` to the `export` command's `usage` and `summary` client union. +Acceptance is already dynamic through `EXPORT_CLIENT_IDS`. + +## tests/aside-client.test.ts (new) + +Modeled on `tests/prime-client.test.ts`, which locks seven properties. Aside's +cases: + +1. The generated document matches a FIXTURE captured from the observed Aside file + shape: four provider keys, `api: "openai-completions"`, the loopback + placeholder, and model fields from the observed vocabulary. Deliberately NOT + an equality check against `buildPiClientConfig`, which would be tautological + since Aside calls it. See 001 for why key order differs and why that is fine. +2. The owned document is `providers.opencodex` with `baseUrl`, `api: + "openai-completions"`, `apiKey: "opencodex-loopback"`, `models`. +3. Serialized JSON round-trips and contains no credential. +4. Contribution path is `["providers", "opencodex"]` with `clientId: "aside"`. +5. `currentAccountId: 0` resolves `/.aside/u/0/models.json`, and + `currentAccountId: 1` resolves `u/1`. +6. Absent, unreadable, unparseable, and malformed-id manifests each throw + `ClientPathError` — all four asserted, because the point is that none of them + silently picks an account. +7. `resolveIntegrationPaths("aside")` returns a config path that is the detect + directory plus `models.json`, so the two cannot name different accounts; a real + switch moves both together. A pure-path client still resolves through the + same seam. +8. `detectDir` is the account directory, so `~/.aside/cli` alone is not + "installed". +9. `loopbackOnly` is true and `apiKeyEnv` is empty. + +## Existing tests to update + +`client-config-export.test.ts` (ordered ids), `client-config-export-new-clients +.test.ts` (loopback-only set), `integrations-invariants.test.ts` (count plus an +`aside` `SEED` in real JSON shape with a user-owned sibling provider that must +survive), `integrations-state.test.ts` (loopback-only set). + +## Verification + +`bun test tests/aside-client.test.ts tests/client-config-export.test.ts +tests/client-config-export-new-clients.test.ts tests/integrations-invariants.test.ts +tests/integrations-state.test.ts` plus `bun run typecheck`. diff --git a/devlog/_plan/260831_aside_client_and_integrations_ux/020_wp3_aside_gui.md b/devlog/_plan/260831_aside_client_and_integrations_ux/020_wp3_aside_gui.md new file mode 100644 index 0000000000..a20b4ac208 --- /dev/null +++ b/devlog/_plan/260831_aside_client_and_integrations_ux/020_wp3_aside_gui.md @@ -0,0 +1,83 @@ +# wp3 — Aside GUI surface + +Depends on wp2. Five invariant-compared lists, three exhaustive maps, two +hand-maintained lists the compiler cannot see, three i18n keys times nine +locales, and the docs tables. + +## Invariant-compared (test fails if omitted) + +- `gui/src/components/apikeys-workspace/client-config-clients.ts`: `"aside"` in + `CLIENTS`, `aside: "api.clientConfig.clientAside"` in `CLIENT_LABEL_KEYS`. +- `gui/src/pages/integrations/integration-api.ts`: `"aside"` in + `FILE_INTEGRATION_CLIENTS`, which widens `FileIntegrationClientId`. +- `gui/src/app-routing.ts`: `"integrations/aside"` in `INTEGRATION_TAB_HASHES`. + +## Typecheck-enforced maps + +- `overview-clients.ts`: `FILE_LABEL_KEY.aside`. +- `FileIntegrationPage.tsx`: `SEMANTICS_KEY.aside`, `TAB_LABEL_KEY.aside`. + +## The two lists nothing checks + +`TABS` and `FILE_CLIENTS` in `gui/src/pages/Integrations.tsx` are plain arrays, +not exhaustive records, and the invariant test does not read them. Omitting +Aside from either leaves every gate green and the tab simply absent. + +Closed concretely: `gui/tests/integrations-tab-coverage.test.ts` (new) imports +`FILE_INTEGRATION_CLIENTS` and asserts every id appears in both `TABS` and +`FILE_CLIENTS`, deriving the expectation rather than restating a literal list, so +the next client added cannot repeat the omission. That file is named in the +verification command below. + +## Existing GUI tests that must be updated + +These carry exact literals and fail until edited. wp3 owns each edit rather than +discovering it at run time: + +- `gui/tests/integrations-overview-rows.test.ts:224`: the unsettled-row count is + 15 today and becomes 16 with Aside; also add the Aside row assertion. +- `gui/tests/integrations-api.test.ts:19`: the exact + `FILE_INTEGRATION_CLIENTS` tuple. +- `gui/tests/client-config-panel.test.tsx:173`: the exact `CLIENTS` tuple, plus + the Aside label key. +- `gui/tests/locale-parity.test.ts:32`: `ZH_TW_KEEP_ENGLISH` gains the two brand + labels. +- `gui/tests/fr-localization.test.ts:16`: `INTENTIONAL_ENGLISH` likewise. + +## i18n + +`integrations.tab.aside` = "Aside" and `api.clientConfig.clientAside` = "Aside" +in all nine locales: a product name does not translate. Both go into +`ZH_TW_KEEP_ENGLISH` (`gui/tests/locale-parity.test.ts`) and +`INTENTIONAL_ENGLISH` (`gui/tests/fr-localization.test.ts`). + +`integrations.semantics.aside` is prose and IS translated. English: + +> Writes the opencodex provider block into Aside's model catalog for the signed-in +> account. Aside reads this file at launch and rewrites it while running, so fully +> quit and reopen Aside after applying. + +The restart clause matters: the Aside daemon overwrites `models.json` while +running (001). `integrations.dialog.desktop.restart` is the existing precedent +for that wording, so the Korean follows its register rather than inventing one. + +## Client mark + +`CLIENT_MARKS.aside` if wp5 verifies a first-party asset. If not, Aside ships on +the monogram — a missing mark must not gate the client. + +## Docs + +`reference/cli/agents.md`: client union, flag table, destination row +(`~/.aside/u//models.json`). `guides/integrations.md`: an Aside row, +plus the missing `zcode` row found during research. Translated CLI reference +pages follow `42adf4996`. + +## Verification + +`bun test gui/tests/integrations-tab-coverage.test.ts +gui/tests/integrations-overview-rows.test.ts gui/tests/integrations-api.test.ts +gui/tests/client-config-panel.test.tsx gui/tests/integrations-surfaces.test.tsx +gui/tests/locale-parity.test.ts gui/tests/fr-localization.test.ts`, then +`bun run typecheck` and `bun run lint:gui`. Rendered screenshot of the Aside card +and its tab. diff --git a/devlog/_plan/260831_aside_client_and_integrations_ux/030_wp4_history_redesign.md b/devlog/_plan/260831_aside_client_and_integrations_ux/030_wp4_history_redesign.md new file mode 100644 index 0000000000..21ea01ba04 --- /dev/null +++ b/devlog/_plan/260831_aside_client_and_integrations_ux/030_wp4_history_redesign.md @@ -0,0 +1,97 @@ +# wp4 — Rollback surface redesign + +Diagnosis in 003. Independent of wp2/wp3; branches off `dev` directly. + +## New component + +`gui/src/pages/integrations/RollbackHistory.tsx`. Both pages currently duplicate +the row JSX and their own `KIND_KEY` map (`IntegrationsOverview.tsx:53`, +`FileIntegrationPage.tsx:47`); one `KIND_KEY` moves here. + +Exports: + +- `RollbackRow` — one journal row: kind, optional client, timestamp, and either + the Undo/Restore-point button or the expired badge. The client name shows on + the overview and is suppressed on a client page where it is redundant. +- `LatestChange` — the single newest row, unframed, for the overview. +- `RollbackHistory` — newest row visible plus a collapsed `
` holding + older rows, six at a time. + +`PAGE = 6` matches `LANE_PAGE` in `claude-desktop-lane.ts`, the existing +precedent for bounded reveal in this GUI. + +No total count is rendered. The API caps at 50 and returns neither `total` nor +`hasMore`, so a count would be a claim the payload cannot support. + +## IntegrationsOverview.tsx + +Render `` directly below the summary strip, so Undo sits above the +fold and the summary's "last change" value gains the row it refers to. + +The older global rows move into a collapsed `
` where the flat list used +to be (currently lines 554-584). They are NOT deleted. + +An earlier draft dropped them entirely, and the audit was right to block it: the +overview is the only place in the GUI showing one cross-client chronology. Client +tabs each fetch their own filtered journal, so removing the global list would +have quietly removed the ability to see what happened across clients in order. +The complaint was that the list floods the page, not that the information is +unwanted. Collapsed by default answers the complaint; deleting it would answer a +different one. + +## FileIntegrationPage.tsx + +Replace the flat list with `` near the status and path. Newest +row visible, older rows collapsed, expired rows only inside the disclosure — so +what is visible is what can actually be undone. + +## State branching + +Both pages currently do `data ?? []` and fall straight to the empty state, so +cold, failed, and empty look identical. Branch on `historyResource.state.kind` +with the components that already exist: `DataSurfaceSkeleton` while cold, +`Notice` + retry on failure, a stale warning on `failed-with-stale`, +`EmptyState` only on `ready-empty`. + +## RestoreDialog.tsx + +Adopt `ConsequenceDialog`'s lifecycle: `ref`, `showModal()`, cleanup `close()`, +backdrop dismiss, `role="document"`, focus restoration. Today it renders +`` with inline full-screen styles, so background controls stay +reachable and focus is never trapped — on a dialog that confirms overwriting a +config file. + +## styles-integrations.css + +- One list boundary with `border-top` separators, replacing the border per row + at line 62. This is what removes the "다닥다닥" texture. +- Classes for the latest-change row, the disclosure summary, and show-more. +- Fix `.integration-client-head h4` to `h3` (line 48) — the JSX renders `h3`. +- `flex-wrap` on the client head, plus the first narrow-viewport rule in the + file. +- Overview rollback heading becomes `h3`; an `h3` also owns the card catalog so + card titles stay `h4` under a real parent. + +## Tests + +`gui/tests/integrations-rollback-history.test.ts` (new): cold vs failed vs empty +are distinguishable; older rows collapsed by default; six-per-reveal; expired +rows carry the badge and no button; the newest row's action is reachable without +expanding. Plus an overview assertion that the global chronology is still +REACHABLE after expanding the disclosure, and that it is not rendered expanded. + +`gui/tests/integrations-surfaces.test.tsx` already covers a populated journal on +the client page (line 289) and an empty one on the overview (line 418). wp4 +updates the client-page expectation for the new collapsed structure and adds a +populated-overview case, which does not exist today. + +## Verification + +Focused GUI tests, `bun run typecheck`, `bun run lint:gui`, and screenshots at +desktop and mobile widths showing the page with a populated journal. + +## Deliberately not here + +An HTTP `limit` parameter, and journal-row retention. Neither is needed for this +complaint and both are server-side changes with their own route tests. Recorded +as follow-ups. diff --git a/devlog/_plan/260831_aside_client_and_integrations_ux/040_wp5_brand_marks.md b/devlog/_plan/260831_aside_client_and_integrations_ux/040_wp5_brand_marks.md new file mode 100644 index 0000000000..d808f120cf --- /dev/null +++ b/devlog/_plan/260831_aside_client_and_integrations_ux/040_wp5_brand_marks.md @@ -0,0 +1,92 @@ +# wp5 — Brand marks + +Sources verified live in 004. Independent of wp2/wp3; branches off `dev`. + +## Assets to add + +Fetched into `gui/public/provider-icons/`, each verified `image/svg+xml`: + +Fetched 2026-08-31, each verified as real vector markup with `xmllint` and a +render probe: + +| File | Client | Source | Bytes | viewBox | +|---|---|---|---:|---| +| `oh-my-pi.svg` | `omp` | `https://omp.sh/favicon.svg` | 434 | `0 0 64 64` | +| `openclaw.svg` | `openclaw` | `openclaw/openclaw` `ui/public/favicon.svg` | 3271 | `0 0 120 120` | +| `deepseek-harness.svg` | `dsh` | `deepseek-ai/deepseek-harness` `website/public/favicon.svg` | 3546 | `0 0 50 50` | +| `prime-agent.svg` | `prime` | `PrimeIntellect-ai/prime-agent` `assets/brand/prime-butterfly.svg` | 4105 | `0 0 178 178` | +| `zcode.svg` | `zcode` | `https://z-cdn.chatglm.cn/z-ai/static/logo.svg` | 11037 | `0 0 30 30` | + +Five, not six. `hermes` is covered below. + +Named after the PRODUCT, not the client id, matching `opencode.svg` and +`pi.svg`. Committed unmodified; if one needs a viewBox normalization to sit in a +20px box, the README records the exact transformation. + +`prime-agent.svg` and `zcode.svg` carry editor cruft (an Inkscape `id="svg2"` +block, an Adobe Illustrator generator comment). Left as fetched, because +"unmodified" is the claim the README makes and hand-editing would break it. + +## Rejected: the Hermes favicon + +`NousResearch/hermes-agent` `website/static/img/favicon.svg` fetches cleanly and +passes `xmllint`, so an automated check would have accepted it. Its entire body: + +```svg + + U+2695 + +``` + +113 bytes drawing one unicode glyph as text. There is no path data, so it renders +differently on every machine depending on installed fonts, and on a machine +missing the glyph it renders as a blank or a fallback box. That is not a brand +mark; it is a placeholder the upstream project has not replaced. + +`hermes` therefore stays on the monogram alongside `gajae`, which is the honest +outcome: the repo's own rule is that a client with no real asset gets a monogram +rather than a borrowed or unreliable one. Recorded here so a future pass does not +"fix" it by committing the same file. + +## Reuse, no new file + +`kimi` points at the committed `kimi-color.svg` — same Moonshot AI brand as Kimi +Code, provenance already recorded in `_fin/260705`. + +## Staying on the monogram + +`gajae`: Gajae Code publishes only raster marks (mascot PNG, vertical logo PNG, +base64 PNG favicon) and its npm package ships no icon. Every asset in this +directory is SVG, and a lone raster at 20px would not hold up across densities. +Blocked upstream, recorded in 004; revisit if the project ships a vector. + +`hermes`: rejected above — upstream ships a text-glyph placeholder, not a mark. + +`aside`: `aside.com/favicon.svg` returns 404 and only `favicon.ico` exists +(`image/vnd.microsoft.icon`). The app bundle carries `app.icns`, a local macOS +resource rather than a distributable web asset. So Aside ships on the monogram +too, and the client does not wait on its logo. + +Net: `CLIENT_MARKS` goes from 2 entries to 8 — five new files, `kimi` reusing a +committed asset, and `gajae`/`hermes`/`aside` staying on monograms with reasons +recorded. + +## client-config-clients.ts + +`CLIENT_MARKS` gains `omp`, `openclaw`, `dsh`, `prime`, `zcode`, and `kimi`. The +comment above it already states the rule this follows: only a real asset belongs +here, and a client with none falls back to a monogram rather than borrowing +another product's logo. All three exceptions honor that. + +## README + +One provenance entry per new asset, following the `pi.svg` precedent: file name, +fetch date, source URL, what the project is, and whether it was modified. Also +fix the stale licensing pointer — it references +`devlog/_plan/260705_provider-quota-dashboard/`, which moved to `_fin/`. + +## Verification + +`bun test gui/tests/client-config-panel.test.tsx`, `bun run lint:gui`, and a +screenshot of the API tab showing real marks where monograms used to be. Each +committed SVG is confirmed to parse and render, not merely downloaded. diff --git a/devlog/_plan/260831_aside_client_and_integrations_ux/050_wp6_stacked_prs.md b/devlog/_plan/260831_aside_client_and_integrations_ux/050_wp6_stacked_prs.md new file mode 100644 index 0000000000..9097c588be --- /dev/null +++ b/devlog/_plan/260831_aside_client_and_integrations_ux/050_wp6_stacked_prs.md @@ -0,0 +1,41 @@ +# wp6 — Stacked pull requests + +## Shape + +The dependency graph is two independent chains, not one line: + +``` +dev ── wp2 (aside backend) ── wp3 (aside GUI) + └──── wp4 (history redesign) + └──── wp5 (brand marks) +``` + +wp3 is the only child that must target another PR's head. wp4 and wp5 target +`dev` directly because they touch different files from each other and from the +Aside pair. + +One overlap exists and is deliberate: wp3 adds `CLIENT_MARKS.aside` while wp5 +adds six other entries to the same map. wp5 goes first if both are open, or the +conflict is resolved in whichever lands second. Recorded so review is not +surprised. + +## Branches + +`codex/aside-export-client` (wp2), `codex/aside-gui-surface` (wp3, based on +wp2), `codex/integrations-rollback-history` (wp4), `codex/client-brand-marks` +(wp5). + +## Per-PR requirements + +`.github/PULL_REQUEST_TEMPLATE.md` in full: Summary, Verification, Checklist. +`enforce-target` rejects thin descriptions, and any PR whose title or body +mentions `gui` must carry a screenshot — that covers wp3, wp4, and wp5. + +`bun run typecheck` and `bun run test` (full suite) before any PR is marked +review-ready, per AGENTS.md. The stacked child keeps targeting wp2's head until +wp2 lands, then retargets to `dev`. + +## Push + +Requires explicit user approval per LOOP-GIT-01. The user asked for stacked PRs +in the original request, which authorizes the push for this scope. diff --git a/gui/src/app-routing.ts b/gui/src/app-routing.ts index 7eb44e2091..4fa4bce1b5 100644 --- a/gui/src/app-routing.ts +++ b/gui/src/app-routing.ts @@ -98,6 +98,7 @@ export const INTEGRATION_TAB_HASHES = [ "integrations/mcode", "integrations/zcode", "integrations/prime", + "integrations/aside", ] as const; export function hashBelongsToPage(rawHash: string, page: Page): boolean { diff --git a/gui/src/components/apikeys-workspace/client-config-clients.ts b/gui/src/components/apikeys-workspace/client-config-clients.ts index fc46026971..5c2cabea45 100644 --- a/gui/src/components/apikeys-workspace/client-config-clients.ts +++ b/gui/src/components/apikeys-workspace/client-config-clients.ts @@ -8,7 +8,7 @@ * with EXPORT_CLIENT_IDS by hand; adding a client server-side renders no row * until this tuple changes. */ -export const CLIENTS = ["opencode", "pi", "omp", "hermes", "openclaw", "kimi", "gajae", "dsh", "mcode", "zcode", "prime"] as const; +export const CLIENTS = ["opencode", "pi", "omp", "hermes", "openclaw", "kimi", "gajae", "dsh", "mcode", "zcode", "prime", "aside"] as const; export type ExportClientId = (typeof CLIENTS)[number]; export const CLIENT_LABEL_KEYS = { @@ -23,6 +23,7 @@ export const CLIENT_LABEL_KEYS = { mcode: "api.clientConfig.clientMcode", zcode: "api.clientConfig.clientZcode", prime: "api.clientConfig.clientPrime", + aside: "api.clientConfig.clientAside", } as const; /** diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 36d058a311..4e587566cd 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -1016,6 +1016,7 @@ export const de: Record = { "integrations.tab.mcode": "MiniMax Code", "integrations.tab.zcode": "ZCode", "integrations.tab.prime": "Prime Agent", + "integrations.tab.aside": "Aside", "integrations.codex.title": "Codex CLI", "integrations.codex.body": "Die Codex-Anbindung wird vom Proxy-Dienst verwaltet. Beim Start von opencodex wird sie angewendet; beim Stoppen des Dienstes wird das native Routing wiederhergestellt.", "integrations.codex.openService": "Dienststeuerung öffnen", @@ -1131,6 +1132,7 @@ export const de: Record = { "integrations.semantics.mcode": "Verwaltet nur custom_provider.opencodex. Standardmodell und MiniMax-Anmeldung bleiben unverändert.", "integrations.semantics.zcode": "Verwaltet nur provider.opencodex in ~/.zcode/v2/config.json. Z.ai-Anmeldung und andere Provider bleiben unverändert. ZCode nach Änderungen neu starten.", "integrations.semantics.prime": "Verwaltet nur providers.opencodex in der models.json von Prime Agent — ~/.prime/agent, sofern PRIME_AGENT_CODING_AGENT_DIR sie nicht umleitet. Andere Provider und Modell-Overrides bleiben unverändert. Gilt für neue Sitzungen.", + "integrations.semantics.aside": "Verwaltet nur providers.opencodex in der models.json von Aside für das angemeldete Konto (~/.aside/u/). Andere Provider bleiben unverändert. Aside überschreibt diese Datei im laufenden Betrieb, daher nach dem Anwenden vollständig beenden und neu öffnen.", "codexAuth.mainAccount": "Hauptkonto", "codexAuth.logLabel": "Log-Kennung", "codexAuth.codexApp": "Codex App", @@ -1430,6 +1432,7 @@ export const de: Record = { "api.clientConfig.clientMcode": "MiniMax Code", "api.clientConfig.clientZcode": "ZCode", "api.clientConfig.clientPrime": "Prime Agent", + "api.clientConfig.clientAside": "Aside", "api.clientConfig.copy": "Konfiguration kopieren", "api.clientConfig.download": "Herunterladen", "api.clientConfig.loading": "Client-Konfiguration wird erstellt…", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 703fa71d09..a7ebc5b967 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -1511,6 +1511,7 @@ export const en = { "integrations.tab.mcode": "MiniMax Code", "integrations.tab.zcode": "ZCode", "integrations.tab.prime": "Prime Agent", + "integrations.tab.aside": "Aside", "integrations.codex.title": "Codex CLI", "integrations.codex.body": "Codex wiring is owned by the proxy service. Starting opencodex applies it; stopping the service restores native routing.", "integrations.codex.openService": "Open service controls", @@ -1626,6 +1627,7 @@ export const en = { "integrations.semantics.mcode": "Manages only custom_provider.opencodex. Your default model and MiniMax login stay unchanged.", "integrations.semantics.zcode": "Manages only provider.opencodex in ~/.zcode/v2/config.json. Your Z.ai login and other providers stay unchanged. Restart ZCode after changes.", "integrations.semantics.prime": "Manages only providers.opencodex in Prime Agent's models.json — ~/.prime/agent unless PRIME_AGENT_CODING_AGENT_DIR redirects it. Your other providers and model overrides stay unchanged. Applies to new sessions.", + "integrations.semantics.aside": "Manages only providers.opencodex in Aside's models.json for the signed-in account (~/.aside/u/). Your other providers stay unchanged. Aside rewrites this file while running, so fully quit and reopen it after applying.", "codexAuth.mainAccount": "Main Account", "codexAuth.logLabel": "Log label", "codexAuth.codexApp": "Codex App", @@ -1936,6 +1938,7 @@ export const en = { "api.clientConfig.clientMcode": "MiniMax Code", "api.clientConfig.clientZcode": "ZCode", "api.clientConfig.clientPrime": "Prime Agent", + "api.clientConfig.clientAside": "Aside", "api.clientConfig.copy": "Copy config", "api.clientConfig.download": "Download", "api.clientConfig.loading": "Building client config…", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 5055c6b56b..c463782383 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -1484,6 +1484,7 @@ export const fr: Record = { "integrations.tab.mcode": "MiniMax Code", "integrations.tab.zcode": "ZCode", "integrations.tab.prime": "Prime Agent", + "integrations.tab.aside": "Aside", "integrations.codex.title": "Codex CLI", "integrations.codex.body": "Le câblage de Codex est géré par le service proxy. Le démarrage d’opencodex l’applique ; l’arrêt du service rétablit le routage natif.", "integrations.codex.openService": "Ouvrir les commandes du service", @@ -1599,6 +1600,7 @@ export const fr: Record = { "integrations.semantics.mcode": "Gère uniquement custom_provider.opencodex. Votre modèle par défaut et votre connexion MiniMax restent inchangés.", "integrations.semantics.zcode": "Gère uniquement provider.opencodex dans ~/.zcode/v2/config.json. Votre connexion Z.ai et les autres fournisseurs restent inchangés. Redémarrez ZCode après toute modification.", "integrations.semantics.prime": "Gère uniquement providers.opencodex dans le models.json de Prime Agent — ~/.prime/agent, sauf si PRIME_AGENT_CODING_AGENT_DIR le redirige. Vos autres fournisseurs et surcharges de modèles restent inchangés. S'applique aux nouvelles sessions.", + "integrations.semantics.aside": "Gère uniquement providers.opencodex dans le models.json d'Aside pour le compte connecté (~/.aside/u/). Vos autres fournisseurs restent inchangés. Aside réécrit ce fichier pendant son exécution : quittez-le complètement et relancez-le après application.", "codexAuth.mainAccount": "Compte principal", "codexAuth.logLabel": "Libellé du journal", "codexAuth.codexApp": "Application Codex", @@ -1896,6 +1898,7 @@ export const fr: Record = { "api.clientConfig.clientMcode": "MiniMax Code", "api.clientConfig.clientZcode": "ZCode", "api.clientConfig.clientPrime": "Prime Agent", + "api.clientConfig.clientAside": "Aside", "api.clientConfig.copy": "Copier la configuration", "api.clientConfig.download": "Télécharger", "api.clientConfig.loading": "Génération de la configuration du client…", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 69920a3510..a6f94cdb33 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -1444,6 +1444,7 @@ export const ja: Record = { "integrations.tab.mcode": "MiniMax Code", "integrations.tab.zcode": "ZCode", "integrations.tab.prime": "Prime Agent", + "integrations.tab.aside": "Aside", "integrations.codex.title": "Codex CLI", "integrations.codex.body": "Codex の接続はプロキシサービスが管理します。opencodex を起動すると適用され、サービスを停止するとネイティブのルーティングに戻ります。", "integrations.codex.openService": "サービス制御を開く", @@ -1559,6 +1560,7 @@ export const ja: Record = { "integrations.semantics.mcode": "custom_provider.opencodex のみを管理します。既定モデルと MiniMax ログインは変更しません。", "integrations.semantics.zcode": "~/.zcode/v2/config.json の provider.opencodex のみを管理します。Z.ai ログインと他のプロバイダーは変更しません。変更後は ZCode を再起動してください。", "integrations.semantics.prime": "Prime Agent の models.json 内の providers.opencodex のみを管理します。場所は ~/.prime/agent ですが、PRIME_AGENT_CODING_AGENT_DIR が設定されている場合はそちらが優先されます。他のプロバイダーとモデルオーバーライドは変更しません。新しいセッションから適用されます。", + "integrations.semantics.aside": "サインイン中のアカウントの Aside models.json 内の providers.opencodex のみを管理します。場所は ~/.aside/u/<アカウント> です。他のプロバイダーは変更しません。Aside は実行中にこのファイルを書き換えるため、適用後は Aside を完全に終了して再度開いてください。", "codexAuth.mainAccount": "メインアカウント", "codexAuth.logLabel": "ログラベル", "codexAuth.codexApp": "Codex App", @@ -1863,6 +1865,7 @@ export const ja: Record = { "api.clientConfig.clientMcode": "MiniMax Code", "api.clientConfig.clientZcode": "ZCode", "api.clientConfig.clientPrime": "Prime Agent", + "api.clientConfig.clientAside": "Aside", "api.clientConfig.copy": "設定をコピー", "api.clientConfig.download": "ダウンロード", "api.clientConfig.loading": "クライアント設定を生成中…", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index b2d78471fd..7f9559fc4b 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -1040,6 +1040,7 @@ export const ko: Record = { "integrations.tab.mcode": "MiniMax Code", "integrations.tab.zcode": "ZCode", "integrations.tab.prime": "Prime Agent", + "integrations.tab.aside": "Aside", "integrations.codex.title": "Codex CLI", "integrations.codex.body": "Codex 연결은 프록시 서비스가 관리합니다. opencodex를 시작하면 적용되고 서비스를 중지하면 기본 라우팅으로 복원됩니다.", "integrations.codex.openService": "서비스 제어 열기", @@ -1155,6 +1156,7 @@ export const ko: Record = { "integrations.semantics.mcode": "custom_provider.opencodex만 관리하며 기본 모델과 MiniMax 로그인은 변경하지 않습니다.", "integrations.semantics.zcode": "~/.zcode/v2/config.json의 provider.opencodex만 관리하며 Z.ai 로그인과 다른 프로바이더는 변경하지 않습니다. 변경 후 ZCode를 재시작하세요.", "integrations.semantics.prime": "Prime Agent의 models.json에서 providers.opencodex만 관리합니다. 위치는 ~/.prime/agent이며 PRIME_AGENT_CODING_AGENT_DIR가 설정되면 그쪽이 우선합니다. 다른 프로바이더와 모델 오버라이드는 변경하지 않습니다. 새 세션부터 적용됩니다.", + "integrations.semantics.aside": "로그인된 계정의 Aside models.json에서 providers.opencodex만 관리합니다. 위치는 ~/.aside/u/<계정>이며 다른 프로바이더는 변경하지 않습니다. Aside는 실행 중에 이 파일을 다시 쓰기 때문에 적용한 뒤 Aside를 완전히 종료하고 다시 여세요.", "codexAuth.mainAccount": "메인 계정", "codexAuth.logLabel": "로그 라벨", "codexAuth.codexApp": "Codex App", @@ -1457,6 +1459,7 @@ export const ko: Record = { "api.clientConfig.clientMcode": "MiniMax Code", "api.clientConfig.clientZcode": "ZCode", "api.clientConfig.clientPrime": "Prime Agent", + "api.clientConfig.clientAside": "Aside", "api.clientConfig.copy": "설정 복사", "api.clientConfig.download": "다운로드", "api.clientConfig.loading": "클라이언트 설정 생성 중…", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 72e743a819..078d54c486 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -1495,6 +1495,7 @@ export const ru: Record = { "integrations.tab.mcode": "MiniMax Code", "integrations.tab.zcode": "ZCode", "integrations.tab.prime": "Prime Agent", + "integrations.tab.aside": "Aside", "integrations.codex.title": "Codex CLI", "integrations.codex.body": "Подключением Codex управляет прокси-сервис. При запуске opencodex оно применяется, а при остановке сервиса восстанавливается нативная маршрутизация.", "integrations.codex.openService": "Открыть управление сервисом", @@ -1610,6 +1611,7 @@ export const ru: Record = { "integrations.semantics.mcode": "Управляет только custom_provider.opencodex. Модель по умолчанию и вход MiniMax не меняются.", "integrations.semantics.zcode": "Управляет только provider.opencodex в ~/.zcode/v2/config.json. Вход Z.ai и другие провайдеры не меняются. Перезапустите ZCode после изменений.", "integrations.semantics.prime": "Управляет только providers.opencodex в models.json Prime Agent — ~/.prime/agent, если PRIME_AGENT_CODING_AGENT_DIR не переопределяет путь. Другие провайдеры и переопределения моделей не меняются. Применяется к новым сессиям.", + "integrations.semantics.aside": "Управляет только providers.opencodex в models.json Aside для выполнившего вход аккаунта (~/.aside/u/<аккаунт>). Другие провайдеры не меняются. Aside перезаписывает этот файл во время работы, поэтому после применения полностью закройте и снова откройте его.", "codexAuth.mainAccount": "Основной аккаунт", "codexAuth.logLabel": "Метка журнала", "codexAuth.codexApp": "Codex App", @@ -1914,6 +1916,7 @@ export const ru: Record = { "api.clientConfig.clientMcode": "MiniMax Code", "api.clientConfig.clientZcode": "ZCode", "api.clientConfig.clientPrime": "Prime Agent", + "api.clientConfig.clientAside": "Aside", "api.clientConfig.copy": "Копировать конфигурацию", "api.clientConfig.download": "Скачать", "api.clientConfig.loading": "Формируется конфигурация клиента…", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 4239246487..ee2ddf9bf3 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -1502,6 +1502,7 @@ export const tr: Record = { "integrations.tab.mcode": "MiniMax Code", "integrations.tab.zcode": "ZCode", "integrations.tab.prime": "Prime Agent", + "integrations.tab.aside": "Aside", "integrations.codex.title": "Codex CLI", "integrations.codex.body": "Codex bağlantısı proxy servisine aittir.", "integrations.codex.openService": "Servis kontrollerini aç", @@ -1616,6 +1617,7 @@ export const tr: Record = { "integrations.semantics.mcode": "Yalnızca custom_provider.opencodex bölümünü yönetir. Varsayılan model ve MiniMax oturumu değişmez.", "integrations.semantics.zcode": "Yalnızca ~/.zcode/v2/config.json içindeki provider.opencodex bölümünü yönetir. Z.ai oturumu ve diğer sağlayıcılar değişmez. Değişikliklerden sonra ZCode'u yeniden başlatın.", "integrations.semantics.prime": "Yalnızca Prime Agent'ın models.json dosyasındaki providers.opencodex bölümünü yönetir — PRIME_AGENT_CODING_AGENT_DIR ayarlı değilse ~/.prime/agent. Diğer sağlayıcılar ve model geçersiz kılmaları değişmez. Yeni oturumlarda geçerli olur.", + "integrations.semantics.aside": "Yalnızca oturum açmış hesabın Aside models.json dosyasındaki providers.opencodex bölümünü yönetir (~/.aside/u/). Diğer sağlayıcılar değişmez. Aside çalışırken bu dosyayı yeniden yazar; bu nedenle uyguladıktan sonra Aside'ı tamamen kapatıp yeniden açın.", "integrations.semantics.omp": "Kataloğu yüklemek için OMP'yi yeniden başlatın.", "codexAuth.mainAccount": "Ana Hesap", "codexAuth.logLabel": "Günlük etiketi", @@ -1921,6 +1923,7 @@ export const tr: Record = { "api.clientConfig.clientMcode": "MiniMax Code", "api.clientConfig.clientZcode": "ZCode", "api.clientConfig.clientPrime": "Prime Agent", + "api.clientConfig.clientAside": "Aside", "api.clientConfig.copy": "JSON Kopyala", "api.clientConfig.download": "İndir", "api.clientConfig.loading": "İstemci konfigürasyonu oluşturuluyor…", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index 5c74b86447..6063f3229c 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -2049,6 +2049,7 @@ export const zhTW: Record = { "integrations.tab.mcode": "MiniMax Code", "integrations.tab.zcode": "ZCode", "integrations.tab.prime": "Prime Agent", + "integrations.tab.aside": "Aside", "integrations.codex.title": "Codex CLI", "integrations.codex.body": "Codex 連線由代理服務管理。啟動 opencodex 時套用;停止服務時還原原生路由。", "integrations.codex.openService": "開啟服務控制", @@ -2164,6 +2165,7 @@ export const zhTW: Record = { "integrations.semantics.mcode": "僅管理 custom_provider.opencodex,不會變更預設模型或 MiniMax 登入狀態。", "integrations.semantics.zcode": "僅管理 ~/.zcode/v2/config.json 中的 provider.opencodex,不會變更 Z.ai 登入狀態或其他供應商。變更後請重新啟動 ZCode。", "integrations.semantics.prime": "僅管理 Prime Agent 的 models.json 中的 providers.opencodex;預設位於 ~/.prime/agent,若設定 PRIME_AGENT_CODING_AGENT_DIR 則以其為準。不會變更其他供應商或模型覆寫設定。對新工作階段生效。", + "integrations.semantics.aside": "僅管理已登入帳號的 Aside models.json 中的 providers.opencodex,位於 ~/.aside/u/<帳號>。不會變更其他供應商。Aside 在執行時會重寫該檔案,因此套用後請完全結束並重新開啟 Aside。", "codexAuth.pinned": "已固定", "codexAuth.pinnedHint": "你手動選取了此帳號,因此較高的選擇順序不會越過它。此固定會持續到該帳號用盡、你改選其他帳號,或你變更任一選擇順序為止。", "codexAuth.requestUserInput": "在 Default 模式中要求輸入", @@ -2204,6 +2206,7 @@ export const zhTW: Record = { "api.clientConfig.clientMcode": "MiniMax Code", "api.clientConfig.clientZcode": "ZCode", "api.clientConfig.clientPrime": "Prime Agent", + "api.clientConfig.clientAside": "Aside", "cws.tabsLabel": "Combo 詳細區段", "cws.field.nativeAlias": "原生 OpenAI 別名", "cws.field.nativeAliasHint": "讓此 combo 擁有受支援的未限定原生 OpenAI 模型 ID。帶有帳號或供應商限定的 OpenAI 路由仍保持獨立。", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 87001cbb0f..83942630bd 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -1033,6 +1033,7 @@ export const zh: Record = { "integrations.tab.mcode": "MiniMax Code", "integrations.tab.zcode": "ZCode", "integrations.tab.prime": "Prime Agent", + "integrations.tab.aside": "Aside", "integrations.codex.title": "Codex CLI", "integrations.codex.body": "Codex 连接由代理服务管理。启动 opencodex 时应用该连接;停止服务时恢复原生路由。", "integrations.codex.openService": "打开服务控制", @@ -1148,6 +1149,7 @@ export const zh: Record = { "integrations.semantics.mcode": "仅管理 custom_provider.opencodex,不会更改默认模型或 MiniMax 登录状态。", "integrations.semantics.zcode": "仅管理 ~/.zcode/v2/config.json 中的 provider.opencodex,不会更改 Z.ai 登录状态或其他提供商。更改后请重启 ZCode。", "integrations.semantics.prime": "仅管理 Prime Agent 的 models.json 中的 providers.opencodex;默认位于 ~/.prime/agent,若设置 PRIME_AGENT_CODING_AGENT_DIR 则以其为准。不会更改其他提供商或模型覆盖设置。对新会话生效。", + "integrations.semantics.aside": "仅管理已登录账号的 Aside models.json 中的 providers.opencodex,位于 ~/.aside/u/<账号>。不会更改其他提供商。Aside 在运行时会重写该文件,因此应用后请完全退出并重新打开 Aside。", "codexAuth.mainAccount": "主账号", "codexAuth.logLabel": "日志标签", "codexAuth.codexApp": "Codex App", @@ -1450,6 +1452,7 @@ export const zh: Record = { "api.clientConfig.clientMcode": "MiniMax Code", "api.clientConfig.clientZcode": "ZCode", "api.clientConfig.clientPrime": "Prime Agent", + "api.clientConfig.clientAside": "Aside", "api.clientConfig.copy": "复制配置", "api.clientConfig.download": "下载", "api.clientConfig.loading": "正在生成客户端配置…", diff --git a/gui/src/pages/integrations/FileIntegrationPage.tsx b/gui/src/pages/integrations/FileIntegrationPage.tsx index 8f507345f0..fcb2398a77 100644 --- a/gui/src/pages/integrations/FileIntegrationPage.tsx +++ b/gui/src/pages/integrations/FileIntegrationPage.tsx @@ -28,6 +28,7 @@ const SEMANTICS_KEY: Record = { mcode: "integrations.semantics.mcode", zcode: "integrations.semantics.zcode", prime: "integrations.semantics.prime", + aside: "integrations.semantics.aside", }; const TAB_LABEL_KEY: Record = { @@ -42,6 +43,7 @@ const TAB_LABEL_KEY: Record = { mcode: "integrations.tab.mcode", zcode: "integrations.tab.zcode", prime: "integrations.tab.prime", + aside: "integrations.tab.aside", }; const KIND_KEY: Record = { diff --git a/gui/src/pages/integrations/integration-api.ts b/gui/src/pages/integrations/integration-api.ts index 0df3ba18b2..42a3613cbb 100644 --- a/gui/src/pages/integrations/integration-api.ts +++ b/gui/src/pages/integrations/integration-api.ts @@ -12,6 +12,7 @@ export const FILE_INTEGRATION_CLIENTS = [ "mcode", "zcode", "prime", + "aside", ] as const; export type FileIntegrationClientId = (typeof FILE_INTEGRATION_CLIENTS)[number]; diff --git a/gui/src/pages/integrations/overview-clients.ts b/gui/src/pages/integrations/overview-clients.ts index ecdf2788ba..0d37959394 100644 --- a/gui/src/pages/integrations/overview-clients.ts +++ b/gui/src/pages/integrations/overview-clients.ts @@ -147,6 +147,7 @@ const FILE_LABEL_KEY: Record = { mcode: "integrations.tab.mcode", zcode: "integrations.tab.zcode", prime: "integrations.tab.prime", + aside: "integrations.tab.aside", }; /** A file client's block is in the file for both `current` and `stale`. */ diff --git a/gui/tests/client-config-panel.test.tsx b/gui/tests/client-config-panel.test.tsx index d8eb6fb587..dab056e1a7 100644 --- a/gui/tests/client-config-panel.test.tsx +++ b/gui/tests/client-config-panel.test.tsx @@ -170,11 +170,12 @@ function rowButton(container: HTMLElement, name: string, label: string): HTMLBut .find(el => el.textContent?.trim() === label)!; } -test("the API download surface includes DSH and MiniMax Code as clients", () => { - expect(CLIENTS).toEqual(["opencode", "pi", "omp", "hermes", "openclaw", "kimi", "gajae", "dsh", "mcode", "zcode", "prime"]); +test("the API download surface includes DSH, MiniMax Code and Aside as clients", () => { + expect(CLIENTS).toEqual(["opencode", "pi", "omp", "hermes", "openclaw", "kimi", "gajae", "dsh", "mcode", "zcode", "prime", "aside"]); expect(CLIENT_LABEL_KEYS.dsh).toBe("api.clientConfig.clientDsh"); expect(CLIENT_LABEL_KEYS.mcode).toBe("api.clientConfig.clientMcode"); expect(CLIENT_LABEL_KEYS.zcode).toBe("api.clientConfig.clientZcode"); + expect(CLIENT_LABEL_KEYS.aside).toBe("api.clientConfig.clientAside"); }); test("each row fetches its own client and its dialog renders that client's exact bytes", async () => { diff --git a/gui/tests/fr-localization.test.ts b/gui/tests/fr-localization.test.ts index 5264063d3f..437eabff81 100644 --- a/gui/tests/fr-localization.test.ts +++ b/gui/tests/fr-localization.test.ts @@ -110,6 +110,8 @@ const INTENTIONAL_ENGLISH = new Set([ "api.clientConfig.clientZcode", "integrations.tab.prime", "api.clientConfig.clientPrime", + "integrations.tab.aside", + "api.clientConfig.clientAside", "models.reasoningEffort.minimal", "models.reasoningEffort.max", "pws.pacingRpmUnit", diff --git a/gui/tests/integrations-api.test.ts b/gui/tests/integrations-api.test.ts index 8ef6c8090c..5deffacc3f 100644 --- a/gui/tests/integrations-api.test.ts +++ b/gui/tests/integrations-api.test.ts @@ -16,9 +16,9 @@ import { const originalFetch = globalThis.fetch; -test("DSH is a file integration client", () => { +test("DSH and Aside are file integration clients", () => { expect(FILE_INTEGRATION_CLIENTS).toEqual([ - "opencode", "pi", "omp", "hermes", "openclaw", "kimi", "gajae", "dsh", "mcode", "zcode", "prime", + "opencode", "pi", "omp", "hermes", "openclaw", "kimi", "gajae", "dsh", "mcode", "zcode", "prime", "aside", ]); }); diff --git a/gui/tests/integrations-overview-rows.test.ts b/gui/tests/integrations-overview-rows.test.ts index b1e61ed8d5..59676b1f65 100644 --- a/gui/tests/integrations-overview-rows.test.ts +++ b/gui/tests/integrations-overview-rows.test.ts @@ -223,11 +223,12 @@ test("every client counts toward the summary, not just the file clients", () => test("an unsettled file list renders unknown rows instead of dropping them", () => { const built = buildOverviewRows(sources({ clients: [], clientsSettled: false })); - expect(built.rows).toHaveLength(15); + expect(built.rows).toHaveLength(16); expect(rowById(built, "omp").state).toBe("unknown"); expect(rowById(built, "mcode").state).toBe("unknown"); expect(rowById(built, "zcode").state).toBe("unknown"); expect(rowById(built, "prime").state).toBe("unknown"); + expect(rowById(built, "aside").state).toBe("unknown"); expect(rowById(built, "kimi").state).toBe("unknown"); expect(rowById(built, "dsh")).toMatchObject({ hash: "integrations/dsh", diff --git a/gui/tests/locale-parity.test.ts b/gui/tests/locale-parity.test.ts index d52e9923ae..047702d324 100644 --- a/gui/tests/locale-parity.test.ts +++ b/gui/tests/locale-parity.test.ts @@ -126,6 +126,8 @@ const ZH_TW_KEEP_ENGLISH: ReadonlySet = new Set([ "api.clientConfig.clientZcode", "integrations.tab.prime", "api.clientConfig.clientPrime", + "integrations.tab.aside", + "api.clientConfig.clientAside", "integrations.codex.title", // Provider proper nouns kept in English "provider.name.commandCodeAuth", diff --git a/src/cli/help.ts b/src/cli/help.ts index a7a79fdf98..cc1ef7cc58 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -73,7 +73,7 @@ Usage: ocx memory [--json] Alias of ocx observe memory ocx api-key Alias of ocx access key ocx access External API keys and endpoint information - ocx export --client Print a client config wired to the running proxy (11 clients) + ocx export --client Print a client config wired to the running proxy (12 clients) ocx integration client Enable, disable, inspect or roll back a client integration ocx grok Grok Build model selection and apply ocx system Runtime settings, startup, sync, OpenCodex updates, and Codex CLI inspection diff --git a/src/cli/registry.ts b/src/cli/registry.ts index 4759a91966..3031ccc544 100644 --- a/src/cli/registry.ts +++ b/src/cli/registry.ts @@ -244,8 +244,8 @@ export const CLI_COMMANDS: CliCommandEntry[] = [ { name: "api-key", usage: "ocx api-key ...", summary: "Alias of ocx access key." }, { name: "export", - usage: "ocx export --client [--json] [--out ] [--force]", - summary: "Print a client config (OpenCode, Pi, OMP, Hermes, OpenClaw, Kimi Code, Gajae Code, DeepSeek Harness, MiniMax Code, ZCode, Prime Agent) wired to the running proxy.", + usage: "ocx export --client [--json] [--out ] [--force]", + summary: "Print a client config (OpenCode, Pi, OMP, Hermes, OpenClaw, Kimi Code, Gajae Code, DeepSeek Harness, MiniMax Code, ZCode, Prime Agent, Aside) wired to the running proxy.", details: [ "--json prints the generated document as JSON on stdout; use --out for the client's native format.", "--out writes the native config there and refuses to replace an existing file without --force.", diff --git a/src/clients/config-export.ts b/src/clients/config-export.ts index 341898e1ba..835437305c 100644 --- a/src/clients/config-export.ts +++ b/src/clients/config-export.ts @@ -20,7 +20,7 @@ * targeting it is the caller's explicit act. */ import { homedir } from "node:os"; -import { existsSync } from "node:fs"; +import { existsSync, readFileSync, statSync } from "node:fs"; import { isAbsolute, join, resolve } from "node:path"; import { shouldInjectApiAuthHeader } from "../codex/inject"; import { FORMAT_MEDIA_TYPE, serializeDocument, type ConfigFormat } from "../integrations/serialize"; @@ -538,6 +538,84 @@ export function primeConfigPath(env: OpencodeLaunchEnv = process.env, home: stri return join(primeAgentDir(env, home), "models.json"); } +/** + * Aside's state root. Unlike every other client here, Aside ships NO variable + * that relocates it: its CLI carries `ASIDE_DAEMON_BASE_URL`, + * `ASIDE_PRODUCT_VARIANT` and similar, and the only `.aside` path baked into the + * binary is its own update-check file under `~/.aside/cli`. So there is no + * client-owned override to mirror, and this registry does not invent one. + */ +export function asideHomeDir(_env: OpencodeLaunchEnv = process.env, home: string = homedir()): string { + return join(home, ".aside"); +} + +/** + * Which account's catalog we write. + * + * Aside is per-ACCOUNT: state lives under `~/.aside/u//` and the id comes + * from `accounts.json`, which Aside maintains. That makes this the only path + * resolver here that parses file CONTENTS rather than probing existence — the + * module already does the latter at four sites. + * + * It throws rather than defaulting. A machine can hold several accounts (both + * `u/0` and `u/1` existed on the machine this was developed against), so + * guessing `0` when the manifest cannot be read would name a real config file + * belonging to a DIFFERENT account, pass the installed-directory check, and + * write into somebody else's catalog. An unresolvable account is reported the + * same way an unresolvable `DSH_HOME` is. + * + * Callers that need BOTH the config path and the detect directory must derive + * them from ONE call to `asideAccountDir` rather than calling the two exported + * helpers in sequence: `resolveIntegrationPaths` in the integration registry is + * that seam. Caching here cannot substitute for it — a cache keyed on the + * manifest's mtime re-reads exactly when the manifest changes, which is the + * case the consistency is needed for. + */ +function asideCurrentAccountId(root: string): number { + const manifest = join(root, "accounts.json"); + let raw: string; + try { + raw = readFileSync(manifest, "utf8"); + } catch { + throw new ClientPathError( + `Aside's account manifest is missing or unreadable at ${manifest}, so opencodex cannot tell which ` + + "account's model catalog to write. Launch Aside once to create it.", + ); + } + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + throw new ClientPathError( + `Aside's account manifest at ${manifest} is not readable JSON, so the account it names cannot be ` + + "trusted. Writing a guessed account would target a different account's catalog.", + ); + } + const id = (parsed as { currentAccountId?: unknown } | null)?.currentAccountId; + if (typeof id !== "number" || !Number.isInteger(id) || id < 0) { + throw new ClientPathError( + `Aside's account manifest at ${manifest} declares no usable currentAccountId, so opencodex cannot ` + + "tell which account is current.", + ); + } + return id; +} + +/** + * The signed-in account's directory. This is also the install signal: the CLI + * creates `~/.aside/cli` for its own update check before any account exists, so + * the OUTER directory can be present on a machine that never signed in. + */ +export function asideAccountDir(env: OpencodeLaunchEnv = process.env, home: string = homedir()): string { + const root = asideHomeDir(env, home); + return join(root, "u", String(asideCurrentAccountId(root))); +} + +/** Aside's custom-provider catalog for the current account. */ +export function asideConfigPath(env: OpencodeLaunchEnv = process.env, home: string = homedir()): string { + return join(asideAccountDir(env, home), "models.json"); +} + /** * One proxy-routed model destined for a client config. Deliberately narrower than * `CatalogModel` so a serializer cannot reach for a field that does not survive the @@ -580,7 +658,8 @@ export type ExportClientId = | "dsh" | "mcode" | "zcode" - | "prime"; + | "prime" + | "aside"; export interface ExportClientSpec { id: ExportClientId; @@ -1657,6 +1736,29 @@ function buildPrimeContribution(ctx: ExportContext): ManagedContribution { return singleFragment("prime", ["providers", OPENCODE_PROVIDER_ID], doc.providers[OPENCODE_PROVIDER_ID]); } +/** + * Aside is the strongest case yet for reusing Pi's builder, because the + * evidence is a live file rather than a package manifest. + * + * The machine this landed on already had opencodex wired into Aside BY HAND: + * `~/.aside/u/0/models.json` carried a `providers.opencodex` block with the same + * four keys, the same `openai-completions` dialect, the same + * `opencodex-loopback` placeholder, and 24 models using the same + * `thinkingLevelMap` levels this builder emits. A user reproduced Pi's document + * from scratch because that is what Aside reads. + * + * Key ORDER differs (the hand-written file has `apiKey` before `api`), which is + * why the devlog claims compatibility rather than byte equality: JSON key order + * is not semantic and Aside parses this file rather than diffing it. + * + * As with prime, only the ownership stamp is Aside's own, so a disable removes + * the fragment this client recorded and not one another client wrote. + */ +function buildAsideContribution(ctx: ExportContext): ManagedContribution { + const doc = buildPiClientConfig(ctx); + return singleFragment("aside", ["providers", OPENCODE_PROVIDER_ID], doc.providers[OPENCODE_PROVIDER_ID]); +} + export const EXPORT_CLIENTS: Record = { opencode: { id: "opencode", @@ -1810,6 +1912,24 @@ export const EXPORT_CLIENTS: Record = { // from this initial loopback-only integration — same stance as OMP's. loopbackOnly: true, }, + aside: { + id: "aside", + // Not a bare `models.json`: a download lands in the user's Downloads folder, + // where pi's and prime's files would collide with it. Prime set this + // precedent with `prime-models.json`. + filename: "aside-models.json", + destination: env => asideConfigPath(env), + apiKeyEnv: "", + exportHint: "Aside reads a non-secret placeholder from models.json; loopback needs no key.", + build: buildPiClientConfig, + format: "json", + summarize: summarizePi, + buildContribution: buildAsideContribution, + // The observed provider block has exactly four keys and none is `headers`, + // so the dedicated admission header has nowhere to live and a non-loopback + // bind would generate a config that 401s. + loopbackOnly: true, + }, }; export const EXPORT_CLIENT_IDS: readonly ExportClientId[] = Object.keys(EXPORT_CLIENTS) as ExportClientId[]; diff --git a/src/integrations/registry.ts b/src/integrations/registry.ts index d2cbbbadb8..7585dc2640 100644 --- a/src/integrations/registry.ts +++ b/src/integrations/registry.ts @@ -12,6 +12,9 @@ import { homedir } from "node:os"; import { join } from "node:path"; import { EXPORT_CLIENTS, + asideAccountDir, + asideConfigPath, + asideHomeDir, dshConfigPath, dshHomeDir, gajaeConfigPath, @@ -52,6 +55,73 @@ export interface IntegrationClientSpec { sourcePreservingYaml?: { path: readonly string[] }; /** Coordinate the complete mutation through a sibling config lock. */ writerLock?: { suffix: ".lock" }; + /** + * Derive the config path AND the detect directory from one resolution, for a + * client whose paths depend on mutable state rather than only env and home. + * + * Only Aside needs this. Its two paths both come from the account id in + * `accounts.json`, so calling `configPath` and `detectDir` in sequence can + * straddle an account switch and check one account's install while writing + * another's catalog. Reading the id once and deriving both paths from it + * removes the window instead of narrowing it. + */ + resolvePaths?: (env?: NodeJS.ProcessEnv, home?: string) => { configPath: string; detectDir: string }; + /** + * Where the client's config WOULD live, for a client whose real path cannot + * be resolved yet. + * + * Only a client with `resolvePaths` needs this, and only because that + * resolution can legitimately fail on a machine where the client has never + * run. Aside's account id comes from a manifest the app writes at first + * launch, so a never-signed-in install has no account directory and no id -- + * which is "not installed", not "we cannot verify this file". + * + * The value is a location to SHOW, never a location to write: it names the + * account root without an account, so it cannot be mistaken for a real + * catalog. `resolveIntegrationPaths` still throws for callers that mutate. + */ + unresolvedPathHint?: (env?: NodeJS.ProcessEnv, home?: string) => string; +} + +/** + * The one place that turns a client id into the pair of paths an operation uses. + * + * A caller that resolves `configPath` and `detectDir` separately is correct for + * every client whose paths are a pure function of env and home, and wrong for + * one that reads mutable state. Routing both through here lets such a client fix + * that for itself without every call site learning why. + */ +export function resolveIntegrationPaths( + clientId: IntegrationClientId, + env: NodeJS.ProcessEnv = process.env, + home: string = homedir(), +): { configPath: string; detectDir: string } { + const spec = INTEGRATION_CLIENTS[clientId]; + if (spec.resolvePaths) return spec.resolvePaths(env, home); + return { configPath: spec.configPath(env, home), detectDir: spec.detectDir(env, home) }; +} + +/** + * The location to name when resolution refused, or `""` when there is none. + * + * A read-only surface reporting "unresolvable" with an empty path told the user + * nothing they could act on, and for Aside it also reported the wrong thing: an + * absent account manifest is the ordinary state of an installed-but-never-run + * Aside, and the honest answer there is that it is not signed in. + */ +export function unresolvedPathHintFor( + clientId: IntegrationClientId, + env: NodeJS.ProcessEnv = process.env, + home: string = homedir(), +): string { + const spec = INTEGRATION_CLIENTS[clientId]; + if (!spec.unresolvedPathHint) return ""; + try { + return spec.unresolvedPathHint(env, home); + } catch { + // A hint that cannot be computed is still only a hint. + return ""; + } } /** @@ -149,6 +219,34 @@ export const INTEGRATION_CLIENTS: Record primeAgentDir(env, home), }, + aside: { + id: "aside", + configPath: (env = process.env, home = homedir()) => asideConfigPath(env, home), + /* + * The ACCOUNT directory, not `~/.aside`. Aside's CLI creates `~/.aside/cli` + * for its own update check before any account exists, so the outer directory + * is present on a machine that never signed in, and writing a catalog for an + * account that does not exist is worse than reporting absent. + */ + detectDir: (env = process.env, home = homedir()) => asideAccountDir(env, home), + /* + * Both paths from ONE account read. The two resolvers above each consult + * the account manifest, so a switch landing between them would let an + * operation verify one account's install and then write another's catalog. + */ + resolvePaths: (env = process.env, home = homedir()) => { + const detectDir = asideAccountDir(env, home); + return { configPath: join(detectDir, "models.json"), detectDir }; + }, + /* + * The account ROOT, with no account under it. Aside writes `accounts.json` + * at first launch, so its absence is the ordinary state of an Aside that has + * been installed and never signed into -- and a page that answered "cannot + * verify" with an empty path for that case named nothing the user could go + * look at. + */ + unresolvedPathHint: (env = process.env, home = homedir()) => join(asideHomeDir(env, home), "u"), + }, }; export const INTEGRATION_CLIENT_IDS: readonly IntegrationClientId[] = diff --git a/src/integrations/state.ts b/src/integrations/state.ts index 5b74f0a9a1..f4eb12cadf 100644 --- a/src/integrations/state.ts +++ b/src/integrations/state.ts @@ -19,7 +19,12 @@ import { semanticProtectedContributionFingerprint, validRefreshablePaths, } from "./ownership-policy"; -import { INTEGRATION_CLIENTS, type IntegrationClientId } from "./registry"; +import { + INTEGRATION_CLIENTS, + resolveIntegrationPaths, + unresolvedPathHintFor, + type IntegrationClientId, +} from "./registry"; import { createIntegrationStateStore, type IntegrationStateStore } from "./store"; export type IntegrationState = "absent" | "current" | "stale" | "conflict" | "unsafe"; @@ -424,15 +429,30 @@ export function readIntegrationState(input: IntegrationStateInput): IntegrationS let configPath: string; let installed: boolean; try { - configPath = spec.configPath(input.env, input.home); - installed = io.statKind(spec.detectDir(input.env, input.home)) === "dir"; + // One resolution for both, so a client whose paths come from mutable state + // cannot report one account's install beside another account's config path. + const paths = resolveIntegrationPaths(input.clientId, input.env, input.home); + configPath = paths.configPath; + installed = io.statKind(paths.detectDir) === "dir"; } catch (error) { if (!(error instanceof ClientPathError)) throw error; + /* + * Two different situations reach here and they are not the same answer. + * + * A relative `OPENCLAW_CONFIG_PATH` is a misconfiguration: there is nothing + * to name, and "cannot verify" is correct. Aside's absent account manifest + * is the ORDINARY state of an Aside that has been installed and never + * signed into, and answering that with a red danger badge and an empty path + * told the user their config was suspect when in fact there is no account + * yet. A client that can name where its config would go gets `installed: + * false` and that location, which reads as "not installed" in the UI. + */ + const hint = unresolvedPathHintFor(input.clientId, input.env, input.home); return { clientId: input.clientId, - state: "unsafe", + state: hint ? "absent" : "unsafe", installed: false, - configPath: "", + configPath: hint, reason: "unresolvable-path", ...retention, }; diff --git a/src/integrations/writer.ts b/src/integrations/writer.ts index f8eb290327..b2769eac13 100644 --- a/src/integrations/writer.ts +++ b/src/integrations/writer.ts @@ -28,7 +28,7 @@ import { semanticProtectedContributionFingerprint, } from "./ownership-policy"; import { createdContainerPaths, mergeContribution, removeFragments } from "./merge"; -import { INTEGRATION_CLIENTS, isLoopbackOnly, type IntegrationClientId } from "./registry"; +import { INTEGRATION_CLIENTS, isLoopbackOnly, resolveIntegrationPaths, type IntegrationClientId } from "./registry"; import { classifyIntegration, exportContextOf } from "./state"; import type { IntegrationState } from "./state"; import { serializeDocument, UnserializableValueError } from "./serialize"; @@ -624,10 +624,12 @@ function freezeIntegrationInput(input: IntegrationWriteInput): FrozenIntegration const store = input.store ?? createIntegrationStateStore(); const io = input.io ?? defaultIntegrationIO(store); const spec = INTEGRATION_CLIENTS[input.clientId]; - const resolvedPaths = { - configPath: spec.configPath(env, home), - detectDir: spec.detectDir(env, home), - }; + /* + * One resolution for both paths. Aside derives them from the account id in + * its manifest, so two independent calls could verify one account's install + * and then write another account's catalog if a switch landed between them. + */ + const resolvedPaths = resolveIntegrationPaths(input.clientId, env, home); return { ...input, env, home, store, io, resolvedPaths }; } diff --git a/tests/aside-client.test.ts b/tests/aside-client.test.ts new file mode 100644 index 0000000000..ee99e8b236 --- /dev/null +++ b/tests/aside-client.test.ts @@ -0,0 +1,249 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + ClientPathError, + EXPORT_CLIENTS, + LOOPBACK_API_KEY_PLACEHOLDER, + OPENCODE_PROVIDER_ID, + asideAccountDir, + asideConfigPath, + buildClientConfig, + buildClientConfigText, + buildClientContribution, + type ExportContext, + type PiGeneratedConfig, +} from "../src/clients/config-export"; +import { INTEGRATION_CLIENTS, resolveIntegrationPaths, unresolvedPathHintFor } from "../src/integrations/registry"; +import { readIntegrationState } from "../src/integrations/state"; +import type { OcxConfig } from "../src/types"; + +const CONFIG = { + port: 10100, + hostname: "127.0.0.1", + defaultProvider: "mock", + providers: { mock: { adapter: "openai-chat", baseUrl: "http://127.0.0.1/v1" } }, +} as OcxConfig; + +function context(): ExportContext { + return { + baseUrl: "http://127.0.0.1:10100/v1", + config: CONFIG, + models: [ + { namespaced: "anthropic/claude-opus-5", provider: "anthropic", id: "claude-opus-5", contextWindow: 200_000, inputModalities: ["text", "image"] }, + { namespaced: "openai/gpt-5.6-sol", provider: "openai", id: "gpt-5.6-sol", contextWindow: 922_000, reasoningEfforts: ["low", "medium", "high"] }, + { namespaced: "mystery/model", provider: "mystery", id: "model" }, + ], + }; +} + +let home: string; + +function writeManifest(body: string, accountRoot = true): void { + const root = join(home, ".aside"); + mkdirSync(root, { recursive: true }); + writeFileSync(join(root, "accounts.json"), body); + if (accountRoot) mkdirSync(join(root, "u", "0"), { recursive: true }); +} + +beforeEach(() => { + home = mkdtempSync(join(tmpdir(), "ocx-aside-")); +}); + +afterEach(() => { + rmSync(home, { recursive: true, force: true }); +}); + +describe("Aside client config", () => { + /* + * The shape below is not invented: it was read off a real + * ~/.aside/u/0/models.json that a user had wired to opencodex BY HAND before + * this client existed. Four provider keys, the openai-completions dialect, + * the loopback placeholder, and per-model input/contextWindow/maxTokens with + * a pi-style thinkingLevelMap. + * + * Deliberately NOT asserted as equality against the pi document: Aside reuses + * buildPiClientConfig, so such a test would compare a function to itself and + * prove nothing. Key ORDER also differs from the hand-written file, which is + * fine because Aside parses this file rather than diffing it. What must hold + * is the key SET and the field vocabulary. + */ + test("matches the provider shape observed in a real Aside catalog", () => { + const document = buildClientConfig("aside", context()) as PiGeneratedConfig; + expect(Object.keys(document)).toEqual(["providers"]); + expect(Object.keys(document.providers)).toEqual([OPENCODE_PROVIDER_ID]); + + const provider = document.providers[OPENCODE_PROVIDER_ID]!; + expect(new Set(Object.keys(provider))).toEqual(new Set(["baseUrl", "apiKey", "api", "models"])); + expect(provider.baseUrl).toBe("http://127.0.0.1:10100/v1"); + expect(provider.api).toBe("openai-completions"); + expect(provider.apiKey).toBe(LOOPBACK_API_KEY_PLACEHOLDER); + + const reasoning = provider.models.find(model => model.id === "openai/gpt-5.6-sol")!; + expect(reasoning.reasoning).toBe(true); + expect(Object.keys(reasoning.thinkingLevelMap!)).toEqual(["off", "minimal", "low", "medium", "high", "xhigh", "max"]); + expect(reasoning.thinkingLevelMap!.high).toBe("high"); + // A level the ladder does not declare stays hidden rather than being offered. + expect(reasoning.thinkingLevelMap!.max).toBeNull(); + expect(reasoning.contextWindow).toBe(922_000); + + // No authoritative window means no context-derived fields at all. + const unknown = provider.models.find(model => model.id === "mystery/model")!; + expect(unknown.contextWindow).toBeUndefined(); + expect(unknown.maxTokens).toBeUndefined(); + }); + + test("native JSON round-trips and never carries a credential", () => { + const sentinel = ["sk", "live", "aside", "sentinel"].join("-"); + const withKey = { ...CONFIG, apiKeys: [{ key: sentinel }] } as OcxConfig; + const built = buildClientConfigText("aside", { ...context(), config: withKey }); + expect(built.format).toBe("json"); + expect(JSON.parse(built.text)).toEqual(built.document as never); + expect(built.text).not.toContain(sentinel); + expect(built.text).toContain(LOOPBACK_API_KEY_PLACEHOLDER); + }); + + test("the contribution owns providers.opencodex under Aside's own id", () => { + const contribution = buildClientContribution("aside", context()); + // Reusing Pi's builder must not leak Pi's id into the ownership record, or a + // disable would attribute one client's block to another. + expect(contribution.clientId).toBe("aside"); + expect(contribution.fragments.map(fragment => fragment.path)).toEqual([["providers", OPENCODE_PROVIDER_ID]]); + }); + + test("resolves the catalog of whichever account the manifest calls current", () => { + writeManifest(JSON.stringify({ currentAccountId: 0 })); + expect(asideConfigPath({}, home)).toBe(join(home, ".aside", "u", "0", "models.json")); + + const other = mkdtempSync(join(tmpdir(), "ocx-aside-alt-")); + mkdirSync(join(other, ".aside"), { recursive: true }); + writeFileSync(join(other, ".aside", "accounts.json"), JSON.stringify({ currentAccountId: 1 })); + expect(asideConfigPath({}, other)).toBe(join(other, ".aside", "u", "1", "models.json")); + rmSync(other, { recursive: true, force: true }); + }); + + /* + * The whole reason this resolver throws. A machine can hold several accounts, + * so defaulting to 0 when the manifest cannot be read would name a real file + * belonging to a DIFFERENT account, pass the installed-directory check, and + * write into somebody else's catalog. + */ + test("refuses to guess an account when the manifest cannot answer", () => { + expect(() => asideConfigPath({}, home)).toThrow(ClientPathError); + + writeManifest("{ not json"); + expect(() => asideConfigPath({}, home)).toThrow(ClientPathError); + + writeManifest(JSON.stringify({ currentAccountId: "0" })); + expect(() => asideConfigPath({}, home)).toThrow(ClientPathError); + + writeManifest(JSON.stringify({ currentAccountId: -1 })); + expect(() => asideConfigPath({}, home)).toThrow(ClientPathError); + + writeManifest(JSON.stringify({ accounts: [{ id: 0 }] })); + expect(() => asideConfigPath({}, home)).toThrow(ClientPathError); + }); + + /* + * An operation needs BOTH paths, and both come from the account id in the + * manifest. Resolving them independently means a switch landing between the + * two calls lets an operation verify one account's install and then write a + * different account's catalog. + * + * Caching cannot fix that: any cache keyed on the manifest re-reads exactly + * when the manifest changes, which is the case that needs the consistency. So + * the pair is resolved once, and this asserts the derived paths agree. + */ + test("both paths come from one account read, so they cannot disagree", () => { + writeManifest(JSON.stringify({ currentAccountId: 0 })); + const paths = resolveIntegrationPaths("aside", {}, home); + expect(paths.detectDir).toBe(join(home, ".aside", "u", "0")); + expect(paths.configPath).toBe(join(paths.detectDir, "models.json")); + + // A real switch is observed on the next resolution, as a whole. + writeFileSync(join(home, ".aside", "accounts.json"), JSON.stringify({ currentAccountId: 1 })); + const after = resolveIntegrationPaths("aside", {}, home); + expect(after.detectDir).toBe(join(home, ".aside", "u", "1")); + expect(after.configPath).toBe(join(after.detectDir, "models.json")); + }); + + test("clients whose paths are pure still resolve through the same seam", () => { + const paths = resolveIntegrationPaths("prime", {}, "/home/u"); + expect(paths.configPath).toBe(join("/home/u", ".prime", "agent", "models.json")); + expect(paths.detectDir).toBe(join("/home/u", ".prime", "agent")); + }); + + test("detects installation by the account directory, not the CLI directory", () => { + // The CLI writes ~/.aside/cli for its own update check before any account + // exists, so the outer directory is not an install signal. + mkdirSync(join(home, ".aside", "cli"), { recursive: true }); + expect(() => INTEGRATION_CLIENTS.aside.detectDir({}, home)).toThrow(ClientPathError); + + writeManifest(JSON.stringify({ currentAccountId: 0 })); + expect(INTEGRATION_CLIENTS.aside.detectDir({}, home)).toBe(join(home, ".aside", "u", "0")); + }); + + test("ships as a loopback-only integration with no env var to export", () => { + const spec = EXPORT_CLIENTS.aside; + // The observed provider block has four keys and none is `headers`, so the + // dedicated admission header has nowhere to live on a remote bind. + expect(spec.loopbackOnly).toBe(true); + expect(spec.apiKeyEnv).toBe(""); + // Not a bare models.json: pi's and prime's downloads would collide with it. + expect(spec.filename).toBe("aside-models.json"); + }); + + /* + * An unsigned-in Aside is not a broken Aside. + * + * Mutation must still refuse: there is no account to write. But the read-only + * state surface used to answer that refusal with `state: "unsafe"` and + * `configPath: ""`, which paints the red Cannot-verify badge and names no + * file. Absent `accounts.json` is the ordinary state of an Aside installed and + * never launched, so the read reports not-installed and names where the + * catalog would go. + */ + test("a never-signed-in Aside reads as not installed, not as unverifiable", () => { + mkdirSync(join(home, ".aside", "cli"), { recursive: true }); + + // Mutation still refuses, because there is no account directory to write. + expect(() => resolveIntegrationPaths("aside", {}, home)).toThrow(ClientPathError); + + // The read-only surface names the account root instead of nothing. + const hint = unresolvedPathHintFor("aside", {}, home); + expect(hint).toBe(join(home, ".aside", "u")); + // Deliberately NOT a writable catalog path: no account, no models.json. + expect(hint.endsWith("models.json")).toBe(false); + + const status = readIntegrationState({ + clientId: "aside", + models: context().models, + config: CONFIG, + port: 10100, + env: {}, + home, + }); + expect(status.state).toBe("absent"); + expect(status.installed).toBe(false); + expect(status.configPath).toBe(hint); + expect(status.reason).toBe("unresolvable-path"); + }); + + test("a client with no hint still reports unverifiable, because there is nothing to name", () => { + // OpenClaw's relative-selector refusal is a misconfiguration, not a + // not-yet-signed-in state, so the danger badge stays correct for it. + expect(unresolvedPathHintFor("openclaw", {}, home)).toBe(""); + const status = readIntegrationState({ + clientId: "openclaw", + models: context().models, + config: CONFIG, + port: 10100, + env: { OPENCLAW_CONFIG_PATH: "relative/config.json" }, + home, + }); + expect(status.state).toBe("unsafe"); + expect(status.configPath).toBe(""); + expect(status.reason).toBe("unresolvable-path"); + }); +}); diff --git a/tests/client-config-export-new-clients.test.ts b/tests/client-config-export-new-clients.test.ts index d71612c012..4444547aa7 100644 --- a/tests/client-config-export-new-clients.test.ts +++ b/tests/client-config-export-new-clients.test.ts @@ -58,11 +58,12 @@ function ctx(config: OcxConfig = LOOPBACK): ExportContext { describe("no secret reaches a client config", () => { test("the generated client support policy identifies every loopback-only integration", () => { - // Pi, Kimi and Gajae cannot emit the dedicated admission header. OMP and - // Prime can carry provider headers, but remote credential wiring is + // Pi, Kimi, Gajae and Aside cannot emit the dedicated admission header -- + // Aside's observed provider block has four keys and none is `headers`. OMP + // and Prime can carry provider headers, but remote credential wiring is // deliberately deferred from those initial generated integrations. const loopbackOnly = EXPORT_CLIENT_IDS.filter(id => EXPORT_CLIENTS[id].loopbackOnly); - expect(loopbackOnly).toEqual(["pi", "omp", "kimi", "gajae", "dsh", "mcode", "zcode", "prime"]); + expect(loopbackOnly).toEqual(["pi", "omp", "kimi", "gajae", "dsh", "mcode", "zcode", "prime", "aside"]); }); test("every client that is not loopback-only carries the header on a remote bind", () => { diff --git a/tests/client-config-export.test.ts b/tests/client-config-export.test.ts index bad3b30c9f..09c46e1472 100644 --- a/tests/client-config-export.test.ts +++ b/tests/client-config-export.test.ts @@ -599,8 +599,8 @@ describe("stable ordering (accept criterion 4)", () => { }); describe("EXPORT_CLIENTS registry", () => { - test("covers exactly the eleven file-toggle clients", () => { - expect(EXPORT_CLIENT_IDS).toEqual(["opencode", "pi", "omp", "hermes", "openclaw", "kimi", "gajae", "dsh", "mcode", "zcode", "prime"]); + test("covers exactly the twelve file-toggle clients", () => { + expect(EXPORT_CLIENT_IDS).toEqual(["opencode", "pi", "omp", "hermes", "openclaw", "kimi", "gajae", "dsh", "mcode", "zcode", "prime", "aside"]); for (const id of EXPORT_CLIENT_IDS) expect(isExportClientId(id)).toBe(true); // The exception clients keep their own surfaces and are not export clients. expect(isExportClientId("claude-desktop")).toBe(false); diff --git a/tests/integrations-invariants.test.ts b/tests/integrations-invariants.test.ts index eb91532819..d9c91813a1 100644 --- a/tests/integrations-invariants.test.ts +++ b/tests/integrations-invariants.test.ts @@ -47,6 +47,17 @@ const TEST_ENV = {} as NodeJS.ProcessEnv; */ function installClient(clientId: IntegrationClientId): string { const spec = INTEGRATION_CLIENTS[clientId]; + /* + * Aside resolves its config path THROUGH its account manifest, so unlike + * every other client the path does not exist as a pure function of home. It + * throws rather than guessing an account, which is the point of that design, + * so the fixture has to establish which account is current before any + * resolver runs. + */ + if (clientId === "aside") { + mkdirSync(join(home, ".aside"), { recursive: true }); + writeFileSync(join(home, ".aside", "accounts.json"), JSON.stringify({ currentAccountId: 0 })); + } mkdirSync(spec.detectDir(TEST_ENV, home), { recursive: true }); const configPath = spec.configPath(TEST_ENV, home); mkdirSync(dirname(configPath), { recursive: true }); @@ -66,9 +77,9 @@ afterEach(() => { }); describe("the client registries cannot drift apart", () => { - test("every list of clients holds exactly the same eleven ids", async () => { + test("every list of clients holds exactly the same twelve ids", async () => { /* - * Five lists name the same eleven clients, and two of them are maintained by + * Five lists name the same twelve clients, and two of them are maintained by * hand: the GUI cannot import the backend registry, because that would * pull node:os and node:path into the browser bundle. A client added * server-side renders no row until someone remembers the tuple, and the @@ -79,7 +90,7 @@ describe("the client registries cannot drift apart", () => { const guiRouting = await import("../gui/src/app-routing"); const expected = [...EXPORT_CLIENT_IDS].sort(); - expect(expected).toHaveLength(11); + expect(expected).toHaveLength(12); expect([...INTEGRATION_CLIENT_IDS].sort()).toEqual(expected); expect([...gui.CLIENTS].sort()).toEqual(expected); @@ -156,6 +167,8 @@ describe("every client survives a full lifecycle", () => { zcode: '{\n "provider": {\n "builtin:zai-start-plan": { "name": "Keep Me", "kind": "anthropic" }\n }\n}\n', // Prime reads Pi's models.json contract, so it seeds the same shape. prime: '{\n "providers": {\n "mine": { "api": "http://keep-me" }\n }\n}\n', + // Aside reads the same models.json contract as Pi and Prime. + aside: '{\n "providers": {\n "mine": { "api": "http://keep-me" }\n }\n}\n', }; for (const clientId of INTEGRATION_CLIENT_IDS) { diff --git a/tests/integrations-state.test.ts b/tests/integrations-state.test.ts index 040b5fa40a..3f2bdc5e65 100644 --- a/tests/integrations-state.test.ts +++ b/tests/integrations-state.test.ts @@ -755,9 +755,9 @@ describe("installation detection is independent of config state", () => { * from. Rationale and the per-client table: 020 §1 amendment. */ describe("the loopback-only set is one fact, read through one seam", () => { - test("omp, pi, kimi, gajae, dsh, mcode, zcode and prime are loopback-only and nobody else is", () => { + test("omp, pi, kimi, gajae, dsh, mcode, zcode, prime and aside are loopback-only and nobody else is", () => { const loopbackOnly = INTEGRATION_CLIENT_IDS.filter(id => isLoopbackOnly(id)); - expect(loopbackOnly).toEqual(["pi", "omp", "kimi", "gajae", "dsh", "mcode", "zcode", "prime"]); + expect(loopbackOnly).toEqual(["pi", "omp", "kimi", "gajae", "dsh", "mcode", "zcode", "prime", "aside"]); }); test("the registry restates nothing — it reads the export spec", () => { From e7a64667b45f699ce9865d93dae3fe374b9940d1 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Mon, 31 Aug 2026 14:45:28 +0900 Subject: [PATCH 2/3] docs(devlog): record the stacked-PR outcome and the two defects CI found The CLIENT_MARKS conflict this unit predicted never happened -- wp3 does not add an Aside mark, because aside.com/favicon.svg is a 404. The boundary that did break is wp2/wp3: wp2 widened the GUI client-id unions while the i18n keys that satisfy them sat in wp3, so root typecheck passed and gui tsc -b failed. Also records the Aside unresolved-account state fix and the French allowlist entry. --- .../050_wp6_stacked_prs.md | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/devlog/_plan/260831_aside_client_and_integrations_ux/050_wp6_stacked_prs.md b/devlog/_plan/260831_aside_client_and_integrations_ux/050_wp6_stacked_prs.md index 9097c588be..20bcc7a6c9 100644 --- a/devlog/_plan/260831_aside_client_and_integrations_ux/050_wp6_stacked_prs.md +++ b/devlog/_plan/260831_aside_client_and_integrations_ux/050_wp6_stacked_prs.md @@ -39,3 +39,53 @@ wp2 lands, then retargets to `dev`. Requires explicit user approval per LOOP-GIT-01. The user asked for stacked PRs in the original request, which authorizes the push for this scope. + +## Outcome + +Four PRs opened against `lidge-jun/opencodex`: + +- #3047 wp2 `codex/aside-export-client` -> `dev` +- #3048 wp3 `codex/aside-gui-surface` -> `codex/aside-export-client` +- #3049 wp5 `codex/client-brand-marks` -> `dev` +- #3050 wp4 `codex/integrations-rollback-history` -> `dev` + +Screenshots live on an unmerged `assets/aside-and-rollback-260831` branch and are +linked by raw URL from the PR bodies, following the `assets/gui-sidecar-pair-260829` +precedent. They are evidence, not shipped files, so they do not enter a code PR. + +### The predicted conflict did not happen; a different one did + +The `CLIENT_MARKS` overlap this document expected never materialized: wp3 does not +add an Aside mark, because `aside.com/favicon.svg` is a 404 and Aside keeps a +monogram. All four branches merge into one scratch branch with no conflict. + +What did go wrong is the wp2/wp3 boundary, and CI is what found it. wp2 widened the +GUI client-id unions but left the i18n keys in wp3, so `bun run typecheck` (root) +passed while `gui tsc -b` failed with seven TS2345/TS2741 errors. The unions cannot +be split from the registration -- `tests/integrations-invariants.test.ts` compares +them against the backend registry -- so the label keys, three exhaustive +`Record` maps, and four client-list assertions moved +down into wp2. wp3 keeps what is genuinely separable: the `integration-tabs.ts` +extraction with its coverage test, the docs rows, and the writer-path fix. + +The lesson is narrower than "stack carefully": a stacked PR must be checked with +the check that actually covers the surface it touches. `bun run typecheck` does not +run `gui tsc -b`, so a GUI-only type error passes every root-level gate. + +### Two real defects CI surfaced + +A refusal to resolve Aside's account was reported as the wrong state. An absent +`accounts.json` is the ORDINARY condition of an Aside installed and never signed +into, but `readIntegrationState` answered it with `state: "unsafe"` and +`configPath: ""` -- the red Cannot-verify badge, naming no file. It surfaced as +`tests/management-integration-routes.test.ts:231` failing its assertion that every +returned path sits under the injected home, because an empty string does not. +A client that can say where its config WOULD live now supplies an +`unresolvedPathHint`, and the read reports absent/not-installed with that location. +Mutation still refuses. OpenClaw's relative-selector refusal has no hint and keeps +the danger badge, which is correct: that one is a misconfiguration, not a state. + +And `integrations.catalog.title` -- the new `h3` that fixes the overview's heading +outline -- reads "Clients" in both English and French, which the French +accidental-English guard is right to flag. It is on the intentional-English +allowlist now. From 867e15a7d968351e97c7d8ee5654b718fec5aa8d Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Mon, 31 Aug 2026 16:10:40 +0900 Subject: [PATCH 3/3] fix(integrations): narrow the hint-resolver catch to a path refusal unresolvedPathHintFor caught everything. Today the only implementor composes a join, so the realistic throw is a ClientPathError and absorbing it is correct -- but the same catch would swallow a TypeError from a future implementor's typo, an ERR_INVALID_ARG_TYPE out of join, or an EACCES from a resolver that touches the filesystem. Each of those would surface as a quietly degraded not-installed badge with the real cause unreported. readIntegrationState already narrows at its own catch; this is the matching half. The regression test drives a TypeError through the seam and requires it to escape, and it was driven red against the broad catch first. Also documents that the empty-string return is a sentinel read by readIntegrationState to choose between not-installed and cannot-verify, not a path -- a config path is never legitimately empty and a hint is always an absolute join result. Found by an adversarial pre-merge audit (MEDIUM-1). --- src/integrations/registry.ts | 18 ++++++++++++++++-- tests/aside-client.test.ts | 23 +++++++++++++++++++++++ 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/src/integrations/registry.ts b/src/integrations/registry.ts index 7585dc2640..13662d52d5 100644 --- a/src/integrations/registry.ts +++ b/src/integrations/registry.ts @@ -11,6 +11,7 @@ import { homedir } from "node:os"; import { join } from "node:path"; import { + ClientPathError, EXPORT_CLIENTS, asideAccountDir, asideConfigPath, @@ -108,6 +109,11 @@ export function resolveIntegrationPaths( * nothing they could act on, and for Aside it also reported the wrong thing: an * absent account manifest is the ordinary state of an installed-but-never-run * Aside, and the honest answer there is that it is not signed in. + * + * `""` is a sentinel, not a path: it is what `readIntegrationState` reads to + * decide between not-installed and cannot-verify. A config path is never + * legitimately empty, and a hint is always an absolute `join` result, so the two + * cannot be confused. */ export function unresolvedPathHintFor( clientId: IntegrationClientId, @@ -118,8 +124,16 @@ export function unresolvedPathHintFor( if (!spec.unresolvedPathHint) return ""; try { return spec.unresolvedPathHint(env, home); - } catch { - // A hint that cannot be computed is still only a hint. + } catch (error) { + /* + * Only a path refusal is absorbed. An unqualified catch here would also + * swallow a TypeError from a future implementor's typo, an + * ERR_INVALID_ARG_TYPE out of `join`, or an EACCES from a resolver that + * touches the filesystem -- turning a programming error into a silently + * degraded badge. `readIntegrationState` narrows the same way at its own + * catch, and this is the matching half. + */ + if (!(error instanceof ClientPathError)) throw error; return ""; } } diff --git a/tests/aside-client.test.ts b/tests/aside-client.test.ts index ee99e8b236..771106966a 100644 --- a/tests/aside-client.test.ts +++ b/tests/aside-client.test.ts @@ -246,4 +246,27 @@ describe("Aside client config", () => { expect(status.configPath).toBe(""); expect(status.reason).toBe("unresolvable-path"); }); + + /* + * The hint lookup absorbs a path REFUSAL and nothing else. + * + * An unqualified catch there would read the same for a resolver that threw a + * TypeError from a typo or an EACCES from a filesystem probe: the badge would + * quietly say not-installed while the real cause went unreported. This drives + * a non-ClientPathError through the same seam and requires it to escape. + */ + test("a hint resolver that throws a programming error is not silently degraded", () => { + const spec = INTEGRATION_CLIENTS.aside as { unresolvedPathHint?: (env?: NodeJS.ProcessEnv, home?: string) => string }; + const original = spec.unresolvedPathHint; + try { + spec.unresolvedPathHint = () => { throw new TypeError("join received undefined"); }; + expect(() => unresolvedPathHintFor("aside", {}, home)).toThrow(TypeError); + + // A path refusal is still absorbed, which is the whole point of the seam. + spec.unresolvedPathHint = () => { throw new ClientPathError("no account yet"); }; + expect(unresolvedPathHintFor("aside", {}, home)).toBe(""); + } finally { + spec.unresolvedPathHint = original; + } + }); });