Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions FORK.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ before changing anything outside the agent crates.

## What is where

Experimental Codex Desktop context and its native command contract are documented in
[docs/codex-desktop.md](docs/codex-desktop.md). The application changes remain in the fork's
agent crates and native startup; launchers and routing are a companion AItoCraft example.

| Path | What | Upstream? |
| --- | --- | --- |
| `crates/agent/` (`gridcraft-agent`) | The layer: data model, anchors, storage in the `.xlsx`, `agent.*` commands, privacy masking, trace, sensitivity, alternatives and calculation methods (`variants.rs`), worst and best combinations and the risk matrix (`stress.rs`), safe file mode (`safe.rs`), Excel → slide links (`links.rs`), the in-app MCP server | fork only |
Expand Down
3 changes: 3 additions & 0 deletions apps/gridcraft/src/agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,9 @@ pub fn control_port(agent: Option<u16>, control: Option<u16>) -> Option<u16> {
pub fn start(agent: Option<u16>, control: Option<u16>) {
let (Some(port), Some(control)) = (agent, control) else { return };
gridcraft_agent_ui::install();
if gridcraft_agent::context::codex_enabled() {
gridcraft_agent::context::persist(false);
}
if !MODE_FROM_FLAG.load(std::sync::atomic::Ordering::Relaxed) {
gridcraft_agent::runner::load_saved_mode();
// What a new document's agent knows: the last choice made in the app.
Expand Down
113 changes: 108 additions & 5 deletions crates/agent-ui/src/context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,13 @@ pub(crate) enum Step {
InstallCode,
AskSession,
ChooseFolder,
CheckCodex,
}

impl Step {
pub(crate) fn label(self) -> &'static str {
match self {
Step::CheckCodex => "Connect Codex",
Step::LogIn => "Log in to Claude",
Step::InstallDesktop | Step::InstallCode => "Install AItoCraft",
Step::AskCowork => "Ask Cowork to collaborate",
Expand All @@ -35,6 +37,7 @@ impl Step {
}
pub(crate) fn tip(self) -> &'static str {
match self {
Step::CheckCodex => "Connects every open and new document to the shared Codex Desktop server.",
Step::LogIn => "Opens a terminal with `claude auth login`",
Step::InstallDesktop => "Adds AItoCraft to Claude Desktop, where Cowork runs: one connection for every Craft app",
Step::AskCowork => "Copies the phrase for Claude: paste it into your Cowork chat",
Expand Down Expand Up @@ -109,6 +112,7 @@ pub(crate) struct View {
pub steps: Vec<(Step, bool)>,
/// The chosen project folder, when it's there.
pub folder: Option<String>,
pub route: Option<cx::CodexRoute>,
}

impl View {
Expand Down Expand Up @@ -173,6 +177,17 @@ pub(crate) fn view(
let (l, d) = background(if c.web { " It may also search and read the web." } else { " It sees only this document." });
(l, d, login.into_iter().collect())
}
(ContextKind::Codex, _) => {
// Old answers never prove a live connection: only this document's listener does.
let ready = matches!(presence, Presence::Listening { seen_ms, .. } if *seen_ms < 15_000);
let lamp = if ready { Lamp::Ready } else { Lamp::NotReady };
let detail = if ready {
"Codex Desktop is connected to this document. Ask a question; edits arrive as proposals."
} else {
"Codex is disconnected. Click Connect Codex to reconnect, or restart the Codex GridCraft launcher."
};
(lamp, detail.to_string(), if ready { vec![] } else { vec![(Step::CheckCodex, false)] })
}
(ContextKind::Cowork, _) => {
let (l, d) = listening("Your Cowork chat");
let asked = copied(Step::AskCowork) || l == Lamp::Ready;
Expand Down Expand Up @@ -211,22 +226,84 @@ pub(crate) fn view(
None => (lamp, lamp.label().to_string(), None, detail),
};
let detail = format!("{detail}\n\nGridCraft's MCP server: {server}");
View { ctx: c.clone(), lamp, label, topic, detail, steps, folder }
View { ctx: c.clone(), lamp, label, topic, detail, steps, folder, route: None }
}

/// Readiness in the Codex edition follows the persistent queue consumer, not a cached ping
/// or an ephemeral model session that has already exited.
fn connection(s: &gridcraft_engine::Session, c: &AgentContext, now: u64) -> Presence {
if c.kind != ContextKind::Codex {
return runner::presence(s);
}
let Some(d) = s.active() else { return Presence::Nobody };
let listener = gridcraft_agent::docs::listeners(d.uid)
.into_iter()
.find(|l| l.label.starts_with("Codex desktop bridge ·") && now.saturating_sub(l.last_seen) < 15_000);
match listener {
Some(l) => Presence::Listening { label: l.label, seen_ms: now.saturating_sub(l.last_seen), more: 0 },
None => Presence::Nobody,
}
}

/// The active document's group.
pub(crate) fn current(st: &AgentUi, app: &SheetApp) -> Option<View> {
let d = app.session.active()?;
let c = cx::effective(&layer::get(d));
let setup = cx::setup();
let presence = runner::presence(&app.session);
let now = gridcraft_agent::now_ms();
let presence = connection(&app.session, &c, now);
let waiting = st.context.waiting.get(&d.uid).is_some_and(|t| now.saturating_sub(*t) < WAIT_MS);
let uid = d.uid;
let copied = |s: Step| st.context.copied.contains(&(uid, s));
let w = working(d, now);
let answered = answered_recently(d, now);
Some(view(&c, &setup, &presence, waiting, &copied, crate::server_url().unwrap_or("http://127.0.0.1:7980/mcp"), w.as_ref(), answered))
let mut v = view(&c, &setup, &presence, waiting, &copied, crate::server_url().unwrap_or("http://127.0.0.1:7980/mcp"), w.as_ref(), answered);
if c.kind == ContextKind::Codex {
v.route = cx::codex_route_for(uid);
if let Some(r) = &v.route {
v.detail = format!("{}\n\nИсходный чат: {}\n{}", r.detail, c.codex_thread_title, v.detail);
if r.status == "disconnected" {
v.lamp = Lamp::NotReady;
v.label = "Codex отключён".into();
} else if v.lamp != Lamp::Working {
match r.status.as_str() {
"attention" => {
v.lamp = Lamp::Waiting;
v.label = "Проверьте доставку".into();
}
"missing" => {
v.lamp = Lamp::Waiting;
v.label = "Исходный чат недоступен".into();
}
"linkMissing" => {
v.lamp = Lamp::Waiting;
v.label = "Чат из ссылки недоступен".into();
}
"linked" => {
v.label = "Чат по ссылке".into();
}
"fallback" => {
v.lamp = Lamp::Ready;
v.label = "Другой чат".into();
}
"queued" => {
v.lamp = Lamp::Waiting;
v.label = "Вопрос доставлен в чат".into();
}
"fresh" => {
v.lamp = Lamp::Ready;
v.label = if c.codex_thread_id.is_empty() { "Новый чат" } else { "Другой чат" }.into();
}
"unbound" => {
v.lamp = Lamp::Ready;
v.label = "Новый чат по вопросу".into();
}
_ => {}
}
}
}
}
Some(v)
}

/// The agent answered a question of this document lately: after its work the lamp says Ready.
Expand Down Expand Up @@ -282,7 +359,7 @@ pub(crate) fn frame(st: &mut AgentUi, app: &mut SheetApp, ctx: &egui::Context) {
let l = layer::get(d);
let c = cx::effective(&l);
let now = gridcraft_agent::now_ms();
let presence = runner::presence(&app.session);
let presence = connection(&app.session, &c, now);
if listener_ready(&presence) {
st.context.waiting.remove(&uid);
}
Expand All @@ -294,7 +371,7 @@ pub(crate) fn frame(st: &mut AgentUi, app: &mut SheetApp, ctx: &egui::Context) {
ctx.request_repaint_after(std::time::Duration::from_secs(2));
}
// Cowork and my session: something listens but hasn't answered for this document yet.
let listens = matches!(c.kind, ContextKind::Cowork) || (c.kind == ContextKind::Code && c.via == CodeVia::Session);
let listens = matches!(c.kind, ContextKind::Cowork | ContextKind::Codex) || (c.kind == ContextKind::Code && c.via == CodeVia::Session);
if listens && !gridcraft_agent::read_only() {
let quiet = runner::classify(None, &runner::signals(uid), runner::mode(), now);
let unsure = matches!(quiet, Presence::Listener(_)) && !listener_ready(&presence) && !matches!(presence, Presence::Checking { .. });
Expand All @@ -319,6 +396,12 @@ pub(crate) fn run(st: &mut AgentUi, app: &mut SheetApp, ctx: &egui::Context, ste
st.context.waiting.insert(uid, now);
};
match step {
Step::CheckCodex => {
wait(st);
if let Err(e) = app.run("agent.codexConnect", json!({})) {
app.message = Some(("Codex".into(), e.to_string()));
}
}
Step::LogIn => match cx::log_in() {
Ok(()) => wait(st),
Err(e) => app.message = Some(("Log in to Claude".into(), e)),
Expand Down Expand Up @@ -521,3 +604,23 @@ mod tests {
assert!(working(s.active().unwrap(), now() + 11_000).is_none());
}
}

#[cfg(test)]
mod codex_tests {
use super::*;

#[test]
fn codex_requires_a_live_document_listener_even_after_an_old_answer() {
let c = AgentContext { kind: ContextKind::Codex, ..AgentContext::default() };
let v = view(&c, &Setup::default(), &Presence::Nobody, true, &|_| false, "url", None, true);
assert_eq!(v.lamp, Lamp::NotReady);
assert_eq!(v.next(), Some(Step::CheckCodex));
assert!(!v.detail.contains("Claude"));
let stale_ping = Presence::Answering { checked_ms: 0, latency_ms: 10 };
assert_eq!(view(&c, &Setup::default(), &stale_ping, false, &|_| false, "url", None, true).lamp, Lamp::NotReady);
let p = Presence::Listening { label: "Codex".into(), seen_ms: 0, more: 0 };
let v = view(&c, &Setup::default(), &p, false, &|_| false, "url", None, false);
assert_eq!(v.lamp, Lamp::Ready);
assert!(v.steps.is_empty());
}
}
35 changes: 32 additions & 3 deletions crates/agent-ui/src/ribbon.rs
Original file line number Diff line number Diff line change
Expand Up @@ -471,7 +471,12 @@ fn context_group(st: &AgentUi, app: &SheetApp, ui: &mut Ui, t: &Tokens, p: &crat
let (name, line, _) = kind.label();
let r = mode_button(ui, kind, lamp, &format!("What the agent knows about this document: {name}. {line}"));
egui::Popup::menu(&r).show(|ui| {
for k in [ContextKind::None, ContextKind::Cowork, ContextKind::Code] {
let choices: &[ContextKind] = if gridcraft_agent::context::codex_enabled() {
&[ContextKind::Codex, ContextKind::Off]
} else {
&[ContextKind::None, ContextKind::Cowork, ContextKind::Code, ContextKind::Codex]
};
for &k in choices {
if mode_row(ui, k, k == kind).clicked() {
if k != kind {
acts.push(Act::Run("agent.agentContext", json!({"kind": k.id()})));
Expand Down Expand Up @@ -509,12 +514,35 @@ fn context_group(st: &AgentUi, app: &SheetApp, ui: &mut Ui, t: &Tokens, p: &crat
{
acts.push(Act::Topic(t.clone()));
}
if kind == ContextKind::Codex {
let route = v.route.as_ref();
let id = route.map(|r| r.thread_id.as_str()).filter(|id| !id.is_empty()).unwrap_or(&v.ctx.codex_thread_id);
let title = route.map(|r| r.title.as_str()).filter(|s| !s.is_empty()).unwrap_or(&v.ctx.codex_thread_title);
let mismatch = route.is_some_and(|r| r.status == "fallback" || r.status == "fresh" && !v.ctx.codex_thread_id.is_empty());
let hint = if mismatch {
format!("Другой чат: {title}")
} else if id.is_empty() {
"Исходный чат не задан".into()
} else {
format!("Чат: {title}")
};
ui.add(
egui::Label::new(egui::RichText::new(&hint).font(theme::ui_font(10.0)).color(if mismatch { p.open } else { t.text_dim })).truncate(),
)
.on_hover_text(format!("{hint}\n{id}\n{}", v.detail));
if !id.is_empty()
&& row_button(ui, Icon::Right, "Открыть чат", "Открыть фактический чат этого документа в Codex Desktop", false).clicked()
{
acts.push(Act::Run("agent.codexOpenChat", json!({})));
}
}
if !v.steps.is_empty() {
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 0.0;
let next = v.next();
let tip = next.map_or(String::new(), |s| format!("Next: {}. {}", s.label(), s.tip()));
if row_button(ui, Icon::Right, "Get ready", &tip, false).clicked()
let action_label = if kind == ContextKind::Codex { "Connect Codex" } else { "Get ready" };
if row_button(ui, Icon::Right, action_label, &tip, false).clicked()
&& let Some(s) = next
{
acts.push(Act::Step(s));
Expand Down Expand Up @@ -563,6 +591,7 @@ fn mode_button(ui: &mut Ui, kind: gridcraft_agent::layer::ContextKind, lamp: Col
ContextKind::None => "No\ncontext",
ContextKind::Cowork => "Claude\nCowork",
ContextKind::Code => "Claude\nCode",
ContextKind::Codex => "Codex",
ContextKind::Off => "Agent\noff",
};
let font = theme::ui_font(11.0);
Expand Down Expand Up @@ -625,7 +654,7 @@ fn mode_sign(p: &egui::Painter, r: Rect, kind: gridcraft_agent::layer::ContextKi
p.circle_filled(pos2(b.center().x + (k as f32 - 1.0) * 4.5, b.center().y), 1.3, color);
}
}
ContextKind::Code => {
ContextKind::Code | ContextKind::Codex => {
let b = r.shrink2(vec2(0.5, 2.5));
p.rect_stroke(b, 3.0, s, egui::StrokeKind::Middle);
let x = b.left() + 4.0;
Expand Down
Loading