@phnx-labs/agents-cli 1.20.76 → 1.20.78
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +264 -0
- package/README.md +2 -0
- package/dist/bin/agents +0 -0
- package/dist/commands/doctor.d.ts +9 -0
- package/dist/commands/doctor.js +143 -9
- package/dist/commands/exec.js +87 -19
- package/dist/commands/repo.js +8 -0
- package/dist/commands/routines.js +275 -15
- package/dist/commands/secrets.js +65 -17
- package/dist/commands/sessions-browser.d.ts +45 -3
- package/dist/commands/sessions-browser.js +128 -12
- package/dist/commands/sessions-export.d.ts +3 -0
- package/dist/commands/sessions-export.js +13 -10
- package/dist/commands/sessions-picker.d.ts +28 -1
- package/dist/commands/sessions-picker.js +222 -15
- package/dist/commands/sessions.d.ts +117 -4
- package/dist/commands/sessions.js +350 -62
- package/dist/commands/ssh.d.ts +13 -0
- package/dist/commands/ssh.js +48 -3
- package/dist/index.js +5 -6
- package/dist/lib/activity.d.ts +5 -4
- package/dist/lib/activity.js +450 -36
- package/dist/lib/agents.d.ts +0 -21
- package/dist/lib/agents.js +0 -37
- package/dist/lib/auto-pull.d.ts +16 -8
- package/dist/lib/auto-pull.js +23 -28
- package/dist/lib/cloud/session-index.js +2 -2
- package/dist/lib/daemon.d.ts +9 -32
- package/dist/lib/daemon.js +70 -84
- package/dist/lib/devices/fleet-divergence.d.ts +101 -0
- package/dist/lib/devices/fleet-divergence.js +188 -0
- package/dist/lib/devices/fleet-inventory.d.ts +19 -0
- package/dist/lib/devices/fleet-inventory.js +57 -0
- package/dist/lib/devices/fleet.d.ts +16 -1
- package/dist/lib/devices/fleet.js +10 -2
- package/dist/lib/devices/health-report.d.ts +10 -2
- package/dist/lib/devices/health-report.js +32 -1
- package/dist/lib/events.d.ts +1 -1
- package/dist/lib/exec.d.ts +16 -0
- package/dist/lib/exec.js +27 -4
- package/dist/lib/feed.d.ts +7 -1
- package/dist/lib/feed.js +96 -6
- package/dist/lib/git.d.ts +13 -0
- package/dist/lib/git.js +102 -4
- package/dist/lib/heal.d.ts +7 -4
- package/dist/lib/heal.js +10 -22
- package/dist/lib/hosts/dispatch.d.ts +9 -0
- package/dist/lib/hosts/dispatch.js +24 -4
- package/dist/lib/hosts/passthrough.js +7 -2
- package/dist/lib/hosts/remote-cmd.d.ts +11 -0
- package/dist/lib/hosts/remote-cmd.js +33 -8
- package/dist/lib/hosts/remote-session-id.d.ts +45 -0
- package/dist/lib/hosts/remote-session-id.js +84 -0
- package/dist/lib/hosts/run-target.d.ts +4 -0
- package/dist/lib/hosts/run-target.js +5 -1
- package/dist/lib/hosts/session-index.js +3 -3
- package/dist/lib/menubar/MenubarHelper.app/Contents/Info.plist +2 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/Resources/AppIcon.icns +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/_CodeSignature/CodeResources +15 -2
- package/dist/lib/menubar/install-menubar.d.ts +9 -0
- package/dist/lib/menubar/install-menubar.js +14 -0
- package/dist/lib/menubar/notify-desktop.d.ts +44 -0
- package/dist/lib/menubar/notify-desktop.js +78 -0
- package/dist/lib/overdue.d.ts +4 -5
- package/dist/lib/overdue.js +8 -41
- package/dist/lib/routine-notify.d.ts +76 -0
- package/dist/lib/routine-notify.js +190 -0
- package/dist/lib/routines-placement.d.ts +38 -0
- package/dist/lib/routines-placement.js +79 -0
- package/dist/lib/routines-project.d.ts +97 -0
- package/dist/lib/routines-project.js +349 -0
- package/dist/lib/routines.d.ts +68 -0
- package/dist/lib/routines.js +74 -7
- package/dist/lib/runner.d.ts +12 -2
- package/dist/lib/runner.js +156 -28
- package/dist/lib/sandbox.js +0 -6
- package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/Info.plist +2 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/Resources/AppIcon.icns +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/_CodeSignature/CodeResources +13 -1
- package/dist/lib/secrets/agent.d.ts +11 -4
- package/dist/lib/secrets/agent.js +46 -24
- package/dist/lib/secrets/bundles.d.ts +21 -7
- package/dist/lib/secrets/bundles.js +82 -38
- package/dist/lib/secrets/index.d.ts +11 -2
- package/dist/lib/secrets/index.js +21 -9
- package/dist/lib/secrets/scope.d.ts +26 -0
- package/dist/lib/secrets/scope.js +29 -0
- package/dist/lib/secrets/session-store.d.ts +16 -2
- package/dist/lib/secrets/session-store.js +118 -15
- package/dist/lib/secrets/unlock-hints.d.ts +27 -0
- package/dist/lib/secrets/unlock-hints.js +36 -0
- package/dist/lib/session/active.d.ts +17 -0
- package/dist/lib/session/active.js +105 -7
- package/dist/lib/session/db.d.ts +43 -0
- package/dist/lib/session/db.js +191 -6
- package/dist/lib/session/digest.js +126 -21
- package/dist/lib/session/discover.d.ts +28 -3
- package/dist/lib/session/discover.js +110 -14
- package/dist/lib/session/origin-machine.d.ts +18 -0
- package/dist/lib/session/origin-machine.js +34 -0
- package/dist/lib/session/parse.js +11 -2
- package/dist/lib/session/prompt.d.ts +7 -0
- package/dist/lib/session/prompt.js +37 -0
- package/dist/lib/session/provenance.d.ts +7 -0
- package/dist/lib/session/render.js +18 -16
- package/dist/lib/session/state.d.ts +5 -0
- package/dist/lib/session/state.js +94 -12
- package/dist/lib/session/sync/config.js +2 -2
- package/dist/lib/session/types.d.ts +25 -1
- package/dist/lib/session/types.js +8 -0
- package/dist/lib/smart-launch.d.ts +86 -0
- package/dist/lib/smart-launch.js +172 -0
- package/dist/lib/state.d.ts +7 -3
- package/dist/lib/state.js +20 -5
- package/dist/lib/types.d.ts +10 -0
- package/package.json +1 -1
package/dist/lib/activity.js
CHANGED
|
@@ -34,6 +34,8 @@ export const MILESTONE_EVENTS = [
|
|
|
34
34
|
'pushed',
|
|
35
35
|
'subagent.spawned',
|
|
36
36
|
'artifact.created',
|
|
37
|
+
'task.completed',
|
|
38
|
+
'checklist.created',
|
|
37
39
|
'status.posted',
|
|
38
40
|
];
|
|
39
41
|
const MILESTONE_SET = new Set(MILESTONE_EVENTS);
|
|
@@ -238,6 +240,8 @@ export const EVENT_STYLE = {
|
|
|
238
240
|
'pushed': { glyph: '↥', color: chalk.yellow, label: 'pushed' },
|
|
239
241
|
'subagent.spawned': { glyph: '⑂', color: chalk.magenta, label: 'sub-agent spawned' },
|
|
240
242
|
'artifact.created': { glyph: '▤', color: chalk.cyan, label: 'artifact' },
|
|
243
|
+
'task.completed': { glyph: '✓', color: chalk.green, label: 'task completed' },
|
|
244
|
+
'checklist.created': { glyph: '☐', color: chalk.cyan, label: 'checklist created' },
|
|
241
245
|
'status.posted': { glyph: '▸', color: chalk.white, label: 'status' },
|
|
242
246
|
'file.edited': { glyph: '·', color: chalk.gray, label: 'file edited' },
|
|
243
247
|
};
|
|
@@ -266,15 +270,17 @@ export function formatActivityLine(ev, opts = {}) {
|
|
|
266
270
|
* line per event to ~/.agents/.history/activity/<sessionId>.jsonl.
|
|
267
271
|
*
|
|
268
272
|
* Matcher-gated to mutating/milestone tools (Bash|Task|ExitPlanMode|Write|
|
|
269
|
-
* Edit|MultiEdit) so
|
|
270
|
-
*
|
|
273
|
+
* Edit|MultiEdit|TodoWrite|update_plan|TaskUpdate|todo_write|TaskCreate) so
|
|
274
|
+
* read-only tools never pay the hook cost. Fail-open: any error is swallowed so
|
|
275
|
+
* a logging hiccup never blocks a tool call.
|
|
271
276
|
*/
|
|
272
277
|
export const ACTIVITY_LOG_HOOK_SCRIPT = `#!/usr/bin/env python3
|
|
273
278
|
"""Append agent-activity events for \`agents feed\` / \`agents activity\`.
|
|
274
279
|
|
|
275
280
|
Bound to PreToolUse (ExitPlanMode, Task) and PostToolUse (Bash, Write, Edit,
|
|
276
|
-
MultiEdit
|
|
277
|
-
|
|
281
|
+
MultiEdit, TodoWrite, update_plan, TaskUpdate, todo_write, TaskCreate). One
|
|
282
|
+
append-only file per session; read-only tools never trigger it because the
|
|
283
|
+
manifest matcher excludes them.
|
|
278
284
|
|
|
279
285
|
Sub-agent gate: when the payload carries \`agent_type\`, this is a Task/Agent
|
|
280
286
|
sub-agent -- skip so only the top-level agent logs its own activity.
|
|
@@ -293,7 +299,7 @@ MAX_LOG_BYTES = 5 * 1024 * 1024 # cap a pathological session's log
|
|
|
293
299
|
MILESTONE_EVENTS = {
|
|
294
300
|
"plan.created", "pr.opened", "pr.merged", "worktree.created",
|
|
295
301
|
"worktree.removed", "commit.created", "pushed", "subagent.spawned",
|
|
296
|
-
"artifact.created", "status.posted",
|
|
302
|
+
"artifact.created", "task.completed", "checklist.created", "status.posted",
|
|
297
303
|
}
|
|
298
304
|
|
|
299
305
|
# Deliverable file types + locations -- a Write here is a recognizable artifact
|
|
@@ -304,6 +310,14 @@ ARTIFACT_EXTS = {
|
|
|
304
310
|
}
|
|
305
311
|
ARTIFACT_DIR_HINTS = ("/tmp/", "/downloads/", "/.agents/artifacts/")
|
|
306
312
|
|
|
313
|
+
# Checklist tools across harnesses. The value is the key that holds the item list.
|
|
314
|
+
CHECKLIST_TOOLS = {
|
|
315
|
+
"TodoWrite": "todos",
|
|
316
|
+
"todo_write": "todos",
|
|
317
|
+
"TaskUpdate": "tasks",
|
|
318
|
+
"update_plan": "plan",
|
|
319
|
+
}
|
|
320
|
+
|
|
307
321
|
|
|
308
322
|
def is_artifact(file_path):
|
|
309
323
|
low = (file_path or "").lower()
|
|
@@ -379,11 +393,418 @@ def extract_url(tool_response):
|
|
|
379
393
|
return m.group(0).rstrip(").,") if m else None
|
|
380
394
|
|
|
381
395
|
|
|
396
|
+
def _checklist_items(tool_input, kind):
|
|
397
|
+
"""Extract normalized checklist items from tool input."""
|
|
398
|
+
if not isinstance(tool_input, dict):
|
|
399
|
+
return []
|
|
400
|
+
if kind == "todos":
|
|
401
|
+
arr = tool_input.get("todos") or []
|
|
402
|
+
elif kind == "tasks":
|
|
403
|
+
arr = tool_input.get("tasks") or []
|
|
404
|
+
if not arr and "taskId" in tool_input:
|
|
405
|
+
arr = [tool_input]
|
|
406
|
+
elif kind == "plan":
|
|
407
|
+
arr = tool_input.get("plan") or []
|
|
408
|
+
else:
|
|
409
|
+
return []
|
|
410
|
+
if not isinstance(arr, list):
|
|
411
|
+
return []
|
|
412
|
+
|
|
413
|
+
items = []
|
|
414
|
+
for t in arr:
|
|
415
|
+
if not isinstance(t, dict):
|
|
416
|
+
continue
|
|
417
|
+
subject = (
|
|
418
|
+
t.get("content") or
|
|
419
|
+
t.get("text") or
|
|
420
|
+
t.get("step") or
|
|
421
|
+
t.get("title") or
|
|
422
|
+
t.get("description") or
|
|
423
|
+
t.get("activeForm") or
|
|
424
|
+
""
|
|
425
|
+
)
|
|
426
|
+
if not isinstance(subject, str):
|
|
427
|
+
subject = str(subject)
|
|
428
|
+
subject = subject.strip()
|
|
429
|
+
item_id = t.get("id") or t.get("taskId") or subject
|
|
430
|
+
if not item_id:
|
|
431
|
+
continue
|
|
432
|
+
status = str(t.get("status", "") or "").lower()
|
|
433
|
+
items.append({"id": item_id, "subject": subject, "status": status})
|
|
434
|
+
return items
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def _read_transcript_checklists(transcript_path, current_tool, current_items):
|
|
438
|
+
"""Tail-read the session transcript and return the previous checklist state.
|
|
439
|
+
|
|
440
|
+
The most recent checklist entry is assumed to be the current tool call if
|
|
441
|
+
it carries the same ids; skip that one and return the next older checklist
|
|
442
|
+
state (or None if there isn't one).
|
|
443
|
+
"""
|
|
444
|
+
if not transcript_path or not os.path.exists(transcript_path):
|
|
445
|
+
return None
|
|
446
|
+
try:
|
|
447
|
+
size = os.path.getsize(transcript_path)
|
|
448
|
+
start = max(0, size - 512 * 1024)
|
|
449
|
+
with open(transcript_path, "r", encoding="utf-8", errors="ignore") as f:
|
|
450
|
+
f.seek(start)
|
|
451
|
+
if start > 0:
|
|
452
|
+
f.readline() # drop a leading partial line
|
|
453
|
+
lines = f.readlines()
|
|
454
|
+
except Exception:
|
|
455
|
+
return None
|
|
456
|
+
|
|
457
|
+
current_ids = {str(item.get("id")) for item in current_items if item.get("id")}
|
|
458
|
+
skipped_current = False
|
|
459
|
+
for line in reversed(lines):
|
|
460
|
+
line = line.strip()
|
|
461
|
+
if not line:
|
|
462
|
+
continue
|
|
463
|
+
try:
|
|
464
|
+
record = json.loads(line)
|
|
465
|
+
except Exception:
|
|
466
|
+
continue
|
|
467
|
+
if not isinstance(record, dict):
|
|
468
|
+
continue
|
|
469
|
+
|
|
470
|
+
tool_uses = []
|
|
471
|
+
if isinstance(record.get("tool_use"), list):
|
|
472
|
+
tool_uses = record["tool_use"]
|
|
473
|
+
elif record.get("name"):
|
|
474
|
+
tool_uses = [record]
|
|
475
|
+
elif record.get("tool_name"):
|
|
476
|
+
tool_uses = [record]
|
|
477
|
+
|
|
478
|
+
for tu in reversed(tool_uses):
|
|
479
|
+
if not isinstance(tu, dict):
|
|
480
|
+
continue
|
|
481
|
+
name = tu.get("name") or tu.get("tool_name") or ""
|
|
482
|
+
if name not in CHECKLIST_TOOLS:
|
|
483
|
+
continue
|
|
484
|
+
kind = CHECKLIST_TOOLS[name]
|
|
485
|
+
args = tu.get("input") or tu.get("tool_input") or tu.get("arguments") or {}
|
|
486
|
+
if isinstance(args, str):
|
|
487
|
+
try:
|
|
488
|
+
args = json.loads(args)
|
|
489
|
+
except Exception:
|
|
490
|
+
continue
|
|
491
|
+
items = _checklist_items(args, kind)
|
|
492
|
+
if not items:
|
|
493
|
+
continue
|
|
494
|
+
ids = {str(item.get("id")) for item in items if item.get("id")}
|
|
495
|
+
# Skip the most recent matching checklist entry once; that is the
|
|
496
|
+
# current call already reflected in the transcript.
|
|
497
|
+
if not skipped_current and name == current_tool and ids == current_ids:
|
|
498
|
+
skipped_current = True
|
|
499
|
+
continue
|
|
500
|
+
return items
|
|
501
|
+
return None
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def _has_previous_task_create(transcript_path, current_tool_use_id=""):
|
|
505
|
+
"""Return True if the transcript contains a TaskCreate before the current one."""
|
|
506
|
+
if not transcript_path or not os.path.exists(transcript_path):
|
|
507
|
+
return False
|
|
508
|
+
try:
|
|
509
|
+
with open(transcript_path, "r", encoding="utf-8", errors="ignore") as f:
|
|
510
|
+
lines = f.readlines()
|
|
511
|
+
except Exception:
|
|
512
|
+
return False
|
|
513
|
+
|
|
514
|
+
for line in reversed(lines):
|
|
515
|
+
line = line.strip()
|
|
516
|
+
if not line:
|
|
517
|
+
continue
|
|
518
|
+
try:
|
|
519
|
+
record = json.loads(line)
|
|
520
|
+
except Exception:
|
|
521
|
+
continue
|
|
522
|
+
if not isinstance(record, dict):
|
|
523
|
+
continue
|
|
524
|
+
|
|
525
|
+
tool_uses = []
|
|
526
|
+
msg = record.get("message", {})
|
|
527
|
+
if isinstance(msg, dict) and isinstance(msg.get("content"), list):
|
|
528
|
+
tool_uses = [
|
|
529
|
+
c for c in msg["content"]
|
|
530
|
+
if isinstance(c, dict) and c.get("type") == "tool_use"
|
|
531
|
+
]
|
|
532
|
+
elif isinstance(record.get("tool_use"), list):
|
|
533
|
+
tool_uses = record["tool_use"]
|
|
534
|
+
elif record.get("name"):
|
|
535
|
+
tool_uses = [record]
|
|
536
|
+
|
|
537
|
+
for tu in reversed(tool_uses):
|
|
538
|
+
if not isinstance(tu, dict):
|
|
539
|
+
continue
|
|
540
|
+
if (tu.get("name") or tu.get("tool_name")) == "TaskCreate":
|
|
541
|
+
if tu.get("id") != current_tool_use_id:
|
|
542
|
+
return True
|
|
543
|
+
return False
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def _claude_task_state(transcript_path, exclude_task_id=None):
|
|
547
|
+
"""Fold Claude TaskCreate/TaskUpdate calls into a task id -> subject/status map.
|
|
548
|
+
|
|
549
|
+
TaskCreate provides the subject (and id via toolUseResult); TaskUpdate
|
|
550
|
+
provides the status. If exclude_task_id is given, the last TaskUpdate for
|
|
551
|
+
that id is skipped (it is the current call already reflected in the
|
|
552
|
+
transcript).
|
|
553
|
+
"""
|
|
554
|
+
state = {}
|
|
555
|
+
if not transcript_path or not os.path.exists(transcript_path):
|
|
556
|
+
return state
|
|
557
|
+
try:
|
|
558
|
+
with open(transcript_path, "r", encoding="utf-8", errors="ignore") as f:
|
|
559
|
+
lines = f.readlines()
|
|
560
|
+
except Exception:
|
|
561
|
+
return state
|
|
562
|
+
|
|
563
|
+
updates = [] # (task_id, status, line_index)
|
|
564
|
+
for idx, line in enumerate(lines):
|
|
565
|
+
line = line.strip()
|
|
566
|
+
if not line:
|
|
567
|
+
continue
|
|
568
|
+
try:
|
|
569
|
+
record = json.loads(line)
|
|
570
|
+
except Exception:
|
|
571
|
+
continue
|
|
572
|
+
if not isinstance(record, dict):
|
|
573
|
+
continue
|
|
574
|
+
|
|
575
|
+
tool_uses = []
|
|
576
|
+
msg = record.get("message", {})
|
|
577
|
+
if isinstance(msg, dict) and isinstance(msg.get("content"), list):
|
|
578
|
+
tool_uses = [
|
|
579
|
+
c for c in msg["content"]
|
|
580
|
+
if isinstance(c, dict) and c.get("type") == "tool_use"
|
|
581
|
+
]
|
|
582
|
+
elif isinstance(record.get("tool_use"), list):
|
|
583
|
+
tool_uses = record["tool_use"]
|
|
584
|
+
elif record.get("name"):
|
|
585
|
+
tool_uses = [record]
|
|
586
|
+
|
|
587
|
+
for tu in tool_uses:
|
|
588
|
+
if not isinstance(tu, dict):
|
|
589
|
+
continue
|
|
590
|
+
name = tu.get("name") or ""
|
|
591
|
+
args = tu.get("input") or tu.get("tool_input") or {}
|
|
592
|
+
if isinstance(args, str):
|
|
593
|
+
try:
|
|
594
|
+
args = json.loads(args)
|
|
595
|
+
except Exception:
|
|
596
|
+
continue
|
|
597
|
+
if name == "TaskCreate":
|
|
598
|
+
subject = (
|
|
599
|
+
args.get("subject") or
|
|
600
|
+
args.get("description") or
|
|
601
|
+
args.get("title") or
|
|
602
|
+
""
|
|
603
|
+
)
|
|
604
|
+
if subject:
|
|
605
|
+
# Link subject to the tool_use id so the result can map it.
|
|
606
|
+
tool_id = tu.get("id")
|
|
607
|
+
if tool_id:
|
|
608
|
+
state.setdefault("__pending_subject", {})[tool_id] = subject
|
|
609
|
+
|
|
610
|
+
tool_result = record.get("toolUseResult") or {}
|
|
611
|
+
if not isinstance(tool_result, dict):
|
|
612
|
+
continue
|
|
613
|
+
task = tool_result.get("task")
|
|
614
|
+
if isinstance(task, dict):
|
|
615
|
+
task_id = str(task.get("id") or task.get("taskId") or "")
|
|
616
|
+
if task_id:
|
|
617
|
+
state.setdefault(task_id, {})
|
|
618
|
+
if task.get("subject"):
|
|
619
|
+
state[task_id]["subject"] = task["subject"]
|
|
620
|
+
if task.get("status"):
|
|
621
|
+
state[task_id]["status"] = str(task.get("status")).lower()
|
|
622
|
+
# Link any pending subject from the matching tool_use id.
|
|
623
|
+
tool_use_id = record.get("tool_use_id") or ""
|
|
624
|
+
pending = state.get("__pending_subject", {})
|
|
625
|
+
if tool_use_id and tool_use_id in pending:
|
|
626
|
+
state[task_id]["subject"] = pending[tool_use_id]
|
|
627
|
+
task_id = str(tool_result.get("taskId") or "")
|
|
628
|
+
status_change = tool_result.get("statusChange") or {}
|
|
629
|
+
status = status_change.get("to") or tool_result.get("status")
|
|
630
|
+
if task_id and status:
|
|
631
|
+
state.setdefault(task_id, {})
|
|
632
|
+
state[task_id]["status"] = str(status).lower()
|
|
633
|
+
updates.append((task_id, str(status).lower(), idx))
|
|
634
|
+
|
|
635
|
+
if exclude_task_id and updates:
|
|
636
|
+
for i in range(len(updates) - 1, -1, -1):
|
|
637
|
+
if updates[i][0] == exclude_task_id:
|
|
638
|
+
del updates[i]
|
|
639
|
+
break
|
|
640
|
+
# Rebuild statuses from remaining updates.
|
|
641
|
+
for task_id in list(state.keys()):
|
|
642
|
+
if task_id.startswith("__"):
|
|
643
|
+
continue
|
|
644
|
+
if "subject" not in state[task_id]:
|
|
645
|
+
del state[task_id]
|
|
646
|
+
else:
|
|
647
|
+
state[task_id].pop("status", None)
|
|
648
|
+
for task_id, status, _ in updates:
|
|
649
|
+
if task_id in state:
|
|
650
|
+
state[task_id]["status"] = status
|
|
651
|
+
|
|
652
|
+
# Drop tasks removed from the checklist (deleted/cancelled): they are gone
|
|
653
|
+
# from the user-visible list, so they must not count toward the N/M total.
|
|
654
|
+
for task_id in list(state.keys()):
|
|
655
|
+
if task_id.startswith("__"):
|
|
656
|
+
continue
|
|
657
|
+
if state[task_id].get("status") in ("deleted", "cancelled", "canceled", "removed"):
|
|
658
|
+
del state[task_id]
|
|
659
|
+
|
|
660
|
+
# Drop internal bookkeeping.
|
|
661
|
+
state.pop("__pending_subject", None)
|
|
662
|
+
return state
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
def _checklist_events(payload, hook_event):
|
|
666
|
+
"""Return a list of (event, detail) tuples for checklist tool calls."""
|
|
667
|
+
if hook_event != "PostToolUse":
|
|
668
|
+
return []
|
|
669
|
+
tool_name = payload.get("tool_name", "")
|
|
670
|
+
tool_input = payload.get("tool_input", {}) or {}
|
|
671
|
+
|
|
672
|
+
if tool_name == "TaskCreate":
|
|
673
|
+
subject = (
|
|
674
|
+
tool_input.get("subject") or
|
|
675
|
+
tool_input.get("description") or
|
|
676
|
+
tool_input.get("title") or
|
|
677
|
+
"task"
|
|
678
|
+
)
|
|
679
|
+
# Only announce the checklist on the very first task creation.
|
|
680
|
+
current_tool_use_id = payload.get("tool_use_id", "")
|
|
681
|
+
transcript_path = payload.get("transcript_path")
|
|
682
|
+
if _has_previous_task_create(transcript_path, current_tool_use_id):
|
|
683
|
+
return []
|
|
684
|
+
return [("checklist.created", subject)]
|
|
685
|
+
|
|
686
|
+
if tool_name not in CHECKLIST_TOOLS:
|
|
687
|
+
return []
|
|
688
|
+
|
|
689
|
+
kind = CHECKLIST_TOOLS[tool_name]
|
|
690
|
+
items = _checklist_items(tool_input, kind)
|
|
691
|
+
if not items:
|
|
692
|
+
return []
|
|
693
|
+
|
|
694
|
+
# Full-list tools (TodoWrite, update_plan) send the whole checklist every
|
|
695
|
+
# time. Per-task update tools (TaskUpdate) send only the changed item, so
|
|
696
|
+
# totals and subject must be resolved from the previous full-list state.
|
|
697
|
+
list_key = {"todos": "todos", "tasks": "tasks", "plan": "plan"}.get(kind)
|
|
698
|
+
is_full_list = list_key is not None and list_key in tool_input
|
|
699
|
+
|
|
700
|
+
previous = _read_transcript_checklists(
|
|
701
|
+
payload.get("transcript_path"), tool_name, items
|
|
702
|
+
)
|
|
703
|
+
|
|
704
|
+
if not is_full_list and previous:
|
|
705
|
+
total = len(previous)
|
|
706
|
+
previous_done = {
|
|
707
|
+
str(i.get("id")) for i in previous
|
|
708
|
+
if i.get("status") == "completed" and i.get("id")
|
|
709
|
+
}
|
|
710
|
+
done_count = len(previous_done)
|
|
711
|
+
events = []
|
|
712
|
+
for item in items:
|
|
713
|
+
if item.get("status") != "completed":
|
|
714
|
+
continue
|
|
715
|
+
item_id = str(item.get("id"))
|
|
716
|
+
if item_id in previous_done:
|
|
717
|
+
continue
|
|
718
|
+
subject = item.get("subject")
|
|
719
|
+
if not subject:
|
|
720
|
+
for p in previous:
|
|
721
|
+
if str(p.get("id")) == item_id and p.get("subject"):
|
|
722
|
+
subject = p["subject"]
|
|
723
|
+
break
|
|
724
|
+
if not subject:
|
|
725
|
+
subject = "task"
|
|
726
|
+
done_count += 1
|
|
727
|
+
events.append(("task.completed", f"{subject} {done_count}/{total} done"))
|
|
728
|
+
return events
|
|
729
|
+
|
|
730
|
+
# For TaskUpdate without a previous full-list state, fold the Claude
|
|
731
|
+
# TaskCreate/TaskUpdate history to resolve subject and N/M.
|
|
732
|
+
if tool_name == "TaskUpdate" and previous is None:
|
|
733
|
+
task_id = str(items[0].get("id")) if items else ""
|
|
734
|
+
task_state = _claude_task_state(
|
|
735
|
+
payload.get("transcript_path"), exclude_task_id=task_id
|
|
736
|
+
)
|
|
737
|
+
if task_state:
|
|
738
|
+
total = len(task_state)
|
|
739
|
+
previous_done = {
|
|
740
|
+
tid for tid, info in task_state.items()
|
|
741
|
+
if info.get("status") == "completed"
|
|
742
|
+
}
|
|
743
|
+
done_count = len(previous_done)
|
|
744
|
+
if task_id in task_state and items[0].get("status") == "completed" and task_id not in previous_done:
|
|
745
|
+
subject = task_state[task_id].get("subject") or "task"
|
|
746
|
+
return [("task.completed", f"{subject} {done_count + 1}/{total} done")]
|
|
747
|
+
return []
|
|
748
|
+
|
|
749
|
+
total = len(items)
|
|
750
|
+
completed = [i for i in items if i.get("status") == "completed"]
|
|
751
|
+
|
|
752
|
+
if previous is None:
|
|
753
|
+
# First checklist call in this session.
|
|
754
|
+
events = []
|
|
755
|
+
events.append(("checklist.created", f"{total} task{'s' if total != 1 else ''}"))
|
|
756
|
+
for item in completed:
|
|
757
|
+
events.append((
|
|
758
|
+
"task.completed",
|
|
759
|
+
f"{item['subject']} {len(completed)}/{total} done",
|
|
760
|
+
))
|
|
761
|
+
return events
|
|
762
|
+
|
|
763
|
+
previous_done = {
|
|
764
|
+
str(i.get("id")) for i in previous
|
|
765
|
+
if i.get("status") == "completed" and i.get("id")
|
|
766
|
+
}
|
|
767
|
+
newly_completed = [
|
|
768
|
+
i for i in completed
|
|
769
|
+
if str(i.get("id")) not in previous_done
|
|
770
|
+
]
|
|
771
|
+
if not newly_completed:
|
|
772
|
+
return []
|
|
773
|
+
|
|
774
|
+
done_count = len(completed)
|
|
775
|
+
events = []
|
|
776
|
+
for item in newly_completed:
|
|
777
|
+
events.append((
|
|
778
|
+
"task.completed",
|
|
779
|
+
f"{item['subject']} {done_count}/{total} done",
|
|
780
|
+
))
|
|
781
|
+
return events
|
|
782
|
+
|
|
783
|
+
|
|
784
|
+
def _make_record(event, detail, tool_name):
|
|
785
|
+
tier = "milestone" if event in MILESTONE_EVENTS else "activity"
|
|
786
|
+
record = {
|
|
787
|
+
"v": 1,
|
|
788
|
+
"ts": datetime.now(timezone.utc).isoformat(),
|
|
789
|
+
"event": event,
|
|
790
|
+
"tier": tier,
|
|
791
|
+
}
|
|
792
|
+
if detail:
|
|
793
|
+
record["detail"] = detail
|
|
794
|
+
record["tool"] = tool_name
|
|
795
|
+
return record
|
|
796
|
+
|
|
797
|
+
|
|
382
798
|
def build_event(payload, hook_event):
|
|
383
799
|
tool_name = payload.get("tool_name", "")
|
|
384
800
|
tool_input = payload.get("tool_input", {}) or {}
|
|
385
801
|
tool_response = payload.get("tool_response", {})
|
|
386
802
|
|
|
803
|
+
# Checklist completions first -- cheap guard above already filtered tool name.
|
|
804
|
+
checklist = _checklist_events(payload, hook_event)
|
|
805
|
+
if checklist:
|
|
806
|
+
return [_make_record(event, detail, tool_name) for event, detail in checklist], tool_name
|
|
807
|
+
|
|
387
808
|
event = None
|
|
388
809
|
detail = None
|
|
389
810
|
url = None
|
|
@@ -414,21 +835,12 @@ def build_event(payload, hook_event):
|
|
|
414
835
|
detail = os.path.basename(fp) if fp else tool_name
|
|
415
836
|
|
|
416
837
|
if not event:
|
|
417
|
-
return
|
|
838
|
+
return [], tool_name
|
|
418
839
|
|
|
419
|
-
|
|
420
|
-
record = {
|
|
421
|
-
"v": 1,
|
|
422
|
-
"ts": datetime.now(timezone.utc).isoformat(),
|
|
423
|
-
"event": event,
|
|
424
|
-
"tier": tier,
|
|
425
|
-
}
|
|
426
|
-
if detail:
|
|
427
|
-
record["detail"] = detail
|
|
840
|
+
record = _make_record(event, detail, tool_name)
|
|
428
841
|
if url:
|
|
429
842
|
record["url"] = url
|
|
430
|
-
record
|
|
431
|
-
return record, tool_name
|
|
843
|
+
return [record], tool_name
|
|
432
844
|
|
|
433
845
|
|
|
434
846
|
def main():
|
|
@@ -447,8 +859,8 @@ def main():
|
|
|
447
859
|
return
|
|
448
860
|
|
|
449
861
|
hook_event = payload.get("hook_event_name", "")
|
|
450
|
-
|
|
451
|
-
if not
|
|
862
|
+
records, tool_name = build_event(payload, hook_event)
|
|
863
|
+
if not records:
|
|
452
864
|
return
|
|
453
865
|
|
|
454
866
|
safe_session = re.sub(r"[^A-Za-z0-9._-]", "-", session_id) or "unknown"
|
|
@@ -463,27 +875,29 @@ def main():
|
|
|
463
875
|
hostname = os.environ.get("AGENTS_SYNC_MACHINE_ID") or socket.gethostname()
|
|
464
876
|
host = re.sub(r"[^a-z0-9_-]", "-", hostname.split(".")[0].strip().lower()) or "unknown"
|
|
465
877
|
|
|
466
|
-
record
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
878
|
+
for record in records:
|
|
879
|
+
record["sessionId"] = session_id
|
|
880
|
+
record["mailboxId"] = mailbox_id
|
|
881
|
+
record["host"] = host
|
|
882
|
+
record["runtime"] = os.environ.get("AGENTS_RUNTIME", "headless")
|
|
883
|
+
cwd = payload.get("cwd") or os.environ.get("AGENTS_CWD")
|
|
884
|
+
if cwd:
|
|
885
|
+
record["cwd"] = cwd
|
|
886
|
+
agent = os.environ.get("AGENTS_AGENT_NAME") or "claude"
|
|
887
|
+
record["agent"] = agent
|
|
475
888
|
|
|
476
889
|
try:
|
|
477
890
|
os.makedirs(activity_dir, exist_ok=True)
|
|
478
891
|
# Cap growth: once over the size limit, keep only milestones.
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
except OSError:
|
|
484
|
-
pass
|
|
892
|
+
try:
|
|
893
|
+
over_limit = os.path.getsize(target) > MAX_LOG_BYTES
|
|
894
|
+
except OSError:
|
|
895
|
+
over_limit = False
|
|
485
896
|
with open(target, "a") as f:
|
|
486
|
-
|
|
897
|
+
for record in records:
|
|
898
|
+
if over_limit and record.get("tier") != "milestone":
|
|
899
|
+
continue
|
|
900
|
+
f.write(json.dumps(record) + "\\n")
|
|
487
901
|
except Exception:
|
|
488
902
|
pass # fail open
|
|
489
903
|
|
|
@@ -506,7 +920,7 @@ export const ACTIVITY_HOOK_DEFINITIONS = {
|
|
|
506
920
|
'activity-log-result': {
|
|
507
921
|
agents: ['claude'],
|
|
508
922
|
events: ['PostToolUse'],
|
|
509
|
-
matcher: 'Bash|Write|Edit|MultiEdit',
|
|
923
|
+
matcher: 'Bash|Write|Edit|MultiEdit|TodoWrite|update_plan|TaskUpdate|todo_write|TaskCreate',
|
|
510
924
|
script: '11-activity-log.py',
|
|
511
925
|
timeout: 5,
|
|
512
926
|
},
|
package/dist/lib/agents.d.ts
CHANGED
|
@@ -265,27 +265,6 @@ export declare function __resetAntigravityKeychainCacheForTest(): void;
|
|
|
265
265
|
* Sync, no Keychain, no network — safe on the `agents run` hot path.
|
|
266
266
|
*/
|
|
267
267
|
export declare function isClaudeCredentialFileBlank(base: string, platform?: NodeJS.Platform): boolean;
|
|
268
|
-
/**
|
|
269
|
-
* Whether a Claude version home holds its OWN on-disk credential — a non-blank
|
|
270
|
-
* `.claude/.credentials.json` (the store Claude Code writes on login off macOS)
|
|
271
|
-
* or a `.claude/.oauth_token` file (the keychain-less shim fallback, shims.ts).
|
|
272
|
-
*
|
|
273
|
-
* RUSH-1979: the routines daemon injects one ambient `CLAUDE_CODE_OAUTH_TOKEN`
|
|
274
|
-
* (RUSH-1759) so a token-less default account still authenticates. But Claude —
|
|
275
|
-
* and the Linux shim's own `-z CLAUDE_CODE_OAUTH_TOKEN` guard — both prefer that
|
|
276
|
-
* env var over a pinned account's config dir, so when balanced rotation pins a
|
|
277
|
-
* specific account's `CLAUDE_CONFIG_DIR` the ambient token shadows it: the pool
|
|
278
|
-
* is inert and every fire 401s on the one stale token. A routine spawn drops the
|
|
279
|
-
* ambient token when the pinned account has its own credential (this returns
|
|
280
|
-
* true) so the account authenticates itself; when it does not (the RUSH-1759
|
|
281
|
-
* default), the injected token is kept.
|
|
282
|
-
*
|
|
283
|
-
* Off-darwin only, mirroring {@link isClaudeCredentialFileBlank}: on macOS the
|
|
284
|
-
* login Keychain is the canonical store and probing it raises an auth sheet, and
|
|
285
|
-
* there the daemon injects no token to shadow (broker-only, usually absent), so
|
|
286
|
-
* returning false changes nothing. Sync, no Keychain, no network.
|
|
287
|
-
*/
|
|
288
|
-
export declare function claudeHomeHasOwnCredential(base: string, platform?: NodeJS.Platform): boolean;
|
|
289
268
|
export declare function getAccountInfo(agentId: AgentId, home?: string): Promise<AccountInfo>;
|
|
290
269
|
/**
|
|
291
270
|
* Determine when the agent was last used by checking session file mtimes,
|
package/dist/lib/agents.js
CHANGED
|
@@ -1404,43 +1404,6 @@ export function isClaudeCredentialFileBlank(base, platform = process.platform) {
|
|
|
1404
1404
|
return false;
|
|
1405
1405
|
}
|
|
1406
1406
|
}
|
|
1407
|
-
/**
|
|
1408
|
-
* Whether a Claude version home holds its OWN on-disk credential — a non-blank
|
|
1409
|
-
* `.claude/.credentials.json` (the store Claude Code writes on login off macOS)
|
|
1410
|
-
* or a `.claude/.oauth_token` file (the keychain-less shim fallback, shims.ts).
|
|
1411
|
-
*
|
|
1412
|
-
* RUSH-1979: the routines daemon injects one ambient `CLAUDE_CODE_OAUTH_TOKEN`
|
|
1413
|
-
* (RUSH-1759) so a token-less default account still authenticates. But Claude —
|
|
1414
|
-
* and the Linux shim's own `-z CLAUDE_CODE_OAUTH_TOKEN` guard — both prefer that
|
|
1415
|
-
* env var over a pinned account's config dir, so when balanced rotation pins a
|
|
1416
|
-
* specific account's `CLAUDE_CONFIG_DIR` the ambient token shadows it: the pool
|
|
1417
|
-
* is inert and every fire 401s on the one stale token. A routine spawn drops the
|
|
1418
|
-
* ambient token when the pinned account has its own credential (this returns
|
|
1419
|
-
* true) so the account authenticates itself; when it does not (the RUSH-1759
|
|
1420
|
-
* default), the injected token is kept.
|
|
1421
|
-
*
|
|
1422
|
-
* Off-darwin only, mirroring {@link isClaudeCredentialFileBlank}: on macOS the
|
|
1423
|
-
* login Keychain is the canonical store and probing it raises an auth sheet, and
|
|
1424
|
-
* there the daemon injects no token to shadow (broker-only, usually absent), so
|
|
1425
|
-
* returning false changes nothing. Sync, no Keychain, no network.
|
|
1426
|
-
*/
|
|
1427
|
-
export function claudeHomeHasOwnCredential(base, platform = process.platform) {
|
|
1428
|
-
if (platform === 'darwin')
|
|
1429
|
-
return false;
|
|
1430
|
-
if (fs.existsSync(path.join(base, '.claude', '.oauth_token')))
|
|
1431
|
-
return true;
|
|
1432
|
-
try {
|
|
1433
|
-
const raw = fs.readFileSync(path.join(base, '.claude', '.credentials.json'), 'utf-8');
|
|
1434
|
-
const oauth = JSON.parse(raw).claudeAiOauth;
|
|
1435
|
-
if (!oauth)
|
|
1436
|
-
return false;
|
|
1437
|
-
const nonEmpty = (v) => typeof v === 'string' && v.trim().length > 0;
|
|
1438
|
-
return nonEmpty(oauth.accessToken) || nonEmpty(oauth.refreshToken);
|
|
1439
|
-
}
|
|
1440
|
-
catch {
|
|
1441
|
-
return false;
|
|
1442
|
-
}
|
|
1443
|
-
}
|
|
1444
1407
|
export async function getAccountInfo(agentId, home) {
|
|
1445
1408
|
const base = home || os.homedir();
|
|
1446
1409
|
const empty = {
|
package/dist/lib/auto-pull.d.ts
CHANGED
|
@@ -2,12 +2,13 @@
|
|
|
2
2
|
* Background sync for tracked git repos:
|
|
3
3
|
* - System repo (~/.agents/.system/) is read-only locally — fast-forward auto-pull is safe.
|
|
4
4
|
* - User repo (~/.agents/) and enabled extras may have local commits, so we only
|
|
5
|
-
* `git fetch` and write a status marker.
|
|
6
|
-
*
|
|
5
|
+
* `git fetch` and write a status marker. `agents doctor` surfaces these markers
|
|
6
|
+
* as a "Repo updates" section instead of printing to stderr on every command.
|
|
7
|
+
* Pulling is left to the user via `agents repo pull`.
|
|
7
8
|
*
|
|
8
9
|
* Public API:
|
|
9
|
-
* spawnDetachedSync()
|
|
10
|
-
*
|
|
10
|
+
* spawnDetachedSync() — fire-and-forget; never blocks the foreground command.
|
|
11
|
+
* readRepoBehindMarkers() — synchronous; reads markers without consuming them.
|
|
11
12
|
*/
|
|
12
13
|
/** Per-repo lock file path. mtime acts as a recency check. */
|
|
13
14
|
export declare function lockFilePath(alias: string): string;
|
|
@@ -24,8 +25,15 @@ export interface FetchStatusMarker {
|
|
|
24
25
|
/** Spawn the detached worker. No-op when AGENTS_NO_AUTOPULL=1 is set. */
|
|
25
26
|
export declare function spawnDetachedSync(): void;
|
|
26
27
|
/**
|
|
27
|
-
* Read
|
|
28
|
-
*
|
|
29
|
-
*
|
|
28
|
+
* Read all current status markers and return those where the local repo is
|
|
29
|
+
* behind upstream. Markers are NOT deleted — they persist until the next
|
|
30
|
+
* background fetch overwrites them with fresh data.
|
|
31
|
+
*
|
|
32
|
+
* Synchronous, cheap (small JSON files). Used by `agents doctor` to surface
|
|
33
|
+
* repo-behind warnings in one place instead of printing to stderr on every
|
|
34
|
+
* command.
|
|
35
|
+
*
|
|
36
|
+
* @param fetchDir - Override the fetch state dir (for tests). Defaults to the
|
|
37
|
+
* production getFetchCacheDir() path.
|
|
30
38
|
*/
|
|
31
|
-
export declare function
|
|
39
|
+
export declare function readRepoBehindMarkers(fetchDir?: string): FetchStatusMarker[];
|