Skip to content

Commit 14d38bf

Browse files
committed
Improve queued command UX for agents
1 parent ca269cc commit 14d38bf

6 files changed

Lines changed: 146 additions & 20 deletions

File tree

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,10 +213,16 @@ codexapi agent delete ci-fixer
213213
`codexapi agent resume` can reopen a `done` agent. Sending to a `done` or
214214
`canceled` agent still triggers a one-off wake on the next tick so you can get
215215
a reply without putting the agent back into continuous heartbeat mode.
216+
For local-owned agents, `send`, `wake`, and `resume` now also nudge an
217+
immediate non-blocking wake even without `--wait`; `--wait` only changes
218+
whether the CLI blocks for completion.
216219
`codexapi agent recover` is for a different failure mode: a local wake that is
217220
still marked `running` but has stopped making rollout progress. `agent list`,
218221
`agent show`, and `agent status` now surface stale running wakes, and `recover`
219222
terminates the stuck local wake, marks it recoverable, and queues a fresh one.
223+
`agent list` also surfaces queued operator intent for local commands, so a
224+
paused agent with a queued `resume` shows as `resuming` with separate queued
225+
message and queued command counts.
220226

221227
Create a child agent explicitly:
222228

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "codexapi"
7-
version = "0.12.1"
7+
version = "0.12.2"
88
description = "Minimal Python API for running the Codex CLI."
99
readme = "README.md"
1010
requires-python = ">=3.8"

src/codexapi/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,4 +27,4 @@
2727
"task_result",
2828
"lead",
2929
]
30-
__version__ = "0.12.1"
30+
__version__ = "0.12.2"

src/codexapi/agents.py

