Skip to content

Commit a9459fa

Browse files
authored
Merge pull request #74 from StackOneHQ/feat/codex-compat-parity
feat(): codex parity with claude code session information
2 parents 742fb39 + 972415a commit a9459fa

8 files changed

Lines changed: 313 additions & 10 deletions

File tree

README.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,10 @@
3030
| Cursor ||
3131
| Codex ||
3232
| Gemini CLI | ✅ † |
33+
| Antigravity CLI | ✅ † |
3334
| Any hooks-capable agent | ✅ — point it at `notify.sh` |
3435

35-
† Gemini's tool-permission hook is observability-only — the banner shows the prompt, but the actual Allow / Deny click has to happen in Gemini's terminal. Claude Code, Cursor, and Codex permission events can be approved from the panel directly.
36+
† Gemini CLI and Antigravity route tool-permission prompts through an observability-only hook — the banner shows the prompt, but the Allow / Deny click still has to happen in the agent's own terminal. Claude Code and Codex permission events can be approved from the panel directly.
3637

3738
**Platforms:** macOS — full app with panel, click-to-focus banners, auto-update, quota tracking, voice. Linux (PulseAudio / ALSA / libnotify) and Windows (Git Bash / WSL) get audio + basic notifications via `notify.sh` only.
3839

@@ -75,7 +76,7 @@ cd stack-nudge
7576

