contree-cli 0.7.0__tar.gz → 0.8.0__tar.gz

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.
Files changed (67) hide show
  1. {contree_cli-0.7.0 → contree_cli-0.8.0}/PKG-INFO +1 -1
  2. {contree_cli-0.7.0 → contree_cli-0.8.0}/contree_cli/cli/file.py +22 -24
  3. {contree_cli-0.7.0 → contree_cli-0.8.0}/contree_cli/cli/images.py +13 -15
  4. {contree_cli-0.7.0 → contree_cli-0.8.0}/contree_cli/cli/operation.py +30 -19
  5. {contree_cli-0.7.0 → contree_cli-0.8.0}/contree_cli/cli/run.py +264 -15
  6. {contree_cli-0.7.0 → contree_cli-0.8.0}/contree_cli/cli/show.py +1 -1
  7. {contree_cli-0.7.0 → contree_cli-0.8.0}/contree_cli/client.py +226 -38
  8. {contree_cli-0.7.0 → contree_cli-0.8.0}/contree_cli/docker/kw_run.py +30 -18
  9. {contree_cli-0.7.0 → contree_cli-0.8.0}/contree_cli/docker/url_fetch.py +13 -48
  10. {contree_cli-0.7.0 → contree_cli-0.8.0}/pyproject.toml +2 -1
  11. {contree_cli-0.7.0 → contree_cli-0.8.0}/LICENSE +0 -0
  12. {contree_cli-0.7.0 → contree_cli-0.8.0}/README.md +0 -0
  13. {contree_cli-0.7.0 → contree_cli-0.8.0}/contree_cli/__init__.py +0 -0
  14. {contree_cli-0.7.0 → contree_cli-0.8.0}/contree_cli/__main__.py +0 -0
  15. {contree_cli-0.7.0 → contree_cli-0.8.0}/contree_cli/agent.md +0 -0
  16. {contree_cli-0.7.0 → contree_cli-0.8.0}/contree_cli/arguments.py +0 -0
  17. {contree_cli-0.7.0 → contree_cli-0.8.0}/contree_cli/cli/__init__.py +0 -0
  18. {contree_cli-0.7.0 → contree_cli-0.8.0}/contree_cli/cli/agent.py +0 -0
  19. {contree_cli-0.7.0 → contree_cli-0.8.0}/contree_cli/cli/auth.py +0 -0
  20. {contree_cli-0.7.0 → contree_cli-0.8.0}/contree_cli/cli/build.py +0 -0
  21. {contree_cli-0.7.0 → contree_cli-0.8.0}/contree_cli/cli/cat.py +0 -0
  22. {contree_cli-0.7.0 → contree_cli-0.8.0}/contree_cli/cli/cd.py +0 -0
  23. {contree_cli-0.7.0 → contree_cli-0.8.0}/contree_cli/cli/cp.py +0 -0
  24. {contree_cli-0.7.0 → contree_cli-0.8.0}/contree_cli/cli/env.py +0 -0
  25. {contree_cli-0.7.0 → contree_cli-0.8.0}/contree_cli/cli/ls.py +0 -0
  26. {contree_cli-0.7.0 → contree_cli-0.8.0}/contree_cli/cli/session.py +0 -0
  27. {contree_cli-0.7.0 → contree_cli-0.8.0}/contree_cli/cli/skill.py +0 -0
  28. {contree_cli-0.7.0 → contree_cli-0.8.0}/contree_cli/cli/tag.py +0 -0
  29. {contree_cli-0.7.0 → contree_cli-0.8.0}/contree_cli/cli/use.py +0 -0
  30. {contree_cli-0.7.0 → contree_cli-0.8.0}/contree_cli/config.py +0 -0
  31. {contree_cli-0.7.0 → contree_cli-0.8.0}/contree_cli/docker/__init__.py +0 -0
  32. {contree_cli-0.7.0 → contree_cli-0.8.0}/contree_cli/docker/context.py +0 -0
  33. {contree_cli-0.7.0 → contree_cli-0.8.0}/contree_cli/docker/dockerignore.py +0 -0
  34. {contree_cli-0.7.0 → contree_cli-0.8.0}/contree_cli/docker/keyword.py +0 -0
  35. {contree_cli-0.7.0 → contree_cli-0.8.0}/contree_cli/docker/kw_add.py +0 -0
  36. {contree_cli-0.7.0 → contree_cli-0.8.0}/contree_cli/docker/kw_arg.py +0 -0
  37. {contree_cli-0.7.0 → contree_cli-0.8.0}/contree_cli/docker/kw_copy.py +0 -0
  38. {contree_cli-0.7.0 → contree_cli-0.8.0}/contree_cli/docker/kw_env.py +0 -0
  39. {contree_cli-0.7.0 → contree_cli-0.8.0}/contree_cli/docker/kw_from.py +0 -0
  40. {contree_cli-0.7.0 → contree_cli-0.8.0}/contree_cli/docker/kw_skipped.py +0 -0
  41. {contree_cli-0.7.0 → contree_cli-0.8.0}/contree_cli/docker/kw_user.py +0 -0
  42. {contree_cli-0.7.0 → contree_cli-0.8.0}/contree_cli/docker/kw_workdir.py +0 -0
  43. {contree_cli-0.7.0 → contree_cli-0.8.0}/contree_cli/docker/local_context.py +0 -0
  44. {contree_cli-0.7.0 → contree_cli-0.8.0}/contree_cli/docker/parser.py +0 -0
  45. {contree_cli-0.7.0 → contree_cli-0.8.0}/contree_cli/log.py +0 -0
  46. {contree_cli-0.7.0 → contree_cli-0.8.0}/contree_cli/man.py +0 -0
  47. {contree_cli-0.7.0 → contree_cli-0.8.0}/contree_cli/manual.md +0 -0
  48. {contree_cli-0.7.0 → contree_cli-0.8.0}/contree_cli/mapped_file.py +0 -0
  49. {contree_cli-0.7.0 → contree_cli-0.8.0}/contree_cli/migrations.py +0 -0
  50. {contree_cli-0.7.0 → contree_cli-0.8.0}/contree_cli/output.py +0 -0
  51. {contree_cli-0.7.0 → contree_cli-0.8.0}/contree_cli/py.typed +0 -0
  52. {contree_cli-0.7.0 → contree_cli-0.8.0}/contree_cli/refs.py +0 -0
  53. {contree_cli-0.7.0 → contree_cli-0.8.0}/contree_cli/session.py +0 -0
  54. {contree_cli-0.7.0 → contree_cli-0.8.0}/contree_cli/shell/__init__.py +0 -0
  55. {contree_cli-0.7.0 → contree_cli-0.8.0}/contree_cli/shell/argmap.py +0 -0
  56. {contree_cli-0.7.0 → contree_cli-0.8.0}/contree_cli/shell/argspec.py +0 -0
  57. {contree_cli-0.7.0 → contree_cli-0.8.0}/contree_cli/shell/cache.py +0 -0
  58. {contree_cli-0.7.0 → contree_cli-0.8.0}/contree_cli/shell/completer.py +0 -0
  59. {contree_cli-0.7.0 → contree_cli-0.8.0}/contree_cli/shell/history.py +0 -0
  60. {contree_cli-0.7.0 → contree_cli-0.8.0}/contree_cli/shell/parser.py +0 -0
  61. {contree_cli-0.7.0 → contree_cli-0.8.0}/contree_cli/shell/repl.py +0 -0
  62. {contree_cli-0.7.0 → contree_cli-0.8.0}/contree_cli/shell/sources.py +0 -0
  63. {contree_cli-0.7.0 → contree_cli-0.8.0}/contree_cli/shell/trie.py +0 -0
  64. {contree_cli-0.7.0 → contree_cli-0.8.0}/contree_cli/skill.py +0 -0
  65. {contree_cli-0.7.0 → contree_cli-0.8.0}/contree_cli/skill_body.md +0 -0
  66. {contree_cli-0.7.0 → contree_cli-0.8.0}/contree_cli/types.py +0 -0
  67. {contree_cli-0.7.0 → contree_cli-0.8.0}/contree_cli/update_check.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: contree-cli