Lines changed: 52 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,8 @@ def show_agent(agent_ref, home=None):
230230
snapshot["state"] = _read_json(agent_dir / "state.json")
231231
snapshot["state"]["child_ids"] = snapshot["child_ids"]
232232
snapshot["state"]["unread_message_count"] = snapshot["unread_message_count"]
233+
snapshot["state"]["pending_command_count"] = snapshot["pending_command_count"]
234+
snapshot["state"]["pending_commands"] = snapshot["pending_commands"]
233235
snapshot["state"]["run_lock_held"] = snapshot["run_lock_held"]
234236
snapshot["state"]["last_event_at"] = snapshot["last_event_at"]
235237
snapshot["state"]["stale"] = snapshot["stale"]
@@ -256,7 +258,8 @@ def status_agent(agent_ref, home=None, include_actions=False):
256258
result = {
257259
"id": snapshot["id"],
258260
"name": snapshot["name"],
259-
"agent_status": snapshot["status"],
261+
"agent_status": snapshot["display_status"],
262+
"state_status": snapshot["status"],
260263
"thread_id": session.get("thread_id") or snapshot.get("thread_id") or "",
261264
"rollout_path": str(rollout_path) if rollout_path else "",
262265
"turn_id": "",
@@ -273,6 +276,9 @@ def status_agent(agent_ref, home=None, include_actions=False):
273276
"stale": snapshot["stale"],
274277
"stale_after_seconds": snapshot["stale_after_seconds"],
275278
"stale_for_seconds": snapshot["stale_for_seconds"],
279+
"pending_command_count": snapshot["pending_command_count"],
280+
"pending_commands": snapshot["pending_commands"],
281+
"queued_message_count": snapshot["unread_message_count"],
276282
}
277283
if rollout_path is None or not rollout_path.exists():
278284
return result
@@ -1097,13 +1103,17 @@ def _snapshot(agent_dir, child_map=None):
10971103
state = _read_json(agent_dir / "state.json")
10981104
session = _read_session(agent_dir)
10991105
runtime = _agent_runtime(agent_dir, meta, state, session)
1106+
queued = _queued_commands(agent_dir)
1107+
queued_controls = [item for item in queued if item.get("kind") != "send"]
1108+
queued_kinds = [item.get("kind") or "" for item in queued_controls if item.get("kind")]
11001109
if child_map is None:
11011110
child_ids = _child_map(agent_dir.parents[1]).get(meta["id"], [])
11021111
else:
11031112
child_ids = child_map.get(meta["id"], [])
11041113
unread = int(state.get("unread_message_count") or 0) + len(
1105-
_queued_send_commands(agent_dir)
1114+
[item for item in queued if item.get("kind") == "send"]
11061115
)
1116+
status = state.get("status") or ""
11071117
return {
11081118
"id": meta["id"],
11091119
"name": meta["name"],
@@ -1114,13 +1124,21 @@ def _snapshot(agent_dir, child_map=None):
11141124
"cwd": meta["cwd"],
11151125
"stop_policy": meta["stop_policy"],
11161126
"heartbeat_minutes": meta["heartbeat_minutes"],
1117-
"status": state.get("status") or "",
1127+
"status": status,
1128+
"display_status": _display_status(
1129+
status,
1130+
queued_kinds,
1131+
runtime["run_lock_held"],
1132+
runtime["stale"],
1133+
),
11181134
"thread_id": state.get("thread_id") or "",
11191135
"last_wake_at": state.get("last_wake_at") or "",
11201136
"last_success_at": state.get("last_success_at") or "",
11211137
"next_wake_at": state.get("next_wake_at") or "",
11221138
"wake_requested_at": state.get("wake_requested_at") or "",
11231139
"unread_message_count": unread,
1140+
"pending_command_count": len(queued_controls),
1141+
"pending_commands": queued_kinds,
11241142
"input_tokens": int(state.get("input_tokens") or 0),
11251143
"output_tokens": int(state.get("output_tokens") or 0),
11261144
"total_tokens": int(state.get("total_tokens") or 0),
@@ -1554,7 +1572,29 @@ def _usage_int(value):
15541572
return None
15551573

15561574

1557-
def _queued_send_commands(agent_dir):
1575+
def _display_status(status, pending_commands, run_lock_held, stale):
1576+
"""Return a user-facing status that includes queued control intent."""
1577+
state = str(status or "")
1578+
commands = [str(kind or "") for kind in pending_commands or [] if kind]
1579+
if stale:
1580+
return "stale"
1581+
if commands:
1582+
last = commands[-1]
1583+
if last == "resume" and state in ("paused", "done"):
1584+
return "resuming"
1585+
if last == "pause" and state in ("ready", "error", "running"):
1586+
return "pausing"
1587+
if last == "cancel" and state != "canceled":
1588+
return "canceling"
1589+
if last == "wake" and state in ("ready", "error"):
1590+
return "waking"
1591+
if run_lock_held and state == "running":
1592+
return "running"
1593+
return state or ""
1594+
1595+
1596+
def _queued_commands(agent_dir, kind=None):
1597+
"""Return queued command payloads from commands/new."""
15581598
queued = []
15591599
new_dir = agent_dir / "commands" / "new"
15601600
if not new_dir.exists():
@@ -1566,11 +1606,17 @@ def _queued_send_commands(agent_dir):
15661606
payload = _read_json(path)
15671607
except (FileNotFoundError, json.JSONDecodeError):
15681608
continue
1569-
if payload.get("kind") == "send":
1570-
queued.append(payload)
1609+
payload_kind = payload.get("kind") or ""
1610+
if kind and payload_kind != kind:
1611+
continue
1612+
queued.append(payload)
15711613
return queued
15721614

15731615

1616+
def _queued_send_commands(agent_dir):
1617+
return _queued_commands(agent_dir, "send")
1618+
1619+
15741620
def _has_new_commands(agent_dir):
15751621
new_dir = agent_dir / "commands" / "new"
15761622
if not new_dir.exists():

src/codexapi/cli.py

Lines changed: 24 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -158,20 +158,21 @@ def _print_managed_agent_list(items):
158158
if not items:
159159
print("No agents.")
160160
return
161-
print("ID STAT POL HOST UNR TOKENS TOK/H NEXT REPO NAME")
161+
print("ID STAT POL HOST QMSG QCMD TOKENS TOK/H NEXT REPO NAME")
162162
for item in items:
163163
ident = item["id"][:8]
164-
status = _truncate_head(item["status"] or "-", 8)
164+
status = _truncate_head(item.get("display_status") or item.get("status") or "-", 9)
165165
policy = _truncate_head(_policy_label(item.get("stop_policy")), 4)
166166
host = _truncate_head(item["hostname"] or "-", 12)
167-
unread = str(item["unread_message_count"])
167+
queued_messages = str(item["unread_message_count"])
168+
queued_commands = str(int(item.get("pending_command_count") or 0))
168169
tokens = _format_token_total(item["total_tokens"])
169170
tok_h = _format_token_rate(item.get("avg_tokens_per_hour"))
170171
next_wake = _truncate_head(_next_wake_label(item), 6)
171172
repo = _truncate_head(_repo_label(item.get("cwd")), 12)
172173
name = item["name"]
173174
print(
174-
f"{ident:<8} {status:<8} {policy:<4} {host:<12} {unread:>3} {tokens:>6} {tok_h:>7} {next_wake:>6} {repo:<12} {name}"
175+
f"{ident:<8} {status:<9} {policy:<4} {host:<12} {queued_messages:>4} {queued_commands:>4} {tokens:>6} {tok_h:>7} {next_wake:>6} {repo:<12} {name}"
175176
)
176177

177178

@@ -196,6 +197,7 @@ def _print_managed_agent_read(result):
196197
def _print_managed_agent_status(result, include_actions=False):
197198
print(f"{result['name']} [{result['agent_status'] or '-'}]")
198199
print(f"ID: {result['id']}")
200+
print(f"State: {result.get('state_status') or '-'}")
199201
print(f"Thread: {result.get('thread_id') or '-'}")
200202
print(f"Turn: {result.get('turn_id') or '-'} [{result.get('turn_state') or '-'}]")
201203
print(f"Started: {result.get('started_at') or '-'}")
@@ -204,6 +206,8 @@ def _print_managed_agent_status(result, include_actions=False):
204206
print(f"Rollout: {result.get('rollout_path') or '-'}")
205207
print(f"Last event: {result.get('last_event_at') or '-'}")
206208
print(f"Stale: {_stale_text(result)}")
209+
print(f"Queued messages: {result.get('queued_message_count') or 0}")
210+
print(f"Pending commands: {_pending_commands_text(result.get('pending_commands'))}")
207211
progress = result.get("progress") or []
208212
print("Progress:")
209213
if not progress:
@@ -310,20 +314,22 @@ def _send_reply_info(agent_ref, message_id):
310314
def _print_managed_agent_show(result):
311315
meta = result["meta"]
312316
state = result["state"]
313-
print(f"{meta['name']} [{state.get('status') or '-'}]")
317+
print(f"{meta['name']} [{result.get('display_status') or state.get('status') or '-'}]")
314318
print(f"ID: {meta['id']}")
315319
print(f"Host: {meta['hostname']}")
316320
print(f"Created: {meta['created_at']} by {meta['created_by']}")
317321
print(f"Parent: {_related_label(result.get('parent'))}")
318322
print(f"Children: {_children_label(result.get('children'))}")
319323
print(
320-
f"Policy: {meta['stop_policy']} Heartbeat: {meta['heartbeat_minutes']}m Unread: {result['unread_message_count']}"
324+
f"Policy: {meta['stop_policy']} Heartbeat: {meta['heartbeat_minutes']}m Qmsg: {result['unread_message_count']} Qcmd: {result.get('pending_command_count') or 0}"
321325
)
322326
print(f"CWD: {meta['cwd']}")
323327
print(f"Agentbook: {result.get('agentbook_path') or '-'}")
328+
print(f"State: {state.get('status') or '-'}")
324329
print(f"Thread: {state.get('thread_id') or '-'}")
325330
print(f"Last event: {result.get('last_event_at') or '-'}")
326331
print(f"Stale: {_stale_text(result)}")
332+
print(f"Pending commands: {_pending_commands_text(result.get('pending_commands'))}")
327333
print(
328334
"Tokens: "
329335
f"{_format_token_total(state.get('total_tokens'))} total "
@@ -416,12 +422,15 @@ def _policy_label(stop_policy):
416422

417423
def _next_wake_label(item):
418424
status = item.get("status") or ""
425+
display_status = item.get("display_status") or status
419426
if status == "running":
420427
if item.get("stale"):
421428
return "stale"
422429
if item.get("run_lock_held"):
423430
return "run"
424431
return "lost"
432+
if display_status in ("resuming", "waking", "pausing", "canceling"):
433+
return "wake"
425434
if status in ("done", "canceled"):
426435
return "-"
427436
if status == "paused":
@@ -472,6 +481,13 @@ def _stale_text(item):
472481
return f"no ({idle} idle; threshold {threshold})"
473482

474483

484+
def _pending_commands_text(value):
485+
commands = [str(item or "") for item in value or [] if str(item or "").strip()]
486+
if not commands:
487+
return "-"
488+
return ", ".join(commands)
489+
490+
475491
def _format_managed_agent_run(run):
476492
started = run.get("started_at") or "-"
477493
reason = run.get("wake_reason") or "-"
@@ -2047,8 +2063,8 @@ def main(argv=None):
20472063
if args.agent_command == "send":
20482064
result = send_agent(args.agent_ref, args.message, args.author)
20492065
result["waited"] = bool(args.wait)
2066+
result["nudge"] = nudge_agent(args.agent_ref, wait=bool(args.wait))
20502067
if args.wait:
2051-
result["nudge"] = nudge_agent(args.agent_ref, wait=True)
20522068
reply_info = _send_reply_info(args.agent_ref, result["id"])
20532069
if reply_info:
20542070
result.update(reply_info)
@@ -2061,8 +2077,7 @@ def main(argv=None):
20612077
args.author,
20622078
)
20632079
result["waited"] = bool(args.wait)
2064-
if args.wait:
2065-
result["nudge"] = nudge_agent(args.agent_ref, wait=True)
2080+
result["nudge"] = nudge_agent(args.agent_ref, wait=bool(args.wait))
20662081
print(json.dumps(result, indent=2, sort_keys=True))
20672082
return
20682083
if args.agent_command in ("pause", "cancel"):

tests/test_agents.py

Lines changed: 62 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1051,6 +1051,8 @@ def fake_runner(meta, session, prompt):
10511051
with redirect_stdout(list_out):
10521052
_print_managed_agent_list([shown])
10531053
self.assertIn("POL", list_out.getvalue())
1054+
self.assertIn("QMSG", list_out.getvalue())
1055+
self.assertIn("QCMD", list_out.getvalue())
10541056
self.assertIn("REPO", list_out.getvalue())
10551057
self.assertIn("done", list_out.getvalue())
10561058
self.assertIn("codexapi", list_out.getvalue())
@@ -1060,6 +1062,7 @@ def fake_runner(meta, session, prompt):
10601062
_print_managed_agent_show(shown)
10611063
text = show_out.getvalue()
10621064
self.assertIn("Policy: until_done", text)
1065+
self.assertIn("Qmsg: 0 Qcmd: 0", text)
10631066
self.assertIn("Tokens: 50 total (30 in, 20 out, 50.0/h)", text)
10641067
self.assertIn("Prompt: Handle messages.", text)
10651068
self.assertIn("Recent runs:", text)
@@ -1087,15 +1090,71 @@ def test_cli_send_queues_by_default(self):
10871090
hostname="host-a",
10881091
)
10891092
output = io.StringIO()
1090-
with redirect_stdout(output):
1091-
cli_main(["agent", "send", agent["id"], "status"])
1093+
with patch(
1094+
"codexapi.cli.nudge_agent",
1095+
return_value={"ran": True, "woken": 1, "spawned": True},
1096+
) as nudge_mock:
1097+
with redirect_stdout(output):
1098+
cli_main(["agent", "send", agent["id"], "status"])
10921099
payload = json.loads(output.getvalue())
10931100
self.assertFalse(payload["waited"])
1094-
self.assertNotIn("nudge", payload)
1101+
self.assertTrue(payload["nudge"]["spawned"])
1102+
nudge_mock.assert_called_once_with(agent["id"], wait=False)
10951103
shown = show_agent(agent["id"])
10961104
self.assertEqual(shown["unread_message_count"], 1)
10971105
self.assertEqual(shown["state"]["status"], "ready")
10981106

