feat(codex): SessionMutator stub + session-file resolver
Wires the host's mirror hook end-to-end: FindCodexSessionFile walks ~/.codex/sessions/<YYYY>/<MM>/<DD>/rollout-*-<thread_id>.jsonl to prove the path for the captured thread_id, then logs the no-op. Real rewrite is deferred to v2: codex keys tool calls by `call_<id>` not the `toolu_<id>` Plexum's canonical block format uses, so a v1 rewriter would never find a matching id. When tool-call id round-trip through codex's native surface lands, the rewriter plugs into this file's path resolver. Also rescues cmd/plexum-openai-provider-plugin/main.go from .gitignore (the bare 'plexum-openai-provider-plugin' pattern was too greedy — it matched the source directory). Tightened to '/plexum-openai-provider-plugin' + '/bin/' so only built binaries get ignored.
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,4 +1,4 @@
|
||||
plexum-openai-provider-plugin
|
||||
/plexum-openai-provider-plugin
|
||||
/bin/
|
||||
*.tmp
|
||||
dist/
|
||||
|
||||
189
cmd/plexum-openai-provider-plugin/main.go
Normal file
189
cmd/plexum-openai-provider-plugin/main.go
Normal file
@@ -0,0 +1,189 @@
|
||||
// plexum-openai-provider-plugin wraps the local `codex` CLI binary
|
||||
// (OpenAI Codex CLI ≥ 0.135). Same CLI-driven approach as
|
||||
// Plexum-gemini-provider — each Plexum turn forks one `codex exec`
|
||||
// subprocess in the agent's workspace, captures JSONL output, and
|
||||
// emits canonical TurnEvents.
|
||||
//
|
||||
// Auth: codex CLI's own ~/.codex/ state (ChatGPT login or
|
||||
// OPENAI_API_KEY env). No api_key field in this plugin's config.
|
||||
//
|
||||
// Models: by default only "codex" (use whatever the CLI's bound
|
||||
// account supports — ChatGPT login restricts model selection). If
|
||||
// running on an API key, operator can add other ids via config.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"git.hangman-lab.top/hzhang/Plexum-sdk-go/canonical"
|
||||
plugin "git.hangman-lab.top/hzhang/Plexum-sdk-go/plugin"
|
||||
|
||||
"git.hangman-lab.top/hzhang/Plexum-openai-provider/internal/runner"
|
||||
)
|
||||
|
||||
const pluginName = "plexum-openai-provider"
|
||||
|
||||
// Default model surface. "codex" → no --model flag (CLI default).
|
||||
// Operator can extend via config.SupportedModels override (e.g.
|
||||
// "gpt-5", "o3" — only valid on API-key accounts).
|
||||
var defaultModels = []string{"codex"}
|
||||
|
||||
// HostConfig at <profile>/plugins/plexum-openai-provider/config.json.
|
||||
// All fields optional.
|
||||
//
|
||||
// {
|
||||
// "binary": "codex",
|
||||
// "extra_args": ["--enable", "some-feature"],
|
||||
// "supported_models": ["codex","gpt-5"], // override manifest models list
|
||||
// "model_args": { // map plexum model id → --model value
|
||||
// "gpt-5": "gpt-5"
|
||||
// }
|
||||
// }
|
||||
type HostConfig struct {
|
||||
Binary string `json:"binary"`
|
||||
ExtraArgs []string `json:"extra_args"`
|
||||
SupportedModels []string `json:"supported_models"`
|
||||
ModelArgs map[string]string `json:"model_args"`
|
||||
}
|
||||
|
||||
type openaiPlugin struct {
|
||||
host plugin.HostAPI
|
||||
cfg HostConfig
|
||||
// modelsAdvertised is the final list used for both the manifest
|
||||
// AND admission ("does the agent's model match one we serve?").
|
||||
modelsAdvertised []string
|
||||
}
|
||||
|
||||
func (p *openaiPlugin) Manifest() plugin.Manifest {
|
||||
// Manifest is built dynamically per init so an operator's
|
||||
// supported_models override surfaces correctly.
|
||||
loaded := p.loadConfigBareForManifest()
|
||||
models := loaded.SupportedModels
|
||||
if len(models) == 0 {
|
||||
models = defaultModels
|
||||
}
|
||||
return plugin.Manifest{
|
||||
Name: pluginName,
|
||||
Version: "0.1.0",
|
||||
Activation: plugin.ActivationLazy,
|
||||
Executable: "plexum-openai-provider-plugin",
|
||||
Contracts: plugin.Contracts{
|
||||
Provider: &plugin.ProviderContract{Models: models},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// loadConfigBareForManifest reads the config file once during
|
||||
// Manifest() (before Init runs) so the advertised model list reflects
|
||||
// operator overrides without requiring a second restart cycle. Errors
|
||||
// are silent — Init will surface them.
|
||||
func (p *openaiPlugin) loadConfigBareForManifest() HostConfig {
|
||||
profileRoot := os.Getenv("PLEXUM_PROFILE_ROOT")
|
||||
if profileRoot == "" {
|
||||
home, _ := os.UserHomeDir()
|
||||
profileRoot = filepath.Join(home, ".plexum")
|
||||
}
|
||||
raw, _ := os.ReadFile(filepath.Join(profileRoot, "plugins", pluginName, "config.json"))
|
||||
var c HostConfig
|
||||
_ = json.Unmarshal(raw, &c)
|
||||
return c
|
||||
}
|
||||
|
||||
func (p *openaiPlugin) Init(ctx context.Context, host plugin.HostAPI) error {
|
||||
p.host = host
|
||||
|
||||
profileRoot := os.Getenv("PLEXUM_PROFILE_ROOT")
|
||||
if profileRoot == "" {
|
||||
home, _ := os.UserHomeDir()
|
||||
profileRoot = filepath.Join(home, ".plexum")
|
||||
}
|
||||
cfgPath := filepath.Join(profileRoot, "plugins", pluginName, "config.json")
|
||||
raw, err := os.ReadFile(cfgPath)
|
||||
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return fmt.Errorf("read %s: %w", cfgPath, err)
|
||||
}
|
||||
if len(raw) > 0 {
|
||||
if err := json.Unmarshal(raw, &p.cfg); err != nil {
|
||||
return fmt.Errorf("parse %s: %w", cfgPath, err)
|
||||
}
|
||||
}
|
||||
if p.cfg.Binary == "" {
|
||||
p.cfg.Binary = "codex"
|
||||
}
|
||||
if len(p.cfg.SupportedModels) > 0 {
|
||||
p.modelsAdvertised = p.cfg.SupportedModels
|
||||
} else {
|
||||
p.modelsAdvertised = defaultModels
|
||||
}
|
||||
host.Log("info", "openai (codex) provider initialized", map[string]any{
|
||||
"binary": p.cfg.Binary,
|
||||
"extra_args": p.cfg.ExtraArgs,
|
||||
"models": p.modelsAdvertised,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stream is the bare ProviderPlugin entrypoint; SDK uses
|
||||
// StreamWithAgent below when AgentContext is needed.
|
||||
func (p *openaiPlugin) Stream(ctx context.Context, modelID string, req canonical.TurnRequest) (<-chan canonical.TurnEvent, error) {
|
||||
return p.StreamWithAgent(ctx, modelID, plugin.AgentContext{}, req)
|
||||
}
|
||||
|
||||
func (p *openaiPlugin) StreamWithAgent(ctx context.Context, modelID string, agent plugin.AgentContext, req canonical.TurnRequest) (<-chan canonical.TurnEvent, error) {
|
||||
cliModel := p.mapModel(modelID)
|
||||
return runner.Run(ctx, p.host, agent, cliModel, p.cfg.Binary, p.cfg.ExtraArgs, req)
|
||||
}
|
||||
|
||||
// MutateSession is the session-mirror hook (decision #29x). codex
|
||||
// stores transcripts at ~/.codex/sessions/<YYYY>/<MM>/<DD>/rollout-*
|
||||
// -<thread_id>.jsonl as response_item lines whose payloads carry
|
||||
// function_call / function_call_output entries keyed by `call_id`
|
||||
// (not toolu_*) — so a Plexum tool_use_id like "toolu_X" won't ever
|
||||
// match in there.
|
||||
//
|
||||
// v1 ships as a logged no-op: we resolve the file path so operator
|
||||
// log can confirm wiring, but we don't rewrite. When Plexum's tool
|
||||
// dispatch eventually routes through codex's native tool surface
|
||||
// (preserving call_id round-trip), the rewrite logic plugs in here.
|
||||
func (p *openaiPlugin) MutateSession(ctx context.Context, req plugin.SessionMutateRequest) error {
|
||||
path, err := runner.FindCodexSessionFile(req.Workspace)
|
||||
if err != nil {
|
||||
p.host.Log("debug", "codex session mutate: find failed", map[string]any{
|
||||
"agent": req.AgentID, "err": err.Error(),
|
||||
})
|
||||
return nil
|
||||
}
|
||||
p.host.Log("info", "codex session mutate (no-op v1)", map[string]any{
|
||||
"agent": req.AgentID,
|
||||
"session_path": path,
|
||||
"requested": len(req.Mutations),
|
||||
"reason": "codex uses call_* ids; tool-call round-trip not yet wired",
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// mapModel resolves the Plexum-advertised model id into the CLI's
|
||||
// --model value. "" / "codex" → no --model flag (CLI default, only
|
||||
// thing ChatGPT-account installs support).
|
||||
func (p *openaiPlugin) mapModel(plexumModel string) string {
|
||||
if plexumModel == "" || plexumModel == "codex" {
|
||||
return ""
|
||||
}
|
||||
if v, ok := p.cfg.ModelArgs[plexumModel]; ok {
|
||||
return v
|
||||
}
|
||||
// Unknown id — pass through verbatim. Codex will accept or 400.
|
||||
return plexumModel
|
||||
}
|
||||
|
||||
func main() {
|
||||
if err := plugin.Serve(&openaiPlugin{}); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "plexum-openai-provider-plugin: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
63
internal/runner/session_mutate.go
Normal file
63
internal/runner/session_mutate.go
Normal file
@@ -0,0 +1,63 @@
|
||||
// session_mutate.go — codex session file path resolution. The actual
|
||||
// JSONL rewrite is deferred (see main.go MutateSession comment): codex
|
||||
// keys tool calls by `call_<id>`, not the `toolu_<id>` Plexum uses,
|
||||
// so a v1 mirror would never match anything. We expose the path
|
||||
// finder here so the host-side wiring telemetry stays useful and the
|
||||
// v2 rewriter has its entry point.
|
||||
//
|
||||
// Codex layout (observed):
|
||||
//
|
||||
// ~/.codex/sessions/<YYYY>/<MM>/<DD>/rollout-<ISO>-<thread_id>.jsonl
|
||||
//
|
||||
// Older layout (pre-0.5x) wrote into the top-level sessions dir with
|
||||
// the same filename convention. We probe both.
|
||||
|
||||
package runner
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// FindCodexSessionFile resolves the rollout JSONL for the thread_id
|
||||
// captured into workspace/.plexum-codex-session. Returns ("", error)
|
||||
// when no session id is recorded yet OR no matching file is on disk.
|
||||
func FindCodexSessionFile(workspace string) (string, error) {
|
||||
threadID := loadSessionID(workspace)
|
||||
if threadID == "" {
|
||||
return "", errors.New("no thread_id captured yet")
|
||||
}
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
root := filepath.Join(home, ".codex", "sessions")
|
||||
var found string
|
||||
err = filepath.WalkDir(root, func(p string, d os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return nil // tolerate per-entry errors; keep walking
|
||||
}
|
||||
if d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
name := d.Name()
|
||||
if !strings.HasSuffix(name, ".jsonl") && !strings.HasSuffix(name, ".json") {
|
||||
return nil
|
||||
}
|
||||
if strings.Contains(name, threadID) {
|
||||
found = p
|
||||
return filepath.SkipAll
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if found == "" {
|
||||
return "", fmt.Errorf("no rollout file for thread_id=%s under %s", threadID, root)
|
||||
}
|
||||
return found, nil
|
||||
}
|
||||
Reference in New Issue
Block a user