@phnx-labs/agents-cli 1.20.72 → 1.20.73
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 +299 -0
- package/README.md +40 -4
- package/dist/bin/agents +0 -0
- package/dist/commands/activity.d.ts +12 -0
- package/dist/commands/activity.js +68 -0
- package/dist/commands/apply.d.ts +13 -0
- package/dist/commands/apply.js +28 -3
- package/dist/commands/browser.js +1 -1
- package/dist/commands/cloud.js +21 -11
- package/dist/commands/doctor.js +8 -4
- package/dist/commands/events.d.ts +11 -10
- package/dist/commands/events.js +30 -21
- package/dist/commands/exec.d.ts +34 -0
- package/dist/commands/exec.js +337 -39
- package/dist/commands/feed.d.ts +13 -0
- package/dist/commands/feed.js +130 -29
- package/dist/commands/hq.d.ts +2 -0
- package/dist/commands/hq.js +58 -0
- package/dist/commands/lease.d.ts +19 -0
- package/dist/commands/lease.js +138 -11
- package/dist/commands/login.d.ts +2 -0
- package/dist/commands/login.js +123 -0
- package/dist/commands/logs.js +1 -1
- package/dist/commands/mine.d.ts +27 -0
- package/dist/commands/mine.js +207 -0
- package/dist/commands/monitors.js +79 -49
- package/dist/commands/plugins.js +1 -0
- package/dist/commands/profiles.js +158 -0
- package/dist/commands/repo.js +2 -60
- package/dist/commands/resources.d.ts +5 -0
- package/dist/commands/resources.js +95 -0
- package/dist/commands/routines.d.ts +2 -0
- package/dist/commands/routines.js +139 -37
- package/dist/commands/secrets.js +234 -47
- package/dist/commands/send.d.ts +15 -0
- package/dist/commands/send.js +62 -0
- package/dist/commands/sessions-tail.d.ts +1 -0
- package/dist/commands/sessions-tail.js +20 -9
- package/dist/commands/sessions.js +18 -3
- package/dist/commands/setup-mine.d.ts +18 -0
- package/dist/commands/setup-mine.js +106 -0
- package/dist/commands/setup-share.js +2 -2
- package/dist/commands/setup.js +3 -1
- package/dist/commands/share.d.ts +8 -1
- package/dist/commands/share.js +122 -37
- package/dist/commands/ssh.d.ts +9 -0
- package/dist/commands/ssh.js +196 -26
- package/dist/commands/sync.js +1 -1
- package/dist/commands/teams.js +11 -2
- package/dist/index.js +79 -10
- package/dist/lib/activity.d.ts +124 -0
- package/dist/lib/activity.js +542 -0
- package/dist/lib/agents.js +16 -5
- package/dist/lib/artifact-actions.d.ts +1 -1
- package/dist/lib/artifact-actions.js +1 -1
- package/dist/lib/ask-classifier.d.ts +2 -1
- package/dist/lib/ask-classifier.js +3 -3
- package/dist/lib/auth-health.d.ts +32 -4
- package/dist/lib/auth-health.js +40 -1
- package/dist/lib/auto-pull.js +8 -1
- package/dist/lib/brand.d.ts +40 -0
- package/dist/lib/brand.js +122 -0
- package/dist/lib/browser/drivers/ssh.js +4 -7
- package/dist/lib/browser/service.js +6 -8
- package/dist/lib/channels/providers/index.d.ts +2 -0
- package/dist/lib/channels/providers/index.js +20 -0
- package/dist/lib/channels/providers/mailbox.d.ts +2 -0
- package/dist/lib/channels/providers/mailbox.js +26 -0
- package/dist/lib/channels/providers/openclaw-telegram.d.ts +2 -0
- package/dist/lib/channels/providers/openclaw-telegram.js +34 -0
- package/dist/lib/channels/providers/rush.d.ts +6 -0
- package/dist/lib/channels/providers/rush.js +55 -0
- package/dist/lib/channels/registry.d.ts +42 -0
- package/dist/lib/channels/registry.js +20 -0
- package/dist/lib/channels/resolve.d.ts +11 -0
- package/dist/lib/channels/resolve.js +12 -0
- package/dist/lib/cli-resources.d.ts +18 -0
- package/dist/lib/cli-resources.js +53 -4
- package/dist/lib/cloud/codex.js +6 -3
- package/dist/lib/cloud/factory.d.ts +1 -0
- package/dist/lib/cloud/factory.js +10 -4
- package/dist/lib/cloud/rush.d.ts +3 -1
- package/dist/lib/cloud/rush.js +6 -1
- package/dist/lib/cloud/types.d.ts +2 -0
- package/dist/lib/codex-home.d.ts +35 -0
- package/dist/lib/codex-home.js +136 -0
- package/dist/lib/crabbox/cli.d.ts +44 -0
- package/dist/lib/crabbox/cli.js +120 -20
- package/dist/lib/crabbox/lease.d.ts +52 -1
- package/dist/lib/crabbox/lease.js +117 -16
- package/dist/lib/crabbox/progress.d.ts +31 -0
- package/dist/lib/crabbox/progress.js +76 -0
- package/dist/lib/crabbox/runtimes.d.ts +4 -0
- package/dist/lib/crabbox/runtimes.js +33 -8
- package/dist/lib/crabbox/setup-copy.d.ts +87 -0
- package/dist/lib/crabbox/setup-copy.js +127 -0
- package/dist/lib/daemon.d.ts +5 -5
- package/dist/lib/daemon.js +9 -9
- package/dist/lib/drive-sync.js +6 -3
- package/dist/lib/event-stream.d.ts +36 -0
- package/dist/lib/event-stream.js +68 -0
- package/dist/lib/events.d.ts +1 -1
- package/dist/lib/exec.d.ts +11 -0
- package/dist/lib/exec.js +23 -2
- package/dist/lib/feed-policy.d.ts +1 -1
- package/dist/lib/feed-policy.js +14 -9
- package/dist/lib/feed-ranking.d.ts +32 -0
- package/dist/lib/feed-ranking.js +224 -0
- package/dist/lib/feed.d.ts +35 -2
- package/dist/lib/feed.js +66 -3
- package/dist/lib/fleet/auth-sync.d.ts +53 -0
- package/dist/lib/fleet/auth-sync.js +92 -0
- package/dist/lib/fleet/remote-login.d.ts +187 -0
- package/dist/lib/fleet/remote-login.js +556 -0
- package/dist/lib/format.d.ts +30 -3
- package/dist/lib/format.js +34 -5
- package/dist/lib/fs-atomic.d.ts +7 -2
- package/dist/lib/fs-atomic.js +8 -12
- package/dist/lib/git.d.ts +16 -0
- package/dist/lib/git.js +79 -2
- package/dist/lib/heal.js +11 -3
- package/dist/lib/hosts/progress.d.ts +28 -10
- package/dist/lib/hosts/progress.js +79 -22
- package/dist/lib/hosts/remote-cmd.js +4 -0
- package/dist/lib/hq/floor.d.ts +87 -0
- package/dist/lib/hq/floor.js +226 -0
- package/dist/lib/menubar/install-menubar.js +14 -20
- package/dist/lib/notify.d.ts +1 -0
- package/dist/lib/notify.js +3 -3
- package/dist/lib/open-url.d.ts +2 -0
- package/dist/lib/open-url.js +19 -0
- package/dist/lib/openclaw-keychain.d.ts +56 -0
- package/dist/lib/openclaw-keychain.js +236 -0
- package/dist/lib/overdue.js +10 -0
- package/dist/lib/permissions.d.ts +44 -1
- package/dist/lib/permissions.js +284 -7
- package/dist/lib/project-launch.d.ts +6 -12
- package/dist/lib/project-launch.js +13 -228
- package/dist/lib/project-resources.d.ts +7 -0
- package/dist/lib/project-resources.js +291 -0
- package/dist/lib/pty-client.d.ts +27 -0
- package/dist/lib/pty-client.js +136 -10
- package/dist/lib/refresh.js +14 -10
- package/dist/lib/resource-profiles.d.ts +26 -0
- package/dist/lib/resource-profiles.js +157 -0
- package/dist/lib/resources/permissions.js +7 -1
- package/dist/lib/resources/types.d.ts +1 -1
- package/dist/lib/resources.d.ts +1 -1
- package/dist/lib/resources.js +32 -3
- package/dist/lib/routines.d.ts +11 -0
- package/dist/lib/routines.js +56 -15
- package/dist/lib/runner.d.ts +1 -0
- package/dist/lib/runner.js +66 -5
- package/dist/lib/secrets/bundles.d.ts +16 -3
- package/dist/lib/secrets/bundles.js +206 -37
- package/dist/lib/secrets/icloud-import.d.ts +2 -2
- package/dist/lib/secrets/icloud-import.js +9 -6
- package/dist/lib/secrets/mcp.js +1 -1
- package/dist/lib/secrets/vault-age-helper.d.ts +1 -0
- package/dist/lib/secrets/vault-age-helper.js +34 -0
- package/dist/lib/secrets/vault.d.ts +49 -0
- package/dist/lib/secrets/vault.js +397 -0
- package/dist/lib/self-heal/checks/path.js +3 -1
- package/dist/lib/self-heal/checks/shadowing.js +10 -2
- package/dist/lib/self-heal/checks/shims.js +19 -11
- package/dist/lib/session/cloud.d.ts +2 -2
- package/dist/lib/session/cloud.js +2 -2
- package/dist/lib/session/db.d.ts +4 -0
- package/dist/lib/session/db.js +44 -7
- package/dist/lib/session/discover.d.ts +2 -0
- package/dist/lib/session/discover.js +114 -3
- package/dist/lib/session/stream-render.d.ts +3 -0
- package/dist/lib/session/stream-render.js +130 -0
- package/dist/lib/session/types.d.ts +6 -0
- package/dist/lib/share/analytics.d.ts +13 -0
- package/dist/lib/share/analytics.js +45 -0
- package/dist/lib/share/config.d.ts +19 -5
- package/dist/lib/share/config.js +44 -8
- package/dist/lib/share/provision.d.ts +58 -6
- package/dist/lib/share/provision.js +97 -22
- package/dist/lib/share/publish.d.ts +36 -13
- package/dist/lib/share/publish.js +55 -8
- package/dist/lib/share/worker-template.js +83 -17
- package/dist/lib/shims.d.ts +9 -0
- package/dist/lib/shims.js +91 -13
- package/dist/lib/ssh-exec.d.ts +14 -0
- package/dist/lib/ssh-exec.js +57 -0
- package/dist/lib/staleness/detectors/hooks.js +25 -1
- package/dist/lib/staleness/detectors/permissions.js +66 -0
- package/dist/lib/staleness/detectors/workflows.js +20 -0
- package/dist/lib/staleness/writers/hooks.js +58 -4
- package/dist/lib/staleness/writers/permissions.js +2 -2
- package/dist/lib/staleness/writers/skills.js +1 -1
- package/dist/lib/staleness/writers/sources.d.ts +10 -1
- package/dist/lib/staleness/writers/sources.js +68 -1
- package/dist/lib/star-nudge.d.ts +45 -0
- package/dist/lib/star-nudge.js +91 -0
- package/dist/lib/startup/command-registry.d.ts +6 -1
- package/dist/lib/startup/command-registry.js +17 -3
- package/dist/lib/state.d.ts +2 -0
- package/dist/lib/state.js +3 -0
- package/dist/lib/subagents-registry.d.ts +2 -0
- package/dist/lib/subagents-registry.js +3 -0
- package/dist/lib/teams/parsers.js +214 -6
- package/dist/lib/teams/supervisor.d.ts +7 -0
- package/dist/lib/teams/supervisor.js +2 -1
- package/dist/lib/template.d.ts +1 -1
- package/dist/lib/template.js +1 -1
- package/dist/lib/triggers/webhook.js +36 -3
- package/dist/lib/types.d.ts +73 -0
- package/dist/lib/version.d.ts +40 -0
- package/dist/lib/version.js +94 -16
- package/dist/lib/versions.d.ts +11 -0
- package/dist/lib/versions.js +208 -76
- package/dist/lib/workflows.d.ts +2 -0
- package/dist/lib/workflows.js +88 -0
- package/package.json +2 -1
- package/dist/commands/daemon.d.ts +0 -10
- package/dist/commands/daemon.js +0 -121
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import type { EventRecord } from './events.js';
|
|
2
|
+
/** Recognizable milestone events, ordered first in any activity lane. */
|
|
3
|
+
export type MilestoneEvent = 'plan.created' | 'pr.opened' | 'pr.merged' | 'worktree.created' | 'worktree.removed' | 'commit.created' | 'pushed' | 'subagent.spawned' | 'artifact.created';
|
|
4
|
+
/** Routine activity events, collapsed to counts by readers. */
|
|
5
|
+
export type ActivityKind = 'file.edited';
|
|
6
|
+
export type ActivityEventKind = MilestoneEvent | ActivityKind;
|
|
7
|
+
export type ActivityTier = 'milestone' | 'activity';
|
|
8
|
+
/** The set of milestone events, for tier classification and ordering. */
|
|
9
|
+
export declare const MILESTONE_EVENTS: readonly MilestoneEvent[];
|
|
10
|
+
export declare function tierForEvent(event: string): ActivityTier;
|
|
11
|
+
export interface ActivityEvent {
|
|
12
|
+
/** Schema version, for forward-compatible readers. */
|
|
13
|
+
v: number;
|
|
14
|
+
/** ISO-8601 timestamp of the event. */
|
|
15
|
+
ts: string;
|
|
16
|
+
/** Event kind (see {@link ActivityEventKind}). */
|
|
17
|
+
event: ActivityEventKind | string;
|
|
18
|
+
/** Coarse importance tier -- stamped by the writer, recomputed if absent. */
|
|
19
|
+
tier: ActivityTier;
|
|
20
|
+
/** Owning session id. */
|
|
21
|
+
sessionId: string;
|
|
22
|
+
/** Mailbox id for routing/joins (falls back to sessionId). */
|
|
23
|
+
mailboxId: string;
|
|
24
|
+
/** Short, normalized hostname the event fired on. */
|
|
25
|
+
host: string;
|
|
26
|
+
/** Runtime label (headless, tmux, cloud, ...). */
|
|
27
|
+
runtime: string;
|
|
28
|
+
/** Working directory at event time -- the join key to a project/git repo. */
|
|
29
|
+
cwd?: string;
|
|
30
|
+
/** Agent that produced the event (claude, codex, ...). */
|
|
31
|
+
agent?: string;
|
|
32
|
+
/** Tool that triggered the event (Bash, Task, ExitPlanMode, ...). */
|
|
33
|
+
tool?: string;
|
|
34
|
+
/** One-line human summary (plan title, PR command, sub-agent role). */
|
|
35
|
+
detail?: string;
|
|
36
|
+
/** Extracted URL when the event has one (e.g. the opened PR). */
|
|
37
|
+
url?: string;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Append one event to a session's activity log. Primarily the Python hook
|
|
41
|
+
* writes these at runtime; this TS writer exists for tests and any in-process
|
|
42
|
+
* emitter. Uses O_APPEND so concurrent appends never interleave a line.
|
|
43
|
+
*/
|
|
44
|
+
export declare function appendActivityEvent(event: Omit<ActivityEvent, 'v' | 'tier'> & {
|
|
45
|
+
v?: number;
|
|
46
|
+
tier?: ActivityTier;
|
|
47
|
+
}, root?: string): void;
|
|
48
|
+
/** Read all events for one session (bounded tail). */
|
|
49
|
+
export declare function readSessionActivity(sessionId: string, root?: string, maxBytes?: number): ActivityEvent[];
|
|
50
|
+
/** List session ids that have an activity log. */
|
|
51
|
+
export declare function listActivitySessions(root?: string): string[];
|
|
52
|
+
export interface RecentActivityOptions {
|
|
53
|
+
/** Only include events at or after this epoch-ms. */
|
|
54
|
+
sinceMs?: number;
|
|
55
|
+
/** Cap the number of returned events (most recent first). */
|
|
56
|
+
limit?: number;
|
|
57
|
+
/** Override the activity dir (tests). */
|
|
58
|
+
root?: string;
|
|
59
|
+
/** Per-session tail budget in bytes. */
|
|
60
|
+
maxBytesPerSession?: number;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Merge recent events across every session's log, newest first. Reads only the
|
|
64
|
+
* tail of each file, so cost scales with active sessions, not transcript size.
|
|
65
|
+
*/
|
|
66
|
+
export declare function readRecentActivity(opts?: RecentActivityOptions): ActivityEvent[];
|
|
67
|
+
export interface CollapsedActivity {
|
|
68
|
+
/** Milestone events, individually preserved, newest first. */
|
|
69
|
+
milestones: ActivityEvent[];
|
|
70
|
+
/** Routine events rolled up to counts, e.g. { 'file.edited': 12 }. */
|
|
71
|
+
counts: Record<string, number>;
|
|
72
|
+
/** Number of sub-agents spawned across the collapsed set. */
|
|
73
|
+
subagentCount: number;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Split a chronological event list into individual milestones plus a
|
|
77
|
+
* count map for the routine (tier-2) events, so a reader shows recognizable
|
|
78
|
+
* deliverables in full and collapses the noise.
|
|
79
|
+
*/
|
|
80
|
+
export declare function collapseActivity(events: ActivityEvent[]): CollapsedActivity;
|
|
81
|
+
/**
|
|
82
|
+
* Normalize one activity event into the shared {@link EventRecord} shape so the
|
|
83
|
+
* agent-semantic stream reads through the same reader as operational events.
|
|
84
|
+
* `module` is stamped `activity` so `--module` filters partition the two cleanly.
|
|
85
|
+
*/
|
|
86
|
+
export declare function activityEventToRecord(ev: ActivityEvent): EventRecord;
|
|
87
|
+
/** Read recent activity across sessions as unified {@link EventRecord}s. */
|
|
88
|
+
export declare function readActivityAsEventRecords(opts?: RecentActivityOptions): EventRecord[];
|
|
89
|
+
/** Glyph + color + human label per event, so the lane reads at a glance. */
|
|
90
|
+
export declare const EVENT_STYLE: Record<string, {
|
|
91
|
+
glyph: string;
|
|
92
|
+
color: (s: string) => string;
|
|
93
|
+
label: string;
|
|
94
|
+
}>;
|
|
95
|
+
export declare function styleForEvent(event: string): {
|
|
96
|
+
glyph: string;
|
|
97
|
+
color: (s: string) => string;
|
|
98
|
+
label: string;
|
|
99
|
+
};
|
|
100
|
+
/** One rendered activity line: ` <rel> [host] <glyph label> detail url`. */
|
|
101
|
+
export declare function formatActivityLine(ev: ActivityEvent, opts?: {
|
|
102
|
+
showHost?: boolean;
|
|
103
|
+
}): string;
|
|
104
|
+
/**
|
|
105
|
+
* The activity-log hook (Python), sibling to 10-feed-publish.py. Classifies
|
|
106
|
+
* PreToolUse/PostToolUse payloads into activity events and appends one JSONL
|
|
107
|
+
* line per event to ~/.agents/.history/activity/<sessionId>.jsonl.
|
|
108
|
+
*
|
|
109
|
+
* Matcher-gated to mutating/milestone tools (Bash|Task|ExitPlanMode|Write|
|
|
110
|
+
* Edit|MultiEdit) so read-only tools never pay the hook cost. Fail-open: any
|
|
111
|
+
* error is swallowed so a logging hiccup never blocks a tool call.
|
|
112
|
+
*/
|
|
113
|
+
export declare const ACTIVITY_LOG_HOOK_SCRIPT = "#!/usr/bin/env python3\n\"\"\"Append agent-activity events for `agents feed` / `agents activity`.\n\nBound to PreToolUse (ExitPlanMode, Task) and PostToolUse (Bash, Write, Edit,\nMultiEdit). One append-only file per session; read-only tools never trigger it\nbecause the manifest matcher excludes them.\n\nSub-agent gate: when the payload carries `agent_type`, this is a Task/Agent\nsub-agent -- skip so only the top-level agent logs its own activity.\n\nFail-open: ANY error is swallowed so a logging hiccup never blocks a tool call.\n\"\"\"\nimport os\nimport re\nimport sys\nimport json\nimport shlex\nimport socket\nfrom datetime import datetime, timezone\n\nMAX_LOG_BYTES = 5 * 1024 * 1024 # cap a pathological session's log\nMILESTONE_EVENTS = {\n \"plan.created\", \"pr.opened\", \"pr.merged\", \"worktree.created\",\n \"worktree.removed\", \"commit.created\", \"pushed\", \"subagent.spawned\",\n \"artifact.created\",\n}\n\n# Deliverable file types + locations -- a Write here is a recognizable artifact\n# (an HTML plan, a PDF report, a rendered image), not a routine code edit.\nARTIFACT_EXTS = {\n \".html\", \".htm\", \".pdf\", \".png\", \".jpg\", \".jpeg\", \".gif\", \".svg\",\n \".webp\", \".mp4\", \".mov\", \".webm\", \".csv\", \".xlsx\", \".pptx\", \".docx\",\n}\nARTIFACT_DIR_HINTS = (\"/tmp/\", \"/downloads/\", \"/.agents/artifacts/\")\n\n\ndef is_artifact(file_path):\n low = (file_path or \"\").lower()\n if os.path.splitext(low)[1] in ARTIFACT_EXTS:\n return True\n return any(hint in low for hint in ARTIFACT_DIR_HINTS)\n\n\ndef first_line(text, limit=140):\n for raw in (text or \"\").splitlines():\n s = raw.strip().lstrip(\"#\").strip()\n if s:\n return s[:limit]\n return \"\"\n\n\ndef _subcommand(tokens, tool):\n \"\"\"First non-flag token after `tool`, i.e. its subcommand (skips -C <path>,\n -c <cfg>, and other leading flags). None if `tool` isn't the invoked command.\"\"\"\n try:\n i = tokens.index(tool) + 1\n except ValueError:\n return None, []\n while i < len(tokens):\n t = tokens[i]\n if t in (\"-C\", \"-c\", \"--git-dir\", \"--work-tree\"):\n i += 2 # flag that consumes the next token\n continue\n if t.startswith(\"-\"):\n i += 1\n continue\n return t, tokens[i + 1:]\n return None, []\n\n\ndef classify_bash(command):\n \"\"\"Return the milestone event for a git/gh command, else None. Tokenizes so a\n path like `git diff -- src/commit.ts` is not mistaken for a commit.\"\"\"\n try:\n tokens = shlex.split(command or \"\")\n except Exception:\n tokens = (command or \"\").split()\n\n verb, rest = _subcommand(tokens, \"git\")\n if verb == \"worktree\":\n sub = rest[0] if rest else \"\"\n if sub == \"add\":\n return \"worktree.created\"\n if sub == \"remove\":\n return \"worktree.removed\"\n elif verb == \"commit\":\n return \"commit.created\"\n elif verb == \"push\":\n return \"pushed\"\n\n verb, rest = _subcommand(tokens, \"gh\")\n if verb == \"pr\" and rest:\n if rest[0] == \"create\":\n return \"pr.opened\"\n if rest[0] == \"merge\":\n return \"pr.merged\"\n return None\n\n\ndef extract_url(tool_response):\n \"\"\"Pull the first https URL out of a Bash tool response (stdout).\"\"\"\n text = \"\"\n if isinstance(tool_response, dict):\n text = str(tool_response.get(\"stdout\") or tool_response.get(\"output\") or \"\")\n elif isinstance(tool_response, str):\n text = tool_response\n m = re.search(r\"https?://\\S+\", text)\n return m.group(0).rstrip(\").,\") if m else None\n\n\ndef build_event(payload, hook_event):\n tool_name = payload.get(\"tool_name\", \"\")\n tool_input = payload.get(\"tool_input\", {}) or {}\n tool_response = payload.get(\"tool_response\", {})\n\n event = None\n detail = None\n url = None\n\n if hook_event == \"PreToolUse\":\n if tool_name == \"ExitPlanMode\":\n event = \"plan.created\"\n detail = first_line(tool_input.get(\"plan\", \"\")) or \"plan presented\"\n elif tool_name == \"Task\":\n event = \"subagent.spawned\"\n role = tool_input.get(\"subagent_type\") or \"agent\"\n desc = tool_input.get(\"description\") or tool_input.get(\"prompt\") or \"\"\n detail = (role + \": \" + first_line(desc)).strip(\": \").strip()\n elif hook_event == \"PostToolUse\":\n if tool_name == \"Bash\":\n event = classify_bash(tool_input.get(\"command\", \"\"))\n if event:\n detail = first_line(tool_input.get(\"command\", \"\"))\n url = extract_url(tool_response)\n elif tool_name in (\"Write\", \"Edit\", \"MultiEdit\"):\n fp = tool_input.get(\"file_path\") or tool_input.get(\"path\") or \"\"\n # A freshly-written deliverable is a milestone; edits and code\n # writes stay routine and collapse to a count.\n if tool_name == \"Write\" and is_artifact(fp):\n event = \"artifact.created\"\n else:\n event = \"file.edited\"\n detail = os.path.basename(fp) if fp else tool_name\n\n if not event:\n return None, tool_name\n\n tier = \"milestone\" if event in MILESTONE_EVENTS else \"activity\"\n record = {\n \"v\": 1,\n \"ts\": datetime.now(timezone.utc).isoformat(),\n \"event\": event,\n \"tier\": tier,\n }\n if detail:\n record[\"detail\"] = detail\n if url:\n record[\"url\"] = url\n record[\"tool\"] = tool_name\n return record, tool_name\n\n\ndef main():\n raw = sys.stdin.read()\n try:\n payload = json.loads(raw) if raw.strip() else {}\n except Exception:\n return\n\n # Sub-agent gate -- only the top-level agent logs.\n if payload.get(\"agent_type\"):\n return\n\n session_id = payload.get(\"session_id\", \"\")\n if not session_id:\n return\n\n hook_event = payload.get(\"hook_event_name\", \"\")\n record, tool_name = build_event(payload, hook_event)\n if not record:\n return\n\n safe_session = re.sub(r\"[^A-Za-z0-9._-]\", \"-\", session_id) or \"unknown\"\n home = os.environ.get(\"HOME\") or os.path.expanduser(\"~\")\n activity_dir = os.path.join(home, \".agents\", \".history\", \"activity\")\n target = os.path.join(activity_dir, safe_session + \".jsonl\")\n\n # Identity (mirrors 10-feed-publish.py).\n mailbox_id = os.path.basename(\n os.environ.get(\"AGENTS_MAILBOX_DIR\", \"\").rstrip(\"/\")\n ) or session_id\n hostname = os.environ.get(\"AGENTS_SYNC_MACHINE_ID\") or socket.gethostname()\n host = re.sub(r\"[^a-z0-9_-]\", \"-\", hostname.split(\".\")[0].strip().lower()) or \"unknown\"\n\n record[\"sessionId\"] = session_id\n record[\"mailboxId\"] = mailbox_id\n record[\"host\"] = host\n record[\"runtime\"] = os.environ.get(\"AGENTS_RUNTIME\", \"headless\")\n cwd = payload.get(\"cwd\") or os.environ.get(\"AGENTS_CWD\")\n if cwd:\n record[\"cwd\"] = cwd\n agent = os.environ.get(\"AGENTS_AGENT_NAME\") or \"claude\"\n record[\"agent\"] = agent\n\n try:\n os.makedirs(activity_dir, exist_ok=True)\n # Cap growth: once over the size limit, keep only milestones.\n if record[\"tier\"] != \"milestone\":\n try:\n if os.path.getsize(target) > MAX_LOG_BYTES:\n return\n except OSError:\n pass\n with open(target, \"a\") as f:\n f.write(json.dumps(record) + \"\\n\")\n except Exception:\n pass # fail open\n\n\nif __name__ == \"__main__\":\n try:\n main()\n except Exception:\n pass # fail open\n";
|
|
114
|
+
/** Hook manifest entries (agents.yaml shape) for the activity-log hook. */
|
|
115
|
+
export declare const ACTIVITY_HOOK_DEFINITIONS: Record<string, Record<string, unknown>>;
|
|
116
|
+
/**
|
|
117
|
+
* Install the activity-log hook script into the user hooks dir and add its
|
|
118
|
+
* manifest entries to the user agents.yaml. Mirrors {@link ensureFeedPublishHook}
|
|
119
|
+
* in feed.ts -- idempotent, and never writes the read-only system repo.
|
|
120
|
+
*/
|
|
121
|
+
export declare function ensureActivityLogHook(userAgentsDir?: string): {
|
|
122
|
+
installed: boolean;
|
|
123
|
+
error?: string;
|
|
124
|
+
};
|
|
@@ -0,0 +1,542 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Activity log -- an append-only, per-session stream of agent-semantic events
|
|
3
|
+
* (plan created, PR opened, worktree created, sub-agent spawned, file edited).
|
|
4
|
+
*
|
|
5
|
+
* This is the counterpart to the feed block store: the block store is
|
|
6
|
+
* last-writer-wins STATE ("this agent is waiting on you"), while the activity
|
|
7
|
+
* log is an append-only EVENT stream ("this agent did X at T"). Together they
|
|
8
|
+
* let `agents feed` show both open decisions and a running activity lane
|
|
9
|
+
* without ever re-parsing session transcripts -- events are emitted at hook
|
|
10
|
+
* time by `11-activity-log.py` and read back by tailing the log.
|
|
11
|
+
*
|
|
12
|
+
* Layout: <activityDir>/<sessionId>.jsonl (one append-only file per session)
|
|
13
|
+
* Each line is one {@link ActivityEvent}. Files are keyed by session so a
|
|
14
|
+
* plain O_APPEND write is race-free (a session has a single writer process).
|
|
15
|
+
*
|
|
16
|
+
* Event tiers:
|
|
17
|
+
* - `milestone` -- recognizable deliverables, always surfaced individually.
|
|
18
|
+
* - `activity` -- routine work (file edits), collapsed to counts by readers.
|
|
19
|
+
*/
|
|
20
|
+
import * as fs from 'fs';
|
|
21
|
+
import * as path from 'path';
|
|
22
|
+
import * as yaml from 'yaml';
|
|
23
|
+
import chalk from 'chalk';
|
|
24
|
+
import { relTime, truncate } from './format.js';
|
|
25
|
+
import { getActivityDir, getUserAgentsDir } from './state.js';
|
|
26
|
+
/** The set of milestone events, for tier classification and ordering. */
|
|
27
|
+
export const MILESTONE_EVENTS = [
|
|
28
|
+
'plan.created',
|
|
29
|
+
'pr.opened',
|
|
30
|
+
'pr.merged',
|
|
31
|
+
'worktree.created',
|
|
32
|
+
'worktree.removed',
|
|
33
|
+
'commit.created',
|
|
34
|
+
'pushed',
|
|
35
|
+
'subagent.spawned',
|
|
36
|
+
'artifact.created',
|
|
37
|
+
];
|
|
38
|
+
const MILESTONE_SET = new Set(MILESTONE_EVENTS);
|
|
39
|
+
export function tierForEvent(event) {
|
|
40
|
+
return MILESTONE_SET.has(event) ? 'milestone' : 'activity';
|
|
41
|
+
}
|
|
42
|
+
function activityPath(root, sessionId) {
|
|
43
|
+
const safe = sessionId.replace(/[^A-Za-z0-9._-]/g, '-');
|
|
44
|
+
if (!safe)
|
|
45
|
+
throw new Error(`Invalid activity session id: ${sessionId}`);
|
|
46
|
+
return path.join(root, `${safe}.jsonl`);
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Append one event to a session's activity log. Primarily the Python hook
|
|
50
|
+
* writes these at runtime; this TS writer exists for tests and any in-process
|
|
51
|
+
* emitter. Uses O_APPEND so concurrent appends never interleave a line.
|
|
52
|
+
*/
|
|
53
|
+
export function appendActivityEvent(event, root) {
|
|
54
|
+
const dir = root ?? getActivityDir();
|
|
55
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
56
|
+
const record = {
|
|
57
|
+
v: event.v ?? 1,
|
|
58
|
+
tier: event.tier ?? tierForEvent(event.event),
|
|
59
|
+
...event,
|
|
60
|
+
};
|
|
61
|
+
fs.appendFileSync(activityPath(dir, event.sessionId), `${JSON.stringify(record)}\n`, { mode: 0o644 });
|
|
62
|
+
}
|
|
63
|
+
function parseLine(line) {
|
|
64
|
+
const trimmed = line.trim();
|
|
65
|
+
if (!trimmed)
|
|
66
|
+
return undefined;
|
|
67
|
+
try {
|
|
68
|
+
const parsed = JSON.parse(trimmed);
|
|
69
|
+
if (!parsed.event || !parsed.sessionId || !parsed.ts)
|
|
70
|
+
return undefined;
|
|
71
|
+
return {
|
|
72
|
+
v: parsed.v ?? 1,
|
|
73
|
+
ts: parsed.ts,
|
|
74
|
+
event: parsed.event,
|
|
75
|
+
tier: parsed.tier ?? tierForEvent(parsed.event),
|
|
76
|
+
sessionId: parsed.sessionId,
|
|
77
|
+
mailboxId: parsed.mailboxId ?? parsed.sessionId,
|
|
78
|
+
host: parsed.host ?? 'unknown',
|
|
79
|
+
runtime: parsed.runtime ?? 'headless',
|
|
80
|
+
cwd: parsed.cwd,
|
|
81
|
+
agent: parsed.agent,
|
|
82
|
+
tool: parsed.tool,
|
|
83
|
+
detail: parsed.detail,
|
|
84
|
+
url: parsed.url,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
return undefined; // skip corrupt / partial lines (fail-open reader)
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
/** Read the tail of a file as UTF-8, bounded to the last `maxBytes`. */
|
|
92
|
+
function readTail(file, maxBytes) {
|
|
93
|
+
let fd;
|
|
94
|
+
try {
|
|
95
|
+
fd = fs.openSync(file, 'r');
|
|
96
|
+
const size = fs.fstatSync(fd).size;
|
|
97
|
+
const start = size > maxBytes ? size - maxBytes : 0;
|
|
98
|
+
const len = size - start;
|
|
99
|
+
if (len <= 0)
|
|
100
|
+
return '';
|
|
101
|
+
const buf = Buffer.allocUnsafe(len);
|
|
102
|
+
fs.readSync(fd, buf, 0, len, start);
|
|
103
|
+
let text = buf.toString('utf-8');
|
|
104
|
+
// Drop a leading partial line when we started mid-file.
|
|
105
|
+
if (start > 0) {
|
|
106
|
+
const nl = text.indexOf('\n');
|
|
107
|
+
text = nl >= 0 ? text.slice(nl + 1) : '';
|
|
108
|
+
}
|
|
109
|
+
return text;
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
return '';
|
|
113
|
+
}
|
|
114
|
+
finally {
|
|
115
|
+
if (fd !== undefined)
|
|
116
|
+
fs.closeSync(fd);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
/** Read all events for one session (bounded tail). */
|
|
120
|
+
export function readSessionActivity(sessionId, root, maxBytes = 256 * 1024) {
|
|
121
|
+
const dir = root ?? getActivityDir();
|
|
122
|
+
const text = readTail(activityPath(dir, sessionId), maxBytes);
|
|
123
|
+
const out = [];
|
|
124
|
+
for (const line of text.split('\n')) {
|
|
125
|
+
const ev = parseLine(line);
|
|
126
|
+
if (ev)
|
|
127
|
+
out.push(ev);
|
|
128
|
+
}
|
|
129
|
+
return out;
|
|
130
|
+
}
|
|
131
|
+
/** List session ids that have an activity log. */
|
|
132
|
+
export function listActivitySessions(root) {
|
|
133
|
+
const dir = root ?? getActivityDir();
|
|
134
|
+
try {
|
|
135
|
+
return fs.readdirSync(dir).filter(n => n.endsWith('.jsonl')).map(n => n.slice(0, -'.jsonl'.length));
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
return [];
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Merge recent events across every session's log, newest first. Reads only the
|
|
143
|
+
* tail of each file, so cost scales with active sessions, not transcript size.
|
|
144
|
+
*/
|
|
145
|
+
export function readRecentActivity(opts = {}) {
|
|
146
|
+
const dir = opts.root ?? getActivityDir();
|
|
147
|
+
const sinceMs = opts.sinceMs ?? 0;
|
|
148
|
+
const all = [];
|
|
149
|
+
for (const sessionId of listActivitySessions(dir)) {
|
|
150
|
+
for (const ev of readSessionActivity(sessionId, dir, opts.maxBytesPerSession)) {
|
|
151
|
+
const t = Date.parse(ev.ts);
|
|
152
|
+
if (Number.isFinite(t) && t >= sinceMs)
|
|
153
|
+
all.push(ev);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
all.sort((a, b) => Date.parse(b.ts) - Date.parse(a.ts));
|
|
157
|
+
return typeof opts.limit === 'number' ? all.slice(0, opts.limit) : all;
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Split a chronological event list into individual milestones plus a
|
|
161
|
+
* count map for the routine (tier-2) events, so a reader shows recognizable
|
|
162
|
+
* deliverables in full and collapses the noise.
|
|
163
|
+
*/
|
|
164
|
+
export function collapseActivity(events) {
|
|
165
|
+
const milestones = [];
|
|
166
|
+
const counts = {};
|
|
167
|
+
let subagentCount = 0;
|
|
168
|
+
for (const ev of events) {
|
|
169
|
+
if (tierForEvent(ev.event) === 'milestone') {
|
|
170
|
+
milestones.push(ev);
|
|
171
|
+
if (ev.event === 'subagent.spawned')
|
|
172
|
+
subagentCount += 1;
|
|
173
|
+
}
|
|
174
|
+
else {
|
|
175
|
+
counts[ev.event] = (counts[ev.event] ?? 0) + 1;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return { milestones, counts, subagentCount };
|
|
179
|
+
}
|
|
180
|
+
// ---------------------------------------------------------------------------
|
|
181
|
+
// Bridge into the unified event stream (lib/event-stream.ts)
|
|
182
|
+
// ---------------------------------------------------------------------------
|
|
183
|
+
/**
|
|
184
|
+
* Normalize one activity event into the shared {@link EventRecord} shape so the
|
|
185
|
+
* agent-semantic stream reads through the same reader as operational events.
|
|
186
|
+
* `module` is stamped `activity` so `--module` filters partition the two cleanly.
|
|
187
|
+
*/
|
|
188
|
+
export function activityEventToRecord(ev) {
|
|
189
|
+
return {
|
|
190
|
+
ts: ev.ts,
|
|
191
|
+
tz: '',
|
|
192
|
+
tzName: '',
|
|
193
|
+
hostname: ev.host,
|
|
194
|
+
platform: process.platform,
|
|
195
|
+
arch: process.arch,
|
|
196
|
+
pid: 0,
|
|
197
|
+
ppid: 0,
|
|
198
|
+
event: ev.event,
|
|
199
|
+
level: 'info',
|
|
200
|
+
caller: 'hook',
|
|
201
|
+
session: ev.sessionId,
|
|
202
|
+
osUser: ev.agent ?? 'agent',
|
|
203
|
+
transport: 'local',
|
|
204
|
+
// payload
|
|
205
|
+
agent: ev.agent,
|
|
206
|
+
sessionId: ev.sessionId,
|
|
207
|
+
cwd: ev.cwd,
|
|
208
|
+
module: 'activity',
|
|
209
|
+
tool: ev.tool,
|
|
210
|
+
detail: ev.detail,
|
|
211
|
+
url: ev.url,
|
|
212
|
+
tier: ev.tier,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
/** Read recent activity across sessions as unified {@link EventRecord}s. */
|
|
216
|
+
export function readActivityAsEventRecords(opts = {}) {
|
|
217
|
+
return readRecentActivity(opts).map(activityEventToRecord);
|
|
218
|
+
}
|
|
219
|
+
// ---------------------------------------------------------------------------
|
|
220
|
+
// Rendering (shared by `agents activity` and `agents feed`)
|
|
221
|
+
// ---------------------------------------------------------------------------
|
|
222
|
+
/** Glyph + color + human label per event, so the lane reads at a glance. */
|
|
223
|
+
export const EVENT_STYLE = {
|
|
224
|
+
'plan.created': { glyph: '◆', color: chalk.cyan, label: 'plan created' },
|
|
225
|
+
'pr.opened': { glyph: '⇡', color: chalk.green, label: 'PR opened' },
|
|
226
|
+
'pr.merged': { glyph: '✔', color: chalk.green, label: 'PR merged' },
|
|
227
|
+
'worktree.created': { glyph: '⌥', color: chalk.blue, label: 'worktree created' },
|
|
228
|
+
'worktree.removed': { glyph: '⌦', color: chalk.gray, label: 'worktree removed' },
|
|
229
|
+
'commit.created': { glyph: '●', color: chalk.yellow, label: 'commit' },
|
|
230
|
+
'pushed': { glyph: '↥', color: chalk.yellow, label: 'pushed' },
|
|
231
|
+
'subagent.spawned': { glyph: '⑂', color: chalk.magenta, label: 'sub-agent spawned' },
|
|
232
|
+
'artifact.created': { glyph: '▤', color: chalk.cyan, label: 'artifact' },
|
|
233
|
+
'file.edited': { glyph: '·', color: chalk.gray, label: 'file edited' },
|
|
234
|
+
};
|
|
235
|
+
export function styleForEvent(event) {
|
|
236
|
+
return EVENT_STYLE[event] ?? { glyph: '•', color: chalk.white, label: event };
|
|
237
|
+
}
|
|
238
|
+
/** One rendered activity line: ` <rel> [host] <glyph label> detail url`. */
|
|
239
|
+
export function formatActivityLine(ev, opts = {}) {
|
|
240
|
+
const s = styleForEvent(ev.event);
|
|
241
|
+
const host = opts.showHost && ev.host && ev.host !== 'unknown' ? chalk.gray(`[${ev.host}] `) : '';
|
|
242
|
+
const label = s.color(`${s.glyph} ${s.label}`);
|
|
243
|
+
const detail = ev.detail ? ` ${truncate(ev.detail, 60)}` : '';
|
|
244
|
+
const url = ev.url ? chalk.gray(` ${ev.url}`) : '';
|
|
245
|
+
const when = chalk.gray(relTime(ev.ts).padStart(7));
|
|
246
|
+
return ` ${when} ${host}${label}${detail}${url}`;
|
|
247
|
+
}
|
|
248
|
+
// ---------------------------------------------------------------------------
|
|
249
|
+
// Hook installation
|
|
250
|
+
// ---------------------------------------------------------------------------
|
|
251
|
+
/**
|
|
252
|
+
* The activity-log hook (Python), sibling to 10-feed-publish.py. Classifies
|
|
253
|
+
* PreToolUse/PostToolUse payloads into activity events and appends one JSONL
|
|
254
|
+
* line per event to ~/.agents/.history/activity/<sessionId>.jsonl.
|
|
255
|
+
*
|
|
256
|
+
* Matcher-gated to mutating/milestone tools (Bash|Task|ExitPlanMode|Write|
|
|
257
|
+
* Edit|MultiEdit) so read-only tools never pay the hook cost. Fail-open: any
|
|
258
|
+
* error is swallowed so a logging hiccup never blocks a tool call.
|
|
259
|
+
*/
|
|
260
|
+
export const ACTIVITY_LOG_HOOK_SCRIPT = `#!/usr/bin/env python3
|
|
261
|
+
"""Append agent-activity events for \`agents feed\` / \`agents activity\`.
|
|
262
|
+
|
|
263
|
+
Bound to PreToolUse (ExitPlanMode, Task) and PostToolUse (Bash, Write, Edit,
|
|
264
|
+
MultiEdit). One append-only file per session; read-only tools never trigger it
|
|
265
|
+
because the manifest matcher excludes them.
|
|
266
|
+
|
|
267
|
+
Sub-agent gate: when the payload carries \`agent_type\`, this is a Task/Agent
|
|
268
|
+
sub-agent -- skip so only the top-level agent logs its own activity.
|
|
269
|
+
|
|
270
|
+
Fail-open: ANY error is swallowed so a logging hiccup never blocks a tool call.
|
|
271
|
+
"""
|
|
272
|
+
import os
|
|
273
|
+
import re
|
|
274
|
+
import sys
|
|
275
|
+
import json
|
|
276
|
+
import shlex
|
|
277
|
+
import socket
|
|
278
|
+
from datetime import datetime, timezone
|
|
279
|
+
|
|
280
|
+
MAX_LOG_BYTES = 5 * 1024 * 1024 # cap a pathological session's log
|
|
281
|
+
MILESTONE_EVENTS = {
|
|
282
|
+
"plan.created", "pr.opened", "pr.merged", "worktree.created",
|
|
283
|
+
"worktree.removed", "commit.created", "pushed", "subagent.spawned",
|
|
284
|
+
"artifact.created",
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
# Deliverable file types + locations -- a Write here is a recognizable artifact
|
|
288
|
+
# (an HTML plan, a PDF report, a rendered image), not a routine code edit.
|
|
289
|
+
ARTIFACT_EXTS = {
|
|
290
|
+
".html", ".htm", ".pdf", ".png", ".jpg", ".jpeg", ".gif", ".svg",
|
|
291
|
+
".webp", ".mp4", ".mov", ".webm", ".csv", ".xlsx", ".pptx", ".docx",
|
|
292
|
+
}
|
|
293
|
+
ARTIFACT_DIR_HINTS = ("/tmp/", "/downloads/", "/.agents/artifacts/")
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def is_artifact(file_path):
|
|
297
|
+
low = (file_path or "").lower()
|
|
298
|
+
if os.path.splitext(low)[1] in ARTIFACT_EXTS:
|
|
299
|
+
return True
|
|
300
|
+
return any(hint in low for hint in ARTIFACT_DIR_HINTS)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def first_line(text, limit=140):
|
|
304
|
+
for raw in (text or "").splitlines():
|
|
305
|
+
s = raw.strip().lstrip("#").strip()
|
|
306
|
+
if s:
|
|
307
|
+
return s[:limit]
|
|
308
|
+
return ""
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _subcommand(tokens, tool):
|
|
312
|
+
"""First non-flag token after \`tool\`, i.e. its subcommand (skips -C <path>,
|
|
313
|
+
-c <cfg>, and other leading flags). None if \`tool\` isn't the invoked command."""
|
|
314
|
+
try:
|
|
315
|
+
i = tokens.index(tool) + 1
|
|
316
|
+
except ValueError:
|
|
317
|
+
return None, []
|
|
318
|
+
while i < len(tokens):
|
|
319
|
+
t = tokens[i]
|
|
320
|
+
if t in ("-C", "-c", "--git-dir", "--work-tree"):
|
|
321
|
+
i += 2 # flag that consumes the next token
|
|
322
|
+
continue
|
|
323
|
+
if t.startswith("-"):
|
|
324
|
+
i += 1
|
|
325
|
+
continue
|
|
326
|
+
return t, tokens[i + 1:]
|
|
327
|
+
return None, []
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def classify_bash(command):
|
|
331
|
+
"""Return the milestone event for a git/gh command, else None. Tokenizes so a
|
|
332
|
+
path like \`git diff -- src/commit.ts\` is not mistaken for a commit."""
|
|
333
|
+
try:
|
|
334
|
+
tokens = shlex.split(command or "")
|
|
335
|
+
except Exception:
|
|
336
|
+
tokens = (command or "").split()
|
|
337
|
+
|
|
338
|
+
verb, rest = _subcommand(tokens, "git")
|
|
339
|
+
if verb == "worktree":
|
|
340
|
+
sub = rest[0] if rest else ""
|
|
341
|
+
if sub == "add":
|
|
342
|
+
return "worktree.created"
|
|
343
|
+
if sub == "remove":
|
|
344
|
+
return "worktree.removed"
|
|
345
|
+
elif verb == "commit":
|
|
346
|
+
return "commit.created"
|
|
347
|
+
elif verb == "push":
|
|
348
|
+
return "pushed"
|
|
349
|
+
|
|
350
|
+
verb, rest = _subcommand(tokens, "gh")
|
|
351
|
+
if verb == "pr" and rest:
|
|
352
|
+
if rest[0] == "create":
|
|
353
|
+
return "pr.opened"
|
|
354
|
+
if rest[0] == "merge":
|
|
355
|
+
return "pr.merged"
|
|
356
|
+
return None
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def extract_url(tool_response):
|
|
360
|
+
"""Pull the first https URL out of a Bash tool response (stdout)."""
|
|
361
|
+
text = ""
|
|
362
|
+
if isinstance(tool_response, dict):
|
|
363
|
+
text = str(tool_response.get("stdout") or tool_response.get("output") or "")
|
|
364
|
+
elif isinstance(tool_response, str):
|
|
365
|
+
text = tool_response
|
|
366
|
+
m = re.search(r"https?://\\S+", text)
|
|
367
|
+
return m.group(0).rstrip(").,") if m else None
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def build_event(payload, hook_event):
|
|
371
|
+
tool_name = payload.get("tool_name", "")
|
|
372
|
+
tool_input = payload.get("tool_input", {}) or {}
|
|
373
|
+
tool_response = payload.get("tool_response", {})
|
|
374
|
+
|
|
375
|
+
event = None
|
|
376
|
+
detail = None
|
|
377
|
+
url = None
|
|
378
|
+
|
|
379
|
+
if hook_event == "PreToolUse":
|
|
380
|
+
if tool_name == "ExitPlanMode":
|
|
381
|
+
event = "plan.created"
|
|
382
|
+
detail = first_line(tool_input.get("plan", "")) or "plan presented"
|
|
383
|
+
elif tool_name == "Task":
|
|
384
|
+
event = "subagent.spawned"
|
|
385
|
+
role = tool_input.get("subagent_type") or "agent"
|
|
386
|
+
desc = tool_input.get("description") or tool_input.get("prompt") or ""
|
|
387
|
+
detail = (role + ": " + first_line(desc)).strip(": ").strip()
|
|
388
|
+
elif hook_event == "PostToolUse":
|
|
389
|
+
if tool_name == "Bash":
|
|
390
|
+
event = classify_bash(tool_input.get("command", ""))
|
|
391
|
+
if event:
|
|
392
|
+
detail = first_line(tool_input.get("command", ""))
|
|
393
|
+
url = extract_url(tool_response)
|
|
394
|
+
elif tool_name in ("Write", "Edit", "MultiEdit"):
|
|
395
|
+
fp = tool_input.get("file_path") or tool_input.get("path") or ""
|
|
396
|
+
# A freshly-written deliverable is a milestone; edits and code
|
|
397
|
+
# writes stay routine and collapse to a count.
|
|
398
|
+
if tool_name == "Write" and is_artifact(fp):
|
|
399
|
+
event = "artifact.created"
|
|
400
|
+
else:
|
|
401
|
+
event = "file.edited"
|
|
402
|
+
detail = os.path.basename(fp) if fp else tool_name
|
|
403
|
+
|
|
404
|
+
if not event:
|
|
405
|
+
return None, tool_name
|
|
406
|
+
|
|
407
|
+
tier = "milestone" if event in MILESTONE_EVENTS else "activity"
|
|
408
|
+
record = {
|
|
409
|
+
"v": 1,
|
|
410
|
+
"ts": datetime.now(timezone.utc).isoformat(),
|
|
411
|
+
"event": event,
|
|
412
|
+
"tier": tier,
|
|
413
|
+
}
|
|
414
|
+
if detail:
|
|
415
|
+
record["detail"] = detail
|
|
416
|
+
if url:
|
|
417
|
+
record["url"] = url
|
|
418
|
+
record["tool"] = tool_name
|
|
419
|
+
return record, tool_name
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def main():
|
|
423
|
+
raw = sys.stdin.read()
|
|
424
|
+
try:
|
|
425
|
+
payload = json.loads(raw) if raw.strip() else {}
|
|
426
|
+
except Exception:
|
|
427
|
+
return
|
|
428
|
+
|
|
429
|
+
# Sub-agent gate -- only the top-level agent logs.
|
|
430
|
+
if payload.get("agent_type"):
|
|
431
|
+
return
|
|
432
|
+
|
|
433
|
+
session_id = payload.get("session_id", "")
|
|
434
|
+
if not session_id:
|
|
435
|
+
return
|
|
436
|
+
|
|
437
|
+
hook_event = payload.get("hook_event_name", "")
|
|
438
|
+
record, tool_name = build_event(payload, hook_event)
|
|
439
|
+
if not record:
|
|
440
|
+
return
|
|
441
|
+
|
|
442
|
+
safe_session = re.sub(r"[^A-Za-z0-9._-]", "-", session_id) or "unknown"
|
|
443
|
+
home = os.environ.get("HOME") or os.path.expanduser("~")
|
|
444
|
+
activity_dir = os.path.join(home, ".agents", ".history", "activity")
|
|
445
|
+
target = os.path.join(activity_dir, safe_session + ".jsonl")
|
|
446
|
+
|
|
447
|
+
# Identity (mirrors 10-feed-publish.py).
|
|
448
|
+
mailbox_id = os.path.basename(
|
|
449
|
+
os.environ.get("AGENTS_MAILBOX_DIR", "").rstrip("/")
|
|
450
|
+
) or session_id
|
|
451
|
+
hostname = os.environ.get("AGENTS_SYNC_MACHINE_ID") or socket.gethostname()
|
|
452
|
+
host = re.sub(r"[^a-z0-9_-]", "-", hostname.split(".")[0].strip().lower()) or "unknown"
|
|
453
|
+
|
|
454
|
+
record["sessionId"] = session_id
|
|
455
|
+
record["mailboxId"] = mailbox_id
|
|
456
|
+
record["host"] = host
|
|
457
|
+
record["runtime"] = os.environ.get("AGENTS_RUNTIME", "headless")
|
|
458
|
+
cwd = payload.get("cwd") or os.environ.get("AGENTS_CWD")
|
|
459
|
+
if cwd:
|
|
460
|
+
record["cwd"] = cwd
|
|
461
|
+
agent = os.environ.get("AGENTS_AGENT_NAME") or "claude"
|
|
462
|
+
record["agent"] = agent
|
|
463
|
+
|
|
464
|
+
try:
|
|
465
|
+
os.makedirs(activity_dir, exist_ok=True)
|
|
466
|
+
# Cap growth: once over the size limit, keep only milestones.
|
|
467
|
+
if record["tier"] != "milestone":
|
|
468
|
+
try:
|
|
469
|
+
if os.path.getsize(target) > MAX_LOG_BYTES:
|
|
470
|
+
return
|
|
471
|
+
except OSError:
|
|
472
|
+
pass
|
|
473
|
+
with open(target, "a") as f:
|
|
474
|
+
f.write(json.dumps(record) + "\\n")
|
|
475
|
+
except Exception:
|
|
476
|
+
pass # fail open
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
if __name__ == "__main__":
|
|
480
|
+
try:
|
|
481
|
+
main()
|
|
482
|
+
except Exception:
|
|
483
|
+
pass # fail open
|
|
484
|
+
`;
|
|
485
|
+
/** Hook manifest entries (agents.yaml shape) for the activity-log hook. */
|
|
486
|
+
export const ACTIVITY_HOOK_DEFINITIONS = {
|
|
487
|
+
'activity-log-intent': {
|
|
488
|
+
agents: ['claude'],
|
|
489
|
+
events: ['PreToolUse'],
|
|
490
|
+
matcher: 'ExitPlanMode|Task',
|
|
491
|
+
script: '11-activity-log.py',
|
|
492
|
+
timeout: 5,
|
|
493
|
+
},
|
|
494
|
+
'activity-log-result': {
|
|
495
|
+
agents: ['claude'],
|
|
496
|
+
events: ['PostToolUse'],
|
|
497
|
+
matcher: 'Bash|Write|Edit|MultiEdit',
|
|
498
|
+
script: '11-activity-log.py',
|
|
499
|
+
timeout: 5,
|
|
500
|
+
},
|
|
501
|
+
};
|
|
502
|
+
/**
|
|
503
|
+
* Install the activity-log hook script into the user hooks dir and add its
|
|
504
|
+
* manifest entries to the user agents.yaml. Mirrors {@link ensureFeedPublishHook}
|
|
505
|
+
* in feed.ts -- idempotent, and never writes the read-only system repo.
|
|
506
|
+
*/
|
|
507
|
+
export function ensureActivityLogHook(userAgentsDir = getUserAgentsDir()) {
|
|
508
|
+
try {
|
|
509
|
+
const hooksDir = path.join(userAgentsDir, 'hooks');
|
|
510
|
+
const scriptPath = path.join(hooksDir, '11-activity-log.py');
|
|
511
|
+
fs.mkdirSync(hooksDir, { recursive: true });
|
|
512
|
+
let installed = false;
|
|
513
|
+
if (!fs.existsSync(scriptPath) || fs.readFileSync(scriptPath, 'utf-8') !== ACTIVITY_LOG_HOOK_SCRIPT) {
|
|
514
|
+
const tmpScript = `${scriptPath}.${process.pid}.tmp`;
|
|
515
|
+
fs.writeFileSync(tmpScript, ACTIVITY_LOG_HOOK_SCRIPT, { mode: 0o755 });
|
|
516
|
+
fs.renameSync(tmpScript, scriptPath);
|
|
517
|
+
installed = true;
|
|
518
|
+
}
|
|
519
|
+
const agentsYamlPath = path.join(userAgentsDir, 'agents.yaml');
|
|
520
|
+
const yamlDoc = fs.existsSync(agentsYamlPath)
|
|
521
|
+
? yaml.parseDocument(fs.readFileSync(agentsYamlPath, 'utf-8'))
|
|
522
|
+
: new yaml.Document({});
|
|
523
|
+
if (yamlDoc.errors.length > 0) {
|
|
524
|
+
throw new Error(`Cannot install activity hook: ${agentsYamlPath} is invalid YAML`);
|
|
525
|
+
}
|
|
526
|
+
for (const [name, definition] of Object.entries(ACTIVITY_HOOK_DEFINITIONS)) {
|
|
527
|
+
if (!yamlDoc.getIn(['hooks', name])) {
|
|
528
|
+
yamlDoc.setIn(['hooks', name], definition);
|
|
529
|
+
installed = true;
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
if (installed) {
|
|
533
|
+
const tmpYaml = `${agentsYamlPath}.${process.pid}.tmp`;
|
|
534
|
+
fs.writeFileSync(tmpYaml, String(yamlDoc));
|
|
535
|
+
fs.renameSync(tmpYaml, agentsYamlPath);
|
|
536
|
+
}
|
|
537
|
+
return { installed };
|
|
538
|
+
}
|
|
539
|
+
catch (err) {
|
|
540
|
+
return { installed: false, error: err.message };
|
|
541
|
+
}
|
|
542
|
+
}
|