7677
**Prerequisites:** Python ≥ 3.10 (the bundled voice engine [stackvox](https://github.com/StackOneHQ/stackvox) requires it).
7778

78-
The installer auto-wires hooks for **Claude Code** (`~/.claude/settings.json`) and **Cursor** (`~/.cursor/hooks.json`). Gemini CLI and Codex are supported through the same `notify.sh` entry-point, but their hooks must be wired manually — see [Manual setup](#manual-setup) below.
79+
The installer auto-wires hooks for every detected agent — **Claude Code** (`~/.claude`), **Cursor** (`~/.cursor`), **Codex** (`~/.codex`), **Gemini CLI** (`~/.gemini`), and **Antigravity CLI** (`~/.gemini/antigravity-cli`). Any other hooks-capable agent can be wired by hand — see [Manual setup](#manual-setup) below.
7980

8081
### From source (macOS dev)
8182

@@ -335,7 +336,7 @@ Same set of cleanups as the in-app path, useful when the .app isn't reachable or
335336

336337
## Manual setup
337338

338-
Claude Code, Cursor, Codex, and Gemini CLI are auto-wired by the first-launch wizard. For other hooks-capable agents (or to integrate from a custom script), all you need is to invoke `notify.sh <agent-label> <event>` from wherever your agent emits lifecycle events. `<event>` should be `stop` (agent finished a turn) or `permission` (waiting for approval); `<agent-label>` can be anything — it just controls the banner title.
339+
Claude Code, Cursor, Codex, Gemini CLI, and Antigravity CLI are auto-wired by the first-launch wizard. For other hooks-capable agents (or to integrate from a custom script), all you need is to invoke `notify.sh <agent-label> <event>` from wherever your agent emits lifecycle events. `<event>` should be `stop` (agent finished a turn) or `permission` (waiting for approval); `<agent-label>` can be anything — it just controls the banner title.
339340

340341
Example block in any agent's hooks config:
341342

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import XCTest
2+
3+
@testable import StackNudgePanelCore
4+
5+
// CodexTranscriptReader parses Codex CLI rollout JSONL into the same
6+
// TranscriptStats the Claude reader produces. The schema is fixed against
7+
// Codex's own TokenUsage definition: context occupancy is
8+
// last_token_usage.total_tokens - reasoning_output_tokens, and cached input
9+
// is a subset of input (never summed). Fixtures here encode that contract so
10+
// a Codex schema drift, or a regression to the cached-double-count bug, is
11+
// caught without needing a live Codex session.
12+
final class CodexTranscriptReaderTests: XCTestCase {
13+
14+
private func writeRollout(_ lines: [String], name: String = "rollout-test.jsonl") -> String {
15+
let dir = NSTemporaryDirectory() + "codex-rollout-\(UUID().uuidString)/"
16+
try? FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true)
17+
let path = dir + name
18+
try? (lines.joined(separator: "\n") + "\n")
19+
.write(toFile: path, atomically: true, encoding: .utf8)
20+
return path
21+
}
22+
23+
func test_read_usesContextOccupancyFromLatestTokenCount() {
24+
let path = writeRollout([
25+
#"{"type":"session_meta","payload":{"id":"s1","model":"gpt-5-codex"}}"#,
26+
#"{"type":"turn_context","payload":{"model":"gpt-5-codex"}}"#,
27+
#"{"type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":10000,"cached_input_tokens":2000,"output_tokens":500,"reasoning_output_tokens":300,"total_tokens":10800},"last_token_usage":{"input_tokens":10000,"cached_input_tokens":2000,"output_tokens":500,"reasoning_output_tokens":300,"total_tokens":10800},"model_context_window":272000}}}"#,
28+
#"{"type":"response_item","payload":{"role":"assistant"}}"#,
29+
#"{"type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":60000,"cached_input_tokens":50000,"output_tokens":2000,"reasoning_output_tokens":1000,"total_tokens":63000},"last_token_usage":{"input_tokens":52000,"cached_input_tokens":48000,"output_tokens":1500,"reasoning_output_tokens":1000,"total_tokens":54500},"model_context_window":272000}}}"#,
30+
])
31+
32+
let actual = CodexTranscriptReader.read(path: path)
33+
34+
// Latest token_count: last_token_usage.total_tokens - reasoning = 54500 - 1000.
35+
XCTAssertEqual(actual?.tokens, 53500)
36+
XCTAssertEqual(actual?.model, "gpt-5-codex")
37+
}
38+
39+
func test_read_ignoresCumulativeTotalAndCachedSubset() {
40+
// A single turn where cached == input. If the reader wrongly summed
41+
// cached into input, or used the cumulative total_token_usage, the
42+
// number would differ. Occupancy must be last.total - last.reasoning.
43+
let path = writeRollout([
44+
#"{"type":"turn_context","payload":{"model":"gpt-5"}}"#,
45+
#"{"type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":900000,"cached_input_tokens":0,"output_tokens":40000,"reasoning_output_tokens":12000,"total_tokens":940000},"last_token_usage":{"input_tokens":30000,"cached_input_tokens":30000,"output_tokens":800,"reasoning_output_tokens":200,"total_tokens":30800},"model_context_window":400000}}}"#,
46+
])
47+
48+
let actual = CodexTranscriptReader.read(path: path)
49+
50+
XCTAssertEqual(actual?.tokens, 30600) // 30800 - 200, not 940000-ish, not +cached
51+
XCTAssertEqual(actual?.model, "gpt-5")
52+
}
53+
54+
func test_read_returnsNilWhenNoTokenCount() {
55+
let path = writeRollout([
56+
#"{"type":"session_meta","payload":{"model":"gpt-5-codex"}}"#,
57+
#"{"type":"response_item","payload":{"role":"user"}}"#,
58+
])
59+
60+
XCTAssertNil(CodexTranscriptReader.read(path: path))
61+
}
62+
63+
func test_read_returnsNilForMissingFile() {
64+
XCTAssertNil(CodexTranscriptReader.read(path: "/nonexistent/rollout-x.jsonl"))
65+
}
66+
67+
func test_dispatch_routesRolloutFilenameToCodexReader() {
68+
// A Claude-shaped assistant line written into a rollout-* file. If the
69+
// dispatcher routed by filename to the Codex reader (correct), it finds
70+
// no token_count and returns nil. If it fell through to the Claude
71+
// reader, it would parse the usage block and return tokens. nil proves
72+
// the routing.
73+
let path = writeRollout([
74+
#"{"type":"assistant","message":{"model":"claude-x","usage":{"input_tokens":5,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}}"#,
75+
], name: "rollout-abc.jsonl")
76+
77+
XCTAssertNil(TranscriptReader.read(path: path))
78+
}
79+
}

build.sh

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,7 @@ build_app "$APP" "stack-nudge" \
239239
panel/Sessions.swift \
240240
panel/CompactView.swift \
241241
panel/TranscriptStats.swift \
242+
panel/CodexTranscriptStats.swift \
242243
panel/ModelLimits.swift \
243244
panel/Phrases.swift \
244245
panel/UpdateChecker.swift \

install.sh

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,11 @@ if [[ -z "$PYTHON" ]]; then
9595
exit 1
9696
fi
9797
if [[ ! -x "$VENV/bin/stackvox" ]]; then
98+
# Clear any partial/incompatible venv first. `python -m venv` over an
99+
# existing directory can fail with "[Errno 17] File exists" — e.g. a venv
100+
# left by a different Python, or an interrupted earlier install — so make
101+
# creation idempotent rather than aborting the whole install (set -e).
102+
rm -rf "$VENV"
98103
"$PYTHON" -m venv "$VENV"
99104
"$VENV/bin/pip" install --quiet "$STACKVOX_SPEC"
100105
echo " Voice engine installed -> $VENV (using $PYTHON)"
@@ -301,6 +306,57 @@ PY
301306
installed_any=true
302307
fi
303308

309+
# Codex
310+
# Codex's hooks file shares Claude Code's matcher-group JSON shape and event
311+
# names (Stop + PermissionRequest), in seconds — only the path and agent-arg
312+
# differ. See https://developers.openai.com/codex/hooks
313+
if [[ -d "$HOME/.codex" ]]; then
314+
echo ""
315+
echo "Detected Codex (~/.codex)"
316+
python3 - "$HOME/.codex/hooks.json" "$NOTIFY" "codex" <<'PY'
317+
import json, os, re, sys
318+
from pathlib import Path
319+
320+
path = Path(sys.argv[1])
321+
notify = sys.argv[2]
322+
agent = sys.argv[3]
323+
path.parent.mkdir(parents=True, exist_ok=True)
324+
if path.exists():
325+
settings = json.loads(path.read_text() or "{}")
326+
else:
327+
settings = {}
328+
329+
STALE = re.compile(r"(?:^|/)\.?(?:tinynudge|stack-nudge)/notify\.sh(?:\s|$)")
330+
331+
hooks = settings.setdefault("hooks", {})
332+
# PermissionRequest blocks on a FIFO until the user approves via stack-nudge,
333+
# so it needs a longer timeout than the default.
334+
for event, arg, timeout in [("Stop", "stop", 30), ("PermissionRequest", "permission", 600)]:
335+
groups = hooks.setdefault(event, [])
336+
337+
cleaned = []
338+
for g in groups:
339+
inner = g.get("hooks", [])
340+
kept = [h for h in inner if not STALE.search(h.get("command", "") or "")]
341+
if not kept:
342+
continue
343+
if kept != inner:
344+
g = {**g, "hooks": kept}
345+
cleaned.append(g)
346+
groups[:] = cleaned
347+
348+
cmd = f"{notify} {agent} {arg}"
349+
groups.append({
350+
"matcher": "",
351+
"hooks": [{"type": "command", "command": cmd, "timeout": timeout}],
352+
})
353+
354+
path.write_text(json.dumps(settings, indent=2) + "\n")
355+
print(f" Updated {path}")
356+
PY
357+
installed_any=true
358+
fi
359+
304360
# Gemini CLI
305361
if [[ -d "$HOME/.gemini" ]]; then
306362
echo ""
@@ -395,7 +451,7 @@ fi
395451

396452
if [[ "$installed_any" == "false" ]]; then
397453
echo ""
398-
echo "No supported agents detected (Claude Code, Cursor, Gemini CLI, Antigravity CLI)."
454+
echo "No supported agents detected (Claude Code, Cursor, Codex, Gemini CLI, Antigravity CLI)."
399455
echo "Install one, then re-run this script."
400456
exit 0
401457
fi

notify.sh

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,18 @@ permission_context() {
4040
file=$(printf '%s' "$HOOK_JSON" | jq -r '.tool_input.file_path // empty' 2>/dev/null | sed 's|.*/||')
4141
[[ -n "$file" ]] && echo "${tool_name}: ${file}"
4242
;;
43+
apply_patch)
44+
# Codex's edit tool. tool_input carries a patch envelope rather than a
45+
# plain file_path; pull the first target file out of the patch body.
46+
# tool_input may be the patch string itself or wrap it under .input/.patch.
47+
local patch file
48+
patch=$(printf '%s' "$HOOK_JSON" \
49+
| jq -r '.tool_input | if type=="string" then . else (.input // .patch // empty) end' 2>/dev/null)
50+
file=$(printf '%s\n' "$patch" \
51+
| grep -m1 -oE '^\*\*\* (Add|Update|Delete) File: .+' \
52+
| sed -E 's/^.*File: //; s|.*/||')
53+
if [[ -n "$file" ]]; then echo "apply_patch: ${file}"; else echo "apply_patch"; fi
54+
;;
4355
*)
4456
echo "$tool_name"
4557
;;
@@ -64,6 +76,15 @@ voice_permission_context() {
6476
file=$(printf '%s' "$HOOK_JSON" | jq -r '.tool_input.file_path // empty' 2>/dev/null | sed 's|.*/||')
6577
[[ -n "$file" ]] && echo "${tool_name}: ${file}"
6678
;;
79+
apply_patch)
80+
local patch file
81+
patch=$(printf '%s' "$HOOK_JSON" \
82+
| jq -r '.tool_input | if type=="string" then . else (.input // .patch // empty) end' 2>/dev/null)
83+
file=$(printf '%s\n' "$patch" \
84+
| grep -m1 -oE '^\*\*\* (Add|Update|Delete) File: .+' \
85+
| sed -E 's/^.*File: //; s|.*/||')
86+
if [[ -n "$file" ]]; then echo "apply_patch: ${file}"; else echo "Edit needs approval"; fi
87+
;;
6788
*)
6889
echo "$tool_name"
6990
;;
@@ -219,6 +240,21 @@ agent_label() {
219240
esac
220241
}
221242

243+
# True when this agent's permission hook blocks on stdout for an allow/deny
244+
# decision, so the panel's Approve/Deny can actually drive it. Claude Code and
245+
# Codex use the blocking PermissionRequest hook with the hookSpecificOutput
246+
# decision schema. Gemini and Antigravity route permission alerts through the
247+
# fire-and-forget Notification hook, which can't consume a decision — creating
248+
# a FIFO and blocking on it for those just burns the hook's timeout budget and
249+
# shows an Allow button that does nothing. (Phase 2 will add antigravity here
250+
# once it's wired via the decision-capable PreToolUse hook.)
251+
agent_supports_decision() {
252+
case "$AGENT" in
253+
claude-code|codex) return 0 ;;
254+
*) return 1 ;;
255+
esac
256+
}
257+
222258
# Bundled voice engine paths. stackvox 0.3.x consolidated the CLI — there
223259
# is no separate `stackvox-say` console script anymore; speech goes through
224260
# `stackvox say <text>` as a subcommand.
@@ -477,9 +513,12 @@ notify_macos() {
477513
-e "end tell" 2>/dev/null)
478514
fi
479515

