<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom" xml:lang="ja">
  <title>Kristopher Baker</title>
  <subtitle>iOSに深く根ざしたシニアプロダクトエンジニア。コンシューマ向けのプロダクトシステム、開発者向けツール、実践的なAI支援ワークフローを東京から作っています。</subtitle>
  <link href="https://krisbaker.com/ja/" rel="alternate"/>
  <link href="https://krisbaker.com/ja/atom.xml" rel="self" type="application/atom+xml"/>
  <id>https://krisbaker.com/ja/</id>
  <generator version="0.13.0">Inkwell</generator>
  <updated>2026-06-02T12:34:39Z</updated>
  <author>
    <name>Kristopher Baker</name>
    <email>hello@krisbaker.com</email>
  </author>
  <entry>
    <title>Shikisha：SwiftでLLMワークフローを作る</title>
    <link href="https://krisbaker.com/ja/posts/shikisha-building-llm-workflows-in-swift/" rel="alternate"/>
    <id>https://krisbaker.com/ja/posts/shikisha-building-llm-workflows-in-swift/</id>
    <updated>2026-06-02T12:34:39Z</updated>
    <published>2026-06-02T12:34:39Z</published>
    <content type="html"><![CDATA[<p>最近、SwiftでAI支援のツールをいくつも作るようになり、同じような問題に何度も突き当たっていました。</p>
<p>SwiftからLLMを呼ぶこと自体は簡単です。</p>
<p>難しいのは、その周りのワークフローを分かりやすく、保守しやすい形に保つことです。</p>
<p>本当に難しくなるのは、その機能が実際のシステムになったときです。プロンプト、構造化出力、リトライ、ストリーミング、ツール、リトリーバル、メモリ、テスト、そしてAppleプラットフォームのアプリとして自然に振る舞うUI。</p>
<p>そこで作り始めたのが <a href="https://github.com/KristopherGBaker/Shikisha">Shikisha</a> です。macOSとiOS向けに、LangChain風のワークフローをSwiftらしく構築するためのライブラリです。まだ早い段階で、現在は <code>0.1.0</code> ですが、まわりに例を書いて試すだけでなく、実際のプロジェクトで使う段階まで来ました。</p>
<h2>問題はグルーコードだった</h2>
<p>たいていのLLMのデモは、まっとうなところから始まります。いくつかメッセージを送り、テキストを受け取り、答えを表示する。</p>
<p>おもちゃのチャットボットならそれで十分です。でも、私が作ってきた種類のツールには足りません。</p>
<p>たとえば <a href="https://github.com/KristopherGBaker/JPResume">JPResume</a> は、英文レジュメを日本式の履歴書と職務経歴書に変換します。そこでモデルは役立ちますが、役立つのは特定の箇所だけです。可能なところでは決定論的なパースをしたい。ステージの間で検証したい。モデルからは、それらしく見える段落ではなく、構造化されたJSONを返してほしい。会社に送るかもしれないPDFになる前に、途中の生成物を確認したい。</p>
<p>ツールにそうした制約があると、モデル呼び出しはより大きなシステムの中の一つのステップになります。</p>
<p>Shikisha 以前も、そのシステムをSwiftで作ることはできましたが、プロジェクトごとに小さなグルーコードの山を抱えていました。</p>
<ul>
<li>使い捨てのプロバイダーラッパー</li>
<li>手書きのJSONパース</li>
<li>その場しのぎのリトライ処理</li>
<li>重複したストリーミングのアダプター</li>
<li>テスト用の個別のモック実装</li>
</ul>
<p>どれも単体では難しくありませんが、新しいプロジェクトのたびに、また一からやり直す感覚になっていました。</p>
<p>共通のワークフローの形があれば、それらの部品は、プロジェクトごとの足場ではなく、組み合わせ可能なパーツになります。</p>
<p>小さなチェーン、RAGパイプライン、ツールを使うエージェント、アプリのUI。これらすべてで、同じ基本的な語彙が使えるようにしたかったのです。</p>
<h2>LangChain のアイデアに、Swiftらしい形を</h2>
<p>Shikisha は LangChain からアイデアを借りていますが、実装は単なる移植ではなく、Swiftに合わせて設計しています。</p>
<p>AIツールのエコシステムの多くはPythonを前提としています。実験やバックエンドのサービスにはそれでうまくいきますが、iOS/macOSアプリでは、やがてワークフローが SwiftUI、Structured Concurrency、アプリの状態、テスト、サンドボックス、プラットフォーム固有のAPIと共存する必要が出てきます。</p>
<p>また、別途Pythonのオーケストレーション用バックエンドを前提とするのではなく、Apple向けアプリの中に直接置けるワークフローがほしかったのです。</p>
<p>それが、Swiftでほしかった部分です。</p>
<p>LLMのワークフローを、横に後付けした別のスクリプト層ではなく、普通の型付きのアプリケーションコードのように感じさせる方法です。</p>
<p>中心となるアイデアは、ひとつのプロトコルです。</p>
<pre><code class="language-swift">public protocol Runnable&lt;Input, Output&gt;: Sendable {
    associatedtype Input: Sendable
    associatedtype Output: Sendable
    func invoke(_ input: Input) async throws -&gt; Output
}</code></pre>
<p>プロンプトテンプレート、チャットモデル、出力パーサー、リトリーバー、そしてチェーン全体まで、すべてが <code>Runnable</code> になれます。つまり、単純な「プロンプト→モデル→パーサー」のパイプラインを、上から下へ読める形で書けます。</p>
<pre><code class="language-swift">let chain = ChatPromptTemplate.fromTuples([
    .system("Answer concisely."),
    .human("{question}")
])
.pipe(model)
.pipe(StringOutputParser())

let answer = try await chain.invoke(["question": "What is Swift Concurrency?"])</code></pre>
<p>大事なのは演算子ではありません。コントラクトです。</p>
<p>プロンプトがメッセージを生成し、モデルがメッセージを受け取るなら、その接続をコンパイラが検査できます。パーサーが <code>AIMessage</code> を期待するなら、モデルはそれを返さなければなりません。チェーンが動けば、それを呼び出し、バッチ実行し、リトライし、コールバックで包み、あるいはフェイクのモデルでテストできます。どれも、より小さな部品とまったく同じやり方でできます。</p>
<pre class="mermaid">flowchart LR
    prompt[Prompt template]
    model[Chat model]
    parser[Output parser]
    tools[Typed tools]
    memory[Memory]
    stateGraph[StateGraph]
    callbacks[Callbacks]

    prompt --&gt; model --&gt; parser
    model --&gt; tools
    tools --&gt; model
    model --&gt; memory
    memory --&gt; model
    parser --&gt; stateGraph
    callbacks -.-&gt; model
    callbacks -.-&gt; tools
    callbacks -.-&gt; stateGraph</pre>
<p>Shikisha は、2026年に当然手に取るであろうSwiftの道具立ても使っています。Structured Concurrency、Sendable な型、AsyncSequence によるストリーミング、プロバイダーのペイロード用の Codable な形、そして通常のインストール経路としての Swift Package Manager です。</p>
<h2>いまのところ含まれているもの</h2>
<p>現在のリリースは、私が繰り返し必要としてきた部品をカバーしています。</p>
<h3>モデルとプロンプト</h3>
<ul>
<li>OpenAI、Anthropic、Google Gemini、Ollama のチャットモデルアダプター</li>
<li>プロンプトテンプレート</li>
<li>構造化出力のヘルパーを含む出力パーサー</li>
</ul>
<h3>リトリーバルとインデックス</h3>
<ul>
<li>ドキュメントローダー、テキストスプリッター、埋め込み（エンベディング）、ベクトルストア</li>
<li>RAG向けのリトリーバー（ベクトル、BM25、ハイブリッド、MMR、マルチクエリ、親ドキュメント、時間加重、セルフクエリ、文脈圧縮など）</li>
<li>変更されたドキュメントのために毎回すべてを再エンベディングせずに済む、インクリメンタルなインデックス更新</li>
</ul>
<h3>エージェントとワークフロー</h3>
<ul>
<li>会話のためのメモリ</li>
<li>型付きのツールと、ツール呼び出し型のエージェントループ</li>
<li>循環的・状態を持つワークフローのための <code>StateGraph</code></li>
</ul>
<h3>可観測性とテスト</h3>
<ul>
<li>トレース、使用量、コスト追跡、その他の副作用のためのコールバック</li>
<li>オフラインのサンプルやテストのための <code>FakeChatModel</code> とローカルの埋め込み</li>
</ul>
<p>一覧にすると大きく見えますが、役に立つのは、これらの部品が同じ形を共有していることです。RAGのチェーンと、ひとつの質問をする基本的なプロンプトは、別世界ではありません。同じ少数のアイデアの、並べ方の違いにすぎません。</p>
<p>APIキーなしで、サンプル一式を実行できます。</p>
<pre><code class="language-bash">swift run ShikishaExamples
swift run ShikishaExamples basicChain
swift run ShikishaExamples ragPipeline</code></pre>
<p>テストやデモは、検証対象がワークフローそのものであるなら、稼働中のプロバイダーに依存すべきではありません。</p>
<h2>ドキュメントもプロジェクトの一部</h2>
<p>最近、本格的な <a href="https://krisbaker.com/Shikisha/documentation/shikisha/">DocC のドキュメントとチュートリアル</a> を追加しました。これでプロジェクトの感触が変わりました。</p>
<p>ドキュメントには、LLMアプリのパターンに不慣れなSwift開発者向けの概念ガイドに加えて、チャットモデル、プロンプト、出力パーサー、構造化出力、ドキュメント、埋め込み、リトリーバー、メモリ、エージェント、グラフ、インデックス、可観測性、回復力（レジリエンス）といった機能ごとの記事を用意しました。</p>
<p>チュートリアルはより実践的です。次のものを作る手順を追っていきます。</p>
<ul>
<li>メモリ付きのストリーミングチャットボット</li>
<li>自分のドキュメントから答えるRAGアプリ</li>
<li>ツールを使うエージェント</li>
<li>ファイル操作ツールを備えた小さなコーディングエージェント</li>
<li>そのエージェントを組み込んだユニバーサルなSwiftUIアプリ</li>
</ul>
<p>コーディングエージェントのチュートリアルは、このフレームワークをSwiftでほしかった理由のよい例です。AIのコーディングアシスタントは、驚くほどの部分が、モデルと少しのファイル操作ツールとループだけでできています。もちろん細部は重要です。サンドボックスのパス、型付きのツール引数、ツールの仕様、メモリ、コールバック、そしてモデルがいつ動いてよいかの判断。ですが、それらの部品に名前がつけば、ワークフローは考えやすくなります。</p>
<p>エージェントのループでさえ、読める程度に小さく収まります。</p>
<pre><code class="language-swift">let agent = ToolCallingAgent(
    model: model,
    tools: [readFileTool, editFileTool],
    maxIterations: 8
)

let result = try await agent.run([
    SystemMessage(content: "You are a coding assistant working in this project."),
    HumanMessage(content: "Summarize the TODOs in this project.")
])</code></pre>
<p>コマンドラインでの実験から SwiftUI アプリへ移るときも、頭の中のモデル全体を別の言語で書き直さずに済むようになります。</p>
<h2>すでに使っているところ</h2>
<p>JPResume は、Shikisha が自然に収まる最初の公開プロジェクトです。</p>
<p>レジュメのパイプラインには、モデル呼び出し、構造化出力、検証、そして見直せる中間ファイルが必要です。Shikisha を使うと、各ステージがそれぞれ独自のプロバイダーラッパーやパーサーの流儀を持たなくても、それらのステージをより整理された形で表現できます。とはいえ、難しいプロダクトの判断、たとえばモデルにどこまで推測を許すか、検証をどれだけ厳しくするか、といったことは消えません。あくまで、ワークフローによりよい土台を与えてくれるだけです。</p>
<p>もう一つ、あとで書く予定の別のプロジェクトでも Shikisha を使っています。そのプロジェクトは、ライブラリの別の側面を試すものでした。エージェントの振る舞い、ツール、アプリへの統合です。まだ説明できる段階ではありませんが、Shikisha が一つのレジュメツールだけに合わせた形になっていないか、という二つ目の確認として役立っています。</p>
<p>抽象化が本当に機能しているか分かるのは、たいていその瞬間です。最初のデモが動いたときではなく、同じ抽象化が、異なる圧力のかかる二つ目のプロジェクトを生き延びたときです。</p>
<h2>まだ早いと感じるところ</h2>
<p>Shikisha は成熟したエコシステムではありません。まだ若いSwiftパッケージで、実際のユースケースがはっきりしてくるにつれて、APIはまだ動くかもしれません。</p>
<p>従来のやり方のままで十分な場面もあります。ボタンひとつの裏でプロバイダーを一回呼ぶだけなら、フレームワークを足すのは、その機能に見合わないほどの構造かもしれません。小さな <code>URLSession</code> の直接呼び出しが正解、ということもあります。</p>
<p>Shikisha が意味を持ち始めるのは、ワークフローが呼び出し以上のものになったときです。繰り返し使えるチェーン、リトリーバル、構造化出力、ツールの実行、メモリ、トレース、リトライ、ストリーミング、あるいはプロバイダーに当てずに走らせたいテストが必要になったときです。</p>
<p>すべてのAI機能を凝って見せたいわけではありません。凝った機能を、より壊れにくくしたいのです。</p>
<h2>何が変わったか</h2>
<p>私にとって面白いのは、Shikisha がモデルを呼べることではありません。</p>
<p>同じ抽象化が、いまや次のすべてを通して生き延びていることです。</p>
<ul>
<li>コマンドラインでの実験</li>
<li>SwiftUI アプリ</li>
<li>リトリーバルのパイプライン</li>
<li>型付きのツール実行</li>
<li>ローカルでのテスト</li>
</ul>
<p>ライブラリが、単なるデモではなくインフラになり始めるのは、その地点です。</p>
<p>ドキュメントはこちらです。</p>
<p><a href="https://krisbaker.com/Shikisha/documentation/shikisha/">krisbaker.com/Shikisha/documentation/shikisha</a></p>
<p>リポジトリはこちらです。</p>
<p><a href="https://github.com/KristopherGBaker/Shikisha">github.com/KristopherGBaker/Shikisha</a></p>
<p>Swiftでアプリを作っていて、Appleプラットフォームのツールチェーンから離れずにLLMワークフローを足すことに興味があったなら、Shikisha が私の現時点での答えです。まだ早い段階ですが、すでに単なる実験という段階は過ぎています。</p>]]></content>
  </entry>
  <entry>
    <title>JPResume：英文レジュメを日本式に変換するツール</title>
    <link href="https://krisbaker.com/ja/posts/jpresume-japanese-resume-tool/" rel="alternate"/>
    <id>https://krisbaker.com/ja/posts/jpresume-japanese-resume-tool/</id>
    <updated>2026-04-20T12:00:00Z</updated>
    <published>2026-04-20T12:00:00Z</published>
    <content type="html"><![CDATA[<video autoplay muted loop playsinline>
  <source src="https://krisbaker.com/posts/jpresume-japanese-resume-tool/jpresume.mp4" type="video/mp4">
  <img src="https://krisbaker.com/posts/jpresume-japanese-resume-tool/jpresume.png" alt="日本式の履歴書の書き方に悩んでいる様子">
</video>
<p>ここ数ヶ月の間に、別々の人から同じことを聞かれました。日本式のレジュメを共有してもらえないか、と。</p>
<p>英語のレジュメではなく、履歴書と職務経歴書。日本の企業に応募する際に実際に求められるフォーマットです。</p>
<p>そのたびに、返事は同じでした。「ちょっと確認して後で返します」と。</p>
<p>英語のレジュメはそれなりに整っていました。一方、日本語の方は中途半端なメモの寄せ集めで、英語版を更新するたびにすぐ古くなる。毎回それを手で書き直すのは、しかも第二言語で、現実的ではありませんでした。</p>
<p>そこで作ったのが <a href="https://github.com/KristopherGBaker/JPResume">JPResume</a> です。英文レジュメから履歴書と職務経歴書を生成し、両方を同期させるための小さなSwift CLIツールです。</p>
<h2>日本式レジュメが単なる翻訳ではない理由</h2>
<p>英文レジュメしか書いたことがないと、日本式のレジュメも単なる翻訳だと思いがちです。でも実際はそうではありません。</p>
<p>履歴書は、基本的に決まったフォーマットのグリッドです。氏名、フリガナ、写真欄、年・月単位で並ぶ学歴・職歴、資格、志望動機、本人希望記入欄。それぞれに細かい慣習があります。現職の書き方、職歴の締め方（「現在に至る」）、西暦か和暦か。</p>
<p>企業によって重視度は違いますが、初めて触れると分かりにくいポイントばかりです。</p>
<p>職務経歴書はその対になる自由形式のドキュメントです。職務要約から始まり、各職歴の詳細、スキル、実績、自己PR。こちらは書き方そのものにコツがあります。</p>
<p>例えば「29.8%のサインアップ増加を達成」という表現は英語では自然でも、日本語では少し浮きます。「新規会員登録数の29.8%増加に寄与」のような表現の方が馴染みます。</p>
<p>さらに厄介なのが整合性です。英語版と日本語版で、日付・役職・内容が一致している必要があります。ずれに気づくのは、大抵よくないタイミングです。</p>
<p>この整合性の問題は、ツールで解決できる部分でもあります。</p>
<h2>仕組み</h2>
<p>意図的にシンプルなパイプラインにしています。魔法はありません。</p>
<pre class="mermaid">flowchart LR
    parse[Parse]
    normalize["Normalize (LLM)"]
    repair[Repair]
    validate[Validate]
    generate["Generate (LLM)"]
    render[Render]

    parse --&gt; normalize --&gt; repair --&gt; validate --&gt; generate --&gt; render</pre>
<p><strong>Parse</strong> は決定論的な処理です。markdown、DOCX、PDFを入力として受け取り、ざっくりとした構造化データに変換します。Markdownはそのままパース。DOCXとPDFはテキスト抽出後に前処理を行います。スキャンPDFの場合はOCR（Vision）にフォールバックします。</p>
<p><strong>Normalize</strong> で初めてLLMが登場します。パース結果と jpresume_config.yaml（漢字氏名、フリガナ、住所、学歴、資格など日本特有の情報）を元に、正規化された構造を生成します。日付は整数化され、箇条書きは実績と責務に分類され、スキルはカテゴリごとに整理されます。</p>
<p>ここで重要なのは、「情報を補完しない」ことです。曖昧な場合は推測せず、低信頼としてマークするようにしています。</p>
<p><strong>Repair</strong> は再び決定論的です。職歴の並び替え、重複期間の整理、is_current の整合性修正などを行います。<strong>Validate</strong> では、重複、低信頼データ、不自然な空白期間、年数のズレなどを警告として出します。</p>
<p><strong>Generate</strong> で履歴書と職務経歴書のJSONを生成し、<strong>Render</strong> でMarkdownとPDFに変換します。PDFはCoreGraphicsとヒラギノを使って、日本語として自然に見える組版になるようにしています。</p>
<p>構成としてはよくある形です。できるところは決定論的に、必要なところだけLLMを使い、その間に確認可能な中間状態を挟む。</p>
<h2>実際の使い方</h2>
<p>CLIは一発で実行できます。</p>
<pre><code class="language-bash">jpresume convert resume.md --provider claude-cli --format both</code></pre>
<p>ただ、普段は agent skill￼ を使っています。理由は external mode です。</p>
<p>external mode では、LLMステージは直接APIを呼びません。プロンプトをファイルとして書き出して終了します。その後、エージェント（Claude CodeやCursorなど）がそれを読み取り、結果JSONを生成して書き戻し、--ingest で処理を再開します。</p>
<p>つまり、エージェント自体がモデルになります。</p>
<p>この違いは意外と大きいです。すべてのプロンプトとレスポンスが見える。途中で止めて確認できる。誤ったフィールドはJSONを直接修正して再取り込みできる。</p>
<p>Normalizeの結果もブラックボックスではなくなります。どのように解釈されたかを確認してから次に進める。</p>
<p>このツールをワンショットで作っていたら、この部分は見落としていたと思います。初稿を作るだけなら十分ですが、実際に提出するドキュメントではレビューのループが重要です。</p>
<h2>現時点での限界</h2>
<p>JPResumeは、現時点ではほぼ自分のレジュメでしか検証していません。</p>
<p>ソフトウェアエンジニアとしての職歴、比較的きれいな日付、認知されやすい企業名、JLPTのように定型化された資格。かなり扱いやすいケースです。</p>
<p>キャリアチェンジ、海外大学、長期ブランク、特殊な職種、日本の慣習と大きく異なる業界などはまだカバーできていません。</p>
<p>別のレジュメを入れたときに、正規化や生成でうまく処理できない部分が出る可能性は高いと思っています。修正自体は簡単にできるように設計していますが、まだ実例は多くありません。</p>
<p>もし試してみて気になる点があれば、IssueでもPRでも、何でも歓迎です。READMEにインストール方法とagent skillの説明を載せています。</p>
<h2>ツールが変わっても残るもの</h2>
<p>このツール自体よりも、残るのは構成の方だと思っています。</p>
<p>決定論的なパース、情報を補完しない正規化、バリデーション後の意図的な停止、エージェントがモデルとして振る舞いながら結果をレビューできるexternal mode。</p>
<p>このパターンは、日本式レジュメに限らず、契約書や申請書のように「間違った値が入ると困る」ドキュメント全般に応用できます。</p>
<p>現時点では、markdownのレジュメから履歴書と職務経歴書を生成し、そのまま提出できる状態にするツールです。</p>
<p>数ヶ月前は、それができませんでした。</p>]]></content>
  </entry>
  <entry>
    <title>制約ありきで組んだリモート開発環境</title>
    <link href="https://krisbaker.com/ja/posts/remote-dev-setup-with-real-constraints/" rel="alternate"/>
    <id>https://krisbaker.com/ja/posts/remote-dev-setup-with-real-constraints/</id>
    <updated>2026-04-01T12:00:00Z</updated>
    <published>2026-04-01T12:00:00Z</published>
    <content type="html"><![CDATA[<video autoplay muted loop playsinline>
  <source src="https://krisbaker.com/posts/remote-dev-setup-with-real-constraints/claude-train.mp4" type="video/mp4">
  <img src="https://krisbaker.com/posts/remote-dev-setup-with-real-constraints/claude-train.png" alt="電車内でスマートフォンからClaude Codeを操作している様子">
</video>
<p>日本の電車の中でスマートフォンを取り出して、自宅のMacで動かしている開発セッションに再接続し、そのまま作業を続ける。今となっては、そこまで突飛な話ではありません。</p>
<p>ただ、それを「ちゃんと使える状態」にする——しかもスマホから、既存の環境を壊さずに、安定して動かす——となると、話は少し変わってきます。</p>
<p>そしてちょうど環境が安定してきた頃に、Claude Codeに <a href="chatgpt://generic-entity?number=0">Claude Code Remote Control</a> と <a href="chatgpt://generic-entity?number=1">Claude Code Dispatch</a> がリリースされました。この話は後半で触れます。</p>
<h2>なぜTailscaleを使わなかったのか</h2>
<p>個人のマシンに安全にリモートアクセスしたい、という相談を受けたら、おそらく私は「Tailscaleを使えばいい」と答えて終わりにすると思います。それくらい優秀なツールですし、多くのケースではそれで十分です。</p>
<p>ただ、自分の状況は少しだけややこしいものでした。</p>
<p>自宅のMacでは業務用VPNが常時動いており、個人用と会社用のTailscaleアカウントを同一マシンで混在させたくありませんでした。また、自宅ネットワークをインターネットからの直接接続にさらすのも避けたかった。さらに、スマホ（しかもWi-Fiとモバイル回線を頻繁に切り替える環境）から使う前提だったので、通常のSSHセッションが切れてしまうような状況でも耐えられる必要がありました。</p>
<p>どれか一つだけなら簡単に解決できる制約です。ただ、それらが重なると、見た目がきれいな構成から順番に候補から外れていきます。最終的に残ったのは、最もエレガントな構成ではなく、実際の制約にちゃんとフィットする構成でした。</p>
<h2>全体の構成</h2>
<p>最終的な構成はシンプルな「連鎖」です。それぞれの要素には明確な役割があります。</p>
<pre class="mermaid">flowchart LR
    phone[Phone]
    mosh[mosh]
    vps[VPS]
    reverse[reverse SSH]
    mac[Mac]
    tmux[tmux]
    tools[Claude Code]

    phone --&gt; mosh --&gt; vps --&gt; reverse --&gt; mac --&gt; tmux --&gt; tools</pre>
<p>スマートフォンからは mosh を使ってVPSに接続します。モバイル回線ではIPが変わったり、Wi-Fiとセルラーが切り替わったり、接続が一時的に切れたりと、通常のSSHが苦手な状況が頻発しますが、moshはそこをうまく吸収してくれます。</p>
<p>VPSは唯一パブリックに公開されているポイントです。自宅のMacからはアウトバウンドのリバースSSHトンネルでVPSに接続しているため、Mac側はインターネットからの着信を一切受ける必要がありません。</p>
<p>そしてMac上の tmux によって、接続は単なる「一時的なセッション」ではなく、「いつでも戻ってこられる状態」に変わります。</p>
<p>この点は意外でした。セッション自体が持続するようになると、スマホは「制限された入口」ではなくなります。どこかに再接続しているというより、進行中の作業にそのまま戻っている感覚に近くなります。</p>
<h2>実際にハマったポイント</h2>
<p>学びの大半は、最初の設計ではなく、うまくいかなかった部分から来ています。</p>
<p>リバースSSHトンネルは、しばらく放置すると半死状態になることがありました。VPS上ではポート22は開いているように見える。でも実際にはセッションが裏で切れていて、Macに接続しようとするとハングする。</p>
<p>これを解決したのは単一の設定ではなく、「生存確認」をちゃんと扱うことでした。Mac側からのSSH keepalive、接続が落ちたときに再構築する autossh、そしてVPS側で localhost:22 が実際に応答しているか確認してから接続するスクリプト。</p>
<p>このジャンプ用スクリプトも学びの一つでした。最初はVPSに接続するたびにMacへSSHしようとしていたのですが、トンネルが落ちているとただハングするだけで何もできません。</p>
<p>改善後は、ポートを事前にチェックし、短いタイムアウトを設定し、ダメならVPSのシェルにフォールバックするようにしました。小さな変更ですが、「たまに使える」状態から「安心して使える」状態への差はここにありました。</p>
<p>tmux も同様です。ある環境で作ったセッションを別の環境から再接続すると、うまく表示されないことがありました。自分の場合はMac側の xterm-ghostty が原因で、モバイル側クライアントとの相性問題が出ていました。より汎用的なterminal設定に変更することで解決しましたが、「持続するセッション」はその前提条件に依存する、という当たり前のことを改めて実感しました。</p>
<p>どれも大きな問題ではありません。ただ、この手の「半分だけ解決されている問題」が積み重なると、結局その環境は使われなくなります。</p>
<h2>その頃、ツール側も進化していた</h2>
<p>ちょうどこの構成が安定したタイミングで、同じ領域をカバーする機能が登場しました。</p>
<p>Claude Code Remote Control は、ローカルで動いているClaude Codeのセッションをスマホやブラウザからそのまま操作できる機能です。ファイルシステムや開発環境はローカルのまま、スマホは単なる「窓」として機能します。ポートフォワーディングもVPSも不要です。</p>
<p>Claude Code Dispatch はさらに一歩進んでいて、スマホからタスクを投げると、自宅のMac上で新しいセッションを立ち上げて処理してくれます。そもそも事前にセッションを立ち上げておく必要すらありません。</p>
<p>では、この構成を作ったのは無駄だったのか。</p>
<p>正直、そうは思っていません。</p>
<p>Remote Controlは1セッション1接続に制限されており、ネットワーク断が10分ほど続くとタイムアウトします。地方の電車などで不安定な回線を使う場合には、やや心許ない。一方、自分の構成は mosh と autossh によって長時間の切断にも耐えられます。</p>
<p>また、VPSという足場があることで、単にClaude Codeに限らず、「とりあえず入れるシェル」が手元にある状態になります。</p>
<p>ただし、今ゼロから同じことをやるかと言われたら話は別です。安定した回線環境で、スマホからClaude Codeを触りたいだけなら、まずRemote Controlを試して、そのまま終わる可能性が高いと思います。</p>
<h2>残った学び</h2>
<p>インフラでも、AIツールでも、そして昨年建てた家でも、同じパターンを何度も見ています。</p>
<p>最初の設計はきれいに見える。まだ現実とぶつかっていないからです。</p>
<p>実際の制約に向き合った結果できあがるものは、少し歪んで見えることもある。でも、実際に使い続けられるのはそちらの方です。</p>
<p>そしてもう一つ。ツールは進化し、前提条件は変わります。</p>
<p>今回の構成は今でも役に立っていますが、この話で伝えたいのは「これを作るべき」ということではありません。「なぜこの構成になったのか」を理解していれば、新しい選択肢が出てきたときに、どこを捨てていいか判断できる、ということです。</p>
<p>ツールが変わっても、その部分は残ります。</p>]]></content>
  </entry>
</feed>