Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude/rules/frontend.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ parts of `packages/presentation/ui` (`chat`/`shell`) and `packages/client/workbe
- **coss-ui first.** Reach for `Card`/`CardFrame`/`CardPanel` instead of hand-writing borders/padding; `Field`+`FieldLabel`+`FieldDescription`+`Input` instead of custom inputs; `Button`, `Sidebar`, `Tabs`, `Combobox`, `Empty`, `Skeleton`, `Badge`, etc. Compose with `render={<Link to="…" />}` where coss-ui supports it. Only hand-roll when no primitive exists.
- **Never edit `coss-ui` source.** If you must customize, "fork" by copying the minimal needed implementation into the consuming package, reusing coss-ui exports as much as possible.
- **lucide icons: import the `Icon`-suffixed variant** (`SearchIcon`, not `Search`).
- **Brand / agent icons: `@proj-airi/lobe-icons` via `unplugin-icons`** — chosen over `simple-icons`, which at adoption time (2026-07) lacked usable `claudecode`/`opencode` glyphs after trademark removals. Adding any `~icons/*` import is a four-point wiring or it silently fails an icon, breaks vitest, or fails lint: (1) register the `Icons({ compiler: 'jsx', jsx: 'react', customCollections: ExternalPackageIconLoader('@proj-airi/lobe-icons') })` plugin in **all three** configs — `apps/webview/vite.config.ts`, `apps/desktop/vite.renderer.config.ts`, and root `vitest.config.ts` (miss the last and any test transitively importing the module fails to load); (2) add `/// <reference types="unplugin-icons/types/react" />` at the top of every source file that imports `~icons`; (3) add that file to the eslint override that ignores `^~icons/` for `import-x/no-unresolved`; (4) keep `unplugin-icons` / `@svgr` / `@proj-airi/lobe-icons` in the pnpm catalog. lobe-icons names are lowercase, no separator (`claudecode`/`codex`/`opencode`); there is no `pi` glyph (it falls back to initials).
- **Brand / agent icons: `@proj-airi/lobe-icons` via `unplugin-icons`** — chosen over `simple-icons` for AI agent/model brands, which at adoption time (2026-07) lacked usable `claudecode`/`opencode` glyphs after trademark removals. Third-party integration glyphs (MCP servers: Linear, Slack, GitHub, …) use static `~icons/simple-icons/*` imports backed by `@iconify-json/simple-icons` instead (`chat/integration-brand.tsx` is the one map) — lobe-icons stays the agent/model collection. Adding any `~icons/*` import is a four-point wiring or it silently fails an icon, breaks vitest, or fails lint: (1) register the `Icons({ compiler: 'jsx', jsx: 'react', customCollections: ExternalPackageIconLoader('@proj-airi/lobe-icons') })` plugin in **all three** configs — `apps/webview/vite.config.ts`, `apps/desktop/vite.renderer.config.ts`, and root `vitest.config.ts` (miss the last and any test transitively importing the module fails to load); (2) add `/// <reference types="unplugin-icons/types/react" />` at the top of every source file that imports `~icons`; (3) add that file to the eslint override that ignores `^~icons/` for `import-x/no-unresolved`; (4) keep `unplugin-icons` / `@svgr` / `@proj-airi/lobe-icons` in the pnpm catalog. lobe-icons names are lowercase, no separator (`claudecode`/`codex`/`opencode`); there is no `pi` glyph (it falls back to initials).
- File-identity icons use static `~icons/material-icon-theme/*` imports backed by `@iconify-json/material-icon-theme`; declare the icon set in the root, webview, and desktop build packages. Use it only for a specific language, tool, brand, or branded file format. Folders and generic file categories stay on Lucide, and Material icons keep their own colors (size classes only; no `fill`, `stroke`, or `text-*`). Never construct a virtual icon import path dynamically.
- **Adding a new agent kind starts in the schema:** extend the `AgentKind` enum (`z.enum(['claude-code','codex','opencode','pi'])`) in `packages/foundation/schema/src/common.ts` — it is the data-plane→presentation join key — then fill `AGENT_LABELS` and `AGENT_INITIALS` in `packages/presentation/ui/src/chat/agent-icon.tsx` (both `Record<AgentKind>` — typecheck-enforced) and add the brand glyph to the `Partial` glyph map if one exists (a missing glyph deliberately falls back to initials). `AgentIcon` there is the single brand-chip component (`solid`/`ghost` variants).
- **Type scale bottoms out at `text-2xs`** (11px — badges, chrome labels); never write ad-hoc pixel sizes like `text-[13px]` — body/secondary/caption are `text-sm`/`text-xs`/`text-2xs`. Below `text-muted-foreground`, dimmer text uses the semantic tiers `text-label-tertiary` (timestamps, weak hints) and `text-label-quaternary` (placeholders, pending edges), never ad-hoc `/NN` opacities (fills like status dots are exempt). Numeric readouts outside `font-mono` take `tabular-nums`.
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
"@electron/asar": "^4.2.1",
"@hookform/resolvers": "^5.5.7",
"@iconify-json/material-icon-theme": "catalog:",
"@iconify-json/simple-icons": "catalog:",
"@linkcode/ui": "workspace:*",
"@linkcode/workbench": "workspace:*",
"@proj-airi/lobe-icons": "catalog:",
Expand Down
1 change: 1 addition & 0 deletions apps/webview/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
},
"devDependencies": {
"@iconify-json/material-icon-theme": "catalog:",
"@iconify-json/simple-icons": "catalog:",
"@proj-airi/lobe-icons": "catalog:",
"@rolldown/plugin-babel": "catalog:",
"@svgr/core": "catalog:",
Expand Down
1 change: 1 addition & 0 deletions eslint.config.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ module.exports = require('eslint-config-sukka').sukka(
name: 'linkcode/unplugin-icons-virtual-modules',
files: [
'packages/presentation/ui/src/chat/agent-icon.tsx',
'packages/presentation/ui/src/chat/integration-brand.tsx',
'packages/presentation/ui/src/lib/__tests__/file-icon.test.ts',
'packages/presentation/ui/src/lib/material-file-icons.ts',
'packages/presentation/ui/src/shell/service-icon.tsx',
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
"@biomejs/biome": "^2.5.5",
"@eslint-sukka/react": "^8.14.2",
"@iconify-json/material-icon-theme": "catalog:",
"@iconify-json/simple-icons": "catalog:",
"@proj-airi/lobe-icons": "catalog:",
"@svgr/core": "catalog:",
"@svgr/plugin-jsx": "catalog:",
Expand Down
20 changes: 20 additions & 0 deletions packages/client/workbench/src/mock/data/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,26 @@ const MOCK_COMMAND_FIXTURES: MockCommandFixture[] = [
},
reply: 'Mock review complete: no blocking issues found.',
},
{
command: {
name: 'documents',
description: 'Create and edit Word and Google Docs files',
displayName: 'Documents',
brandColor: '#2563EB',
iconDataUri:
'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHJlY3Qgd2lkdGg9IjI0IiBoZWlnaHQ9IjI0IiByeD0iNiIgZmlsbD0iIzI1NjNFQiIvPjxwYXRoIGQ9Ik03IDZoN2wzIDN2OUg3eiIgZmlsbD0id2hpdGUiLz48L3N2Zz4=',
},
reply: 'Mock document created.',
},
{
command: {
name: 'sync-linear',
description: 'Sync issues into the tracker',
displayName: 'Linear',
brandColor: '#5E6AD2',
},
reply: 'Mock issues synced.',
},
{
command: {
name: 'usage',
Expand Down
10 changes: 10 additions & 0 deletions packages/client/workbench/src/mock/data/showcase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -658,6 +658,16 @@ export function createShowcaseToolBursts(terminalId = SHOWCASE_TERMINAL_ID): Sho
rawInput: { id: 'CODE-228', includeRelations: true },
rawOutput: { content: [{ type: 'text', text: 'CODE-228' }] },
},
// Adjacent same-brand calls form a dedicated brand group ("Used Linear 2 times").
{
toolCallId: 'mock-tool-mcp-slug-save',
title: 'mcp__linear__save_issue',
kind: 'other',
status: 'completed',
content: [{ type: 'content', content: textBlock('CODE-228 moved to In Review.') }],
rawInput: { id: 'CODE-228', state: 'In Review' },
rawOutput: { content: [{ type: 'text', text: 'CODE-228' }] },
},
{
toolCallId: 'mock-tool-task-review',
title: 'Review metadata policy',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { describe, expect, it } from 'vitest';
import { createDevMockTransport } from '../../src/mock/dev-mock-transport';

const rBlobUrl = /^blob:/;
const RE_SVG_DATA_URI = /^data:image\/svg\+xml;base64,/;

async function connectedClient(): Promise<LinkCodeClient> {
const client = new LinkCodeClient(createDevMockTransport());
Expand Down Expand Up @@ -230,6 +231,19 @@ describe('dev mock transport', () => {
description: 'Review the current changes',
argumentHint: '<path>',
},
{
name: 'documents',
description: 'Create and edit Word and Google Docs files',
displayName: 'Documents',
brandColor: '#2563EB',
iconDataUri: expect.stringMatching(RE_SVG_DATA_URI),
},
{
name: 'sync-linear',
description: 'Sync issues into the tracker',
displayName: 'Linear',
brandColor: '#5E6AD2',
},
{
name: 'usage',
description: 'Show session usage and rate limits',
Expand Down
11 changes: 11 additions & 0 deletions packages/foundation/schema/src/model/agent/input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,17 @@ export const AgentCommandSchema = z.object({
/** Alternate names that invoke the same command (claude-code, e.g. `/cost` → `/usage`), no
* leading slash. Input matching accepts them; menus display only the canonical `name`. */
aliases: z.array(z.string().min(1)).optional(),
/** Provider-supplied human name (codex plugin skills: "Documents"), shown beside `name`. */
displayName: z.string().min(1).optional(),
/** Small brand icon embedded as a data URI (no asset endpoint exists) — size-capped and, for
* SVG, active-content-screened at adapter ingest. Render via `<img>` only; never inline SVG
* markup into the DOM — the screen is depth, not a sanitizer. */
iconDataUri: z.string().startsWith('data:image/').optional(),
/** Brand accent for icon fallbacks (menu initial chips). */
brandColor: z
.string()
.regex(/^#[0-9A-F]{6}$/i)
.optional(),
});
export type AgentCommand = z.infer<typeof AgentCommandSchema>;