516+
# Only offer the in-panel Allow/Deny (and block on a FIFO for the response)
517+
# when the agent's hook can actually consume a decision. For observability-
518+
# only agents the banner still shows; the user approves in the agent's own UI.
480519
local has_action="false"
481520
local fifo_path=""
482-
if [[ "${EVENT}" == "permission" ]]; then
521+
if [[ "${EVENT}" == "permission" ]] && agent_supports_decision; then
483522
has_action="true"
484523
fifo_path=$(create_perm_fifo)
485524
fi

panel/CodexTranscriptStats.swift

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import Foundation
2+
3+
// Reads a Codex CLI rollout JSONL (~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl)
4+
// and returns the same TranscriptStats the Claude reader produces, so the
5+
// Sessions/Compact UI shows context usage for Codex sessions identically.
6+
//
7+
// Codex differs from Claude Code's transcript in three ways that matter here:
8+
// • Usage lives in `event_msg` lines whose payload.type == "token_count",
9+
// not in assistant messages. `last_token_usage` is the latest turn's
10+
// snapshot; `total_token_usage` is cumulative across the whole session
11+
// (useful for cost, wrong for "how full is the window now").
12+
// • Context-window occupancy is `total_tokens - reasoning_output_tokens`,
13+
// mirroring Codex's own TokenUsage::tokens_in_context_window() — reasoning
14+
// tokens don't persist in the window between turns.
15+
// • `input_tokens` already includes `cached_input_tokens` (cached is a
16+
// subset, not additive), so we must not sum them — the mistake that
17+
// produces the well-known token-inflation bugs in third-party parsers.
18+
//
19+
// The active model is stamped on `turn_context` lines (and `session_meta`).
20+
enum CodexTranscriptReader {
21+
22+
// Read the whole file and scan newest-first, same approach (and same
23+
// tens-of-MB caveat) as the Claude reader. The latest token_count gives
24+
// current context occupancy; the latest model-bearing line gives the
25+
// active model. Returns nil for unreadable files or rollouts with no
26+
// token_count event yet.
27+
static func read(path: String) -> TranscriptStats? {
28+
guard let data = try? Data(contentsOf: URL(fileURLWithPath: path), options: .mappedIfSafe),
29+
let text = String(data: data, encoding: .utf8)
30+
else { return nil }
31+
32+
let lines = text.split(separator: "\n", omittingEmptySubsequences: true)
33+
34+
var tokens: Int?
35+
var model: String?
36+
37+
for line in lines.reversed() {
38+
if tokens != nil && model != nil { break }
39+
guard let lineData = line.data(using: .utf8),
40+
let obj = try? JSONSerialization.jsonObject(with: lineData) as? [String: Any]
41+
else { continue }
42+
43+
let payload = obj["payload"] as? [String: Any]
44+
let payloadType = (payload?["type"] as? String) ?? (obj["type"] as? String)
45+
46+
if tokens == nil,
47+
payloadType == "token_count",
48+
let info = payload?["info"] as? [String: Any],
49+
let last = info["last_token_usage"] as? [String: Any] {
50+
let total = intValue(last["total_tokens"])
51+
let reasoning = intValue(last["reasoning_output_tokens"])
52+
if total > 0 { tokens = max(0, total - reasoning) }
53+
}
54+
55+
if model == nil {
56+
model = modelFrom(obj: obj, payload: payload)
57+
}
58+
}
59+
60+
guard let tokens else { return nil }
61+
return TranscriptStats(tokens: tokens, model: model)
62+
}
63+
64+
// Codex stamps the active model on turn_context lines; session_meta carries
65+
// it for the session as a whole. Nesting has shifted across Codex versions,
66+
// so probe the few known shapes defensively and ignore anything else.
67+
private static func modelFrom(obj: [String: Any], payload: [String: Any]?) -> String? {
68+
if let model = payload?["model"] as? String, !model.isEmpty { return model }
69+
if let turnContext = payload?["turn_context"] as? [String: Any],
70+
let model = turnContext["model"] as? String, !model.isEmpty { return model }
71+
if let model = obj["model"] as? String, !model.isEmpty { return model }
72+
return nil
73+
}
74+
75+
// Rollout numbers are emitted as JSON integers, but decode defensively in
76+
// case a writer or version serialises them as doubles.
77+
private static func intValue(_ value: Any?) -> Int {
78+
if let int = value as? Int { return int }
79+
if let number = value as? NSNumber { return number.intValue }
80+
if let double = value as? Double { return Int(double) }
81+
return 0
82+
}
83+
}

