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

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.
package/npm/cli.cjs CHANGED
@@ -66,7 +66,8 @@ function ensureFreshInstall(context) {
66
66
  if (context.uid === 0) throw new Error('Run as the user who will own the server, without sudo.')
67
67
  if (!['darwin', 'linux'].includes(context.platform)) throw new Error('Server installation requires Linux or Apple silicon macOS.')
68
68
  for (const name of ROOT_SELECTORS) if (context.env[name]) throw new Error(`Custom installation selector ${name} is unsupported by the fresh-install CLI. Use the existing server's managed updater.`)
69
- const existingInstall = () => new Error('An existing server installation or state was found. Use AgentsDock or agentsdock-server update; no installer was started.')
69
+ const existingInstall = (message = 'An existing server installation or state was found. Use AgentsDock or agentsdock-server update; no installer was started.') =>
70
+ Object.assign(new Error(message), { code: 'AGENTSDOCK_EXISTING_INSTALLATION' })
70
71
  const safeDirectory = info => info.isDirectory() && !info.isSymbolicLink() && info.uid === context.uid && (info.mode & 0o022) === 0 && (info.mode & 0o500) === 0o500
71
72
  const allowEmptyScaffold = (relative, child) => {
72
73
  const root = path.join(context.home, relative)
@@ -100,11 +101,14 @@ function ensureFreshInstall(context) {
100
101
  }
101
102
  if (context.platform === 'darwin') {
102
103
  const check = context.spawn('/bin/launchctl', ['print', `gui/${context.uid}/com.agentsdock.server`], { encoding: 'utf8', timeout: 10000 })
103
- if (check.status === 0) throw new Error('An AgentsServer service already exists. Use its managed updater.')
104
+ if (check.status === 0) throw existingInstall('An AgentsServer service already exists. Use its managed updater.')
104
105
  if (check.error || !/could not find service|service.*not found/i.test(`${check.stdout || ''} ${check.stderr || ''}`)) throw new Error('Could not verify the user service is absent. No installer was started.')
105
106
  } else {
106
107
  for (const unit of ['agents-server.service', 'zenithbot-agent.service']) {
107
108
  const check = context.spawn('systemctl', ['--user', 'show', unit, '--property=LoadState', '--value'], { encoding: 'utf8', timeout: 10000 })
109
+ if (!check.error && check.status === 0 && String(check.stdout).trim() && String(check.stdout).trim() !== 'not-found') {
110
+ throw existingInstall('An existing user service was found. Use its managed updater.')
111
+ }
108
112
  if (check.error || String(check.stdout).trim() !== 'not-found') throw new Error('An existing user service was found or its absence could not be verified. Use its managed updater.')
109
113
  }
110
114
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agentsdock/server",
3
- "version": "1.0.10-beta.1",
3
+ "version": "1.0.10-beta.4",
4
4
  "description": "AgentsDock's self-hosted Python server and explicit installation CLI",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
package/server/VERSION CHANGED
@@ -1 +1 @@
1
- 1.0.10-beta.1
1
+ 1.0.10-beta.4
@@ -10765,6 +10765,7 @@ class SessionStore:
10765
10765
  if "subagent_limit" in patch:
10766
10766
  validate_session_subagent_limit({**sess, "backend": patch.get("backend") or sess.get("backend")}, patch["subagent_limit"])
10767
10767
  previous_provider_runtime = dict(sess) if {"codex_provider", "provider_connection", "subagent_limit", "model", "effort"}.intersection(patch) or sess.get("_codex_provider_pending") else None
10768
+ previous_title_runtime = title_runtime_key(sess)
10768
10769
  missing_policy = object()
10769
10770
  previous_provider_jobs_access = sess.get(
10770
10771
  "provider_jobs_access",
@@ -10862,9 +10863,6 @@ class SessionStore:
10862
10863
  sess.pop("_title_seed", None)
10863
10864
  if patch.get("auto_title_enabled") is not None:
10864
10865
  sess["auto_title_enabled"] = bool(patch["auto_title_enabled"])
10865
- if (any(key in patch for key in ("title", "model", "backend", "codex_provider"))
10866
- or patch.get("auto_title_enabled") is False or patch.get("archived") is True):
10867
- cancel_generated_session_title(sid)
10868
10866
  for key, default, allowed in (
10869
10867
  (
10870
10868
  "claude_permission_mode",
@@ -10991,6 +10989,13 @@ class SessionStore:
10991
10989
  sess["_codex_provider_pending"] = desired
10992
10990
  else:
10993
10991
  sess.pop("_codex_provider_pending", None)
10992
+ # Ordinary/queued turns re-save their backend and may repeat model
10993
+ # or provider selections. Only an actual runtime change invalidates
10994
+ # the in-flight title; cancelling a no-op consumes its sole attempt.
10995
+ # An explicit title PATCH is still manual ownership, even if equal.
10996
+ if ("title" in patch or title_runtime_key(sess) != previous_title_runtime
10997
+ or patch.get("auto_title_enabled") is False or patch.get("archived") is True):
10998
+ cancel_generated_session_title(sid)
10994
10999
  sess["updated_at"] = now_iso()
10995
11000
  try:
10996
11001
  await self.save()
@@ -25588,7 +25593,7 @@ async def _start_next_queued_turn_locked(
25588
25593
  if e.status_code in (409, 503):
25589
25594
  already_notified = bool(item.get("_turn_deferred_notified"))
25590
25595
  item["_turn_deferred_notified"] = True
25591
- deferred_detail = str(e.detail or "")
25596
+ deferred_detail = str(e.detail.get("message", "") if isinstance(e.detail, dict) else e.detail or "")
25592
25597
  item["_last_deferred_detail"] = deferred_detail
25593
25598
  # Pure wait states (capacity, memory, goals or maintenance in
25594
25599
  # progress) are not admission failures of this row.
@@ -25602,7 +25607,7 @@ async def _start_next_queued_turn_locked(
25602
25607
  if not already_notified:
25603
25608
  await append_event(session_id, "turn_deferred", {
25604
25609
  "queued_id": item.get("queued_id"),
25605
- "message": f"Queued turn deferred: {e.detail}",
25610
+ "message": f"Queued turn deferred: {deferred_detail}",
25606
25611
  })
25607
25612
  schedule_queued_turn_retry(session_id)
25608
25613
  return
@@ -41583,7 +41588,7 @@ def cross_chat_exchange_submission_defer_reason(
41583
41588
  if isinstance(error, asyncio.CancelledError):
41584
41589
  return "AgentsServer stopped while admitting the delivery"
41585
41590
  if isinstance(error, TransientAdmissionWait):
41586
- return str(error.detail or "").strip() or "target admission deferred"
41591
+ return str(error.detail.get("message", "") if isinstance(error.detail, dict) else error.detail or "").strip() or "target admission deferred"
41587
41592
  if isinstance(error, HTTPException) and error.status_code == 503:
41588
41593
  detail = error.detail
41589
41594
  if isinstance(detail, dict):
@@ -46812,8 +46817,9 @@ def active_generated_title_work_labels() -> list[str]:
46812
46817
 
46813
46818
  def title_runtime_key(sess: dict[str, Any]) -> tuple:
46814
46819
  return (sess.get("backend") or DEFAULT_BACKEND, session_provider_id(sess),
46815
- sess.get("model"), sess.get("codex_provider"), sess.get("codex_provider_revision"),
46816
- sess.get("provider_connection"), sess.get("provider_connection_revision"))
46820
+ sess.get("model"), codex_provider.session_choice(sess.get("codex_provider")),
46821
+ sess.get("codex_provider_revision"), sess.get("provider_connection") or "default",
46822
+ sess.get("provider_connection_revision"))
46817
46823
 
46818
46824
 
46819
46825
  def generated_title_eligible(sess: dict[str, Any], *, allow_attempted: bool = False) -> bool:
@@ -54111,8 +54117,17 @@ def codex_manager_has_callers(manager: CodexAppServerManager, session_id: str |
54111
54117
  for task, owner in tuple(getattr(manager, "_agentsdock_callers", {}).items()))
54112
54118
 
54113
54119
 
54114
- def codex_manager_has_callbacks(manager: CodexAppServerManager) -> bool:
54115
- tasks = [task for task in manager.client._callback_tasks if not task.done()]
54120
+ def codex_manager_work_belongs_to_session(work: Any, session_id: str) -> bool:
54121
+ thread_id = getattr(work, "_codex_thread_id", "")
54122
+ owner = codex_session_id_for_thread(thread_id) if thread_id else None
54123
+ # Unknown ownership stays process-wide. Work for a known different chat
54124
+ # must not prevent an idle chat from releasing its own subscription.
54125
+ return owner is None or owner == session_id
54126
+
54127
+
54128
+ def codex_manager_has_callbacks(manager: CodexAppServerManager, session_id: str | None = None) -> bool:
54129
+ tasks = [task for task in manager.client._callback_tasks if not task.done()
54130
+ and (session_id is None or codex_manager_work_belongs_to_session(task, session_id))]
54116
54131
  for task in tasks:
54117
54132
  if not getattr(task, "_agentsdock_drain_watched", False):
54118
54133
  task._agentsdock_drain_watched = True
@@ -54165,9 +54180,9 @@ async def refresh_codex_app_server_binary(*, force: bool = False) -> None:
54165
54180
  async def refresh_codex_app_server_login(*, request_handoff: bool = False) -> None:
54166
54181
  """Change normal-Codex admission; never kill a credential-owning process.
54167
54182
 
54168
- Ordinary lookups use a conservative native file revision. Recheck CLIs is
54169
- an explicit handoff request, also covering keyring and same-account logins
54170
- without an OIDC auth_time. This is not a token refresh or login operation.
54183
+ Catalog refresh only observes the native login revision. Only an explicit
54184
+ provider reload requests handoff for opaque credential stores. Neither
54185
+ operation logs in or rotates native credentials.
54171
54186
  """
54172
54187
  global CODEX_APP_SERVER_MANAGER, CODEX_LOGIN_REVISION
54173
54188
  if CODEX_TRANSPORT == CODEX_TRANSPORT_EXEC or CODEX_AUTH_LOCK.locked() or CODEX_MANAGER_CLOSING:
@@ -54191,7 +54206,12 @@ async def refresh_codex_app_server_login(*, request_handoff: bool = False) -> No
54191
54206
  continue
54192
54207
  previous = getattr(manager, "_agentsdock_login_revision", None)
54193
54208
  changed = revision is not None and revision != previous
54194
- if changed or request_handoff:
54209
+ # Recheck CLIs is observational when the native sign-in
54210
+ # revision is unchanged. Retiring a healthy writer here
54211
+ # strands its chats behind an unnecessary login handoff.
54212
+ if revision is not None and revision == previous:
54213
+ manager._agentsdock_login_superseded = False
54214
+ if changed or (request_handoff and revision is None):
54195
54215
  manager._agentsdock_login_superseded = True
54196
54216
  if not any(manager is item for item in CODEX_RETIRED_APP_SERVER_MANAGERS):
54197
54217
  CODEX_RETIRED_APP_SERVER_MANAGERS.append(manager)
@@ -54200,22 +54220,59 @@ async def refresh_codex_app_server_login(*, request_handoff: bool = False) -> No
54200
54220
  schedule_codex_manager_drain()
54201
54221
 
54202
54222
 
54203
- def codex_manager_session_busy(manager: CodexAppServerManager, session_id: str, *, ignore_task=None, ignore_maintenance=False) -> bool:
54223
+ def codex_manager_session_blocker(manager: CodexAppServerManager, session_id: str, *, ignore_task=None, ignore_maintenance=False) -> str | None:
54204
54224
  session = STORE.sessions.get(session_id) or {}
54205
- return bool(
54206
- session_id in BUSY_SESSIONS or ACTIVE.get(session_id) is not None
54207
- or (not ignore_maintenance and session_id in SERVER_MAINTENANCE_SESSIONS)
54208
- or codex_manager_has_callers(manager, session_id, ignore_task=ignore_task)
54209
- or codex_manager_has_callbacks(manager)
54210
- or any(task is not ignore_task and not task.done()
54211
- for registry in (SESSION_TURN_TASKS, CODEX_NATIVE_ACTION_TASKS, CODEX_INTERACTION_HANDLER_TASKS)
54212
- for task in registry.get(session_id, ()))
54213
- or any(item.get("session_id") == session_id for item in CODEX_PENDING_INTERACTIONS.values())
54214
- or session_id in SIDE_QUESTIONS.active_session_ids()
54215
- or (isinstance(session.get("codex_goal"), dict)
54216
- and session["codex_goal"].get("status") == "active")
54217
- or codex_session_has_live_subagents(session_id)
54218
- )
54225
+ if session_id in BUSY_SESSIONS or ACTIVE.get(session_id) is not None:
54226
+ return "active_turn"
54227
+ if not ignore_maintenance and session_id in SERVER_MAINTENANCE_SESSIONS:
54228
+ return "maintenance"
54229
+ if any(item.get("session_id") == session_id for item in CODEX_PENDING_INTERACTIONS.values()):
54230
+ return "approval"
54231
+ if session_id in SIDE_QUESTIONS.active_session_ids():
54232
+ return "side_chat"
54233
+ if (session.get("codex_goal") or {}).get("status") == "active":
54234
+ return "active_goal"
54235
+ if codex_session_has_live_subagents(session_id):
54236
+ return "subagents"
54237
+ if codex_manager_has_callers(manager, session_id, ignore_task=ignore_task):
54238
+ return "runtime_request"
54239
+ if codex_manager_has_callbacks(manager, session_id):
54240
+ return "callback"
54241
+ if any(task is not ignore_task and not task.done()
54242
+ for registry in (SESSION_TURN_TASKS, CODEX_INTERACTION_HANDLER_TASKS)
54243
+ for task in registry.get(session_id, ())):
54244
+ return "runtime_request"
54245
+ if any(sid == session_id and task is not ignore_task and not task.done()
54246
+ for (sid, _), task in CODEX_NATIVE_ACTION_TASKS.items()):
54247
+ return "runtime_request"
54248
+ return None
54249
+
54250
+
54251
+ def codex_manager_session_busy(manager: CodexAppServerManager, session_id: str, *, ignore_task=None, ignore_maintenance=False) -> bool:
54252
+ return codex_manager_session_blocker(manager, session_id, ignore_task=ignore_task,
54253
+ ignore_maintenance=ignore_maintenance) is not None
54254
+
54255
+
54256
+ def codex_login_handoff_wait(manager: CodexAppServerManager, session_id: str) -> HTTPException:
54257
+ reason = (getattr(manager, "_agentsdock_handoff_reasons", {}).get(session_id)
54258
+ or codex_manager_session_blocker(manager, session_id, ignore_task=asyncio.current_task())
54259
+ or "runtime_request")
54260
+ return TransientAdmissionWait(409, codex_auth.handoff_detail(reason))
54261
+
54262
+
54263
+ def watch_codex_login_handoff_blockers(manager: CodexAppServerManager, session_id: str, *, ignore_task=None) -> None:
54264
+ if not getattr(manager, "_agentsdock_login_superseded", False):
54265
+ return
54266
+ work = set(manager.client._callback_tasks) | set(manager.client._server_request_tasks.values())
54267
+ work.update(value[1] for value in manager.client._pending.values())
54268
+ work.update(task for task, owner in tuple(getattr(manager, "_agentsdock_callers", {}).items())
54269
+ if not owner or owner == session_id)
54270
+ for task in work:
54271
+ if task is ignore_task or task.done() or not codex_manager_work_belongs_to_session(task, session_id):
54272
+ continue
54273
+ if not getattr(task, "_agentsdock_login_handoff_watched", False):
54274
+ task._agentsdock_login_handoff_watched = True
54275
+ task.add_done_callback(lambda _: schedule_codex_manager_drain())
54219
54276
 
54220
54277
 
54221
54278
  def watch_codex_provider_handoff_blockers(
@@ -54269,11 +54326,20 @@ async def release_idle_codex_manager_session(manager: CodexAppServerManager, ses
54269
54326
  # The response Future can finish before its RPC coroutine
54270
54327
  # removes the registry entry. Treat that settled response as
54271
54328
  # complete so its wakeup cannot get lost during unwinding.
54272
- or any(not value[1].done() for value in manager.client._pending.values())
54273
- or any(not task.done() for task in manager.client._server_request_tasks.values()))
54329
+ or any(not value[1].done() and codex_manager_work_belongs_to_session(value[1], session_id)
54330
+ for value in manager.client._pending.values())
54331
+ or any(not task.done() and codex_manager_work_belongs_to_session(task, session_id)
54332
+ for task in manager.client._server_request_tasks.values()))
54274
54333
 
54275
54334
  async def defer(reason: str, *, error_type: str = "") -> bool:
54276
54335
  session = STORE.sessions.get(session_id) or {}
54336
+ reasons = getattr(manager, "_agentsdock_handoff_reasons", None)
54337
+ if reasons is None:
54338
+ reasons = manager._agentsdock_handoff_reasons = {}
54339
+ reasons[session_id] = reason
54340
+ if reason in {"runtime_borrowers", "runtime_borrowers_after_metadata", "runtime_borrowers_after_unsubscribe"}:
54341
+ reasons[session_id] = codex_manager_session_blocker(manager, session_id, ignore_task=ignore_task, ignore_maintenance=True) or "runtime_request"
54342
+ watch_codex_login_handoff_blockers(manager, session_id, ignore_task=ignore_task)
54277
54343
  if isinstance(session.get("_codex_provider_pending"), dict):
54278
54344
  # A failed ownership/goal request can itself emit callbacks.
54279
54345
  # Only verified transient borrowers own a completion retry; an
@@ -54299,6 +54365,7 @@ async def release_idle_codex_manager_session(manager: CodexAppServerManager, ses
54299
54365
  if blocked():
54300
54366
  return await defer("runtime_borrowers")
54301
54367
  deadline = time.monotonic() + 8.0
54368
+ failure_reason = "thread_list_unavailable"
54302
54369
  SERVER_MAINTENANCE_SESSIONS.add(session_id)
54303
54370
  try:
54304
54371
  threads = [thread for thread in tuple(manager.client._loaded_threads)
@@ -54334,6 +54401,7 @@ async def release_idle_codex_manager_session(manager: CodexAppServerManager, ses
54334
54401
  if (thread in CODEX_APP_SERVER_PINNED_THREADS or thread in CODEX_INTERACTIVE_CONTROL_THREADS
54335
54402
  or manager.active_turn(thread) is not None):
54336
54403
  return await defer("native_thread_in_use")
54404
+ failure_reason = "goal_state_unavailable"
54337
54405
  try:
54338
54406
  goal = await asyncio.wait_for(manager.get_thread_goal(thread), timeout=min(3.0, deadline - time.monotonic()))
54339
54407
  except CodexAppServerRequestError as exc:
@@ -54342,6 +54410,7 @@ async def release_idle_codex_manager_session(manager: CodexAppServerManager, ses
54342
54410
  goal = None
54343
54411
  if isinstance(goal, dict) and goal.get("status") == "active":
54344
54412
  return await defer("native_goal_active")
54413
+ failure_reason = "terminal_state_unavailable"
54345
54414
  try:
54346
54415
  terminals = await asyncio.wait_for(manager.list_background_terminals(thread), timeout=min(3.0, deadline - time.monotonic()))
54347
54416
  except CodexAppServerRequestError as exc:
@@ -54354,6 +54423,7 @@ async def release_idle_codex_manager_session(manager: CodexAppServerManager, ses
54354
54423
  return await defer("background_terminals")
54355
54424
  if blocked(own_maintenance=True):
54356
54425
  return await defer("runtime_borrowers_after_metadata")
54426
+ failure_reason = "native_unsubscribe_incomplete"
54357
54427
  if manager.is_thread_loaded(thread):
54358
54428
  evicted = await asyncio.wait_for(evict_codex_app_server_thread(manager, thread, reinsert_on_failure=True),
54359
54429
  timeout=max(0.0, deadline - time.monotonic()))
@@ -54364,14 +54434,16 @@ async def release_idle_codex_manager_session(manager: CodexAppServerManager, ses
54364
54434
  # Unsubscribe removes the client subscription, but native Codex keeps
54365
54435
  # the native writer alive. Release it before another manager can own
54366
54436
  # this chat; unrelated active chats keep their current process.
54437
+ failure_reason = "writer_release_failed"
54367
54438
  await release_codex_provider_writers(manager, session_id, threads)
54439
+ getattr(manager, "_agentsdock_handoff_reasons", {}).pop(session_id, None)
54368
54440
  if CODEX_SESSION_APP_SERVER_MANAGERS.get(session_id) is manager:
54369
54441
  CODEX_SESSION_APP_SERVER_MANAGERS.pop(session_id, None)
54370
54442
  CODEX_GOAL_SYNC_GENERATIONS.pop(session_id, None)
54371
54443
  return True
54372
54444
  except Exception as exc:
54373
54445
  logger.debug("Codex handoff retained an unverified idle owner", exc_info=True)
54374
- return await defer("ownership_unverified", error_type=type(exc).__name__)
54446
+ return await defer(failure_reason, error_type=type(exc).__name__)
54375
54447
  finally:
54376
54448
  SERVER_MAINTENANCE_SESSIONS.discard(session_id)
54377
54449
 
@@ -54397,7 +54469,7 @@ async def prepare_codex_login_turn(session: dict[str, Any]) -> None:
54397
54469
  if session_id in BUSY_SESSIONS or ACTIVE.get(session_id) is not None:
54398
54470
  return
54399
54471
  if not await release_idle_codex_manager_session(manager, session_id, ignore_task=asyncio.current_task()):
54400
- raise TransientAdmissionWait(409, codex_auth.HANDOFF_MESSAGE)
54472
+ raise codex_login_handoff_wait(manager, session_id)
54401
54473
  schedule_codex_manager_drain()
54402
54474
 
54403
54475
 
@@ -54414,6 +54486,11 @@ async def drain_retired_codex_managers() -> None:
54414
54486
  for session_id, owner in tuple(CODEX_SESSION_APP_SERVER_MANAGERS.items()):
54415
54487
  if owner is not manager or codex_manager_session_busy(manager, session_id):
54416
54488
  continue
54489
+ # Failed metadata RPCs can emit status callbacks themselves.
54490
+ # Those callbacks must not trigger an endless self-retry.
54491
+ # A user's next send always retries the proof in preflight.
54492
+ if getattr(manager, "_agentsdock_handoff_reasons", {}).get(session_id) in {"thread_list_unavailable", "goal_state_unavailable", "terminal_state_unavailable", "ownership_deadline"}:
54493
+ continue
54417
54494
  lock = session_lifecycle_lock(session_id)
54418
54495
  if lock.locked():
54419
54496
  continue # Never invert the session -> manager lock order.
@@ -54556,7 +54633,7 @@ async def codex_app_server_manager(sess: dict[str, Any] | None = None, *, allow_
54556
54633
  if (getattr(manager, "_agentsdock_login_superseded", False) is True and not allow_retired_login
54557
54634
  and getattr(manager, "_agentsdock_callers", {}).get(asyncio.current_task()) != session_id):
54558
54635
  schedule_codex_manager_drain()
54559
- raise TransientAdmissionWait(409, codex_auth.HANDOFF_MESSAGE)
54636
+ raise codex_login_handoff_wait(manager, session_id)
54560
54637
  if session_id:
54561
54638
  CODEX_SESSION_APP_SERVER_MANAGERS[session_id] = manager
54562
54639
  retain_codex_manager_caller(manager, session_id)
@@ -62620,10 +62697,9 @@ async def run_claude_print(
62620
62697
  if btype == "text" and block.get("text"):
62621
62698
  text = clean_assistant_text(block["text"])
62622
62699
  if text:
62623
- await append_event(session_id, "reasoning_summary", {
62700
+ await append_event(session_id, "assistant_text", {
62624
62701
  "run_id": run_id,
62625
62702
  "text": text,
62626
- "phase": "commentary",
62627
62703
  "backend": BACKEND_CLAUDE,
62628
62704
  **run_event_metadata(run_id),
62629
62705
  })
@@ -63063,10 +63139,9 @@ async def project_claude_sdk_message(
63063
63139
  )
63064
63140
  if text:
63065
63141
  text_parts.append(text)
63066
- await append_event(session_id, "reasoning_summary", {
63142
+ await append_event(session_id, "assistant_text", {
63067
63143
  "run_id": run_id,
63068
63144
  "text": text,
63069
- "phase": "commentary",
63070
63145
  "backend": BACKEND_CLAUDE,
63071
63146
  **({"provider_message_id": message_uuid}
63072
63147
  if (message_uuid := claude_sdk_field(message, "uuid")) else {}),
@@ -73472,7 +73547,7 @@ def scheduled_job_lifecycle_admission_defer_reason(
73472
73547
  """
73473
73548
 
73474
73549
  if isinstance(error, TransientAdmissionWait):
73475
- return str(error.detail or "").strip() or "turn admission deferred"
73550
+ return str(error.detail.get("message", "") if isinstance(error.detail, dict) else error.detail or "").strip() or "turn admission deferred"
73476
73551
  if not isinstance(error, HTTPException) or error.status_code != 503:
73477
73552
  return None
73478
73553
  reason = str(error.detail or "").strip()
@@ -73610,22 +73685,14 @@ def managed_server_update_scheduled_job_blocker(
73610
73685
  *,
73611
73686
  manual: bool = False,
73612
73687
  ) -> str | None:
73613
- """Fence autonomous job admission while an idle update is reserved.
73614
-
73615
- A pending reservation deliberately stays invisible to ordinary user turns,
73616
- Force Send, provider controls, and manual Run Now. Automatic job
73617
- dispatches are durable and retryable, so deferring only that autonomous
73618
- admission path prevents recurring jobs from starving the updater without
73619
- reviving the global pending fence that locked operators out on
73620
- 2026-09-04.
73688
+ """Apply the same update admission policy to scheduled and manual work.
73689
+
73690
+ Waiting for idle must not manufacture idle by suspending recurring jobs.
73691
+ Only an actual replacement/restart (or an explicitly confirmed force
73692
+ update) fences new work, just as it does for ordinary user turns.
73621
73693
  """
73622
73694
 
73623
- blocker = managed_server_update_blocker()
73624
- if blocker:
73625
- return blocker
73626
- if not manual and managed_server_update_is_pending():
73627
- return MANAGED_SERVER_UPDATE_PENDING_DETAIL
73628
- return None
73695
+ return managed_server_update_admission_blocker()
73629
73696
 
73630
73697
 
73631
73698
  def live_unsafe_http_mutation_ids_locked() -> list[str]:
@@ -77212,13 +77279,12 @@ async def lifespan(app: FastAPI):
77212
77279
  startup_update_status = read_server_update_status()
77213
77280
  active_update_schedule_id = (
77214
77281
  str(startup_update_status.get("schedule_id") or "").strip()
77215
- if managed_server_update_is_pending(startup_update_status)
77282
+ if managed_server_force_update_is_pending(startup_update_status)
77216
77283
  else None
77217
77284
  )
77218
- # A completed/failed update, an offline cancellation, or replacement by a
77219
- # new reservation may leave exact automatic occurrences parked in the
77220
- # durable job registry. Rearm untouched revisions except those owned by
77221
- # the one reservation that is still live.
77285
+ # Older releases parked automatic jobs while merely waiting for idle.
77286
+ # Restore those unchanged occurrences even if that reservation still exists.
77287
+ # Preserve only an explicitly confirmed force update's admission fence.
77222
77288
  await JOBS.resume_update_parked(
77223
77289
  active_schedule_id=active_update_schedule_id,
77224
77290
  )
@@ -80644,14 +80710,21 @@ async def codex_provider_mcp(request: Request) -> Response:
80644
80710
  client_meta = turn_meta
80645
80711
  run_id = str(client_meta.get("agentsdock_run_id") or "")
80646
80712
  proof = str(client_meta.get("agentsdock_run_proof") or "")
80713
+ # Codex's own goal turns need not inherit turn/start client metadata.
80714
+ # Their authenticated native thread/turn identity still has an exact
80715
+ # server-owned supervisor. Resolve that owner, never a caller's chat ID
80716
+ # or a previously completed run. Partially supplied proofs stay invalid.
80717
+ native_goal_metadata = not run_id and not proof
80647
80718
  core_call_id = str(meta.get("callId") or "")
80648
80719
  if (
80649
80720
  not thread_id
80650
80721
  or len(thread_id) > 256
80651
80722
  or not turn_id
80652
80723
  or len(turn_id) > 256
80653
- or re.fullmatch(r"run_[A-Za-z0-9_-]{1,128}", run_id) is None
80654
- or re.fullmatch(r"[0-9a-f]{64}", proof) is None
80724
+ or (not native_goal_metadata and (
80725
+ re.fullmatch(r"run_[A-Za-z0-9_-]{1,128}", run_id) is None
80726
+ or re.fullmatch(r"[0-9a-f]{64}", proof) is None
80727
+ ))
80655
80728
  or not core_call_id
80656
80729
  ):
80657
80730
  return codex_provider_mcp_jsonrpc_error(request_id, -32602, "Incomplete turn metadata")
@@ -80666,11 +80739,18 @@ async def codex_provider_mcp(request: Request) -> Response:
80666
80739
  active.get("backend") == BACKEND_CODEX
80667
80740
  and active.get("transport") == CODEX_TRANSPORT_APP_SERVER
80668
80741
  and str(active.get("provider_thread_id") or "") == thread_id
80669
- and str(active.get("run_id") or "") == run_id
80742
+ and (
80743
+ codex_native_mailbox_owner_matches(
80744
+ session_id, str(active.get("run_id") or ""), thread_id, turn_id,
80745
+ ) if native_goal_metadata
80746
+ else str(active.get("run_id") or "") == run_id
80747
+ )
80670
80748
  and session_id in BUSY_SESSIONS
80671
80749
  )
80672
80750
  ]
80673
80751
  if len(owners) == 1:
80752
+ if native_goal_metadata:
80753
+ run_id = str(ACTIVE[owners[0]].get("run_id") or "")
80674
80754
  standalone_active_owner = bool(
80675
80755
  (ACTIVE.get(owners[0]) or {}).get(
80676
80756
  "standalone_provider_context"
@@ -80693,7 +80773,7 @@ async def codex_provider_mcp(request: Request) -> Response:
80693
80773
  ):
80694
80774
  return codex_provider_mcp_jsonrpc_error(request_id, -32602, "Ambiguous thread owner")
80695
80775
  expected_proof = codex_provider_mcp_run_proof(session_id, thread_id, run_id)
80696
- if not hmac.compare_digest(proof, expected_proof):
80776
+ if not native_goal_metadata and not hmac.compare_digest(proof, expected_proof):
80697
80777
  return codex_provider_mcp_jsonrpc_error(request_id, -32602, "Invalid turn proof")
80698
80778
  try:
80699
80779
  text, is_error = await execute_provider_tool_once(
@@ -82185,15 +82265,16 @@ async def release_codex_provider_writers(manager: CodexAppServerManager, session
82185
82265
  try:
82186
82266
  await manager.request("thread/unarchive", {"threadId": thread_id})
82187
82267
  except CodexAppServerRequestError:
82188
- if thread_id == parent_id:
82189
- raise
82268
+ # A lost restore response can leave the parent marker after the
82269
+ # native rollout is already restored. Recover it idempotently too.
82190
82270
  # Older versions archived the parent, then tried to archive each
82191
82271
  # already-archived child. Their finally block restored the child
82192
82272
  # but left its marker. Require native relationship, terminal turn
82193
82273
  # and existing unarchived rollout proof before clearing it.
82194
- descendants = await manager.list_descendant_threads(parent_id)
82195
- if not any(isinstance(child, dict) and child.get("id") == thread_id for child in descendants):
82196
- raise
82274
+ if thread_id != parent_id:
82275
+ descendants = await manager.list_descendant_threads(parent_id)
82276
+ if not any(isinstance(child, dict) and child.get("id") == thread_id for child in descendants):
82277
+ raise
82197
82278
  child = await manager.read_thread(thread_id)
82198
82279
  turns = await manager.list_turns(thread_id, limit=1, items_view="summary", sort_direction="desc")
82199
82280
  path = Path(str(child.get("path") or ""))
@@ -84198,7 +84279,7 @@ async def server_update_status(
84198
84279
  # second update attempt before reconnecting.
84199
84280
  public_status = public_server_update_status(status)
84200
84281
  await TERMINAL_ATTACHMENTS.reopen_if_update_inactive(public_status)
84201
- if managed_server_update_is_pending(status):
84282
+ if managed_server_force_update_is_pending(status):
84202
84283
  await JOBS.resume_update_parked(
84203
84284
  active_schedule_id=str(status.get("schedule_id") or "").strip(),
84204
84285
  )
@@ -84756,7 +84837,14 @@ async def _start_server_update(
84756
84837
  active_session_ids = (
84757
84838
  server_update_active_session_ids_locked()
84758
84839
  )
84759
- queued_turn_count = update_blocking_queued_turn_count_locked()
84840
+ # An idle update waits behind queued work too; persistence
84841
+ # across restart is not permission to jump ahead of it.
84842
+ queued_turn_count = (
84843
+ sum(len(queue) for queue in QUEUED_TURNS.values())
84844
+ + len(RUN_NOW_TURNS)
84845
+ if body.when_idle and not managed_server_force_update_is_pending(status)
84846
+ else update_blocking_queued_turn_count_locked()
84847
+ )
84760
84848
  mutation_count = unsafe_http_mutation_count_locked()
84761
84849
  duplicate_provider_labels = {
84762
84850
  *(f"active chat {session_id}" for session_id in BUSY_SESSIONS),
@@ -85685,7 +85773,7 @@ async def host_diagnostics(limit: int = 40) -> dict[str, Any]:
85685
85773
  async def runtime_catalog(refresh: bool = False) -> dict[str, Any]:
85686
85774
  if refresh:
85687
85775
  await refresh_codex_app_server_binary(force=True)
85688
- await refresh_codex_app_server_login(request_handoff=True)
85776
+ await refresh_codex_app_server_login()
85689
85777
  return await asyncio.to_thread(discover_runtime_catalog, force_runtime_probe=refresh)
85690
85778
 
85691
85779
 
@@ -86109,7 +86197,9 @@ async def get_session_subagents(
86109
86197
  session = STORE.sessions[session_id]
86110
86198
  if str(session.get("backend") or "") == BACKEND_CODEX and session_provider_id(session):
86111
86199
  try:
86112
- manager = await codex_app_server_manager(session)
86200
+ # Inspect the process which still owns the children during login
86201
+ # handoff. This read must not be rejected as a new model turn.
86202
+ manager = await codex_app_server_manager(session, allow_retired_login=True)
86113
86203
  await reconcile_codex_subagents(session_id, manager)
86114
86204
  except Exception as exc:
86115
86205
  logger.warning(
@@ -86120,6 +86210,29 @@ async def get_session_subagents(
86120
86210
  return await asyncio.to_thread(build_subagent_snapshot, session_id, limit)
86121
86211
 
86122
86212
 
86213
+ def find_timeline_event_anchor(session_id: str, event_id: str) -> dict[str, Any] | None:
86214
+ """Resolve a persisted pin by identity, independent of FTS text/index lag."""
86215
+ for event in iter_session_events(session_id):
86216
+ if event.get("id") != event_id:
86217
+ continue
86218
+ if str(event.get("session_id") or session_id) != session_id:
86219
+ return None
86220
+ seq = event.get("seq")
86221
+ if type(seq) is not int or seq <= 0:
86222
+ return None
86223
+ return {"session_id": session_id, "event_id": event_id, "seq": seq,
86224
+ "ts": event.get("ts", ""), "role": "system", "snippet": ""}
86225
+ return None
86226
+
86227
+
86228
+ @app.get("/api/sessions/{session_id}/timeline-event/{event_id}")
86229
+ async def get_timeline_event_anchor(session_id: str, event_id: str) -> dict[str, Any]:
86230
+ if session_id not in STORE.sessions:
86231
+ raise HTTPException(status_code=404, detail="session not found")
86232
+ anchor = await asyncio.to_thread(find_timeline_event_anchor, session_id, event_id)
86233
+ return {"event": anchor}
86234
+
86235
+
86123
86236
  @app.get("/api/sessions/{session_id}/search")
86124
86237
  async def search_session_timeline(
86125
86238
  session_id: str,
@@ -87251,6 +87364,8 @@ async def reload_session_provider(session_id: str) -> dict[str, Any]:
87251
87364
  ),
87252
87365
  )
87253
87366
  await release_codex_provider_writers(manager, session_id, [provider_id])
87367
+ if codex_provider.session_choice(session.get("codex_provider")) != "custom":
87368
+ await refresh_codex_app_server_login(request_handoff=True)
87254
87369
  runtime = await codex_runtime_snapshot(session_id)
87255
87370
 
87256
87371
  latest_session = STORE.sessions.get(session_id) or session
@@ -88560,7 +88675,7 @@ async def _put_codex_goal_locked(
88560
88675
  blocker = await turn_start_blocker(ignore_session_id=session_id)
88561
88676
  if blocker:
88562
88677
  raise HTTPException(status_code=503, detail=f"agent launch deferred: {blocker}")
88563
- operation_id = f"codexgoal_{uuid.uuid4().hex[:16]}"
88678
+ operation_id = f"run_goal_{uuid.uuid4().hex[:16]}"
88564
88679
  subscription = manager.subscribe_thread(thread_id)
88565
88680
  provider_model, provider_effort, provider_service_tier = codex_runtime_settings(stored_session)
88566
88681
  async with ACTIVE_LOCK:
@@ -88577,6 +88692,21 @@ async def _put_codex_goal_locked(
88577
88692
  if current_turn is not None:
88578
88693
  current_turn["run_id"] = operation_id
88579
88694
  current_turn["purpose"] = "codex_goal_resume"
88695
+ # goal/set starts native work without passing through turn/start.
88696
+ # Issue this new logical run's helper authority before native work
88697
+ # can call Publish/Jobs/Mail. Reuse only existing durable routes;
88698
+ # resuming a goal must not mint grants from its text or history.
88699
+ resume_routes = provider_cross_chat_route_snapshot_for_authority(
88700
+ provider_cross_chat_routes(stored_session), [], source_session_id=session_id,
88701
+ )
88702
+ authority_path = await issue_cross_chat_capability(
88703
+ session_id, operation_id, [],
88704
+ actions={"publish", "jobs", "emergency", "agent_cross_chat_routes"},
88705
+ provider_route_snapshot=resume_routes,
88706
+ async_route_v1=True,
88707
+ )
88708
+ if authority_path is None:
88709
+ raise ProviderToolError("Could not prepare the resumed goal's tools")
88580
88710
  await append_event(session_id, "turn_started", {
88581
88711
  "run_id": operation_id,
88582
88712
  "backend": BACKEND_CODEX,
@@ -88701,6 +88831,8 @@ async def _put_codex_goal_locked(
88701
88831
  finally:
88702
88832
  if not consumer_owns_reservation:
88703
88833
  try:
88834
+ if operation_id:
88835
+ await revoke_cross_chat_capability(operation_id)
88704
88836
  if goal_activation_attempted:
88705
88837
  await stop_codex_goal_resume(
88706
88838
  session_id, manager, thread_id, reservation_id,
@@ -95908,7 +96040,12 @@ async def active_artifact_publication_run(
95908
96040
  or not isinstance(active, dict)
95909
96041
  or not run_id
95910
96042
  or str(active.get("run_id") or "").strip() != run_id
95911
- or active.get("codex_native_operation") is True
96043
+ or (active.get("codex_native_operation") is True
96044
+ and not codex_native_mailbox_owner_matches(
96045
+ session_id, run_id,
96046
+ str(active.get("provider_thread_id") or ""),
96047
+ str(active.get("provider_turn_id") or ""),
96048
+ ))
95912
96049
  or active.get("stop_requested") is True
95913
96050
  or run_id in STOPPED_RUNS
95914
96051
  ):
@@ -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:
@@ -17,6 +17,7 @@ import os
17
17
  from pathlib import Path
18
18
  import plistlib
19
19
  import re
20
+ import select
20
21
  import shlex
21
22
  import shutil
22
23
  import socket
@@ -24,7 +25,9 @@ import stat
24
25
  import subprocess
25
26
  import sys
26
27
  import tempfile
28
+ import termios
27
29
  import time
30
+ import tty
28
31
  from urllib.parse import urlsplit
29
32
 
30
33
  INSTANCE_PROTOCOL = 1
@@ -611,6 +614,147 @@ def show(instance: Instance, *, network: dict[str, str] | None = None):
611
614
  print()
612
615
 
613
616
 
617
+ def show_status(instance: Instance, *, network: dict[str, str]) -> None:
618
+ """Read-only per-instance summary; never print configuration or tokens."""
619
+ item = describe(instance)
620
+ print(terminal_color(f"Server ({item['name']}):", "34"))
621
+ print(f" Status: {item['status']}")
622
+ addresses = []
623
+ if item["port"]:
624
+ choices = connection_choices(read_config(instance).get("AGENTSDOCK_AGENT_BIND", "0.0.0.0"),
625
+ item["port"], item["addresses"], network)
626
+ if choices["tailscale"]:
627
+ addresses.append(f"{choices['tailscale']} (Tailscale / other networks)")
628
+ for key, label in (("lan", "Same Wi-Fi / LAN"), ("local", "This machine only"),
629
+ ("other", "Other / unverified")):
630
+ addresses.extend(f"{url} ({label})" for url in choices[key])
631
+ print(" Address: " + (addresses[0] if addresses else "Not configured"))
632
+ for address in addresses[1:]:
633
+ print(" " + address)
634
+ print(f" Version: {item['version'] or 'unknown'}")
635
+ print(f" Port: {item['port'] or 'Not configured'}")
636
+ print()
637
+
638
+
639
+ def read_token_menu_key(fd: int) -> str:
640
+ key = os.read(fd, 1)
641
+ if key in (b"", b"\x03", b"\x04"):
642
+ return "cancel"
643
+ if key in (b"\r", b"\n"):
644
+ return "enter"
645
+ if key != b"\x1b":
646
+ return "other"
647
+ sequence = b""
648
+ while len(sequence) < 16 and select.select([fd], [], [], 0.15)[0]:
649
+ part = os.read(fd, 1)
650
+ if not part:
651
+ return "cancel"
652
+ sequence += part
653
+ if sequence in (b"[A", b"OA"):
654
+ return "up"
655
+ if sequence in (b"[B", b"OB"):
656
+ return "down"
657
+ if sequence[:1] not in (b"[", b"O") or (len(sequence) > 1 and 0x40 <= part[0] <= 0x7e):
658
+ return "other"
659
+ return "cancel" if not sequence else "other"
660
+
661
+
662
+ def arrow_token_choice(rows: list[str], fd: int, settings: list) -> int | None:
663
+ """Render a bounded terminal menu and restore input mode before token output."""
664
+ size = shutil.get_terminal_size(fallback=(80, 24))
665
+ visible = min(len(rows), max(1, size.lines - 6), 10)
666
+ width = max(1, size.columns - 1)
667
+ selected, start = 0, 0
668
+ hint = "Use ↑/↓ to choose; Enter to select; Esc to cancel."
669
+
670
+ def render(redraw=False):
671
+ if redraw:
672
+ sys.stdout.write(f"\x1b[{visible + 1}A")
673
+ for index in range(start, start + visible):
674
+ row = (" > " if index == selected else " ") + rows[index]
675
+ row = row[:width]
676
+ if index == selected:
677
+ row = terminal_color(row, "1;36")
678
+ sys.stdout.write("\r\x1b[2K" + row + "\n")
679
+ sys.stdout.write("\r\x1b[2K" + hint[:width] + "\n")
680
+ sys.stdout.flush()
681
+
682
+ try:
683
+ tty.setcbreak(fd, termios.TCSANOW)
684
+ sys.stdout.write("\x1b[?25l")
685
+ render()
686
+ while True:
687
+ key = read_token_menu_key(fd)
688
+ if key == "cancel":
689
+ return None
690
+ if key == "enter":
691
+ return selected
692
+ if key not in ("up", "down"):
693
+ continue
694
+ selected = (selected + (1 if key == "down" else -1)) % len(rows)
695
+ start = min(start, selected) if selected < start else max(start, selected - visible + 1)
696
+ render(redraw=True)
697
+ except KeyboardInterrupt:
698
+ return None
699
+ finally:
700
+ termios.tcsetattr(fd, termios.TCSANOW, settings)
701
+ sys.stdout.write("\x1b[?25h\n")
702
+ sys.stdout.flush()
703
+
704
+
705
+ def choose_token_instance(registry: Registry) -> Instance | None:
706
+ """Select by stable instance name; never default or expose unselected tokens."""
707
+ items = registry.instances()
708
+ if not items:
709
+ print("No installations found. Run agentsdock setup to create your first server.")
710
+ return None
711
+ if not (sys.stdin.isatty() and sys.stdout.isatty()):
712
+ raise ValueError("Choose a server in an interactive terminal, or run agentsdock token NAME "
713
+ "(for example: agentsdock token default). No token was shown.")
714
+ print("\nChoose a server to view its access token:\n")
715
+ rows = []
716
+ for index, instance in enumerate(items, 1):
717
+ config = read_config(instance)
718
+ port = config.get("AGENTSDOCK_AGENT_PORT") or "not configured"
719
+ rows.append(f"{instance.name:<20} {service_status(instance):<10} Port: {port}")
720
+ if os.environ.get("TERM", "dumb") not in ("", "dumb"):
721
+ try:
722
+ fd = sys.stdin.fileno()
723
+ settings = termios.tcgetattr(fd)
724
+ except (OSError, ValueError, termios.error):
725
+ pass # Basic terminals retain the explicit numbered/name prompt.
726
+ else:
727
+ index = arrow_token_choice(rows, fd, settings)
728
+ if index is None:
729
+ print("Cancelled; no token was shown.")
730
+ return None
731
+ selected = items[index]
732
+ if selected.name not in {item.name for item in registry.instances()}:
733
+ raise ValueError("That server is no longer available. Run agentsdock token again. No token was shown.")
734
+ return selected
735
+ for index, row in enumerate(rows, 1):
736
+ print(f" {index}. {row}")
737
+ print()
738
+ names = {item.name: item for item in items}
739
+ while True:
740
+ try:
741
+ answer = input("Enter a number or server name (Enter to cancel): ").strip()
742
+ except (EOFError, KeyboardInterrupt):
743
+ print()
744
+ answer = ""
745
+ if not answer:
746
+ print("Cancelled; no token was shown.")
747
+ return None
748
+ selected = names.get(answer)
749
+ if re.fullmatch(r"[1-9][0-9]{0,5}", answer) and int(answer) <= len(items):
750
+ selected = items[int(answer) - 1]
751
+ if selected is not None:
752
+ if selected.name not in {item.name for item in registry.instances()}:
753
+ raise ValueError("That server is no longer available. Run agentsdock token again. No token was shown.")
754
+ return selected
755
+ print(f"Please enter a number from 1 to {len(items)} or a listed server name (Enter cancels).")
756
+
757
+
614
758
  def terminal_color(text: str, code: str) -> str:
615
759
  color = sys.stdout.isatty() and os.environ.get("TERM") != "dumb" and "NO_COLOR" not in os.environ
616
760
  return f"\033[{code}m{text}\033[0m" if color else text
@@ -747,8 +891,11 @@ def parser() -> argparse.ArgumentParser:
747
891
  result = argparse.ArgumentParser(description="Independent AgentsServer instances for the current OS user. Defaults to list.")
748
892
  commands = result.add_subparsers(dest="command")
749
893
  commands.add_parser("list")
894
+ commands.add_parser("token", help="Choose an existing server and display only its access token")
750
895
  info = commands.add_parser("info")
751
896
  info.add_argument("name", type=instance_name)
897
+ status = commands.add_parser("status", help="Show readable details for all instances or one name")
898
+ status.add_argument("name", nargs="?", type=instance_name)
752
899
  new = commands.add_parser("new")
753
900
  new.add_argument("--name", type=instance_name)
754
901
  new.add_argument("--port", type=int)
@@ -785,13 +932,30 @@ def main(argv: list[str] | None = None) -> int:
785
932
  validate_binding(instance)
786
933
  print(instance.shell_bindings())
787
934
  return 0
788
- if args.command in {None, "list", "info"}:
935
+ if args.command == "token":
936
+ selected = choose_token_instance(registry)
937
+ if selected is None:
938
+ return 0
939
+ try:
940
+ run(["/bin/bash", str(ROOT / "install.sh"), "--instance", selected.name, "--show-token"],
941
+ env=clean_environment(selected), cwd=ROOT)
942
+ return 0
943
+ except subprocess.CalledProcessError as exc:
944
+ return exc.returncode if exc.returncode > 0 else 1
945
+ if args.command in {None, "list", "info", "status"}:
789
946
  items = registry.instances()
790
- if args.command == "info":
947
+ if args.command in {"info", "status"} and args.name:
791
948
  items = [item for item in items if item.name == args.name]
792
949
  if not items:
793
950
  raise ValueError("Unknown instance.")
951
+ if args.command == "info":
794
952
  print(json.dumps(describe(items[0]), indent=2))
953
+ elif args.command == "status":
954
+ network = tailscale_status() if items else None
955
+ for item in items:
956
+ show_status(item, network=network)
957
+ if not items:
958
+ print("No installations found. Run agentsdock setup to create your first server.")
795
959
  else:
796
960
  network = tailscale_status() if items else None
797
961
  print("NAME STATUS PORT")