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.
- {collab_runtime-0.4.2/collab_runtime.egg-info → collab_runtime-0.5.0}/PKG-INFO +1 -1
- {collab_runtime-0.4.2 → collab_runtime-0.5.0}/README.md +34 -12
- {collab_runtime-0.4.2 → collab_runtime-0.5.0}/collab/agent_identity.py +52 -5
- {collab_runtime-0.4.2 → collab_runtime-0.5.0}/collab/dashboard/index.html +138 -11
- {collab_runtime-0.4.2 → collab_runtime-0.5.0}/collab/dashboard_server.py +2 -0
- {collab_runtime-0.4.2 → collab_runtime-0.5.0}/collab/live_locks_watcher.py +116 -2
- {collab_runtime-0.4.2 → collab_runtime-0.5.0}/collab/lock_client.py +69 -7
- {collab_runtime-0.4.2 → collab_runtime-0.5.0}/collab/main.py +68 -2
- {collab_runtime-0.4.2 → collab_runtime-0.5.0/collab_runtime.egg-info}/PKG-INFO +1 -1
- {collab_runtime-0.4.2 → collab_runtime-0.5.0}/pyproject.toml +1 -1
- {collab_runtime-0.4.2 → collab_runtime-0.5.0}/LICENSE +0 -0
- {collab_runtime-0.4.2 → collab_runtime-0.5.0}/collab/__init__.py +0 -0
- {collab_runtime-0.4.2 → collab_runtime-0.5.0}/collab/__main__.py +0 -0
- {collab_runtime-0.4.2 → collab_runtime-0.5.0}/collab/dashboard/dashboard-format.js +0 -0
- {collab_runtime-0.4.2 → collab_runtime-0.5.0}/collab/errors.py +0 -0
- {collab_runtime-0.4.2 → collab_runtime-0.5.0}/collab/logging_config.py +0 -0
- {collab_runtime-0.4.2 → collab_runtime-0.5.0}/collab/platform_probe.py +0 -0
- {collab_runtime-0.4.2 → collab_runtime-0.5.0}/collab/safe_subprocess.py +0 -0
- {collab_runtime-0.4.2 → collab_runtime-0.5.0}/collab/subprocess_bridge.py +0 -0
- {collab_runtime-0.4.2 → collab_runtime-0.5.0}/collab_runtime.egg-info/SOURCES.txt +0 -0
- {collab_runtime-0.4.2 → collab_runtime-0.5.0}/collab_runtime.egg-info/dependency_links.txt +0 -0
- {collab_runtime-0.4.2 → collab_runtime-0.5.0}/collab_runtime.egg-info/entry_points.txt +0 -0
- {collab_runtime-0.4.2 → collab_runtime-0.5.0}/collab_runtime.egg-info/requires.txt +0 -0
- {collab_runtime-0.4.2 → collab_runtime-0.5.0}/collab_runtime.egg-info/top_level.txt +0 -0
- {collab_runtime-0.4.2 → collab_runtime-0.5.0}/docs/pypi/README.md +0 -0
- {collab_runtime-0.4.2 → collab_runtime-0.5.0}/setup.cfg +0 -0
|
@@ -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
|
|
85
|
-
| `
|
|
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
|
|
106
|
-
`agent_label` columns
|
|
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
|
|
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, "&")
|
|
814
|
+
.replace(/</g , "<") .replace( />/g, ">")
|
|
815
|
+
.replace(/"/g, """)
|
|
816
|
+
.replace(/'/g, "'");
|
|
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
|
|
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
|
-
|
|
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
|
|
3146
|
-
|
|
3147
|
-
|
|
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=
|
|
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}")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|