Expand Down
2 changes: 1 addition & 1 deletion packages/foundation/schema/src/wire/message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { WIRE_PAYLOAD_KINDS, WirePayloadSchema } from './payload';
*/

/** Stamped on every frame this build sends; bump on any wire schema change. */
export const WIRE_PROTOCOL_VERSION = 73 as const;
export const WIRE_PROTOCOL_VERSION = 74 as const;

/** The oldest `v` this build still accepts. Bump only for a breaking change — a variant or field
* removed, renamed, or given a new meaning; additive changes leave it alone. */
Expand Down
2 changes: 1 addition & 1 deletion packages/host/agent-adapter/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ Every new adapter MUST honor these (`base.ts`); downstream relies on them, they
- **opencode** — `consumeEvents()` keeps one active `event.subscribe({directory: cwd})` and resubscribes after a clean SSE close at normal turn end (`session.idle`) or on cancel. The directory scope is LOAD-BEARING: events ride a per-directory instance bus, so a bare `subscribe()` silently misses every session event whenever the daemon cwd differs from the session cwd (verified live on 1.17.11). A close is fatal ONLY while a turn is active with no cancel pending, and the fatal path emits status `stopped` (NOT `idle`) so the UI disables the composer — misclassifying it (the pre-fix bug) stranded the composer enabled against a dead adapter. Each event has its own try/catch; the resubscribe delay prevents an empty-response busy loop.
- **opencode control plane** (CODE-224, live-verified on binary 1.18.2 × SDK 1.17.18 — script + readback transcript attached to the issue): `set-model` and `set-approval-policy` are pure store-then-emit — the pick is resent on every `session.promptAsync`/`session.command` as the `model`/`agent` fields, and a mid-session change routes the very next turn (assistant `providerID`/`modelID`/`agent` readback all flip; next-turn semantics, in-flight turns unaffected). User and assistant `message.updated` frames reflect the actually routed `providerID/modelID`, including the native default when no override was sent. No dedicated switched/ack event fires on the legacy bus, so the immediate reflect is the only switch confirmation channel. `set-model` rejects refs that aren't `providerID/modelID` (a stored bare id would emit a "successful" model-update while prompts silently omit the field) and rejects cross-provider switches when a per-account credential was injected at spawn (the injection is spawn-time-only, scoped to one provider). **The approval-policy axis IS opencode's agent axis**: selectable agents from `app.agents({directory})` (`mode === 'primary'|'all'`, non-hidden — hidden primaries like `compaction`/`title` and subagents are excluded) are advertised as policies, default = first primary (the TUI's own default); permission posture stays config-driven (CODE-136). The axis is dynamic: a failed discovery at start hides it for the session (a later `$` shell command retries and re-arms it on success). `$` shell runs under the selected agent. Resume adopts the Session record's last-used `model`/`agent` (both live-verified to update after every turn) unless `StartOptions.model` overrides; a credential-carrying resume without an explicit model pre-reads that record off the shared history server BEFORE the spawn, because the credential injection is spawn-time-only and keyed by the model's provider. There is NO effort axis — opencode's only analogue is per-model `variant` keys (free strings, incompatible with the closed `EffortLevel` enum; a follow-up on the dynamic catalog). **The model catalog is adapter-advertised** (CODE-226): `provider.list({directory})` at start → `available-models-update` (full-replace, engine-cached, attach-replayed — the command-catalog contract), filtered to connected / key-less `api`-source providers and narrowed to the credential-injected provider when one is in play; the composer prefers this catalog over the static `AGENT_MODEL_OPTIONS` table (which deliberately has no opencode entry — its model set is provider-dependent, not a fixed vendor list).
- **opencode turn lifecycle** (all verified live on 1.17.11, CODE-136): prompts go through `session.promptAsync` — the blocking `session.prompt` holds its HTTP response open for the whole turn, so `send()` would not return until the turn ended. `session.status {busy|retry}` is the on-stream acknowledgement that the active turn is running, and it ALWAYS precedes the turn's own error/idle — the `turnStarted` gate built on it is what keeps the previous turn's post-settle stragglers (an abort's DUPLICATE idle; the error re-fired with a stack after the settle) from falsely settling or poisoning a next turn that was already dispatched. An abort delivers `session.error {MessageAbortedError}` + `session.idle` — the error folds into the cancel path (stop `cancelled`), never surfaces as an error. Other `session.error`s fail the turn: `ProviderAuthError` → `AUTH_FAILED_ERROR_CODE` (non-recoverable, triggers the daemon login re-probe), everything else recoverable; `sessionID` is OPTIONAL on this one event — an unattributed error still counts as ours. A failed turn's idle settle emits status `idle` but NO `end_turn` stop. An idle absorbed before the busy acknowledgement logs a `console.warn` — the one trace if a server never emits `session.status` (the turn would then hang at `running`).
- **opencode RPC results resolve, they don't reject**: the generated client returns `{error}` for HTTP and network failures alike (`throwOnError` is never set) — every RPC result goes through `okOrThrow` or a failure silently reads as success (a permission reply that never landed, a prompt that never started).
- **opencode RPC errors resolve as `{error}`, they don't reject** (`throwOnError` is never set) — every RPC result goes through `okOrThrow` or a failure silently reads as success (a permission reply that never landed, a prompt that never started). Transport-level failures are the exception: fetch itself rejects on a dead/unreachable server (verified on SDK 1.18.3), so best-effort reads need their own catch.
- **opencode permissions & questions** (CODE-136): opencode's default posture is allow-all — asks only fire when the user's own config (or a future preset) says `ask`. `permission.asked` → the shared `requestPermission` round-trip → `permission.reply({reply: 'once'|'always'|'reject'})`; `always` is persisted server-side as a saved rule. `question.asked` → `requestQuestion` → `question.reply({answers})` (one label array per question) or `question.reject`. An UNANSWERED ask gates the turn server-side forever, so a teardown-cancelled permission replies `reject` and a cancelled question calls `reject` — reply failures after a cancel are swallowed (the abort already discarded the ask). Asks cite their tool via `tool.callID`, but tool cards are announced under the PART id — `toolPartIdByCallId` re-joins them. A custom "Other" answer rides as an extra label: upstream `Question.reply` hands the answer arrays to the asking tool verbatim, with no validation against option labels (verified in anomalyco/opencode source).
- **opencode history** (CODE-171, live-verified on 1.17.11): `list`/`read` are served by a daemon-shared, lazily-spawned, idle-reaped `opencode serve` (`opencode/history-server.ts`) — NOT a per-session server: HistoryService calls history on never-started factory instances. The manager spawns with a **neutral cwd** (`opencode-history` inside the resolved channel's state dir — `~/.linkcode/` or `~/.linkcode.development/`, CODE-460) because opencode indexes its cwd as the default workspace (a daemon launched from `$HOME` would index the whole home tree); `--port=0` does NOT auto-allocate (falls back to 4096) so the free port is found up front; shutdown escalates SIGTERM→SIGKILL. All `opencode serve` spawns (this manager and the per-session live server) go through `opencode/serve.ts` (CODE-76): binary from `agentRuntimeProber.resolveBinary('opencode')` (managed `agent:opencode` asset → detected user install, incl. `~/.opencode/bin`) with a bare-name PATH fallback for unprobed hosts — the SDK's `createOpencodeServer` is no longer used (it hard-codes bare-name PATH resolution, no cwd, no `windowsHide`). The neutral-cwd instance lists/reads sessions across every project with no `directory` scoping. `readHistory` full-fetches then slices at event level — the messages RPC's `limit` returns the LAST n messages, so it cannot page forward — and truncates at `Session.revert.messageID` (partial `partID` reverts keep everything rather than over-cut). Replay reuses the live part ids (`streamDelta`'s message keys, `toolCallFromPart` snapshots) so live and cold cards converge by id. `resumeHistory` is a native continue: adopt the existing session id (no `session.create`) and scope every call by the session's OWN directory, not the resume cwd. Fresh sessions defer `session-ref` until the first on-stream busy acknowledgement — announcing at create would seed the client against an empty transcript and the `uptoSeq` cut would swallow the first prompt (same deferral as codex).
- **pi** — pure JS in-process (`createAgentSession()`), no binary spawn, unaffected by asar-spawn, not staged. The SDK import resolves store-first (CODE-219): `agentRuntimeProber.resolveEntry('pi')` — the managed npm-closure entry installed by `@linkcode/assets` — then the bare `import()` (dev/standalone). The SDK is deliberately a devDependency: `--prod` deploys (desktop staging, standalone daemon) carry no closure, so packaged hosts have only the store. The prober reports pi three-state: managed entry → `source:'managed'`, node_modules → `source:'sdk'`, neither → `missing` (onboarding offers the download). auth via `authStorage.setRuntimeApiKey(provider, apiKey)` overriding `~/.pi/agent/auth.json` + env; model `provider/rest`, falls back to `modelRegistry.getAvailable()[0]`, and the SDK-selected session model is reflected as `provider/id` after creation. `agent_end {willRetry:true}` is intermediate; only `agent_settled` finalizes the turn, sweeps unfinished tools, and maps the final assistant's success/error/aborted outcome. Pi has no approval callback: it advertises one fixed `bypassPermissions` policy so the UI exposes that all tools run without prompts, while policy changes still reject.
Expand Down
Loading