collab-runtime 0.4.2__tar.gz → 0.5.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 (26) hide show
  1. {collab_runtime-0.4.2/collab_runtime.egg-info → collab_runtime-0.5.0}/PKG-INFO +1 -1
  2. {collab_runtime-0.4.2 → collab_runtime-0.5.0}/README.md +34 -12
  3. {collab_runtime-0.4.2 → collab_runtime-0.5.0}/collab/agent_identity.py +52 -5
  4. {collab_runtime-0.4.2 → collab_runtime-0.5.0}/collab/dashboard/index.html +138 -11
  5. {collab_runtime-0.4.2 → collab_runtime-0.5.0}/collab/dashboard_server.py +2 -0
  6. {collab_runtime-0.4.2 → collab_runtime-0.5.0}/collab/live_locks_watcher.py +116 -2
  7. {collab_runtime-0.4.2 → collab_runtime-0.5.0}/collab/lock_client.py +69 -7
  8. {collab_runtime-0.4.2 → collab_runtime-0.5.0}/collab/main.py +68 -2
  9. {collab_runtime-0.4.2 → collab_runtime-0.5.0/collab_runtime.egg-info}/PKG-INFO +1 -1
  10. {collab_runtime-0.4.2 → collab_runtime-0.5.0}/pyproject.toml +1 -1
  11. {collab_runtime-0.4.2 → collab_runtime-0.5.0}/LICENSE +0 -0
  12. {collab_runtime-0.4.2 → collab_runtime-0.5.0}/collab/__init__.py +0 -0
  13. {collab_runtime-0.4.2 → collab_runtime-0.5.0}/collab/__main__.py +0 -0
  14. {collab_runtime-0.4.2 → collab_runtime-0.5.0}/collab/dashboard/dashboard-format.js +0 -0
  15. {collab_runtime-0.4.2 → collab_runtime-0.5.0}/collab/errors.py +0 -0
  16. {collab_runtime-0.4.2 → collab_runtime-0.5.0}/collab/logging_config.py +0 -0
  17. {collab_runtime-0.4.2 → collab_runtime-0.5.0}/collab/platform_probe.py +0 -0
  18. {collab_runtime-0.4.2 → collab_runtime-0.5.0}/collab/safe_subprocess.py +0 -0
  19. {collab_runtime-0.4.2 → collab_runtime-0.5.0}/collab/subprocess_bridge.py +0 -0
  20. {collab_runtime-0.4.2 → collab_runtime-0.5.0}/collab_runtime.egg-info/SOURCES.txt +0 -0
  21. {collab_runtime-0.4.2 → collab_runtime-0.5.0}/collab_runtime.egg-info/dependency_links.txt +0 -0
  22. {collab_runtime-0.4.2 → collab_runtime-0.5.0}/collab_runtime.egg-info/entry_points.txt +0 -0
  23. {collab_runtime-0.4.2 → collab_runtime-0.5.0}/collab_runtime.egg-info/requires.txt +0 -0
  24. {collab_runtime-0.4.2 → collab_runtime-0.5.0}/collab_runtime.egg-info/top_level.txt +0 -0
  25. {collab_runtime-0.4.2 → collab_runtime-0.5.0}/docs/pypi/README.md +0 -0
  26. {collab_runtime-0.4.2 → collab_runtime-0.5.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: collab-runtime
3
- Version: 0.4.2
3
+ Version: 0.5.0
4
4
  Summary: Collaborative file locking runtime
5
5
  Author-email: KirilMT <kiril.mt95@gmail.com>
6
6
  License-Expression: MIT
@@ -74,15 +74,18 @@ The setup script automatically:
74
74
 
75
75
  After setup, verify your `.env` at the project root has these values:
76
76
 
77
- | Variable | Description |
78
- | --------------------------- | ---------------------------------------------------------------- |
79
- | `SUPABASE_URL` | Your Supabase project URL (from Project Settings → API) |
80
- | `SUPABASE_ANON_KEY` | Anonymous/public key (from Project Settings → API) |
81
- | `SUPABASE_SERVICE_ROLE_KEY` | Service role key (**required** for dashboard force-release) |
82
- | `LOCK_STRICT` | If `1`, git hooks block on lock errors. Default `0` (warn only) |
83
- | `COLLAB_AGENT_ID` | Optional stable id for an AI agent session (multi-agent locking) |
84
- | `COLLAB_AGENT_LABEL` | Optional display label (e.g. `refactor-auth`) |
85
- | `COLLAB_AGENT_MODE` | Set to `1` to auto-generate/persist an agent id when unset |
77
+ | Variable | Description |
78
+ | --------------------------- | ------------------------------------------------------------------- |
79
+ | `SUPABASE_URL` | Your Supabase project URL (from Project Settings → API) |
80
+ | `SUPABASE_ANON_KEY` | Anonymous/public key (from Project Settings → API) |
81
+ | `SUPABASE_SERVICE_ROLE_KEY` | Service role key (**required** for dashboard force-release) |
82
+ | `LOCK_STRICT` | If `1`, git hooks block on lock errors. Default `0` (warn only) |
83
+ | `COLLAB_AGENT_ID` | Optional stable id for an AI agent session (multi-agent locking) |
84
+ | `COLLAB_AGENT_LABEL` | Optional task label shown on the dashboard (e.g. `refactor-auth`) |
85
+ | `COLLAB_AGENT_KIND` | Optional AI runtime for the dashboard icon (auto-detected) |
86
+ | `COLLAB_AGENT_MODE` | Set to `1` to auto-generate/persist an agent id when unset |
87
+ | `COLLAB_AGENT_HOOKS` | Set to `1` to enable the IDE edit hook that auto-claims agent edits |
88
+ | `COLLAB_WATCHER_AGENT_ID` | Opt in to a dedicated agent watcher (default: watcher = human) |
86
89
 
87
90
  > **Important:** `SUPABASE_SERVICE_ROLE_KEY` is needed for the dashboard's Force Release button. Without it, only your own locks can be released.
88
91
 
@@ -102,9 +105,28 @@ set COLLAB_AGENT_ID=agent-fix-tests
102
105
  collab acquire src/auth.py # conflict — locked by agent-refactor-auth
103
106
  ```
104
107
 
105
- For existing Supabase projects, re-run the `acquire_lock` function and add the `agent_id` /
106
- `agent_label` columns from the updated [supabase/schema.sql](supabase/schema.sql) (fresh installs
107
- already include them).
108
+ For existing Supabase projects, re-run [supabase/schema.sql](supabase/schema.sql) to add the
109
+ `agent_id` / `agent_label` / `origin` / `agent_kind` columns and the updated `acquire_lock` function
110
+ (the script is idempotent; fresh installs already include them).
111
+
112
+ #### Strict user-vs-agent attribution
113
+
114
+ The dashboard distinguishes **human** edits from **AI agent** edits and shows _what the agent is
115
+ working on_ — not a cryptic id. Attribution is decided by an explicit signal:
116
+
117
+ - The background watcher locks bulk git changes as the **human** (`User` chip), even inside an AI
118
+ IDE. So normal work is never mislabelled as an agent.
119
+ - An AI agent claims the files it edits, producing an **"AI Agent"** badge (runtime icon + task).
120
+ Make this automatic by wiring your IDE's edit hook to `collab claim` — see
121
+ [scripts/agent-hooks/](scripts/agent-hooks/README.md). It is **runtime-agnostic** (Cursor, Claude
122
+ Code, Copilot, Gemini, ...). Enable with `COLLAB_AGENT_HOOKS=1` and optionally
123
+ `COLLAB_AGENT_LABEL="<task>"`.
124
+
125
+ Agents can also claim explicitly:
126
+
127
+ ```bash
128
+ collab claim src/auth.py --label "refactor-auth" --reason "Refactor auth"
129
+ ```
108
130
 
109
131
  ### 4. Verify Setup
110
132
 
@@ -58,13 +58,19 @@ def detect_agent_runtime_label() -> Optional[str]:
58
58
 
59
59
 
60
60
  def is_agent_mode_requested() -> bool:
61
- """Return True when agent identity should be active for this process."""
61
+ """Return True when agent identity should be active for this process.
62
+
63
+ STRICT ATTRIBUTION: the mere *presence* of an AI runtime (e.g. a process
64
+ spawned from a Cursor/Claude terminal that exports ``CURSOR_TRACE_ID``) does
65
+ NOT by itself attribute locks to an agent. Doing so caused every background
66
+ auto-lock to be mislabelled as the runtime. Agent attribution now requires an
67
+ *explicit* signal: ``COLLAB_AGENT_ID`` or ``COLLAB_AGENT_MODE``. The detected
68
+ runtime is still used for friendly display only (see :func:`resolve_agent_kind`).
69
+ """
62
70
  if _read_clean_env("COLLAB_AGENT_ID"):
63
71
  return True
64
72
  if _is_truthy_env("COLLAB_AGENT_MODE"):
65
73
  return True
66
- if detect_agent_runtime_label():
67
- return True
68
74
  return False
69
75
 
70
76
 
@@ -152,12 +158,17 @@ def resolve_agent_label(
152
158
  explicit_label: Optional[str] = None,
153
159
  runtime_label: Optional[str] = None,
154
160
  ) -> Optional[str]:
155
- """Resolve optional human-readable agent label."""
161
+ """Resolve the human-readable *task* label (the "why / what for").
162
+
163
+ This intentionally does NOT fall back to the runtime name (e.g. ``cursor``): the
164
+ label describes the task an agent is working on (``fix-ci-dashboard``), while the
165
+ runtime family is tracked separately as ``agent_kind`` for display. When no task
166
+ label is supplied the dashboard shows a generic "AI Agent".
167
+ """
156
168
  for candidate in (
157
169
  explicit_label,
158
170
  _read_clean_env("COLLAB_AGENT_LABEL"),
159
171
  runtime_label,
160
- detect_agent_runtime_label(),
161
172
  ):
162
173
  if candidate:
163
174
  val = candidate.strip()
@@ -166,6 +177,40 @@ def resolve_agent_label(
166
177
  return None
167
178
 
168
179
 
180
+ def resolve_agent_kind(
181
+ *,
182
+ explicit_kind: Optional[str] = None,
183
+ agent_id: Optional[str] = None,
184
+ ) -> Optional[str]:
185
+ """Resolve the AI runtime family for friendly display (icon/name).
186
+
187
+ Precedence: explicit value → ``COLLAB_AGENT_KIND`` → detected runtime marker.
188
+ When an agent identity exists but the runtime is unknown, falls back to the
189
+ generic ``"other"`` so the dashboard can still render an AI badge. Returns
190
+ ``None`` for human (no agent) locks.
191
+ """
192
+ for candidate in (
193
+ explicit_kind,
194
+ _read_clean_env("COLLAB_AGENT_KIND"),
195
+ detect_agent_runtime_label(),
196
+ ):
197
+ if candidate:
198
+ val = candidate.strip().lower()
199
+ if val:
200
+ return val[:64]
201
+ if agent_id:
202
+ return "other"
203
+ return None
204
+
205
+
206
+ def resolve_origin(agent_id: Optional[str]) -> str:
207
+ """Return the authoritative attribution origin for a lock.
208
+
209
+ ``'agent'`` when a unique agent identity is present, otherwise ``'human'``.
210
+ """
211
+ return "agent" if agent_id else "human"
212
+
213
+
169
214
  def agent_ids_match(
170
215
  lock_agent_id: Optional[str],
171
216
  client_agent_id: Optional[str],
@@ -282,6 +327,7 @@ def identity_summary(
282
327
  developer_id: str,
283
328
  agent_id: Optional[str],
284
329
  agent_label: Optional[str],
330
+ agent_kind: Optional[str] = None,
285
331
  ) -> dict[str, Optional[str]]:
286
332
  """Return a dict suitable for ``collab whoami`` JSON output."""
287
333
  mode = "agent" if agent_id else "human"
@@ -289,5 +335,6 @@ def identity_summary(
289
335
  "developer_id": developer_id,
290
336
  "agent_id": agent_id,
291
337
  "agent_label": agent_label,
338
+ "agent_kind": agent_kind,
292
339
  "mode": mode,
293
340
  }
@@ -324,6 +324,55 @@
324
324
  color: #4338ca;
325
325
  }
326
326
 
327
+ /* Human vs AI actor attribution badges */
328
+ .actor-chip {
329
+ display: inline-flex;
330
+ align-items: center;
331
+ gap: 0.3rem;
332
+ margin-left: 0.4rem;
333
+ padding: 0.12rem 0.5rem;
334
+ border-radius: 999px;
335
+ font-size: 0.72rem;
336
+ font-weight: 600;
337
+ white-space: nowrap;
338
+ vertical-align: middle;
339
+ }
340
+
341
+ .user-chip {
342
+ background: #f1f5f9;
343
+ color: #475569;
344
+ border: 1px solid var(--border-color);
345
+ }
346
+
347
+ .agent-badge {
348
+ background: linear-gradient(135deg, #ede9fe 0%, #e0e7ff 100%);
349
+ color: #5b21b6;
350
+ border: 1px solid #ddd6fe;
351
+ }
352
+
353
+ .agent-badge-owner {
354
+ background: linear-gradient(135deg, #ddd6fe 0%, #c7d2fe 100%);
355
+ color: #4c1d95;
356
+ border-color: #c4b5fd;
357
+ }
358
+
359
+ .agent-badge .agent-badge-kind {
360
+ font-weight: 700;
361
+ text-transform: capitalize;
362
+ }
363
+
364
+ .agent-badge .agent-badge-task {
365
+ font-weight: 500;
366
+ opacity: 0.85;
367
+ max-width: 16rem;
368
+ overflow: hidden;
369
+ text-overflow: ellipsis;
370
+ }
371
+
372
+ .agent-badge .agent-badge-sep {
373
+ opacity: 0.5;
374
+ }
375
+
327
376
  code {
328
377
  color: #1d4ed8;
329
378
  background: #f8fafc;
@@ -759,20 +808,98 @@ SUPABASE_ANON_KEY=your_anon_key</pre>
759
808
  return !!(SUPABASE_USER && lock.developer_id === SUPABASE_USER);
760
809
  }
761
810
 
811
+ function escapeHtml(value) {
812
+ return String(value == null ? "" : value)
813
+ .replace(/&/g, "&amp;")
814
+ .replace(/</g , "&lt;") .replace( />/g, "&gt;")
815
+ .replace(/"/g, "&quot;")
816
+ .replace(/'/g, "&#39;");
817
+ }
818
+
819
+ function isAgentLock(lock) {
820
+ if (lock.origin === "agent") {
821
+ return true;
822
+ }
823
+ // Backward compatibility for rows created before `origin` existed.
824
+ return !!lock.agent_id && lock.origin !== "human";
825
+ }
826
+
827
+ function agentKindIcon(kind) {
828
+ switch (String(kind || "").toLowerCase()) {
829
+ case "cursor":
830
+ return '<i class="fas fa-i-cursor"></i>';
831
+ case "copilot":
832
+ return '<i class="fab fa-github"></i>';
833
+ case "claude-code":
834
+ case "claude":
835
+ case "composer":
836
+ case "other":
837
+ default:
838
+ return '<i class="fas fa-robot"></i>';
839
+ }
840
+ }
841
+
842
+ function prettifyKind(kind) {
843
+ const k = String(kind || "").trim();
844
+ if (!k || k === "other") {
845
+ return "";
846
+ }
847
+ return k
848
+ .split(/[-_]/)
849
+ .map((p) => (p ? p.charAt(0).toUpperCase() + p.slice(1) : p))
850
+ .join(" ");
851
+ }
852
+
762
853
  function formatDeveloperCell(lock) {
763
854
  const dev = lock.developer_id || "?";
764
- const agentText = lock.agent_label || lock.agent_id;
765
- const chip = agentText
766
- ? '<span class="agent-chip' + (lockOwnedByMe(lock) ? " agent-chip-owner" : "") + '">' +
767
- agentText +
768
- "</span>"
769
- : '<span class="agent-chip">user</span>';
770
- return (
855
+ const devTag =
771
856
  '<span class="dev-tag ' + (lockOwnedByMe(lock) ? "dev-tag-owner" : "") + '">' +
772
- dev +
773
- "</span>" +
774
- chip
775
- );
857
+ escapeHtml(dev) +
858
+ "</span>";
859
+
860
+ if (!isAgentLock(lock)) {
861
+ const userChip =
862
+ '<span class="actor-chip user-chip" title="Edited by a human developer">' +
863
+ '<i class="fas fa-user"></i>User</span>';
864
+ return devTag + userChip;
865
+ }
866
+
867
+ // AI agent lock: show clearly that it was an AI agent and what for.
868
+ // The raw agent_id is intentionally never displayed in the cell — it
869
+ // appears only in the hover tooltip for debugging.
870
+ const kind = lock.agent_kind || "";
871
+ const kindName = prettifyKind(kind);
872
+ const task = String(lock.agent_label || "").trim();
873
+
874
+ const tip = ["Edited by an AI agent"];
875
+ if (kindName) tip.push("Runtime: " + kindName);
876
+ if (task) tip.push("Task: " + task);
877
+ if (lock.agent_id) tip.push("Agent id: " + lock.agent_id);
878
+ if (lock.reason) tip.push("Reason: " + lock.reason);
879
+
880
+ let inner =
881
+ agentKindIcon(kind) + '<span class="agent-badge-text">AI Agent</span>';
882
+ if (kindName) {
883
+ inner +=
884
+ '<span class="agent-badge-sep">·</span>' +
885
+ '<span class="agent-badge-kind">' +
886
+ escapeHtml(kindName) +
887
+ "</span>";
888
+ }
889
+ if (task) {
890
+ inner +=
891
+ '<span class="agent-badge-sep">·</span>' +
892
+ '<span class="agent-badge-task">' +
893
+ escapeHtml(task) +
894
+ "</span>";
895
+ }
896
+
897
+ const badge =
898
+ '<span class="actor-chip agent-badge' + (lockOwnedByMe(lock) ? " agent-badge-owner" : "") + '" title="' + escapeHtml(tip.join("\n")) + '">' +
899
+ inner +
900
+ "</span>";
901
+
902
+ return devTag + badge;
776
903
  }
777
904
 
778
905
  function updateTimestamp() {
@@ -161,6 +161,7 @@ def load_runtime_supabase_config(project_root: str) -> dict[str, Any]:
161
161
  state_dir = state_override or os.path.join(project_root, ".collab")
162
162
  agent_id = agent_identity.resolve_agent_id(state_dir)
163
163
  agent_label = agent_identity.resolve_agent_label()
164
+ agent_kind = agent_identity.resolve_agent_kind(agent_id=agent_id)
164
165
  project_name = resolve_project_display_name(project_root, file_vals)
165
166
  return {
166
167
  "url": url,
@@ -169,6 +170,7 @@ def load_runtime_supabase_config(project_root: str) -> dict[str, Any]:
169
170
  "user": user,
170
171
  "agentId": agent_id,
171
172
  "agentLabel": agent_label,
173
+ "agentKind": agent_kind,
172
174
  "projectName": project_name,
173
175
  }
174
176
 
@@ -239,6 +239,7 @@ PID_FILE = agent_identity.resolve_daemon_pid_path(_COLLAB_ROOT, None)
239
239
  DEVELOPER_ID = None
240
240
  AGENT_ID: Optional[str] = None
241
241
  AGENT_LABEL: Optional[str] = None
242
+ AGENT_KIND: Optional[str] = None
242
243
 
243
244
  # Ephemeral developer prefixes enforced in code (not via env) to avoid
244
245
  # accidental disabling of lock persistence. These accounts (e.g. CI/test)
@@ -362,6 +363,8 @@ def _acquire_rpc_payload(
362
363
  "p_is_ephemeral": _is_ephemeral_dev(DEVELOPER_ID or ""),
363
364
  "p_agent_id": AGENT_ID,
364
365
  "p_agent_label": AGENT_LABEL,
366
+ "p_origin": agent_identity.resolve_origin(AGENT_ID),
367
+ "p_agent_kind": AGENT_KIND,
365
368
  }
366
369
 
367
370
 
@@ -749,6 +752,88 @@ def _process_releases(client, released: set[str]) -> None:
749
752
  logger.exception("Failed to release lock for %s", fp)
750
753
 
751
754
 
755
+ def _resolve_watcher_identity(
756
+ agent_id: Optional[str],
757
+ agent_label: Optional[str],
758
+ agent_kind: Optional[str],
759
+ ) -> tuple[Optional[str], Optional[str], Optional[str]]:
760
+ """Apply strict attribution to the watcher's resolved identity.
761
+
762
+ The background watcher attributes bulk auto-locks to the human developer
763
+ unless a dedicated agent watcher explicitly opts in via
764
+ ``COLLAB_WATCHER_AGENT_ID``. When opting out (the default) the agent identity
765
+ is dropped so every auto-lock is stamped ``origin=human``.
766
+ """
767
+ if os.getenv("COLLAB_WATCHER_AGENT_ID", "").strip():
768
+ return agent_id, agent_label, agent_kind
769
+ return None, None, None
770
+
771
+
772
+ def _release_developer_scope(client, file_path: str) -> bool:
773
+ """Release a lock for *file_path* owned by this developer, ignoring agent id.
774
+
775
+ Used by the human watcher to clean up the developer's own AI-agent locks for files
776
+ that are no longer in progress. It never touches other developers' locks. Returns
777
+ True when a delete was issued.
778
+ """
779
+ if _is_ephemeral_dev(DEVELOPER_ID):
780
+ return False
781
+ try:
782
+ client.table("file_locks").delete().eq("file_path", file_path).eq(
783
+ "developer_id", DEVELOPER_ID
784
+ ).execute()
785
+ except Exception:
786
+ logger.exception("Developer-scoped release failed for %s", file_path)
787
+ return False
788
+ _local_owned_locks.discard(file_path)
789
+ return True
790
+
791
+
792
+ def _fetch_dev_other_identity_locks(client) -> dict[str, dict]:
793
+ """Return ``{file_path: lock}`` for same-developer locks NOT owned by us.
794
+
795
+ When the watcher runs as the human (the default), these are the developer's
796
+ AI-agent locks. Strict attribution requires the watcher to never fight or
797
+ downgrade them: it skips acquiring such files and only cleans them up once the
798
+ file is no longer in progress (mirrors the ``collab watch`` reconcile).
799
+ """
800
+ try:
801
+ res = (
802
+ client.table("file_locks")
803
+ .select("*")
804
+ .eq("developer_id", DEVELOPER_ID)
805
+ .execute()
806
+ )
807
+ rows = getattr(res, "data", None) or []
808
+ except Exception as exc:
809
+ logger.debug("Failed to fetch developer locks for attribution: %s", exc)
810
+ return {}
811
+ out: dict[str, dict] = {}
812
+ for lock in rows:
813
+ fp = lock.get("file_path", "")
814
+ if fp and not _lock_owned_by_us(lock):
815
+ out[fp] = lock
816
+ return out
817
+
818
+
819
+ def _filter_agent_held_new_files(client, new_files: set[str]) -> set[str]:
820
+ """Drop files already held by this developer's AI agent from the auto-lock set.
821
+
822
+ Prevents the human watcher from fighting (and logging false CONFLICTs for) the
823
+ developer's own agent locks. The ``acquire_lock`` RPC lets an agent take over a
824
+ human auto-lock, never the reverse, so the human watcher must simply step aside.
825
+ """
826
+ if not new_files:
827
+ return new_files
828
+ dev_agent_held = set(_fetch_dev_other_identity_locks(client))
829
+ skipped = new_files & dev_agent_held
830
+ if not skipped:
831
+ return new_files
832
+ for fp in sorted(skipped):
833
+ logger.debug("Skipping auto-lock for %s — held by this developer's agent", fp)
834
+ return new_files - dev_agent_held
835
+
836
+
752
837
  def _start_dashboard_server() -> str | None:
753
838
  """Start a local HTTP server serving the dashboard and return the URL.
754
839
 
@@ -970,8 +1055,24 @@ def _reconcile_on_startup(client) -> None:
970
1055
  except Exception:
971
1056
  logger.exception("Failed to release stale lock for %s", fp)
972
1057
 
973
- # Step D: Acquire locks for dirty files that have no existing lock
1058
+ # Step D: Acquire locks for dirty files that have no existing lock.
974
1059
  unlocked_dirty = dirty_files - locked_paths
1060
+
1061
+ # Strict attribution: never fight this developer's AI-agent locks. Skip
1062
+ # acquiring files already held by the same developer under another identity,
1063
+ # and clean up such locks for files that are no longer in progress.
1064
+ dev_other = _fetch_dev_other_identity_locks(client)
1065
+ if dev_other:
1066
+ unlocked_dirty -= set(dev_other)
1067
+ for fp in sorted(set(dev_other) - dirty_files):
1068
+ if _release_developer_scope(client, fp):
1069
+ n_stale_released += 1
1070
+ msg = (
1071
+ f"🔓 [STALE-RELEASED] {fp} — agent lock for clean file, "
1072
+ "releasing"
1073
+ )
1074
+ logger.info(_color(msg, Fore.MAGENTA) if _HAS_COLORAMA else msg)
1075
+
975
1076
  for fp in sorted(unlocked_dirty):
976
1077
  if _should_ignore_path(fp):
977
1078
  continue
@@ -1647,10 +1748,19 @@ def main() -> None:
1647
1748
  sys.exit(1)
1648
1749
 
1649
1750
  # Normalize developer ID aggressively to avoid token divergence between IDEs
1650
- global DEVELOPER_ID, AGENT_ID, AGENT_LABEL, SESSION_TOKEN, PID_FILE
1751
+ global DEVELOPER_ID, AGENT_ID, AGENT_LABEL, AGENT_KIND, SESSION_TOKEN, PID_FILE
1651
1752
  DEVELOPER_ID = _get_developer_id().strip()
1652
1753
  AGENT_ID = agent_identity.resolve_agent_id(_COLLAB_ROOT)
1653
1754
  AGENT_LABEL = agent_identity.resolve_agent_label()
1755
+ AGENT_KIND = agent_identity.resolve_agent_kind(agent_id=AGENT_ID)
1756
+ # Strict attribution (parity with ``collab watch``): the background watcher —
1757
+ # including this PyCharm entrypoint — attributes bulk git-status auto-locks to
1758
+ # the HUMAN developer, never to an AI agent, even when launched from a terminal
1759
+ # that exported COLLAB_AGENT_ID/COLLAB_AGENT_MODE. A dedicated agent watcher
1760
+ # can opt in explicitly via COLLAB_WATCHER_AGENT_ID.
1761
+ AGENT_ID, AGENT_LABEL, AGENT_KIND = _resolve_watcher_identity(
1762
+ AGENT_ID, AGENT_LABEL, AGENT_KIND
1763
+ )
1654
1764
  if not os.getenv("COLLAB_PID_FILE"):
1655
1765
  PID_FILE = agent_identity.resolve_daemon_pid_path(_COLLAB_ROOT, AGENT_ID)
1656
1766
 
@@ -1848,6 +1958,10 @@ def main() -> None:
1848
1958
 
1849
1959
  # New files to lock
1850
1960
  new_files = current_modified - last_modified
1961
+ # Strict attribution: never auto-lock (as the human) a file that
1962
+ # this developer's AI agent already holds — that would create
1963
+ # false CONFLICT noise and fight the agent.
1964
+ new_files = _filter_agent_held_new_files(client, new_files)
1851
1965
  # Delegate acquire/release logic to helper functions to allow
1852
1966
  # targeted unit tests to exercise error/fallback branches.
1853
1967
  _process_new_files(client, branch, new_files)
@@ -661,6 +661,8 @@ class LockClient:
661
661
  local_only: bool = False,
662
662
  agent_id: Optional[str] = None,
663
663
  agent_label: Optional[str] = None,
664
+ agent_kind: Optional[str] = None,
665
+ agent_mode: Optional[bool] = None,
664
666
  ) -> None:
665
667
  from typing import cast
666
668
 
@@ -669,15 +671,21 @@ class LockClient:
669
671
  developer_id or os.getenv("COLLAB_DEVELOPER_ID") or self._get_git_username()
670
672
  )
671
673
  state_dir = _get_state_dir()
672
- runtime_label = agent_identity.detect_agent_runtime_label()
673
674
  self.agent_id = agent_identity.resolve_agent_id(
674
675
  state_dir,
675
676
  explicit_agent_id=agent_id,
677
+ agent_mode=agent_mode,
676
678
  )
677
679
  self.agent_label = agent_identity.resolve_agent_label(
678
680
  explicit_label=agent_label,
679
- runtime_label=runtime_label,
680
681
  )
682
+ # Runtime family (cursor/claude-code/...) for friendly display only, and
683
+ # the authoritative attribution origin (human vs agent).
684
+ self.agent_kind = agent_identity.resolve_agent_kind(
685
+ explicit_kind=agent_kind,
686
+ agent_id=self.agent_id,
687
+ )
688
+ self.origin = agent_identity.resolve_origin(self.agent_id)
681
689
  _refresh_pid_file(self.agent_id)
682
690
  self._client: Optional[Any] = None
683
691
  self._branch_name: Optional[str] = None
@@ -1032,6 +1040,9 @@ class LockClient:
1032
1040
  "p_is_ephemeral": bool(getattr(self, "_is_ephemeral", False)),
1033
1041
  "p_agent_id": self.agent_id,
1034
1042
  "p_agent_label": self.agent_label,
1043
+ "p_origin": getattr(self, "origin", None)
1044
+ or agent_identity.resolve_origin(self.agent_id),
1045
+ "p_agent_kind": getattr(self, "agent_kind", None),
1035
1046
  }
1036
1047
 
1037
1048
  client = self._require_client()
@@ -1380,6 +1391,35 @@ class LockClient:
1380
1391
  count += 1
1381
1392
  return True, count, "Success"
1382
1393
 
1394
+ def _release_developer_scope(self, file_path: str) -> bool:
1395
+ """Release a lock owned by this *developer*, ignoring agent identity.
1396
+
1397
+ Used by the background watcher to clean up this developer's locks for files that
1398
+ are no longer in progress (e.g. after a push), regardless of whether the lock
1399
+ was created by the human auto-watcher or by an AI agent of the same developer.
1400
+ It never touches other developers' locks.
1401
+ """
1402
+ if getattr(self, "_is_ephemeral", False):
1403
+ return True
1404
+ try:
1405
+ client = self._require_client()
1406
+ norm = self._normalize_file_path(file_path)
1407
+ delete_query = (
1408
+ client.table("file_locks")
1409
+ .delete()
1410
+ .eq("file_path", norm)
1411
+ .eq("developer_id", self.developer_id)
1412
+ )
1413
+ res = _retry_on_network_error(lambda: delete_query.execute())
1414
+ except Exception as exc:
1415
+ logger.debug("Developer-scoped release failed for %s: %s", file_path, exc)
1416
+ return False
1417
+ status, data, error = self._parse_response(res)
1418
+ if error:
1419
+ logger.debug("Developer-scoped release error for %s: %s", file_path, error)
1420
+ return False
1421
+ return status in (200, 204) or data is not None
1422
+
1383
1423
  def history(self, file_path: Optional[str] = None, limit: int = 20) -> List[Dict]:
1384
1424
  """Fetch lock history records.
1385
1425
 
@@ -3140,11 +3180,18 @@ class LockClient:
3140
3180
  my_locks = {lk["file_path"] for lk in active if self._lock_owned_by_me(lk)}
3141
3181
  # Build lock_map for token checking
3142
3182
  lock_map: dict[str, dict] = {}
3183
+ # Locks held by THIS developer under a *different* identity. For the
3184
+ # background watcher (which runs as the human) these are the same
3185
+ # developer's AI-agent locks. We must never downgrade or fight them.
3186
+ dev_other_locked: set = set()
3143
3187
  for lk in active:
3188
+ fp = lk.get("file_path", "")
3189
+ if not fp:
3190
+ continue
3144
3191
  if self._lock_owned_by_me(lk):
3145
- fp = lk.get("file_path", "")
3146
- if fp:
3147
- lock_map[fp] = lk
3192
+ lock_map[fp] = lk
3193
+ elif lk.get("developer_id") == self.developer_id:
3194
+ dev_other_locked.add(fp)
3148
3195
  except LockServiceUnavailableError as e:
3149
3196
  logger.error("Error getting Supabase locks (service unavailable): %s", e)
3150
3197
  return git_modified
@@ -3152,11 +3199,26 @@ class LockClient:
3152
3199
  logger.error("Error getting Supabase locks: %s", e)
3153
3200
  return git_modified
3154
3201
 
3155
- # Calculate lock categories
3202
+ # Calculate lock categories. ``missing`` excludes files already held by
3203
+ # this developer under another (agent) identity so the human watcher does
3204
+ # not generate conflicts trying to re-lock an agent's file. Agent claims
3205
+ # take over human auto-locks atomically in the acquire_lock RPC instead.
3156
3206
  stale = my_locks - git_modified
3157
- missing = git_modified - my_locks
3207
+ missing = git_modified - my_locks - dev_other_locked
3158
3208
  still_valid = my_locks & git_modified
3159
3209
 
3210
+ # Clean up this developer's agent locks for work that is no longer in
3211
+ # progress (e.g. after a push). Keeps the dashboard tidy without an agent
3212
+ # having to explicitly release every file it touched.
3213
+ dev_other_stale = dev_other_locked - git_modified
3214
+ if dev_other_stale:
3215
+ for fp in sorted(dev_other_stale):
3216
+ logger.info(
3217
+ "🔓 [STALE-RELEASED] %s — agent lock for clean file, releasing",
3218
+ fp,
3219
+ )
3220
+ self._release_developer_scope(fp)
3221
+
3160
3222
  # Count categories for summary
3161
3223
  current_token = self._get_session_token()
3162
3224
  resumed_locks = []
@@ -93,7 +93,16 @@ def _run_cli() -> None:
93
93
  "--agent-label",
94
94
  dest="agent_label",
95
95
  default=None,
96
- help="Human-readable agent label (or set COLLAB_AGENT_LABEL)",
96
+ help="Human-readable agent task label (or set COLLAB_AGENT_LABEL)",
97
+ )
98
+ common.add_argument(
99
+ "--agent-kind",
100
+ dest="agent_kind",
101
+ default=None,
102
+ help=(
103
+ "AI runtime family for display, e.g. cursor|claude-code|copilot "
104
+ "(or set COLLAB_AGENT_KIND; auto-detected when possible)"
105
+ ),
97
106
  )
98
107
 
99
108
  parser = ArgumentParser(
@@ -107,6 +116,22 @@ def _run_cli() -> None:
107
116
  acq.add_argument("file_path")
108
117
  acq.add_argument("--reason", help="Reason for the lock")
109
118
 
119
+ # claim — runtime-agnostic entrypoint for AI agents / IDE hooks. Acquires
120
+ # one or more files as the current AI agent (origin=agent), auto-generating a
121
+ # stable agent identity when none is supplied. Designed to be invoked from
122
+ # any IDE's edit hook (Cursor, Claude Code, Copilot, Gemini, ...).
123
+ clm = sub.add_parser(
124
+ "claim",
125
+ help="Claim file(s) as an AI agent edit (origin=agent)",
126
+ )
127
+ clm.add_argument("file_paths", nargs="+")
128
+ clm.add_argument("--reason", help="Reason for the claim")
129
+ clm.add_argument(
130
+ "--label",
131
+ dest="claim_label",
132
+ help="Task label describing what the agent is working on",
133
+ )
134
+
110
135
  # release
111
136
  rel = sub.add_parser("release", help="Release a lock on a file")
112
137
  rel.add_argument("file_path")
@@ -238,16 +263,44 @@ def _run_cli() -> None:
238
263
  sys.exit(1)
239
264
 
240
265
  local_only = args.command in ("daemon-status", "daemon-stop")
266
+
267
+ # ``claim`` always runs as an AI agent. Enable agent mode so a stable agent
268
+ # identity is generated/persisted even when the caller (IDE hook) did not set
269
+ # COLLAB_AGENT_ID, and prefer the claim-specific task label.
270
+ resolved_agent_label = getattr(args, "agent_label", None)
271
+ agent_mode: bool | None = None
272
+ if args.command == "claim":
273
+ agent_mode = True
274
+ resolved_agent_label = (
275
+ getattr(args, "claim_label", None) or resolved_agent_label
276
+ )
277
+
241
278
  client = LockClient(
242
279
  local_only=local_only,
243
280
  agent_id=getattr(args, "agent_id", None),
244
- agent_label=getattr(args, "agent_label", None),
281
+ agent_label=resolved_agent_label,
282
+ agent_kind=getattr(args, "agent_kind", None),
283
+ agent_mode=agent_mode,
245
284
  )
246
285
 
286
+ # The background watcher must attribute bulk git-status auto-locks to the
287
+ # HUMAN developer, never to an AI agent — even when launched from a terminal
288
+ # that exported agent env vars. A dedicated agent watcher can opt in with
289
+ # COLLAB_WATCHER_AGENT_ID.
290
+ if args.command == "watch":
291
+ watcher_agent = os.getenv("COLLAB_WATCHER_AGENT_ID", "").strip()
292
+ if not watcher_agent:
293
+ client.agent_id = None
294
+ client.agent_label = None
295
+ client.agent_kind = None
296
+ client.origin = "human"
297
+ client._session_token = None
298
+
247
299
  # Keep CLI output clean by silencing noisy dependency logs (httpx, urllib3,
248
300
  # postgrest, supabase) for user-facing commands that can hit network APIs.
249
301
  quiet_commands = {
250
302
  "acquire",
303
+ "claim",
251
304
  "release",
252
305
  "active",
253
306
  "status",
@@ -274,6 +327,7 @@ def _run_cli() -> None:
274
327
  client.developer_id,
275
328
  client.agent_id,
276
329
  client.agent_label,
330
+ getattr(client, "agent_kind", None),
277
331
  )
278
332
  print(f"Developer: {summary['developer_id']}")
279
333
  print(f"Mode: {summary['mode']}")
@@ -281,6 +335,8 @@ def _run_cli() -> None:
281
335
  print(f"Agent ID: {summary['agent_id']}")
282
336
  if summary["agent_label"]:
283
337
  print(f"Agent label: {summary['agent_label']}")
338
+ if summary["agent_kind"]:
339
+ print(f"Agent kind: {summary['agent_kind']}")
284
340
  sys.exit(0)
285
341
 
286
342
  elif args.command == "acquire":
@@ -291,6 +347,16 @@ def _run_cli() -> None:
291
347
  print(f"✗ Failed to lock {args.file_path}: {msg}")
292
348
  sys.exit(0 if ok else 1)
293
349
 
350
+ elif args.command == "claim":
351
+ reason = args.reason or "AI agent edit"
352
+ ok, failed, _msg = client.acquire_multiple(args.file_paths, reason=reason)
353
+ claimed = [fp for fp in args.file_paths if fp not in failed]
354
+ if claimed:
355
+ print(f"✓ Claimed {len(claimed)} file(s) as agent.")
356
+ if failed:
357
+ print(f"✗ Could not claim: {', '.join(failed)}")
358
+ sys.exit(0 if ok else 1)
359
+
294
360
  elif args.command == "release":
295
361
  ok, msg = client.release(args.file_path)
296
362
  print(f"{'✓' if ok else '✗'} {msg}")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: collab-runtime
3
- Version: 0.4.2
3
+ Version: 0.5.0
4
4
  Summary: Collaborative file locking runtime
5
5
  Author-email: KirilMT <kiril.mt95@gmail.com>
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "collab-runtime"
7
- version = "0.4.2"
7
+ version = "0.5.0"
8
8
  description = "Collaborative file locking runtime"
9
9
  readme = "docs/pypi/README.md"
10
10
  license = "MIT"
File without changes
File without changes