portacode 1.4.15.dev23__py3-none-any.whl → 1.4.16.dev3__py3-none-any.whl

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.
portacode/_version.py CHANGED
@@ -28,7 +28,7 @@ version_tuple: VERSION_TUPLE
28
28
  commit_id: COMMIT_ID
29
29
  __commit_id__: COMMIT_ID
30
30
 
31
- __version__ = version = '1.4.15.dev23'
32
- __version_tuple__ = version_tuple = (1, 4, 15, 'dev23')
31
+ __version__ = version = '1.4.16.dev3'
32
+ __version_tuple__ = version_tuple = (1, 4, 16, 'dev3')
33
33
 
34
34
  __commit_id__ = commit_id = None
@@ -381,6 +381,7 @@ Starts a previously provisioned, Portacode-managed LXC container. Handled by [`S
381
381
  **Payload Fields:**
382
382
 
383
383
  * `ctid` (string, required): Identifier of the container to start.
384
+ * `child_device_id` (string, required): Dashboard `Device.id` of the container that triggered the request; the handler validates the CT belongs to that device before issuing the start.
384
385
 
385
386
  **Responses:**
386
387
 
@@ -394,6 +395,7 @@ Stops a running Portacode-managed container. Handled by [`StopProxmoxContainerHa
394
395
  **Payload Fields:**
395
396
 
396
397
  * `ctid` (string, required): Identifier of the container to stop.
398
+ * `child_device_id` (string, required): Dashboard `Device.id` that owns the container; the handler rejects the request if the CT is mapped to another device.
397
399
 
398
400
  **Responses:**
399
401
 
@@ -407,6 +409,7 @@ Deletes a managed container from Proxmox (stopping it first if necessary) and re
407
409
  **Payload Fields:**
408
410
 
409
411
  * `ctid` (string, required): Identifier of the container to delete.
412
+ * `child_device_id` (string, required): Dashboard `Device.id` that should own the container metadata being purged.
410
413
 
411
414
  **Responses:**
412
415
 
@@ -1133,22 +1136,34 @@ Provides system information in response to a `system_info` action. Handled by [`
1133
1136
  * `bridge` (string): The bridge interface configured (typically `vmbr1`).
1134
1137
  * `health` (string|null): `"healthy"` when the connectivity verification succeeded.
1135
1138
  * `node_status` (object|null): Status response returned by the Proxmox API when validating the token.
1136
- * `managed_containers` (object): Cached summary of the Portacode-managed containers:
1137
- * `updated_at` (string): ISO timestamp when this snapshot was last refreshed.
1138
- * `count` (integer): Number of managed containers.
1139
- * `total_ram_mib` (integer): RAM footprint summed across all containers.
1140
- * `total_disk_gib` (integer): Disk footprint summed across all containers.
1141
- * `total_cpu_share` (number): CPU shares requested across all containers.
1142
- * `containers` (array[object]): Container summaries with the following fields:
1143
- * `vmid` (string|null): Numeric CT ID.
1144
- * `hostname` (string|null): Hostname configured in the CT.
1145
- * `template` (string|null): Template identifier used.
1146
- * `storage` (string|null): Storage pool backing the rootfs.
1147
- * `disk_gib` (integer): Rootfs size in GiB.
1148
- * `ram_mib` (integer): Memory size in MiB.
1149
- * `cpu_share` (number): vCPU-equivalent share requested at creation.
1150
- * `status` (string): Lowercase lifecycle status (e.g., `running`, `stopped`, `deleted`).
1151
- * `created_at` (string|null): ISO timestamp recorded when the CT was provisioned.
1139
+ * `managed_containers` (object): Cached summary of the Portacode-managed containers:
1140
+ * `updated_at` (string): ISO timestamp when this snapshot was last refreshed.
1141
+ * `count` (integer): Number of managed containers.
1142
+ * `total_ram_mib` (integer): RAM footprint summed across all containers.
1143
+ * `total_disk_gib` (integer): Disk footprint summed across all containers.
1144
+ * `total_cpu_share` (number): CPU shares requested across all containers.
1145
+ * `containers` (array[object]): Container summaries with the following fields:
1146
+ * `vmid` (string|null): Numeric CT ID.
1147
+ * `hostname` (string|null): Hostname configured in the CT.
1148
+ * `template` (string|null): Template identifier used.
1149
+ * `storage` (string|null): Storage pool backing the rootfs.
1150
+ * `disk_gib` (integer): Rootfs size in GiB.
1151
+ * `ram_mib` (integer): Memory size in MiB.
1152
+ * `cpu_share` (number): vCPU-equivalent share requested at creation.
1153
+ * `status` (string): Lowercase lifecycle status (e.g., `running`, `stopped`, `deleted`).
1154
+ * `created_at` (string|null): ISO timestamp recorded when the CT was provisioned.
1155
+ * `managed` (boolean): `true` for Portacode-managed entries.
1156
+ * `unmanaged_containers` (array[object]): Facts about containers Portacode did not provision; fields mirror the managed list but are marked `managed=false`.
1157
+ * `unmanaged_count` (integer): Number of unmanaged containers detected on the node.
1158
+ * `allocated_ram_mib` (integer): Total RAM reserved by both managed and unmanaged containers.
1159
+ * `allocated_disk_gib` (integer): Total disk reserved by both managed and unmanaged containers.
1160
+ * `allocated_cpu_share` (number): Total CPU shares requested by both managed and unmanaged containers.
1161
+ * `available_ram_mib` (integer|null): Remaining RAM after subtracting all reservations from the host total (null when unavailable).
1162
+ * `available_disk_gib` (integer|null): Remaining disk GB after subtracting allocations from the host total.
1163
+ * `available_cpu_share` (number|null): Remaining CPU shares after allocations.
1164
+ * `host_total_ram_mib` (integer|null): Host memory capacity observed via Proxmox.
1165
+ * `host_total_disk_gib` (integer|null): Host disk capacity observed via Proxmox.
1166
+ * `host_total_cpu_cores` (integer|null): Number of CPU cores reported by Proxmox.
1152
1167
  * `portacode_version` (string): Installed CLI version returned by `portacode.__version__`.
1153
1168
 
1154
1169
  ### `proxmox_infra_configured`
@@ -21,6 +21,24 @@ from .git_manager import GitManager
21
21
  from .file_system_watcher import FileSystemWatcher
22
22
  from ....logging_categories import get_categorized_logger, LogCategory
23
23
 
24
+ def _deterministic_file_tab_id(file_path: str) -> str:
25
+ try:
26
+ resolved = os.path.abspath(file_path)
27
+ mtime = int(Path(resolved).stat().st_mtime)
28
+ except OSError:
29
+ mtime = "unknown"
30
+ resolved = os.path.abspath(file_path)
31
+ return f"{resolved}:{mtime}"
32
+
33
+ def _deterministic_diff_tab_id(
34
+ file_path: str,
35
+ from_ref: str,
36
+ to_ref: str,
37
+ from_hash: Optional[str],
38
+ to_hash: Optional[str]
39
+ ) -> str:
40
+ return f"diff:{os.path.abspath(file_path)}:{from_ref}:{to_ref}:{from_hash or ''}:{to_hash or ''}"
41
+
24
42
  logger = get_categorized_logger(__name__)
25
43
 
26
44
  # Global singleton instance
@@ -657,10 +675,10 @@ class ProjectStateManager:
657
675
  # Create new file tab using tab factory
658
676
  from ..tab_factory import get_tab_factory
659
677
  tab_factory = get_tab_factory()
660
-
678
+
661
679
  try:
662
680
  logger.info(f"About to create tab for file: {file_path}")
663
- new_tab = await tab_factory.create_file_tab(file_path)
681
+ new_tab = await tab_factory.create_file_tab(file_path, tab_id=_deterministic_file_tab_id(file_path))
664
682
  logger.info(f"Tab created successfully, adding to project state")
665
683
  project_state.open_tabs[tab_key] = new_tab
666
684
  if set_active:
@@ -842,7 +860,11 @@ class ProjectStateManager:
842
860
  diff_title = f"{os.path.basename(file_path)} ({' '.join(title_parts)})"
843
861
 
844
862
  diff_tab = await tab_factory.create_diff_tab_with_title(
845
- file_path, original_content, modified_content, diff_title,
863
+ file_path,
864
+ original_content,
865
+ modified_content,
866
+ diff_title,
867
+ tab_id=_deterministic_diff_tab_id(file_path, from_ref, to_ref, from_hash, to_hash),
846
868
  diff_details=diff_details
847
869
  )
848
870
 
@@ -7,6 +7,7 @@ import json
7
7
  import logging
8
8
  import math
9
9
  import os
10
+ import re
10
11
  import secrets
11
12
  import shlex
12
13
  import shutil
@@ -18,7 +19,7 @@ import time
18
19
  import threading
19
20
  from datetime import datetime, timezone
20
21
  from pathlib import Path
21
- from typing import Any, Callable, Dict, Iterable, List, Optional, Sequence, Tuple
22
+ from typing import Any, Callable, Dict, Iterable, List, Optional, Sequence, Set, Tuple
22
23
 
23
24
  import platformdirs
24
25
 
@@ -201,6 +202,63 @@ def _current_time_iso() -> str:
201
202
  return datetime.now(timezone.utc).isoformat()
202
203
 
203
204
 
205
+ def _to_int(value: Any, default: int = 0) -> int:
206
+ try:
207
+ return int(value)
208
+ except (TypeError, ValueError):
209
+ return default
210
+
211
+
212
+ def _to_float(value: Any, default: float = 0.0) -> float:
213
+ try:
214
+ return float(value)
215
+ except (TypeError, ValueError):
216
+ return default
217
+
218
+
219
+ def _calculate_available(total: Optional[float], used: float) -> Optional[float]:
220
+ if total is None:
221
+ return None
222
+ delta = total - used
223
+ return delta if delta >= 0 else 0.0
224
+
225
+
226
+ def _normalize_bytes(value: Any) -> float:
227
+ if isinstance(value, (int, float)):
228
+ return float(value)
229
+ text = str(value or "").strip()
230
+ if not text:
231
+ return 0.0
232
+ match = re.match(r"(?i)^\s*([0-9]*\.?[0-9]+)\s*([kmgtp]?i?b?)?\s*$", text)
233
+ if not match:
234
+ return 0.0
235
+ number = match.group(1)
236
+ unit = (match.group(2) or "").lower()
237
+ try:
238
+ value = float(number)
239
+ except ValueError:
240
+ return 0.0
241
+ if unit.startswith("k"):
242
+ return value * 1024
243
+ if unit.startswith("m"):
244
+ return value * 1024**2
245
+ if unit.startswith("g"):
246
+ return value * 1024**3
247
+ if unit.startswith("t"):
248
+ return value * 1024**4
249
+ if unit.startswith("p"):
250
+ return value * 1024**5
251
+ return value
252
+
253
+
254
+ def _bytes_to_mib(value: Any) -> int:
255
+ return int(round(_normalize_bytes(value) / (1024**2)))
256
+
257
+
258
+ def _bytes_to_gib(value: Any) -> int:
259
+ return int(round(_normalize_bytes(value) / (1024**3)))
260
+
261
+
204
262
  def _parse_iso_timestamp(value: str) -> Optional[datetime]:
205
263
  if not value:
206
264
  return None
@@ -381,35 +439,67 @@ def _load_managed_container_records() -> List[Dict[str, Any]]:
381
439
  return records
382
440
 
383
441
 
384
- def _build_managed_containers_summary(records: List[Dict[str, Any]]) -> Dict[str, Any]:
442
+ def _extract_host_totals(node_status: Dict[str, Any] | None) -> Tuple[Optional[int], Optional[int], Optional[int]]:
443
+ if not node_status:
444
+ return None, None, None
445
+ memory_total = node_status.get("memory", {}).get("total")
446
+ disk_total = node_status.get("disk", {}).get("total")
447
+ cpu_cores = node_status.get("cpuinfo", {}).get("cores")
448
+ host_ram = _bytes_to_mib(memory_total) if memory_total is not None else None
449
+ host_disk = _bytes_to_gib(disk_total) if disk_total is not None else None
450
+ host_cpu = _to_int(cpu_cores) if cpu_cores is not None else None
451
+ return host_ram, host_disk, host_cpu
452
+
453
+
454
+ def _build_unmanaged_container_entry(ct: Dict[str, Any], cfg: Dict[str, Any], vmid: str) -> Dict[str, Any]:
455
+ ram_mib = _to_int(cfg.get("memory")) or _bytes_to_mib(ct.get("maxmem"))
456
+ disk_gib = _bytes_to_gib(ct.get("maxdisk"))
457
+ cpu_share = _to_float(
458
+ cfg.get("cpulimit")
459
+ or cfg.get("cpus")
460
+ or cfg.get("cores")
461
+ or ct.get("cpus")
462
+ or ct.get("cpu")
463
+ )
464
+ hostname = ct.get("name") or cfg.get("hostname") or f"ct{vmid}"
465
+ storage = cfg.get("storage") or ct.get("storage")
466
+ status = (ct.get("status") or "unknown").lower()
467
+ return {
468
+ "vmid": vmid,
469
+ "hostname": hostname,
470
+ "template": cfg.get("ostemplate"),
471
+ "storage": storage,
472
+ "disk_gib": disk_gib,
473
+ "ram_mib": ram_mib,
474
+ "cpu_share": cpu_share,
475
+ "status": status,
476
+ "managed": False,
477
+ }
478
+
479
+
480
+
481
+ def _build_managed_containers_summary(
482
+ records: List[Dict[str, Any]],
483
+ unmanaged_records: List[Dict[str, Any]],
484
+ node_status: Dict[str, Any] | None,
485
+ ) -> Dict[str, Any]:
486
+ managed_containers: List[Dict[str, Any]] = []
385
487
  total_ram = 0
386
488
  total_disk = 0
387
489
  total_cpu_share = 0.0
388
- containers: List[Dict[str, Any]] = []
389
-
390
- def _as_int(value: Any) -> int:
391
- try:
392
- return int(value)
393
- except (TypeError, ValueError):
394
- return 0
395
490
 
396
- def _as_float(value: Any) -> float:
397
- try:
398
- return float(value)
399
- except (TypeError, ValueError):
400
- return 0.0
401
-
402
- for record in sorted(records, key=lambda entry: _as_int(entry.get("vmid"))):
403
- ram_mib = _as_int(record.get("ram_mib"))
404
- disk_gib = _as_int(record.get("disk_gib"))
405
- cpu_share = _as_float(record.get("cpus"))
491
+ for record in sorted(records, key=lambda entry: _to_int(entry.get("vmid"))):
492
+ ram_mib = _to_int(record.get("ram_mib"))
493
+ disk_gib = _to_int(record.get("disk_gib"))
494
+ cpu_share = _to_float(record.get("cpus"))
406
495
  total_ram += ram_mib
407
496
  total_disk += disk_gib
408
497
  total_cpu_share += cpu_share
409
498
  status = (record.get("status") or "unknown").lower()
410
- containers.append(
499
+ managed_containers.append(
411
500
  {
412
- "vmid": str(_as_int(record.get("vmid"))) if record.get("vmid") is not None else None,
501
+ "vmid": str(_to_int(record.get("vmid"))) if record.get("vmid") is not None else None,
502
+ "device_id": record.get("device_id"),
413
503
  "hostname": record.get("hostname"),
414
504
  "template": record.get("template"),
415
505
  "storage": record.get("storage"),
@@ -418,44 +508,76 @@ def _build_managed_containers_summary(records: List[Dict[str, Any]]) -> Dict[str
418
508
  "cpu_share": cpu_share,
419
509
  "created_at": record.get("created_at"),
420
510
  "status": status,
511
+ "managed": True,
421
512
  }
422
513
  )
423
514
 
515
+ unmanaged_total_ram = sum(_to_int(entry.get("ram_mib")) for entry in unmanaged_records)
516
+ unmanaged_total_disk = sum(_to_int(entry.get("disk_gib")) for entry in unmanaged_records)
517
+ unmanaged_total_cpu = sum(_to_float(entry.get("cpu_share")) for entry in unmanaged_records)
518
+
519
+ allocated_ram = total_ram + unmanaged_total_ram
520
+ allocated_disk = total_disk + unmanaged_total_disk
521
+ allocated_cpu = total_cpu_share + unmanaged_total_cpu
522
+
523
+ host_ram, host_disk, host_cpu = _extract_host_totals(node_status)
524
+
424
525
  return {
425
526
  "updated_at": datetime.utcnow().isoformat() + "Z",
426
- "count": len(containers),
527
+ "count": len(managed_containers),
427
528
  "total_ram_mib": total_ram,
428
529
  "total_disk_gib": total_disk,
429
530
  "total_cpu_share": round(total_cpu_share, 2),
430
- "containers": containers,
531
+ "containers": managed_containers,
532
+ "unmanaged_containers": unmanaged_records,
533
+ "unmanaged_count": len(unmanaged_records),
534
+ "allocated_ram_mib": allocated_ram,
535
+ "allocated_disk_gib": allocated_disk,
536
+ "allocated_cpu_share": round(allocated_cpu, 2),
537
+ "available_ram_mib": _calculate_available(host_ram, allocated_ram) if host_ram is not None else None,
538
+ "available_disk_gib": _calculate_available(host_disk, allocated_disk) if host_disk is not None else None,
539
+ "available_cpu_share": _calculate_available(host_cpu, allocated_cpu) if host_cpu is not None else None,
540
+ "host_total_ram_mib": host_ram,
541
+ "host_total_disk_gib": host_disk,
542
+ "host_total_cpu_cores": host_cpu,
431
543
  }
432
544
 
433
545
 
434
546
  def _get_managed_containers_summary(force: bool = False) -> Dict[str, Any]:
435
- def _refresh_container_statuses(records: List[Dict[str, Any]], config: Dict[str, Any] | None) -> None:
436
- if not records or not config:
437
- return
547
+ def _refresh_container_statuses(
548
+ records: List[Dict[str, Any]],
549
+ config: Dict[str, Any] | None,
550
+ managed_vmids: Set[str],
551
+ ) -> Tuple[Dict[str, str], List[Dict[str, Any]], Dict[str, Any] | None]:
552
+ statuses: Dict[str, str] = {}
553
+ unmanaged: List[Dict[str, Any]] = []
554
+ node_status: Dict[str, Any] | None = None
555
+ if not config:
556
+ return statuses, unmanaged, node_status
438
557
  try:
439
558
  proxmox = _connect_proxmox(config)
440
559
  node = _get_node_from_config(config)
441
- statuses = {
442
- str(ct.get("vmid")): (ct.get("status") or "unknown").lower()
443
- for ct in proxmox.nodes(node).lxc.get()
444
- }
560
+ node_status = proxmox.nodes(node).status.get()
561
+ for ct in proxmox.nodes(node).lxc.get():
562
+ vmid_val = ct.get("vmid")
563
+ if vmid_val is None:
564
+ continue
565
+ vmid_key = str(_to_int(vmid_val))
566
+ statuses[vmid_key] = (ct.get("status") or "unknown").lower()
567
+ if vmid_key in managed_vmids:
568
+ continue
569
+ cfg: Dict[str, Any] = {}
570
+ try:
571
+ cfg = proxmox.nodes(node).lxc(vmid_key).config.get() or {}
572
+ except Exception as exc: # pragma: no cover - best effort
573
+ logger.debug("Failed to read config for container %s: %s", vmid_key, exc)
574
+ description = (cfg.get("description") or "")
575
+ if MANAGED_MARKER in description:
576
+ continue
577
+ unmanaged.append(_build_unmanaged_container_entry(ct, cfg, vmid_key))
445
578
  except Exception as exc: # pragma: no cover - best effort
446
579
  logger.debug("Failed to refresh container statuses: %s", exc)
447
- return
448
- for record in records:
449
- vmid = record.get("vmid")
450
- if vmid is None:
451
- continue
452
- try:
453
- vmid_key = str(int(vmid))
454
- except (ValueError, TypeError):
455
- continue
456
- status = statuses.get(vmid_key)
457
- if status:
458
- record["status"] = status
580
+ return statuses, unmanaged, node_status
459
581
 
460
582
  now = time.monotonic()
461
583
  with _MANAGED_CONTAINERS_CACHE_LOCK:
@@ -465,8 +587,19 @@ def _get_managed_containers_summary(force: bool = False) -> Dict[str, Any]:
465
587
  return cached
466
588
  config = _load_config()
467
589
  records = _load_managed_container_records()
468
- _refresh_container_statuses(records, config)
469
- summary = _build_managed_containers_summary(records)
590
+ managed_vmids: Set[str] = {
591
+ str(_to_int(record.get("vmid"))) for record in records if record.get("vmid") is not None
592
+ }
593
+ statuses, unmanaged, node_status = _refresh_container_statuses(records, config, managed_vmids)
594
+ for record in records:
595
+ vmid = record.get("vmid")
596
+ if vmid is None:
597
+ continue
598
+ vmid_key = str(_to_int(vmid))
599
+ status = statuses.get(vmid_key)
600
+ if status:
601
+ record["status"] = status
602
+ summary = _build_managed_containers_summary(records, unmanaged, node_status)
470
603
  with _MANAGED_CONTAINERS_CACHE_LOCK:
471
604
  _MANAGED_CONTAINERS_CACHE["timestamp"] = now
472
605
  _MANAGED_CONTAINERS_CACHE["summary"] = summary
@@ -607,7 +740,18 @@ def _build_bootstrap_steps(
607
740
  )
608
741
  steps.extend(
609
742
  [
610
- {"name": "user_exists", "cmd": f"id -u {user} >/dev/null 2>&1 || adduser --disabled-password --gecos '' {user}", "retries": 0},
743
+ {
744
+ "name": "user_exists",
745
+ "cmd": (
746
+ f"id -u {user} >/dev/null 2>&1 || "
747
+ f"(if command -v adduser >/dev/null 2>&1 && adduser --disabled-password --help >/dev/null 2>&1; then "
748
+ f" adduser --disabled-password --gecos '' {user}; "
749
+ "else "
750
+ f" useradd -m -s /bin/sh {user}; "
751
+ "fi)"
752
+ ),
753
+ "retries": 0,
754
+ },
611
755
  {
612
756
  "name": "add_sudo",
613
757
  "cmd": (
@@ -633,7 +777,7 @@ def _build_bootstrap_steps(
633
777
  {
634
778
  "name": "add_sudoers",
635
779
  "cmd": (
636
- f"printf '%s ALL=(ALL) NOPASSWD:ALL\n' {shlex.quote(user)} >/etc/sudoers.d/portacode && "
780
+ f"printf '%s ALL=(ALL) NOPASSWD:ALL\\n' {shlex.quote(user)} >/etc/sudoers.d/portacode && "
637
781
  "chmod 0440 /etc/sudoers.d/portacode"
638
782
  ),
639
783
  "retries": 0,
@@ -865,12 +1009,17 @@ def _parse_ctid(message: Dict[str, Any]) -> int:
865
1009
 
866
1010
 
867
1011
  def _ensure_container_managed(
868
- proxmox: Any, node: str, vmid: int
1012
+ proxmox: Any, node: str, vmid: int, *, device_id: Optional[str] = None
869
1013
  ) -> Tuple[Dict[str, Any], Dict[str, Any]]:
870
1014
  record = _read_container_record(vmid)
871
1015
  ct_cfg = proxmox.nodes(node).lxc(str(vmid)).config.get()
872
1016
  if not ct_cfg or MANAGED_MARKER not in (ct_cfg.get("description") or ""):
873
1017
  raise RuntimeError(f"Container {vmid} is not managed by Portacode.")
1018
+ record_device_id = record.get("device_id")
1019
+ if device_id and str(record_device_id or "") != str(device_id):
1020
+ raise RuntimeError(
1021
+ f"Container {vmid} is managed for device {record_device_id!r}, not {device_id!r}."
1022
+ )
874
1023
  return record, ct_cfg
875
1024
 
876
1025
 
@@ -1371,6 +1520,13 @@ def _instantiate_container(proxmox: Any, node: str, payload: Dict[str, Any]) ->
1371
1520
  ssh_public_keys=payload.get("ssh_public_key") or None,
1372
1521
  )
1373
1522
  status, elapsed = _wait_for_task(proxmox, node, upid)
1523
+ exitstatus = (status or {}).get("exitstatus")
1524
+ if exitstatus and exitstatus.upper() != "OK":
1525
+ msg = status.get("status") or "unknown error"
1526
+ details = status.get("error") or status.get("errmsg") or status.get("description") or status
1527
+ raise RuntimeError(
1528
+ f"Container creation task failed ({exitstatus}): {msg} details={details}"
1529
+ )
1374
1530
  return vmid, elapsed
1375
1531
  except ResourceException as exc:
1376
1532
  raise RuntimeError(f"Failed to create container: {exc}") from exc
@@ -1817,10 +1973,13 @@ class StartProxmoxContainerHandler(SyncHandler):
1817
1973
 
1818
1974
  def execute(self, message: Dict[str, Any]) -> Dict[str, Any]:
1819
1975
  vmid = _parse_ctid(message)
1976
+ child_device_id = (message.get("child_device_id") or "").strip()
1977
+ if not child_device_id:
1978
+ raise ValueError("child_device_id is required for start_proxmox_container")
1820
1979
  config = _ensure_infra_configured()
1821
1980
  proxmox = _connect_proxmox(config)
1822
1981
  node = _get_node_from_config(config)
1823
- _ensure_container_managed(proxmox, node, vmid)
1982
+ _ensure_container_managed(proxmox, node, vmid, device_id=child_device_id)
1824
1983
 
1825
1984
  status, elapsed = _start_container(proxmox, node, vmid)
1826
1985
  _update_container_record(vmid, {"status": "running"})
@@ -1847,10 +2006,13 @@ class StopProxmoxContainerHandler(SyncHandler):
1847
2006
 
1848
2007
  def execute(self, message: Dict[str, Any]) -> Dict[str, Any]:
1849
2008
  vmid = _parse_ctid(message)
2009
+ child_device_id = (message.get("child_device_id") or "").strip()
2010
+ if not child_device_id:
2011
+ raise ValueError("child_device_id is required for stop_proxmox_container")
1850
2012
  config = _ensure_infra_configured()
1851
2013
  proxmox = _connect_proxmox(config)
1852
2014
  node = _get_node_from_config(config)
1853
- _ensure_container_managed(proxmox, node, vmid)
2015
+ _ensure_container_managed(proxmox, node, vmid, device_id=child_device_id)
1854
2016
 
1855
2017
  status, elapsed = _stop_container(proxmox, node, vmid)
1856
2018
  final_status = status.get("status") or "stopped"
@@ -1883,10 +2045,13 @@ class RemoveProxmoxContainerHandler(SyncHandler):
1883
2045
 
1884
2046
  def execute(self, message: Dict[str, Any]) -> Dict[str, Any]:
1885
2047
  vmid = _parse_ctid(message)
2048
+ child_device_id = (message.get("child_device_id") or "").strip()
2049
+ if not child_device_id:
2050
+ raise ValueError("child_device_id is required for remove_proxmox_container")
1886
2051
  config = _ensure_infra_configured()
1887
2052
  proxmox = _connect_proxmox(config)
1888
2053
  node = _get_node_from_config(config)
1889
- _ensure_container_managed(proxmox, node, vmid)
2054
+ _ensure_container_managed(proxmox, node, vmid, device_id=child_device_id)
1890
2055
 
1891
2056
  stop_status, stop_elapsed = _stop_container(proxmox, node, vmid)
1892
2057
  delete_status, delete_elapsed = _delete_container(proxmox, node, vmid)
@@ -685,6 +685,7 @@ class TerminalManager:
685
685
  try:
686
686
  # Initialize project state
687
687
  project_state = await manager.initialize_project_state(session_name, project_folder_path)
688
+ await self._restore_tabs_from_session_metadata(manager, project_state, session)
688
689
 
689
690
  # Send initial project state to the client
690
691
  initial_state_payload = {
@@ -710,6 +711,85 @@ class TerminalManager:
710
711
 
711
712
  except Exception as exc:
712
713
  logger.exception("terminal_manager: Error initializing project states for new sessions: %s", exc)
714
+
715
+ async def _restore_tabs_from_session_metadata(self, manager, project_state, session):
716
+ """Restore open tabs/active tab from client session metadata if available."""
717
+ if not session or not project_state:
718
+ return
719
+
720
+ descriptors = session.get("open_tabs") or []
721
+ if not descriptors:
722
+ return
723
+
724
+ session_id = project_state.client_session_id
725
+ logger.info("terminal_manager: 🧭 Restoring %d tabs from metadata for session %s", len(descriptors), session_id)
726
+
727
+ for descriptor in descriptors:
728
+ parsed = self._parse_tab_descriptor(descriptor)
729
+ if not parsed:
730
+ continue
731
+
732
+ tab_type = parsed.get("tab_type")
733
+ file_path = parsed.get("file_path")
734
+ metadata = parsed.get("metadata", {})
735
+
736
+ if tab_type == "file" and file_path:
737
+ try:
738
+ await manager.open_file(session_id, file_path, set_active=False)
739
+ except Exception as exc:
740
+ logger.warning("terminal_manager: Failed to restore file tab %s for session %s: %s", file_path, session_id, exc)
741
+ continue
742
+
743
+ if tab_type == "diff" and file_path:
744
+ from_ref = metadata.get("from") or metadata.get("from_ref")
745
+ to_ref = metadata.get("to") or metadata.get("to_ref")
746
+ if not from_ref or not to_ref:
747
+ logger.warning("terminal_manager: Skipping diff tab %s for session %s because from/to references are missing", file_path, session_id)
748
+ continue
749
+ from_hash = metadata.get("from_hash") or metadata.get("fromHash")
750
+ to_hash = metadata.get("to_hash") or metadata.get("toHash")
751
+ try:
752
+ await manager.open_diff_tab(session_id, file_path, from_ref, to_ref, from_hash=from_hash, to_hash=to_hash)
753
+ except Exception as exc:
754
+ logger.warning("terminal_manager: Failed to restore diff tab %s for session %s: %s", file_path, session_id, exc)
755
+ continue
756
+
757
+ logger.debug("terminal_manager: Unknown tab descriptor ignored for session %s: %s", session_id, descriptor)
758
+
759
+ active_index = session.get("active_tab")
760
+ try:
761
+ active_index_int = int(active_index) if active_index is not None else None
762
+ except (TypeError, ValueError):
763
+ active_index_int = None
764
+
765
+ if active_index_int is not None and active_index_int >= 0:
766
+ current_tabs = list(project_state.open_tabs.values())
767
+ if 0 <= active_index_int < len(current_tabs):
768
+ try:
769
+ await manager.set_active_tab(session_id, current_tabs[active_index_int].tab_id)
770
+ except Exception as exc:
771
+ logger.warning("terminal_manager: Failed to set active tab for session %s: %s", session_id, exc)
772
+ else:
773
+ logger.debug("terminal_manager: Active tab index %s out of range for session %s", active_index_int, session_id)
774
+
775
+ def _parse_tab_descriptor(self, descriptor: str) -> Optional[Dict[str, Any]]:
776
+ """Parse a URL-friendly tab descriptor string."""
777
+ if not descriptor:
778
+ return None
779
+
780
+ try:
781
+ parts = descriptor.split("|")
782
+ tab_type = parts[0] if parts else None
783
+ file_path = parts[1] if len(parts) > 1 else None
784
+ metadata = {}
785
+ for part in parts[2:]:
786
+ if "=" in part:
787
+ key, value = part.split("=", 1)
788
+ metadata[key] = value
789
+ return {"tab_type": tab_type, "file_path": file_path, "metadata": metadata}
790
+ except Exception as exc:
791
+ logger.warning("terminal_manager: Failed to parse tab descriptor '%s': %s", descriptor, exc)
792
+ return None
713
793
 
714
794
  async def _send_targeted_terminal_list(self, message: Dict[str, Any], target_sessions: List[str]) -> None:
715
795
  """Send terminal_list command to specific client sessions.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: portacode
3
- Version: 1.4.15.dev23
3
+ Version: 1.4.16.dev3
4
4
  Summary: Portacode CLI client and SDK
5
5
  Home-page: https://github.com/portacode/portacode
6
6
  Author: Meena Erian
@@ -1,7 +1,7 @@
1
1
  portacode/README.md,sha256=4dKtpvR8LNgZPVz37GmkQCMWIr_u25Ao63iW56s7Ke4,775
2
2
  portacode/__init__.py,sha256=oB3sV1wXr-um-RXio73UG8E5Xx6cF2ZVJveqjNmC-vQ,1086
3
3
  portacode/__main__.py,sha256=jmHTGC1hzmo9iKJLv-SSYe9BSIbPPZ2IOpecI03PlTs,296
4
- portacode/_version.py,sha256=8YGMPC5_rqCpJnjJF3sLM5flS3H0d8do1siDivvmwVs,721
4
+ portacode/_version.py,sha256=HLH9O1BtoKVO_ZLCnGRXJ_xMQe43OyaD07QzQgNnelQ,719
5
5
  portacode/cli.py,sha256=mGLKoZ-T2FBF7IA9wUq0zyG0X9__-A1ao7gajjcVRH8,21828
6
6
  portacode/data.py,sha256=5-s291bv8J354myaHm1Y7CQZTZyRzMU3TGe5U4hb-FA,1591
7
7
  portacode/keypair.py,sha256=0OO4vHDcF1XMxCDqce61xFTlFwlTcmqe5HyGsXFEt7s,5838
@@ -12,9 +12,9 @@ portacode/connection/README.md,sha256=f9rbuIEKa7cTm9C98rCiBbEtbiIXQU11esGSNhSMiJ
12
12
  portacode/connection/__init__.py,sha256=atqcVGkViIEd7pRa6cP2do07RJOM0UWpbnz5zXjGktU,250
13
13
  portacode/connection/client.py,sha256=jtLb9_YufqPkzi9t8VQH3iz_JEMisbtY6a8L9U5weiU,14181
14
14
  portacode/connection/multiplex.py,sha256=L-TxqJ_ZEbfNEfu1cwxgJ5vUdyRzZjsMy2Kx1diiZys,5237
15
- portacode/connection/terminal.py,sha256=oyLPOVLPlUuN_eRvHPGazB51yi8W8JEF3oOEYxucGTE,45069
15
+ portacode/connection/terminal.py,sha256=n1Uu92JacV5K6d1Qwx94Tw9OB2Tpke5HqsW2NDn76Ls,49032
16
16
  portacode/connection/handlers/README.md,sha256=HsLZG1QK1JNm67HsgL6WoDg9nxzKXxwkc5fJPFJdX5g,12169
17
- portacode/connection/handlers/WEBSOCKET_PROTOCOL.md,sha256=OtObmoVAXlf0uU1HidTWNmyJYBS1Yl6Rpgyh6TOTjUQ,100590
17
+ portacode/connection/handlers/WEBSOCKET_PROTOCOL.md,sha256=U7zkUpwscWf5aqimNIP3uYXvg-t9S0MhyaZ3ygF-ADk,102593
18
18
  portacode/connection/handlers/__init__.py,sha256=WSeBmi65GWFQPYt9M3E10rn0uZ_EPCJzNJOzSf2HZyw,2921
19
19
  portacode/connection/handlers/base.py,sha256=oENFb-Fcfzwk99Qx8gJQriEMiwSxwygwjOiuCH36hM4,10231
20
20
  portacode/connection/handlers/chunked_content.py,sha256=h6hXRmxSeOgnIxoU8CkmvEf2Odv-ajPrpHIe_W3GKcA,9251
@@ -22,7 +22,7 @@ portacode/connection/handlers/diff_handlers.py,sha256=iYTIRCcpEQ03vIPKZCsMTE5aZb
22
22
  portacode/connection/handlers/file_handlers.py,sha256=nAJH8nXnX07xxD28ngLpgIUzcTuRwZBNpEGEKdRqohw,39507
23
23
  portacode/connection/handlers/project_aware_file_handlers.py,sha256=AqgMnDqX2893T2NsrvUSCwjN5VKj4Pb2TN0S_SuboOE,9803
24
24
  portacode/connection/handlers/project_state_handlers.py,sha256=v6ZefGW9i7n1aZLq2jOGumJIjYb6aHlPI4m1jkYewm8,1686
25
- portacode/connection/handlers/proxmox_infra.py,sha256=cAueQb_D3COmPI84LfI1bqHOcgt02BOgho_pCRGeLWE,71132
25
+ portacode/connection/handlers/proxmox_infra.py,sha256=IG8qp607grx4v9jSWL5TawUrIN0VElIR2swjA_rpn9U,78132
26
26
  portacode/connection/handlers/registry.py,sha256=qXGE60sYEWg6ZtVQzFcZ5YI2XWR6lMgw4hAL9x5qR1I,6181
27
27
  portacode/connection/handlers/session.py,sha256=uNGfiO_1B9-_yjJKkpvmbiJhIl6b-UXlT86UTfd6WYE,42219
28
28
  portacode/connection/handlers/system_handlers.py,sha256=fr12QpOr_Z8KYGUU-AYrTQwRPAcrLK85hvj3SEq1Kw8,14757
@@ -35,7 +35,7 @@ portacode/connection/handlers/project_state/__init__.py,sha256=5ucIqk6Iclqg6bKkL
35
35
  portacode/connection/handlers/project_state/file_system_watcher.py,sha256=r9_UKxWTbzum0jGqxIafe68Ced2Y3xOp3ZmkpBOfRpw,8573
36
36
  portacode/connection/handlers/project_state/git_manager.py,sha256=iGQ7LYIA7uHsZHdj3HEc_LYo7S1Lqv6-AeyyMwknBPo,70027
37
37
  portacode/connection/handlers/project_state/handlers.py,sha256=qgOSt26rxAGNxW07AoevTwDPBdxblX4J-dX-EjOKtg4,38232
38
- portacode/connection/handlers/project_state/manager.py,sha256=pRMZqPOTK9YE3abNxiAbnERIJmRys673HFOEIBiKnm4,67184
38
+ portacode/connection/handlers/project_state/manager.py,sha256=ori_QpeoY1sdpY8WDYIx-kl_gNfZ7o8eq84CcOBlvIs,67915
39
39
  portacode/connection/handlers/project_state/models.py,sha256=EZTKvxHKs8QlQUbzI0u2IqfzfRRXZixUIDBwTGCJATI,4313
40
40
  portacode/connection/handlers/project_state/utils.py,sha256=LsbQr9TH9Bz30FqikmtTxco4PlB_n0kUIuPKQ6Fb_mo,1665
41
41
  portacode/link_capture/__init__.py,sha256=93LjyYDqzOimsIDBhsPibTl7tr-8DiIzyDF7JWQkE2A,1231
@@ -65,7 +65,7 @@ portacode/utils/__init__.py,sha256=NgBlWTuNJESfIYJzP_3adI1yJQJR0XJLRpSdVNaBAN0,3
65
65
  portacode/utils/diff_apply.py,sha256=4Oi7ft3VUCKmiUE4VM-OeqO7Gk6H7PF3WnN4WHXtjxI,15157
66
66
  portacode/utils/diff_renderer.py,sha256=S76StnQ2DLfsz4Gg0m07UwPfRp8270PuzbNaQq-rmYk,13850
67
67
  portacode/utils/ntp_clock.py,sha256=VqCnWCTehCufE43W23oB-WUdAZGeCcLxkmIOPwInYHc,2499
68
- portacode-1.4.15.dev23.dist-info/licenses/LICENSE,sha256=2FGbCnUDgRYuQTkB1O1dUUpu5CVAjK1j4_p6ack9Z54,1066
68
+ portacode-1.4.16.dev3.dist-info/licenses/LICENSE,sha256=2FGbCnUDgRYuQTkB1O1dUUpu5CVAjK1j4_p6ack9Z54,1066
69
69
  test_modules/README.md,sha256=Do_agkm9WhSzueXjRAkV_xEj6Emy5zB3N3VKY5Roce8,9274
70
70
  test_modules/__init__.py,sha256=1LcbHodIHsB0g-g4NGjSn6AMuCoGbymvXPYLOb6Z7F0,53
71
71
  test_modules/test_device_online.py,sha256=QtYq0Dq9vME8Gp2O4fGSheqVf8LUtpsSKosXXk56gGM,1654
@@ -91,8 +91,8 @@ testing_framework/core/playwright_manager.py,sha256=Tw46qwxIhOFkS48C2IWIQHHNpEe-
91
91
  testing_framework/core/runner.py,sha256=j2QwNJmAxVBmJvcbVS7DgPJUKPNzqfLmt_4NNdaKmZU,19297
92
92
  testing_framework/core/shared_cli_manager.py,sha256=BESSNtyQb7BOlaOvZmm04T8Uezjms4KCBs2MzTxvzYQ,8790
93
93
  testing_framework/core/test_discovery.py,sha256=2FZ9fJ8Dp5dloA-fkgXoJ_gCMC_nYPBnA3Hs2xlagzM,4928
94
- portacode-1.4.15.dev23.dist-info/METADATA,sha256=1pp2pJiX9YjuOBghWO4mYN7fkpK0dqJSv6CdFMgbwOQ,13052
95
- portacode-1.4.15.dev23.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
96
- portacode-1.4.15.dev23.dist-info/entry_points.txt,sha256=lLUUL-BM6_wwe44Xv0__5NQ1BnAz6jWjSMFvZdWW3zU,48
97
- portacode-1.4.15.dev23.dist-info/top_level.txt,sha256=TGhTYUxfW8SyVZc_zGgzjzc24gGT7nSw8Qf73liVRKM,41
98
- portacode-1.4.15.dev23.dist-info/RECORD,,
94
+ portacode-1.4.16.dev3.dist-info/METADATA,sha256=ulkuZ2NkbFWXncNI8ZPHvWD2ThvRJ3-rFMYBNuFHpG8,13051
95
+ portacode-1.4.16.dev3.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
96
+ portacode-1.4.16.dev3.dist-info/entry_points.txt,sha256=lLUUL-BM6_wwe44Xv0__5NQ1BnAz6jWjSMFvZdWW3zU,48
97
+ portacode-1.4.16.dev3.dist-info/top_level.txt,sha256=TGhTYUxfW8SyVZc_zGgzjzc24gGT7nSw8Qf73liVRKM,41
98
+ portacode-1.4.16.dev3.dist-info/RECORD,,