panel/TranscriptStats.swift

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,17 +14,31 @@ struct TranscriptStats: Equatable {
1414

1515
enum TranscriptReader {
1616

17-
// Read the transcript at `path`, scan from the end for the most
18-
// recent assistant message with a usage block, and return its
19-
// stats. Returns nil for unreadable files, empty transcripts, or
20-
// transcripts that don't yet contain an assistant message.
17+
// Dispatch by transcript path. Codex rollout files live under
18+
// ~/.codex/sessions/.../rollout-*.jsonl and use a different JSONL schema
19+
// (token_count events rather than assistant-message usage blocks), so they
20+
// route to CodexTranscriptReader. Everything else is a Claude Code
21+
// transcript. Both return the same TranscriptStats shape so the Sessions
22+
// and Compact views render context usage identically across agents.
23+
static func read(path: String) -> TranscriptStats? {
24+
if path.contains("/.codex/")
25+
|| (path as NSString).lastPathComponent.hasPrefix("rollout-") {
26+
return CodexTranscriptReader.read(path: path)
27+
}
28+
return readClaude(path: path)
29+
}
30+
31+
// Read a Claude Code transcript at `path`, scan from the end for the most
32+
// recent assistant message with a usage block, and return its stats.
33+
// Returns nil for unreadable files, empty transcripts, or transcripts that
34+
// don't yet contain an assistant message.
2135
//
2236
// We read the whole file rather than tail-seeking — Claude Code
2337
// transcripts are typically a few MB even for long sessions, and
2438
// tail-seeking JSONL safely requires byte-by-byte reverse scanning
2539
// to find a newline boundary. If transcripts grow to tens of MB
2640
// in practice we can revisit.
27-
static func read(path: String) -> TranscriptStats? {
41+
private static func readClaude(path: String) -> TranscriptStats? {
2842
guard let data = try? Data(contentsOf: URL(fileURLWithPath: path), options: .mappedIfSafe),
2943
let text = String(data: data, encoding: .utf8)
3044
else { return nil }

0 commit comments

Comments
 (0)