3
- Version: 0.7.0
3
+ Version: 0.8.0
4
4
  Summary: CLI client for the ConTree sandbox platform
5
5
  Keywords: sandbox,cli,vm,ai-agent,contree
6
6
  Author: Dmitry Orlov
@@ -328,37 +328,35 @@ def cmd_file_ls(args: FileListArgs) -> int | None:
328
328
  if args.until is not None:
329
329
  params["until"] = isoformat_datetime(args.until)
330
330
 
331
- fetcher = PaginatedFetcher(
331
+ emitted = 0
332
+ hit_limit = False
333
+ with PaginatedFetcher(
332
334
  client,
333
335
  "/v1/files",
334
336
  params,
335
337
  lambda body: json.loads(body).get("files", []),
336
338
  limit=args.limit,
337
339
  concurrency=CONTREE_CONCURRENCY,
338
- )
339
-
340
- emitted = 0
341
- hit_limit = False
342
- for page in fetcher:
343
- for entry in page:
344
- if emitted >= args.limit:
345
- hit_limit = True
340
+ ) as fetcher:
341
+ for page in fetcher:
342
+ for entry in page:
343
+ if emitted >= args.limit:
344
+ hit_limit = True
345
+ break
346
+ uuid_str = entry.get("uuid")
347
+ source = sources.get(uuid_str, "") if isinstance(uuid_str, str) else ""
348
+ if args.quiet:
349
+ formatter(
350
+ uuid=uuid_str,
351
+ sha256=entry.get("sha256", ""),
352
+ source=source,
353
+ )
354
+ else:
355
+ formatter(**{**entry, "source": source})
356
+ emitted += 1
357
+ formatter.flush()
358
+ if hit_limit:
346
359
  break
