@agentsdock/server 1.0.10-beta.1 → 1.0.10-beta.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -138,13 +138,19 @@ class ClaudeGoalProjection:
138
138
  def consume(self, event: Any) -> bool:
139
139
  """Fold one native JSONL record; return whether visible goal changed."""
140
140
  if not isinstance(event, dict) or (
141
- event.get("type") != "attachment"
141
+ event.get("type") not in {"attachment", "system"}
142
142
  or event.get("sessionId") != self.provider_session_id
143
143
  or event.get("isSidechain") is not False
144
144
  ):
145
145
  return False
146
146
  attachment = event.get("attachment")
147
- if not isinstance(attachment, dict) or attachment.get("type") != "goal_status":
147
+ content = event.get("content")
148
+ stopped = (event.get("type") == "system" and event.get("subtype") == "informational"
149
+ and event.get("level") == "warning" and isinstance(content, str)
150
+ and re.match(r"\AA hook blocked the turn from ending [0-9]+ consecutive times [—-] overriding and ending turn\.", content))
151
+ if stopped:
152
+ attachment = {"type": "goal_status", "condition": (self.goal or {}).get("condition"), "met": False}
153
+ elif not isinstance(attachment, dict) or attachment.get("type") != "goal_status":
148
154
  return False
149
155
  event_id = event.get("uuid")
150
156
  if not isinstance(event_id, str) or len(event_id) != 36:
@@ -175,10 +181,16 @@ class ClaudeGoalProjection:
175
181
  return False
176
182
  goal = dict(previous)
177
183
  goal["status"] = "cleared" if sentinel else "achieved" if met else "active"
184
+ if stopped:
185
+ goal["execution_stop_reason"] = "continuation_limit"
186
+ goal["execution_stopped_at"] = timestamp
187
+ else:
188
+ goal.pop("execution_stop_reason", None)
189
+ goal.pop("execution_stopped_at", None)
178
190
  if sentinel:
179
191
  for field in ("iterations", "duration_ms", "tokens"):
180
192
  goal.pop(field, None)
181
- if not sentinel:
193
+ if not sentinel and not stopped:
182
194
  reason = attachment.get("reason")
183
195
  if isinstance(reason, str) and reason:
184
196
  goal["last_reason"] = reason[:8192]
@@ -233,7 +245,7 @@ class ClaudeGoalProjection:
233
245
  if self._discard_line:
234
246
  self._discard_line = False
235
247
  continue
236
- if len(line) > MAX_GOAL_RECORD_BYTES or b'"goal_status"' not in line:
248
+ if len(line) > MAX_GOAL_RECORD_BYTES or not (b'"goal_status"' in line or b"A hook blocked the turn from ending" in line):
237
249
  continue
238
250
  try:
239
251
  self.consume(json.loads(line))
@@ -1088,6 +1088,8 @@ class CodexAppServerClient:
1088
1088
  self._next_id += 1
1089
1089
  request_id = self._next_id
1090
1090
  future: asyncio.Future[Any] = loop.create_future()
1091
+ # Preserve request ownership for per-chat process handoff.
1092
+ future._codex_thread_id = str((params or {}).get("threadId") or (params or {}).get("ancestorThreadId") or "")
1091
1093
  self._pending[request_id] = (
1092
1094
  method,
1093
1095
  future,
@@ -1557,6 +1559,7 @@ class CodexAppServerClient:
1557
1559
  previous,
1558
1560
  )
1559
1561
  )
1562
+ task._codex_thread_id = thread_id
1560
1563
  self._callback_tails[owner] = task
1561
1564
  self._callback_tasks.add(task)
1562
1565
  task.add_done_callback(
@@ -1619,6 +1622,7 @@ class CodexAppServerClient:
1619
1622
  self._handle_server_request(request_id, method, params),
1620
1623
  name=f"codex-app-server-request-{request_id}",
1621
1624
  )
1625
+ task._codex_thread_id = str(params.get("threadId") or "")
1622
1626
  self._server_request_tasks[request_id] = task