1107+
def test_cli_resume_without_wait_async_nudges_and_list_shows_resuming(self):
1108+
def fake_runner(meta, session, prompt):
1109+
return {
1110+
"message": json.dumps(
1111+
{
1112+
"status": "Still running",
1113+
"continue": True,
1114+
"reply": "Continuing.",
1115+
}
1116+
),
1117+
"thread_id": "thread-resume-ui",
1118+
}
1119+
1120+
with _temp_home():
1121+
agent = start_agent("Keep an eye on this.", hostname="host-a")
1122+
control_agent(agent["id"], "pause", hostname="host-a")
1123+
tick(hostname="host-a", runner=fake_runner)
1124+
1125+
output = io.StringIO()
1126+
with patch(
1127+
"codexapi.cli.nudge_agent",
1128+
return_value={"ran": True, "woken": 1, "spawned": True},
1129+
) as nudge_mock:
1130+
with redirect_stdout(output):
1131+
cli_main(["agent", "resume", agent["id"]])
1132+
payload = json.loads(output.getvalue())
1133+
self.assertFalse(payload["waited"])
1134+
self.assertTrue(payload["nudge"]["spawned"])
1135+
nudge_mock.assert_called_once_with(agent["id"], wait=False)
1136+
1137+
shown = show_agent(agent["id"])
1138+
self.assertEqual(shown["state"]["status"], "paused")
1139+
self.assertEqual(shown["display_status"], "resuming")
1140+
self.assertEqual(shown["pending_commands"], ["resume"])
1141+
self.assertEqual(shown["pending_command_count"], 1)
1142+
1143+
list_out = io.StringIO()
1144+
with redirect_stdout(list_out):
1145+
_print_managed_agent_list([shown])
1146+
self.assertIn("resuming", list_out.getvalue())
1147+
self.assertIn("QMSG", list_out.getvalue())
1148+
self.assertIn("QCMD", list_out.getvalue())
1149+
1150+
show_out = io.StringIO()
1151+
with redirect_stdout(show_out):
1152+
_print_managed_agent_show(shown)
1153+
text = show_out.getvalue()
1154+
self.assertIn("[resuming]", text)
1155+
self.assertIn("State: paused", text)
1156+
self.assertIn("Pending commands: resume", text)
1157+
10991158
def test_cli_send_wait_shows_immediate_agent_reply(self):
11001159
with _temp_home():
11011160
with patch.dict(os.environ, {"CODEXAPI_HOSTNAME": "host-a"}, clear=False):

0 commit comments

Comments
 (0)