diff --git a/.gitignore b/.gitignore
index 8e3e39835..72762260f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -308,3 +308,6 @@ profiles.json
tmpclaude-*
# Per-issue git worktrees created locally
.worktree/
+
+# Local-only superpowers plan/spec notes (kept out of source control)
+docs/superpowers/
diff --git a/build/scripts/Verify-AgentHooks.ps1 b/build/scripts/Verify-AgentHooks.ps1
index 06c5252c6..88dc18305 100644
--- a/build/scripts/Verify-AgentHooks.ps1
+++ b/build/scripts/Verify-AgentHooks.ps1
@@ -2,7 +2,7 @@
<#
.SYNOPSIS
Inspect, install, or remove the wt-agent-hooks bridge for one or all
- supported agent CLIs (Copilot, Claude, Gemini).
+ supported agent CLIs (Copilot, Claude, Gemini, Codex).
.DESCRIPTION
Wrapper around `wta hooks status --json` / `wta install-hooks` /
@@ -25,8 +25,8 @@
.PARAMETER CliFilter
Restrict Uninstall (and the SmokeTest) to one CLI. Defaults to `all`.
- Has no effect on Check / Install (Install always installs all three;
- Check always reports all three).
+ Has no effect on Check / Install (Install always installs all four;
+ Check always reports all four).
.PARAMETER SmokeTest
After Check / Install, fire a no-op prompt at each detected CLI and
@@ -57,7 +57,7 @@ param(
[string]$Mode = 'Check',
[Parameter()]
- [ValidateSet('all', 'copilot', 'claude', 'gemini')]
+ [ValidateSet('all', 'copilot', 'claude', 'gemini', 'codex')]
[string]$CliFilter = 'all',
[Parameter()]
@@ -82,6 +82,7 @@ $script:CliDisplayNames = @{
copilot = 'Copilot CLI'
claude = 'Claude Code'
gemini = 'Gemini CLI'
+ codex = 'Codex CLI'
}
# ── Helpers ──────────────────────────────────────────────────────────
diff --git a/src/cascadia/TerminalSettingsEditor/AIAgents.xaml b/src/cascadia/TerminalSettingsEditor/AIAgents.xaml
index 79fba2333..1c2d82eb2 100644
--- a/src/cascadia/TerminalSettingsEditor/AIAgents.xaml
+++ b/src/cascadia/TerminalSettingsEditor/AIAgents.xaml
@@ -377,6 +377,42 @@
MinWidth="120" />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/cascadia/TerminalSettingsEditor/AIAgentsViewModel.cpp b/src/cascadia/TerminalSettingsEditor/AIAgentsViewModel.cpp
index 8b0cce984..4b1a51992 100644
--- a/src/cascadia/TerminalSettingsEditor/AIAgentsViewModel.cpp
+++ b/src/cascadia/TerminalSettingsEditor/AIAgentsViewModel.cpp
@@ -861,22 +861,27 @@ namespace winrt::Microsoft::Terminal::Settings::Editor::implementation
_copilotCliDetected = false;
_claudeCliDetected = false;
_geminiCliDetected = false;
+ _codexCliDetected = false;
_showCopilotHookRow = false;
_showClaudeHookRow = false;
_showGeminiHookRow = false;
+ _showCodexHookRow = false;
_copilotHooksSubtitle = {};
_claudeHooksSubtitle = {};
_geminiHooksSubtitle = {};
+ _codexHooksSubtitle = {};
}
else
{
const auto* copilot = FindCli(*report, "copilot");
const auto* claude = FindCli(*report, "claude");
const auto* gemini = FindCli(*report, "gemini");
+ const auto* codex = FindCli(*report, "codex");
_copilotCliDetected = copilot && copilot->binaryOnPath;
_claudeCliDetected = claude && claude->binaryOnPath;
_geminiCliDetected = gemini && gemini->binaryOnPath;
+ _codexCliDetected = codex && codex->binaryOnPath;
const auto hasState = [](const CliStatus* cli) {
return cli && (cli->marketplaceRegistered || cli->pluginInstalled);
@@ -884,26 +889,32 @@ namespace winrt::Microsoft::Terminal::Settings::Editor::implementation
_showCopilotHookRow = hasState(copilot);
_showClaudeHookRow = hasState(claude);
_showGeminiHookRow = hasState(gemini);
+ _showCodexHookRow = hasState(codex);
_copilotHooksSubtitle = _ComputeHooksSubtitle(copilot);
_claudeHooksSubtitle = _ComputeHooksSubtitle(claude);
_geminiHooksSubtitle = _ComputeHooksSubtitle(gemini);
+ _codexHooksSubtitle = _ComputeHooksSubtitle(codex);
}
_NotifyChanges(L"IsCopilotCliDetected",
L"IsClaudeCliDetected",
L"IsGeminiCliDetected",
+ L"IsCodexCliDetected",
L"IsAnyAgentCliDetected",
L"CanInstallAgentHooks",
L"ShowCopilotHookRow",
L"ShowClaudeHookRow",
L"ShowGeminiHookRow",
+ L"ShowCodexHookRow",
L"CopilotHooksSubtitle",
L"ClaudeHooksSubtitle",
L"GeminiHooksSubtitle",
+ L"CodexHooksSubtitle",
L"ShowCopilotHooksSubtitle",
L"ShowClaudeHooksSubtitle",
- L"ShowGeminiHooksSubtitle");
+ L"ShowGeminiHooksSubtitle",
+ L"ShowCodexHooksSubtitle");
}
void AIAgentsViewModel::RefreshAgentHooksStatus()
@@ -969,6 +980,15 @@ namespace winrt::Microsoft::Terminal::Settings::Editor::implementation
_RunHooksWtaAsync(L"hooks uninstall --cli gemini");
}
+ void AIAgentsViewModel::RemoveCodexHooks()
+ {
+ if (_installingAgentHooks) return;
+ _installingAgentHooks = true;
+ _agentHooksInstallSummary = RS_(L"AIAgents_HooksRemovingCodexSummary");
+ _NotifyChanges(L"IsInstallingAgentHooks", L"AgentHooksInstallSummary", L"HasAgentHooksInstallSummary");
+ _RunHooksWtaAsync(L"hooks uninstall --cli codex");
+ }
+
winrt::fire_and_forget AIAgentsViewModel::_RunHooksWtaAsync(std::wstring wtaArgs)
{
auto strongThis = get_strong();
diff --git a/src/cascadia/TerminalSettingsEditor/AIAgentsViewModel.h b/src/cascadia/TerminalSettingsEditor/AIAgentsViewModel.h
index e0c791a78..d1ed3ed99 100644
--- a/src/cascadia/TerminalSettingsEditor/AIAgentsViewModel.h
+++ b/src/cascadia/TerminalSettingsEditor/AIAgentsViewModel.h
@@ -126,9 +126,10 @@ namespace winrt::Microsoft::Terminal::Settings::Editor::implementation
bool IsCopilotCliDetected() const noexcept { return _copilotCliDetected; }
bool IsClaudeCliDetected() const noexcept { return _claudeCliDetected; }
bool IsGeminiCliDetected() const noexcept { return _geminiCliDetected; }
+ bool IsCodexCliDetected() const noexcept { return _codexCliDetected; }
bool IsAnyAgentCliDetected() const noexcept
{
- return _copilotCliDetected || _claudeCliDetected || _geminiCliDetected;
+ return _copilotCliDetected || _claudeCliDetected || _geminiCliDetected || _codexCliDetected;
}
// Per-CLI "row visible" flags — true when the CLI has any wt-agent-hooks
// state on disk (marketplace registered OR plugin installed). The
@@ -137,14 +138,17 @@ namespace winrt::Microsoft::Terminal::Settings::Editor::implementation
bool ShowCopilotHookRow() const noexcept { return _showCopilotHookRow; }
bool ShowClaudeHookRow() const noexcept { return _showClaudeHookRow; }
bool ShowGeminiHookRow() const noexcept { return _showGeminiHookRow; }
+ bool ShowCodexHookRow() const noexcept { return _showCodexHookRow; }
// Detail text shown under the CLI name when state isn't fully
// installed. Empty for fully-installed CLIs (subtitle is hidden in XAML).
winrt::hstring CopilotHooksSubtitle() const { return _copilotHooksSubtitle; }
winrt::hstring ClaudeHooksSubtitle() const { return _claudeHooksSubtitle; }
winrt::hstring GeminiHooksSubtitle() const { return _geminiHooksSubtitle; }
+ winrt::hstring CodexHooksSubtitle() const { return _codexHooksSubtitle; }
bool ShowCopilotHooksSubtitle() const noexcept { return !_copilotHooksSubtitle.empty(); }
bool ShowClaudeHooksSubtitle() const noexcept { return !_claudeHooksSubtitle.empty(); }
bool ShowGeminiHooksSubtitle() const noexcept { return !_geminiHooksSubtitle.empty(); }
+ bool ShowCodexHooksSubtitle() const noexcept { return !_codexHooksSubtitle.empty(); }
bool CanInstallAgentHooks() const noexcept
{
return IsAnyAgentCliDetected() && !IsAgentSessionHooksPolicyLocked();
@@ -158,6 +162,7 @@ namespace winrt::Microsoft::Terminal::Settings::Editor::implementation
void RemoveCopilotHooks();
void RemoveClaudeHooks();
void RemoveGeminiHooks();
+ void RemoveCodexHooks();
private:
Model::GlobalAppSettings _GlobalSettings;
@@ -212,15 +217,18 @@ namespace winrt::Microsoft::Terminal::Settings::Editor::implementation
bool _copilotCliDetected{ false };
bool _claudeCliDetected{ false };
bool _geminiCliDetected{ false };
+ bool _codexCliDetected{ false };
// Row visibility — true when the CLI has any wt-agent-hooks state
// on disk (marketplace registered OR plugin installed).
bool _showCopilotHookRow{ false };
bool _showClaudeHookRow{ false };
bool _showGeminiHookRow{ false };
+ bool _showCodexHookRow{ false };
// Subtitle text per CLI; empty for fully-installed CLIs.
winrt::hstring _copilotHooksSubtitle;
winrt::hstring _claudeHooksSubtitle;
winrt::hstring _geminiHooksSubtitle;
+ winrt::hstring _codexHooksSubtitle;
bool _installingAgentHooks{ false };
bool _refreshingAgentHooks{ false };
winrt::hstring _agentHooksInstallSummary;
diff --git a/src/cascadia/TerminalSettingsEditor/AIAgentsViewModel.idl b/src/cascadia/TerminalSettingsEditor/AIAgentsViewModel.idl
index 4ae8f9f65..5ff54558e 100644
--- a/src/cascadia/TerminalSettingsEditor/AIAgentsViewModel.idl
+++ b/src/cascadia/TerminalSettingsEditor/AIAgentsViewModel.idl
@@ -113,16 +113,20 @@ namespace Microsoft.Terminal.Settings.Editor
Boolean IsCopilotCliDetected { get; };
Boolean IsClaudeCliDetected { get; };
Boolean IsGeminiCliDetected { get; };
+ Boolean IsCodexCliDetected { get; };
Boolean IsAnyAgentCliDetected { get; };
Boolean ShowCopilotHookRow { get; };
Boolean ShowClaudeHookRow { get; };
Boolean ShowGeminiHookRow { get; };
+ Boolean ShowCodexHookRow { get; };
String CopilotHooksSubtitle { get; };
String ClaudeHooksSubtitle { get; };
String GeminiHooksSubtitle { get; };
+ String CodexHooksSubtitle { get; };
Boolean ShowCopilotHooksSubtitle { get; };
Boolean ShowClaudeHooksSubtitle { get; };
Boolean ShowGeminiHooksSubtitle { get; };
+ Boolean ShowCodexHooksSubtitle { get; };
Boolean CanInstallAgentHooks { get; };
Boolean IsInstallingAgentHooks { get; };
String AgentHooksInstallSummary { get; };
@@ -135,5 +139,6 @@ namespace Microsoft.Terminal.Settings.Editor
void RemoveCopilotHooks();
void RemoveClaudeHooks();
void RemoveGeminiHooks();
+ void RemoveCodexHooks();
}
}
diff --git a/src/cascadia/TerminalSettingsEditor/Resources/de-DE/Resources.resw b/src/cascadia/TerminalSettingsEditor/Resources/de-DE/Resources.resw
index 2aaad80ea..80855ad78 100644
--- a/src/cascadia/TerminalSettingsEditor/Resources/de-DE/Resources.resw
+++ b/src/cascadia/TerminalSettingsEditor/Resources/de-DE/Resources.resw
@@ -2793,7 +2793,7 @@
Agent-HooksSection header for the Agent Hooks installer (lets users install hook scripts that report agent CLI activity to the agent pane).
Hook-Skripte für Agenten installierenHeader for the Agent Hooks install section.
Sitzungen über Agenten hinweg verfolgen. Erforderlich für die Verwaltung von Agentensitzungen.Description for the Agent Hooks install section.
- Hooks installierenButton label that triggers installation of the wt-agent-hooks plugin/extension into all detected agent CLIs (Copilot, Claude, Gemini).
+ Hooks installierenButton label that triggers installation of the wt-agent-hooks plugin/extension into all detected agent CLIs (Copilot, Claude, Codex, Gemini).
AktualisierenButton label that re-detects which agent CLIs are present and which already have hooks installed.
Beim Schließen warnenHeader for a dropdown controlling when to show a confirmation dialog before closing.
Legt fest, wann vor dem Schließen von Registerkarten oder Fenstern ein Bestätigungsdialog angezeigt wird. „Immer“ zeigt den Dialog auch beim Schließen eines einzelnen Bereichs an.Help text associated with Globals_ConfirmOnClose. "Always" refers to Globals_ConfirmOnCloseAlways.Content.
@@ -2822,6 +2822,14 @@
Gemini hooks werden entfernt...
Status summary shown while removing agent session tracking hooks for Gemini. {Locked="Gemini","hooks"}
+
+ Codex hooks werden entfernt...
+ Status summary shown while removing agent session tracking hooks for Codex. {Locked="Codex","hooks"}
+
+
+ Führen Sie nach der Installation /hooks in Codex aus und genehmigen Sie die neuen Ereignishandler, um ihnen zu vertrauen.
+ One-line reminder shown under the Codex CLI row in the Agent Hooks section. Codex requires per-command trust before hook handlers run; users must execute /hooks inside Codex and approve. {Locked="/hooks","Codex"}
+
Fehler: wta.exe konnte nicht gefunden werden
Failure summary shown when the Settings UI cannot find the WTA helper executable. {Locked="wta.exe"}
diff --git a/src/cascadia/TerminalSettingsEditor/Resources/en-US/Resources.resw b/src/cascadia/TerminalSettingsEditor/Resources/en-US/Resources.resw
index d3aede7d4..671e607d8 100644
--- a/src/cascadia/TerminalSettingsEditor/Resources/en-US/Resources.resw
+++ b/src/cascadia/TerminalSettingsEditor/Resources/en-US/Resources.resw
@@ -2941,6 +2941,14 @@ In this context, "agent" refers to an AI agent (e.g. Copilot, Claude, Gemini), n
Removing Gemini hooks...
Status summary shown while removing agent session tracking hooks for Gemini. {Locked="Gemini","hooks"}
+
+ Removing Codex hooks...
+ Status summary shown while removing agent session tracking hooks for Codex. {Locked="Codex","hooks"}
+
+
+ After installing, run /hooks in Codex and approve to trust the new event handlers.
+ One-line reminder shown under the Codex CLI row in the Agent Hooks section. Codex requires per-command trust before hook handlers run; users must execute /hooks inside Codex and approve. {Locked="/hooks","Codex"}
+
Failed: could not locate wta.exe
Failure summary shown when the Settings UI cannot find the WTA helper executable. {Locked="wta.exe"}
diff --git a/src/cascadia/TerminalSettingsEditor/Resources/es-ES/Resources.resw b/src/cascadia/TerminalSettingsEditor/Resources/es-ES/Resources.resw
index dad9ee6e0..dbe7d57c7 100644
--- a/src/cascadia/TerminalSettingsEditor/Resources/es-ES/Resources.resw
+++ b/src/cascadia/TerminalSettingsEditor/Resources/es-ES/Resources.resw
@@ -2793,7 +2793,7 @@
Hooks del agenteSection header for the Agent Hooks installer (lets users install hook scripts that report agent CLI activity to the agent pane).
Instalar hooks del agenteHeader for the Agent Hooks install section.
Realiza un seguimiento de las sesiones entre agentes. Necesario para la gestión de sesiones del agente.Description for the Agent Hooks install section.
- Instalar hooksButton label that triggers installation of the wt-agent-hooks plugin/extension into all detected agent CLIs (Copilot, Claude, Gemini).
+ Instalar hooksButton label that triggers installation of the wt-agent-hooks plugin/extension into all detected agent CLIs (Copilot, Claude, Codex, Gemini).
ActualizarButton label that re-detects which agent CLIs are present and which already have hooks installed.
Advertir al cerrarHeader for a dropdown controlling when to show a confirmation dialog before closing.
Controla cuándo aparece un diálogo de confirmación antes de cerrar pestañas o ventanas. "Siempre" muestra el diálogo al cerrar cualquier panel.Help text associated with Globals_ConfirmOnClose. "Always" refers to Globals_ConfirmOnCloseAlways.Content.
@@ -2822,6 +2822,14 @@
Quitando hooks de Gemini...
Status summary shown while removing agent session tracking hooks for Gemini. {Locked="Gemini","hooks"}
+
+ Quitando hooks de Codex...
+ Status summary shown while removing agent session tracking hooks for Codex. {Locked="Codex","hooks"}
+
+
+ Después de instalar, ejecuta /hooks en Codex y aprueba para confiar en los nuevos controladores de eventos.
+ One-line reminder shown under the Codex CLI row in the Agent Hooks section. Codex requires per-command trust before hook handlers run; users must execute /hooks inside Codex and approve. {Locked="/hooks","Codex"}
+
Error: no se pudo encontrar wta.exe
Failure summary shown when the Settings UI cannot find the WTA helper executable. {Locked="wta.exe"}
diff --git a/src/cascadia/TerminalSettingsEditor/Resources/fr-FR/Resources.resw b/src/cascadia/TerminalSettingsEditor/Resources/fr-FR/Resources.resw
index 4eccff2fa..c36949156 100644
--- a/src/cascadia/TerminalSettingsEditor/Resources/fr-FR/Resources.resw
+++ b/src/cascadia/TerminalSettingsEditor/Resources/fr-FR/Resources.resw
@@ -2794,7 +2794,7 @@
Hooks d'agentSection header for the Agent Hooks installer (lets users install hook scripts that report agent CLI activity to the agent pane).
Installer les scripts de hook d'agentHeader for the Agent Hooks install section.
Suivez les sessions sur les agents. Requis pour la gestion des sessions d'agent.Description for the Agent Hooks install section.
- Installer les hooksButton label that triggers installation of the wt-agent-hooks plugin/extension into all detected agent CLIs (Copilot, Claude, Gemini).
+ Installer les hooksButton label that triggers installation of the wt-agent-hooks plugin/extension into all detected agent CLIs (Copilot, Claude, Codex, Gemini).
ActualiserButton label that re-detects which agent CLIs are present and which already have hooks installed.
Avertir lors de la fermetureHeader for a dropdown controlling when to show a confirmation dialog before closing.
Détermine quand une boîte de dialogue de confirmation s'affiche avant de fermer des onglets ou des fenêtres. « Toujours » affiche la boîte de dialogue lors de la fermeture de n'importe quel volet.Help text associated with Globals_ConfirmOnClose. "Always" refers to Globals_ConfirmOnCloseAlways.Content.
@@ -2823,6 +2823,14 @@
Suppression des hooks Gemini...
Status summary shown while removing agent session tracking hooks for Gemini. {Locked="Gemini","hooks"}
+
+ Suppression des hooks Codex...
+ Status summary shown while removing agent session tracking hooks for Codex. {Locked="Codex","hooks"}
+
+
+ Après l'installation, exécutez /hooks dans Codex et approuvez pour faire confiance aux nouveaux gestionnaires d'événements.
+ One-line reminder shown under the Codex CLI row in the Agent Hooks section. Codex requires per-command trust before hook handlers run; users must execute /hooks inside Codex and approve. {Locked="/hooks","Codex"}
+
Échec : impossible de trouver wta.exe
Failure summary shown when the Settings UI cannot find the WTA helper executable. {Locked="wta.exe"}
diff --git a/src/cascadia/TerminalSettingsEditor/Resources/it-IT/Resources.resw b/src/cascadia/TerminalSettingsEditor/Resources/it-IT/Resources.resw
index c6f057bd9..cc2debaee 100644
--- a/src/cascadia/TerminalSettingsEditor/Resources/it-IT/Resources.resw
+++ b/src/cascadia/TerminalSettingsEditor/Resources/it-IT/Resources.resw
@@ -2822,6 +2822,14 @@
Rimozione degli hooks Gemini...
Status summary shown while removing agent session tracking hooks for Gemini. {Locked="Gemini","hooks"}
+
+ Rimozione degli hooks Codex...
+ Status summary shown while removing agent session tracking hooks for Codex. {Locked="Codex","hooks"}
+
+
+ Dopo l'installazione, esegui /hooks in Codex e approva per considerare attendibili i nuovi gestori di eventi.
+ One-line reminder shown under the Codex CLI row in the Agent Hooks section. Codex requires per-command trust before hook handlers run; users must execute /hooks inside Codex and approve. {Locked="/hooks","Codex"}
+
Operazione non riuscita: impossibile trovare wta.exe
Failure summary shown when the Settings UI cannot find the WTA helper executable. {Locked="wta.exe"}
diff --git a/src/cascadia/TerminalSettingsEditor/Resources/ja-JP/Resources.resw b/src/cascadia/TerminalSettingsEditor/Resources/ja-JP/Resources.resw
index da9a40057..7c7dc4d26 100644
--- a/src/cascadia/TerminalSettingsEditor/Resources/ja-JP/Resources.resw
+++ b/src/cascadia/TerminalSettingsEditor/Resources/ja-JP/Resources.resw
@@ -2793,7 +2793,7 @@
エージェント HooksSection header for the Agent Hooks installer (lets users install hook scripts that report agent CLI activity to the agent pane).
エージェント Hook スクリプトのインストールHeader for the Agent Hooks install section.
エージェント間でセッションを追跡します。エージェントのセッション管理に必要です。Help text shown under the expandable section header.
- Hooks をインストールButton label that triggers installation of the wt-agent-hooks plugin/extension into all detected agent CLIs (Copilot, Claude, Gemini).
+ Hooks をインストールButton label that triggers installation of the wt-agent-hooks plugin/extension into all detected agent CLIs (Copilot, Claude, Codex, Gemini).
再読み込みButton label that re-detects which agent CLIs are present and which already have hooks installed.
閉じる前に警告するHeader for a dropdown controlling when to show a confirmation dialog before closing.
タブやウィンドウを閉じる前に確認ダイアログを表示する条件を制御します。"常に" を選ぶと、任意のペインを閉じるときにダイアログが表示されます。Help text associated with Globals_ConfirmOnClose. "Always" refers to Globals_ConfirmOnCloseAlways.Content.
@@ -2822,6 +2822,14 @@
Gemini hooks を削除しています...
Status summary shown while removing agent session tracking hooks for Gemini. {Locked="Gemini","hooks"}
+
+ Codex hooks を削除しています...
+ Status summary shown while removing agent session tracking hooks for Codex. {Locked="Codex","hooks"}
+
+
+ インストール後、Codex で /hooks を実行し、承認して新しいイベント ハンドラーを信頼してください。
+ One-line reminder shown under the Codex CLI row in the Agent Hooks section. Codex requires per-command trust before hook handlers run; users must execute /hooks inside Codex and approve. {Locked="/hooks","Codex"}
+
失敗: wta.exe が見つかりませんでした
Failure summary shown when the Settings UI cannot find the WTA helper executable. {Locked="wta.exe"}
diff --git a/src/cascadia/TerminalSettingsEditor/Resources/ko-KR/Resources.resw b/src/cascadia/TerminalSettingsEditor/Resources/ko-KR/Resources.resw
index 7a90d8ccd..be3dced61 100644
--- a/src/cascadia/TerminalSettingsEditor/Resources/ko-KR/Resources.resw
+++ b/src/cascadia/TerminalSettingsEditor/Resources/ko-KR/Resources.resw
@@ -2846,7 +2846,7 @@
Hooks 설치
- Button label that triggers installation of the wt-agent-hooks plugin/extension into all detected agent CLIs (Copilot, Claude, Gemini).
+ Button label that triggers installation of the wt-agent-hooks plugin/extension into all detected agent CLIs (Copilot, Claude, Codex, Gemini).
새로 고침
@@ -2900,6 +2900,14 @@
Gemini hooks 제거 중...
Status summary shown while removing agent session tracking hooks for Gemini. {Locked="Gemini","hooks"}
+
+ Codex hooks 제거 중...
+ Status summary shown while removing agent session tracking hooks for Codex. {Locked="Codex","hooks"}
+
+
+ 설치 후 Codex에서 /hooks를 실행하고 승인하여 새 이벤트 처리기를 신뢰하세요.
+ One-line reminder shown under the Codex CLI row in the Agent Hooks section. Codex requires per-command trust before hook handlers run; users must execute /hooks inside Codex and approve. {Locked="/hooks","Codex"}
+
실패: wta.exe를 찾을 수 없습니다
Failure summary shown when the Settings UI cannot find the WTA helper executable. {Locked="wta.exe"}
diff --git a/src/cascadia/TerminalSettingsEditor/Resources/pt-BR/Resources.resw b/src/cascadia/TerminalSettingsEditor/Resources/pt-BR/Resources.resw
index f6ed2d4bb..b1adda6ad 100644
--- a/src/cascadia/TerminalSettingsEditor/Resources/pt-BR/Resources.resw
+++ b/src/cascadia/TerminalSettingsEditor/Resources/pt-BR/Resources.resw
@@ -2877,6 +2877,14 @@
Removendo hooks do Gemini...
Status summary shown while removing agent session tracking hooks for Gemini. {Locked="Gemini","hooks"}
+
+ Removendo hooks do Codex...
+ Status summary shown while removing agent session tracking hooks for Codex. {Locked="Codex","hooks"}
+
+
+ Após instalar, execute /hooks no Codex e aprove para confiar nos novos manipuladores de eventos.
+ One-line reminder shown under the Codex CLI row in the Agent Hooks section. Codex requires per-command trust before hook handlers run; users must execute /hooks inside Codex and approve. {Locked="/hooks","Codex"}
+
Falha: não foi possível localizar wta.exe
Failure summary shown when the Settings UI cannot find the WTA helper executable. {Locked="wta.exe"}
diff --git a/src/cascadia/TerminalSettingsEditor/Resources/qps-ploc/Resources.resw b/src/cascadia/TerminalSettingsEditor/Resources/qps-ploc/Resources.resw
index 6771d7506..260e1f576 100644
--- a/src/cascadia/TerminalSettingsEditor/Resources/qps-ploc/Resources.resw
+++ b/src/cascadia/TerminalSettingsEditor/Resources/qps-ploc/Resources.resw
@@ -2786,7 +2786,7 @@
Left
Value shown in the agent pane position dropdown (selects which side of the terminal the agent pane opens on). Translation should match the existing FreOverlay_PanePositionLeft string used in the first-run experience.
- NeverOption associated with Globals_ConfirmOnClose. "Never" means that the system will never display a warning when closing.Pane positionHeader for the setting that controls where the agent pane appears relative to the active pane.Detect command errors and suggest fixes automatically.Supplementary description for the auto-error-detection setting.Track sessions across agents. Required for agent session management.Help text shown under the expandable section header.Install hooksButton label that triggers installation of the wt-agent-hooks plugin/extension into all detected agent CLIs (Copilot, Claude, Gemini).
+ NeverOption associated with Globals_ConfirmOnClose. "Never" means that the system will never display a warning when closing.Pane positionHeader for the setting that controls where the agent pane appears relative to the active pane.Detect command errors and suggest fixes automatically.Supplementary description for the auto-error-detection setting.Track sessions across agents. Required for agent session management.Help text shown under the expandable section header.Install hooksButton label that triggers installation of the wt-agent-hooks plugin/extension into all detected agent CLIs (Copilot, Claude, Codex, Gemini).
This setting is managed by your organization.
Text shown when Group Policy controls this feature. Standard Windows policy notice wording. {Locked=qps-ploc,qps-ploca,qps-plocm}
@@ -2807,6 +2807,14 @@
Removing Gemini hooks...
Status summary shown while removing agent session tracking hooks for Gemini. {Locked="Gemini","hooks"}
+
+ Removing Codex hooks...
+ Status summary shown while removing agent session tracking hooks for Codex. {Locked="Codex","hooks"}
+
+
+ After installing, run /hooks in Codex and approve to trust the new event handlers.
+ One-line reminder shown under the Codex CLI row in the Agent Hooks section. Codex requires per-command trust before hook handlers run; users must execute /hooks inside Codex and approve. {Locked="/hooks","Codex"}
+
Failed: could not locate wta.exe
Failure summary shown when the Settings UI cannot find the WTA helper executable. {Locked="wta.exe"}
diff --git a/src/cascadia/TerminalSettingsEditor/Resources/qps-ploca/Resources.resw b/src/cascadia/TerminalSettingsEditor/Resources/qps-ploca/Resources.resw
index 6771d7506..260e1f576 100644
--- a/src/cascadia/TerminalSettingsEditor/Resources/qps-ploca/Resources.resw
+++ b/src/cascadia/TerminalSettingsEditor/Resources/qps-ploca/Resources.resw
@@ -2786,7 +2786,7 @@
Left
Value shown in the agent pane position dropdown (selects which side of the terminal the agent pane opens on). Translation should match the existing FreOverlay_PanePositionLeft string used in the first-run experience.
- NeverOption associated with Globals_ConfirmOnClose. "Never" means that the system will never display a warning when closing.Pane positionHeader for the setting that controls where the agent pane appears relative to the active pane.Detect command errors and suggest fixes automatically.Supplementary description for the auto-error-detection setting.Track sessions across agents. Required for agent session management.Help text shown under the expandable section header.Install hooksButton label that triggers installation of the wt-agent-hooks plugin/extension into all detected agent CLIs (Copilot, Claude, Gemini).
+ NeverOption associated with Globals_ConfirmOnClose. "Never" means that the system will never display a warning when closing.Pane positionHeader for the setting that controls where the agent pane appears relative to the active pane.Detect command errors and suggest fixes automatically.Supplementary description for the auto-error-detection setting.Track sessions across agents. Required for agent session management.Help text shown under the expandable section header.Install hooksButton label that triggers installation of the wt-agent-hooks plugin/extension into all detected agent CLIs (Copilot, Claude, Codex, Gemini).
This setting is managed by your organization.
Text shown when Group Policy controls this feature. Standard Windows policy notice wording. {Locked=qps-ploc,qps-ploca,qps-plocm}
@@ -2807,6 +2807,14 @@
Removing Gemini hooks...
Status summary shown while removing agent session tracking hooks for Gemini. {Locked="Gemini","hooks"}
+
+ Removing Codex hooks...
+ Status summary shown while removing agent session tracking hooks for Codex. {Locked="Codex","hooks"}
+
+
+ After installing, run /hooks in Codex and approve to trust the new event handlers.
+ One-line reminder shown under the Codex CLI row in the Agent Hooks section. Codex requires per-command trust before hook handlers run; users must execute /hooks inside Codex and approve. {Locked="/hooks","Codex"}
+
Failed: could not locate wta.exe
Failure summary shown when the Settings UI cannot find the WTA helper executable. {Locked="wta.exe"}
diff --git a/src/cascadia/TerminalSettingsEditor/Resources/qps-plocm/Resources.resw b/src/cascadia/TerminalSettingsEditor/Resources/qps-plocm/Resources.resw
index 6771d7506..260e1f576 100644
--- a/src/cascadia/TerminalSettingsEditor/Resources/qps-plocm/Resources.resw
+++ b/src/cascadia/TerminalSettingsEditor/Resources/qps-plocm/Resources.resw
@@ -2786,7 +2786,7 @@
Left
Value shown in the agent pane position dropdown (selects which side of the terminal the agent pane opens on). Translation should match the existing FreOverlay_PanePositionLeft string used in the first-run experience.
- NeverOption associated with Globals_ConfirmOnClose. "Never" means that the system will never display a warning when closing.Pane positionHeader for the setting that controls where the agent pane appears relative to the active pane.Detect command errors and suggest fixes automatically.Supplementary description for the auto-error-detection setting.Track sessions across agents. Required for agent session management.Help text shown under the expandable section header.Install hooksButton label that triggers installation of the wt-agent-hooks plugin/extension into all detected agent CLIs (Copilot, Claude, Gemini).
+ NeverOption associated with Globals_ConfirmOnClose. "Never" means that the system will never display a warning when closing.Pane positionHeader for the setting that controls where the agent pane appears relative to the active pane.Detect command errors and suggest fixes automatically.Supplementary description for the auto-error-detection setting.Track sessions across agents. Required for agent session management.Help text shown under the expandable section header.Install hooksButton label that triggers installation of the wt-agent-hooks plugin/extension into all detected agent CLIs (Copilot, Claude, Codex, Gemini).
This setting is managed by your organization.
Text shown when Group Policy controls this feature. Standard Windows policy notice wording. {Locked=qps-ploc,qps-ploca,qps-plocm}
@@ -2807,6 +2807,14 @@
Removing Gemini hooks...
Status summary shown while removing agent session tracking hooks for Gemini. {Locked="Gemini","hooks"}
+
+ Removing Codex hooks...
+ Status summary shown while removing agent session tracking hooks for Codex. {Locked="Codex","hooks"}
+
+
+ After installing, run /hooks in Codex and approve to trust the new event handlers.
+ One-line reminder shown under the Codex CLI row in the Agent Hooks section. Codex requires per-command trust before hook handlers run; users must execute /hooks inside Codex and approve. {Locked="/hooks","Codex"}
+
Failed: could not locate wta.exe
Failure summary shown when the Settings UI cannot find the WTA helper executable. {Locked="wta.exe"}
diff --git a/src/cascadia/TerminalSettingsEditor/Resources/ru-RU/Resources.resw b/src/cascadia/TerminalSettingsEditor/Resources/ru-RU/Resources.resw
index 907cd04b8..9b6e0bb0c 100644
--- a/src/cascadia/TerminalSettingsEditor/Resources/ru-RU/Resources.resw
+++ b/src/cascadia/TerminalSettingsEditor/Resources/ru-RU/Resources.resw
@@ -2877,6 +2877,14 @@
Удаление Gemini hooks...
Status summary shown while removing agent session tracking hooks for Gemini. {Locked="Gemini","hooks"}
+
+ Удаление Codex hooks...
+ Status summary shown while removing agent session tracking hooks for Codex. {Locked="Codex","hooks"}
+
+
+ После установки выполните /hooks в Codex и подтвердите, чтобы доверять новым обработчикам событий.
+ One-line reminder shown under the Codex CLI row in the Agent Hooks section. Codex requires per-command trust before hook handlers run; users must execute /hooks inside Codex and approve. {Locked="/hooks","Codex"}
+
Сбой: не удалось найти wta.exe
Failure summary shown when the Settings UI cannot find the WTA helper executable. {Locked="wta.exe"}
diff --git a/src/cascadia/TerminalSettingsEditor/Resources/sr-Cyrl-RS/Resources.resw b/src/cascadia/TerminalSettingsEditor/Resources/sr-Cyrl-RS/Resources.resw
index a08a08e1d..3d385da89 100644
--- a/src/cascadia/TerminalSettingsEditor/Resources/sr-Cyrl-RS/Resources.resw
+++ b/src/cascadia/TerminalSettingsEditor/Resources/sr-Cyrl-RS/Resources.resw
@@ -769,7 +769,7 @@
Help text shown under the expandable section header.
Инсталирај Hooks
- Button label that triggers installation of the wt-agent-hooks plugin/extension into all detected agent CLIs (Copilot, Claude, Gemini).
+ Button label that triggers installation of the wt-agent-hooks plugin/extension into all detected agent CLIs (Copilot, Claude, Codex, Gemini).
Освежи
Button label that re-detects which agent CLIs are present and which already have hooks installed.
@@ -2898,6 +2898,14 @@
Уклањање Gemini hooks...
Status summary shown while removing agent session tracking hooks for Gemini. {Locked="Gemini","hooks"}
+
+ Уклањање Codex hooks...
+ Status summary shown while removing agent session tracking hooks for Codex. {Locked="Codex","hooks"}
+
+
+ Након инсталације, покрените /hooks у Codex-у и одобрите да бисте веровали новим обрађивачима догађаја.
+ One-line reminder shown under the Codex CLI row in the Agent Hooks section. Codex requires per-command trust before hook handlers run; users must execute /hooks inside Codex and approve. {Locked="/hooks","Codex"}
+
Неуспех: није могуће пронаћи wta.exe
Failure summary shown when the Settings UI cannot find the WTA helper executable. {Locked="wta.exe"}
diff --git a/src/cascadia/TerminalSettingsEditor/Resources/uk-UA/Resources.resw b/src/cascadia/TerminalSettingsEditor/Resources/uk-UA/Resources.resw
index 568203d78..c69d8f4ea 100644
--- a/src/cascadia/TerminalSettingsEditor/Resources/uk-UA/Resources.resw
+++ b/src/cascadia/TerminalSettingsEditor/Resources/uk-UA/Resources.resw
@@ -2516,7 +2516,7 @@
Hooks агентаSection header for the Agent Hooks installer (lets users install hook scripts that report agent CLI activity to the agent pane).
Установити скрипти Hooks агентаHeader for the Agent Hooks install section.
Відстежуйте сеанси між агентами. Потрібно для керування сеансами агентів.Help text shown under the expandable section header.
- Установити HooksButton label that triggers installation of the wt-agent-hooks plugin/extension into all detected agent CLIs (Copilot, Claude, Gemini).
+ Установити HooksButton label that triggers installation of the wt-agent-hooks plugin/extension into all detected agent CLIs (Copilot, Claude, Codex, Gemini).
ОновитиButton label that re-detects which agent CLIs are present and which already have hooks installed.
Попереджати під час закриттяHeader for a dropdown controlling when to show a confirmation dialog before closing.
Керує тим, коли перед закриттям вкладок або вікон з'являється діалог підтвердження. Параметр "Завжди" показує діалог під час закриття будь-якої панелі.Help text associated with Globals_ConfirmOnClose. "Always" refers to Globals_ConfirmOnCloseAlways.Content.
@@ -2544,6 +2544,14 @@
Видалення Gemini hooks...
Status summary shown while removing agent session tracking hooks for Gemini. {Locked="Gemini","hooks"}
+
+ Видалення Codex hooks...
+ Status summary shown while removing agent session tracking hooks for Codex. {Locked="Codex","hooks"}
+
+
+ Після встановлення виконайте /hooks у Codex і підтвердьте, щоб довіряти новим обробникам подій.
+ One-line reminder shown under the Codex CLI row in the Agent Hooks section. Codex requires per-command trust before hook handlers run; users must execute /hooks inside Codex and approve. {Locked="/hooks","Codex"}
+
Помилка: не вдалося знайти wta.exe
Failure summary shown when the Settings UI cannot find the WTA helper executable. {Locked="wta.exe"}
diff --git a/src/cascadia/TerminalSettingsEditor/Resources/zh-CN/Resources.resw b/src/cascadia/TerminalSettingsEditor/Resources/zh-CN/Resources.resw
index 8269f6c51..be2bf7181 100644
--- a/src/cascadia/TerminalSettingsEditor/Resources/zh-CN/Resources.resw
+++ b/src/cascadia/TerminalSettingsEditor/Resources/zh-CN/Resources.resw
@@ -2841,12 +2841,12 @@
Header for the Agent Hooks install section.
- 智能体会话管理需要此功能。系统会将 Hooks 安装到检测到的每个智能体(Copilot、Claude、Gemini)中,以便它们将会话和状态报告给智能体窗格。安装后,请重启所有已打开的智能体。
- Description for the Agent Hooks install section. {Locked="Hooks","Copilot","Claude","Gemini"}
+ 智能体会话管理需要此功能。系统会将 Hooks 安装到检测到的每个智能体(Copilot、Claude、Codex、Gemini)中,以便它们将会话和状态报告给智能体窗格。安装后,请重启所有已打开的智能体。
+ Description for the Agent Hooks install section. {Locked="Hooks","Copilot","Claude","Codex","Gemini"}
安装 Hooks
- Button label that triggers installation of the wt-agent-hooks plugin/extension into all detected agent CLIs (Copilot, Claude, Gemini).
+ Button label that triggers installation of the wt-agent-hooks plugin/extension into all detected agent CLIs (Copilot, Claude, Codex, Gemini).
刷新
@@ -2899,6 +2899,14 @@
正在删除 Gemini hooks...
Status summary shown while removing agent session tracking hooks for Gemini. {Locked="Gemini","hooks"}
+
+ 正在删除 Codex hooks...
+ Status summary shown while removing agent session tracking hooks for Codex. {Locked="Codex","hooks"}
+
+
+ 安装后,在 Codex 中运行 /hooks 并批准,以信任新的事件处理程序。
+ One-line reminder shown under the Codex CLI row in the Agent Hooks section. Codex requires per-command trust before hook handlers run; users must execute /hooks inside Codex and approve. {Locked="/hooks","Codex"}
+
失败: 找不到 wta.exe
Failure summary shown when the Settings UI cannot find the WTA helper executable. {Locked="wta.exe"}
diff --git a/src/cascadia/TerminalSettingsEditor/Resources/zh-TW/Resources.resw b/src/cascadia/TerminalSettingsEditor/Resources/zh-TW/Resources.resw
index da8ec36d1..1d3555767 100644
--- a/src/cascadia/TerminalSettingsEditor/Resources/zh-TW/Resources.resw
+++ b/src/cascadia/TerminalSettingsEditor/Resources/zh-TW/Resources.resw
@@ -2793,7 +2793,7 @@
代理 HooksSection header for the Agent Hooks installer (lets users install hook scripts that report agent CLI activity to the agent pane).
安裝代理 Hook 指令碼Header for the Agent Hooks install section.
跨代理追蹤工作階段。代理工作階段管理需要此功能。Help text shown under the expandable section header.
- 安裝 HooksButton label that triggers installation of the wt-agent-hooks plugin/extension into all detected agent CLIs (Copilot, Claude, Gemini).
+ 安裝 HooksButton label that triggers installation of the wt-agent-hooks plugin/extension into all detected agent CLIs (Copilot, Claude, Codex, Gemini).
重新整理Button label that re-detects which agent CLIs are present and which already have hooks installed.
關閉前警告Header for a dropdown controlling when to show a confirmation dialog before closing.
控制在關閉索引標籤或視窗前何時顯示確認對話方塊。「一律」會在關閉任何窗格時顯示此對話方塊。Help text associated with Globals_ConfirmOnClose. "Always" refers to Globals_ConfirmOnCloseAlways.Content.
@@ -2822,6 +2822,14 @@
正在移除 Gemini hooks...
Status summary shown while removing agent session tracking hooks for Gemini. {Locked="Gemini","hooks"}
+
+ 正在移除 Codex hooks...
+ Status summary shown while removing agent session tracking hooks for Codex. {Locked="Codex","hooks"}
+
+
+ 安裝後,在 Codex 中執行 /hooks 並核准,以信任新的事件處理常式。
+ One-line reminder shown under the Codex CLI row in the Agent Hooks section. Codex requires per-command trust before hook handlers run; users must execute /hooks inside Codex and approve. {Locked="/hooks","Codex"}
+
失敗: 找不到 wta.exe
Failure summary shown when the Settings UI cannot find the WTA helper executable. {Locked="wta.exe"}
diff --git a/src/cascadia/inc/AgentHooksStatus.h b/src/cascadia/inc/AgentHooksStatus.h
index befbdd025..33d7fc863 100644
--- a/src/cascadia/inc/AgentHooksStatus.h
+++ b/src/cascadia/inc/AgentHooksStatus.h
@@ -39,7 +39,7 @@ namespace Microsoft::Terminal::AgentHooks
// One entry of `clis[]` from the JSON report.
struct CliStatus
{
- std::string name; // "copilot" | "claude" | "gemini"
+ std::string name; // "copilot" | "claude" | "gemini" | "codex"
bool binaryOnPath{ false };
std::optional binaryPath;
bool marketplaceRegistered{ false };
diff --git a/tools/wta/src/agent_hooks_installer.rs b/tools/wta/src/agent_hooks_installer.rs
index 41c7b53a8..f390c52ba 100644
--- a/tools/wta/src/agent_hooks_installer.rs
+++ b/tools/wta/src/agent_hooks_installer.rs
@@ -17,7 +17,7 @@
// -------------------------------------------
//
// The installable plugin contents live entirely under `tools/wta/wt-agent-hooks/`
-// in the repo, in three CLI-specific subtrees:
+// in the repo, in four CLI-specific subtrees:
//
// tools/wta/wt-agent-hooks/
// claude/ <- passed to `claude plugin marketplace add`
@@ -30,6 +30,11 @@
// gemini-extension/ <- passed to `gemini extensions install`
// gemini-extension.json
// hooks/{hooks.json,send-event.ps1}
+// codex/ <- passed to `codex plugin marketplace add`
+// .agents/plugins/marketplace.json <- Codex's mandatory sentinel location
+// wt-agent-hooks/ <- the plugin folder Codex copies
+// .codex-plugin/plugin.json
+// hooks/{hooks.json,send-event.ps1}
//
// The MSIX package ships this directory next to `wta.exe` (see
// `CascadiaPackage.wapproj`'s `wt-agent-hooks` Content glob), so at runtime
@@ -163,18 +168,25 @@ pub enum CliKind {
Copilot,
Claude,
Gemini,
+ Codex,
}
impl CliKind {
/// Iteration order also dictates the order rows appear in
/// `wta hooks status` output.
- pub const ALL: &'static [CliKind] = &[CliKind::Copilot, CliKind::Claude, CliKind::Gemini];
+ pub const ALL: &'static [CliKind] = &[
+ CliKind::Copilot,
+ CliKind::Claude,
+ CliKind::Gemini,
+ CliKind::Codex,
+ ];
pub fn name(self) -> &'static str {
match self {
Self::Copilot => "copilot",
Self::Claude => "claude",
Self::Gemini => "gemini",
+ Self::Codex => "codex",
}
}
@@ -183,6 +195,7 @@ impl CliKind {
"copilot" => Some(Self::Copilot),
"claude" => Some(Self::Claude),
"gemini" => Some(Self::Gemini),
+ "codex" => Some(Self::Codex),
_ => None,
}
}
@@ -194,6 +207,7 @@ impl CliKind {
Self::Claude => "claude",
Self::Copilot => "copilot",
Self::Gemini => "gemini-extension",
+ Self::Codex => "codex",
}
}
}
@@ -488,6 +502,9 @@ pub fn ensure_installed_scoped(scope: CliScope) {
if scope.includes(CliKind::Gemini) {
install_for_gemini(&home);
}
+ if scope.includes(CliKind::Codex) {
+ install_for_codex(&home);
+ }
}
/// Run the installer against a specific home directory. Split out from
@@ -497,6 +514,7 @@ fn ensure_installed_in(home: &Path) {
install_for_claude(home);
install_for_copilot(home);
install_for_gemini(home);
+ install_for_codex(home);
}
// ---------------------------------------------------------------------------
@@ -604,6 +622,101 @@ fn install_for_claude(home: &Path) {
}
}
+/// Install hooks for Codex CLI by spawning `codex plugin marketplace add`
+/// followed by `codex plugin add`. Mirrors `install_for_claude` in shape.
+///
+/// Subcommand differences vs Claude:
+/// * `codex plugin add` (not `install`)
+/// * `codex plugin remove` (not `uninstall`) — used by `uninstall_for_codex`
+/// * Marketplace metadata lives in `.agents/plugins/marketplace.json`
+/// under the bundle root (not `.claude-plugin/marketplace.json`)
+///
+/// Trust step: after install, the user must run `/hooks` inside Codex
+/// to trust the plugin before any events fire. That's documented in
+/// the slice-C README; this function returns success on registration.
+fn install_for_codex(home: &Path) {
+ let codex_dir = home.join(".codex");
+ if !codex_dir.is_dir() {
+ tracing::debug!(target: "agent_hooks", "no ~/.codex dir; Codex not present");
+ return;
+ }
+
+ let bundle_dir = match bundle::resolve_cli_dir(CliKind::Codex) {
+ Some(p) => p,
+ None => {
+ tracing::warn!(
+ target: "agent_hooks",
+ "no wt-agent-hooks/codex bundle found next to wta.exe or in dev tree; \
+ skipping Codex plugin install (set WTA_HOOKS_BUNDLE_DIR to override)",
+ );
+ return;
+ }
+ };
+
+ // Stage out of WindowsApps if necessary — Codex is Rust-native so it
+ // shouldn't hit the cpSync EPERM that bites Claude, but staging is
+ // cheap insurance and keeps the per-CLI install flow uniform.
+ let staged_dir = maybe_stage_bundle_for_codex(&bundle_dir);
+ let bundle_dir = staged_dir.as_deref().unwrap_or(&bundle_dir);
+
+ let bundle_path = bundle_dir.to_string_lossy().into_owned();
+ if let Err(e) = run_plugin_cli(
+ "codex",
+ &["plugin", "marketplace", "add", &bundle_path],
+ "agent_hooks",
+ &["already registered"],
+ ) {
+ tracing::warn!(
+ target: "agent_hooks",
+ err = %e,
+ "codex plugin marketplace add failed; aborting plugin install",
+ );
+ return;
+ }
+
+ let plugin_ref = format!("{}@{}", PLUGIN_NAME, MARKETPLACE_NAME);
+ if let Err(e) = run_plugin_cli("codex", &["plugin", "add", &plugin_ref], "agent_hooks", &[]) {
+ tracing::warn!(
+ target: "agent_hooks",
+ err = %e,
+ plugin = %plugin_ref,
+ "codex plugin add failed",
+ );
+ }
+}
+
+/// WindowsApps -> LOCALAPPDATA staging for Codex bundles. Mirrors
+/// `maybe_stage_bundle_for_claude`; see that function's comment for
+/// rationale.
+fn maybe_stage_bundle_for_codex(source: &Path) -> Option {
+ if !is_under_windows_apps(source) {
+ return None;
+ }
+ let root = crate::runtime_paths::intelligent_terminal_root()?;
+ let staged = root.join(STAGING_SUBDIR).join(CliKind::Codex.dir_name());
+ match restage_bundle_dir(source, &staged) {
+ Ok(()) => {
+ tracing::info!(
+ target: "agent_hooks",
+ source = %source.display(),
+ staged = %staged.display(),
+ "restaged codex bundle out of WindowsApps",
+ );
+ Some(staged)
+ }
+ Err(e) => {
+ tracing::warn!(
+ target: "agent_hooks",
+ err = %e,
+ source = %source.display(),
+ staged = %staged.display(),
+ "failed to restage codex bundle out of WindowsApps; using original path",
+ );
+ None
+ }
+ }
+}
+
/// Install hooks for Copilot CLI by spawning `copilot plugin install`.
fn install_for_copilot(home: &Path) {
let copilot_dir = home.join(".copilot");
@@ -795,6 +908,7 @@ fn status_for(cli: CliKind, home: Option<&Path>) -> CliStatus {
CliKind::Copilot => copilot_status(on_path, bin_path, home),
CliKind::Claude => claude_status(on_path, bin_path, home),
CliKind::Gemini => gemini_status(on_path, bin_path, home),
+ CliKind::Codex => codex_status(on_path, bin_path, home),
}
}
@@ -1156,6 +1270,7 @@ fn populate_marketplace_path(out: &mut CliStatus, cli: CliKind, home: Option<&Pa
CliKind::Copilot => copilot_marketplace_info(home),
CliKind::Claude => claude_marketplace_info(home),
CliKind::Gemini => gemini_marketplace_info(home),
+ CliKind::Codex => codex_marketplace_info(home),
};
out.marketplace_path = info.path;
out.marketplace_path_valid = info.valid;
@@ -1371,6 +1486,128 @@ fn parse_gemini_extensions_list_json(stdout: &str) -> Option {
})
}
+/// Parse `codex plugin marketplace list` plain-text output.
+/// Returns `(registered, root_path)` where `registered` is true when a
+/// row whose first whitespace-delimited column equals `wt-local`
+/// exists, and `root_path` is the remainder of that row trimmed.
+fn parse_codex_marketplace_list(stdout: &str) -> (bool, Option) {
+ for line in stdout.lines() {
+ let line = line.trim();
+ // Skip header and blank lines.
+ if line.is_empty() || line.starts_with("MARKETPLACE") {
+ continue;
+ }
+ let mut split = line.splitn(2, char::is_whitespace);
+ let name = match split.next() {
+ Some(s) => s.trim(),
+ None => continue,
+ };
+ if name == MARKETPLACE_NAME {
+ let rest = split.next().unwrap_or("").trim();
+ let path = if rest.is_empty() { None } else { Some(rest.to_string()) };
+ return (true, path);
+ }
+ }
+ (false, None)
+}
+
+/// Parse `codex plugin list` plain-text output. Returns true when a row
+/// for `wt-agent-hooks` exists AND its STATUS column starts with
+/// "installed" (not "not installed", "available", etc.).
+fn parse_codex_plugin_list(stdout: &str) -> bool {
+ // Real Codex output lists the plugin as "wt-agent-hooks@wt-local".
+ // We accept either the qualified or bare form (forward-compat).
+ let qualified = format!("{}@{}", PLUGIN_NAME, MARKETPLACE_NAME);
+ for line in stdout.lines() {
+ let line = line.trim_end();
+ if line.is_empty()
+ || line.starts_with("PLUGIN")
+ || line.starts_with("Marketplace ")
+ || line.starts_with("C:\\")
+ || line.starts_with('/')
+ || line.starts_with('.')
+ {
+ continue;
+ }
+ let mut cols = line.split_whitespace();
+ let name = match cols.next() {
+ Some(s) => s,
+ None => continue,
+ };
+ let matches = name == PLUGIN_NAME || name == qualified;
+ if !matches {
+ continue;
+ }
+ let rest: Vec<&str> = cols.collect();
+ if rest.is_empty() {
+ return false;
+ }
+ // Status column starts here. Only an "installed*" status
+ // (installed / installed, enabled / installed, disabled)
+ // counts as installed — "not installed", "available", and
+ // any other status mean the plugin is not active.
+ return rest[0].starts_with("installed");
+ }
+ false
+}
+
+/// Parse `codex plugin list` for the auto-upgrade flow. Returns
+/// `Some(InstalledInfo)` only when the wt-agent-hooks row reports an
+/// `installed*` status, extracting the version (column 3) and the
+/// enabled flag (`installed, enabled` vs `installed, disabled`).
+/// Returns `None` for "not installed" / "available" / missing rows so
+/// the caller treats the plugin as absent.
+///
+/// Sibling of [`parse_codex_plugin_list`]; that function returns a
+/// bool used by the install verifier, this one returns the richer
+/// state used by `decide_upgrade`.
+fn parse_codex_plugin_list_entry(stdout: &str) -> Option {
+ let qualified = format!("{}@{}", PLUGIN_NAME, MARKETPLACE_NAME);
+ for line in stdout.lines() {
+ let line = line.trim_end();
+ if line.is_empty()
+ || line.starts_with("PLUGIN")
+ || line.starts_with("Marketplace ")
+ || line.starts_with("C:\\")
+ || line.starts_with('/')
+ || line.starts_with('.')
+ {
+ continue;
+ }
+ let mut cols = line.split_whitespace();
+ let name = cols.next()?;
+ if name != PLUGIN_NAME && name != qualified {
+ continue;
+ }
+ let rest: Vec<&str> = cols.collect();
+ // Must start with "installed" (rules out "not installed",
+ // "available", etc.).
+ if !rest.first().map(|s| s.starts_with("installed")).unwrap_or(false) {
+ return None;
+ }
+ // Enabled unless the next status token explicitly says
+ // "disabled". Codex doesn't currently expose a disable
+ // subcommand, but be defensive in case that changes.
+ let enabled = rest
+ .get(1)
+ .map(|s| !s.starts_with("disabled"))
+ .unwrap_or(true);
+ // Version: first token after the status column that parses as
+ // semver. Skips past the status word(s) and any "-" placeholder.
+ let version = rest
+ .iter()
+ .skip(1)
+ .find_map(|t| t.parse::().ok());
+ return Some(InstalledInfo {
+ version,
+ enabled,
+ gemini_source: None,
+ gemini_type: None,
+ });
+ }
+ None
+}
+
// ---------------------------------------------------------------------------
// Public uninstall entry point (Track 2 / #18)
// ---------------------------------------------------------------------------
@@ -1398,6 +1635,7 @@ fn uninstall_for(cli: CliKind, home: Option<&Path>) -> CliUninstallResult {
CliKind::Copilot => copilot_uninstall(home),
CliKind::Claude => claude_uninstall(home),
CliKind::Gemini => gemini_uninstall(home),
+ CliKind::Codex => uninstall_for_codex(home),
}
}
@@ -1671,6 +1909,7 @@ fn legacy_staging_dirs(cli: CliKind) -> Vec {
root.join("gemini-plugin-src")
.join(GEMINI_EXTENSION_DIR_NAME),
),
+ CliKind::Codex => dirs.push(root.join("codex-plugin-src").join(MARKETPLACE_NAME)),
}
// #20-first-commit-style embedded-fallback materialization.
dirs.push(root.join("hook-bundle-fallback").join(cli.dir_name()));
@@ -2233,6 +2472,172 @@ fn paths_equivalent(a: &Path, b: &Path) -> bool {
normalize(a) == normalize(b)
}
+// ---------------------------------------------------------------------------
+// Codex status: CLI-parse path (`codex plugin marketplace list` +
+// `codex plugin list`) with a filesystem fallback when the binary
+// isn't on PATH. Both helpers default to a safe "not installed"
+// response on any IO / parse failure so runtime behavior stays
+// conservative.
+// ---------------------------------------------------------------------------
+
+fn codex_status(on_path: bool, bin_path: Option, home: Option<&Path>) -> CliStatus {
+ let mut out = CliStatus {
+ name: CliKind::Codex.name(),
+ binary_on_path: on_path,
+ binary_path: bin_path,
+ marketplace_registered: false,
+ marketplace_path: None,
+ marketplace_path_valid: false,
+ plugin_installed: false,
+ plugin_enabled: false,
+ detection_fallback: None,
+ };
+ if !on_path {
+ codex_fs_fallback(&mut out, home);
+ populate_marketplace_path(&mut out, CliKind::Codex, home);
+ return out;
+ }
+
+ let mkt = match run_plugin_cli_capture("codex", &["plugin", "marketplace", "list"]) {
+ Ok(o) if o.success => Some(parse_codex_marketplace_list(&o.stdout)),
+ Ok(_) | Err(_) => None,
+ };
+ // `--marketplace wt-local` scopes the listing to just our marketplace
+ // (the only plugin there is wt-agent-hooks). Without this flag Codex
+ // dumps every plugin from every registered marketplace (e.g. the
+ // ~150-entry `openai-curated` snapshot), which is pure noise for our
+ // parser and pollutes the master log.
+ let plugin = match run_plugin_cli_capture(
+ "codex",
+ &["plugin", "list", "--marketplace", MARKETPLACE_NAME],
+ ) {
+ Ok(o) if o.success => Some(parse_codex_plugin_list(&o.stdout)),
+ Ok(_) | Err(_) => None,
+ };
+
+ match (mkt, plugin) {
+ (Some((registered, path)), Some(installed)) => {
+ out.marketplace_registered = registered;
+ if path.is_some() {
+ out.marketplace_path = path;
+ }
+ out.plugin_installed = installed;
+ out.plugin_enabled = installed;
+ }
+ _ => {
+ codex_fs_fallback(&mut out, home);
+ }
+ }
+ populate_marketplace_path(&mut out, CliKind::Codex, home);
+ out
+}
+
+fn codex_fs_fallback(out: &mut CliStatus, home: Option<&Path>) {
+ out.detection_fallback = Some("fs");
+ let Some(home) = home else { return };
+ let cache_root = home
+ .join(".codex")
+ .join("plugins")
+ .join("cache")
+ .join(MARKETPLACE_NAME);
+
+ // Marketplace is "registered" if Codex created the per-marketplace
+ // cache dir AND something is inside it. An empty leftover dir from
+ // a prior remove should not count.
+ out.marketplace_registered = dir_has_entries(&cache_root);
+
+ let plugin_root = cache_root.join(PLUGIN_NAME);
+ let installed = dir_has_entries(&plugin_root);
+ out.plugin_installed = installed;
+ out.plugin_enabled = installed; // Codex has no separate enable flag.
+}
+
+fn dir_has_entries(p: &Path) -> bool {
+ match fs::read_dir(p) {
+ Ok(mut it) => it.next().is_some(),
+ Err(_) => false,
+ }
+}
+
+fn codex_marketplace_info(home: &Path) -> MarketplaceInfo {
+ let mut info = MarketplaceInfo { path: None, valid: false };
+ let marketplace_path = home
+ .join(".codex")
+ .join("plugins")
+ .join("cache")
+ .join(MARKETPLACE_NAME);
+ if marketplace_path.is_dir() {
+ info.path = Some(marketplace_path.to_string_lossy().into_owned());
+ info.valid = true;
+ }
+ info
+}
+
+fn uninstall_for_codex(home: Option<&Path>) -> CliUninstallResult {
+ let mut result = CliUninstallResult {
+ name: CliKind::Codex.name(),
+ attempted: false,
+ plugin_uninstalled: None,
+ marketplace_removed: None,
+ staging_dir_removed: true,
+ messages: Vec::new(),
+ };
+
+ let Some(home) = home else {
+ result.messages.push("home path not provided; skipping".into());
+ return result;
+ };
+
+ let codex_dir = home.join(".codex");
+ if !codex_dir.is_dir() {
+ result.messages.push("skipped: no ~/.codex directory".to_string());
+ return result;
+ }
+ result.attempted = true;
+
+ let plugin_ref = format!("{}@{}", PLUGIN_NAME, MARKETPLACE_NAME);
+ match run_plugin_cli(
+ "codex",
+ &["plugin", "remove", &plugin_ref],
+ "agent_hooks",
+ &["not installed"],
+ ) {
+ Ok(()) => {
+ result.plugin_uninstalled = Some(true);
+ result.messages.push("codex plugin remove succeeded".to_string());
+ }
+ Err(e) => {
+ result.plugin_uninstalled = Some(false);
+ result.messages.push(format!("codex plugin remove failed: {e}"));
+ }
+ }
+
+ match run_plugin_cli(
+ "codex",
+ &["plugin", "marketplace", "remove", MARKETPLACE_NAME],
+ "agent_hooks",
+ &[
+ "not registered",
+ "not found",
+ "not configured",
+ "not installed",
+ ],
+ ) {
+ Ok(()) => {
+ result.marketplace_removed = Some(true);
+ result.messages.push("codex plugin marketplace remove succeeded".to_string());
+ }
+ Err(e) => {
+ result.marketplace_removed = Some(false);
+ result.messages.push(format!("codex plugin marketplace remove failed: {e}"));
+ }
+ }
+
+ result.staging_dir_removed = sweep_legacy_staging_dirs(&mut result.messages, CliKind::Codex);
+
+ result
+}
+
// ---------------------------------------------------------------------------
// Auto-upgrade on IT install / upgrade
// ---------------------------------------------------------------------------
@@ -2358,6 +2763,10 @@ fn read_bundled_version(cli: CliKind) -> Option {
CliKind::Copilot | CliKind::Claude => {
dir.join("wt-agent-hooks").join(".claude-plugin").join("plugin.json")
}
+ CliKind::Codex => dir
+ .join("wt-agent-hooks")
+ .join(".codex-plugin")
+ .join("plugin.json"),
CliKind::Gemini => dir.join("gemini-extension.json"),
};
read_version_field(&manifest)
@@ -2445,6 +2854,32 @@ fn read_installed_claude() -> Option {
None
}
+/// Spawn `codex plugin list` and parse the wt-agent-hooks row to
+/// determine installed version + enabled state. Codex is a Rust
+/// binary so the list call is fast (~10ms); no PATH probe needed.
+/// Returns `None` when the spawn fails, the plugin row is missing,
+/// or the status indicates "not installed" / "available".
+fn read_installed_codex() -> Option {
+ // Scope the listing to our marketplace; otherwise Codex prints every
+ // plugin from every registered marketplace (~150 lines from the
+ // built-in `openai-curated` snapshot) which is wasted work and
+ // pollutes the master log.
+ let outcome = run_plugin_cli_capture(
+ "codex",
+ &["plugin", "list", "--marketplace", MARKETPLACE_NAME],
+ )
+ .ok()?;
+ if !outcome.success {
+ return None;
+ }
+ let payload = if !outcome.stdout.trim().is_empty() {
+ &outcome.stdout
+ } else {
+ &outcome.stderr
+ };
+ parse_codex_plugin_list_entry(payload)
+}
+
/// Read Gemini's installed extension from disk: version from
/// `gemini-extension.json`, source/type from `.gemini-extension-install.json`.
/// Pure file IO. Treats a missing metadata file as `gemini_source: None`,
@@ -2512,6 +2947,15 @@ enum UpgradeAction {
/// Copilot / Claude: rewrite stale marketplace path, then
/// `plugin update @`.
UpdatePlugin,
+ /// Codex: no `plugin update` subcommand exists and
+ /// `marketplace upgrade` only refreshes Git marketplaces (not the
+ /// local `wt-local` marketplace), so we uninstall + reinstall via
+ /// the same flow as the first-run installer. Trust hashes in
+ /// `~/.codex/config.toml` survive because they hash the hook
+ /// *command string* (with the literal `${PLUGIN_ROOT}` token, not
+ /// a resolved path), so a reinstall pointing at a different
+ /// bundle dir still validates against the cached hash.
+ CodexReinstall,
/// Gemini, source path still under the current bundle:
/// `gemini extensions update ` with trust env.
GeminiUpdateInPlace,
@@ -2549,6 +2993,7 @@ fn decide_upgrade(
}
match cli {
CliKind::Copilot | CliKind::Claude => UpgradeAction::UpdatePlugin,
+ CliKind::Codex => UpgradeAction::CodexReinstall,
CliKind::Gemini => {
// Auto-update only `local` installs; `git`/`link` are user
// configurations we don't second-guess.
@@ -2594,6 +3039,7 @@ fn gemini_source_under_bundle(source: &Path, bundle_dir: &Path) -> bool {
struct UpgradeState {
copilot: Option,
claude: Option,
+ codex: Option,
gemini: Option,
}
@@ -2602,6 +3048,7 @@ impl UpgradeState {
match cli {
CliKind::Copilot => self.copilot.as_deref(),
CliKind::Claude => self.claude.as_deref(),
+ CliKind::Codex => self.codex.as_deref(),
CliKind::Gemini => self.gemini.as_deref(),
}
}
@@ -2610,6 +3057,7 @@ impl UpgradeState {
match cli {
CliKind::Copilot => self.copilot = version,
CliKind::Claude => self.claude = version,
+ CliKind::Codex => self.codex = version,
CliKind::Gemini => self.gemini = version,
}
}
@@ -2622,6 +3070,9 @@ impl UpgradeState {
if let Some(v) = &self.claude {
m.insert("claude".into(), Value::String(v.clone()));
}
+ if let Some(v) = &self.codex {
+ m.insert("codex".into(), Value::String(v.clone()));
+ }
if let Some(v) = &self.gemini {
m.insert("gemini".into(), Value::String(v.clone()));
}
@@ -2638,6 +3089,7 @@ impl UpgradeState {
UpgradeState {
copilot: get("copilot"),
claude: get("claude"),
+ codex: get("codex"),
gemini: get("gemini"),
}
}
@@ -2860,6 +3312,11 @@ fn upgrade_one_cli(cli: CliKind, home: &Path, bundle_version: Option) {
read_installed_claude()
}
}
+ CliKind::Codex => {
+ // Codex is a Rust binary so the list call is fast; no
+ // need for the PATH presence pre-check we use for Claude.
+ read_installed_codex()
+ }
CliKind::Gemini => read_installed_gemini(home),
};
@@ -2885,6 +3342,18 @@ fn upgrade_one_cli(cli: CliKind, home: &Path, bundle_version: Option) {
UpgradeAction::UpdatePlugin => match cli {
CliKind::Copilot => upgrade_copilot(home),
CliKind::Claude => upgrade_claude(home),
+ CliKind::Codex => {
+ // Defensive: `decide_upgrade` for Codex always returns
+ // `CodexReinstall` (Codex has no `plugin update`
+ // subcommand), so this arm shouldn't fire. Log and
+ // no-op so a future regression is visible without
+ // panicking on the blocking-pool thread.
+ tracing::error!(
+ target: "agent_hooks",
+ cli = cli.name(),
+ "decide_upgrade returned UpdatePlugin for Codex; skipping (treat as bug)",
+ );
+ }
CliKind::Gemini => {
// Defensive: `decide_upgrade` is the only producer of
// `UpdatePlugin` and currently only returns it for
@@ -2901,6 +3370,7 @@ fn upgrade_one_cli(cli: CliKind, home: &Path, bundle_version: Option) {
);
}
},
+ UpgradeAction::CodexReinstall => upgrade_codex(home),
UpgradeAction::GeminiUpdateInPlace => upgrade_gemini_in_place(),
UpgradeAction::GeminiReinstall => upgrade_gemini_reinstall(home),
}
@@ -2973,6 +3443,37 @@ fn upgrade_claude(home: &Path) {
}
}
+/// Codex auto-upgrade: reinstall the plugin in place. Codex has no
+/// `plugin update` subcommand and `marketplace upgrade` only refreshes
+/// Git marketplaces (not the local `wt-local` marketplace), so we
+/// re-run the same uninstall + install flow used at first-run.
+///
+/// Trust hashes recorded in `~/.codex/config.toml` survive the
+/// reinstall as long as the hook command strings in `hooks.json`
+/// don't change — the hashes are computed over the command string
+/// (which uses the literal `${PLUGIN_ROOT}` token, not a resolved
+/// path), so they stay stable even when the bundle dir moves between
+/// MSIX version directories.
+fn upgrade_codex(home: &Path) {
+ // 1. Uninstall — `uninstall_for_codex` already tolerates
+ // "not installed" / "not registered" idempotency, so it's safe
+ // to call against a partial install state.
+ let result = uninstall_for_codex(Some(home));
+ for msg in &result.messages {
+ tracing::debug!(
+ target: "agent_hooks",
+ cli = "codex",
+ msg = %msg,
+ "codex pre-upgrade uninstall step",
+ );
+ }
+
+ // 2. Reinstall pointing at the current bundle dir. Reuse the
+ // existing install flow so we pick up the WindowsApps staging
+ // and `already registered` tolerance handling.
+ install_for_codex(home);
+}
+
fn upgrade_gemini_in_place() {
// `extensions update` upstream yargs does NOT accept `--consent` /
// `--skip-settings` (those are install-only flags). Keep
@@ -4444,6 +4945,33 @@ Registered marketplaces:
assert!(v2.get("marketplace_path_valid").is_some());
}
+ #[test]
+ fn cli_kind_codex_roundtrips() {
+ assert_eq!(CliKind::from_name("codex"), Some(CliKind::Codex));
+ assert_eq!(CliKind::from_name("CODEX"), Some(CliKind::Codex));
+ assert_eq!(CliKind::Codex.name(), "codex");
+ assert_eq!(CliKind::Codex.dir_name(), "codex");
+ assert!(CliKind::ALL.contains(&CliKind::Codex));
+ }
+
+ #[test]
+ fn bundle_resolves_codex_dir_in_dev_tree() {
+ // Dev-tree lookup walks up from CARGO_MANIFEST_DIR to find
+ // tools/wta/wt-agent-hooks//. Task 2 puts a real
+ // directory at that path, so this should resolve.
+ let resolved = bundle::resolve_cli_dir(CliKind::Codex)
+ .expect("codex bundle should resolve in dev tree");
+ assert!(
+ resolved
+ .join(".agents")
+ .join("plugins")
+ .join("marketplace.json")
+ .is_file(),
+ "resolved codex bundle should contain marketplace.json (got {})",
+ resolved.display(),
+ );
+ }
+
// ---- auto-upgrade: Version parser & ordering -----------------------
#[test]
@@ -4504,6 +5032,202 @@ Registered marketplaces:
);
}
+ #[test]
+ fn install_for_codex_skips_when_home_absent() {
+ let tmp = unique_dir("codex-home-absent");
+ // No ~/.codex created. Function should return cleanly without panic
+ // and without spawning `codex` (which may or may not be on PATH on CI).
+ install_for_codex(&tmp);
+ let _ = fs::remove_dir_all(tmp);
+ }
+
+ #[test]
+ fn install_dispatches_codex() {
+ // Smoke: dispatch on an empty HOME shouldn't panic when CliKind::Codex
+ // is in CliKind::ALL but ~/.codex doesn't exist.
+ let tmp = unique_dir("codex-dispatch");
+ ensure_installed_in(&tmp);
+ let _ = fs::remove_dir_all(tmp);
+ }
+
+ #[test]
+ fn codex_status_falls_back_when_binary_missing() {
+ let tmp_root = unique_dir("codex_status_fallback");
+ std::fs::create_dir_all(&tmp_root).unwrap();
+ let s = codex_status(false, None, Some(&tmp_root));
+ assert_eq!(s.name, "codex");
+ assert!(!s.binary_on_path);
+ assert_eq!(s.detection_fallback, Some("fs"));
+ let _ = std::fs::remove_dir_all(&tmp_root);
+ }
+
+ #[test]
+ fn codex_fs_fallback_detects_install_dirs() {
+ let tmp_root = unique_dir("codex_fs_fallback");
+ let codex_dir = tmp_root.join(".codex");
+ let cache_root = codex_dir.join("plugins").join("cache").join(MARKETPLACE_NAME);
+ let plugin_dir = cache_root.join(PLUGIN_NAME).join("0.1.0");
+ std::fs::create_dir_all(&plugin_dir).unwrap();
+
+ let mut s = CliStatus {
+ name: CliKind::Codex.name(),
+ binary_on_path: false,
+ binary_path: None,
+ marketplace_registered: false,
+ marketplace_path: None,
+ marketplace_path_valid: false,
+ plugin_installed: false,
+ plugin_enabled: false,
+ detection_fallback: None,
+ };
+ codex_fs_fallback(&mut s, Some(&tmp_root));
+ assert!(s.marketplace_registered);
+ assert!(s.plugin_installed);
+ assert!(s.plugin_enabled);
+ assert_eq!(s.detection_fallback, Some("fs"));
+ let _ = std::fs::remove_dir_all(&tmp_root);
+ }
+
+ #[test]
+ fn parse_codex_marketplace_list_finds_wt_local() {
+ let sample = "MARKETPLACE ROOT\n\
+ openai-curated https://github.com/openai/codex-marketplace\n\
+ wt-local C:\\some\\path\\to\\codex\n";
+ let (registered, path) = parse_codex_marketplace_list(sample);
+ assert!(registered);
+ assert_eq!(path.as_deref(), Some("C:\\some\\path\\to\\codex"));
+ }
+
+ #[test]
+ fn parse_codex_marketplace_list_absent() {
+ let sample = "MARKETPLACE ROOT\n\
+ openai-curated https://github.com/openai/codex-marketplace\n";
+ let (registered, path) = parse_codex_marketplace_list(sample);
+ assert!(!registered);
+ assert!(path.is_none());
+ }
+
+ #[test]
+ fn parse_codex_plugin_list_finds_wt_agent_hooks() {
+ let sample = "Marketplace `openai-curated`\n\
+ C:\\Users\\x\\.codex\\.tmp\\plugins\\.agents\\plugins\\marketplace.json\n\
+ \n\
+ PLUGIN STATUS VERSION PATH\n\
+ linear@openai-curated not installed - -\n\
+ \n\
+ Marketplace `wt-local`\n\
+ C:\\path\\to\\bundle\\.agents\\plugins\\marketplace.json\n\
+ \n\
+ PLUGIN STATUS VERSION PATH\n\
+ wt-agent-hooks@wt-local installed, enabled 0.1.0 C:\\path\n";
+ assert!(parse_codex_plugin_list(sample));
+ }
+
+ #[test]
+ fn parse_codex_plugin_list_not_installed() {
+ let sample = "Marketplace `wt-local`\n\
+ C:\\path\\.agents\\plugins\\marketplace.json\n\
+ \n\
+ PLUGIN STATUS VERSION PATH\n\
+ wt-agent-hooks@wt-local not installed - -\n";
+ assert!(!parse_codex_plugin_list(sample));
+ }
+
+ #[test]
+ fn parse_codex_plugin_list_absent_row() {
+ let sample = "Marketplace `openai-curated`\n\
+ C:\\path\\marketplace.json\n\
+ \n\
+ PLUGIN STATUS VERSION PATH\n\
+ linear@openai-curated not installed - -\n";
+ assert!(!parse_codex_plugin_list(sample));
+ }
+
+ #[test]
+ fn parse_codex_plugin_list_treats_disabled_as_installed() {
+ let sample = "Marketplace `wt-local`\n\
+ \n\
+ PLUGIN STATUS VERSION PATH\n\
+ wt-agent-hooks@wt-local installed 0.1.0 C:\\path\n";
+ // Plugin is present even if not currently enabled; we still treat
+ // it as installed so that we know there's something to clean up.
+ assert!(parse_codex_plugin_list(sample));
+ }
+
+ #[test]
+ fn parse_codex_plugin_list_entry_extracts_version_and_enabled() {
+ let sample = "Marketplace `wt-local`\n\
+ C:\\path\\to\\bundle\\.agents\\plugins\\marketplace.json\n\
+ \n\
+ PLUGIN STATUS VERSION PATH\n\
+ wt-agent-hooks@wt-local installed, enabled 0.1.0 C:\\path\n";
+ let info = parse_codex_plugin_list_entry(sample).expect("expected entry");
+ assert_eq!(info.version, Some("0.1.0".parse().unwrap()));
+ assert!(info.enabled);
+ assert!(info.gemini_source.is_none());
+ assert!(info.gemini_type.is_none());
+ }
+
+ #[test]
+ fn parse_codex_plugin_list_entry_handles_bare_installed_status() {
+ // Some Codex builds may omit the ", enabled" suffix; tolerate
+ // bare "installed" and default to enabled=true.
+ let sample = "PLUGIN STATUS VERSION PATH\n\
+ wt-agent-hooks@wt-local installed 0.2.3 C:\\path\n";
+ let info = parse_codex_plugin_list_entry(sample).expect("expected entry");
+ assert_eq!(info.version, Some("0.2.3".parse().unwrap()));
+ assert!(info.enabled);
+ }
+
+ #[test]
+ fn parse_codex_plugin_list_entry_marks_disabled_status() {
+ // Defensive: if a future Codex release surfaces a disabled
+ // status, the upgrade flow must back off (decide_upgrade
+ // returns Skip(Disabled) when enabled=false).
+ let sample = "PLUGIN STATUS VERSION PATH\n\
+ wt-agent-hooks@wt-local installed, disabled 0.1.0 C:\\path\n";
+ let info = parse_codex_plugin_list_entry(sample).expect("expected entry");
+ assert_eq!(info.version, Some("0.1.0".parse().unwrap()));
+ assert!(!info.enabled);
+ }
+
+ #[test]
+ fn parse_codex_plugin_list_entry_returns_none_when_not_installed() {
+ let sample = "PLUGIN STATUS VERSION PATH\n\
+ wt-agent-hooks@wt-local not installed - -\n";
+ assert!(parse_codex_plugin_list_entry(sample).is_none());
+ }
+
+ #[test]
+ fn parse_codex_plugin_list_entry_returns_none_when_row_absent() {
+ let sample = "PLUGIN STATUS VERSION PATH\n\
+ linear@openai-curated not installed - -\n";
+ assert!(parse_codex_plugin_list_entry(sample).is_none());
+ }
+
+ #[test]
+ fn parse_codex_plugin_list_entry_returns_none_when_version_unparseable() {
+ // Status is installed but version column is "-" — InstalledInfo
+ // returned with version=None so decide_upgrade conservative-skips
+ // via UnknownInstalledVersion.
+ let sample = "PLUGIN STATUS VERSION PATH\n\
+ wt-agent-hooks@wt-local installed, enabled - C:\\path\n";
+ let info = parse_codex_plugin_list_entry(sample).expect("expected entry");
+ assert!(info.version.is_none());
+ assert!(info.enabled);
+ }
+
+ #[test]
+ fn uninstall_for_codex_skips_when_home_absent() {
+ let parent = unique_dir("uninstall_codex_absent");
+ let result = uninstall_for_codex(Some(&parent));
+ assert_eq!(result.name, "codex");
+ assert!(!result.attempted);
+ assert!(result.plugin_uninstalled.is_none());
+ assert!(result.marketplace_removed.is_none());
+ let _ = std::fs::remove_dir_all(&parent);
+ }
+
#[test]
fn read_version_field_returns_none_on_garbage_or_missing() {
let dir = unique_dir("read-version-bad");
@@ -4725,6 +5449,55 @@ Registered marketplaces:
}
}
+ #[test]
+ fn decide_codex_upgrade_via_reinstall() {
+ // Codex outdated installed → CodexReinstall (Codex has no
+ // `plugin update` subcommand).
+ let info = installed("0.1.0", true);
+ let a = decide_upgrade(
+ CliKind::Codex,
+ Some("0.1.1".parse().unwrap()),
+ Some(&info),
+ None,
+ );
+ assert_eq!(a, UpgradeAction::CodexReinstall);
+ }
+
+ #[test]
+ fn decide_codex_skip_when_up_to_date() {
+ let info = installed("0.1.1", true);
+ let a = decide_upgrade(
+ CliKind::Codex,
+ Some("0.1.1".parse().unwrap()),
+ Some(&info),
+ None,
+ );
+ assert_eq!(a, UpgradeAction::Skip(SkipReason::UpToDate));
+ }
+
+ #[test]
+ fn decide_codex_skip_when_disabled() {
+ let info = installed("0.1.0", false);
+ let a = decide_upgrade(
+ CliKind::Codex,
+ Some("0.1.1".parse().unwrap()),
+ Some(&info),
+ None,
+ );
+ assert_eq!(a, UpgradeAction::Skip(SkipReason::Disabled));
+ }
+
+ #[test]
+ fn decide_codex_skip_when_not_installed() {
+ let a = decide_upgrade(
+ CliKind::Codex,
+ Some("0.1.1".parse().unwrap()),
+ None,
+ None,
+ );
+ assert_eq!(a, UpgradeAction::Skip(SkipReason::NotInstalled));
+ }
+
#[test]
fn decide_gemini_in_place_when_source_under_current_bundle() {
let bundle_dir = unique_dir("gemini-bundle-current");
diff --git a/tools/wta/src/agent_registry.rs b/tools/wta/src/agent_registry.rs
index cc52a0b78..afb35b3b0 100644
--- a/tools/wta/src/agent_registry.rs
+++ b/tools/wta/src/agent_registry.rs
@@ -123,7 +123,10 @@ pub const KNOWN_AGENTS: &[AgentProfile] = &[
install_hint: "npm install -g @openai/codex",
install_url: "https://github.com/openai/codex",
auth_check_command: "",
- resume_flag: "",
+ // `codex resume ` is a subcommand (not a flag);
+ // the command-synthesis template `format!("{cli} {flag} {key}")`
+ // produces `codex resume ` which Codex CLI accepts.
+ resume_flag: "resume",
auth_hint: "Run: codex auth (or set OPENAI_API_KEY)",
},
AgentProfile {
@@ -502,4 +505,15 @@ mod tests {
assert_eq!(resolve_agent_id_from_cmd("npx"), "unknown");
assert_eq!(resolve_agent_id_from_cmd("my-bot --x"), "unknown");
}
+
+ #[test]
+ fn codex_profile_advertises_resume_support() {
+ let profile = lookup_profile_by_id("codex");
+ assert_eq!(
+ profile.resume_flag, "resume",
+ "Codex CLI uses `codex resume ` (subcommand form, no dash). \
+ An empty resume_flag would make session_mgmt classify Codex rows \
+ as Class B (not-resumable) and silently break F2 Enter."
+ );
+ }
}
diff --git a/tools/wta/src/agent_sessions.rs b/tools/wta/src/agent_sessions.rs
index 2adada8ba..024b4a863 100644
--- a/tools/wta/src/agent_sessions.rs
+++ b/tools/wta/src/agent_sessions.rs
@@ -34,6 +34,7 @@ pub type AgentKey = String;
#[derive(Clone, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
pub enum CliSource {
Claude,
+ Codex,
Copilot,
Gemini,
Unknown(String),
@@ -43,6 +44,7 @@ impl CliSource {
pub fn parse(s: Option<&str>) -> Self {
match s.unwrap_or("").to_ascii_lowercase().as_str() {
"claude" => Self::Claude,
+ "codex" => Self::Codex,
"copilot" => Self::Copilot,
"gemini" => Self::Gemini,
"" => Self::Unknown(String::new()),
@@ -50,15 +52,16 @@ impl CliSource {
}
}
- /// Map an `agent_registry` agent id (`"copilot"`, `"claude"`, `"gemini"`,
+ /// Map an `agent_registry` agent id (`"copilot"`, `"claude"`, `"codex"`, `"gemini"`,
/// ...) to the matching `CliSource` variant. Returns `None` for agents
- /// the session registry does not track (e.g. `"codex"`, `"unknown"`, or
- /// an empty string), which the session management view treats as
+ /// the session registry does not track (e.g. `"unknown"`, or
+ /// an empty string), which the session-management view treats as
/// "no filter — show all rows".
pub fn from_agent_id(agent_id: &str) -> Option {
match agent_id.to_ascii_lowercase().as_str() {
- "copilot" => Some(Self::Copilot),
"claude" => Some(Self::Claude),
+ "codex" => Some(Self::Codex),
+ "copilot" => Some(Self::Copilot),
"gemini" => Some(Self::Gemini),
_ => None,
}
@@ -1160,11 +1163,12 @@ impl AgentSessionRegistry {
///
/// Layout (sorted by last_activity_at desc, newest first):
/// 1. copilot WORKING — currently running a tool
- /// 2. claude ATTENTION — needs user approval
- /// 3. gemini IDLE — sitting waiting for input
- /// 4. copilot ERROR — connection failed
- /// 5. claude ENDED — exited normally a moment ago
- /// 6. gemini HISTORICAL — loaded from an old log (no live pane)
+ /// 2. codex WORKING — running a tool (second active session)
+ /// 3. claude ATTENTION — needs user approval
+ /// 4. gemini IDLE — sitting waiting for input
+ /// 5. copilot ERROR — connection failed
+ /// 6. claude ENDED — exited normally a moment ago
+ /// 7. gemini HISTORICAL — loaded from an old log (no live pane)
pub fn populate_demo_data(&mut self) {
use std::time::Duration;
@@ -1184,7 +1188,20 @@ impl AgentSessionRegistry {
tool_name: "shell".to_string(),
});
- // 2. Attention — claude waiting for tool approval
+ // 2. Working — codex running a tool concurrently
+ self.apply(SessionEvent::SessionStarted {
+ key: "demo-codex-working".to_string(),
+ cli_source: CliSource::Codex,
+ pane_session_id: "77777777-7777-7777-7777-777777777777".to_string(),
+ cwd: cwd.clone(),
+ title: "codex — implement refactor parser".to_string(),
+ });
+ self.apply(SessionEvent::ToolStarting {
+ key: "demo-codex-working".to_string(),
+ tool_name: "shell".to_string(),
+ });
+
+ // 3. Attention — claude waiting for tool approval
self.apply(SessionEvent::SessionStarted {
key: "demo-claude-attention".to_string(),
cli_source: CliSource::Claude,
@@ -1197,7 +1214,7 @@ impl AgentSessionRegistry {
message: "Allow tool: write_file ./src/lib.rs?".to_string(),
});
- // 3. Idle — gemini waiting for next prompt
+ // 4. Idle — gemini waiting for next prompt
self.apply(SessionEvent::SessionStarted {
key: "demo-gemini-idle".to_string(),
cli_source: CliSource::Gemini,
@@ -1206,7 +1223,7 @@ impl AgentSessionRegistry {
title: "gemini — explain build system".to_string(),
});
- // 4. Error — copilot lost network
+ // 5. Error — copilot lost network
self.apply(SessionEvent::SessionStarted {
key: "demo-copilot-error".to_string(),
cli_source: CliSource::Copilot,
@@ -1219,7 +1236,7 @@ impl AgentSessionRegistry {
reason: "API request failed: 503 Service Unavailable".to_string(),
});
- // 5. Ended — claude finished cleanly a moment ago
+ // 6. Ended — claude finished cleanly a moment ago
self.apply(SessionEvent::SessionStarted {
key: "demo-claude-ended".to_string(),
cli_source: CliSource::Claude,
@@ -1227,7 +1244,7 @@ impl AgentSessionRegistry {
cwd: cwd.clone(),
title: "claude — review PR diff".to_string(),
});
- // 5. Ended — claude finished cleanly a moment ago. Origin is the
+ // 6. Ended — claude finished cleanly a moment ago. Origin is the
// default (Unknown), so SessionStopped takes the original
// immediate-Ended path — no PaneClosed needed.
self.apply(SessionEvent::SessionStopped {
@@ -1235,7 +1252,7 @@ impl AgentSessionRegistry {
reason: "end_turn".to_string(),
});
- // 6. Historical — loaded from old log, no live pane
+ // 7. Historical — loaded from old log, no live pane
let two_hours_ago = now - Duration::from_secs(2 * 60 * 60);
let key = "demo-gemini-historical".to_string();
self.sessions.insert(key.clone(), AgentSession {
@@ -1260,6 +1277,7 @@ impl AgentSessionRegistry {
// narrative (working newest, historical oldest).
let stagger = |secs: u64| now - Duration::from_secs(secs);
if let Some(s) = self.sessions.get_mut("demo-copilot-working") { s.last_activity_at = stagger(2); }
+ if let Some(s) = self.sessions.get_mut("demo-codex-working") { s.last_activity_at = stagger(5); }
if let Some(s) = self.sessions.get_mut("demo-claude-attention") { s.last_activity_at = stagger(15); }
if let Some(s) = self.sessions.get_mut("demo-gemini-idle") { s.last_activity_at = stagger(45); }
if let Some(s) = self.sessions.get_mut("demo-copilot-error") { s.last_activity_at = stagger(120); }
@@ -1937,12 +1955,12 @@ mod tests {
let mut reg = AgentSessionRegistry::new();
reg.populate_demo_data();
let sessions = reg.iter_sorted();
- assert_eq!(sessions.len(), 6, "demo data should yield exactly 6 sessions");
+ assert_eq!(sessions.len(), 7, "demo data should yield exactly 7 sessions");
- // Verify each status appears exactly once.
+ // Verify each non-Working status appears exactly once; Working appears
+ // twice (copilot + codex are both running tools concurrently).
let statuses: Vec = sessions.iter().map(|s| s.status.clone()).collect();
for st in [
- AgentStatus::Working,
AgentStatus::Attention,
AgentStatus::Idle,
AgentStatus::Error,
@@ -1951,12 +1969,16 @@ mod tests {
] {
assert_eq!(statuses.iter().filter(|s| **s == st).count(), 1, "expected exactly one {:?}", st);
}
+ assert_eq!(
+ statuses.iter().filter(|s| **s == AgentStatus::Working).count(), 2,
+ "expected exactly two Working sessions (copilot + codex)",
+ );
// Working session must come first (most recent activity).
assert_eq!(sessions[0].status, AgentStatus::Working);
// Historical session must be last and have no live pane binding.
- assert_eq!(sessions[5].status, AgentStatus::Historical);
- assert!(sessions[5].pane_session_id.is_none());
+ assert_eq!(sessions[6].status, AgentStatus::Historical);
+ assert!(sessions[6].pane_session_id.is_none());
// Error session must carry the failure reason.
let err = sessions.iter().find(|s| s.status == AgentStatus::Error).unwrap();
@@ -2086,14 +2108,31 @@ mod tests {
#[test]
fn from_agent_id_returns_none_for_untracked_or_empty() {
- // Empty / unknown / codex are all "no filter" — the session management view will
+ // Empty / unknown are "no filter" — the session management view will
// fall back to showing every row.
assert_eq!(CliSource::from_agent_id(""), None);
- assert_eq!(CliSource::from_agent_id("codex"), None);
assert_eq!(CliSource::from_agent_id("unknown"), None);
assert_eq!(CliSource::from_agent_id("bogus"), None);
}
+ #[test]
+ fn cli_source_from_agent_id_recognizes_codex() {
+ assert_eq!(
+ CliSource::from_agent_id("codex"),
+ Some(CliSource::Codex),
+ );
+ }
+
+ #[test]
+ fn cli_source_parse_round_trips_codex() {
+ // Wire format used by SessionHookCliSource::Known("Codex" | "codex")
+ // must parse back to the typed variant — otherwise Codex hook events
+ // would degrade to CliSource::Unknown after a serde round-trip.
+ // Note: CliSource has `pub fn parse(Option<&str>) -> Self` (not FromStr).
+ assert_eq!(CliSource::parse(Some("Codex")), CliSource::Codex);
+ assert_eq!(CliSource::parse(Some("codex")), CliSource::Codex);
+ }
+
#[test]
fn iter_sorted_filtered_keeps_only_matching_cli_source() {
let mut reg = AgentSessionRegistry::new();
diff --git a/tools/wta/src/app.rs b/tools/wta/src/app.rs
index c9f16b593..7a494f3f6 100644
--- a/tools/wta/src/app.rs
+++ b/tools/wta/src/app.rs
@@ -2000,6 +2000,22 @@ impl Default for HistoryLoadState {
}
}
+/// Reverse of `CliSource::from_agent_id` — yields the lowercase CLI id
+/// used by the command-synthesis template and dispatch routing.
+/// Returns `None` for `CliSource::Unknown(_)` so each call-site retains
+/// its current Unknown-handling semantics (display fallback / bool
+/// false / early return — they differ).
+pub(crate) fn known_cli_id(src: &crate::agent_sessions::CliSource) -> Option<&'static str> {
+ use crate::agent_sessions::CliSource;
+ match src {
+ CliSource::Claude => Some("claude"),
+ CliSource::Codex => Some("codex"),
+ CliSource::Copilot => Some("copilot"),
+ CliSource::Gemini => Some("gemini"),
+ CliSource::Unknown(_) => None,
+ }
+}
+
pub(crate) fn session_info_to_agent_session(
info: &crate::session_registry::SessionInfo,
) -> crate::agent_sessions::AgentSession {
@@ -2358,21 +2374,14 @@ impl App {
decide_enter_action, liveness_from_status, EnterAction, NotResumableReason, RowSnapshot,
};
// Ambient: load_session capability is set during ACP init;
- // resume-flag support is a per-CLI profile constant (false for
- // Codex today; true for Claude/Copilot/Gemini).
- let cli_supports_resume_flag = match s.cli_source {
- crate::agent_sessions::CliSource::Unknown(_) => false,
- ref known => {
- let id = match known {
- crate::agent_sessions::CliSource::Claude => "claude",
- crate::agent_sessions::CliSource::Copilot => "copilot",
- crate::agent_sessions::CliSource::Gemini => "gemini",
- crate::agent_sessions::CliSource::Unknown(_) => unreachable!(),
- };
- !crate::agent_registry::lookup_profile_by_id(id)
- .resume_flag
- .is_empty()
- }
+ // resume-flag support is a per-CLI profile constant — true for
+ // Claude / Codex / Copilot / Gemini (all four CLIs accept some
+ // form of `--resume`/`resume ` re-attach surface).
+ let cli_supports_resume_flag = match known_cli_id(&s.cli_source) {
+ Some(id) => !crate::agent_registry::lookup_profile_by_id(id)
+ .resume_flag
+ .is_empty(),
+ None => false,
};
let row = RowSnapshot {
origin: s.origin.clone(),
@@ -2416,25 +2425,11 @@ impl App {
// Surface a user-visible system message scoped to the
// current tab so the user can read it from the
// agent session view (which is rendered in-tab).
- let agent_display: String = match s.cli_source {
- crate::agent_sessions::CliSource::Claude => {
- crate::agent_registry::lookup_profile_by_id("claude")
- .display_name
- .to_string()
- }
- crate::agent_sessions::CliSource::Copilot => {
- crate::agent_registry::lookup_profile_by_id("copilot")
- .display_name
- .to_string()
- }
- crate::agent_sessions::CliSource::Gemini => {
- crate::agent_registry::lookup_profile_by_id("gemini")
- .display_name
- .to_string()
- }
- crate::agent_sessions::CliSource::Unknown(_) => {
- t!("system.fallback.this_agent").into_owned()
- }
+ let agent_display: String = match known_cli_id(&s.cli_source) {
+ Some(id) => crate::agent_registry::lookup_profile_by_id(id)
+ .display_name
+ .to_string(),
+ None => t!("system.fallback.this_agent").into_owned(),
};
let msg = match reason {
NotResumableReason::LiveWithoutPane => {
@@ -2555,7 +2550,7 @@ impl App {
/// Open a new WT tab whose primary pane runs `
/// ` to rehydrate a Historical/Ended agent session from
/// the CLI's on-disk session store. Silent no-op for CLIs without a
- /// resume flag (Codex today) or unknown CLI sources.
+ /// resume flag or unknown CLI sources.
///
/// Flow:
/// 1. Apply `ResumeDispatched` synchronously so a rapid second Enter
@@ -2575,11 +2570,9 @@ impl App {
/// (Gemini), allowing a later `PaneClosed` to transition the
/// row back to Ended.
fn dispatch_resume(&mut self, s: &crate::agent_sessions::AgentSession) {
- let cli_id = match s.cli_source {
- crate::agent_sessions::CliSource::Claude => "claude",
- crate::agent_sessions::CliSource::Copilot => "copilot",
- crate::agent_sessions::CliSource::Gemini => "gemini",
- crate::agent_sessions::CliSource::Unknown(_) => {
+ let cli_id = match known_cli_id(&s.cli_source) {
+ Some(id) => id,
+ None => {
tracing::debug!(
target: "agents_view",
key = %s.key,
@@ -2843,25 +2836,11 @@ impl App {
"dispatch_resume_in_agent_pane: refusing to load phantom session; pruning row",
);
let short_key: String = s.key.chars().take(8).collect();
- let agent_display: String = match s.cli_source {
- crate::agent_sessions::CliSource::Claude => {
- crate::agent_registry::lookup_profile_by_id("claude")
- .display_name
- .to_string()
- }
- crate::agent_sessions::CliSource::Copilot => {
- crate::agent_registry::lookup_profile_by_id("copilot")
- .display_name
- .to_string()
- }
- crate::agent_sessions::CliSource::Gemini => {
- crate::agent_registry::lookup_profile_by_id("gemini")
- .display_name
- .to_string()
- }
- crate::agent_sessions::CliSource::Unknown(_) => {
- t!("system.fallback.this_agent").into_owned()
- }
+ let agent_display: String = match known_cli_id(&s.cli_source) {
+ Some(id) => crate::agent_registry::lookup_profile_by_id(id)
+ .display_name
+ .to_string(),
+ None => t!("system.fallback.this_agent").into_owned(),
};
let msg = t!(
"system.cannot_resume_phantom_via_load",
@@ -12996,4 +12975,19 @@ mod tests {
None,
);
}
+
+ #[test]
+ fn known_cli_id_returns_some_for_all_first_party_clis() {
+ use crate::agent_sessions::CliSource;
+ assert_eq!(known_cli_id(&CliSource::Claude), Some("claude"));
+ assert_eq!(known_cli_id(&CliSource::Codex), Some("codex"));
+ assert_eq!(known_cli_id(&CliSource::Copilot), Some("copilot"));
+ assert_eq!(known_cli_id(&CliSource::Gemini), Some("gemini"));
+ }
+
+ #[test]
+ fn known_cli_id_returns_none_for_unknown_variant() {
+ use crate::agent_sessions::CliSource;
+ assert_eq!(known_cli_id(&CliSource::Unknown("anything".to_string())), None);
+ }
}
diff --git a/tools/wta/src/history_loader.rs b/tools/wta/src/history_loader.rs
index 46840f776..49830df60 100644
--- a/tools/wta/src/history_loader.rs
+++ b/tools/wta/src/history_loader.rs
@@ -37,6 +37,20 @@
// the row would launch `gemini --resume ` and
// dead-end on a similar "no session" rejection.
//
+// Codex: ~/.codex/sessions/YYYY/MM/DD/rollout--.jsonl
+// - session id = first JSONL line `session_meta` payload.id
+// - cwd = `session_meta` payload.cwd
+// - title = first `event_msg` payload.user_message,
+// else first `response_item` role=user content
+// (skipping synthetic ``
+// prefixes injected by the CLI)
+// - last_activity= `session_meta` payload.timestamp (fallback file mtime)
+// - skip "phantom" sessions whose jsonl contains only the
+// `session_meta` header and/or synthetic
+// `` response_items (no real user
+// turn). `codex resume ` would reject these as having
+// no conversation to resume.
+//
// (Note: per-subagent JSONL files may live in nested `/` subdirs of
// `chats/`. Top-level Gemini sessions are flat files named `session-*.jsonl`.
// under `/.json`. We only pick up `session-*.json` at the
@@ -71,6 +85,7 @@ pub fn load_all() -> Vec {
out.extend(take_n(load_copilot(&home), MAX_PER_CLI));
out.extend(take_n(load_claude(&home), MAX_PER_CLI));
out.extend(take_n(load_gemini(&home), MAX_PER_CLI));
+ out.extend(take_n(load_codex(&home), MAX_PER_CLI));
// Stamp `origin: AgentPane` on rows whose session id was recorded in
// the local agent-pane index. Loaded once and applied as a join so the
// per-CLI scanners stay agnostic of how the index is shaped or where
@@ -112,7 +127,8 @@ pub fn lookup_title_for_session_in(
CliSource::Copilot => copilot_title_for_key(home, key),
CliSource::Claude => claude_title_for_key(home, key),
CliSource::Gemini => gemini_title_for_key(home, key),
- _ => None,
+ CliSource::Codex => codex_title_for_key(home, key),
+ CliSource::Unknown(_) => None,
}
}
@@ -230,6 +246,7 @@ pub(crate) fn key_is_resumable_on_disk_in(
use crate::agent_sessions::CliSource;
match cli {
CliSource::Claude => claude_key_is_resumable_on_disk_in(home, key),
+ CliSource::Codex => codex_key_is_resumable_on_disk_in(home, key),
CliSource::Copilot => copilot_key_is_resumable_on_disk_in(home, key),
CliSource::Gemini => gemini_key_is_resumable_on_disk_in(home, key),
CliSource::Unknown(_) => true,
@@ -274,6 +291,7 @@ pub(crate) fn key_has_definite_resumable_content_in(
use crate::agent_sessions::CliSource;
match cli {
CliSource::Claude => claude_key_has_definite_resumable_content_in(home, key),
+ CliSource::Codex => codex_key_has_definite_resumable_content_in(home, key),
CliSource::Copilot => copilot_key_has_definite_resumable_content_in(home, key),
CliSource::Gemini => gemini_key_has_definite_resumable_content_in(home, key),
CliSource::Unknown(_) => true,
@@ -410,6 +428,22 @@ pub(crate) fn gemini_jsonl_has_real_content(path: &Path) -> bool {
false
}
+// ─── Codex per-key helpers ──────────────────────────────────────────────
+
+fn codex_key_is_resumable_on_disk_in(home: &Path, id: &str) -> bool {
+ match find_codex_rollout_by_id(home, id) {
+ None => true,
+ Some(path) => codex_session_has_real_content(&path),
+ }
+}
+
+fn codex_key_has_definite_resumable_content_in(home: &Path, id: &str) -> bool {
+ match find_codex_rollout_by_id(home, id) {
+ None => false,
+ Some(path) => codex_session_has_real_content(&path),
+ }
+}
+
// ─── Copilot ────────────────────────────────────────────────────────────
fn load_copilot(home: &Path) -> Vec {
@@ -629,6 +663,309 @@ fn is_gemini_session_file(p: &Path) -> bool {
name.ends_with(".jsonl")
}
+// ─── Codex ──────────────────────────────────────────────────────────────
+
+fn load_codex(home: &Path) -> Vec {
+ let root = home.join(".codex").join("sessions");
+ let mut out: Vec = Vec::new();
+ let Ok(years) = fs::read_dir(&root) else { return out };
+ for y in years.flatten() {
+ let Ok(months) = fs::read_dir(y.path()) else { continue };
+ for m in months.flatten() {
+ let Ok(days) = fs::read_dir(m.path()) else { continue };
+ for d in days.flatten() {
+ let Ok(files) = fs::read_dir(d.path()) else { continue };
+ for f in files.flatten() {
+ let path = f.path();
+ let Some(name) = path.file_name().and_then(|s| s.to_str()) else { continue };
+ if !name.starts_with("rollout-") || !name.ends_with(".jsonl") { continue; }
+ if !codex_session_has_real_content(&path) { continue; }
+ let Some(meta) = read_codex_session_meta(&path) else { continue; };
+ let title = codex_title_from_file(&path)
+ .unwrap_or_else(|| short_id(&meta.id, "codex"));
+ let last_activity_at = meta.timestamp
+ .or_else(|| fs::metadata(&path).and_then(|m| m.modified()).ok())
+ .unwrap_or_else(SystemTime::now);
+ out.push(AgentSession {
+ key: meta.id,
+ cli_source: CliSource::Codex,
+ pane_session_id: None,
+ window_id: None,
+ tab_id: None,
+ title,
+ cwd: meta.cwd,
+ started_at: last_activity_at,
+ last_activity_at,
+ status: AgentStatus::Historical,
+ last_error: None,
+ current_tool: None,
+ attention_reason: None,
+ log_path: Some(path),
+ origin: crate::agent_sessions::SessionOrigin::default(),
+ });
+ }
+ }
+ }
+ }
+ out.sort_by(|a, b| b.last_activity_at.cmp(&a.last_activity_at));
+ out
+}
+
+struct CodexSessionMeta {
+ id: String,
+ cwd: PathBuf,
+ timestamp: Option,
+}
+
+fn read_codex_session_meta(path: &Path) -> Option {
+ use std::io::BufRead;
+ let f = fs::File::open(path).ok()?;
+ let mut reader = std::io::BufReader::new(f);
+ let mut line = String::new();
+ reader.read_line(&mut line).ok()?;
+ let v: serde_json::Value = serde_json::from_str(line.trim()).ok()?;
+ if v.get("type")?.as_str()? != "session_meta" { return None; }
+ let payload = v.get("payload")?;
+ let ts_str = payload.get("timestamp").and_then(|s| s.as_str());
+ Some(CodexSessionMeta {
+ id: payload.get("id")?.as_str()?.to_string(),
+ cwd: PathBuf::from(payload.get("cwd")?.as_str()?),
+ timestamp: ts_str.and_then(parse_iso_to_system_time),
+ })
+}
+
+fn codex_session_has_real_content(path: &Path) -> bool {
+ let Some(lines) = stream_jsonl_lines(path, CLASSIFY_SCAN_BYTES_CAP) else {
+ return true; // conservative on IO error
+ };
+ for line in lines {
+ let Ok(v) = serde_json::from_str::(&line) else { continue };
+ let ty = v.get("type").and_then(|s| s.as_str()).unwrap_or("");
+ match ty {
+ "event_msg" => {
+ let pty = v.get("payload")
+ .and_then(|p| p.get("type"))
+ .and_then(|s| s.as_str())
+ .unwrap_or("");
+ if matches!(pty, "user_message" | "agent_message") { return true; }
+ }
+ "response_item" => {
+ let Some(payload) = v.get("payload") else { continue };
+ let role = payload.get("role").and_then(|s| s.as_str()).unwrap_or("");
+ if role == "assistant" { return true; }
+ if role == "user" {
+ let text = payload.get("content")
+ .and_then(|c| c.get(0))
+ .and_then(|c0| c0.get("text"))
+ .and_then(|s| s.as_str())
+ .unwrap_or("");
+ if !text.starts_with("") { return true; }
+ }
+ }
+ _ => {}
+ }
+ }
+ false
+}
+
+fn codex_title_from_file(path: &Path) -> Option {
+ let lines = stream_jsonl_lines(path, CLASSIFY_SCAN_BYTES_CAP)?;
+ for line in lines {
+ let Ok(v) = serde_json::from_str::(&line) else { continue };
+ let ty = v.get("type").and_then(|s| s.as_str()).unwrap_or("");
+ match ty {
+ "event_msg" => {
+ let Some(payload) = v.get("payload") else { continue };
+ let pty = payload.get("type").and_then(|s| s.as_str()).unwrap_or("");
+ if pty == "user_message" {
+ let msg = payload.get("message").and_then(|s| s.as_str()).unwrap_or("");
+ let title = first_nonblank_line(msg);
+ if !title.is_empty() { return Some(title); }
+ }
+ }
+ "response_item" => {
+ let Some(payload) = v.get("payload") else { continue };
+ let role = payload.get("role").and_then(|s| s.as_str()).unwrap_or("");
+ if role == "user" {
+ let text = payload.get("content")
+ .and_then(|c| c.get(0))
+ .and_then(|c0| c0.get("text"))
+ .and_then(|s| s.as_str())
+ .unwrap_or("");
+ if !text.starts_with("") {
+ let title = first_nonblank_line(text);
+ if !title.is_empty() { return Some(title); }
+ }
+ }
+ }
+ _ => {}
+ }
+ }
+ None
+}
+
+fn first_nonblank_line(raw: &str) -> String {
+ raw.lines().find(|l| !l.trim().is_empty()).unwrap_or("").trim().to_string()
+}
+
+pub fn codex_title_for_key(home: &Path, key: &str) -> Option {
+ let path = find_codex_rollout_by_id(home, key)?;
+ codex_title_from_file(&path)
+}
+
+/// Locate the rollout file for a given session UUID.
+///
+/// Defensive walking: only an unreadable ROOT (`~/.codex/sessions`) returns
+/// None. Subtree errors (an unreadable year / month / day directory)
+/// `continue` so the search proceeds across siblings — same contract as
+/// `load_codex`.
+///
+/// The filename suffix `.jsonl` is a fast pre-filter; we still verify
+/// `payload.id == id` to guard against renamed files or UUID-prefix
+/// collisions.
+fn find_codex_rollout_by_id(home: &Path, id: &str) -> Option {
+ let root = home.join(".codex").join("sessions");
+ let Ok(years) = fs::read_dir(&root) else { return None };
+ for y in years.flatten() {
+ let Ok(months) = fs::read_dir(y.path()) else { continue };
+ for m in months.flatten() {
+ let Ok(days) = fs::read_dir(m.path()) else { continue };
+ for d in days.flatten() {
+ let Ok(files) = fs::read_dir(d.path()) else { continue };
+ for f in files.flatten() {
+ let p = f.path();
+ let Some(name) = p.file_name().and_then(|s| s.to_str()) else { continue };
+ if !(name.starts_with("rollout-") && name.ends_with(&format!("-{}.jsonl", id))) {
+ continue;
+ }
+ if let Some(meta) = read_codex_session_meta(&p) {
+ if meta.id == id {
+ return Some(p);
+ }
+ }
+ }
+ }
+ }
+ }
+ None
+}
+
+/// Parse a subset of ISO 8601 timestamps into `SystemTime`.
+///
+/// Handles the UTC shapes Codex `session_meta` emits
+/// (`YYYY-MM-DDTHH:MM:SSZ` and `YYYY-MM-DDTHH:MM:SS.fffZ`) plus the
+/// numeric offset variants (`±HH:MM`), e.g. `2026-05-27T10:53:09+08:00`.
+/// Returns `None` for any out-of-range / overflowing / malformed input
+/// (never panics).
+fn parse_iso_to_system_time(s: &str) -> Option {
+ let s = s.trim();
+
+ // Detect and parse timezone offset (+HH:MM or -HH:MM, or Z for UTC)
+ let offset_seconds = if s.ends_with('Z') {
+ 0
+ } else if s.len() >= 25 {
+ // Check if last 6 characters match ±HH:MM pattern
+ let offset_part = s.get(s.len()-6..)?;
+ if let Some(sign_idx) = offset_part.rfind(|c| c == '+' || c == '-') {
+ if sign_idx == 0 {
+ // Parse HH:MM
+ let hm = offset_part.get(1..)?;
+ if hm.len() == 5 && hm.chars().nth(2) == Some(':') {
+ let hh: i32 = hm.get(..2)?.parse().ok()?;
+ let mm: i32 = hm.get(3..)?.parse().ok()?;
+ // Reject out-of-range offsets (e.g. `+99:99`) so they
+ // don't silently skew the timestamp.
+ if !(0..=23).contains(&hh) || !(0..=59).contains(&mm) {
+ return None;
+ }
+ let total_seconds = hh * 3600 + mm * 60;
+ if offset_part.starts_with('-') { -total_seconds } else { total_seconds }
+ } else {
+ return None;
+ }
+ } else {
+ 0
+ }
+ } else {
+ 0
+ }
+ } else {
+ 0
+ };
+
+ // Determine the core portion to parse (strip Z or offset)
+ let core = if s.ends_with('Z') {
+ s.strip_suffix('Z')?
+ } else if offset_seconds != 0 && s.len() >= 6 {
+ s.get(..s.len()-6)?
+ } else {
+ s.get(..19)?
+ };
+
+ // Split at 'T' → date + time
+ let (date_part, time_part) = core.split_once('T')?;
+ let mut date_iter = date_part.split('-');
+ let year: u64 = date_iter.next()?.parse().ok()?;
+ let month: u64 = date_iter.next()?.parse().ok()?;
+ let day: u64 = date_iter.next()?.parse().ok()?;
+ let time_no_frac = time_part.split('.').next().unwrap_or(time_part);
+ let mut time_iter = time_no_frac.split(':');
+ let hour: u64 = time_iter.next()?.parse().ok()?;
+ let min: u64 = time_iter.next()?.parse().ok()?;
+ let sec: u64 = time_iter.next()?.parse().ok()?;
+
+ // Pre-1970 underflow check, and bound the year so the day/seconds
+ // arithmetic below cannot overflow u64 (the documented subset of
+ // ISO 8601 only needs 4-digit years anyway).
+ if year < 1970 || year > 9999 {
+ return None;
+ }
+
+ // Validate hour/min/sec bounds
+ if hour > 23 || min > 59 || sec > 59 {
+ return None;
+ }
+
+ // Convert to Unix timestamp (simplified — no leap seconds).
+ // Days from year 0 to start of `year`, then add months+day.
+ fn days_before_year(y: u64) -> u64 {
+ let y = y - 1;
+ 365 * y + y / 4 - y / 100 + y / 400
+ }
+ fn is_leap(y: u64) -> bool {
+ y % 4 == 0 && (y % 100 != 0 || y % 400 == 0)
+ }
+ let days_in_month: [u64; 12] = [31, if is_leap(year) { 29 } else { 28 },
+ 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
+
+ // Validate month bounds
+ if month < 1 || month > 12 {
+ return None;
+ }
+
+ // Validate day bounds
+ let days_in_current_month = days_in_month[(month - 1) as usize];
+ if day < 1 || day > days_in_current_month {
+ return None;
+ }
+
+ let mut total_days = days_before_year(year) - days_before_year(1970);
+ for i in 0..(month - 1) as usize {
+ total_days += days_in_month[i];
+ }
+ total_days += day - 1;
+ let mut secs = (total_days * 86400 + hour * 3600 + min * 60 + sec) as i64;
+ // Subtract offset to convert from local time to UTC
+ secs -= offset_seconds as i64;
+
+ if secs < 0 {
+ return None;
+ }
+ // `checked_add` so malformed / far-future timestamps fail closed
+ // (return `None`) instead of panicking on overflow.
+ SystemTime::UNIX_EPOCH.checked_add(std::time::Duration::from_secs(secs as u64))
+}
+
// ─── Helpers ────────────────────────────────────────────────────────────
fn short_id(id: &str, cli: &str) -> String {
@@ -1505,7 +1842,7 @@ mod tests {
// in-memory rows / test fixtures aren't blocked preemptively.
use crate::agent_sessions::CliSource;
let home = tmp_root("resumable-missing-all-clis");
- for cli in [CliSource::Claude, CliSource::Copilot, CliSource::Gemini] {
+ for cli in [CliSource::Claude, CliSource::Codex, CliSource::Copilot, CliSource::Gemini] {
assert!(
key_is_resumable_on_disk_in(&home, &cli, "no-such-id"),
"{:?} should defer to CLI when on-disk artefact is missing",
@@ -1646,7 +1983,7 @@ mod tests {
// row stuck Ended in session management view.
use crate::agent_sessions::CliSource;
let home = tmp_root("strict-probe-missing");
- for cli in [CliSource::Claude, CliSource::Copilot, CliSource::Gemini] {
+ for cli in [CliSource::Claude, CliSource::Codex, CliSource::Copilot, CliSource::Gemini] {
assert!(
!key_has_definite_resumable_content_in(&home, &cli, "no-such-id"),
"{:?} strict probe must report phantom when artefact is missing",
@@ -1875,4 +2212,262 @@ mod tests {
assert!(v[1].last_activity_at >= v[2].last_activity_at);
let _ = fs::remove_dir_all(&home);
}
+
+ // ─── Codex tests ────────────────────────────────────────────────────
+
+ fn codex_session_path(home: &Path, yyyy: &str, mm: &str, dd: &str, iso: &str, id: &str) -> PathBuf {
+ let dir = home.join(".codex").join("sessions").join(yyyy).join(mm).join(dd);
+ fs::create_dir_all(&dir).unwrap();
+ dir.join(format!("rollout-{}-{}.jsonl", iso, id))
+ }
+
+ fn codex_meta_line(id: &str, ts: &str, cwd: &str) -> String {
+ format!(
+ "{{\"timestamp\":\"{ts}\",\"type\":\"session_meta\",\
+\"payload\":{{\"id\":\"{id}\",\"timestamp\":\"{ts}\",\"cwd\":\"{cwd}\",\
+\"originator\":\"codex-tui\",\"cli_version\":\"0.1.0\",\"source\":\"cli\"}}}}\n")
+ }
+
+ fn codex_user_msg_line(ts: &str, text: &str) -> String {
+ format!(
+ "{{\"timestamp\":\"{ts}\",\"type\":\"event_msg\",\
+\"payload\":{{\"type\":\"user_message\",\"message\":\"{text}\"}}}}\n")
+ }
+
+ #[test]
+ fn load_codex_returns_one_row_per_real_rollout_file() {
+ let home = tmp_root("load-codex-basic");
+ let id = "11111111-2222-3333-4444-555555555555";
+ let path = codex_session_path(&home, "2026", "05", "28", "2026-05-28T10-30-00", id);
+ let body = codex_meta_line(id, "2026-05-28T10:30:00Z", "C:/work/proj")
+ + &codex_user_msg_line("2026-05-28T10:30:05Z", "summarize this repo");
+ write_file(&path, &body);
+ let rows = load_codex(&home);
+ assert_eq!(rows.len(), 1, "expected one row, got {:?}", rows);
+ let row = &rows[0];
+ assert_eq!(row.cli_source, crate::agent_sessions::CliSource::Codex);
+ assert_eq!(row.key, id, "key must be the rollout UUID");
+ assert_eq!(row.cwd, PathBuf::from("C:/work/proj"));
+ assert!(row.title.contains("summarize this repo"));
+ let _ = fs::remove_dir_all(&home);
+ }
+
+ #[test]
+ fn load_codex_skips_phantom_meta_only_files() {
+ let home = tmp_root("load-codex-phantom");
+ let id = "deadbeef-2222-3333-4444-555555555555";
+ let path = codex_session_path(&home, "2026", "05", "28", "2026-05-28T11-00-00", id);
+ write_file(&path, &codex_meta_line(id, "2026-05-28T11:00:00Z", "C:/x"));
+ assert_eq!(load_codex(&home).len(), 0, "phantom (meta-only) must be filtered out");
+ let _ = fs::remove_dir_all(&home);
+ }
+
+ #[test]
+ fn load_codex_skips_phantom_meta_plus_env_context_only() {
+ let home = tmp_root("load-codex-env-only");
+ let id = "deadbeef-3333-3333-3333-333333333333";
+ let path = codex_session_path(&home, "2026", "05", "28", "2026-05-28T11-30-00", id);
+ let env_line = format!(
+ "{{\"type\":\"response_item\",\"payload\":{{\"role\":\"user\",\
+\"content\":[{{\"text\":\"cwd=C:/x\"}}]}}}}\n");
+ write_file(&path, &(codex_meta_line(id, "2026-05-28T11:30:00Z", "C:/x") + &env_line));
+ assert_eq!(load_codex(&home).len(), 0,
+ "meta + environment_context wrapper alone must be classified phantom");
+ let _ = fs::remove_dir_all(&home);
+ }
+
+ #[test]
+ fn load_codex_orders_newest_first_by_payload_timestamp() {
+ let home = tmp_root("load-codex-order");
+ for (i, ts) in [
+ (0u32, "2026-05-28T10:00:00Z"),
+ (1u32, "2026-05-28T10:05:00Z"),
+ (2u32, "2026-05-28T10:10:00Z"),
+ ] {
+ let id = format!("aaaaaaaa-{:04}-3333-4444-555555555555", i);
+ let iso = ts.replace(':', "-").trim_end_matches('Z').to_string();
+ let path = codex_session_path(&home, "2026", "05", "28", &iso, &id);
+ write_file(&path,
+ &(codex_meta_line(&id, ts, "C:/x")
+ + &codex_user_msg_line(ts, &format!("prompt {i}"))));
+ }
+ let rows = load_codex(&home);
+ assert_eq!(rows.len(), 3);
+ assert!(rows[0].title.contains("prompt 2"),
+ "newest first; got titles {:?}",
+ rows.iter().map(|r| &r.title).collect::>());
+ let _ = fs::remove_dir_all(&home);
+ }
+
+ #[test]
+ fn codex_session_has_real_content_is_conservative_on_io_error() {
+ let nowhere = PathBuf::from("Z:/definitely/does/not/exist.jsonl");
+ assert!(codex_session_has_real_content(&nowhere),
+ "must default to true when the file can't be opened");
+ }
+
+ #[test]
+ fn codex_session_has_real_content_detects_user_message() {
+ let home = tmp_root("codex-scan-user");
+ let id = "abcd0001-2222-3333-4444-555555555555";
+ let path = codex_session_path(&home, "2026", "05", "28", "2026-05-28T12-00-00", id);
+ write_file(&path,
+ &(codex_meta_line(id, "2026-05-28T12:00:00Z", "C:/x")
+ + &codex_user_msg_line("2026-05-28T12:00:05Z", "hi")));
+ assert!(codex_session_has_real_content(&path));
+ let _ = fs::remove_dir_all(&home);
+ }
+
+ #[test]
+ fn codex_session_has_real_content_detects_agent_message() {
+ let home = tmp_root("codex-scan-agent");
+ let id = "abcd0002-2222-3333-4444-555555555555";
+ let path = codex_session_path(&home, "2026", "05", "28", "2026-05-28T12-30-00", id);
+ let agent_line = "{\"type\":\"event_msg\",\"payload\":{\"type\":\"agent_message\",\"message\":\"ok\"}}\n";
+ write_file(&path,
+ &(codex_meta_line(id, "2026-05-28T12:30:00Z", "C:/x") + agent_line));
+ assert!(codex_session_has_real_content(&path));
+ let _ = fs::remove_dir_all(&home);
+ }
+
+ #[test]
+ fn codex_title_falls_back_to_response_item_user_skipping_env_context() {
+ let home = tmp_root("codex-title-fallback");
+ let id = "abcdef00-3333-3333-3333-333333333333";
+ let path = codex_session_path(&home, "2026", "05", "28", "2026-05-28T13-00-00", id);
+ let env = format!(
+ "{{\"type\":\"response_item\",\"payload\":{{\"role\":\"user\",\
+\"content\":[{{\"text\":\"cwd=C:/x\"}}]}}}}\n");
+ let real = format!(
+ "{{\"type\":\"response_item\",\"payload\":{{\"role\":\"user\",\
+\"content\":[{{\"text\":\"refactor the parser\"}}]}}}}\n");
+ write_file(&path, &(codex_meta_line(id, "2026-05-28T13:00:00Z", "C:/x") + &env + &real));
+ let rows = load_codex(&home);
+ assert_eq!(rows.len(), 1);
+ assert!(rows[0].title.contains("refactor the parser"),
+ "got title: {:?}", rows[0].title);
+ let _ = fs::remove_dir_all(&home);
+ }
+
+ #[test]
+ fn codex_key_resumable_returns_true_when_artefact_missing() {
+ use crate::agent_sessions::CliSource;
+ let home = tmp_root("codex-resumable-missing");
+ // Lenient probe: missing on-disk artefact defers to CLI (true)
+ // so fresh in-memory rows aren't blocked preemptively.
+ assert!(key_is_resumable_on_disk_in(&home, &CliSource::Codex, "no-such-id"));
+ let _ = fs::remove_dir_all(&home);
+ }
+
+ #[test]
+ fn codex_key_resumable_returns_false_for_meta_only_jsonl() {
+ use crate::agent_sessions::CliSource;
+ let home = tmp_root("codex-resumable-phantom");
+ let id = "ffffffff-2222-3333-4444-555555555555";
+ // Build the meta-only file inline. The path shape is:
+ // home/.codex/sessions/2026/05/28/rollout-2026-05-28T10-00-00-.jsonl
+ let dir = home.join(".codex").join("sessions").join("2026").join("05").join("28");
+ fs::create_dir_all(&dir).unwrap();
+ let path = dir.join(format!("rollout-2026-05-28T10-00-00-{}.jsonl", id));
+ let meta = format!("{{\"type\":\"session_meta\",\"payload\":{{\"id\":\"{id}\",\"timestamp\":\"2026-05-28T10:00:00Z\",\"cwd\":\"C:/x\",\"originator\":\"codex-tui\",\"cli_version\":\"0.1.0\",\"source\":\"cli\"}}}}\n");
+ fs::write(&path, meta).unwrap();
+ assert!(!key_is_resumable_on_disk_in(&home, &CliSource::Codex, id));
+ let _ = fs::remove_dir_all(&home);
+ }
+
+ #[test]
+ fn codex_key_resumable_returns_true_for_jsonl_with_user_message() {
+ use crate::agent_sessions::CliSource;
+ let home = tmp_root("codex-resumable-real");
+ let id = "abcdef00-2222-3333-4444-555555555555";
+ let dir = home.join(".codex").join("sessions").join("2026").join("05").join("28");
+ fs::create_dir_all(&dir).unwrap();
+ let path = dir.join(format!("rollout-2026-05-28T10-30-00-{}.jsonl", id));
+ let content = format!(
+ "{{\"type\":\"session_meta\",\"payload\":{{\"id\":\"{id}\",\"timestamp\":\"2026-05-28T10:30:00Z\",\"cwd\":\"C:/x\",\"originator\":\"codex-tui\",\"cli_version\":\"0.1.0\",\"source\":\"cli\"}}}}\n\
+{{\"type\":\"event_msg\",\"payload\":{{\"type\":\"user_message\",\"message\":\"hi\"}}}}\n");
+ fs::write(&path, content).unwrap();
+ assert!(key_is_resumable_on_disk_in(&home, &CliSource::Codex, id));
+ let _ = fs::remove_dir_all(&home);
+ }
+
+ #[test]
+ fn codex_strict_probe_returns_false_when_artefact_missing() {
+ use crate::agent_sessions::CliSource;
+ let home = tmp_root("codex-strict-missing");
+ assert!(!key_has_definite_resumable_content_in(&home, &CliSource::Codex, "no-id"));
+ let _ = fs::remove_dir_all(&home);
+ }
+
+ #[test]
+ fn codex_title_for_key_finds_user_message() {
+ let home = tmp_root("codex-title-by-key");
+ let dir = home.join(".codex").join("sessions").join("2026").join("05").join("28");
+ fs::create_dir_all(&dir).unwrap();
+ let id = "cafebabe-1111-2222-3333-444444444444";
+ let path = dir.join(format!("rollout-2026-05-28T12-00-00-{}.jsonl", id));
+ write_file(&path,
+ &format!("{{\"timestamp\":\"2026-05-28T12:00:00Z\",\"type\":\"session_meta\",\
+\"payload\":{{\"id\":\"{id}\",\"timestamp\":\"2026-05-28T12:00:00Z\",\
+\"cwd\":\"C:/x\",\"originator\":\"codex-tui\",\"cli_version\":\"0.1.0\",\"source\":\"cli\"}}}}\n\
+{{\"timestamp\":\"2026-05-28T12:00:05Z\",\"type\":\"event_msg\",\
+\"payload\":{{\"type\":\"user_message\",\"message\":\"refactor the parser\"}}}}\n"));
+ assert_eq!(codex_title_for_key(&home, id).as_deref(), Some("refactor the parser"));
+ fs::remove_dir_all(&home).ok();
+ }
+
+ #[test]
+ fn codex_title_for_key_returns_none_for_unknown_id() {
+ let home = tmp_root("codex-title-missing");
+ assert_eq!(codex_title_for_key(&home, "no-such-id"), None);
+ fs::remove_dir_all(&home).ok();
+ }
+
+ #[test]
+ fn parse_iso_handles_positive_offset() {
+ // 2026-05-27T10:53:09+08:00 is 2026-05-27T02:53:09Z
+ let t1 = parse_iso_to_system_time("2026-05-27T10:53:09+08:00").unwrap();
+ let t2 = parse_iso_to_system_time("2026-05-27T02:53:09Z").unwrap();
+ assert_eq!(t1, t2);
+ }
+
+ #[test]
+ fn parse_iso_handles_negative_offset() {
+ // 2026-05-27T02:53:09-05:00 is 2026-05-27T07:53:09Z
+ let t1 = parse_iso_to_system_time("2026-05-27T02:53:09-05:00").unwrap();
+ let t2 = parse_iso_to_system_time("2026-05-27T07:53:09Z").unwrap();
+ assert_eq!(t1, t2);
+ }
+
+ #[test]
+ fn parse_iso_rejects_pre_1970_years() {
+ assert!(parse_iso_to_system_time("1969-12-31T23:59:59Z").is_none());
+ }
+
+ #[test]
+ fn parse_iso_rejects_invalid_month() {
+ assert!(parse_iso_to_system_time("2026-13-01T00:00:00Z").is_none());
+ assert!(parse_iso_to_system_time("2026-00-01T00:00:00Z").is_none());
+ }
+
+ #[test]
+ fn parse_iso_rejects_invalid_day_for_month() {
+ assert!(parse_iso_to_system_time("2026-02-30T00:00:00Z").is_none());
+ assert!(parse_iso_to_system_time("2026-05-32T00:00:00Z").is_none());
+ assert!(parse_iso_to_system_time("2026-04-31T00:00:00Z").is_none()); // April has 30
+ }
+
+ #[test]
+ fn parse_iso_rejects_invalid_time_components() {
+ assert!(parse_iso_to_system_time("2026-05-28T25:30:00Z").is_none());
+ assert!(parse_iso_to_system_time("2026-05-28T10:60:00Z").is_none());
+ assert!(parse_iso_to_system_time("2026-05-28T10:30:60Z").is_none());
+ }
+
+ #[test]
+ fn parse_iso_accepts_february_29_leap_year() {
+ // 2024 IS a leap year; 2023 is not.
+ assert!(parse_iso_to_system_time("2024-02-29T00:00:00Z").is_some());
+ assert!(parse_iso_to_system_time("2023-02-29T00:00:00Z").is_none());
+ }
}
diff --git a/tools/wta/src/main.rs b/tools/wta/src/main.rs
index cff558da5..895a73626 100644
--- a/tools/wta/src/main.rs
+++ b/tools/wta/src/main.rs
@@ -542,6 +542,7 @@ enum HooksCliFilter {
Copilot,
Claude,
Gemini,
+ Codex,
}
impl HooksCliFilter {
@@ -552,6 +553,7 @@ impl HooksCliFilter {
HooksCliFilter::Copilot => CliScope::One(CliKind::Copilot),
HooksCliFilter::Claude => CliScope::One(CliKind::Claude),
HooksCliFilter::Gemini => CliScope::One(CliKind::Gemini),
+ HooksCliFilter::Codex => CliScope::One(CliKind::Codex),
}
}
}
@@ -1307,9 +1309,10 @@ fn status_label(status: Option<&agent_sessions::AgentStatus>) -> String {
fn cli_source_label(source: Option<&agent_sessions::CliSource>) -> String {
match source {
- Some(agent_sessions::CliSource::Claude) => "Claude".to_string(),
+ Some(agent_sessions::CliSource::Claude) => "Claude".to_string(),
+ Some(agent_sessions::CliSource::Codex) => "Codex".to_string(),
Some(agent_sessions::CliSource::Copilot) => "Copilot".to_string(),
- Some(agent_sessions::CliSource::Gemini) => "Gemini".to_string(),
+ Some(agent_sessions::CliSource::Gemini) => "Gemini".to_string(),
Some(agent_sessions::CliSource::Unknown(s)) if !s.is_empty() => s.clone(),
_ => "-".to_string(),
}
diff --git a/tools/wta/src/session_mgmt.rs b/tools/wta/src/session_mgmt.rs
index 62e7a8f97..602bdbaec 100644
--- a/tools/wta/src/session_mgmt.rs
+++ b/tools/wta/src/session_mgmt.rs
@@ -78,8 +78,7 @@ pub enum NotResumableReason {
/// Wanted `ResumeInAgentPane` but the connected agent didn't
/// advertise the `loadSession` capability.
LoadSessionNotSupported,
- /// Wanted `ResumeCliFlag` but the CLI has no `--resume`-style flag
- /// (Codex today).
+ /// Wanted `ResumeCliFlag` but the CLI has no `--resume`-style flag.
CliHasNoResumeFlag,
/// `CliSource::Unknown(_)` — we don't know how to spawn the CLI, so
/// neither dead-row path applies.
@@ -123,7 +122,8 @@ pub struct RowSnapshot {
/// ACP) advertised the `loadSession` capability at initialize.
pub load_session_supported: bool,
/// Whether the CLI has a `--resume`-style flag. True for
- /// Claude/Copilot/Gemini, false for Codex.
+ /// Claude/Copilot/Codex/Gemini (all four CLIs accept some form of
+ /// `--resume`/`resume ` re-attach surface).
pub cli_supports_resume_flag: bool,
}
@@ -266,6 +266,39 @@ mod tests {
assert_eq!(decide_enter_action(&r, true), decide_enter_action(&r, false));
}
+ #[test]
+ fn codex_class_a_live_with_pane_enter_focuses() {
+ let r = row(
+ SessionOrigin::AgentPane,
+ Liveness::Live {
+ pane_session_id: Some("pane-A".into()),
+ },
+ CliSource::Codex,
+ true,
+ true,
+ );
+ assert_eq!(
+ decide_enter_action(&r, false),
+ EnterAction::Focus {
+ pane_session_id: "pane-A".into()
+ }
+ );
+ }
+
+ #[test]
+ fn codex_class_a_live_with_pane_shift_same_as_enter() {
+ let r = row(
+ SessionOrigin::AgentPane,
+ Liveness::Live {
+ pane_session_id: Some("pane-A".into()),
+ },
+ CliSource::Codex,
+ true,
+ true,
+ );
+ assert_eq!(decide_enter_action(&r, true), decide_enter_action(&r, false));
+ }
+
#[test]
fn class_b_live_with_pane_enter_focuses() {
let r = row(
@@ -359,6 +392,24 @@ mod tests {
);
}
+ #[test]
+ fn codex_class_a_ended_enter_resumes_in_agent_pane_when_supported() {
+ let r = row(
+ SessionOrigin::AgentPane,
+ Liveness::Ended,
+ CliSource::Codex,
+ true,
+ true,
+ );
+ assert_eq!(
+ decide_enter_action(&r, false),
+ EnterAction::ResumeInAgentPane {
+ key: "k".into(),
+ cli: CliSource::Codex
+ }
+ );
+ }
+
#[test]
fn class_a_ended_enter_not_resumable_when_load_unsupported() {
let r = row(
@@ -376,6 +427,23 @@ mod tests {
);
}
+ #[test]
+ fn codex_class_a_ended_enter_not_resumable_when_load_unsupported() {
+ let r = row(
+ SessionOrigin::AgentPane,
+ Liveness::Ended,
+ CliSource::Codex,
+ false, // load_session not supported
+ true,
+ );
+ assert_eq!(
+ decide_enter_action(&r, false),
+ EnterAction::NotResumable {
+ reason: NotResumableReason::LoadSessionNotSupported
+ }
+ );
+ }
+
#[test]
fn class_a_ended_shift_resumes_via_cli_flag() {
let r = row(
@@ -394,6 +462,24 @@ mod tests {
);
}
+ #[test]
+ fn codex_class_a_ended_shift_resumes_via_cli_flag() {
+ let r = row(
+ SessionOrigin::AgentPane,
+ Liveness::Ended,
+ CliSource::Codex,
+ true,
+ true,
+ );
+ assert_eq!(
+ decide_enter_action(&r, true),
+ EnterAction::ResumeCliFlag {
+ key: "k".into(),
+ cli: CliSource::Codex
+ }
+ );
+ }
+
#[test]
fn class_a_ended_shift_not_resumable_when_cli_has_no_flag() {
let r = row(
@@ -411,6 +497,23 @@ mod tests {
);
}
+ #[test]
+ fn codex_class_a_ended_shift_not_resumable_when_cli_has_no_flag() {
+ let r = row(
+ SessionOrigin::AgentPane,
+ Liveness::Ended,
+ CliSource::Codex,
+ true,
+ false, // no --resume flag
+ );
+ assert_eq!(
+ decide_enter_action(&r, true),
+ EnterAction::NotResumable {
+ reason: NotResumableReason::CliHasNoResumeFlag
+ }
+ );
+ }
+
#[test]
fn class_a_historical_enter_routes_like_ended() {
let r = row(
@@ -429,6 +532,24 @@ mod tests {
);
}
+ #[test]
+ fn codex_class_a_historical_enter_routes_like_ended() {
+ let r = row(
+ SessionOrigin::AgentPane,
+ Liveness::Historical,
+ CliSource::Codex,
+ true,
+ true,
+ );
+ assert_eq!(
+ decide_enter_action(&r, false),
+ EnterAction::ResumeInAgentPane {
+ key: "k".into(),
+ cli: CliSource::Codex
+ }
+ );
+ }
+
// --- Dead rows: Class B ------------------------------------------
#[test]
diff --git a/tools/wta/src/session_registry.rs b/tools/wta/src/session_registry.rs
index e5d470544..03cb350eb 100644
--- a/tools/wta/src/session_registry.rs
+++ b/tools/wta/src/session_registry.rs
@@ -457,6 +457,7 @@ impl From<&crate::agent_sessions::CliSource> for SessionHookCliSource {
fn from(value: &crate::agent_sessions::CliSource) -> Self {
match value {
crate::agent_sessions::CliSource::Claude => Self::Known("Claude".to_string()),
+ crate::agent_sessions::CliSource::Codex => Self::Known("Codex".to_string()),
crate::agent_sessions::CliSource::Copilot => Self::Known("Copilot".to_string()),
crate::agent_sessions::CliSource::Gemini => Self::Known("Gemini".to_string()),
crate::agent_sessions::CliSource::Unknown(value) => Self::Unknown {
@@ -471,6 +472,7 @@ impl From for crate::agent_sessions::CliSource {
match value {
SessionHookCliSource::Known(value) => match value.as_str() {
"Claude" | "claude" => Self::Claude,
+ "Codex" | "codex" => Self::Codex,
"Copilot" | "copilot" => Self::Copilot,
"Gemini" | "gemini" => Self::Gemini,
other => Self::Unknown(other.to_string()),
@@ -2195,6 +2197,24 @@ mod tests {
"pane binding must stay None after handoff");
}
+ #[tokio::test]
+ async fn registry_assigns_codex_cli_source_when_session_started_via_agent_id() {
+ use crate::agent_sessions::{CliSource, SessionEvent};
+ let reg = InMemoryRegistry::new();
+ let cli = CliSource::from_agent_id("codex")
+ .expect("from_agent_id('codex') must yield Some(Codex)");
+ let event = SessionEvent::SessionStarted {
+ key: "codex-fan-in-test".to_string(),
+ cli_source: cli.clone(),
+ pane_session_id: "p1".to_string(),
+ cwd: PathBuf::from(r#"C:\x"#),
+ title: "fan-in test".to_string(),
+ };
+ reg.apply_event(event).await;
+ let row = reg.lookup(&acp::SessionId::new("codex-fan-in-test")).await.expect("row inserted");
+ assert_eq!(row.cli_source, Some(CliSource::Codex));
+ }
+
#[test]
fn sessions_list_response_round_trips_session_info_with_typed_fields() {
let mut info = SessionInfo::new(acp::SessionId::new("sid-1"), PathBuf::from("/repo"));
@@ -2381,6 +2401,25 @@ mod tests {
assert_eq!(parsed, event);
}
+ #[test]
+ fn session_hook_cli_source_round_trips_codex() {
+ use crate::agent_sessions::CliSource;
+ let typed = CliSource::Codex;
+ let wire: SessionHookCliSource = (&typed).into();
+ assert!(matches!(wire, SessionHookCliSource::Known(ref s) if s == "Codex"),
+ "Codex must serialize to Known(\"Codex\"), got {:?}", wire);
+ let back: CliSource = wire.into();
+ assert_eq!(back, CliSource::Codex);
+ }
+
+ #[test]
+ fn session_hook_cli_source_accepts_lowercase_codex() {
+ use crate::agent_sessions::CliSource;
+ let wire = SessionHookCliSource::Known("codex".to_string());
+ let typed: CliSource = wire.into();
+ assert_eq!(typed, CliSource::Codex);
+ }
+
#[test]
fn parse_session_hook_params_rejects_garbage() {
let raw = serde_json::value::RawValue::from_string(r#"{"wrong":"shape"}"#.into()).unwrap();
diff --git a/tools/wta/src/ui/agents_view.rs b/tools/wta/src/ui/agents_view.rs
index 9311552f0..231e9e7f5 100644
--- a/tools/wta/src/ui/agents_view.rs
+++ b/tools/wta/src/ui/agents_view.rs
@@ -462,7 +462,7 @@ fn badge_style(s: &AgentSession) -> Style {
}
}
-/// Show the CLI provider (`copilot`, `claude`, `gemini`) only on the
+/// Show the CLI provider (`claude`, `codex`, `copilot`, `gemini`) only on the
/// active row or the keyboard-selected row — matches the Figma where the
/// agent icon appears only on the currently-engaged session and avoids
/// cluttering the historical list.
@@ -473,6 +473,7 @@ fn cli_suffix_for(s: &AgentSession, selected: bool) -> String {
}
let label = match s.cli_source {
CliSource::Claude => "claude",
+ CliSource::Codex => "codex",
CliSource::Copilot => "copilot",
CliSource::Gemini => "gemini",
CliSource::Unknown(_) => return String::new(),
@@ -863,4 +864,27 @@ mod tests {
assert!(s.contains("20"), "expected day in {:?}", s);
assert!(s.contains("2026"), "expected year in {:?}", s);
}
+
+ #[test]
+ fn cli_suffix_renders_codex_label_on_selected_row() {
+ let s = AgentSession {
+ key: "k".to_string(),
+ cli_source: CliSource::Codex,
+ pane_session_id: None,
+ window_id: None,
+ tab_id: None,
+ title: "codex — test".to_string(),
+ cwd: std::path::PathBuf::from("."),
+ started_at: SystemTime::now(),
+ last_activity_at: SystemTime::now(),
+ status: AgentStatus::Idle,
+ last_error: None,
+ current_tool: None,
+ attention_reason: None,
+ log_path: None,
+ origin: SessionOrigin::default(),
+ };
+ assert_eq!(cli_suffix_for(&s, true), "· codex");
+ assert_eq!(cli_suffix_for(&s, false), String::new());
+ }
}
diff --git a/tools/wta/wt-agent-hooks/claude/.claude-plugin/marketplace.json b/tools/wta/wt-agent-hooks/claude/.claude-plugin/marketplace.json
index c46d38e2d..a5affab17 100644
--- a/tools/wta/wt-agent-hooks/claude/.claude-plugin/marketplace.json
+++ b/tools/wta/wt-agent-hooks/claude/.claude-plugin/marketplace.json
@@ -8,7 +8,7 @@
{
"name": "wt-agent-hooks",
"description": "Forward CLI agent hook events to Windows Terminal for WTA display",
- "version": "0.1.1",
+ "version": "0.1.2",
"source": "./wt-agent-hooks"
}
]
diff --git a/tools/wta/wt-agent-hooks/claude/wt-agent-hooks/.claude-plugin/plugin.json b/tools/wta/wt-agent-hooks/claude/wt-agent-hooks/.claude-plugin/plugin.json
index 584553c77..acfd17533 100644
--- a/tools/wta/wt-agent-hooks/claude/wt-agent-hooks/.claude-plugin/plugin.json
+++ b/tools/wta/wt-agent-hooks/claude/wt-agent-hooks/.claude-plugin/plugin.json
@@ -1,7 +1,7 @@
{
"name": "wt-agent-hooks",
"description": "Forward CLI agent hook events to Windows Terminal for WTA display",
- "version": "0.1.1",
+ "version": "0.1.2",
"author": {
"name": "Agentic Terminal"
},
diff --git a/tools/wta/wt-agent-hooks/claude/wt-agent-hooks/hooks/send-event.ps1 b/tools/wta/wt-agent-hooks/claude/wt-agent-hooks/hooks/send-event.ps1
index 0639f8356..9140f728f 100644
--- a/tools/wta/wt-agent-hooks/claude/wt-agent-hooks/hooks/send-event.ps1
+++ b/tools/wta/wt-agent-hooks/claude/wt-agent-hooks/hooks/send-event.ps1
@@ -1,11 +1,10 @@
# send-event.ps1 — Telemetry hook for WTA agent session tracking.
#
# ── EXIT-CODE CONTRACT ──────────────────────────────────────────────────
-# This script MUST exit 0 unconditionally. It is wired to Claude / Copilot
-# PreToolUse, UserPromptSubmit, Stop, SubagentStop, and other lifecycle
+# This script MUST exit 0 unconditionally. It is wired to lifecycle
# events where a non-zero exit has *semantic* consequences:
# * Exit 2 → blocks the tool call / erases the user prompt /
-# forces Claude to keep going past Stop
+# forces to keep going past Stop
# * Other → shows " hook error" + first line of stderr in the
# transcript on every fire
# Two guarantees defend the contract:
@@ -24,7 +23,7 @@
#
# ── CLI-source identification ───────────────────────────────────────────
# The installer hard-codes which CLI invokes this script via the
-# `-CliSource` parameter (claude / copilot / gemini). That is the
+# `-CliSource` parameter (claude / codex / copilot / gemini). That is the
# ONLY reliable signal — env-var heuristics are unreliable because
# Copilot CLI inherits Claude's plugin shape and sets CLAUDE_PLUGIN_ROOT,
# making it indistinguishable from a real Claude run by env vars alone.
@@ -88,6 +87,7 @@ try {
if ($env:COPILOT_SESSION_ID) { 'copilot' }
elseif ($env:GEMINI_SESSION_ID) { 'gemini' }
elseif ($env:CLAUDE_SESSION_ID) { 'claude' }
+ elseif ($env:CODEX_SESSION_ID) { 'codex' }
elseif ($env:GEMINI_CLI) { 'gemini' }
elseif ($env:COPILOT_CLI) { 'copilot' }
elseif ($env:CLAUDE_PLUGIN_ROOT) { 'claude' }
@@ -130,7 +130,7 @@ try {
$parsed = $hookData | ConvertFrom-Json
}
- # Extract agent_session_id from stdin JSON (Claude/Gemini), env (Copilot), or empty.
+ # Extract agent_session_id from stdin JSON (Claude/Gemini/Codex), env (Copilot), or empty.
$agentSessionId = ""
if ($parsed -and ($parsed.PSObject.Properties.Name -contains "session_id")) {
$agentSessionId = [string]$parsed.session_id
@@ -140,6 +140,8 @@ try {
$agentSessionId = $env:CLAUDE_SESSION_ID
} elseif ($env:GEMINI_SESSION_ID) {
$agentSessionId = $env:GEMINI_SESSION_ID
+ } elseif ($env:CODEX_SESSION_ID) {
+ $agentSessionId = $env:CODEX_SESSION_ID
}
# Detect CLI source — priority order:
@@ -157,6 +159,7 @@ try {
if ($env:COPILOT_SESSION_ID) { $CliSource = "copilot" }
elseif ($env:GEMINI_SESSION_ID) { $CliSource = "gemini" }
elseif ($env:CLAUDE_SESSION_ID) { $CliSource = "claude" }
+ elseif ($env:CODEX_SESSION_ID) { $CliSource = "codex" }
elseif ($env:GEMINI_CLI) { $CliSource = "gemini" }
elseif ($env:COPILOT_CLI) { $CliSource = "copilot" }
elseif ($env:CLAUDE_PLUGIN_ROOT) { $CliSource = "claude" }
diff --git a/tools/wta/wt-agent-hooks/codex/.agents/plugins/marketplace.json b/tools/wta/wt-agent-hooks/codex/.agents/plugins/marketplace.json
new file mode 100644
index 000000000..599a181fa
--- /dev/null
+++ b/tools/wta/wt-agent-hooks/codex/.agents/plugins/marketplace.json
@@ -0,0 +1,20 @@
+{
+ "name": "wt-local",
+ "interface": {
+ "displayName": "Windows Terminal (local)"
+ },
+ "plugins": [
+ {
+ "name": "wt-agent-hooks",
+ "source": {
+ "source": "local",
+ "path": "./wt-agent-hooks"
+ },
+ "policy": {
+ "installation": "AVAILABLE",
+ "authentication": "ON_INSTALL"
+ },
+ "category": "Productivity"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/tools/wta/wt-agent-hooks/codex/wt-agent-hooks/.codex-plugin/plugin.json b/tools/wta/wt-agent-hooks/codex/wt-agent-hooks/.codex-plugin/plugin.json
new file mode 100644
index 000000000..3cb27036e
--- /dev/null
+++ b/tools/wta/wt-agent-hooks/codex/wt-agent-hooks/.codex-plugin/plugin.json
@@ -0,0 +1,8 @@
+{
+ "name": "wt-agent-hooks",
+ "version": "0.1.2",
+ "description": "Forward Codex hook events to Windows Terminal for session-management UI.",
+ "author": {
+ "name": "Agentic Terminal"
+ }
+}
\ No newline at end of file
diff --git a/tools/wta/wt-agent-hooks/codex/wt-agent-hooks/hooks/hooks.json b/tools/wta/wt-agent-hooks/codex/wt-agent-hooks/hooks/hooks.json
new file mode 100644
index 000000000..9b2d2aff8
--- /dev/null
+++ b/tools/wta/wt-agent-hooks/codex/wt-agent-hooks/hooks/hooks.json
@@ -0,0 +1,45 @@
+{
+ "hooks": {
+ "SessionStart": [
+ {
+ "matcher": "startup|resume",
+ "hooks": [
+ {
+ "type": "command",
+ "command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File \"${PLUGIN_ROOT}/hooks/send-event.ps1\" -CliSource codex agent.session.start"
+ }
+ ]
+ }
+ ],
+ "PermissionRequest": [
+ {
+ "hooks": [
+ {
+ "type": "command",
+ "command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File \"${PLUGIN_ROOT}/hooks/send-event.ps1\" -CliSource codex agent.notification"
+ }
+ ]
+ }
+ ],
+ "UserPromptSubmit": [
+ {
+ "hooks": [
+ {
+ "type": "command",
+ "command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File \"${PLUGIN_ROOT}/hooks/send-event.ps1\" -CliSource codex agent.prompt.submit"
+ }
+ ]
+ }
+ ],
+ "Stop": [
+ {
+ "hooks": [
+ {
+ "type": "command",
+ "command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File \"${PLUGIN_ROOT}/hooks/send-event.ps1\" -CliSource codex agent.stop"
+ }
+ ]
+ }
+ ]
+ }
+}
\ No newline at end of file
diff --git a/tools/wta/wt-agent-hooks/codex/wt-agent-hooks/hooks/send-event.ps1 b/tools/wta/wt-agent-hooks/codex/wt-agent-hooks/hooks/send-event.ps1
new file mode 100644
index 000000000..9140f728f
--- /dev/null
+++ b/tools/wta/wt-agent-hooks/codex/wt-agent-hooks/hooks/send-event.ps1
@@ -0,0 +1,255 @@
+# send-event.ps1 — Telemetry hook for WTA agent session tracking.
+#
+# ── EXIT-CODE CONTRACT ──────────────────────────────────────────────────
+# This script MUST exit 0 unconditionally. It is wired to lifecycle
+# events where a non-zero exit has *semantic* consequences:
+# * Exit 2 → blocks the tool call / erases the user prompt /
+# forces to keep going past Stop
+# * Other → shows " hook error" + first line of stderr in the
+# transcript on every fire
+# Two guarantees defend the contract:
+# 1. `trap { exit 0 }` at the top — catches any terminating error that
+# escapes the outer try/catch (script init, throws from inside the
+# catch handler itself, etc).
+# 2. The single outer try/catch wraps every action and broadly swallows
+# anything that fails inside it.
+# Do NOT add `exit N` for non-zero N anywhere. Do NOT remove the trap.
+#
+# ── STDIO DISCIPLINE ────────────────────────────────────────────────────
+# Write nothing to stdout or stderr. On UserPromptSubmit / SessionStart,
+# stdout is added to the model's context — every byte leaks tokens and
+# can be a prompt-injection vector. Diagnostics go to
+# %LOCALAPPDATA%\IntelligentTerminal\logs\hook-trace.log only.
+#
+# ── CLI-source identification ───────────────────────────────────────────
+# The installer hard-codes which CLI invokes this script via the
+# `-CliSource` parameter (claude / codex / copilot / gemini). That is the
+# ONLY reliable signal — env-var heuristics are unreliable because
+# Copilot CLI inherits Claude's plugin shape and sets CLAUDE_PLUGIN_ROOT,
+# making it indistinguishable from a real Claude run by env vars alone.
+param(
+ [string]$EventType = "agent.hook",
+ [string]$CliSource = ""
+)
+
+# Failsafe: see CONTRACT above. Last line of defense behind the outer
+# try/catch. Triggers on any terminating error (including ones thrown
+# from inside the catch handler itself).
+trap { exit 0 }
+
+# Skip if not running inside Windows Terminal.
+# (Checked before the diagnostic trace so we don't spam hook-trace.log
+# with ENTER lines on every tool event when WTA isn't in play — the
+# hook has nothing useful to do without WT_COM_CLSID anyway.)
+if (-not $env:WT_COM_CLSID) { exit 0 }
+
+# Single outer try/catch wraps every action this script takes. Catch is
+# intentionally broad — see CONTRACT above. We deliberately do NOT
+# narrow exception types here: this script must never propagate any
+# failure to the parent agent CLI.
+$tracePath = $null
+try {
+ # ── diagnostic trace + 5 MB rotation ────────────────────────────────
+ # Appends one ENTER line per invocation so we can diagnose missing
+ # SessionEnd events on Ctrl+C. Soft 5 MB rotation: the check fires
+ # at the start of the NEXT hook after the threshold, so both the
+ # active log and the `.1` backup can briefly exceed 5 MB.
+ #
+ # `-ErrorAction SilentlyContinue` on every filesystem cmdlet: the
+ # hook CONTRACT forbids writing anything to stdout/stderr, and
+ # New-Item / Get-Item / Move-Item emit non-terminating errors
+ # (AV-locked file, ACL denied, hooks racing) that bypass the outer
+ # try/catch and would otherwise leak into the parent CLI transcript.
+ # A persistent failure (read-only / no disk) surfaces via unbounded
+ # log growth, which is a visible signal.
+ # Prefer the package-private log dir handed down by wta-master via
+ # WTA_HOOK_LOG_DIR — it points at the LocalCache\Local store this script
+ # can't resolve on its own (it only sees the un-redirected %LOCALAPPDATA%
+ # and doesn't know the package family name). Fall back to the bare path
+ # when the var is absent (unpackaged dev runs / older wta).
+ $traceDir = if ($env:WTA_HOOK_LOG_DIR) {
+ $env:WTA_HOOK_LOG_DIR
+ } else {
+ Join-Path $env:LOCALAPPDATA 'IntelligentTerminal\logs'
+ }
+ if (-not (Test-Path -LiteralPath $traceDir)) {
+ New-Item -ItemType Directory -Path $traceDir -Force -ErrorAction SilentlyContinue | Out-Null
+ }
+ $tracePath = Join-Path $traceDir 'hook-trace.log'
+
+ $traceItem = Get-Item -LiteralPath $tracePath -ErrorAction SilentlyContinue
+ if (($traceItem -is [System.IO.FileInfo]) -and $traceItem.Length -ge 5MB) {
+ Move-Item -LiteralPath $tracePath -Destination "$tracePath.1" -Force -ErrorAction SilentlyContinue
+ }
+
+ $stamp = (Get-Date).ToString('yyyy-MM-dd HH:mm:ss.fff')
+ $cliEnvHint =
+ if ($env:COPILOT_SESSION_ID) { 'copilot' }
+ elseif ($env:GEMINI_SESSION_ID) { 'gemini' }
+ elseif ($env:CLAUDE_SESSION_ID) { 'claude' }
+ elseif ($env:CODEX_SESSION_ID) { 'codex' }
+ elseif ($env:GEMINI_CLI) { 'gemini' }
+ elseif ($env:COPILOT_CLI) { 'copilot' }
+ elseif ($env:CLAUDE_PLUGIN_ROOT) { 'claude' }
+ else { '' }
+ $wtSess = if ($env:WT_SESSION) { $env:WT_SESSION } else { '' }
+ Add-Content -LiteralPath $tracePath -Value "$stamp | ENTER cli=$CliSource event=$EventType envHint=$cliEnvHint wt=$wtSess pid=$PID" -ErrorAction SilentlyContinue
+
+ # ── Locate wtcli.exe ────────────────────────────────────────────────
+ # Order: PATH (works if the package registers a wtcli AppExecutionAlias),
+ # then $env:WTCLI_PATH override (escape hatch for dev builds /
+ # debugging), then the Windows Terminal package InstallLocation
+ # (where the build drops it).
+ $wtcliPath = (Get-Command wtcli -ErrorAction SilentlyContinue).Source
+ if (-not $wtcliPath -and $env:WTCLI_PATH -and (Test-Path $env:WTCLI_PATH)) {
+ $wtcliPath = $env:WTCLI_PATH
+ }
+ if (-not $wtcliPath) {
+ $pkgs = Get-AppxPackage -Name "*Terminal*" -ErrorAction SilentlyContinue
+ foreach ($pkg in $pkgs) {
+ $candidate = Join-Path $pkg.InstallLocation "wtcli.exe"
+ if (Test-Path $candidate) { $wtcliPath = $candidate; break }
+ }
+ }
+ if (-not $wtcliPath) { exit 0 }
+
+ # ── Read hook JSON from stdin ───────────────────────────────────────
+ # May be empty for events that don't carry a payload, e.g. some CLIs'
+ # AfterTool / SessionEnd. We still want those to reach WTA so the
+ # state can transition out of Working back to Idle.
+ $hookData = [Console]::In.ReadToEnd()
+ if (-not $hookData) { $hookData = "" }
+
+ # ConvertFrom-Json on empty/whitespace input throws; skip the call so
+ # the outer catch isn't triggered for a benign empty payload.
+ # Malformed (non-empty) JSON is rare in practice and will fall through
+ # to the outer catch, dropping the event entirely — that's acceptable
+ # given the single-try-catch design.
+ $parsed = $null
+ if ($hookData.Trim()) {
+ $parsed = $hookData | ConvertFrom-Json
+ }
+
+ # Extract agent_session_id from stdin JSON (Claude/Gemini/Codex), env (Copilot), or empty.
+ $agentSessionId = ""
+ if ($parsed -and ($parsed.PSObject.Properties.Name -contains "session_id")) {
+ $agentSessionId = [string]$parsed.session_id
+ } elseif ($env:COPILOT_SESSION_ID) {
+ $agentSessionId = $env:COPILOT_SESSION_ID
+ } elseif ($env:CLAUDE_SESSION_ID) {
+ $agentSessionId = $env:CLAUDE_SESSION_ID
+ } elseif ($env:GEMINI_SESSION_ID) {
+ $agentSessionId = $env:GEMINI_SESSION_ID
+ } elseif ($env:CODEX_SESSION_ID) {
+ $agentSessionId = $env:CODEX_SESSION_ID
+ }
+
+ # Detect CLI source — priority order:
+ # 1. The `-CliSource` script parameter (set by the installer per-CLI;
+ # most reliable: hard-coded at install time, not affected by
+ # env-var leakage between CLIs that share Claude's plugin shape).
+ # 2. WTA_CLI_SOURCE env var (manual override / bash hooks).
+ # 3. CLI-specific session-id env vars (only that CLI sets each one).
+ # 4. CLI-specific marker env vars.
+ # 5. CLAUDE_PLUGIN_ROOT — last resort BEFORE the default.
+ # 6. Default "copilot" — LEGACY fallback; should never be hit when
+ # installer plumbing is correct.
+ if (-not $CliSource) { $CliSource = $env:WTA_CLI_SOURCE }
+ if (-not $CliSource) {
+ if ($env:COPILOT_SESSION_ID) { $CliSource = "copilot" }
+ elseif ($env:GEMINI_SESSION_ID) { $CliSource = "gemini" }
+ elseif ($env:CLAUDE_SESSION_ID) { $CliSource = "claude" }
+ elseif ($env:CODEX_SESSION_ID) { $CliSource = "codex" }
+ elseif ($env:GEMINI_CLI) { $CliSource = "gemini" }
+ elseif ($env:COPILOT_CLI) { $CliSource = "copilot" }
+ elseif ($env:CLAUDE_PLUGIN_ROOT) { $CliSource = "claude" }
+ else { $CliSource = "copilot" }
+ }
+ $cliSource = $CliSource
+
+ # Drop large model-bound fields wta never reads, so multi-KB tool output
+ # doesn't ride the hook -> wtcli -> COM -> wta pipeline for nothing.
+ if ($parsed -is [System.Management.Automation.PSCustomObject]) {
+ foreach ($key in @('tool_result', 'tool_response', 'tool_output', 'toolResult', 'toolResponse', 'toolOutput')) {
+ if ($parsed.PSObject.Properties[$key]) {
+ $parsed.PSObject.Properties.Remove($key)
+ }
+ }
+ }
+
+ $wrapper = @{
+ cli_source = $cliSource
+ agent_session_id = $agentSessionId
+ payload = $parsed
+ }
+
+ $payload = $wrapper | ConvertTo-Json -Compress -Depth 5
+
+ # CommandLineToArgvW-correct escape for a quoted argument:
+ # * Every backslash run that precedes a `"` (or end of string) is doubled.
+ # * Every `"` is preceded by a single extra backslash.
+ # This is required so messages containing Windows paths (e.g. permission
+ # prompts: 'Get-Acl -Path "C:\Windows\..."') don't have their JSON truncated
+ # by the child process's argv parser.
+ $sb = New-Object System.Text.StringBuilder
+ $bsRun = 0
+ foreach ($ch in $payload.ToCharArray()) {
+ if ($ch -eq '\') {
+ $bsRun++
+ } elseif ($ch -eq '"') {
+ [void]$sb.Append([string]'\' * ($bsRun * 2 + 1))
+ [void]$sb.Append('"')
+ $bsRun = 0
+ } else {
+ if ($bsRun -gt 0) { [void]$sb.Append([string]'\' * $bsRun); $bsRun = 0 }
+ [void]$sb.Append($ch)
+ }
+ }
+ if ($bsRun -gt 0) { [void]$sb.Append([string]'\' * ($bsRun * 2)) }
+ $escaped = $sb.ToString()
+
+ # Pass our pane GUID via -p so wtcli stamps the event with this pane's
+ # session_id. Without -p, wtcli falls back to GetActivePane() which is
+ # whichever pane the user is currently focused on — that gives every row
+ # in the session management list the same (focused) pane GUID, so Enter on any live row
+ # focuses the focused pane instead of its own pane.
+ $paneArg = ''
+ if ($env:WT_SESSION) {
+ $paneArg = " -p `"$($env:WT_SESSION)`""
+ }
+ # Async dispatch: launch wtcli via ShellExecuteEx so the parent PowerShell
+ # process can exit immediately without waiting for wtcli's COM round-trip.
+ # The hook contract is "exit 0 quickly"; WTA is a fire-and-observe
+ # listener, so we don't need wtcli's exit code or stderr.
+ #
+ # Why UseShellExecute=$true:
+ # - Child gets its own console (no inherited stdio handles), so this
+ # PowerShell can exit without waiting for the child's pipes to drain.
+ # - WindowStyle=Hidden -> wtcli runs invisibly (no flashing console).
+ # - No cmd.exe wrapper, no handle juggling, no WaitForExit timeout.
+ $psi = New-Object System.Diagnostics.ProcessStartInfo
+ $psi.FileName = $wtcliPath
+ $psi.Arguments = "send-event -e $EventType$paneArg `"$escaped`""
+ $psi.UseShellExecute = $true
+ $psi.WindowStyle = 'Hidden'
+ [void][System.Diagnostics.Process]::Start($psi)
+ $stamp = (Get-Date).ToString('yyyy-MM-dd HH:mm:ss.fff')
+ $sessIdShort = if ($agentSessionId) { $agentSessionId.Substring(0, [Math]::Min(8, $agentSessionId.Length)) } else { '' }
+ Add-Content -LiteralPath $tracePath -Value "$stamp | DISPATCHED cli=$cliSource event=$EventType sessId=$sessIdShort wtcli=$wtcliPath" -ErrorAction SilentlyContinue
+} catch {
+ # Single error sink. Best-effort ERROR breadcrumb; if Add-Content
+ # itself throws, the `trap { exit 0 }` at the top catches it.
+ # $tracePath may be unset if we crashed before reaching the trace
+ # dir setup — guard before touching it.
+ if ($tracePath) {
+ $stamp = (Get-Date).ToString('yyyy-MM-dd HH:mm:ss.fff')
+ $msg = ($_.Exception.Message -replace "[\r\n]+", ' ').Trim()
+ Add-Content -LiteralPath $tracePath -Value "$stamp | ERROR cli=$CliSource event=$EventType ex=`"$msg`"" -ErrorAction SilentlyContinue
+ }
+}
+
+# Explicit exit 0 per CONTRACT above. Without this, PowerShell's default
+# exit code reflects whatever $LASTEXITCODE was set to by the most recent
+# native command (e.g. wtcli's own exit code) — which we do NOT want to
+# propagate to the parent CLI.
+exit 0
diff --git a/tools/wta/wt-agent-hooks/copilot/.claude-plugin/marketplace.json b/tools/wta/wt-agent-hooks/copilot/.claude-plugin/marketplace.json
index c46d38e2d..a5affab17 100644
--- a/tools/wta/wt-agent-hooks/copilot/.claude-plugin/marketplace.json
+++ b/tools/wta/wt-agent-hooks/copilot/.claude-plugin/marketplace.json
@@ -8,7 +8,7 @@
{
"name": "wt-agent-hooks",
"description": "Forward CLI agent hook events to Windows Terminal for WTA display",
- "version": "0.1.1",
+ "version": "0.1.2",
"source": "./wt-agent-hooks"
}
]
diff --git a/tools/wta/wt-agent-hooks/copilot/wt-agent-hooks/.claude-plugin/plugin.json b/tools/wta/wt-agent-hooks/copilot/wt-agent-hooks/.claude-plugin/plugin.json
index 584553c77..acfd17533 100644
--- a/tools/wta/wt-agent-hooks/copilot/wt-agent-hooks/.claude-plugin/plugin.json
+++ b/tools/wta/wt-agent-hooks/copilot/wt-agent-hooks/.claude-plugin/plugin.json
@@ -1,7 +1,7 @@
{
"name": "wt-agent-hooks",
"description": "Forward CLI agent hook events to Windows Terminal for WTA display",
- "version": "0.1.1",
+ "version": "0.1.2",
"author": {
"name": "Agentic Terminal"
},
diff --git a/tools/wta/wt-agent-hooks/copilot/wt-agent-hooks/hooks/send-event.ps1 b/tools/wta/wt-agent-hooks/copilot/wt-agent-hooks/hooks/send-event.ps1
index 0639f8356..9140f728f 100644
--- a/tools/wta/wt-agent-hooks/copilot/wt-agent-hooks/hooks/send-event.ps1
+++ b/tools/wta/wt-agent-hooks/copilot/wt-agent-hooks/hooks/send-event.ps1
@@ -1,11 +1,10 @@
# send-event.ps1 — Telemetry hook for WTA agent session tracking.
#
# ── EXIT-CODE CONTRACT ──────────────────────────────────────────────────
-# This script MUST exit 0 unconditionally. It is wired to Claude / Copilot
-# PreToolUse, UserPromptSubmit, Stop, SubagentStop, and other lifecycle
+# This script MUST exit 0 unconditionally. It is wired to lifecycle
# events where a non-zero exit has *semantic* consequences:
# * Exit 2 → blocks the tool call / erases the user prompt /
-# forces Claude to keep going past Stop
+# forces to keep going past Stop
# * Other → shows " hook error" + first line of stderr in the
# transcript on every fire
# Two guarantees defend the contract:
@@ -24,7 +23,7 @@
#
# ── CLI-source identification ───────────────────────────────────────────
# The installer hard-codes which CLI invokes this script via the
-# `-CliSource` parameter (claude / copilot / gemini). That is the
+# `-CliSource` parameter (claude / codex / copilot / gemini). That is the
# ONLY reliable signal — env-var heuristics are unreliable because
# Copilot CLI inherits Claude's plugin shape and sets CLAUDE_PLUGIN_ROOT,
# making it indistinguishable from a real Claude run by env vars alone.
@@ -88,6 +87,7 @@ try {
if ($env:COPILOT_SESSION_ID) { 'copilot' }
elseif ($env:GEMINI_SESSION_ID) { 'gemini' }
elseif ($env:CLAUDE_SESSION_ID) { 'claude' }
+ elseif ($env:CODEX_SESSION_ID) { 'codex' }
elseif ($env:GEMINI_CLI) { 'gemini' }
elseif ($env:COPILOT_CLI) { 'copilot' }
elseif ($env:CLAUDE_PLUGIN_ROOT) { 'claude' }
@@ -130,7 +130,7 @@ try {
$parsed = $hookData | ConvertFrom-Json
}
- # Extract agent_session_id from stdin JSON (Claude/Gemini), env (Copilot), or empty.
+ # Extract agent_session_id from stdin JSON (Claude/Gemini/Codex), env (Copilot), or empty.
$agentSessionId = ""
if ($parsed -and ($parsed.PSObject.Properties.Name -contains "session_id")) {
$agentSessionId = [string]$parsed.session_id
@@ -140,6 +140,8 @@ try {
$agentSessionId = $env:CLAUDE_SESSION_ID
} elseif ($env:GEMINI_SESSION_ID) {
$agentSessionId = $env:GEMINI_SESSION_ID
+ } elseif ($env:CODEX_SESSION_ID) {
+ $agentSessionId = $env:CODEX_SESSION_ID
}
# Detect CLI source — priority order:
@@ -157,6 +159,7 @@ try {
if ($env:COPILOT_SESSION_ID) { $CliSource = "copilot" }
elseif ($env:GEMINI_SESSION_ID) { $CliSource = "gemini" }
elseif ($env:CLAUDE_SESSION_ID) { $CliSource = "claude" }
+ elseif ($env:CODEX_SESSION_ID) { $CliSource = "codex" }
elseif ($env:GEMINI_CLI) { $CliSource = "gemini" }
elseif ($env:COPILOT_CLI) { $CliSource = "copilot" }
elseif ($env:CLAUDE_PLUGIN_ROOT) { $CliSource = "claude" }
diff --git a/tools/wta/wt-agent-hooks/gemini-extension/gemini-extension.json b/tools/wta/wt-agent-hooks/gemini-extension/gemini-extension.json
index f584728d5..2934b23a7 100644
--- a/tools/wta/wt-agent-hooks/gemini-extension/gemini-extension.json
+++ b/tools/wta/wt-agent-hooks/gemini-extension/gemini-extension.json
@@ -1,5 +1,5 @@
{
"name": "wt-agent-hooks",
- "version": "0.1.1",
+ "version": "0.1.2",
"description": "Forward Gemini CLI hook events to Windows Terminal for WTA display"
}
diff --git a/tools/wta/wt-agent-hooks/gemini-extension/hooks/send-event.ps1 b/tools/wta/wt-agent-hooks/gemini-extension/hooks/send-event.ps1
index 0639f8356..9140f728f 100644
--- a/tools/wta/wt-agent-hooks/gemini-extension/hooks/send-event.ps1
+++ b/tools/wta/wt-agent-hooks/gemini-extension/hooks/send-event.ps1
@@ -1,11 +1,10 @@
# send-event.ps1 — Telemetry hook for WTA agent session tracking.
#
# ── EXIT-CODE CONTRACT ──────────────────────────────────────────────────
-# This script MUST exit 0 unconditionally. It is wired to Claude / Copilot
-# PreToolUse, UserPromptSubmit, Stop, SubagentStop, and other lifecycle
+# This script MUST exit 0 unconditionally. It is wired to lifecycle
# events where a non-zero exit has *semantic* consequences:
# * Exit 2 → blocks the tool call / erases the user prompt /
-# forces Claude to keep going past Stop
+# forces to keep going past Stop
# * Other → shows " hook error" + first line of stderr in the
# transcript on every fire
# Two guarantees defend the contract:
@@ -24,7 +23,7 @@
#
# ── CLI-source identification ───────────────────────────────────────────
# The installer hard-codes which CLI invokes this script via the
-# `-CliSource` parameter (claude / copilot / gemini). That is the
+# `-CliSource` parameter (claude / codex / copilot / gemini). That is the
# ONLY reliable signal — env-var heuristics are unreliable because
# Copilot CLI inherits Claude's plugin shape and sets CLAUDE_PLUGIN_ROOT,
# making it indistinguishable from a real Claude run by env vars alone.
@@ -88,6 +87,7 @@ try {
if ($env:COPILOT_SESSION_ID) { 'copilot' }
elseif ($env:GEMINI_SESSION_ID) { 'gemini' }
elseif ($env:CLAUDE_SESSION_ID) { 'claude' }
+ elseif ($env:CODEX_SESSION_ID) { 'codex' }
elseif ($env:GEMINI_CLI) { 'gemini' }
elseif ($env:COPILOT_CLI) { 'copilot' }
elseif ($env:CLAUDE_PLUGIN_ROOT) { 'claude' }
@@ -130,7 +130,7 @@ try {
$parsed = $hookData | ConvertFrom-Json
}
- # Extract agent_session_id from stdin JSON (Claude/Gemini), env (Copilot), or empty.
+ # Extract agent_session_id from stdin JSON (Claude/Gemini/Codex), env (Copilot), or empty.
$agentSessionId = ""
if ($parsed -and ($parsed.PSObject.Properties.Name -contains "session_id")) {
$agentSessionId = [string]$parsed.session_id
@@ -140,6 +140,8 @@ try {
$agentSessionId = $env:CLAUDE_SESSION_ID
} elseif ($env:GEMINI_SESSION_ID) {
$agentSessionId = $env:GEMINI_SESSION_ID
+ } elseif ($env:CODEX_SESSION_ID) {
+ $agentSessionId = $env:CODEX_SESSION_ID
}
# Detect CLI source — priority order:
@@ -157,6 +159,7 @@ try {
if ($env:COPILOT_SESSION_ID) { $CliSource = "copilot" }
elseif ($env:GEMINI_SESSION_ID) { $CliSource = "gemini" }
elseif ($env:CLAUDE_SESSION_ID) { $CliSource = "claude" }
+ elseif ($env:CODEX_SESSION_ID) { $CliSource = "codex" }
elseif ($env:GEMINI_CLI) { $CliSource = "gemini" }
elseif ($env:COPILOT_CLI) { $CliSource = "copilot" }
elseif ($env:CLAUDE_PLUGIN_ROOT) { $CliSource = "claude" }