347
- uuid_str = entry.get("uuid")
348
- source = sources.get(uuid_str, "") if isinstance(uuid_str, str) else ""
349
- if args.quiet:
350
- formatter(
351
- uuid=uuid_str,
352
- sha256=entry.get("sha256", ""),
353
- source=source,
354
- )
355
- else:
356
- formatter(**{**entry, "source": source})
357
- emitted += 1
358
- formatter.flush()
359
- if hit_limit:
360
- fetcher.stop()
361
- break
362
360
 
363
361
  if hit_limit:
364
362
  logger.warning(
@@ -272,28 +272,26 @@ def cmd_images(args: ImagesArgs) -> None:
272
272
  if args.until is not None:
273
273
  base_params["until"] = isoformat_datetime(args.until)
274
274
 
275
- fetcher = PaginatedFetcher(
275
+ emitted = 0
276
+ hit_limit = False
277
+ with PaginatedFetcher(
276
278
  client,
277
279
  "/v1/images",
278
280
  base_params,
279
281
  lambda body: json.loads(body)["images"],
280
282
  limit=args.limit,
281
283
  concurrency=CONTREE_CONCURRENCY,
282
- )
283
-
284
- emitted = 0
285
- hit_limit = False
286
- for page in fetcher:
287
- for image in page:
288
- if emitted >= args.limit:
289
- hit_limit = True
284
+ ) as fetcher:
285
+ for page in fetcher:
286
+ for image in page:
287
+ if emitted >= args.limit:
288
+ hit_limit = True
289
+ break
290
+ formatter(**image)
291
+ emitted += 1
292
+ formatter.flush()
293
+ if hit_limit:
290
294
  break
291
- formatter(**image)
292
- emitted += 1
293
- formatter.flush()
294
- if hit_limit:
295
- fetcher.stop()
296
- break
297
295
 
298
296
  if hit_limit:
299
297
  logger.warning(
@@ -13,6 +13,7 @@ Subcommands:
13
13
  from __future__ import annotations
14
14
 
15
15
  import argparse
16
+ import contextlib
16
17
  import itertools
17
18
  import json
18
19
  import logging
@@ -21,7 +22,7 @@ from dataclasses import dataclass, field
21
22
  from datetime import datetime
22
23
  from typing import Any
23
24
 
24
- from contree_cli import CLIENT, FORMATTER, ArgumentsProtocol, SetupResult
25
+ from contree_cli import CLIENT, FORMATTER, SESSION_STORE, ArgumentsProtocol, SetupResult
25
26
  from contree_cli.cli.show import ShowArgs, cmd_show
26
27
  from contree_cli.client import ApiError, ContreeClient, PaginatedFetcher
27
28
  from contree_cli.output import OutputFormatter
@@ -393,31 +394,29 @@ def cmd_list(args: ListArgs) -> None:
393
394
  base_params["until"] = isoformat_datetime(args.until)
394
395
 
395
396
  limit = args.show_max
396
- fetcher = PaginatedFetcher(
397
+ emitted = 0
398
+ hit_limit = False
399
+ with PaginatedFetcher(
397
400
  client,
398
401
  "/v1/operations",
399
402
  base_params,
400
403
  json.loads,
401
404
  limit=limit,
402
405
  concurrency=CONTREE_CONCURRENCY,
403
- )
404
-
405
- emitted = 0
406
- hit_limit = False
407
- for page in fetcher:
408
- for op in page:
409
- if limit is not None and emitted >= limit:
410
- hit_limit = True
406
+ ) as fetcher:
407
+ for page in fetcher:
408
+ for op in page:
409
+ if limit is not None and emitted >= limit:
410
+ hit_limit = True
411
+ break
412
+ if args.quiet:
413
+ print(op["uuid"])
414
+ else:
415
+ formatter(**op)
416
+ emitted += 1
417
+ formatter.flush()
418
+ if hit_limit:
411
419
  break
412
- if args.quiet:
413
- print(op["uuid"])
414
- else:
415
- formatter(**op)
416
- emitted += 1
417
- formatter.flush()
418
- if hit_limit:
419
- fetcher.stop()
420
- break
421
420
 
422
421
  if hit_limit:
423
422
  logger.warning(
@@ -498,11 +497,23 @@ def cmd_wait(args: WaitArgs) -> int | None:
498
497
  exit_status = 0
499
498
  sleep_time = 0.5
500
499
 
500
+ # Op cache is best-effort — `wait` doesn't strictly need the
501
+ # session store, and the cmd is reachable from contexts that don't
502
+ # set one (e.g. standalone scripts).
503
+ try:
504
+ store = SESSION_STORE.get()
505
+ except LookupError:
506
+ store = None
507
+
501
508
  while pending and time.monotonic() < deadline:
502
509
  for uuid in list(pending):
503
510
  resp = client.get(f"/v1/operations/{uuid}")
504
511
  op = json.loads(resp.read())
512
+
505
513
  if op.get("status") in TERMINAL_STATUSES:
514
+ if store is not None:
515
+ with contextlib.suppress(Exception):
516
+ store.cache[(uuid, "operation")] = op
506
517
  pending.discard(uuid)
507
518
  exit_code = extract_exit_code(op)
508
519
  formatter(
@@ -48,6 +48,7 @@ from __future__ import annotations
48
48
 
49
49
  import argparse
50
50
  import base64
51
+ import contextlib
51
52
  import fnmatch
52
53
  import functools
53
54
  import io
@@ -62,9 +63,18 @@ import time
62
63
  import uuid
63
64
  from dataclasses import dataclass, field
64
65
  from multiprocessing.pool import ThreadPool
66
+ from typing import Any
65
67
 
66
68
  from contree_cli import CLIENT, FORMATTER, SESSION_STORE, ArgumentsProtocol, SetupResult
67
- from contree_cli.client import ApiError, ContreeClient, decode_stream, resolve_image
69
+ from contree_cli.client import (
70
+ RETRYABLE_NETWORK_ERRORS,
71
+ ApiError,
72
+ ContreeClient,
73
+ decode_event_chunk,
74
+ decode_stream,
75
+ iter_sse_events,
76
+ resolve_image,
77
+ )
68
78
  from contree_cli.mapped_file import MAPPING_RULES, MappedFile
69
79
  from contree_cli.output import (
70
80
  DefaultFormatter,
@@ -505,11 +515,227 @@ def _build_payload(
505
515
  return payload
506
516
 
507
517
 
518
+ TERMINAL_OP_STATUSES = frozenset({"SUCCESS", "FAILED", "CANCELLED"})
519
+
520
+
521
+ @dataclass
522
+ class TerminalSummary:
523
+ """Authoritative end-of-stream snapshot built from the SSE events
524
+ themselves — `cmd_run` consumes this directly instead of doing a
525
+ second ``GET /operations/{uuid}``.
526
+
527
+ If SSE ends without a `completion` frame but a between-attempt GET
528
+ detects the op is already terminal, the full op dict lands in
529
+ ``fallback_op`` so `cmd_run` can use it directly."""
530
+
531
+ completion: dict[str, Any] | None = None
532
+ exit_event: dict[str, Any] | None = None
533
+ stdout: bytearray = field(default_factory=bytearray)
534
+ stderr: bytearray = field(default_factory=bytearray)
535
+ fallback_op: dict[str, Any] | None = None
536
+
537
+
538
+ def _check_terminal_via_get(
539
+ client: ContreeClient, op_uuid: str, summary: TerminalSummary
540
+ ) -> bool:
541
+ """Poll `GET /v1/operations/{uuid}`; if the op is in a terminal
542
+ status, park the full response on ``summary.fallback_op`` and
543
+ return True. Any GET failure or non-terminal status returns False
544
+ so the caller keeps retrying SSE."""
545
+ try:
546
+ resp = client.request("GET", f"/v1/operations/{op_uuid}")
547
+ op = json.loads(resp.read())
548
+ except (ApiError, ValueError) as exc:
549
+ logger.debug("terminal check GET failed: %s", exc)
550
+ return False
551
+ except RETRYABLE_NETWORK_ERRORS as exc:
552
+ logger.debug("terminal check GET failed: %s", exc)
553
+ return False
554
+ if isinstance(op, dict) and op.get("status") in TERMINAL_OP_STATUSES:
555
+ summary.fallback_op = op
556
+ return True
557
+ return False
558
+
559
+
560
+ TIGHT_LOOP_FLOOR = 0.5
561
+
562
+
563
+ def _stream_events_until_close(
564
+ client: ContreeClient,
565
+ op_uuid: str,
566
+ formatter: OutputFormatter,
567
+ ) -> TerminalSummary:
568
+ """Open `follow=1` SSE for *op_uuid* and write events to stdio,
569
+ transparently resuming on network drops / mid-stream errors using
570
+ ``Last-Event-Id`` for replay-free continuation.
571
+
572
+ For ``DefaultFormatter``: ``stdout`` / ``stderr`` events go to
573
+ ``sys.std*.buffer`` directly so the user sees output as it arrives.
574
+ For JSON formatters: data goes to ``log.debug`` so the JSON output
575
+ isn't polluted, but the chunks are accumulated into the returned
576
+ ``TerminalSummary`` so the caller can render full stdout/stderr
577
+ without a follow-up GET.
578
+
579
+ Loops until one of: `completion` event received, GET-detected
580
+ terminal status, ``BrokenPipeError`` from a local stdio write, or
581
+ ``KeyboardInterrupt``. Backoff between attempts is delegated to
582
+ ``client.request`` — every SSE reconnect goes through its
583
+ ``RETRY_DELAYS`` ladder before raising, so an extra streamer-level
584
+ ramp would double up. The one floor kept here (``TIGHT_LOOP_FLOOR``)
585
+ only kicks in when a cycle made no forward progress, guarding
586
+ against a server that returns immediate empty streams for an
587
+ executing op.
588
+
589
+ ``BrokenPipeError`` from a local stdio write propagates unchanged —
590
+ it means the shell pipe closed and retrying cannot help; the
591
+ caller cancels the op and exits.
592
+ """
593
+ is_default = isinstance(formatter, DefaultFormatter)
594
+ last_id: int = -1
595
+ summary = TerminalSummary()
596
+
597
+ while True:
598
+ headers: dict[str, str] | None = None
599
+ if last_id >= 0:
600
+ headers = {"Last-Event-Id": str(last_id)}
601
+ try:
602
+ resp = client.request(
603
+ "GET",
604
+ f"/v1/operations/{op_uuid}/events?follow=1",
605
+ headers=headers,
606
+ )
607
+ except ApiError as exc:
608
+ logger.debug("SSE connect failed after client retries: %s", exc)
609
+ if _check_terminal_via_get(client, op_uuid, summary):
610
+ return summary
611
+ continue
612
+ except RETRYABLE_NETWORK_ERRORS as exc:
613
+ logger.debug("SSE connect network error after client retries: %s", exc)
614
+ if _check_terminal_via_get(client, op_uuid, summary):
615
+ return summary
616
+ continue
617
+
618
+ events_before = last_id
619
+ try:
620
+ for ev in iter_sse_events(resp):
621
+ ev_id = ev.get("id")
622
+ if isinstance(ev_id, int):
623
+ last_id = ev_id
624
+ ev_type = ev.get("type")
625
+ data = ev.get("data")
626
+ match ev_type:
627
+ case "stdout":
628
+ chunk = decode_event_chunk(data)
629
+ summary.stdout.extend(chunk)
630
+ if is_default:
631
+ sys.stdout.buffer.write(chunk)
632
+ sys.stdout.buffer.flush()
633
+ case "stderr":
634
+ chunk = decode_event_chunk(data)
635
+ summary.stderr.extend(chunk)
636
+ if is_default:
637
+ sys.stderr.buffer.write(chunk)
638
+ sys.stderr.buffer.flush()
639
+ case "exit":
640
+ # spid=1 is the main process — its exit code/timed_out
641
+ # drive the CLI's own exit code.
642
+ if ev.get("spid") == 1:
643
+ summary.exit_event = ev
644
+ logger.debug("event: %s", ev)
645
+ case "sse_error":
646
+ logger.warning(
647
+ "server-side stream error (last_id=%s): %s",
648
+ last_id,
649
+ ev.get("message"),
650
+ )
651
+ case "completion":
652
+ # Authoritative terminal frame — don't wait for the server
653
+ # to close, return the summary for the caller.
654
+ summary.completion = ev
655
+ logger.debug("event: %s", ev)
656
+ return summary
657
+ case _:
658
+ logger.debug("event: %s", ev)
659
+ except BrokenPipeError:
660
+ # Local stdout/stderr was closed by the shell (e.g. piping
661
+ # into `head`). Retrying cannot help — the caller cancels
662
+ # the op and exits.
663
+ raise
664
+ except RETRYABLE_NETWORK_ERRORS as exc:
665
+ logger.debug("SSE stream broken (last_id=%s): %s", last_id, exc)
666
+ finally:
667
+ with contextlib.suppress(Exception):
668
+ resp.close()
669
+
670
+ if _check_terminal_via_get(client, op_uuid, summary):
671
+ return summary
672
+
673
+ # No forward progress this cycle → briefly floor the loop so a
674
+ # server that keeps returning immediate EOFs doesn't spin us.
675
+ if last_id == events_before:
676
+ time.sleep(TIGHT_LOOP_FLOOR)
677
+
678
+
679
+ def _build_op_from_summary(op_uuid: str, summary: TerminalSummary) -> dict[str, Any]:
680
+ """Synthesize a `GET /operations/{uuid}` shape from the SSE terminal
681
+ summary — completion event drives status / error / duration / image
682
+ metadata; exit event drives exit_code + timed_out; stdout/stderr
683
+ are reassembled from the streamed chunks.
684
+
685
+ Lets `cmd_run` avoid a second HTTP round-trip on the happy path
686
+ while keeping the downstream `_display_operation` consumers
687
+ (default and JSON formatters) untouched."""
688
+ assert summary.completion is not None
689
+ completion_data = summary.completion.get("data") or {}
690
+ status = completion_data.get("status")
691
+ state: dict[str, Any] = {}
692
+ if summary.exit_event:
693
+ exit_data = summary.exit_event.get("data") or {}
694
+ if "code" in exit_data:
695
+ state["exit_code"] = int(exit_data["code"])
696
+ if "timed_out" in exit_data:
697
+ state["timed_out"] = bool(exit_data["timed_out"])
698
+ result_image_uuid = completion_data.get("result_image_uuid")
699
+ return {
700
+ "uuid": op_uuid,
701
+ "kind": "instance",
702
+ "status": status,
703
+ "error": completion_data.get("error"),
704
+ "duration": (completion_data.get("duration_ms") or 0) / 1000.0,
705
+ "image_size": completion_data.get("image_size_bytes"),
706
+ "result_image_uuid": result_image_uuid,
707
+ "metadata": {
708
+ "result": {
709
+ "stdout": {
710
+ "value": bytes(summary.stdout).decode("utf-8", errors="replace"),
711
+ "encoding": "ascii",
712
+ "truncated": False,
713
+ },
714
+ "stderr": {
715
+ "value": bytes(summary.stderr).decode("utf-8", errors="replace"),
716
+ "encoding": "ascii",
717
+ "truncated": False,
718
+ },
719
+ "state": state or None,
720
+ }
721
+ },
722
+ "result": {"image": result_image_uuid, "tag": None},
723
+ }
724
+
725
+
508
726
  def _display_operation(
509
727
  op: dict[str, object],
510
728
  formatter: OutputFormatter,
729
+ live_streamed: bool = False,
511
730
  ) -> None:
512
- """Display an operation result using the given formatter."""
731
+ """Display an operation result using the given formatter.
732
+
733
+ ``live_streamed=True`` means the SSE path already wrote stdout/
734
+ stderr to stdio incrementally — DefaultFormatter then has nothing
735
+ left to print. ``live_streamed=False`` is the GET-fallback path
736
+ (legacy server or aborted stream) — DefaultFormatter prints
737
+ sanitized stdout/stderr from the operation's metadata blob.
738
+ """
513
739
  result = op.get("result") or {}
514
740
  assert isinstance(result, dict)
515
741
  metadata = op.get("metadata") or {}
@@ -544,10 +770,14 @@ def _display_operation(
544
770
  "only json/json-pretty/default are supported"
545
771
  )
546
772
 
547
- # For DefaultFormatter, just only print stdout/stderr
773
+ if live_streamed:
774
+ # stdout/stderr already streamed live via SSE — nothing more to print.
775
+ return
776
+
777
+ # Legacy / fallback path (no completion event received): print
778
+ # sanitized stdout/stderr from op metadata.
548
779
  stdout = _BREAKING_ESC_RE.sub("", decode_stream(instance_result.get("stdout")))
549
780
  stderr = _BREAKING_ESC_RE.sub("", decode_stream(instance_result.get("stderr")))
550
-
551
781
  if stdout:
552
782
  sys.stdout.write(stdout)
553
783
  if not stdout.endswith("\n"):
@@ -688,19 +918,27 @@ def cmd_run(args: RunArgs) -> int | None:
688
918
  formatter.flush()
689
919
  return None
690
920
 
691
- # 5. Poll until terminal status
692
- sleep_time = 0.5
921
+ # 5. Stream events (follow=1) — write stdout/stderr to stdtio as they
922
+ # come, log other events at debug, accumulate the terminal frame.
923
+ store = SESSION_STORE.get()
693
924
  try:
694
- time.sleep(sleep_time)
695
- sleep_time += sleep_time
696
- while True:
925
+ summary = _stream_events_until_close(client, op_uuid, formatter)
926
+ if summary.completion is not None:
927
+ # Authoritative terminal frame from the server — build the
928
+ # full op dict from the SSE events themselves, no GET.
929
+ op = _build_op_from_summary(op_uuid, summary)
930
+ elif summary.fallback_op is not None:
931
+ # SSE couldn't deliver `completion`, but a GET between
932
+ # retries confirmed the op is terminal — use that dict.
933
+ op = summary.fallback_op
934
+ else:
935
+ # Safety net: streamer normally loops until either
936
+ # completion or a terminal GET, so this path is only hit
937
+ # in tests where the stub queue drains early.
697
938
  resp = client.get(f"/v1/operations/{op_uuid}")
698
939
  op = json.loads(resp.read())
699
- if op["status"] in TERMINAL_STATUSES:
700
- break
701
- time.sleep(sleep_time)
702
- if sleep_time < 5:
703
- sleep_time += sleep_time
940
+ cache_key = (op_uuid, "operation")
941
+ store.cache[cache_key] = op
704
942
  except KeyboardInterrupt:
705
943
  try:
706
944
  client.delete(f"/v1/operations/{op_uuid}")
@@ -708,6 +946,17 @@ def cmd_run(args: RunArgs) -> int | None:
708
946
  except (ApiError, KeyboardInterrupt, OSError):
709
947
  pass
710
948
  raise
949
+ except BrokenPipeError:
950
+ # Local stdout/stderr was closed (e.g. `contree run | head`).
951
+ # Cancel the op, silence further stdio writes, then exit 141
952
+ # so callers see the SIGPIPE convention (128 + 13).
953
+ with contextlib.suppress(ApiError, OSError):
954
+ client.delete(f"/v1/operations/{op_uuid}")
955
+ with contextlib.suppress(OSError):
956
+ devnull = os.open(os.devnull, os.O_WRONLY)
957
+ os.dup2(devnull, sys.stdout.fileno())
958
+ os.close(devnull)
959
+ raise SystemExit(141) from None
711
960
 
712
961
  # 6. Cache terminal operation result
713
962
  store.cache[(op_uuid, "operation")] = op
@@ -735,7 +984,7 @@ def cmd_run(args: RunArgs) -> int | None:
735
984
  )
736
985
 
737
986
  # 7. Display result
738
- _display_operation(op, formatter)
987
+ _display_operation(op, formatter, live_streamed=summary.completion is not None)
739
988
 
740
989
  result = op.get("result") or {}
741
990
  assert isinstance(result, dict)
@@ -61,7 +61,7 @@ def cmd_show(args: ShowArgs) -> int | None:
61
61
 
62
62
  cache_key = (op_uuid, "operation")
63
63
  cached = store.cache.get(cache_key)
64
- if cached is not None:
64
+ if isinstance(cached, dict) and cached.get("status") in TERMINAL:
65
65
  op = cast(dict[str, Any], cached)
66
66
  else:
67
67
  resp = client.get(f"/v1/operations/{op_uuid}")