1623
1627
  task.add_done_callback(
1624
1628
  lambda done, rid=request_id: self._finish_server_request(rid, done)
@@ -28,6 +28,37 @@ HANDOFF_MESSAGE = (
28
28
  )
29
29
  _REVISION_KEY = secrets.token_bytes(32)
30
30
  _MAX_AUTH_BYTES = 128 * 1024
31
+ HANDOFF_PROBE_FAILURES = frozenset({"thread_list_unavailable", "goal_state_unavailable",
32
+ "terminal_state_unavailable", "ownership_deadline", "native_unsubscribe_incomplete", "writer_release_failed"})
33
+
34
+
35
+ def handoff_detail(reason: str) -> dict:
36
+ """Content-free admission status, distinct from a native auth rejection."""
37
+ reasons = {
38
+ "active_turn": "this chat still has a running turn",
39
+ "maintenance": "this chat is finishing a provider operation",
40
+ "approval": "this chat has an unanswered approval or question",
41
+ "side_chat": "this chat has a running Side chat",
42
+ "active_goal": "this chat has an active goal",
43
+ "native_goal_active": "Codex reports an active goal for this chat",
44
+ "subagents": "this chat has running subagents",
45
+ "callback": "this chat is finishing a provider notification",
46
+ "runtime_request": "a provider request for this chat is still finishing",
47
+ "native_thread_in_use": "Codex still has work using this thread",
48
+ "background_terminals": "this chat has a running background terminal",
49
+ "thread_list_unavailable": "Codex could not report which threads it owns",
50
+ "goal_state_unavailable": "Codex could not report this chat's goal state",
51
+ "terminal_state_unavailable": "Codex could not report this chat's background terminals",
52
+ "ownership_deadline": "Codex took too long to report this chat's state",
53
+ "native_unsubscribe_incomplete": "Codex has not released this thread yet",
54
+ "writer_release_failed": "Codex could not release the old connection to this thread",
55
+ }
56
+ reason = reason if reason in reasons else "runtime_request"
57
+ action = ("Retry the message to check again." if reason in HANDOFF_PROBE_FAILURES
58
+ else "Retry after that operation finishes.")
59
+ return {"code": "codex_login_handoff", "reason": reason, "retryable": True,
60
+ "message": f"Codex sign-in handoff is waiting: {reasons[reason]}. "
61
+ f"Your message has not been sent. {action}"}
31
62
 
32
63
 
33
64
  @dataclass(frozen=True)
@@ -202,10 +202,26 @@ def _native_records(path: Path, expected: tuple[int, int, int, int], budget: _Na
202
202
 
203
203
  def _native_event_identity(event: dict) -> dict:
204
204
  """Keep bounded public identity, never retain tool bodies or message text."""
205
- return {key: event[key] for key in ("id", "seq", "type", "phase", "item_id")
205
+ return {key: event[key] for key in ("id", "seq", "type", "phase", "item_id", "provider_thread_id", "provider_turn_id")
206
206
  if key in event and (type(event[key]) is int or isinstance(event[key], str) and len(event[key]) <= 256)}
207
207
 
208
208
 
209
+ def _native_goal_steer_turn(event: dict) -> str | None:
210
+ """Accepted goal follow-ups already have a user bubble in this chat.
211
+
212
+ Keep their exact native turn: one goal run can span many provider turns,
213
+ including later genuine messages with identical text.
214
+ """
215
+ turn = event.get("provider_turn_id")
216
+ if (event.get("type") == "turn_steered" and event.get("backend") == "codex"
217
+ and event.get("purpose") == "codex_goal_resume"
218
+ and event.get("native_goal_steer") is True and event.get("native_steer") is True
219
+ and event.get("provider_user_authored") is True
220
+ and isinstance(turn, str) and 0 < len(turn) <= 256):
221
+ return turn
222
+ return None
223
+
224
+
209
225
  def _native_import_batch(event: dict) -> dict:
210
226
  result = {key: event.get(key) for key in ("seq", "provider_session_id", "source_path")}
211
227
  checkpoint = event.get("_history_sync_checkpoint")
@@ -242,10 +258,13 @@ def _native_owned_turn_ids(event: dict) -> set[str]:
242
258
  if isinstance(value := event.get(field), str) and 0 < len(value) <= 256}
243
259
 
244
260
 
245
- def _native_assistant_item_owners(assistant_items: dict, completed_runs: set[str]) -> dict:
261
+ def _native_assistant_item_owners(assistant_items: dict, completed_runs: set[str], provider_id: str) -> dict:
246
262
  owners: dict = {}
247
- for run, item_id, body_key in assistant_items:
248
- if run in completed_runs:
263
+ for (run, item_id, body_key), event in assistant_items.items():
264
+ # A restart or Stop can omit the completion receipt. An exact public
265
+ # item already delivered by this provider thread still identifies its
266
+ # native run; source reconciliation below supplies the matching turn.
267
+ if run in completed_runs or event.get("provider_thread_id") == provider_id:
249
268
  owners.setdefault((item_id, body_key), set()).add(run)
250
269
  return owners
251
270
 
@@ -856,7 +875,7 @@ def _prove_native_replays(session_id: str, provider_id: str, events: Path, sourc
856
875
  owned = owners.setdefault((thread, turn), set())
857
876
  retained += run not in owned
858
877
  owned.add(run)
859
- kind = "user" if event.get("type") == "turn_started" else "assistant" if event.get("type") in ("assistant_text", "reasoning_summary", "turn_finished") else None
878
+ kind = "user" if event.get("type") == "turn_started" or _native_goal_steer_turn(event) else "assistant" if event.get("type") in ("assistant_text", "reasoning_summary", "turn_finished") else None
860
879
  body = event.get("prompt") if kind == "user" else event.get("result_text") if event.get("type") == "turn_finished" else event.get("text")
861
880
  if kind and isinstance(body, str) and body and len(body) <= 4 * 1024 * 1024:
862
881
  native.setdefault((run, kind, _text_key(body)), []).append(_native_event_identity(event))
@@ -935,14 +954,14 @@ def _prove_native_source(thread: str, source: Path, root: Path, batches: dict, c
935
954
  assistant_native_keys = {}
936
955
  compactions, candidate_assistants = {}, set()
937
956
  compaction_tracker = CodexCompactionSummaryTracker(thread)
938
- delivery_bodies, delivery_source_ids = {}, {}
957
+ delivery_bodies, delivery_source_ids, full_user_keys = {}, {}, {}
939
958
  completed_runs = {run for (owner_thread, _turn), runs in owners.items()
940
959
  if owner_thread == thread for run in runs}
941
960
  assistant_item_owners = _native_assistant_item_owners({
942
961
  (run, item_id, body_key): event
943
962
  for (run, kind, body_key), events in native.items() if kind == "assistant"
944
963
  for event in events if (item_id := _public_assistant_item_id(event)) is not None
945
- }, completed_runs)
964
+ }, completed_runs, thread)
946
965
  assistant_owner_evidence: dict = {}
947
966
  assistant_source_turns: dict = {}
948
967
  allowed_header_owners, header_parents = {thread}, {}
@@ -1016,6 +1035,21 @@ def _prove_native_source(thread: str, source: Path, root: Path, batches: dict, c
1016
1035
  raise _Unproven()
1017
1036
  if (item["kind"], key[2], key[3]) not in candidate_keys:
1018
1037
  continue
1038
+ if item["kind"] == "user" and key[3] is not None:
1039
+ # A display-trimmed input still has its complete source in this
1040
+ # checkpoint-verified record. Compare those bytes to the original
1041
+ # native input, never a shared preview or an unverified hash.
1042
+ full_text = None
1043
+ if record.get("type") == "event_msg" and payload.get("type") == "user_message":
1044
+ full_text = payload.get("message")
1045
+ elif record.get("type") == "response_item" and payload.get("role") == "user":
1046
+ blocks = payload.get("content")
1047
+ if (isinstance(blocks, list) and len(blocks) == 1 and isinstance(blocks[0], dict)
1048
+ and blocks[0].get("type") == "input_text"):
1049
+ full_text = blocks[0].get("text")
1050
+ if (isinstance(full_text, str) and full_text
1051
+ and _text_key(" ".join(full_text.split())) == key[3]):
1052
+ full_user_keys[key] = _text_key(full_text)
1019
1053
  if item["kind"] == "user" and (body := _async_delivery_body(item["text"])):
1020
1054
  delivery_bodies[key] = body
1021
1055
  if item["kind"] == "assistant":
@@ -1073,9 +1107,7 @@ def _prove_native_source(thread: str, source: Path, root: Path, batches: dict, c
1073
1107
  if len(proofs) > MAX_TARGETS:
1074
1108
  raise _Unproven()
1075
1109
  continue
1076
- if len(source_ids) != 1 or source_hash is not None:
1077
- # Truncated source hashes need a separately retained native full-body
1078
- # hash. Until one exists, the bounded preview cannot prove equality.
1110
+ if len(source_ids) != 1 or source_hash is not None and key not in full_user_keys:
1079
1111
  continue
1080
1112
  source_origin = next(iter(source_ids.values()))
1081
1113
  if source_origin.get("kind") in ("subagent_notification", "turn_aborted", "provider_notice"):
@@ -1087,8 +1119,10 @@ def _prove_native_source(thread: str, source: Path, root: Path, batches: dict, c
1087
1119
  if len(owned_runs) != 1:
1088
1120
  continue
1089
1121
  native_run = next(iter(owned_runs))
1090
- native_matches = native.get((native_run, kind, body_key), [])
1091
- if not native_matches and kind == "user":
1122
+ native_body_key = full_user_keys.get(key, body_key)
1123
+ native_matches = [event for event in native.get((native_run, kind, native_body_key), [])
1124
+ if event.get("type") != "turn_steered" or event.get("provider_turn_id") == key[0]]
1125
+ if not native_matches and kind == "user" and source_hash is None:
1092
1126
  delivery = deliveries.match(session_id, thread, native_run, source_origin,
1093
1127
  delivery_bodies.get(key), delivery_source_ids.get(key[0], set()), body_key)
1094
1128
  if delivery is not None:
@@ -1105,7 +1139,7 @@ def _prove_native_source(thread: str, source: Path, root: Path, batches: dict, c
1105
1139
  if not native_matches:
1106
1140
  continue
1107
1141
  representative = min(native_matches, key=lambda event: event["seq"])
1108
- proofs[target] = {**next(iter(source_ids.values())), "native_event_id": representative["id"], "source_text_sha256": body_key}
1142
+ proofs[target] = {**next(iter(source_ids.values())), "native_event_id": representative["id"], "source_text_sha256": native_body_key}
1109
1143
  if len(proofs) > MAX_TARGETS:
1110
1144
  raise _Unproven()
1111
1145
  budget.check()
@@ -1183,10 +1217,10 @@ class CodexNativeHistoryRepairCache(CodexGoalHistoryRepairCache):
1183
1217
  **({"_agentsdock_imported_prompt_hidden": True} if event["type"] == "turn_started" else {})}
1184
1218
 
1185
1219
 
1186
- def _prove_pending_async_deliveries(session_id: str, provider_id: str, items: list[dict],
1220
+ def _prove_pending_native_inputs(session_id: str, provider_id: str, items: list[dict],
1187
1221
  source: Path, root: Path, checkpoint: dict, parse_item: Callable,
1188
1222
  previous_seq: int, owners: dict, deliveries: _AsyncDeliveryIndex,
1189
- budget: _NativeReadBudget) -> dict:
1223
+ budget: _NativeReadBudget, native_inputs: dict | None = None) -> dict:
1190
1224
  """Reuse pinned-prefix proof before broadcast; the synthetic batch is never stored."""
1191
1225
  cursor = checkpoint.get("cursor")
1192
1226
  if (checkpoint.get("version") != 1 or not isinstance(cursor, dict) or cursor.get("version") != 1
@@ -1195,16 +1229,21 @@ def _prove_pending_async_deliveries(session_id: str, provider_id: str, items: li
1195
1229
  raise _Unproven()
1196
1230
  run = "import_pending_native_delivery_proof"
1197
1231
  candidates, indexes = [], {}
1232
+ steer_keys = {key[2] for key, events in (native_inputs or {}).items()
1233
+ if key[1] == "user" and any(event.get("type") == "turn_steered" for event in events)}
1198
1234
  for index, item in enumerate(items):
1199
1235
  budget.check()
1200
1236
  origin = item.get("provider_origin")
1201
1237
  if (item.get("kind") != "user" or not isinstance(item.get("text"), str)
1202
1238
  or not isinstance(origin, dict) or origin.get("provider") != "codex" or origin.get("kind") != "user"
1203
- or origin.get("session_id", provider_id) != provider_id or item.get("source_text_sha256") is not None
1204
- or not (_async_delivery_body(item["text"]) or _text_key(item["text"]) in deliveries.status_body_keys)):
1239
+ or origin.get("session_id", provider_id) != provider_id
1240
+ or not (item.get("source_text_sha256") is not None
1241
+ or _async_delivery_body(item["text"]) or _text_key(item["text"]) in deliveries.status_body_keys
1242
+ or _text_key(item["text"]) in steer_keys)):
1205
1243
  continue
1206
1244
  candidate = _replay_target({"seq": previous_seq + index + 2, "id": f"pending:{index}", "run_id": run,
1207
1245
  "type": "turn_started", "backend": "codex", "imported": True, "prompt": item["text"],
1246
+ "source_text_sha256": item.get("source_text_sha256"),
1208
1247
  "ts": origin.get("timestamp"), "provider_user_authored": _runtime_human_provenance(item)})
1209
1248
  if candidate is not None:
1210
1249
  candidates.append(candidate)
@@ -1214,7 +1253,7 @@ def _prove_pending_async_deliveries(session_id: str, provider_id: str, items: li
1214
1253
  if not candidates:
1215
1254
  return {}
1216
1255
  proof = _prove_native_source(provider_id, source, root,
1217
- {run: (previous_seq + 1, previous_seq + len(items) + 2, checkpoint)}, candidates, {},
1256
+ {run: (previous_seq + 1, previous_seq + len(items) + 2, checkpoint)}, candidates, native_inputs or {},
1218
1257
  {(provider_id, turn): runs for turn, runs in owners.items()}, parse_item, budget,
1219
1258
  session_id=session_id, deliveries=deliveries, require_verified_checkpoint=True)
1220
1259
  result = {}
@@ -1234,10 +1273,10 @@ def filter_native_codex_history_items(session_id: str, provider_id: str, events:
1234
1273
  parse_item: Callable[[dict], dict | None] | None = None) -> list[dict]:
1235
1274
  """An existing verified import boundary can omit exact completed native copies.
1236
1275
 
1237
- Exact native bodies need only the ledger. Legacy async wrappers additionally
1238
- require the caller's source/checkpoint/parser and one verified source-prefix
1239
- scan: a delta range alone cannot disambiguate a later same-turn human quote.
1240
- Missing source context leaves those wrappers visible for later read repair.
1276
+ Exact native bodies need only the ledger. Legacy async wrappers and trimmed
1277
+ user inputs additionally require the caller's source/checkpoint/parser and
1278
+ one verified source-prefix scan. A preview alone never proves equality.
1279
+ Missing source context leaves those inputs visible for later read repair.
1241
1280
  Incomplete proof raises; the caller must defer import and its cursor commit.
1242
1281
  """
1243
1282
  if not items:
@@ -1248,6 +1287,7 @@ def filter_native_codex_history_items(session_id: str, provider_id: str, events:
1248
1287
  budget = _NativeReadBudget(cancelled, deadline)
1249
1288
  stamp = _native_stamp(events)
1250
1289
  owners, native, assistant_items, wake_keys, native_count = {}, {}, {}, set(), 0
1290
+ native_inputs, steered_inputs = {}, {}
1251
1291
  deliveries = _AsyncDeliveryIndex()
1252
1292
  prove_deliveries = (source_path is not None and root is not None
1253
1293
  and isinstance(sync_checkpoint, dict) and callable(parse_item)
@@ -1273,24 +1313,31 @@ def filter_native_codex_history_items(session_id: str, provider_id: str, events:
1273
1313
  owned = owners.setdefault(turn_id, set())
1274
1314
  owner_count += run not in owned
1275
1315
  owned.add(run)
1276
- kind = "user" if event.get("type") == "turn_started" else "assistant" if event.get("type") in ("assistant_text", "reasoning_summary", "turn_finished") else None
1316
+ steer_turn = _native_goal_steer_turn(event)
1317
+ kind = "user" if event.get("type") == "turn_started" or steer_turn else "assistant" if event.get("type") in ("assistant_text", "reasoning_summary", "turn_finished") else None
1277
1318
  body = event.get("prompt") if kind == "user" else event.get("result_text") if event.get("type") == "turn_finished" else event.get("text")
1278
1319
  wake_hash = _native_mailbox_wake_hash(event) if kind == "user" else None
1279
1320
  if kind and (isinstance(body, str) and body or wake_hash):
1280
- keys = native.setdefault(run, {})
1321
+ keys = steered_inputs.setdefault((run, steer_turn), {}) if steer_turn else native.setdefault(run, {})
1281
1322
  key = (kind, wake_hash or _text_key(body))
1282
1323
  native_count += key not in keys
1283
1324
  if isinstance(event.get("id"), str) and 0 < len(event["id"]) <= 256:
1284
1325
  keys.setdefault(key, event["id"])
1326
+ if prove_deliveries and kind == "user" and isinstance(body, str) and body:
1327
+ inputs = native_inputs.setdefault((run, kind, key[1]), [])
1328
+ if not any(prior.get("type") == event.get("type")
1329
+ and prior.get("provider_turn_id") == event.get("provider_turn_id") for prior in inputs):
1330
+ inputs.append(_native_event_identity(event))
1331
+ native_count += 1
1285
1332
  if wake_hash:
1286
1333
  wake_keys.add((run, wake_hash))
1287
1334
  item_id = _public_assistant_item_id(event)
1288
1335
  if item_id is not None:
1289
- assistant_items[(run, item_id, key[1])] = event["id"]
1336
+ assistant_items[(run, item_id, key[1])] = _native_event_identity(event)
1290
1337
  if native_count + owner_count + len(assistant_items) + deliveries.count > MAX_KEYS:
1291
1338
  raise _Unproven()
1292
1339
  assistant_item_owners = _native_assistant_item_owners(
1293
- assistant_items, {run for runs in owners.values() for run in runs},
1340
+ assistant_items, {run for runs in owners.values() for run in runs}, provider_id,
1294
1341
  )
1295
1342
  assistant_source_turns: dict = {}
1296
1343
  for item in items:
@@ -1308,10 +1355,17 @@ def filter_native_codex_history_items(session_id: str, provider_id: str, events:
1308
1355
  for item_key, turns in assistant_source_turns.items():
1309
1356
  if len(turns) == 1:
1310
1357
  owners.setdefault(next(iter(turns)), set()).update(assistant_item_owners[item_key])
1311
- delivery_proofs = _prove_pending_async_deliveries(
1358
+ if prove_deliveries and steered_inputs:
1359
+ # The unmatched import can contain only follow-ups: its assistant
1360
+ # messages were already reconciled. Preserve public item identities
1361
+ # for the source scan to recover earlier turns of a long goal run.
1362
+ for (run, _item_id, body_key), event in assistant_items.items():
1363
+ native_inputs.setdefault((run, "assistant", body_key), []).append(event)
1364
+ delivery_proofs = _prove_pending_native_inputs(
1312
1365
  session_id, provider_id, items, source_path, root, sync_checkpoint, parse_item,
1313
- previous_seq, owners, deliveries, budget,
1314
- ) if prove_deliveries and deliveries.starts else {}
1366
+ previous_seq, owners, deliveries, budget, native_inputs,
1367
+ ) if prove_deliveries and (deliveries.starts or steered_inputs or any(
1368
+ item.get("kind") == "user" and item.get("source_text_sha256") is not None for item in items)) else {}
1315
1369
  # A hidden wake has no public prompt body. Require a single exact source
1316
1370
  # item in this verified import range, in addition to native turn ownership.
1317
1371
  wake_source_ids = {}
@@ -1332,9 +1386,15 @@ def filter_native_codex_history_items(session_id: str, provider_id: str, events:
1332
1386
  and origin.get("session_id", provider_id) == provider_id and len(runs) == 1
1333
1387
  and item.get("source_text_sha256") is None and isinstance(item.get("text"), str))
1334
1388
  known = owned and (item["kind"], _text_key(item["text"])) in native.get(next(iter(runs)), {})
1335
- delivery = delivery_proofs.get(index) if owned else None
1389
+ # Steers never use delta-local text equality as proof. An equal
1390
+ # source item can precede this delta's checkpoint in the same
1391
+ # native turn. Only the full verified prefix can establish that
1392
+ # this source occurrence is the already-shown follow-up.
1393
+ # This proof already established exact source bytes, native owner,
1394
+ # original event and checkpoint even when the display is trimmed.
1395
+ delivery = delivery_proofs.get(index)
1336
1396
  known = known or delivery is not None
1337
- if known and (next(iter(runs)), _text_key(item["text"])) in wake_keys:
1397
+ if known and runs and (next(iter(runs)), _text_key(item["text"])) in wake_keys:
1338
1398
  known = (bool(origin.get("event_id")) and bool(origin.get("timestamp"))
1339
1399
  and len(wake_source_ids.get((origin.get("turn_id"), _text_key(item["text"])), set())) == 1)
1340
1400
  if owned and not known and item["kind"] == "assistant":
@@ -1350,7 +1410,7 @@ def filter_native_codex_history_items(session_id: str, provider_id: str, events:
1350
1410
  "provider_history_repair": "source_proven_native_replay",
1351
1411
  "provider_origin": {**origin, "session_id": provider_id,
1352
1412
  "native_event_id": delivery["native_event_id"] if delivery else native[next(iter(runs))][key],
1353
- "source_text_sha256": key[1]}})
1413
+ "source_text_sha256": delivery["source_text_sha256"] if delivery else key[1]}})
1354
1414
  budget.check()
1355
1415
  return result
1356
1416
  except (OSError, ValueError, TypeError, KeyError, RuntimeError) as exc:
@@ -814,6 +814,17 @@ def safe_model_label(label, model):
814
814
  return label.strip() if isinstance(label, str) and label.strip() and len(label) <= 160 and all(ord(c) >= 32 and ord(c) != 127 for c in label) else model
815
815
 
816
816
 
817
+ def endpoint_api_base_url(base: str, protocol: str) -> str:
818
+ """Resolve the API prefix, retaining the gateway's path and saved identity.
819
+
820
+ Anthropic settings accept either a root or its /v1 API prefix. AI SDK
821
+ adapters append /messages, unlike Claude Code which appends /v1/messages.
822
+ OpenAI-compatible settings already specify their complete API prefix.
823
+ """
824
+ base = base.rstrip("/")
825
+ return base + "/v1" if protocol == "anthropic" and not base.endswith("/v1") else base
826
+
827
+
817
828
  def endpoint_model_catalog(selected: dict, *, protocol="responses") -> dict:
818
829
  """Read only this endpoint's inventory. Never use CLI models or infer a default.
819
830
 
@@ -823,8 +834,7 @@ def endpoint_model_catalog(selected: dict, *, protocol="responses") -> dict:
823
834
  base = selected["base_url"].rstrip("/")
824
835
  parsed = urlsplit(base)
825
836
  router = parsed.scheme == "https" and parsed.netloc in {"openrouter.ai", "eu.openrouter.ai"} and parsed.path in {"/api", "/api/v1"}
826
- target = (base.removesuffix("/v1") + "/v1/models/user") if router else base + (
827
- "/v1/models" if protocol == "anthropic" and not base.endswith("/v1") else "/models")
837
+ target = (base.removesuffix("/v1") + "/v1/models/user") if router else endpoint_api_base_url(base, protocol) + "/models"
828
838
  headers = {"Accept": "application/json", "anthropic-version": "2023-06-01"}
829
839
  header = selected.get("auth_header", "bearer")
830
840
  headers["x-api-key" if header == "x-api-key" else "Authorization"] = selected["api_key"] if header == "x-api-key" else "Bearer " + selected["api_key"]
@@ -249,7 +249,7 @@ def require_retired_legacy_intent(layout: files.ExecutionLayout, previous: Any,
249
249
 
250
250
  root = layout.install_root
251
251
  def no_journal():
252
- for name in (".activation-transaction", ".execution-transaction", ".execution-uninstall.json"):
252
+ for name in (".activation-transaction", ".execution-transaction", ".execution-uninstall.json", ".execution-lifecycle.json"):
253
253
  if (root / name).exists() or (root / name).is_symlink():
254
254
  raise RuntimeError("a lifecycle journal still owns the previous recovery intent")
255
255
  no_journal()
@@ -186,6 +186,10 @@ class ExecutionControl:
186
186
  if length not in {None, 0}:
187
187
  raise ExecutionControlError(400, "Status requests do not accept a body")
188
188
  result = await self.status_callback()
189
+ # This running worker understands execution-lifecycle startup
190
+ # holds. New CLIs must not stop an older retained worker and
191
+ # discover only after restart that its queue was not held.
192
+ result = {**result, "native_lifecycle_protocol": 1}
189
193
  else:
190
194
  content_types = _header_values(headers, b"content-type")
191
195
  if (len(content_types) != 1 or content_types[0].lower()
@@ -329,6 +329,36 @@ def layout_manifest(layout: ExecutionLayout) -> dict[str, Any]:
329
329
  "retained_releases": sorted({str(layout.worker_release), str(layout.gateway_release)})}
330
330
 
331
331
 
332
+ def installed_layout(root: Path) -> ExecutionLayout:
333
+ """Validate authoritative bindings without discarding retained rollback history."""
334
+ root = _path(root)
335
+ data, _mode = _read_file(root / LAYOUT_NAME, private=True)
336
+ value = json.loads(data)
337
+ if not isinstance(value, dict):
338
+ raise ValueError("installed execution layout is invalid")
339
+ layout = ExecutionLayout.from_dict(value.get("layout"))
340
+ expected = layout_manifest(layout)
341
+ retained = value.get("retained_releases")
342
+ if (layout.install_root != root or set(value) != set(expected)
343
+ or {key: item for key, item in value.items() if key != "retained_releases"}
344
+ != {key: item for key, item in expected.items() if key != "retained_releases"}
345
+ or not isinstance(retained, list) or not all(isinstance(item, str) for item in retained)
346
+ or len(retained) != len(set(retained))
347
+ or not set(expected["retained_releases"]).issubset(retained)):
348
+ raise ValueError("installed execution layout does not match its runtime")
349
+ # Old monolith releases may have no execution_service.py. Retention is not
350
+ # activation authority, but every advisory entry must be an owned, real
351
+ # version directory inside this installation (never an arbitrary path).
352
+ for item in retained:
353
+ path = _path(item)
354
+ if path.parent != root / "releases" or path.name.startswith("."):
355
+ raise ValueError("retained release is outside the installation")
356
+ _owned_directory(path)
357
+ version, _mode = _read_file(path / "VERSION")
358
+ _text(version.decode().strip())
359
+ return layout
360
+
361
+
332
362
  def prepare_runtime(layout: ExecutionLayout) -> None:
333
363
  """Create private IPC state; never rotate an existing control token."""
334
364
  from execution_transport import ensure_execution_secret
@@ -399,6 +429,10 @@ def stage(layout: ExecutionLayout, *, scope: str, prior_services: dict[str, Any]
399
429
  legacy = layout.install_root / ".activation-transaction"
400
430
  if legacy.exists() or legacy.is_symlink():
401
431
  raise RuntimeError("legacy activation recovery must finish before execution activation")
432
+ for name in (".execution-lifecycle.json", ".execution-uninstall.json"):
433
+ marker = layout.install_root / name
434
+ if marker.exists() or marker.is_symlink():
435
+ raise RuntimeError("native lifecycle ownership must finish before execution activation")
402
436
  if layout.transaction_dir.exists() or layout.transaction_dir.is_symlink():
403
437
  raise FileExistsError("an execution activation is already pending")
404
438
  prior_layout = _snapshot(layout.manifest_path)
@@ -674,6 +708,19 @@ def pending_worker_operation(root: Path, runtime_root: Path) -> str | None:
674
708
  journal = root / TRANSACTION_NAME
675
709
  outer = root / ".activation-transaction"
676
710
  uninstall = root / ".execution-uninstall.json"
711
+ lifecycle = root / ".execution-lifecycle.json"
712
+ if lifecycle.exists() or lifecycle.is_symlink():
713
+ if any(path.exists() or path.is_symlink() for path in (outer, journal)):
714
+ raise RuntimeError("activation and lifecycle both claim execution ownership")
715
+ from execution_manage import pending_lifecycle_operation
716
+ operation = pending_lifecycle_operation(root, runtime_root)
717
+ if uninstall.exists() or uninstall.is_symlink():
718
+ # An interrupted owned-stop -> uninstall adoption may retain both
719
+ # journals, but they must describe the very same sealed operation.
720
+ from execution_uninstall import pending_uninstall_operation
721
+ if pending_uninstall_operation(root, runtime_root) != operation:
722
+ raise RuntimeError("uninstall and lifecycle ownership conflict")
723
+ return operation
677
724
  if uninstall.exists() or uninstall.is_symlink():
678
725
  if outer.exists() or outer.is_symlink() or journal.exists() or journal.is_symlink():
679
726
  raise RuntimeError("activation and uninstall both claim execution ownership")
@@ -714,13 +761,7 @@ def active_worker_release(root: Path) -> Path | None:
714
761
  data, _mode = _read_file(root / LAYOUT_NAME, private=True)
715
762
  except FileNotFoundError:
716
763
  return None
717
- value = json.loads(data)
718
- if not isinstance(value, dict) or value.get("format") != FORMAT:
719
- raise ValueError("installed execution layout is invalid")
720
- layout = ExecutionLayout.from_dict(value.get("layout"))
721
- if layout.install_root != root or value != layout_manifest(layout):
722
- raise ValueError("installed execution layout no longer matches its runtime")
723
- return layout.worker_release
764
+ return installed_layout(root).worker_release
724
765
 
725
766
 
726
767
  def main(argv: list[str] | None = None) -> None: