@phnx-labs/agents-cli 1.20.76 → 1.20.77
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 +162 -0
- package/dist/bin/agents +0 -0
- package/dist/commands/exec.js +58 -16
- package/dist/commands/routines.js +271 -13
- package/dist/commands/secrets.js +59 -17
- package/dist/commands/sessions-browser.js +11 -1
- package/dist/commands/sessions-picker.d.ts +14 -1
- package/dist/commands/sessions-picker.js +168 -15
- package/dist/commands/sessions.d.ts +23 -4
- package/dist/commands/sessions.js +128 -38
- package/dist/commands/ssh.d.ts +13 -0
- package/dist/commands/ssh.js +39 -2
- package/dist/index.js +2 -1
- package/dist/lib/activity.d.ts +5 -4
- package/dist/lib/activity.js +450 -36
- package/dist/lib/cloud/session-index.js +2 -2
- package/dist/lib/daemon.d.ts +10 -1
- package/dist/lib/daemon.js +99 -12
- package/dist/lib/devices/fleet.d.ts +16 -1
- package/dist/lib/devices/fleet.js +10 -2
- 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/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 +31 -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/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 +150 -25
- package/dist/lib/sandbox.js +10 -0
- package/dist/lib/secrets/account-token.d.ts +20 -0
- package/dist/lib/secrets/account-token.js +64 -0
- package/dist/lib/secrets/agent.d.ts +9 -4
- package/dist/lib/secrets/agent.js +30 -20
- package/dist/lib/secrets/bundles.d.ts +20 -6
- package/dist/lib/secrets/bundles.js +70 -34
- package/dist/lib/secrets/index.d.ts +11 -2
- package/dist/lib/secrets/index.js +21 -9
- package/dist/lib/secrets/session-store.d.ts +6 -2
- package/dist/lib/secrets/session-store.js +65 -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 +10 -0
- package/dist/lib/session/active.js +67 -4
- package/dist/lib/session/db.d.ts +6 -0
- package/dist/lib/session/db.js +83 -6
- package/dist/lib/session/discover.d.ts +13 -1
- package/dist/lib/session/discover.js +91 -3
- 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 +4 -0
- package/dist/lib/session/state.js +93 -11
- package/dist/lib/session/types.d.ts +25 -1
- package/dist/lib/session/types.js +8 -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
|
},
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* nothing to resolve or resume by id.
|
|
18
18
|
*/
|
|
19
19
|
import { upsertSession } from '../session/db.js';
|
|
20
|
-
import {
|
|
20
|
+
import { isSessionTrackedAgent } from '../session/types.js';
|
|
21
21
|
import { deriveShortId } from '../session/short-id.js';
|
|
22
22
|
/** The execution-id charset the session index will accept as a row id. */
|
|
23
23
|
const EXECUTION_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,255}$/;
|
|
@@ -29,7 +29,7 @@ const EXECUTION_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,255}$/;
|
|
|
29
29
|
* failed index write must never break the dispatch/poll it rides on.
|
|
30
30
|
*/
|
|
31
31
|
export function registerCloudSession(task, ctx = {}) {
|
|
32
|
-
if (!
|
|
32
|
+
if (!task.agent || !isSessionTrackedAgent(task.agent))
|
|
33
33
|
return;
|
|
34
34
|
if (!task.id || !EXECUTION_ID_RE.test(task.id))
|
|
35
35
|
return;
|
package/dist/lib/daemon.d.ts
CHANGED
|
@@ -81,6 +81,15 @@ export declare function runDaemon(): Promise<void>;
|
|
|
81
81
|
export declare function readDaemonClaudeOAuthToken(opts?: {
|
|
82
82
|
allowPrompt?: boolean;
|
|
83
83
|
}): string | null;
|
|
84
|
+
/**
|
|
85
|
+
* Read the FULL `claude` bundle env — the main `CLAUDE_CODE_OAUTH_TOKEN` plus any
|
|
86
|
+
* per-account `CLAUDE_CODE_OAUTH_TOKEN_<slug>` setup-tokens. Same resolution and
|
|
87
|
+
* never-throws contract as {@link readDaemonClaudeOAuthToken}; returns `{}` when
|
|
88
|
+
* the bundle can't be read (broker-only headless miss, absent bundle, etc.).
|
|
89
|
+
*/
|
|
90
|
+
export declare function readDaemonClaudeBundleEnv(opts?: {
|
|
91
|
+
allowPrompt?: boolean;
|
|
92
|
+
}): Record<string, string>;
|
|
84
93
|
/**
|
|
85
94
|
* Write a launchd plist or systemd unit with owner-only permissions atomically.
|
|
86
95
|
*
|
|
@@ -139,7 +148,7 @@ export declare function ensureDaemonStarted(): {
|
|
|
139
148
|
* the daemon then passes it to every routine run it spawns. An already-set
|
|
140
149
|
* value (e.g. inherited from launchd) is left untouched.
|
|
141
150
|
*/
|
|
142
|
-
export declare function buildDetachedDaemonEnv(baseEnv?: NodeJS.ProcessEnv,
|
|
151
|
+
export declare function buildDetachedDaemonEnv(baseEnv?: NodeJS.ProcessEnv, bundleEnv?: Record<string, string>): NodeJS.ProcessEnv;
|
|
143
152
|
/**
|
|
144
153
|
* Resolve how to launch the daemon: `node <entry> __daemon-run`, matching the
|
|
145
154
|
* exact form that works under a direct `__daemon-run`.
|