diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json
index cd41b29eed..a77d81f80b 100644
--- a/frontend/src/i18n/locales/en.json
+++ b/frontend/src/i18n/locales/en.json
@@ -1507,6 +1507,22 @@
"unavailable": "Connection status unavailable."
},
"agentByo": {
+ "harnessPicker": {
+ "label": "Harness",
+ "connects": "connects",
+ "mac": "Mac",
+ "linux": "Linux",
+ "windows": "Windows",
+ "connectsCommand": "one command",
+ "connectsTerminal": "terminal steps",
+ "privateOnly": "private pods only",
+ "osState": {
+ "walked": "Walked on {{os}}",
+ "mcp-only": "{{os}}: connects over MCP only, no @mention listener",
+ "not-walked": "Not walked on {{os}} yet",
+ "not-offered": "Not offered on {{os}}"
+ }
+ },
"title": "Bring your own agent",
"description": "A running agent answers @mentions in its pod.",
"form": {
diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json
index 1667dc2a2c..c6a27e0a09 100644
--- a/frontend/src/i18n/locales/zh-CN.json
+++ b/frontend/src/i18n/locales/zh-CN.json
@@ -1567,6 +1567,22 @@
"unavailable": "暂时无法获取连接状态。"
},
"agentByo": {
+ "harnessPicker": {
+ "label": "运行器",
+ "connects": "接入方式",
+ "mac": "Mac",
+ "linux": "Linux",
+ "windows": "Windows",
+ "connectsCommand": "一条命令",
+ "connectsTerminal": "终端步骤",
+ "privateOnly": "仅限私密 Pod",
+ "osState": {
+ "walked": "{{os}} 已验证",
+ "mcp-only": "{{os}}:仅通过 MCP 接入,不会监听 @提及",
+ "not-walked": "{{os}} 尚未验证",
+ "not-offered": "{{os}} 上不提供"
+ }
+ },
"title": "接入你自己的智能体",
"description": "智能体运行期间会在所在 Pod 回复 @提及。",
"form": {
diff --git a/frontend/src/v2/__tests__/V2HarnessPicker.test.tsx b/frontend/src/v2/__tests__/V2HarnessPicker.test.tsx
new file mode 100644
index 0000000000..3d41c77a75
--- /dev/null
+++ b/frontend/src/v2/__tests__/V2HarnessPicker.test.tsx
@@ -0,0 +1,101 @@
+// @ts-nocheck
+import React from 'react';
+import { fireEvent, render, screen, within } from '@testing-library/react';
+import V2HarnessPicker from '../components/V2HarnessPicker';
+import { HARNESS_WALK_TABLE, HIDDEN_HARNESS_IDS } from '../utils/harnessWalkTable';
+import fs from 'fs';
+import path from 'path';
+
+const v2Css = fs.readFileSync(path.join(__dirname, '../v2.css'), 'utf8').replace(/\/\*[\s\S]*?\*\//g, '');
+// Body of the first rule whose selector list includes `selector` as a complete selector
+// (not a prefix of a longer class name), so grouped selectors match too.
+const ruleBody = (selector) => {
+ // Escape the selector, then require it to start a selector-list entry and end at a comma or
+ // an opening brace. A compound selector such as `.dot--walked.is-active` does not match.
+ const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+ const match = v2Css.match(new RegExp(`(?:^|[,\\n])\\s*${escaped}\\s*(?=,|\\{)[^{}]*\\{([^}]*)\\}`));
+ return match ? match[1] : '';
+};
+
+afterEach(() => jest.clearAllMocks());
+
+describe('V2HarnessPicker', () => {
+ it('renders exactly the walked harnesses from the walk table, in table order', () => {
+ render();
+ const rows = within(screen.getByTestId('harness-picker')).getAllByRole('button');
+ expect(rows.map((r) => r.getAttribute('data-testid'))).toEqual(
+ HARNESS_WALK_TABLE.map((row) => `harness-row-${row.id}`),
+ );
+ expect(rows.map((r) => r.textContent.replace(/\s+/g, ' ').trim()).join('|')).toContain('Qwen Code');
+ });
+
+ it('never renders a harness that has no walk on record', () => {
+ render();
+ HIDDEN_HARNESS_IDS.forEach((id) => {
+ expect(screen.queryByTestId(`harness-row-${id}`)).toBeNull();
+ });
+ expect(screen.queryByText('Hermes')).toBeNull();
+ expect(screen.queryByText('goose')).toBeNull();
+ });
+
+ it('shows Qwen Code and hides Hermes, per the rev 3 walk table', () => {
+ render();
+ expect(screen.getByTestId('harness-row-qwen')).toBeInTheDocument();
+ expect(screen.queryByTestId('harness-row-hermes')).toBeNull();
+ });
+
+ it('draws the OS dots from the table: filled when walked, dashed when not', () => {
+ render();
+ const claude = screen.getByTestId('harness-row-claude');
+ expect(within(claude).getByTestId('harness-dot-claude-mac')).toHaveAttribute('data-state', 'walked');
+ expect(within(claude).getByTestId('harness-dot-claude-linux')).toHaveAttribute('data-state', 'mcp-only');
+ expect(within(claude).getByTestId('harness-dot-claude-windows')).toHaveAttribute('data-state', 'mcp-only');
+ expect(within(screen.getByTestId('harness-row-codex')).getByTestId('harness-dot-codex-linux')).toHaveAttribute('data-state', 'not-walked');
+ });
+
+ it('gives each dot the state class the stylesheet keys on, for every state', () => {
+ render();
+ expect(screen.getByTestId('harness-dot-claude-mac')).toHaveClass('v2-harness-picker__dot--walked');
+ expect(screen.getByTestId('harness-dot-claude-linux')).toHaveClass('v2-harness-picker__dot--mcp-only');
+ expect(screen.getByTestId('harness-dot-codex-linux')).toHaveClass('v2-harness-picker__dot--not-walked');
+ expect(screen.getByTestId('harness-dot-dsh-windows')).toHaveClass('v2-harness-picker__dot--not-offered');
+ });
+
+ it('titles each OS dot with why it is filled or dashed', () => {
+ render();
+ expect(screen.getByTestId('harness-dot-claude-mac')).toHaveAttribute('title', 'Walked on Mac');
+ expect(screen.getByTestId('harness-dot-claude-linux')).toHaveAttribute('title', 'Linux: connects over MCP only, no @mention listener');
+ expect(screen.getByTestId('harness-dot-dsh-windows')).toHaveAttribute('title', 'Not offered on Windows');
+ });
+
+ it('keeps the filled and dashed dot rules in v2.css', () => {
+ expect(ruleBody('.v2-harness-picker__dot--walked')).toContain('background: var(--v2-ink)');
+ expect(ruleBody('.v2-harness-picker__dot--not-walked')).toContain('border: 1px dashed var(--v2-text-tertiary)');
+ });
+
+ it('marks only DeepSeek Harness as private-pods-only', () => {
+ render();
+ const privateRows = HARNESS_WALK_TABLE.filter((row) => row.privatePodsOnly).map((row) => row.id);
+ expect(privateRows).toEqual(['dsh']);
+ expect(within(screen.getByTestId('harness-row-dsh')).getByRole('img', { name: 'private pods only' })).toBeInTheDocument();
+ });
+
+ it('marks the selected row and reports the id it picks', () => {
+ const onSelect = jest.fn();
+ render();
+ expect(screen.getByTestId('harness-row-codex')).toHaveAttribute('aria-pressed', 'true');
+ expect(screen.getByTestId('harness-row-claude')).toHaveAttribute('aria-pressed', 'false');
+ fireEvent.click(screen.getByTestId('harness-row-opencode'));
+ expect(onSelect).toHaveBeenCalledWith('opencode');
+ });
+
+ it('pins the one-command rows to Claude Code and Codex', () => {
+ expect(HARNESS_WALK_TABLE.filter((row) => row.connects === 'command').map((row) => row.id)).toEqual(['claude', 'codex']);
+ });
+
+ it('uses the connect method from the table for each row', () => {
+ render();
+ expect(screen.getByTestId('harness-connects-claude')).toHaveAttribute('aria-label', 'one command');
+ expect(screen.getByTestId('harness-connects-opencode')).toHaveAttribute('aria-label', 'terminal steps');
+ });
+});
diff --git a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts
index 9d2d27a90b..36a34db620 100644
--- a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts
+++ b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts
@@ -5150,3 +5150,17 @@ describe('v2 board on a phone (TASK-576)', () => {
expect(page).toContain('');
});
});
+
+describe('harness picker tracks (TASK-361)', () => {
+ const v2 = read('../v2.css');
+ test('the head and the rows share the same fixed tracks on desktop', () => {
+ const rule = ruleBody(v2, '.v2-harness-picker__head,\n.v2-harness-picker__row');
+ expect(rule).toContain('grid-template-columns: minmax(0, 1fr) var(--hp-connects) var(--hp-mac) var(--hp-linux) var(--hp-windows)');
+ expect(ruleBody(v2, '.v2-harness-picker')).toContain('--hp-connects: 64px');
+ });
+ test('the phone block keeps the 54/24/36/48 tracks and the 6px gap', () => {
+ const block = blockContaining(v2, '@media (max-width: 760px)', '.v2-harness-picker__head');
+ expect(block).toContain('--hp-connects: 54px; --hp-mac: 24px; --hp-linux: 36px; --hp-windows: 48px;');
+ expect(ruleBody(block, '.v2-harness-picker__head,\n .v2-harness-picker__row')).toContain('gap: 6px');
+ });
+});
diff --git a/frontend/src/v2/components/V2HarnessPicker.tsx b/frontend/src/v2/components/V2HarnessPicker.tsx
new file mode 100644
index 0000000000..e7398cdf76
--- /dev/null
+++ b/frontend/src/v2/components/V2HarnessPicker.tsx
@@ -0,0 +1,87 @@
+import React from 'react';
+import { useTranslation } from 'react-i18next';
+import { HARNESS_WALK_TABLE, HarnessOsState, HarnessWalkRow } from '../utils/harnessWalkTable';
+
+interface V2HarnessPickerProps {
+ selectedId: string;
+ onSelect: (id: string) => void;
+}
+
+const GLYPH_PROPS = { width: 16, height: 16, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 1.8, 'aria-hidden': true };
+
+const ClipboardGlyph = () => (
+
+);
+const TerminalGlyph = () => (
+
+);
+const LockGlyph = () => (
+
+);
+
+// One dot per OS. Filled when walked, dashed ring otherwise. The title says why.
+const OsDot = ({ state, os, row }: { state: HarnessOsState; os: 'mac' | 'linux' | 'windows'; row: HarnessWalkRow }) => {
+ const { t } = useTranslation();
+ const title = t(`agentByo.harnessPicker.osState.${state}`, { os: t(`agentByo.harnessPicker.${os}`) });
+ return (
+
+ );
+};
+
+const HarnessRow = ({ row, selected, onSelect }: { row: HarnessWalkRow; selected: boolean; onSelect: (id: string) => void }) => {
+ const { t } = useTranslation();
+ const connectsLabel = t(row.connects === 'command' ? 'agentByo.harnessPicker.connectsCommand' : 'agentByo.harnessPicker.connectsTerminal');
+ return (
+
+ );
+};
+
+// Renders only the rows in HARNESS_WALK_TABLE. Hidden harnesses have no entry to render.
+const V2HarnessPicker = ({ selectedId, onSelect }: V2HarnessPickerProps) => {
+ const { t } = useTranslation();
+ return (
+
+
+
+ {t('agentByo.harnessPicker.connects')}
+ {t('agentByo.harnessPicker.mac')}
+ {t('agentByo.harnessPicker.linux')}
+ {t('agentByo.harnessPicker.windows')}
+
+ {HARNESS_WALK_TABLE.map((row) => (
+
+ ))}
+
+ );
+};
+
+export default V2HarnessPicker;
diff --git a/frontend/src/v2/utils/harnessWalkTable.ts b/frontend/src/v2/utils/harnessWalkTable.ts
new file mode 100644
index 0000000000..c7538c4ab7
--- /dev/null
+++ b/frontend/src/v2/utils/harnessWalkTable.ts
@@ -0,0 +1,73 @@
+// The harness walk table, as data. Source of truth: ~/agents/shared/d15/harness-walk-table.md
+// (ux-lead, 2026-10-27). A harness is listed in the picker only when it has a passing walk on
+// record. Change a row here only with a new walk on record, and update the table to match.
+
+/**
+ * walked: passing walk on record. mcp-only: connects, but no @mention listener on this OS.
+ * not-walked: no walk yet. not-offered: the harness does not ship for this OS.
+ */
+export type HarnessOsState = 'walked' | 'mcp-only' | 'not-walked' | 'not-offered';
+
+export interface HarnessWalkRow {
+ id: string;
+ label: string;
+ /** Two-letter stand-in until the vendor's own mark is wired in. */
+ monogram: string;
+ /** `command` = one clipboard command; `terminal` = steps in your terminal. */
+ connects: 'command' | 'terminal';
+ os: { mac: HarnessOsState; linux: HarnessOsState; windows: HarnessOsState };
+ /** Private pods only (lock glyph). */
+ privatePodsOnly: boolean;
+ walkedOn: string;
+}
+
+export const HARNESS_WALK_TABLE: readonly HarnessWalkRow[] = [
+ {
+ id: 'claude',
+ label: 'Claude Code',
+ monogram: 'CC',
+ connects: 'command',
+ os: { mac: 'walked', linux: 'mcp-only', windows: 'mcp-only' },
+ privatePodsOnly: false,
+ walkedOn: '2026-10-26',
+ },
+ {
+ id: 'codex',
+ label: 'Codex',
+ monogram: 'CX',
+ connects: 'command',
+ os: { mac: 'walked', linux: 'not-walked', windows: 'not-walked' },
+ privatePodsOnly: false,
+ walkedOn: '2026-10-27',
+ },
+ {
+ id: 'opencode',
+ label: 'OpenCode',
+ monogram: 'OC',
+ connects: 'terminal',
+ os: { mac: 'walked', linux: 'not-walked', windows: 'not-walked' },
+ privatePodsOnly: false,
+ walkedOn: '2026-10-27',
+ },
+ {
+ id: 'qwen',
+ label: 'Qwen Code',
+ monogram: 'QW',
+ connects: 'terminal',
+ os: { mac: 'walked', linux: 'not-walked', windows: 'not-walked' },
+ privatePodsOnly: false,
+ walkedOn: '2026-10-27',
+ },
+ {
+ id: 'dsh',
+ label: 'DeepSeek Harness',
+ monogram: 'DS',
+ connects: 'terminal',
+ os: { mac: 'walked', linux: 'not-offered', windows: 'not-offered' },
+ privatePodsOnly: true,
+ walkedOn: '2026-10-23',
+ },
+];
+
+/** Harnesses with no passing walk on record. They never render in the picker. */
+export const HIDDEN_HARNESS_IDS: readonly string[] = ['hermes', 'goose', 'pi', 'gemini', 'grok-build'];
diff --git a/frontend/src/v2/v2.css b/frontend/src/v2/v2.css
index 23f9de9860..57c7aec2ba 100644
--- a/frontend/src/v2/v2.css
+++ b/frontend/src/v2/v2.css
@@ -13346,3 +13346,56 @@ body.modern-ui.v2-canvas {
.v2-legal__section { margin-bottom: 28px; }
.v2-legal__section h2 { font-size: 18px; font-weight: 600; margin: 0 0 8px; }
.v2-legal__section p { font-size: 15px; line-height: 1.6; color: var(--v2-text-secondary); margin: 0 0 8px; }
+
+/* TASK-361 harness picker: one row per walked harness, OS dots from the walk table. */
+/* The head and every row share the same fixed tracks, so each head sits over its dot. */
+.v2-harness-picker { --hp-connects: 64px; --hp-mac: 60px; --hp-linux: 60px; --hp-windows: 60px; display: flex; flex-direction: column; gap: 8px; }
+.v2-harness-picker__head,
+.v2-harness-picker__row {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) var(--hp-connects) var(--hp-mac) var(--hp-linux) var(--hp-windows);
+ align-items: center;
+ gap: 16px;
+}
+.v2-harness-picker__head { padding: 0 16px; font-family: var(--v2-font-mono); font-size: 12px; color: var(--v2-text-tertiary); }
+.v2-harness-picker__head > span { white-space: nowrap; text-align: center; }
+.v2-harness-picker__row {
+ width: 100%;
+ padding: 14px 16px;
+ background: var(--v2-surface);
+ border: 1px solid var(--v2-border);
+ border-radius: var(--v2-radius-sm);
+ color: var(--v2-text-primary);
+ font: inherit;
+ text-align: left;
+ cursor: pointer;
+}
+.v2-harness-picker__row:hover { background: var(--v2-surface-hover); }
+.v2-harness-picker__row--selected { border-color: var(--v2-accent); }
+.v2-harness-picker__name { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; min-width: 0; font-size: 15px; font-weight: 500; }
+.v2-harness-picker__mark {
+ display: inline-grid;
+ place-items: center;
+ flex: none;
+ width: 28px;
+ height: 28px;
+ border: 1px solid var(--v2-border);
+ border-radius: var(--v2-radius-sm);
+ font-family: var(--v2-font-mono);
+ font-size: 11px;
+}
+.v2-harness-picker__lock { display: inline-flex; color: var(--v2-text-tertiary); }
+.v2-harness-picker__connects { display: inline-flex; justify-self: center; color: var(--v2-text-secondary); }
+.v2-harness-picker__dot { justify-self: center; width: 8px; height: 8px; border-radius: 50%; }
+.v2-harness-picker__dot--walked { background: var(--v2-ink); }
+.v2-harness-picker__dot--mcp-only,
+.v2-harness-picker__dot--not-walked,
+.v2-harness-picker__dot--not-offered { border: 1px dashed var(--v2-text-tertiary); }
+
+@media (max-width: 760px) {
+ .v2-harness-picker { --hp-connects: 54px; --hp-mac: 24px; --hp-linux: 36px; --hp-windows: 48px; }
+ .v2-harness-picker__head,
+ .v2-harness-picker__row { gap: 6px; padding-left: 12px; padding-right: 12px; }
+ .v2-harness-picker__head { font-family: var(--v2-font-mono); font-size: 11px; }
+ .v2-harness-picker__name { font-size: 14px; }
+}